Compare commits
54 Commits
f52c504c2b
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c534a972a9 | ||
|
|
6fc2f34ba9 | ||
|
|
c1d8357956 | ||
|
|
695dafacd5 | ||
|
|
69a3088579 | ||
|
|
3d212979d9 | ||
|
|
7dd39f9712 | ||
|
|
f74322beab | ||
|
|
f5ef9e0811 | ||
|
|
06f8fa5842 | ||
|
|
7a2408e310 | ||
|
|
09a5403f83 | ||
|
|
3d73d9e422 | ||
|
|
dafc2df0d4 | ||
|
|
5e01fefd4c | ||
|
|
8020b2d14b | ||
|
|
172debdbdb | ||
|
|
ecb4777a35 | ||
|
|
cc872cfdad | ||
|
|
8d58e97ac6 | ||
|
|
b930b2bc85 | ||
|
|
5dfd795908 | ||
|
|
27af22d241 | ||
|
|
35e3881264 | ||
|
|
2ad051efbc | ||
|
|
e9cc41b282 | ||
|
|
7241896749 | ||
|
|
9ee61415cf | ||
|
|
3208804966 | ||
|
|
8827782aca | ||
|
|
84b24b1434 | ||
|
|
43327c4a70 | ||
|
|
227dabd6d4 | ||
|
|
a0649279cf | ||
|
|
db29822a5b | ||
|
|
49471e9f34 | ||
|
|
ced6c0a2d4 | ||
|
|
843378061a | ||
|
|
a9d0cead79 | ||
|
|
ee60a17f00 | ||
|
|
926543a2e4 | ||
|
|
25aa9e544c | ||
|
|
137c4a8b0b | ||
|
|
ac8a01eb3e | ||
|
|
c4fa643eca | ||
|
|
9a6bec33ff | ||
|
|
680f670037 | ||
|
|
5a0ea462e5 | ||
|
|
452dc9b9f0 | ||
|
|
9cacc19d15 | ||
|
|
15ccbe028a | ||
|
|
b69b5e7104 | ||
| 2e65855fe2 | |||
| 8681a92eec |
72
.claude/agents/backend-api.md
Normal file
72
.claude/agents/backend-api.md
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: backend-api
|
||||
description: Team agent pentru modificari backend FastAPI — routers, services, modele Pydantic, integrare Oracle/SQLite. Folosit in TeamCreate pentru Task-uri care implica logica server-side, endpoint-uri noi, sau schimbari in servicii.
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# Backend API Agent
|
||||
|
||||
Esti un teammate specializat pe backend FastAPI in proiectul GoMag Import Manager.
|
||||
|
||||
## Responsabilitati
|
||||
|
||||
- Modificari in `api/app/routers/*.py` — endpoint-uri FastAPI
|
||||
- Modificari in `api/app/services/*.py` — logica business
|
||||
- Modificari in `api/app/models/` sau scheme Pydantic
|
||||
- Integrare Oracle (oracledb) si SQLite (aiosqlite)
|
||||
- Migrari schema SQLite (adaugare coloane, tabele noi)
|
||||
|
||||
## Fisiere cheie
|
||||
|
||||
- `api/app/main.py` — entry point, middleware, router include
|
||||
- `api/app/config.py` — setari Pydantic (env vars)
|
||||
- `api/app/database.py` — Oracle pool + SQLite connections
|
||||
- `api/app/routers/dashboard.py` — comenzi dashboard
|
||||
- `api/app/routers/sync.py` — sync, history, order detail
|
||||
- `api/app/routers/mappings.py` — CRUD mapari SKU
|
||||
- `api/app/routers/articles.py` — cautare articole Oracle
|
||||
- `api/app/routers/validation.py` — validare comenzi
|
||||
- `api/app/services/sync_service.py` — orchestrator sync
|
||||
- `api/app/services/gomag_client.py` — client API GoMag
|
||||
- `api/app/services/sqlite_service.py` — tracking local SQLite
|
||||
- `api/app/services/mapping_service.py` — logica mapari
|
||||
- `api/app/services/import_service.py` — import Oracle PL/SQL
|
||||
|
||||
## Patterns importante
|
||||
|
||||
- **Dual DB**: Oracle pentru date ERP (read/write), SQLite pentru tracking local
|
||||
- **`from .. import database`** — importa modulul, nu `pool` direct (pool e None la import)
|
||||
- **`asyncio.to_thread()`** — wrapeaza apeluri Oracle blocante
|
||||
- **CLOB**: `cursor.var(oracledb.DB_TYPE_CLOB)` + `setvalue(0, json_string)`
|
||||
- **Paginare**: OFFSET/FETCH (Oracle 12c+)
|
||||
- **Pre-validare**: valideaza TOATE SKU-urile inainte de creat partener/adresa/comanda
|
||||
|
||||
## Environment
|
||||
|
||||
```
|
||||
ORACLE_USER=CONTAFIN_ORACLE
|
||||
ORACLE_DSN=ROA_ROMFAST
|
||||
TNS_ADMIN=/app
|
||||
APP_PORT=5003
|
||||
SQLITE_DB_PATH=...
|
||||
```
|
||||
|
||||
## Workflow in echipa
|
||||
|
||||
1. Citeste task-ul cu `TaskGet` sa intelegi exact ce trebuie facut
|
||||
2. Marcheaza task-ul ca `in_progress` cu `TaskUpdate`
|
||||
3. Citeste fisierele afectate inainte sa le modifici
|
||||
4. Implementeaza modificarile
|
||||
5. Ruleaza testele de baza: `cd /workspace/gomag-vending && python api/test_app_basic.py`
|
||||
6. Marcheaza task-ul ca `completed` cu `TaskUpdate`
|
||||
7. Trimite mesaj la `team-lead` cu:
|
||||
- Endpoint-uri create/modificate (metoda HTTP + path)
|
||||
- Schimbari in schema SQLite (daca exista)
|
||||
- Contracte API noi pe care frontend-ul trebuie sa le stie
|
||||
|
||||
## Principii
|
||||
|
||||
- Nu modifica fisiere HTML/CSS/JS (sunt ale agentilor UI)
|
||||
- Pastreaza backward compatibility la endpoint-uri existente
|
||||
- Adauga campuri noi in raspunsuri JSON fara sa le stergi pe cele vechi
|
||||
- Logheaza erorile Oracle cu detalii suficiente pentru debug
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: oracle-dba
|
||||
description: Oracle PL/SQL specialist for database scripts, packages, and schema changes in the ROA ERP system
|
||||
model: opus
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# Oracle DBA Agent
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: python-backend
|
||||
description: FastAPI backend developer for services, routes, Oracle/SQLite integration, and API logic
|
||||
model: opus
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# Python Backend Agent
|
||||
|
||||
50
.claude/agents/ui-js.md
Normal file
50
.claude/agents/ui-js.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: ui-js
|
||||
description: Team agent pentru modificari JavaScript (dashboard.js, logs.js, mappings.js, shared.js). Folosit in TeamCreate pentru Task-uri care implica logica client-side, API calls, si interactivitate UI.
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# UI JavaScript Agent
|
||||
|
||||
Esti un teammate specializat pe JavaScript client-side in proiectul GoMag Import Manager.
|
||||
|
||||
## Responsabilitati
|
||||
|
||||
- Modificari in `api/app/static/js/*.js`
|
||||
- Fetch API calls catre backend (`/api/...`)
|
||||
- Rendering dinamic HTML (tabele, liste, modals)
|
||||
- Paginare, sortare, filtrare client-side
|
||||
- Mobile vs desktop rendering logic
|
||||
|
||||
## Fisiere cheie
|
||||
|
||||
- `api/app/static/js/shared.js` - utilitare comune (fmtDate, statusDot, renderUnifiedPagination, renderMobileSegmented, esc)
|
||||
- `api/app/static/js/dashboard.js` - logica dashboard comenzi
|
||||
- `api/app/static/js/logs.js` - logica jurnale import
|
||||
- `api/app/static/js/mappings.js` - CRUD mapari SKU
|
||||
|
||||
## Functii utilitare disponibile (din shared.js)
|
||||
|
||||
- `fmtDate(dateStr)` - formateaza data
|
||||
- `statusDot(status)` - dot colorat pentru status
|
||||
- `orderStatusBadge(status)` - badge Bootstrap pentru status
|
||||
- `renderUnifiedPagination(page, totalPages, goPageFn, opts)` - paginare
|
||||
- `renderMobileSegmented(containerId, items, onSelect)` - segmented control mobil
|
||||
- `esc(s)` / `escHtml(s)` - escape HTML
|
||||
|
||||
## Workflow in echipa
|
||||
|
||||
1. Citeste task-ul cu `TaskGet` sa intelegi exact ce trebuie facut
|
||||
2. Marcheaza task-ul ca `in_progress` cu `TaskUpdate`
|
||||
3. Citeste fisierele afectate inainte sa le modifici
|
||||
4. Implementeaza modificarile
|
||||
5. Marcheaza task-ul ca `completed` cu `TaskUpdate`
|
||||
6. Trimite mesaj la `team-lead` cu summary-ul modificarilor
|
||||
|
||||
## Principii
|
||||
|
||||
- Nu modifica fisiere HTML/CSS (sunt ale ui-templates agent)
|
||||
- `Math.round(x)` → `Number(x).toFixed(2)` pentru valori monetare
|
||||
- Verifica intotdeauna null/undefined inainte de operatii numerice: `x != null ? Number(x).toFixed(2) : '-'`
|
||||
- Reset elementele din modal la inceputul fiecarei deschideri (loading state)
|
||||
- Foloseste `esc()` pe orice valoare inserata in HTML
|
||||
42
.claude/agents/ui-templates.md
Normal file
42
.claude/agents/ui-templates.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: ui-templates
|
||||
description: Team agent pentru modificari HTML templates (dashboard.html, logs.html, mappings.html, base.html) si CSS (style.css). Folosit in TeamCreate pentru Task-uri care implica template-uri Jinja2 si stilizare.
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# UI Templates Agent
|
||||
|
||||
Esti un teammate specializat pe templates HTML si CSS in proiectul GoMag Import Manager.
|
||||
|
||||
## Responsabilitati
|
||||
|
||||
- Modificari in `api/app/templates/*.html` (Jinja2)
|
||||
- Modificari in `api/app/static/css/style.css`
|
||||
- Cache-bust: incrementeaza `?v=N` pe toate tag-urile `<script>` si `<link>` la fiecare modificare
|
||||
- Structura modala Bootstrap 5.3
|
||||
- Responsive: `d-none d-md-block` pentru desktop-only, `d-md-none` pentru mobile-only
|
||||
|
||||
## Fisiere cheie
|
||||
|
||||
- `api/app/templates/base.html` - layout de baza cu navigatie
|
||||
- `api/app/templates/dashboard.html` - dashboard comenzi
|
||||
- `api/app/templates/logs.html` - jurnale import
|
||||
- `api/app/templates/mappings.html` - CRUD mapari SKU
|
||||
- `api/app/templates/missing_skus.html` - SKU-uri lipsa
|
||||
- `api/app/static/css/style.css` - stiluri aplicatie
|
||||
|
||||
## Workflow in echipa
|
||||
|
||||
1. Citeste task-ul cu `TaskGet` sa intelegi exact ce trebuie facut
|
||||
2. Marcheaza task-ul ca `in_progress` cu `TaskUpdate`
|
||||
3. Citeste fisierele afectate inainte sa le modifici
|
||||
4. Implementeaza modificarile
|
||||
5. Marcheaza task-ul ca `completed` cu `TaskUpdate`
|
||||
6. Trimite mesaj la `team-lead` cu summary-ul modificarilor
|
||||
|
||||
## Principii
|
||||
|
||||
- Nu modifica fisiere JS (sunt ale ui-js agent)
|
||||
- Desktop layout-ul nu se schimba cand se adauga imbunatatiri mobile
|
||||
- Foloseste clasele Bootstrap existente, nu adauga CSS custom decat daca e necesar
|
||||
- Pastreaza consistenta cu designul existent
|
||||
61
.claude/agents/ui-verify.md
Normal file
61
.claude/agents/ui-verify.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: ui-verify
|
||||
description: Team agent de verificare Playwright pentru UI. Captureaza screenshots after-implementation, compara cu preview-urile aprobate, si raporteaza discrepante la team lead. Folosit intotdeauna dupa implementare.
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# UI Verify Agent
|
||||
|
||||
Esti un teammate specializat pe verificare vizuala Playwright in proiectul GoMag Import Manager.
|
||||
|
||||
## Responsabilitati
|
||||
|
||||
- Capturare screenshots post-implementare → `screenshots/after/`
|
||||
- Comparare vizuala `after/` vs `preview/`
|
||||
- Verificare ca desktop-ul ramane neschimbat unde nu s-a modificat intentionat
|
||||
- Raportare discrepante la team lead cu descriere exacta
|
||||
|
||||
## Server
|
||||
|
||||
App ruleaza la `http://localhost:5003`. Verifica cu `curl -s http://localhost:5003/health` inainte de screenshots.
|
||||
|
||||
**IMPORTANT**: NU restarteaza serverul singur. Serverul trebuie pornit de user via `./start.sh` care seteaza variabilele de mediu Oracle (`LD_LIBRARY_PATH`, `TNS_ADMIN`). Daca serverul nu raspunde sau Oracle e `"error"`, raporteaza la team-lead si asteapta ca userul sa-l reporneasca.
|
||||
|
||||
## Viewports
|
||||
|
||||
- **Mobile:** 375x812 — `browser_resize width=375 height=812`
|
||||
- **Desktop:** 1440x900 — `browser_resize width=1440 height=900`
|
||||
|
||||
## Pagini de verificat
|
||||
|
||||
- `http://localhost:5003/` — Dashboard
|
||||
- `http://localhost:5003/logs?run=<run_id>` — Logs cu run selectat
|
||||
- `http://localhost:5003/mappings` — Mapari SKU
|
||||
- `http://localhost:5003/missing-skus` — SKU-uri lipsa
|
||||
|
||||
## Workflow in echipa
|
||||
|
||||
1. Citeste task-ul cu `TaskGet` pentru lista exacta de pagini si criterii de verificat
|
||||
2. Marcheaza task-ul ca `in_progress` cu `TaskUpdate`
|
||||
3. Restarteza serverul daca e necesar
|
||||
4. Captureaza screenshots la ambele viewports pentru fiecare pagina
|
||||
5. Verifica vizual fiecare screenshot vs criteriile din task
|
||||
6. Marcheaza task-ul ca `completed` cu `TaskUpdate`
|
||||
7. Trimite raport detaliat la `team-lead`:
|
||||
- ✅ Ce e corect
|
||||
- ❌ Ce e gresit / lipseste (cu descriere exacta)
|
||||
- Sugestii de fix daca e cazul
|
||||
|
||||
## Naming convention screenshots
|
||||
|
||||
```
|
||||
screenshots/after/dashboard_desktop.png
|
||||
screenshots/after/dashboard_mobile.png
|
||||
screenshots/after/dashboard_modal_desktop.png
|
||||
screenshots/after/dashboard_modal_mobile.png
|
||||
screenshots/after/logs_desktop.png
|
||||
screenshots/after/logs_mobile.png
|
||||
screenshots/after/logs_modal_desktop.png
|
||||
screenshots/after/logs_modal_mobile.png
|
||||
screenshots/after/mappings_desktop.png
|
||||
```
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -8,6 +8,8 @@
|
||||
*.err
|
||||
*.ERR
|
||||
*.log
|
||||
/screenshots
|
||||
/.playwright-mcp
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
@@ -22,10 +24,12 @@ __pycache__/
|
||||
# Settings files with secrets
|
||||
settings.ini
|
||||
vfp/settings.ini
|
||||
vfp/output/
|
||||
.gittoken
|
||||
output/
|
||||
vfp/*.json
|
||||
*.~pck
|
||||
.claude/HANDOFF.md
|
||||
scripts/work/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
@@ -42,3 +46,4 @@ api/api/
|
||||
|
||||
# Logs directory
|
||||
logs/
|
||||
.gstack/
|
||||
|
||||
286
CLAUDE.md
286
CLAUDE.md
@@ -1,270 +1,60 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**System:** Import Comenzi Web → Sistem ROA Oracle
|
||||
**System:** Import Comenzi Web GoMag → Sistem ROA Oracle
|
||||
Stack: FastAPI + Jinja2 + Bootstrap 5.3 + Oracle PL/SQL + SQLite
|
||||
|
||||
This is a multi-tier system that automatically imports orders from web platforms (GoMag, etc.) into the ROA Oracle ERP system. The project combines Oracle PL/SQL packages, Visual FoxPro orchestration, and a FastAPI web admin/dashboard interface.
|
||||
Documentatie completa: [README.md](README.md)
|
||||
|
||||
**Current Status:** Phase 4 Complete, Phase 5 In Progress
|
||||
- ✅ Phase 1: Database Foundation (ARTICOLE_TERTI, IMPORT_PARTENERI, IMPORT_COMENZI)
|
||||
- ✅ Phase 2: VFP Integration (gomag-vending.prg, sync-comenzi-web.prg)
|
||||
- ✅ Phase 3-4: FastAPI Admin + Dashboard (mappings CRUD, sync orchestration, pre-validation)
|
||||
- 🔄 Phase 5: Production (file logging done, auth + notifications pending)
|
||||
## Implementare cu TeamCreate
|
||||
|
||||
## Architecture
|
||||
**OBLIGATORIU:** Folosim TeamCreate + TaskCreate, NU Agent tool cu subagenti paraleli. Skill-ul `superpowers:dispatching-parallel-agents` NU se aplica in acest proiect.
|
||||
|
||||
```
|
||||
[Web Platform API] → [VFP Orchestrator] → [Oracle PL/SQL] → [Web Admin Interface]
|
||||
↓ ↓ ↑ ↑
|
||||
JSON Orders Process & Log Store/Update Configuration
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
- **Backend:** Oracle PL/SQL packages
|
||||
- **Integration:** Visual FoxPro 9
|
||||
- **Admin/Dashboard:** FastAPI + Jinja2 + Oracle pool + SQLite
|
||||
- **Data:** Oracle 11g/12c (ROA system), SQLite (local tracking)
|
||||
|
||||
## Core Components
|
||||
|
||||
### Oracle PL/SQL Packages
|
||||
|
||||
#### 1. IMPORT_PARTENERI Package
|
||||
**Location:** `api/database-scripts/02_import_parteneri.sql`
|
||||
**Functions:**
|
||||
- `cauta_sau_creeaza_partener()` - Search/create partners with priority: cod_fiscal → denumire → create new
|
||||
- `parseaza_adresa_semicolon()` - Parse addresses in format "JUD:București;BUCURESTI;Str.Victoriei;10"
|
||||
|
||||
**Logic:**
|
||||
- Individual vs company detection (CUI 13 digits)
|
||||
- Automatic address defaults to București Sectorul 1
|
||||
- All new partners get ID_UTIL = -3 (system)
|
||||
|
||||
#### 2. IMPORT_COMENZI Package
|
||||
**Location:** `api/database-scripts/03_import_comenzi.sql`
|
||||
**Functions:**
|
||||
- `gaseste_articol_roa()` - Complex SKU mapping with pipelined functions
|
||||
- `importa_comanda_web()` - Complete order import with JSON parsing
|
||||
|
||||
**Mapping Types:**
|
||||
- Simple: SKU found directly in nom_articole (not stored in ARTICOLE_TERTI)
|
||||
- Repackaging: SKU → CODMAT with different quantities
|
||||
- Complex sets: One SKU → multiple CODMATs with percentage pricing
|
||||
|
||||
### Visual FoxPro Integration
|
||||
|
||||
#### gomag-vending.prg
|
||||
**Location:** `vfp/gomag-vending.prg`
|
||||
Current functionality:
|
||||
- GoMag API integration with pagination
|
||||
- JSON data retrieval and processing
|
||||
- HTML entity cleaning (ă→a, ș→s, ț→t, î→i, â→a)
|
||||
|
||||
**Future:** Will be adapted for JSON output to Oracle packages
|
||||
|
||||
#### sync-comenzi-web.prg (Phase 2)
|
||||
**Planned orchestrator with:**
|
||||
- 5-minute timer automation
|
||||
- Oracle package integration
|
||||
- Comprehensive logging system
|
||||
- Error handling and retry logic
|
||||
|
||||
### Database Schema
|
||||
|
||||
#### ARTICOLE_TERTI Table
|
||||
**Location:** `api/database-scripts/01_create_table.sql`
|
||||
```sql
|
||||
CREATE TABLE ARTICOLE_TERTI (
|
||||
sku VARCHAR2(100), -- SKU from web platform
|
||||
codmat VARCHAR2(50), -- CODMAT from nom_articole
|
||||
cantitate_roa NUMBER(10,3), -- ROA units per web unit
|
||||
procent_pret NUMBER(5,2), -- Price percentage for sets
|
||||
activ NUMBER(1), -- 1=active, 0=inactive
|
||||
PRIMARY KEY (sku, codmat)
|
||||
);
|
||||
```
|
||||
|
||||
### FastAPI Admin/Dashboard
|
||||
|
||||
#### app/main.py
|
||||
**Location:** `api/app/main.py`
|
||||
**Features:**
|
||||
- FastAPI with lifespan (Oracle pool + SQLite init)
|
||||
- File logging to `logs/sync_comenzi_YYYYMMDD_HHMMSS.log`
|
||||
- Routers: health, dashboard, mappings, articles, validation, sync
|
||||
- Services: mapping, article, import, sync, validation, order_reader, sqlite, scheduler
|
||||
- Templates: Jinja2 (dashboard, mappings, sync_detail, missing_skus)
|
||||
- Dual database: Oracle (ERP data) + SQLite (tracking)
|
||||
- APScheduler for periodic sync
|
||||
- Team lead citeste TOATE fisierele implicate, creeaza planul
|
||||
- **ASTEAPTA aprobare explicita** de la user inainte de implementare
|
||||
- Task-uri pe fisiere non-overlapping (evita conflicte)
|
||||
- Cache-bust static assets (`?v=N`) la fiecare schimbare UI
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Database Setup
|
||||
```bash
|
||||
# Start Oracle container
|
||||
docker-compose up -d
|
||||
# INTOTDEAUNA via start.sh (seteaza Oracle env vars)
|
||||
./start.sh
|
||||
# NU folosi uvicorn direct — lipsesc LD_LIBRARY_PATH si TNS_ADMIN
|
||||
|
||||
# Run database scripts in order
|
||||
sqlplus CONTAFIN_ORACLE/password@ROA_ROMFAST @01_create_table.sql
|
||||
sqlplus CONTAFIN_ORACLE/password@ROA_ROMFAST @02_import_parteneri.sql
|
||||
sqlplus CONTAFIN_ORACLE/password@ROA_ROMFAST @03_import_comenzi.sql
|
||||
# Tests
|
||||
python api/test_app_basic.py # fara Oracle
|
||||
python api/test_integration.py # cu Oracle
|
||||
```
|
||||
|
||||
### VFP Development
|
||||
```foxpro
|
||||
DO vfp/gomag-vending.prg
|
||||
```
|
||||
## Reguli critice (nu le incalca)
|
||||
|
||||
### FastAPI Admin/Dashboard
|
||||
```bash
|
||||
cd api
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 5003 --reload
|
||||
```
|
||||
### Flux import comenzi
|
||||
1. Download GoMag API → JSON → parse → validate SKU-uri → import Oracle
|
||||
2. Ordinea: **parteneri** (cauta/creeaza) → **adrese** → **comanda** → **factura cache**
|
||||
3. SKU lookup: ARTICOLE_TERTI (mapped) are prioritate fata de NOM_ARTICOLE (direct)
|
||||
4. Complex sets: un SKU → multiple CODMAT-uri cu `procent_pret` (trebuie sa fie sum=100%)
|
||||
5. Comenzi anulate (GoMag statusId=7): verifica daca au factura inainte de stergere din Oracle
|
||||
|
||||
### Testare
|
||||
```bash
|
||||
python api/test_app_basic.py # Test A - fara Oracle
|
||||
python api/test_integration.py # Test C - cu Oracle
|
||||
```
|
||||
### Statusuri comenzi
|
||||
`IMPORTED` / `ALREADY_IMPORTED` / `SKIPPED` / `ERROR` / `CANCELLED` / `DELETED_IN_ROA`
|
||||
- Upsert: `IMPORTED` existent NU se suprascrie cu `ALREADY_IMPORTED`
|
||||
- Recovery: la fiecare sync, comenzile ERROR sunt reverificate in Oracle
|
||||
|
||||
## Project Structure
|
||||
### Parteneri
|
||||
- Prioritate: **companie** (PJ, cod_fiscal + registru) daca exista in GoMag, altfel persoana fizica cu **shipping name**
|
||||
- Adresa livrare: intotdeauna GoMag shipping
|
||||
- Adresa facturare: daca shipping ≠ billing person → shipping pt ambele; altfel → billing din GoMag
|
||||
|
||||
```
|
||||
/
|
||||
├── api/ # ✅ Flask Admin & Database
|
||||
│ ├── admin.py # ✅ Flask app with Oracle pool
|
||||
│ ├── database-scripts/ # ✅ Oracle SQL scripts
|
||||
│ │ ├── 01_create_table.sql # ✅ ARTICOLE_TERTI table
|
||||
│ │ ├── 02_import_parteneri.sql # ✅ Partners package
|
||||
│ │ └── 03_import_comenzi.sql # ✅ Orders package
|
||||
│ ├── Dockerfile # ✅ Oracle client container
|
||||
│ ├── tnsnames.ora # ✅ Oracle connection config
|
||||
│ ├── .env # ✅ Environment variables
|
||||
│ └── requirements.txt # ✅ Python dependencies
|
||||
├── docs/ # 📋 Project Documentation
|
||||
│ ├── PRD.md # ✅ Product Requirements
|
||||
│ ├── LLM_PROJECT_MANAGER_PROMPT.md # ✅ Project Management
|
||||
│ └── stories/ # 📋 User Stories
|
||||
│ ├── P1-001-ARTICOLE_TERTI.md # ✅ Story P1-001 (COMPLETE)
|
||||
│ ├── P1-002-Package-IMPORT_PARTENERI.md # ✅ Story P1-002 (COMPLETE)
|
||||
│ ├── P1-003-Package-IMPORT_COMENZI.md # ✅ Story P1-003 (COMPLETE)
|
||||
│ └── P1-004-Testing-Manual-Packages.md # 📋 Story P1-004 (READY)
|
||||
├── vfp/ # ⏳ VFP Integration
|
||||
│ ├── gomag-vending.prg # ✅ Current GoMag client
|
||||
│ ├── utils.prg # ✅ Utility functions
|
||||
│ ├── nfjson/ # ✅ JSON parsing library
|
||||
│ └── sync-comenzi-web.prg # ⏳ Future orchestrator
|
||||
├── docker-compose.yaml # ✅ Container setup
|
||||
└── logs/ # ✅ Application logs
|
||||
```
|
||||
### Preturi
|
||||
- Dual policy: articolele sunt rutate la `id_pol_vanzare` sau `id_pol_productie` pe baza contului contabil (341/345 = productie)
|
||||
- Daca pretul lipseste, se insereaza automat pret=0
|
||||
|
||||
## Configuration
|
||||
### Invoice cache
|
||||
- Coloanele `factura_*` pe `orders` (SQLite), populate lazy din Oracle (`vanzari WHERE sters=0`)
|
||||
- Refresh complet: verifica facturi noi + facturi sterse + comenzi sterse din ROA
|
||||
|
||||
### Environment Variables (.env)
|
||||
```env
|
||||
ORACLE_USER=CONTAFIN_ORACLE
|
||||
ORACLE_PASSWORD=********
|
||||
ORACLE_DSN=ROA_ROMFAST
|
||||
TNS_ADMIN=/app
|
||||
INSTANTCLIENTPATH=/opt/oracle/instantclient
|
||||
```
|
||||
## Deploy Windows
|
||||
|
||||
### Business Rules
|
||||
|
||||
#### Partners
|
||||
- Search priority: cod_fiscal → denumire → create new
|
||||
- Individuals (CUI 13 digits): separate nume/prenume
|
||||
- Default address: București Sectorul 1
|
||||
- All new partners: ID_UTIL = -3
|
||||
|
||||
#### Articles
|
||||
- Simple SKUs: found directly in nom_articole (not stored)
|
||||
- Special mappings: only repackaging and complex sets
|
||||
- Inactive articles: activ=0 (not deleted)
|
||||
|
||||
#### Orders
|
||||
- Uses existing PACK_COMENZI packages
|
||||
- Default: ID_GESTIUNE=1, ID_SECTIE=1, ID_POL=0
|
||||
- Delivery date = order date + 1 day
|
||||
- All orders: INTERNA=0 (external)
|
||||
|
||||
## Phase Implementation Status
|
||||
|
||||
### ✅ Phase 1: Database Foundation (75% Complete)
|
||||
- **P1-001:** ✅ ARTICOLE_TERTI table + Docker setup
|
||||
- **P1-002:** ✅ IMPORT_PARTENERI package complete
|
||||
- **P1-003:** ✅ IMPORT_COMENZI package complete
|
||||
- **P1-004:** 🔄 Manual testing (READY TO START)
|
||||
|
||||
### ⏳ Phase 2: VFP Integration (Planned)
|
||||
- Adapt gomag-vending.prg for JSON output
|
||||
- Create sync-comenzi-web.prg orchestrator
|
||||
- Oracle packages integration
|
||||
- Logging system with rotation
|
||||
|
||||
### ⏳ Phase 3: Web Admin Interface (Planned)
|
||||
- Flask app with Oracle connection pool
|
||||
- HTML/CSS admin interface
|
||||
- JavaScript CRUD operations
|
||||
- Client/server-side validation
|
||||
|
||||
### ⏳ Phase 4: Testing & Deployment (Planned)
|
||||
- End-to-end testing with real orders
|
||||
- Complex mappings validation
|
||||
- Production environment setup
|
||||
- User documentation
|
||||
|
||||
## Key Functions
|
||||
|
||||
### Oracle Packages
|
||||
- `IMPORT_PARTENERI.cauta_sau_creeaza_partener()` - Partner management
|
||||
- `IMPORT_PARTENERI.parseaza_adresa_semicolon()` - Address parsing
|
||||
- `IMPORT_COMENZI.gaseste_articol_roa()` - SKU resolution
|
||||
- `IMPORT_COMENZI.importa_comanda_web()` - Order import
|
||||
|
||||
### VFP Utilities (utils.prg)
|
||||
- `LoadSettings` - INI configuration management
|
||||
- `InitLog`/`LogMessage`/`CloseLog` - Logging system
|
||||
- `TestConnectivity` - Connection verification
|
||||
- `CreateDefaultIni` - Default configuration
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Technical KPIs
|
||||
- Import success rate > 95%
|
||||
- Average processing time < 30s per order
|
||||
- Zero downtime for main ROA system
|
||||
- 100% log coverage
|
||||
|
||||
### Business KPIs
|
||||
- 90% reduction in manual order entry time
|
||||
- Elimination of manual transcription errors
|
||||
- New mapping configuration < 5 minutes
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Categories
|
||||
1. **Oracle connection errors:** Retry logic + alerts
|
||||
2. **SKU not found:** Log warning + skip item
|
||||
3. **Invalid partner:** Create attempt + detailed log
|
||||
4. **Duplicate orders:** Skip with info log
|
||||
|
||||
### Logging Format
|
||||
```
|
||||
2025-09-09 14:30:25 | ORDER-123 | OK | ID:456789
|
||||
2025-09-09 14:30:26 | ORDER-124 | ERROR | SKU 'XYZ' not found
|
||||
```
|
||||
|
||||
## Project Manager Commands
|
||||
|
||||
Available commands for project tracking:
|
||||
- `status` - Overall progress and current story
|
||||
- `stories` - List all stories with status
|
||||
- `phase` - Current phase details
|
||||
- `risks` - Identify and prioritize risks
|
||||
- `demo [story-id]` - Demonstrate implemented functionality
|
||||
- `plan` - Re-planning for changes
|
||||
Vezi [README.md](README.md#deploy-windows)
|
||||
|
||||
286
README.md
286
README.md
@@ -5,16 +5,16 @@ System automat de import comenzi din platforma GoMag in sistemul ERP ROA Oracle.
|
||||
## Arhitectura
|
||||
|
||||
```
|
||||
[GoMag API] → [VFP Orchestrator] → [Oracle PL/SQL] → [FastAPI Admin]
|
||||
[GoMag API] → [Python Sync Service] → [Oracle PL/SQL] → [FastAPI Admin]
|
||||
↓ ↓ ↑ ↑
|
||||
JSON Orders Process & Log Store/Update Dashboard + Config
|
||||
JSON Orders Download/Parse/Import Store/Update Dashboard + Config
|
||||
```
|
||||
|
||||
### Stack Tehnologic
|
||||
- **Database:** Oracle PL/SQL packages (PACK_IMPORT_PARTENERI, PACK_IMPORT_COMENZI)
|
||||
- **Integrare:** Visual FoxPro 9 (gomag-vending.prg, sync-comenzi-web.prg)
|
||||
- **Admin/Dashboard:** FastAPI + Jinja2 + Oracle pool + SQLite
|
||||
- **Date:** Oracle 11g/12c (schema ROA), SQLite (tracking local)
|
||||
- **API + Admin:** FastAPI + Jinja2 + Bootstrap 5.3
|
||||
- **GoMag Integration:** Python (`gomag_client.py` — download comenzi cu paginare)
|
||||
- **Sync Orchestrator:** Python (`sync_service.py` — download → parse → validate → import)
|
||||
- **Database:** Oracle PL/SQL packages (IMPORT_PARTENERI, IMPORT_COMENZI) + SQLite (tracking)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,7 +27,6 @@ System automat de import comenzi din platforma GoMag in sistemul ERP ROA Oracle.
|
||||
### Instalare
|
||||
|
||||
```bash
|
||||
# Din project root (gomag/)
|
||||
pip install -r api/requirements.txt
|
||||
cp api/.env.example api/.env
|
||||
# Editeaza api/.env cu datele de conectare Oracle
|
||||
@@ -38,7 +37,6 @@ cp api/.env.example api/.env
|
||||
**Important:** serverul trebuie pornit **din project root**, nu din `api/`:
|
||||
|
||||
```bash
|
||||
# Din gomag/
|
||||
python -m uvicorn api.app.main:app --host 0.0.0.0 --port 5003
|
||||
```
|
||||
|
||||
@@ -55,13 +53,11 @@ Deschide `http://localhost:5003` in browser.
|
||||
```bash
|
||||
python api/test_app_basic.py
|
||||
```
|
||||
Verifica importuri de module + rute GET. Asteptat: 32/33 PASS (1 fail pre-existent `/sync` HTML).
|
||||
|
||||
**Test C - Integrare Oracle:**
|
||||
```bash
|
||||
python api/test_integration.py
|
||||
```
|
||||
Necesita Oracle activ. Verifica health, mappings CRUD, article search, validation, sync.
|
||||
|
||||
---
|
||||
|
||||
@@ -82,7 +78,7 @@ cp api/.env.example api/.env
|
||||
| `INSTANTCLIENTPATH` | Cale Instant Client (thick mode) | `/opt/oracle/instantclient_21_15` |
|
||||
| `FORCE_THIN_MODE` | Thin mode fara Instant Client | `true` |
|
||||
| `SQLITE_DB_PATH` | Path SQLite (relativ la project root) | `api/data/import.db` |
|
||||
| `JSON_OUTPUT_DIR` | Folder JSON-uri VFP (relativ la project root) | `vfp/output` |
|
||||
| `JSON_OUTPUT_DIR` | Folder JSON-uri descarcate | `api/data/orders` |
|
||||
| `APP_PORT` | Port HTTP | `5003` |
|
||||
| `ID_POL` | ID Politica ROA | `39` |
|
||||
| `ID_GESTIUNE` | ID Gestiune ROA | `0` |
|
||||
@@ -97,7 +93,7 @@ cp api/.env.example api/.env
|
||||
## Structura Proiect
|
||||
|
||||
```
|
||||
gomag/
|
||||
gomag-vending/
|
||||
├── api/ # FastAPI Admin + Dashboard
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # Entry point, lifespan, logging
|
||||
@@ -105,36 +101,34 @@ gomag/
|
||||
│ │ ├── database.py # Oracle pool + SQLite schema + migrari
|
||||
│ │ ├── routers/ # Endpoint-uri HTTP
|
||||
│ │ │ ├── health.py # GET /health
|
||||
│ │ │ ├── dashboard.py # GET / (HTML)
|
||||
│ │ │ ├── dashboard.py # GET / (HTML) + /settings (HTML)
|
||||
│ │ │ ├── mappings.py # /mappings, /api/mappings
|
||||
│ │ │ ├── articles.py # /api/articles/search
|
||||
│ │ │ ├── validation.py # /api/validate/*
|
||||
│ │ │ └── sync.py # /api/sync/* + /api/dashboard/orders
|
||||
│ │ │ └── sync.py # /api/sync/* + /api/dashboard/* + /api/settings
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── sync_service.py # Orchestrare: JSON→validate→import
|
||||
│ │ │ ├── gomag_client.py # Download comenzi GoMag API
|
||||
│ │ │ ├── sync_service.py # Orchestrare: download→validate→import
|
||||
│ │ │ ├── import_service.py # Import comanda in Oracle ROA
|
||||
│ │ │ ├── mapping_service.py # CRUD ARTICOLE_TERTI + pct_total
|
||||
│ │ │ ├── sqlite_service.py # Tracking runs/orders/missing SKUs
|
||||
│ │ │ ├── order_reader.py # Citire gomag_orders_page*.json
|
||||
│ │ │ ├── validation_service.py
|
||||
│ │ │ ├── article_service.py
|
||||
│ │ │ ├── invoice_service.py # Verificare facturi ROA
|
||||
│ │ │ └── scheduler_service.py # APScheduler timer
|
||||
│ │ ├── templates/ # Jinja2 HTML
|
||||
│ │ └── static/ # CSS + JS
|
||||
│ │ ├── templates/ # Jinja2 (dashboard, mappings, missing_skus, logs, settings)
|
||||
│ │ └── static/ # CSS (style.css) + JS (dashboard, logs, mappings, settings, shared)
|
||||
│ ├── database-scripts/ # Oracle SQL (ARTICOLE_TERTI, packages)
|
||||
│ ├── data/ # SQLite DB (import.db)
|
||||
│ ├── data/ # SQLite DB (import.db) + JSON orders
|
||||
│ ├── .env # Configurare locala (nu in git)
|
||||
│ ├── .env.example # Template configurare
|
||||
│ ├── test_app_basic.py # Test A - fara Oracle
|
||||
│ ├── test_integration.py # Test C - cu Oracle
|
||||
│ └── requirements.txt
|
||||
├── vfp/ # VFP Integration
|
||||
│ ├── gomag-vending.prg # Client GoMag API (descarca JSON-uri)
|
||||
│ ├── sync-comenzi-web.prg # Orchestrator VFP
|
||||
│ ├── utils.prg # Utilitare (log, settings, connectivity)
|
||||
│ └── output/ # JSON-uri descarcate (gomag_orders_page*.json)
|
||||
├── logs/ # Log-uri aplicatie (sync_comenzi_*.log)
|
||||
├── docs/ # Documentatie (PRD, stories)
|
||||
├── screenshots/ # Before/preview/after pentru UI changes
|
||||
├── start.sh # Script pornire (Linux/WSL)
|
||||
└── CLAUDE.md # Instructiuni pentru AI assistants
|
||||
```
|
||||
@@ -171,36 +165,244 @@ gomag/
|
||||
## Fluxul de Import
|
||||
|
||||
```
|
||||
1. VFP descarca comenzi GoMag API → vfp/output/gomag_orders_page*.json
|
||||
2. FastAPI citeste JSON-urile (order_reader)
|
||||
3. Valideaza SKU-uri contra ARTICOLE_TERTI + NOM_ARTICOLE (validation_service)
|
||||
4. Import_service creeaza/cauta partener in Oracle (shipping person = facturare)
|
||||
5. PACK_IMPORT_COMENZI.importa_comanda_web() insereaza comanda in ROA
|
||||
6. Rezultate salvate in SQLite (orders, sync_run_orders, order_items)
|
||||
1. gomag_client.py descarca comenzi GoMag API → JSON files (paginat)
|
||||
2. order_reader.py parseaza JSON-urile, sorteaza cronologic (cele mai vechi primele)
|
||||
3. Comenzi anulate (GoMag statusId=7) → separate, sterse din Oracle daca nu au factura
|
||||
4. validation_service.py valideaza SKU-uri: ARTICOLE_TERTI (mapped) → NOM_ARTICOLE (direct) → missing
|
||||
5. Verificare existenta in Oracle (COMENZI by date range) → deja importate se sar
|
||||
6. Stale error recovery: comenzi ERROR reverificate in Oracle (crash recovery)
|
||||
7. Validare preturi + dual policy: articole rutate la id_pol_vanzare sau id_pol_productie
|
||||
8. import_service.py: cauta/creeaza partener → adrese → importa comanda in Oracle
|
||||
9. Invoice cache: verifica facturi + comenzi sterse din ROA
|
||||
10. Rezultate salvate in SQLite (orders, sync_run_orders, order_items)
|
||||
```
|
||||
|
||||
### Statuses Comenzi
|
||||
|
||||
| Status | Descriere |
|
||||
|--------|-----------|
|
||||
| `IMPORTED` | Importata nou in ROA in acest run |
|
||||
| `ALREADY_IMPORTED` | Existenta deja in Oracle, contorizata |
|
||||
| `SKIPPED` | SKU-uri lipsa → neimportata |
|
||||
| `ERROR` | Eroare la import (reverificate automat la urmatorul sync) |
|
||||
| `CANCELLED` | Comanda anulata in GoMag (statusId=7) |
|
||||
| `DELETED_IN_ROA` | A fost importata dar comanda a fost stearsa din ROA |
|
||||
|
||||
**Regula upsert:** daca statusul existent este `IMPORTED`, nu se suprascrie cu `ALREADY_IMPORTED`.
|
||||
|
||||
### Reguli Business
|
||||
- **Persoana**: shipping name = persoana pe eticheta = beneficiarul facturii
|
||||
- **Adresa**: cand billing ≠ shipping → adresa shipping pentru ambele (facturare + livrare)
|
||||
- **SKU simplu**: gasit direct in NOM_ARTICOLE → nu se stocheaza in ARTICOLE_TERTI
|
||||
- **SKU cu repackaging**: un SKU → CODMAT cu cantitate diferita
|
||||
- **SKU set complex**: un SKU → multiple CODMAT-uri cu procente de pret
|
||||
|
||||
**Parteneri & Adrese:**
|
||||
- Prioritate partener: daca exista **companie** in GoMag (billing.company_name) → firma (PJ, cod_fiscal + registru). Altfel → persoana fizica, cu **shipping name** ca nume partener
|
||||
- Adresa livrare: intotdeauna din GoMag shipping
|
||||
- Adresa facturare: daca shipping name ≠ billing name → adresa shipping pt ambele; daca aceeasi persoana → adresa billing din GoMag
|
||||
- Cautare partener in Oracle: cod_fiscal → denumire → create new (ID_UTIL = -3)
|
||||
|
||||
**Articole & Mapari:**
|
||||
- SKU lookup: ARTICOLE_TERTI (mapped, activ=1) are prioritate fata de NOM_ARTICOLE (direct)
|
||||
- SKU simplu: gasit direct in NOM_ARTICOLE → nu se stocheaza in ARTICOLE_TERTI
|
||||
- SKU cu repackaging: un SKU → CODMAT cu cantitate diferita (`cantitate_roa`)
|
||||
- SKU set complex: un SKU → multiple CODMAT-uri cu `procent_pret` (trebuie sum = 100%)
|
||||
|
||||
**Preturi & Discounturi:**
|
||||
- Dual policy: articolele sunt rutate la `id_pol_vanzare` sau `id_pol_productie` pe baza contului contabil (341/345 = productie)
|
||||
- Daca pretul lipseste in politica, se insereaza automat pret=0
|
||||
- Discount VAT splitting: daca `split_discount_vat=1`, discountul se repartizeaza proportional pe cotele TVA din comanda
|
||||
|
||||
---
|
||||
|
||||
## Status Implementare
|
||||
## Facturi & Cache
|
||||
|
||||
| Faza | Status | Descriere |
|
||||
|------|--------|-----------|
|
||||
| Phase 1: Database Foundation | ✅ Complet | ARTICOLE_TERTI, PACK_IMPORT_PARTENERI, PACK_IMPORT_COMENZI |
|
||||
| Phase 2: VFP Integration | ✅ Complet | gomag-vending.prg, sync-comenzi-web.prg |
|
||||
| Phase 3-4: FastAPI Dashboard | ✅ Complet | Redesign UI, smart polling, filter bar, paginare, tooltip |
|
||||
| Phase 5: Production | 🔄 In Progress | Logging ✅, Auth ⏳, SMTP ⏳, NSSM service ⏳ |
|
||||
Facturile sunt verificate live din Oracle si cacate in SQLite (`factura_*` pe tabelul `orders`).
|
||||
|
||||
### Sursa Oracle
|
||||
```sql
|
||||
SELECT id_comanda, numar_act, serie_act,
|
||||
total_fara_tva, total_tva, total_cu_tva,
|
||||
TO_CHAR(data_act, 'YYYY-MM-DD')
|
||||
FROM vanzari
|
||||
WHERE id_comanda IN (...) AND sters = 0
|
||||
```
|
||||
|
||||
### Populare Cache
|
||||
1. **Dashboard** (`GET /api/dashboard/orders`) — comenzile fara cache sunt verificate live si cacate automat la fiecare request
|
||||
2. **Detaliu comanda** (`GET /api/sync/order/{order_number}`) — verifica Oracle live daca nu e caat
|
||||
3. **Refresh manual** (`POST /api/dashboard/refresh-invoices`) — refresh complet pentru toate comenzile
|
||||
|
||||
### Refresh Complet — `/api/dashboard/refresh-invoices`
|
||||
|
||||
Face trei verificari in Oracle si actualizeaza SQLite:
|
||||
|
||||
| Verificare | Actiune |
|
||||
|------------|---------|
|
||||
| Comenzi necacturate → au primit factura? | Cacheaza datele facturii |
|
||||
| Comenzi cacturate → factura a fost stearsa? | Sterge cache factura |
|
||||
| Toate comenzile importate → comanda stearsa din ROA? | Seteaza status `DELETED_IN_ROA` |
|
||||
|
||||
Returneaza: `{ checked, invoices_added, invoices_cleared, orders_deleted }`
|
||||
|
||||
---
|
||||
|
||||
## API Reference — Sync & Comenzi
|
||||
|
||||
### Sync
|
||||
| Method | Path | Descriere |
|
||||
|--------|------|-----------|
|
||||
| POST | `/api/sync/start` | Porneste sync in background |
|
||||
| POST | `/api/sync/stop` | Trimite semnal de stop |
|
||||
| GET | `/api/sync/status` | Status curent + progres + last_run |
|
||||
| GET | `/api/sync/history` | Istoric run-uri (paginat) |
|
||||
| GET | `/api/sync/run/{id}` | Detalii run specific |
|
||||
| GET | `/api/sync/run/{id}/log` | Log per comanda (JSON) |
|
||||
| GET | `/api/sync/run/{id}/text-log` | Log text (live din memorie sau reconstruit din SQLite) |
|
||||
| GET | `/api/sync/run/{id}/orders` | Comenzi run filtrate/paginate |
|
||||
| GET | `/api/sync/order/{number}` | Detaliu comanda + items + ARTICOLE_TERTI + factura |
|
||||
|
||||
### Dashboard Comenzi
|
||||
| Method | Path | Descriere |
|
||||
|--------|------|-----------|
|
||||
| GET | `/api/dashboard/orders` | Comenzi cu enrichment factura |
|
||||
| POST | `/api/dashboard/refresh-invoices` | Force-refresh stare facturi + deleted orders |
|
||||
|
||||
**Parametri `/api/dashboard/orders`:**
|
||||
- `period_days`: 3/7/30/90 sau 0 (toate sau interval custom)
|
||||
- `period_start`, `period_end`: interval custom (cand `period_days=0`)
|
||||
- `status`: `all` / `IMPORTED` / `SKIPPED` / `ERROR` / `UNINVOICED` / `INVOICED`
|
||||
- `search`, `sort_by`, `sort_dir`, `page`, `per_page`
|
||||
|
||||
Filtrele `UNINVOICED` si `INVOICED` fac fetch din toate comenzile IMPORTED si filtreaza server-side dupa prezenta/absenta cache-ului de factura.
|
||||
|
||||
### Scheduler
|
||||
| Method | Path | Descriere |
|
||||
|--------|------|-----------|
|
||||
| PUT | `/api/sync/schedule` | Configureaza (enabled, interval_minutes: 5/10/30) |
|
||||
| GET | `/api/sync/schedule` | Status curent |
|
||||
|
||||
Configuratia este persistata in SQLite (`scheduler_config`).
|
||||
|
||||
### Settings
|
||||
| Method | Path | Descriere |
|
||||
|--------|------|-----------|
|
||||
| GET | `/api/settings` | Citeste setari aplicatie |
|
||||
| PUT | `/api/settings` | Salveaza setari |
|
||||
| GET | `/api/settings/sectii` | Lista sectii Oracle |
|
||||
| GET | `/api/settings/politici` | Lista politici preturi Oracle |
|
||||
|
||||
**Setari disponibile:** `transport_codmat`, `transport_vat`, `discount_codmat`, `discount_vat`, `transport_id_pol`, `discount_id_pol`, `id_pol`, `id_pol_productie`, `id_sectie`, `split_discount_vat`, `gomag_api_key`, `gomag_api_shop`, `gomag_order_days_back`, `gomag_limit`
|
||||
|
||||
---
|
||||
|
||||
## Deploy Windows
|
||||
|
||||
### Instalare initiala
|
||||
|
||||
```powershell
|
||||
# Ruleaza ca Administrator
|
||||
.\deploy.ps1
|
||||
```
|
||||
|
||||
Scriptul `deploy.ps1` face automat: git clone, venv, dependinte, detectare Oracle, `start.bat`, serviciu NSSM, configurare IIS reverse proxy.
|
||||
|
||||
### Update cod (pull + restart)
|
||||
|
||||
```powershell
|
||||
# Ca Administrator
|
||||
.\update.ps1
|
||||
```
|
||||
|
||||
Sau manual:
|
||||
```powershell
|
||||
cd C:\gomag-vending
|
||||
git pull origin main
|
||||
nssm restart GoMagVending
|
||||
```
|
||||
|
||||
### Configurare `.env` pe Windows
|
||||
|
||||
```ini
|
||||
# api/.env — exemplu Windows
|
||||
ORACLE_USER=VENDING
|
||||
ORACLE_PASSWORD=****
|
||||
ORACLE_DSN=ROA
|
||||
TNS_ADMIN=C:\roa\instantclient_11_2_0_2
|
||||
INSTANTCLIENTPATH=C:\app\Server\product\18.0.0\dbhomeXE\bin
|
||||
SQLITE_DB_PATH=api/data/import.db
|
||||
JSON_OUTPUT_DIR=api/data/orders
|
||||
APP_PORT=5003
|
||||
ID_POL=39
|
||||
ID_GESTIUNE=0
|
||||
ID_SECTIE=6
|
||||
GOMAG_API_KEY=...
|
||||
GOMAG_API_SHOP=...
|
||||
GOMAG_ORDER_DAYS_BACK=7
|
||||
GOMAG_LIMIT=100
|
||||
```
|
||||
|
||||
**Important:**
|
||||
- `TNS_ADMIN` = folderul care contine `tnsnames.ora` (NU fisierul in sine)
|
||||
- `ORACLE_DSN` = alias-ul exact din `tnsnames.ora`
|
||||
- `INSTANTCLIENTPATH` = calea catre Oracle bin (thick mode, Oracle 10g/11g)
|
||||
- `FORCE_THIN_MODE=true` = elimina necesitatea Instant Client (Oracle 12.1+)
|
||||
- Setarile din `.env` pot fi suprascrise din UI → `Setari` → salvate in SQLite
|
||||
|
||||
### Serviciu Windows (NSSM)
|
||||
|
||||
```powershell
|
||||
nssm restart GoMagVending # restart serviciu
|
||||
nssm status GoMagVending # status serviciu
|
||||
nssm stop GoMagVending # stop serviciu
|
||||
nssm start GoMagVending # start serviciu
|
||||
```
|
||||
|
||||
Loguri serviciu: `logs/service_stdout.log`, `logs/service_stderr.log`
|
||||
Loguri aplicatie: `logs/sync_comenzi_*.log`
|
||||
|
||||
**Nota:** Userul `gomag` nu are drepturi de admin — `nssm restart` necesita PowerShell Administrator direct pe server.
|
||||
|
||||
### Depanare SSH
|
||||
|
||||
```bash
|
||||
# Conectare SSH (PowerShell remote, cheie publica)
|
||||
ssh -p 22122 gomag@79.119.86.134
|
||||
|
||||
# Verificare .env
|
||||
cmd /c type C:\gomag-vending\api\.env
|
||||
|
||||
# Test conexiune Oracle
|
||||
C:\gomag-vending\venv\Scripts\python.exe -c "import oracledb, os; os.environ['TNS_ADMIN']='C:/roa/instantclient_11_2_0_2'; conn=oracledb.connect(user='VENDING', password='ROMFASTSOFT', dsn='ROA'); print('Connected!'); conn.close()"
|
||||
|
||||
# Verificare tnsnames.ora
|
||||
cmd /c type C:\roa\instantclient_11_2_0_2\tnsnames.ora
|
||||
|
||||
# Verificare procese Python
|
||||
Get-Process *python* | Select-Object Id,ProcessName,Path
|
||||
|
||||
# Verificare loguri recente
|
||||
Get-ChildItem C:\gomag-vending\logs\*.log | Sort-Object LastWriteTime -Descending | Select-Object -First 3
|
||||
|
||||
# Test sync manual (verifica ca Oracle pool porneste)
|
||||
curl http://localhost:5003/health
|
||||
curl -X POST http://localhost:5003/api/sync/start
|
||||
|
||||
# Refresh facturi manual
|
||||
curl -X POST http://localhost:5003/api/dashboard/refresh-invoices
|
||||
```
|
||||
|
||||
### Probleme frecvente
|
||||
|
||||
| Eroare | Cauza | Solutie |
|
||||
|--------|-------|---------|
|
||||
| `ORA-12154: TNS:could not resolve` | `TNS_ADMIN` gresit sau `tnsnames.ora` nu contine alias-ul DSN | Verifica `TNS_ADMIN` in `.env` + alias in `tnsnames.ora` |
|
||||
| `ORA-04088: LOGON_AUDIT_TRIGGER` + `Nu aveti licenta pentru PYTHON` | Trigger ROA blocheaza executabile nelicențiate | Adauga `python.exe` (calea completa) in ROASUPORT |
|
||||
| `503 Service Unavailable` pe `/api/articles/search` | Oracle pool nu s-a initializat | Verifica logul `sync_comenzi_*.log` pentru eroarea exacta |
|
||||
| Facturile nu apar in dashboard | Cache SQLite gol — invoice_service nu a putut interoga Oracle | Apasa butonul Refresh Facturi din dashboard sau `POST /api/dashboard/refresh-invoices` |
|
||||
| Comanda apare ca `DELETED_IN_ROA` | Comanda a fost stearsa manual din ROA | Normal — marcat automat la refresh |
|
||||
| Scheduler nu porneste dupa restart | Config pierduta | Verifica SQLite `scheduler_config` sau reconfigureaza din UI |
|
||||
|
||||
---
|
||||
|
||||
## WSL2 Note
|
||||
|
||||
- `uvicorn --reload` **nu functioneaza** pe `/mnt/e/` (WSL2 limitation) — restarta manual
|
||||
- Serverul trebuie pornit din **project root** (`gomag/`), nu din `api/`
|
||||
- Serverul trebuie pornit din **project root**, nu din `api/`
|
||||
- `JSON_OUTPUT_DIR` si `SQLITE_DB_PATH` sunt relative la project root
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
SPECIFICATIE PROIECT - IMPORT COMENZI WEB IN ROA ORACLE
|
||||
Data: 5 martie 2026
|
||||
|
||||
================================================================================
|
||||
DESCRIERE SCOP
|
||||
================================================================================
|
||||
|
||||
Implementarea unui sistem automat de import a comenzilor de pe platforme web
|
||||
(GoMag si altele) in sistemul ERP ROA Oracle. Sistemul va prelua comenzi,
|
||||
va realiza mapari de articole, va converte unitati de masura si va crea
|
||||
comenzi in ROA automat.
|
||||
|
||||
|
||||
================================================================================
|
||||
DELIVERABLES
|
||||
================================================================================
|
||||
|
||||
1. Logica de import completa in baza de date ROA Oracle
|
||||
2. Orchestrator automat (cron job) pentru sincronizare comenzi
|
||||
3. Interfata web de configurare mapari SKU-uri
|
||||
4. Suport pentru articole compuse (mapari complexe)
|
||||
5. Conversii unitati de masura intre platforme
|
||||
6. Documentatie tehnica si handover
|
||||
7. Support 3 luni pentru bug fixes
|
||||
|
||||
|
||||
================================================================================
|
||||
EFORTURI SI COSTURI
|
||||
================================================================================
|
||||
|
||||
Lucrat deja: 20h 1,200 EUR
|
||||
De lucrat: 60h 3,600 EUR
|
||||
Support 3 luni: 24h 1,440 EUR
|
||||
|
||||
TOTAL IMPLEMENTARE: 80h 4,800 EUR
|
||||
TOTAL CU SUPPORT: 104h 6,240 EUR
|
||||
|
||||
Tarif orar: 60 EUR/h
|
||||
|
||||
|
||||
================================================================================
|
||||
INCLUS IN PRET
|
||||
================================================================================
|
||||
|
||||
- Analiza si integrare cu baza de date client
|
||||
- Testare completa cu date reale
|
||||
- Integrare in sistemul ROA Oracle
|
||||
- Validari si controale de integritate
|
||||
- Documentation si training
|
||||
- Support de 3 luni pentru probleme critice
|
||||
|
||||
|
||||
================================================================================
|
||||
CONDITII GENERALE
|
||||
================================================================================
|
||||
|
||||
Duratie proiect: 2-4 saptamani
|
||||
Payment terms: 50% avans, 50% la finalizare
|
||||
Garantie: 3 luni (bug fixes gratuit)
|
||||
Suport suplimentar: 60 EUR/h (dupa perioada garantie)
|
||||
|
||||
Buffer estimare: 50% (pentru integrare ROA + incertitudini)
|
||||
|
||||
|
||||
================================================================================
|
||||
RESPONSABILITATI CLIENT
|
||||
================================================================================
|
||||
|
||||
- Acces la baza de date client si ROA Oracle
|
||||
- Accesul la comenzile din platforma web
|
||||
- Clarificarea logicii maparii articole compuse
|
||||
- Testing si validare in mediu pilot
|
||||
|
||||
|
||||
================================================================================
|
||||
@@ -35,14 +35,16 @@ APP_PORT=5003
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# =============================================================================
|
||||
# CALE FISIERE (relative la project root - directorul gomag/)
|
||||
# CALE FISIERE
|
||||
# Relative: JSON_OUTPUT_DIR la project root, SQLITE_DB_PATH la api/
|
||||
# Se pot folosi si cai absolute
|
||||
# =============================================================================
|
||||
|
||||
# JSON-uri descarcate de VFP
|
||||
JSON_OUTPUT_DIR=vfp/output
|
||||
# JSON-uri comenzi GoMag
|
||||
JSON_OUTPUT_DIR=output
|
||||
|
||||
# SQLite tracking DB
|
||||
SQLITE_DB_PATH=api/data/import.db
|
||||
SQLITE_DB_PATH=data/import.db
|
||||
|
||||
# =============================================================================
|
||||
# ROA - Setari import comenzi (din vfp/settings.ini sectiunea [ROA])
|
||||
@@ -57,6 +59,15 @@ ID_GESTIUNE=0
|
||||
# Sectie implicita
|
||||
ID_SECTIE=6
|
||||
|
||||
# =============================================================================
|
||||
# GoMag API
|
||||
# =============================================================================
|
||||
|
||||
GOMAG_API_KEY=your_api_key_here
|
||||
GOMAG_API_SHOP=https://yourstore.gomag.ro
|
||||
GOMAG_ORDER_DAYS_BACK=7
|
||||
GOMAG_LIMIT=100
|
||||
|
||||
# =============================================================================
|
||||
# SMTP - Notificari email (optional)
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import model_validator
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
# Resolve .env relative to this file (api/app/config.py → api/.env)
|
||||
_env_path = Path(__file__).resolve().parent.parent / ".env"
|
||||
# Anchored paths - independent of CWD
|
||||
_api_root = Path(__file__).resolve().parent.parent # .../gomag/api/
|
||||
_project_root = _api_root.parent # .../gomag/
|
||||
_env_path = _api_root / ".env"
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Oracle
|
||||
@@ -15,12 +18,12 @@ class Settings(BaseSettings):
|
||||
TNS_ADMIN: str = ""
|
||||
|
||||
# SQLite
|
||||
SQLITE_DB_PATH: str = str(Path(__file__).parent.parent / "data" / "import.db")
|
||||
SQLITE_DB_PATH: str = "data/import.db"
|
||||
|
||||
# App
|
||||
APP_PORT: int = 5003
|
||||
LOG_LEVEL: str = "INFO"
|
||||
JSON_OUTPUT_DIR: str = ""
|
||||
JSON_OUTPUT_DIR: str = "output"
|
||||
|
||||
# SMTP (optional)
|
||||
SMTP_HOST: str = ""
|
||||
@@ -35,9 +38,26 @@ class Settings(BaseSettings):
|
||||
|
||||
# ROA Import Settings
|
||||
ID_POL: int = 0
|
||||
ID_GESTIUNE: int = 0
|
||||
ID_SECTIE: int = 0
|
||||
|
||||
# GoMag API
|
||||
GOMAG_API_KEY: str = ""
|
||||
GOMAG_API_SHOP: str = ""
|
||||
GOMAG_ORDER_DAYS_BACK: int = 7
|
||||
GOMAG_LIMIT: int = 100
|
||||
GOMAG_API_URL: str = "https://api.gomag.ro/api/v1/order/read/json"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def resolve_paths(self):
|
||||
"""Resolve relative paths against known roots, independent of CWD."""
|
||||
# SQLITE_DB_PATH: relative to api/ root
|
||||
if self.SQLITE_DB_PATH and not os.path.isabs(self.SQLITE_DB_PATH):
|
||||
self.SQLITE_DB_PATH = str(_api_root / self.SQLITE_DB_PATH)
|
||||
# JSON_OUTPUT_DIR: relative to project root
|
||||
if self.JSON_OUTPUT_DIR and not os.path.isabs(self.JSON_OUTPUT_DIR):
|
||||
self.JSON_OUTPUT_DIR = str(_project_root / self.JSON_OUTPUT_DIR)
|
||||
return self
|
||||
|
||||
model_config = {"env_file": str(_env_path), "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -23,6 +23,8 @@ def init_oracle():
|
||||
if settings.TNS_ADMIN:
|
||||
os.environ['TNS_ADMIN'] = settings.TNS_ADMIN
|
||||
|
||||
logger.info(f"Oracle config: DSN={dsn}, TNS_ADMIN={settings.TNS_ADMIN or os.environ.get('TNS_ADMIN', '(not set)')}, INSTANTCLIENTPATH={instantclient_path or '(not set)'}")
|
||||
|
||||
if force_thin:
|
||||
logger.info(f"FORCE_THIN_MODE=true: thin mode for {dsn}")
|
||||
elif instantclient_path:
|
||||
@@ -73,7 +75,9 @@ CREATE TABLE IF NOT EXISTS sync_runs (
|
||||
skipped INTEGER DEFAULT 0,
|
||||
errors INTEGER DEFAULT 0,
|
||||
json_files INTEGER DEFAULT 0,
|
||||
error_message TEXT
|
||||
error_message TEXT,
|
||||
already_imported INTEGER DEFAULT 0,
|
||||
new_imported INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
@@ -95,7 +99,19 @@ CREATE TABLE IF NOT EXISTS orders (
|
||||
shipping_name TEXT,
|
||||
billing_name TEXT,
|
||||
payment_method TEXT,
|
||||
delivery_method TEXT
|
||||
delivery_method TEXT,
|
||||
factura_serie TEXT,
|
||||
factura_numar TEXT,
|
||||
factura_total_fara_tva REAL,
|
||||
factura_total_tva REAL,
|
||||
factura_total_cu_tva REAL,
|
||||
factura_data TEXT,
|
||||
invoice_checked_at TEXT,
|
||||
order_total REAL,
|
||||
delivery_cost REAL,
|
||||
discount_total REAL,
|
||||
web_status TEXT,
|
||||
discount_split TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date);
|
||||
@@ -131,6 +147,11 @@ CREATE TABLE IF NOT EXISTS web_products (
|
||||
order_count INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
order_number TEXT,
|
||||
sku TEXT,
|
||||
@@ -266,14 +287,20 @@ def init_sqlite():
|
||||
if col not in cols:
|
||||
conn.execute(f"ALTER TABLE missing_skus ADD COLUMN {col} {typedef}")
|
||||
logger.info(f"Migrated missing_skus: added column {col}")
|
||||
# Migrate sync_runs: add error_message column
|
||||
# Migrate sync_runs: add columns
|
||||
cursor = conn.execute("PRAGMA table_info(sync_runs)")
|
||||
sync_cols = {row[1] for row in cursor.fetchall()}
|
||||
if "error_message" not in sync_cols:
|
||||
conn.execute("ALTER TABLE sync_runs ADD COLUMN error_message TEXT")
|
||||
logger.info("Migrated sync_runs: added column error_message")
|
||||
if "already_imported" not in sync_cols:
|
||||
conn.execute("ALTER TABLE sync_runs ADD COLUMN already_imported INTEGER DEFAULT 0")
|
||||
logger.info("Migrated sync_runs: added column already_imported")
|
||||
if "new_imported" not in sync_cols:
|
||||
conn.execute("ALTER TABLE sync_runs ADD COLUMN new_imported INTEGER DEFAULT 0")
|
||||
logger.info("Migrated sync_runs: added column new_imported")
|
||||
|
||||
# Migrate orders: add shipping/billing/payment/delivery columns
|
||||
# Migrate orders: add shipping/billing/payment/delivery + invoice columns
|
||||
cursor = conn.execute("PRAGMA table_info(orders)")
|
||||
order_cols = {row[1] for row in cursor.fetchall()}
|
||||
for col, typedef in [
|
||||
@@ -281,6 +308,18 @@ def init_sqlite():
|
||||
("billing_name", "TEXT"),
|
||||
("payment_method", "TEXT"),
|
||||
("delivery_method", "TEXT"),
|
||||
("factura_serie", "TEXT"),
|
||||
("factura_numar", "TEXT"),
|
||||
("factura_total_fara_tva", "REAL"),
|
||||
("factura_total_tva", "REAL"),
|
||||
("factura_total_cu_tva", "REAL"),
|
||||
("factura_data", "TEXT"),
|
||||
("invoice_checked_at", "TEXT"),
|
||||
("order_total", "REAL"),
|
||||
("delivery_cost", "REAL"),
|
||||
("discount_total", "REAL"),
|
||||
("web_status", "TEXT"),
|
||||
("discount_split", "TEXT"),
|
||||
]:
|
||||
if col not in order_cols:
|
||||
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
|
||||
|
||||
@@ -15,3 +15,7 @@ async def dashboard(request: Request):
|
||||
@router.get("/missing-skus", response_class=HTMLResponse)
|
||||
async def missing_skus_page(request: Request):
|
||||
return templates.TemplateResponse("missing_skus.html", {"request": request})
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
return templates.TemplateResponse("settings.html", {"request": request})
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Query, Request, UploadFile, File
|
||||
from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, validator
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import io
|
||||
@@ -21,6 +21,12 @@ class MappingCreate(BaseModel):
|
||||
cantitate_roa: float = 1
|
||||
procent_pret: float = 100
|
||||
|
||||
@validator('sku', 'codmat')
|
||||
def not_empty(cls, v):
|
||||
if not v or not v.strip():
|
||||
raise ValueError('nu poate fi gol')
|
||||
return v.strip()
|
||||
|
||||
class MappingUpdate(BaseModel):
|
||||
cantitate_roa: Optional[float] = None
|
||||
procent_pret: Optional[float] = None
|
||||
@@ -32,6 +38,12 @@ class MappingEdit(BaseModel):
|
||||
cantitate_roa: float = 1
|
||||
procent_pret: float = 100
|
||||
|
||||
@validator('new_sku', 'new_codmat')
|
||||
def not_empty(cls, v):
|
||||
if not v or not v.strip():
|
||||
raise ValueError('nu poate fi gol')
|
||||
return v.strip()
|
||||
|
||||
class MappingLine(BaseModel):
|
||||
codmat: str
|
||||
cantitate_roa: float = 1
|
||||
@@ -40,6 +52,7 @@ class MappingLine(BaseModel):
|
||||
class MappingBatchCreate(BaseModel):
|
||||
sku: str
|
||||
mappings: list[MappingLine]
|
||||
auto_restore: bool = False
|
||||
|
||||
# HTML page
|
||||
@router.get("/mappings", response_class=HTMLResponse)
|
||||
@@ -129,7 +142,7 @@ async def create_batch_mapping(data: MappingBatchCreate):
|
||||
try:
|
||||
results = []
|
||||
for m in data.mappings:
|
||||
r = mapping_service.create_mapping(data.sku, m.codmat, m.cantitate_roa, m.procent_pret)
|
||||
r = mapping_service.create_mapping(data.sku, m.codmat, m.cantitate_roa, m.procent_pret, auto_restore=data.auto_restore)
|
||||
results.append(r)
|
||||
# Mark SKU as resolved in missing_skus tracking
|
||||
await sqlite_service.resolve_missing_sku(data.sku)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Request, BackgroundTasks
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
@@ -10,6 +13,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ..services import sync_service, scheduler_service, sqlite_service, invoice_service
|
||||
from .. import database
|
||||
|
||||
router = APIRouter(tags=["sync"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
@@ -20,6 +24,25 @@ class ScheduleConfig(BaseModel):
|
||||
interval_minutes: int = 5
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
transport_codmat: str = ""
|
||||
transport_vat: str = "21"
|
||||
discount_codmat: str = ""
|
||||
transport_id_pol: str = ""
|
||||
discount_vat: str = "21"
|
||||
discount_id_pol: str = ""
|
||||
id_pol: str = ""
|
||||
id_pol_productie: str = ""
|
||||
id_sectie: str = ""
|
||||
id_gestiune: str = ""
|
||||
split_discount_vat: str = ""
|
||||
gomag_api_key: str = ""
|
||||
gomag_api_shop: str = ""
|
||||
gomag_order_days_back: str = "7"
|
||||
gomag_limit: str = "100"
|
||||
dashboard_poll_seconds: str = "5"
|
||||
|
||||
|
||||
# API endpoints
|
||||
@router.post("/api/sync/start")
|
||||
async def start_sync(background_tasks: BackgroundTasks):
|
||||
@@ -47,12 +70,14 @@ async def sync_status():
|
||||
|
||||
# Build last_run from most recent completed/failed sync_runs row
|
||||
current_run_id = status.get("run_id")
|
||||
is_running = status.get("status") == "running"
|
||||
last_run = None
|
||||
try:
|
||||
from ..database import get_sqlite
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
if current_run_id:
|
||||
if current_run_id and is_running:
|
||||
# Only exclude current run while it's actively running
|
||||
cursor = await db.execute("""
|
||||
SELECT * FROM sync_runs
|
||||
WHERE status IN ('completed', 'failed') AND run_id != ?
|
||||
@@ -84,6 +109,8 @@ async def sync_status():
|
||||
"imported": row_dict.get("imported", 0),
|
||||
"skipped": row_dict.get("skipped", 0),
|
||||
"errors": row_dict.get("errors", 0),
|
||||
"already_imported": row_dict.get("already_imported", 0),
|
||||
"new_imported": row_dict.get("new_imported", 0),
|
||||
}
|
||||
finally:
|
||||
await db.close()
|
||||
@@ -147,6 +174,9 @@ async def sync_run_log(run_id: str):
|
||||
"id_partener": o.get("id_partener"),
|
||||
"error_message": o.get("error_message"),
|
||||
"missing_skus": o.get("missing_skus"),
|
||||
"order_total": o.get("order_total"),
|
||||
"factura_numar": o.get("factura_numar"),
|
||||
"factura_serie": o.get("factura_serie"),
|
||||
}
|
||||
for o in orders
|
||||
]
|
||||
@@ -179,6 +209,9 @@ def _format_text_log_from_detail(detail: dict) -> str:
|
||||
if status == "IMPORTED":
|
||||
id_cmd = o.get("id_comanda", "?")
|
||||
lines.append(f"#{number} [{order_date}] {customer} → IMPORTAT (ID: {id_cmd})")
|
||||
elif status == "ALREADY_IMPORTED":
|
||||
id_cmd = o.get("id_comanda", "?")
|
||||
lines.append(f"#{number} [{order_date}] {customer} → DEJA IMPORTAT (ID: {id_cmd})")
|
||||
elif status == "SKIPPED":
|
||||
missing = o.get("missing_skus", "")
|
||||
if isinstance(missing, str):
|
||||
@@ -210,6 +243,11 @@ def _format_text_log_from_detail(detail: dict) -> str:
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
already = run.get("already_imported", 0)
|
||||
new_imp = run.get("new_imported", 0)
|
||||
if already:
|
||||
lines.append(f"Finalizat: {new_imp} importate, {already} deja importate, {skipped} nemapate, {errors} erori din {total} comenzi{duration_str}")
|
||||
else:
|
||||
lines.append(f"Finalizat: {imported} importate, {skipped} nemapate, {errors} erori din {total} comenzi{duration_str}")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -240,7 +278,7 @@ async def sync_run_text_log(run_id: str):
|
||||
|
||||
@router.get("/api/sync/run/{run_id}/orders")
|
||||
async def sync_run_orders(run_id: str, status: str = "all", page: int = 1, per_page: int = 50,
|
||||
sort_by: str = "created_at", sort_dir: str = "asc"):
|
||||
sort_by: str = "order_date", sort_dir: str = "desc"):
|
||||
"""Get filtered, paginated orders for a sync run (R1)."""
|
||||
return await sqlite_service.get_run_orders_filtered(run_id, status, page, per_page,
|
||||
sort_by=sort_by, sort_dir=sort_dir)
|
||||
@@ -281,6 +319,29 @@ def _get_articole_terti_for_skus(skus: set) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def _get_nom_articole_for_direct_skus(skus: set) -> dict:
|
||||
"""Query NOM_ARTICOLE for SKUs that exist directly as CODMAT (direct mapping)."""
|
||||
from .. import database
|
||||
result = {}
|
||||
sku_list = list(skus)
|
||||
conn = database.get_oracle_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for i in range(0, len(sku_list), 500):
|
||||
batch = sku_list[i:i+500]
|
||||
placeholders = ",".join([f":s{j}" for j in range(len(batch))])
|
||||
params = {f"s{j}": sku for j, sku in enumerate(batch)}
|
||||
cur.execute(f"""
|
||||
SELECT codmat, denumire FROM NOM_ARTICOLE
|
||||
WHERE codmat IN ({placeholders}) AND sters = 0 AND inactiv = 0
|
||||
""", params)
|
||||
for row in cur:
|
||||
result[row[0]] = row[1] or ""
|
||||
finally:
|
||||
database.pool.release(conn)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/sync/order/{order_number}")
|
||||
async def order_detail(order_number: str):
|
||||
"""Get order detail with line items (R9), enriched with ARTICOLE_TERTI data."""
|
||||
@@ -298,6 +359,63 @@ async def order_detail(order_number: str):
|
||||
if sku and sku in codmat_map:
|
||||
item["codmat_details"] = codmat_map[sku]
|
||||
|
||||
# Enrich direct SKUs (SKU=CODMAT in NOM_ARTICOLE, no ARTICOLE_TERTI entry)
|
||||
direct_skus = {item["sku"] for item in items
|
||||
if item.get("sku") and item.get("mapping_status") == "direct"
|
||||
and not item.get("codmat_details")}
|
||||
if direct_skus:
|
||||
nom_map = await asyncio.to_thread(_get_nom_articole_for_direct_skus, direct_skus)
|
||||
for item in items:
|
||||
sku = item.get("sku")
|
||||
if sku and sku in nom_map and not item.get("codmat_details"):
|
||||
item["codmat_details"] = [{
|
||||
"codmat": sku,
|
||||
"cantitate_roa": 1,
|
||||
"procent_pret": 100,
|
||||
"denumire": nom_map[sku],
|
||||
"direct": True
|
||||
}]
|
||||
|
||||
# Enrich with invoice data
|
||||
order = detail.get("order", {})
|
||||
if order.get("factura_numar") and order.get("factura_data"):
|
||||
order["invoice"] = {
|
||||
"facturat": True,
|
||||
"serie_act": order.get("factura_serie"),
|
||||
"numar_act": order.get("factura_numar"),
|
||||
"data_act": order.get("factura_data"),
|
||||
"total_fara_tva": order.get("factura_total_fara_tva"),
|
||||
"total_tva": order.get("factura_total_tva"),
|
||||
"total_cu_tva": order.get("factura_total_cu_tva"),
|
||||
}
|
||||
elif order.get("id_comanda"):
|
||||
# Check Oracle live
|
||||
try:
|
||||
inv_data = await asyncio.to_thread(
|
||||
invoice_service.check_invoices_for_orders, [order["id_comanda"]]
|
||||
)
|
||||
inv = inv_data.get(order["id_comanda"])
|
||||
if inv and inv.get("facturat"):
|
||||
order["invoice"] = inv
|
||||
await sqlite_service.update_order_invoice(
|
||||
order_number,
|
||||
serie=inv.get("serie_act"),
|
||||
numar=str(inv.get("numar_act", "")),
|
||||
total_fara_tva=inv.get("total_fara_tva"),
|
||||
total_tva=inv.get("total_tva"),
|
||||
total_cu_tva=inv.get("total_cu_tva"),
|
||||
data_act=inv.get("data_act"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Parse discount_split JSON string
|
||||
if order.get("discount_split"):
|
||||
try:
|
||||
order["discount_split"] = json.loads(order["discount_split"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return detail
|
||||
|
||||
|
||||
@@ -313,11 +431,12 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||
period_days=0 without dates means all time.
|
||||
"""
|
||||
is_uninvoiced_filter = (status == "UNINVOICED")
|
||||
is_invoiced_filter = (status == "INVOICED")
|
||||
|
||||
# For UNINVOICED: fetch all IMPORTED orders, then filter post-invoice-check
|
||||
fetch_status = "IMPORTED" if is_uninvoiced_filter else status
|
||||
fetch_per_page = 10000 if is_uninvoiced_filter else per_page
|
||||
fetch_page = 1 if is_uninvoiced_filter else page
|
||||
# For UNINVOICED/INVOICED: fetch all IMPORTED orders, then filter post-invoice-check
|
||||
fetch_status = "IMPORTED" if (is_uninvoiced_filter or is_invoiced_filter) else status
|
||||
fetch_per_page = 10000 if (is_uninvoiced_filter or is_invoiced_filter) else per_page
|
||||
fetch_page = 1 if (is_uninvoiced_filter or is_invoiced_filter) else page
|
||||
|
||||
result = await sqlite_service.get_orders(
|
||||
page=fetch_page, per_page=fetch_per_page, search=search,
|
||||
@@ -327,22 +446,49 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||
period_end=period_end if period_days == 0 else "",
|
||||
)
|
||||
|
||||
# Enrich imported orders with invoice data from Oracle
|
||||
# Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle
|
||||
all_orders = result["orders"]
|
||||
imported_orders = [o for o in all_orders if o.get("id_comanda")]
|
||||
invoice_data = {}
|
||||
if imported_orders:
|
||||
id_comanda_list = [o["id_comanda"] for o in imported_orders]
|
||||
for o in all_orders:
|
||||
if o.get("factura_numar") and o.get("factura_data"):
|
||||
# Use cached invoice data from SQLite (only if complete)
|
||||
o["invoice"] = {
|
||||
"facturat": True,
|
||||
"serie_act": o.get("factura_serie"),
|
||||
"numar_act": o.get("factura_numar"),
|
||||
"total_fara_tva": o.get("factura_total_fara_tva"),
|
||||
"total_tva": o.get("factura_total_tva"),
|
||||
"total_cu_tva": o.get("factura_total_cu_tva"),
|
||||
"data_act": o.get("factura_data"),
|
||||
}
|
||||
else:
|
||||
o["invoice"] = None
|
||||
|
||||
# For orders without cached invoice, check Oracle (only uncached imported orders)
|
||||
uncached_orders = [o for o in all_orders if o.get("id_comanda") and not o.get("invoice")]
|
||||
if uncached_orders:
|
||||
try:
|
||||
id_comanda_list = [o["id_comanda"] for o in uncached_orders]
|
||||
invoice_data = await asyncio.to_thread(
|
||||
invoice_service.check_invoices_for_orders, id_comanda_list
|
||||
)
|
||||
|
||||
for o in all_orders:
|
||||
for o in uncached_orders:
|
||||
idc = o.get("id_comanda")
|
||||
if idc and idc in invoice_data:
|
||||
o["invoice"] = invoice_data[idc]
|
||||
else:
|
||||
o["invoice"] = None
|
||||
# Update SQLite cache so counts stay accurate
|
||||
inv = invoice_data[idc]
|
||||
if inv.get("facturat"):
|
||||
await sqlite_service.update_order_invoice(
|
||||
o["order_number"],
|
||||
serie=inv.get("serie_act"),
|
||||
numar=str(inv.get("numar_act", "")),
|
||||
total_fara_tva=inv.get("total_fara_tva"),
|
||||
total_tva=inv.get("total_tva"),
|
||||
total_cu_tva=inv.get("total_cu_tva"),
|
||||
data_act=inv.get("data_act"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add shipping/billing name fields + is_different_person flag
|
||||
s_name = o.get("shipping_name") or ""
|
||||
@@ -351,19 +497,32 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||
o["billing_name"] = b_name
|
||||
o["is_different_person"] = bool(s_name and b_name and s_name != b_name)
|
||||
|
||||
# Build period-total counts (across all pages, same filters)
|
||||
nefacturate_count = sum(
|
||||
1 for o in all_orders
|
||||
if o.get("status") == "IMPORTED" and not o.get("invoice")
|
||||
)
|
||||
# Use counts from sqlite_service (already period-scoped) and add nefacturate
|
||||
# Use counts from sqlite_service (already period-scoped)
|
||||
counts = result.get("counts", {})
|
||||
counts["nefacturate"] = nefacturate_count
|
||||
# Count newly-cached invoices found during this request
|
||||
newly_invoiced = sum(1 for o in uncached_orders if o.get("invoice") and o["invoice"].get("facturat"))
|
||||
# Adjust uninvoiced count: start from SQLite count, subtract newly-found invoices
|
||||
uninvoiced_base = counts.get("uninvoiced_sqlite", sum(
|
||||
1 for o in all_orders
|
||||
if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")
|
||||
))
|
||||
counts["nefacturate"] = max(0, uninvoiced_base - newly_invoiced)
|
||||
imported_total = counts.get("imported_all") or counts.get("imported", 0)
|
||||
counts["facturate"] = max(0, imported_total - counts["nefacturate"])
|
||||
counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0))
|
||||
|
||||
# For UNINVOICED filter: apply server-side filtering + pagination
|
||||
if is_uninvoiced_filter:
|
||||
filtered = [o for o in all_orders if o.get("status") == "IMPORTED" and not o.get("invoice")]
|
||||
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")]
|
||||
total = len(filtered)
|
||||
offset = (page - 1) * per_page
|
||||
result["orders"] = filtered[offset:offset + per_page]
|
||||
result["total"] = total
|
||||
result["page"] = page
|
||||
result["per_page"] = per_page
|
||||
result["pages"] = (total + per_page - 1) // per_page if total > 0 else 0
|
||||
elif is_invoiced_filter:
|
||||
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and o.get("invoice")]
|
||||
total = len(filtered)
|
||||
offset = (page - 1) * per_page
|
||||
result["orders"] = filtered[offset:offset + per_page]
|
||||
@@ -384,6 +543,77 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/dashboard/refresh-invoices")
|
||||
async def refresh_invoices():
|
||||
"""Force-refresh invoice/order status from Oracle.
|
||||
|
||||
Checks:
|
||||
1. Uninvoiced orders → did they get invoiced?
|
||||
2. Invoiced orders → was the invoice deleted?
|
||||
3. All imported orders → was the order deleted from ROA?
|
||||
"""
|
||||
try:
|
||||
invoices_added = 0
|
||||
invoices_cleared = 0
|
||||
orders_deleted = 0
|
||||
|
||||
# 1. Check uninvoiced → new invoices
|
||||
uninvoiced = await sqlite_service.get_uninvoiced_imported_orders()
|
||||
if uninvoiced:
|
||||
id_comanda_list = [o["id_comanda"] for o in uninvoiced]
|
||||
invoice_data = await asyncio.to_thread(
|
||||
invoice_service.check_invoices_for_orders, id_comanda_list
|
||||
)
|
||||
id_to_order = {o["id_comanda"]: o["order_number"] for o in uninvoiced}
|
||||
for idc, inv in invoice_data.items():
|
||||
order_num = id_to_order.get(idc)
|
||||
if order_num and inv.get("facturat"):
|
||||
await sqlite_service.update_order_invoice(
|
||||
order_num,
|
||||
serie=inv.get("serie_act"),
|
||||
numar=str(inv.get("numar_act", "")),
|
||||
total_fara_tva=inv.get("total_fara_tva"),
|
||||
total_tva=inv.get("total_tva"),
|
||||
total_cu_tva=inv.get("total_cu_tva"),
|
||||
data_act=inv.get("data_act"),
|
||||
)
|
||||
invoices_added += 1
|
||||
|
||||
# 2. Check invoiced → deleted invoices
|
||||
invoiced = await sqlite_service.get_invoiced_imported_orders()
|
||||
if invoiced:
|
||||
id_comanda_list = [o["id_comanda"] for o in invoiced]
|
||||
invoice_data = await asyncio.to_thread(
|
||||
invoice_service.check_invoices_for_orders, id_comanda_list
|
||||
)
|
||||
for o in invoiced:
|
||||
if o["id_comanda"] not in invoice_data:
|
||||
await sqlite_service.clear_order_invoice(o["order_number"])
|
||||
invoices_cleared += 1
|
||||
|
||||
# 3. Check all imported → deleted orders in ROA
|
||||
all_imported = await sqlite_service.get_all_imported_orders()
|
||||
if all_imported:
|
||||
id_comanda_list = [o["id_comanda"] for o in all_imported]
|
||||
existing_ids = await asyncio.to_thread(
|
||||
invoice_service.check_orders_exist, id_comanda_list
|
||||
)
|
||||
for o in all_imported:
|
||||
if o["id_comanda"] not in existing_ids:
|
||||
await sqlite_service.mark_order_deleted_in_roa(o["order_number"])
|
||||
orders_deleted += 1
|
||||
|
||||
checked = len(uninvoiced) + len(invoiced) + len(all_imported)
|
||||
return {
|
||||
"checked": checked,
|
||||
"invoices_added": invoices_added,
|
||||
"invoices_cleared": invoices_cleared,
|
||||
"orders_deleted": orders_deleted,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "invoices_added": 0}
|
||||
|
||||
|
||||
@router.put("/api/sync/schedule")
|
||||
async def update_schedule(config: ScheduleConfig):
|
||||
"""Update scheduler configuration."""
|
||||
@@ -403,3 +633,110 @@ async def update_schedule(config: ScheduleConfig):
|
||||
async def get_schedule():
|
||||
"""Get current scheduler status."""
|
||||
return scheduler_service.get_scheduler_status()
|
||||
|
||||
|
||||
@router.get("/api/settings")
|
||||
async def get_app_settings():
|
||||
"""Get application settings."""
|
||||
from ..config import settings as config_settings
|
||||
s = await sqlite_service.get_app_settings()
|
||||
return {
|
||||
"transport_codmat": s.get("transport_codmat", ""),
|
||||
"transport_vat": s.get("transport_vat", "21"),
|
||||
"discount_codmat": s.get("discount_codmat", ""),
|
||||
"transport_id_pol": s.get("transport_id_pol", ""),
|
||||
"discount_vat": s.get("discount_vat", "21"),
|
||||
"discount_id_pol": s.get("discount_id_pol", ""),
|
||||
"id_pol": s.get("id_pol", ""),
|
||||
"id_pol_productie": s.get("id_pol_productie", ""),
|
||||
"id_sectie": s.get("id_sectie", ""),
|
||||
"id_gestiune": s.get("id_gestiune", ""),
|
||||
"split_discount_vat": s.get("split_discount_vat", ""),
|
||||
"gomag_api_key": s.get("gomag_api_key", "") or config_settings.GOMAG_API_KEY,
|
||||
"gomag_api_shop": s.get("gomag_api_shop", "") or config_settings.GOMAG_API_SHOP,
|
||||
"gomag_order_days_back": s.get("gomag_order_days_back", "") or str(config_settings.GOMAG_ORDER_DAYS_BACK),
|
||||
"gomag_limit": s.get("gomag_limit", "") or str(config_settings.GOMAG_LIMIT),
|
||||
"dashboard_poll_seconds": s.get("dashboard_poll_seconds", "5"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/settings")
|
||||
async def update_app_settings(config: AppSettingsUpdate):
|
||||
"""Update application settings."""
|
||||
await sqlite_service.set_app_setting("transport_codmat", config.transport_codmat)
|
||||
await sqlite_service.set_app_setting("transport_vat", config.transport_vat)
|
||||
await sqlite_service.set_app_setting("discount_codmat", config.discount_codmat)
|
||||
await sqlite_service.set_app_setting("transport_id_pol", config.transport_id_pol)
|
||||
await sqlite_service.set_app_setting("discount_vat", config.discount_vat)
|
||||
await sqlite_service.set_app_setting("discount_id_pol", config.discount_id_pol)
|
||||
await sqlite_service.set_app_setting("id_pol", config.id_pol)
|
||||
await sqlite_service.set_app_setting("id_pol_productie", config.id_pol_productie)
|
||||
await sqlite_service.set_app_setting("id_sectie", config.id_sectie)
|
||||
await sqlite_service.set_app_setting("id_gestiune", config.id_gestiune)
|
||||
await sqlite_service.set_app_setting("split_discount_vat", config.split_discount_vat)
|
||||
await sqlite_service.set_app_setting("gomag_api_key", config.gomag_api_key)
|
||||
await sqlite_service.set_app_setting("gomag_api_shop", config.gomag_api_shop)
|
||||
await sqlite_service.set_app_setting("gomag_order_days_back", config.gomag_order_days_back)
|
||||
await sqlite_service.set_app_setting("gomag_limit", config.gomag_limit)
|
||||
await sqlite_service.set_app_setting("dashboard_poll_seconds", config.dashboard_poll_seconds)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/api/settings/gestiuni")
|
||||
async def get_gestiuni():
|
||||
"""Get list of warehouses from Oracle for dropdown."""
|
||||
def _query():
|
||||
conn = database.get_oracle_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id_gestiune, nume_gestiune FROM nom_gestiuni WHERE sters=0 AND inactiv=0 ORDER BY id_gestiune"
|
||||
)
|
||||
return [{"id": str(row[0]), "label": f"{row[0]} - {row[1]}"} for row in cur]
|
||||
finally:
|
||||
database.pool.release(conn)
|
||||
try:
|
||||
return await asyncio.to_thread(_query)
|
||||
except Exception as e:
|
||||
logger.error(f"get_gestiuni error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/api/settings/sectii")
|
||||
async def get_sectii():
|
||||
"""Get list of sections from Oracle for dropdown."""
|
||||
def _query():
|
||||
conn = database.get_oracle_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id_sectie, sectie FROM nom_sectii WHERE sters=0 AND inactiv=0 ORDER BY id_sectie"
|
||||
)
|
||||
return [{"id": str(row[0]), "label": f"{row[0]} - {row[1]}"} for row in cur]
|
||||
finally:
|
||||
database.pool.release(conn)
|
||||
try:
|
||||
return await asyncio.to_thread(_query)
|
||||
except Exception as e:
|
||||
logger.error(f"get_sectii error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/api/settings/politici")
|
||||
async def get_politici():
|
||||
"""Get list of price policies from Oracle for dropdown."""
|
||||
def _query():
|
||||
conn = database.get_oracle_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id_pol, nume_lista_preturi FROM crm_politici_preturi WHERE sters=0 ORDER BY id_pol"
|
||||
)
|
||||
return [{"id": str(row[0]), "label": f"{row[0]} - {row[1]}"} for row in cur]
|
||||
finally:
|
||||
database.pool.release(conn)
|
||||
try:
|
||||
return await asyncio.to_thread(_query)
|
||||
except Exception as e:
|
||||
logger.error(f"get_politici error: {e}")
|
||||
return []
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
@@ -25,10 +24,6 @@ async def scan_and_validate():
|
||||
result = validation_service.validate_skus(all_skus)
|
||||
importable, skipped = validation_service.classify_orders(orders, result)
|
||||
|
||||
# Find new orders (not yet in Oracle)
|
||||
all_order_numbers = [o.number for o in orders]
|
||||
new_orders = await asyncio.to_thread(validation_service.find_new_orders, all_order_numbers)
|
||||
|
||||
# Build SKU context from skipped orders and track missing SKUs
|
||||
sku_context = {} # sku -> {order_numbers: [], customers: []}
|
||||
for order, missing_list in skipped:
|
||||
@@ -73,7 +68,7 @@ async def scan_and_validate():
|
||||
"total_skus": len(all_skus),
|
||||
"importable": len(importable),
|
||||
"skipped": len(skipped),
|
||||
"new_orders": len(new_orders),
|
||||
"new_orders": len(importable),
|
||||
# Fields consumed by the rescan progress banner in missing_skus.html
|
||||
"total_skus_scanned": total_skus_scanned,
|
||||
"new_missing": new_missing_count,
|
||||
|
||||
103
api/app/services/gomag_client.py
Normal file
103
api/app/services/gomag_client.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""GoMag API client - downloads orders and saves them as JSON files."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def download_orders(
|
||||
json_dir: str,
|
||||
days_back: int = None,
|
||||
api_key: str = None,
|
||||
api_shop: str = None,
|
||||
limit: int = None,
|
||||
log_fn: Callable[[str], None] = None,
|
||||
) -> dict:
|
||||
"""Download orders from GoMag API and save as JSON files.
|
||||
|
||||
Returns dict with keys: pages, total, files (list of saved file paths).
|
||||
If API keys are not configured, returns immediately with empty result.
|
||||
Optional api_key, api_shop, limit override config.settings values.
|
||||
"""
|
||||
def _log(msg: str):
|
||||
logger.info(msg)
|
||||
if log_fn:
|
||||
log_fn(msg)
|
||||
|
||||
effective_key = api_key or settings.GOMAG_API_KEY
|
||||
effective_shop = api_shop or settings.GOMAG_API_SHOP
|
||||
effective_limit = limit or settings.GOMAG_LIMIT
|
||||
|
||||
if not effective_key or not effective_shop:
|
||||
_log("GoMag API keys neconfigurați, skip download")
|
||||
return {"pages": 0, "total": 0, "files": []}
|
||||
|
||||
if days_back is None:
|
||||
days_back = settings.GOMAG_ORDER_DAYS_BACK
|
||||
|
||||
start_date = (datetime.now() - timedelta(days=days_back)).strftime("%Y-%m-%d")
|
||||
out_dir = Path(json_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Clean old JSON files before downloading new ones
|
||||
old_files = list(out_dir.glob("gomag_orders*.json"))
|
||||
if old_files:
|
||||
for f in old_files:
|
||||
f.unlink()
|
||||
_log(f"Șterse {len(old_files)} fișiere JSON vechi")
|
||||
|
||||
headers = {
|
||||
"Apikey": effective_key,
|
||||
"ApiShop": effective_shop,
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
saved_files = []
|
||||
total_orders = 0
|
||||
total_pages = 1
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
page = 1
|
||||
while page <= total_pages:
|
||||
params = {
|
||||
"startDate": start_date,
|
||||
"page": page,
|
||||
"limit": effective_limit,
|
||||
}
|
||||
try:
|
||||
response = await client.get(settings.GOMAG_API_URL, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPError as e:
|
||||
_log(f"GoMag API eroare pagina {page}: {e}")
|
||||
break
|
||||
except Exception as e:
|
||||
_log(f"GoMag eroare neașteptată pagina {page}: {e}")
|
||||
break
|
||||
|
||||
# Update totals from first page response
|
||||
if page == 1:
|
||||
total_orders = int(data.get("total", 0))
|
||||
total_pages = int(data.get("pages", 1))
|
||||
_log(f"GoMag: {total_orders} comenzi în {total_pages} pagini (startDate={start_date})")
|
||||
|
||||
filename = out_dir / f"gomag_orders_page{page}_{timestamp}.json"
|
||||
filename.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
saved_files.append(str(filename))
|
||||
_log(f"GoMag: pagina {page}/{total_pages} salvată → {filename.name}")
|
||||
|
||||
page += 1
|
||||
if page <= total_pages:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
return {"pages": total_pages, "total": total_orders, "files": saved_files}
|
||||
@@ -60,21 +60,148 @@ def format_address_for_oracle(address: str, city: str, region: str) -> str:
|
||||
return f"JUD:{region_clean};{city_clean};{address_clean}"
|
||||
|
||||
|
||||
def build_articles_json(items) -> str:
|
||||
"""Build JSON string for Oracle PACK_IMPORT_COMENZI.importa_comanda."""
|
||||
def compute_discount_split(order, settings: dict) -> dict | None:
|
||||
"""Compute proportional discount split by VAT rate from order items.
|
||||
|
||||
Returns: {"11": 3.98, "21": 1.43} or None if split not applicable.
|
||||
Only splits when split_discount_vat is enabled AND multiple VAT rates exist.
|
||||
When single VAT rate: returns {actual_rate: total} (smarter than GoMag's fixed 21%).
|
||||
"""
|
||||
if not order or order.discount_total <= 0:
|
||||
return None
|
||||
|
||||
split_enabled = settings.get("split_discount_vat") == "1"
|
||||
|
||||
# Calculate VAT distribution from order items (exclude zero-value)
|
||||
vat_totals = {}
|
||||
for item in order.items:
|
||||
item_value = abs(item.price * item.quantity)
|
||||
if item_value > 0:
|
||||
vat_key = str(int(item.vat)) if item.vat == int(item.vat) else str(item.vat)
|
||||
vat_totals[vat_key] = vat_totals.get(vat_key, 0) + item_value
|
||||
|
||||
if not vat_totals:
|
||||
return None
|
||||
|
||||
grand_total = sum(vat_totals.values())
|
||||
if grand_total <= 0:
|
||||
return None
|
||||
|
||||
if len(vat_totals) == 1:
|
||||
# Single VAT rate — use that rate (smarter than GoMag's fixed 21%)
|
||||
actual_vat = list(vat_totals.keys())[0]
|
||||
return {actual_vat: round(order.discount_total, 2)}
|
||||
|
||||
if not split_enabled:
|
||||
return None
|
||||
|
||||
# Multiple VAT rates — split proportionally
|
||||
result = {}
|
||||
discount_remaining = order.discount_total
|
||||
sorted_rates = sorted(vat_totals.keys(), key=lambda x: float(x))
|
||||
|
||||
for i, vat_rate in enumerate(sorted_rates):
|
||||
if i == len(sorted_rates) - 1:
|
||||
split_amount = round(discount_remaining, 2) # last gets remainder
|
||||
else:
|
||||
proportion = vat_totals[vat_rate] / grand_total
|
||||
split_amount = round(order.discount_total * proportion, 2)
|
||||
discount_remaining -= split_amount
|
||||
|
||||
if split_amount > 0:
|
||||
result[vat_rate] = split_amount
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def build_articles_json(items, order=None, settings=None) -> str:
|
||||
"""Build JSON string for Oracle PACK_IMPORT_COMENZI.importa_comanda.
|
||||
Includes transport and discount as extra articles if configured.
|
||||
Supports per-article id_pol from codmat_policy_map and discount VAT splitting."""
|
||||
articles = []
|
||||
codmat_policy_map = settings.get("_codmat_policy_map", {}) if settings else {}
|
||||
default_id_pol = settings.get("id_pol", "") if settings else ""
|
||||
|
||||
for item in items:
|
||||
articles.append({
|
||||
article_dict = {
|
||||
"sku": item.sku,
|
||||
"quantity": str(item.quantity),
|
||||
"price": str(item.price),
|
||||
"vat": str(item.vat),
|
||||
"name": clean_web_text(item.name)
|
||||
})
|
||||
}
|
||||
# Per-article id_pol from dual-policy validation
|
||||
item_pol = codmat_policy_map.get(item.sku)
|
||||
if item_pol and str(item_pol) != str(default_id_pol):
|
||||
article_dict["id_pol"] = str(item_pol)
|
||||
articles.append(article_dict)
|
||||
|
||||
if order and settings:
|
||||
transport_codmat = settings.get("transport_codmat", "")
|
||||
transport_vat = settings.get("transport_vat", "21")
|
||||
discount_codmat = settings.get("discount_codmat", "")
|
||||
|
||||
# Transport as article with quantity +1
|
||||
if order.delivery_cost > 0 and transport_codmat:
|
||||
article_dict = {
|
||||
"sku": transport_codmat,
|
||||
"quantity": "1",
|
||||
"price": str(order.delivery_cost),
|
||||
"vat": transport_vat,
|
||||
"name": "Transport"
|
||||
}
|
||||
if settings.get("transport_id_pol"):
|
||||
article_dict["id_pol"] = settings["transport_id_pol"]
|
||||
articles.append(article_dict)
|
||||
|
||||
# Discount — smart VAT splitting
|
||||
if order.discount_total > 0 and discount_codmat:
|
||||
discount_split = compute_discount_split(order, settings)
|
||||
|
||||
if discount_split and len(discount_split) > 1:
|
||||
# Multiple VAT rates — multiple discount lines
|
||||
for vat_rate, split_amount in sorted(discount_split.items(), key=lambda x: float(x[0])):
|
||||
article_dict = {
|
||||
"sku": discount_codmat,
|
||||
"quantity": "-1",
|
||||
"price": str(split_amount),
|
||||
"vat": vat_rate,
|
||||
"name": f"Discount (TVA {vat_rate}%)"
|
||||
}
|
||||
if settings.get("discount_id_pol"):
|
||||
article_dict["id_pol"] = settings["discount_id_pol"]
|
||||
articles.append(article_dict)
|
||||
elif discount_split and len(discount_split) == 1:
|
||||
# Single VAT rate — use detected rate
|
||||
actual_vat = list(discount_split.keys())[0]
|
||||
article_dict = {
|
||||
"sku": discount_codmat,
|
||||
"quantity": "-1",
|
||||
"price": str(order.discount_total),
|
||||
"vat": actual_vat,
|
||||
"name": "Discount"
|
||||
}
|
||||
if settings.get("discount_id_pol"):
|
||||
article_dict["id_pol"] = settings["discount_id_pol"]
|
||||
articles.append(article_dict)
|
||||
else:
|
||||
# Fallback — original behavior with GoMag VAT or settings default
|
||||
discount_vat = getattr(order, 'discount_vat', None) or settings.get("discount_vat", "21")
|
||||
article_dict = {
|
||||
"sku": discount_codmat,
|
||||
"quantity": "-1",
|
||||
"price": str(order.discount_total),
|
||||
"vat": discount_vat,
|
||||
"name": "Discount"
|
||||
}
|
||||
if settings.get("discount_id_pol"):
|
||||
article_dict["id_pol"] = settings["discount_id_pol"]
|
||||
articles.append(article_dict)
|
||||
|
||||
return json.dumps(articles)
|
||||
|
||||
|
||||
def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dict:
|
||||
def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_settings: dict = None, id_gestiuni: list[int] = None) -> dict:
|
||||
"""Import a single order into Oracle ROA.
|
||||
|
||||
Returns dict with:
|
||||
@@ -94,6 +221,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
|
||||
"error": None
|
||||
}
|
||||
|
||||
conn = None
|
||||
try:
|
||||
order_number = clean_web_text(order.number)
|
||||
order_date = convert_web_date(order.date)
|
||||
@@ -104,7 +232,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
|
||||
|
||||
if database.pool is None:
|
||||
raise RuntimeError("Oracle pool not initialized")
|
||||
with database.pool.acquire() as conn:
|
||||
conn = database.pool.acquire()
|
||||
with conn.cursor() as cur:
|
||||
# Step 1: Process partner — use shipping person data for name
|
||||
id_partener = cur.var(oracledb.DB_TYPE_NUMBER)
|
||||
@@ -203,7 +331,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
|
||||
result["id_adresa_livrare"] = int(addr_livr_id)
|
||||
|
||||
# Step 4: Build articles JSON and import order
|
||||
articles_json = build_articles_json(order.items)
|
||||
articles_json = build_articles_json(order.items, order, app_settings)
|
||||
|
||||
# Use CLOB for the JSON
|
||||
clob_var = cur.var(oracledb.DB_TYPE_CLOB)
|
||||
@@ -211,6 +339,9 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
|
||||
|
||||
id_comanda = cur.var(oracledb.DB_TYPE_NUMBER)
|
||||
|
||||
# Convert list[int] to CSV string for Oracle VARCHAR2 param
|
||||
id_gestiune_csv = ",".join(str(g) for g in id_gestiuni) if id_gestiuni else None
|
||||
|
||||
cur.callproc("PACK_IMPORT_COMENZI.importa_comanda", [
|
||||
order_number, # p_nr_comanda_ext
|
||||
order_date, # p_data_comanda
|
||||
@@ -220,6 +351,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
|
||||
addr_fact_id, # p_id_adresa_facturare
|
||||
id_pol, # p_id_pol
|
||||
id_sectie, # p_id_sectie
|
||||
id_gestiune_csv, # p_id_gestiune (CSV string)
|
||||
id_comanda # v_id_comanda (OUT)
|
||||
])
|
||||
|
||||
@@ -238,8 +370,72 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
|
||||
error_msg = str(e)
|
||||
result["error"] = error_msg
|
||||
logger.error(f"Oracle error importing order {order.number}: {error_msg}")
|
||||
if conn:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
logger.error(f"Error importing order {order.number}: {e}")
|
||||
if conn:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if conn:
|
||||
try:
|
||||
database.pool.release(conn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def soft_delete_order_in_roa(id_comanda: int) -> dict:
|
||||
"""Soft-delete an order in Oracle ROA (set sters=1 on comenzi + comenzi_detalii).
|
||||
Returns {"success": bool, "error": str|None, "details_deleted": int}
|
||||
"""
|
||||
result = {"success": False, "error": None, "details_deleted": 0}
|
||||
|
||||
if database.pool is None:
|
||||
result["error"] = "Oracle pool not initialized"
|
||||
return result
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = database.pool.acquire()
|
||||
with conn.cursor() as cur:
|
||||
# Soft-delete order details
|
||||
cur.execute(
|
||||
"UPDATE comenzi_detalii SET sters = 1 WHERE id_comanda = :1 AND sters = 0",
|
||||
[id_comanda]
|
||||
)
|
||||
result["details_deleted"] = cur.rowcount
|
||||
|
||||
# Soft-delete the order itself
|
||||
cur.execute(
|
||||
"UPDATE comenzi SET sters = 1 WHERE id_comanda = :1 AND sters = 0",
|
||||
[id_comanda]
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
result["success"] = True
|
||||
logger.info(f"Soft-deleted order ID={id_comanda} in Oracle ROA ({result['details_deleted']} details)")
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
logger.error(f"Error soft-deleting order ID={id_comanda}: {e}")
|
||||
if conn:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if conn:
|
||||
try:
|
||||
database.pool.release(conn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
@@ -22,7 +22,8 @@ def check_invoices_for_orders(id_comanda_list: list) -> dict:
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT id_comanda, numar_act, serie_act,
|
||||
total_fara_tva, total_tva, total_cu_tva
|
||||
total_fara_tva, total_tva, total_cu_tva,
|
||||
TO_CHAR(data_act, 'YYYY-MM-DD') AS data_act
|
||||
FROM vanzari
|
||||
WHERE id_comanda IN ({placeholders}) AND sters = 0
|
||||
""", params)
|
||||
@@ -34,6 +35,7 @@ def check_invoices_for_orders(id_comanda_list: list) -> dict:
|
||||
"total_fara_tva": float(row[3]) if row[3] else 0,
|
||||
"total_tva": float(row[4]) if row[4] else 0,
|
||||
"total_cu_tva": float(row[5]) if row[5] else 0,
|
||||
"data_act": row[6],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Invoice check failed (table may not exist): {e}")
|
||||
@@ -41,3 +43,33 @@ def check_invoices_for_orders(id_comanda_list: list) -> dict:
|
||||
database.pool.release(conn)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def check_orders_exist(id_comanda_list: list) -> set:
|
||||
"""Check which id_comanda values still exist in Oracle COMENZI (sters=0).
|
||||
Returns set of id_comanda that exist.
|
||||
"""
|
||||
if not id_comanda_list or database.pool is None:
|
||||
return set()
|
||||
|
||||
existing = set()
|
||||
conn = database.get_oracle_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for i in range(0, len(id_comanda_list), 500):
|
||||
batch = id_comanda_list[i:i+500]
|
||||
placeholders = ",".join([f":c{j}" for j in range(len(batch))])
|
||||
params = {f"c{j}": cid for j, cid in enumerate(batch)}
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT id_comanda FROM COMENZI
|
||||
WHERE id_comanda IN ({placeholders}) AND sters = 0
|
||||
""", params)
|
||||
for row in cur:
|
||||
existing.add(row[0])
|
||||
except Exception as e:
|
||||
logger.warning(f"Order existence check failed: {e}")
|
||||
finally:
|
||||
database.pool.release(conn)
|
||||
|
||||
return existing
|
||||
|
||||
@@ -88,7 +88,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
||||
for r in rows
|
||||
if r.get("activ") == 1
|
||||
)
|
||||
if pct_total >= 99.99:
|
||||
if abs(pct_total - 100) <= 0.01:
|
||||
complete_skus += 1
|
||||
else:
|
||||
incomplete_skus += 1
|
||||
@@ -108,7 +108,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
||||
for r in rows
|
||||
if r.get("activ") == 1
|
||||
)
|
||||
is_complete = pct_total >= 99.99
|
||||
is_complete = abs(pct_total - 100) <= 0.01
|
||||
if pct_filter == "complete" and is_complete:
|
||||
filtered_groups[sku] = rows
|
||||
elif pct_filter == "incomplete" and not is_complete:
|
||||
@@ -129,7 +129,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
||||
for r in rows
|
||||
if r.get("activ") == 1
|
||||
)
|
||||
sku_pct[sku] = {"pct_total": pct_total, "is_complete": pct_total >= 99.99}
|
||||
sku_pct[sku] = {"pct_total": pct_total, "is_complete": abs(pct_total - 100) <= 0.01}
|
||||
|
||||
for row in page_rows:
|
||||
meta = sku_pct.get(row["sku"], {"pct_total": 0, "is_complete": False})
|
||||
@@ -145,13 +145,38 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
||||
"counts": counts,
|
||||
}
|
||||
|
||||
def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100):
|
||||
"""Create a new mapping. Returns dict or raises HTTPException on duplicate."""
|
||||
def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100, auto_restore: bool = False):
|
||||
"""Create a new mapping. Returns dict or raises HTTPException on duplicate.
|
||||
|
||||
When auto_restore=True, soft-deleted records are restored+updated instead of raising 409.
|
||||
"""
|
||||
if not sku or not sku.strip():
|
||||
raise HTTPException(status_code=400, detail="SKU este obligatoriu")
|
||||
if not codmat or not codmat.strip():
|
||||
raise HTTPException(status_code=400, detail="CODMAT este obligatoriu")
|
||||
if database.pool is None:
|
||||
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
||||
|
||||
with database.pool.acquire() as conn:
|
||||
with conn.cursor() as cur:
|
||||
# Validate CODMAT exists in NOM_ARTICOLE
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM NOM_ARTICOLE
|
||||
WHERE codmat = :codmat AND sters = 0 AND inactiv = 0
|
||||
""", {"codmat": codmat})
|
||||
if cur.fetchone()[0] == 0:
|
||||
raise HTTPException(status_code=400, detail="CODMAT-ul nu exista in nomenclator")
|
||||
|
||||
# Warn if SKU is already a direct CODMAT in NOM_ARTICOLE
|
||||
if sku == codmat:
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM NOM_ARTICOLE
|
||||
WHERE codmat = :sku AND sters = 0 AND inactiv = 0
|
||||
""", {"sku": sku})
|
||||
if cur.fetchone()[0] > 0:
|
||||
raise HTTPException(status_code=409,
|
||||
detail="SKU-ul exista direct in nomenclator ca CODMAT, nu necesita mapare")
|
||||
|
||||
# Check for active duplicate
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM ARTICOLE_TERTI
|
||||
@@ -166,6 +191,17 @@ def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret
|
||||
WHERE sku = :sku AND codmat = :codmat AND sters = 1
|
||||
""", {"sku": sku, "codmat": codmat})
|
||||
if cur.fetchone()[0] > 0:
|
||||
if auto_restore:
|
||||
cur.execute("""
|
||||
UPDATE ARTICOLE_TERTI SET sters = 0, activ = 1,
|
||||
cantitate_roa = :cantitate_roa, procent_pret = :procent_pret,
|
||||
data_modif = SYSDATE
|
||||
WHERE sku = :sku AND codmat = :codmat AND sters = 1
|
||||
""", {"sku": sku, "codmat": codmat,
|
||||
"cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
|
||||
conn.commit()
|
||||
return {"sku": sku, "codmat": codmat}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Maparea a fost ștearsă anterior",
|
||||
@@ -229,6 +265,10 @@ def delete_mapping(sku: str, codmat: str):
|
||||
def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str,
|
||||
cantitate_roa: float = 1, procent_pret: float = 100):
|
||||
"""Edit a mapping. If PK changed, soft-delete old and insert new."""
|
||||
if not new_sku or not new_sku.strip():
|
||||
raise HTTPException(status_code=400, detail="SKU este obligatoriu")
|
||||
if not new_codmat or not new_codmat.strip():
|
||||
raise HTTPException(status_code=400, detail="CODMAT este obligatoriu")
|
||||
if database.pool is None:
|
||||
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
||||
|
||||
@@ -283,23 +323,27 @@ def import_csv(file_content: str):
|
||||
|
||||
reader = csv.DictReader(io.StringIO(file_content))
|
||||
created = 0
|
||||
updated = 0
|
||||
skipped_no_codmat = 0
|
||||
errors = []
|
||||
|
||||
with database.pool.acquire() as conn:
|
||||
with conn.cursor() as cur:
|
||||
for i, row in enumerate(reader, 1):
|
||||
try:
|
||||
sku = row.get("sku", "").strip()
|
||||
codmat = row.get("codmat", "").strip()
|
||||
|
||||
if not sku:
|
||||
errors.append(f"Rând {i}: SKU lipsă")
|
||||
continue
|
||||
|
||||
if not codmat:
|
||||
skipped_no_codmat += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
cantitate = float(row.get("cantitate_roa", "1") or "1")
|
||||
procent = float(row.get("procent_pret", "100") or "100")
|
||||
|
||||
if not sku or not codmat:
|
||||
errors.append(f"Row {i}: missing sku or codmat")
|
||||
continue
|
||||
|
||||
# Try update first, insert if not exists (MERGE)
|
||||
cur.execute("""
|
||||
MERGE INTO ARTICOLE_TERTI t
|
||||
USING (SELECT :sku AS sku, :codmat AS codmat FROM DUAL) s
|
||||
@@ -314,16 +358,14 @@ def import_csv(file_content: str):
|
||||
(sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
|
||||
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
|
||||
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent})
|
||||
|
||||
# Check if it was insert or update by rowcount
|
||||
created += 1 # We count total processed
|
||||
created += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Row {i}: {str(e)}")
|
||||
errors.append(f"Rând {i}: {str(e)}")
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"processed": created, "errors": errors}
|
||||
return {"processed": created, "skipped_no_codmat": skipped_no_codmat, "errors": errors}
|
||||
|
||||
def export_csv():
|
||||
"""Export all mappings as CSV string."""
|
||||
|
||||
@@ -54,6 +54,10 @@ class OrderData:
|
||||
items: list = field(default_factory=list) # list of OrderItem
|
||||
billing: OrderBilling = field(default_factory=OrderBilling)
|
||||
shipping: Optional[OrderShipping] = None
|
||||
total: float = 0.0
|
||||
delivery_cost: float = 0.0
|
||||
discount_total: float = 0.0
|
||||
discount_vat: Optional[str] = None
|
||||
payment_name: str = ""
|
||||
delivery_name: str = ""
|
||||
source_file: str = ""
|
||||
@@ -154,6 +158,18 @@ def _parse_order(order_id: str, data: dict, source_file: str) -> OrderData:
|
||||
payment = data.get("payment", {}) or {}
|
||||
delivery = data.get("delivery", {}) or {}
|
||||
|
||||
# Parse delivery cost
|
||||
delivery_cost = float(delivery.get("total", 0) or 0) if isinstance(delivery, dict) else 0.0
|
||||
|
||||
# Parse discount total (sum of all discount values) and VAT from first discount item
|
||||
discount_total = 0.0
|
||||
discount_vat = None
|
||||
for d in data.get("discounts", []):
|
||||
if isinstance(d, dict):
|
||||
discount_total += float(d.get("value", 0) or 0)
|
||||
if discount_vat is None and d.get("vat") is not None:
|
||||
discount_vat = str(d["vat"])
|
||||
|
||||
return OrderData(
|
||||
id=str(data.get("id", order_id)),
|
||||
number=str(data.get("number", "")),
|
||||
@@ -163,6 +179,10 @@ def _parse_order(order_id: str, data: dict, source_file: str) -> OrderData:
|
||||
items=items,
|
||||
billing=billing,
|
||||
shipping=shipping,
|
||||
total=float(data.get("total", 0) or 0),
|
||||
delivery_cost=delivery_cost,
|
||||
discount_total=discount_total,
|
||||
discount_vat=discount_vat,
|
||||
payment_name=str(payment.get("name", "")) if isinstance(payment, dict) else "",
|
||||
delivery_name=str(delivery.get("name", "")) if isinstance(delivery, dict) else "",
|
||||
source_file=source_file
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from ..database import get_sqlite, get_sqlite_sync
|
||||
|
||||
_tz_bucharest = ZoneInfo("Europe/Bucharest")
|
||||
|
||||
|
||||
def _now_str():
|
||||
"""Return current Bucharest time as ISO string."""
|
||||
return datetime.now(_tz_bucharest).replace(tzinfo=None).isoformat()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -12,8 +20,8 @@ async def create_sync_run(run_id: str, json_files: int = 0):
|
||||
try:
|
||||
await db.execute("""
|
||||
INSERT INTO sync_runs (run_id, started_at, status, json_files)
|
||||
VALUES (?, datetime('now'), 'running', ?)
|
||||
""", (run_id, json_files))
|
||||
VALUES (?, ?, 'running', ?)
|
||||
""", (run_id, _now_str(), json_files))
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
@@ -21,21 +29,25 @@ async def create_sync_run(run_id: str, json_files: int = 0):
|
||||
|
||||
async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
|
||||
imported: int = 0, skipped: int = 0, errors: int = 0,
|
||||
error_message: str = None):
|
||||
error_message: str = None,
|
||||
already_imported: int = 0, new_imported: int = 0):
|
||||
"""Update sync run with results."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
await db.execute("""
|
||||
UPDATE sync_runs SET
|
||||
finished_at = datetime('now'),
|
||||
finished_at = ?,
|
||||
status = ?,
|
||||
total_orders = ?,
|
||||
imported = ?,
|
||||
skipped = ?,
|
||||
errors = ?,
|
||||
error_message = ?
|
||||
error_message = ?,
|
||||
already_imported = ?,
|
||||
new_imported = ?
|
||||
WHERE run_id = ?
|
||||
""", (status, total_orders, imported, skipped, errors, error_message, run_id))
|
||||
""", (_now_str(), status, total_orders, imported, skipped, errors, error_message,
|
||||
already_imported, new_imported, run_id))
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
@@ -46,7 +58,10 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
|
||||
id_partener: int = None, error_message: str = None,
|
||||
missing_skus: list = None, items_count: int = 0,
|
||||
shipping_name: str = None, billing_name: str = None,
|
||||
payment_method: str = None, delivery_method: str = None):
|
||||
payment_method: str = None, delivery_method: str = None,
|
||||
order_total: float = None,
|
||||
delivery_cost: float = None, discount_total: float = None,
|
||||
web_status: str = None, discount_split: str = None):
|
||||
"""Upsert a single order — one row per order_number, status updated in place."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
@@ -55,10 +70,16 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
|
||||
(order_number, order_date, customer_name, status,
|
||||
id_comanda, id_partener, error_message, missing_skus, items_count,
|
||||
last_sync_run_id, shipping_name, billing_name,
|
||||
payment_method, delivery_method)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
payment_method, delivery_method, order_total,
|
||||
delivery_cost, discount_total, web_status, discount_split)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(order_number) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
customer_name = excluded.customer_name,
|
||||
status = CASE
|
||||
WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED'
|
||||
THEN orders.status
|
||||
ELSE excluded.status
|
||||
END,
|
||||
error_message = excluded.error_message,
|
||||
missing_skus = excluded.missing_skus,
|
||||
items_count = excluded.items_count,
|
||||
@@ -72,12 +93,18 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
|
||||
billing_name = COALESCE(excluded.billing_name, orders.billing_name),
|
||||
payment_method = COALESCE(excluded.payment_method, orders.payment_method),
|
||||
delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method),
|
||||
order_total = COALESCE(excluded.order_total, orders.order_total),
|
||||
delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost),
|
||||
discount_total = COALESCE(excluded.discount_total, orders.discount_total),
|
||||
web_status = COALESCE(excluded.web_status, orders.web_status),
|
||||
discount_split = COALESCE(excluded.discount_split, orders.discount_split),
|
||||
updated_at = datetime('now')
|
||||
""", (order_number, order_date, customer_name, status,
|
||||
id_comanda, id_partener, error_message,
|
||||
json.dumps(missing_skus) if missing_skus else None,
|
||||
items_count, sync_run_id, shipping_name, billing_name,
|
||||
payment_method, delivery_method))
|
||||
payment_method, delivery_method, order_total,
|
||||
delivery_cost, discount_total, web_status, discount_split))
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
@@ -96,6 +123,97 @@ async def add_sync_run_order(sync_run_id: str, order_number: str, status_at_run:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def save_orders_batch(orders_data: list[dict]):
|
||||
"""Batch save a list of orders + their sync_run_orders + order_items in one transaction.
|
||||
|
||||
Each dict must have: sync_run_id, order_number, order_date, customer_name, status,
|
||||
id_comanda, id_partener, error_message, missing_skus (list|None), items_count,
|
||||
shipping_name, billing_name, payment_method, delivery_method, status_at_run,
|
||||
items (list of item dicts), delivery_cost (optional), discount_total (optional),
|
||||
web_status (optional).
|
||||
"""
|
||||
if not orders_data:
|
||||
return
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
# 1. Upsert orders
|
||||
await db.executemany("""
|
||||
INSERT INTO orders
|
||||
(order_number, order_date, customer_name, status,
|
||||
id_comanda, id_partener, error_message, missing_skus, items_count,
|
||||
last_sync_run_id, shipping_name, billing_name,
|
||||
payment_method, delivery_method, order_total,
|
||||
delivery_cost, discount_total, web_status, discount_split)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(order_number) DO UPDATE SET
|
||||
customer_name = excluded.customer_name,
|
||||
status = CASE
|
||||
WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED'
|
||||
THEN orders.status
|
||||
ELSE excluded.status
|
||||
END,
|
||||
error_message = excluded.error_message,
|
||||
missing_skus = excluded.missing_skus,
|
||||
items_count = excluded.items_count,
|
||||
id_comanda = COALESCE(excluded.id_comanda, orders.id_comanda),
|
||||
id_partener = COALESCE(excluded.id_partener, orders.id_partener),
|
||||
times_skipped = CASE WHEN excluded.status = 'SKIPPED'
|
||||
THEN orders.times_skipped + 1
|
||||
ELSE orders.times_skipped END,
|
||||
last_sync_run_id = excluded.last_sync_run_id,
|
||||
shipping_name = COALESCE(excluded.shipping_name, orders.shipping_name),
|
||||
billing_name = COALESCE(excluded.billing_name, orders.billing_name),
|
||||
payment_method = COALESCE(excluded.payment_method, orders.payment_method),
|
||||
delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method),
|
||||
order_total = COALESCE(excluded.order_total, orders.order_total),
|
||||
delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost),
|
||||
discount_total = COALESCE(excluded.discount_total, orders.discount_total),
|
||||
web_status = COALESCE(excluded.web_status, orders.web_status),
|
||||
discount_split = COALESCE(excluded.discount_split, orders.discount_split),
|
||||
updated_at = datetime('now')
|
||||
""", [
|
||||
(d["order_number"], d["order_date"], d["customer_name"], d["status"],
|
||||
d.get("id_comanda"), d.get("id_partener"), d.get("error_message"),
|
||||
json.dumps(d["missing_skus"]) if d.get("missing_skus") else None,
|
||||
d.get("items_count", 0), d["sync_run_id"],
|
||||
d.get("shipping_name"), d.get("billing_name"),
|
||||
d.get("payment_method"), d.get("delivery_method"),
|
||||
d.get("order_total"),
|
||||
d.get("delivery_cost"), d.get("discount_total"),
|
||||
d.get("web_status"), d.get("discount_split"))
|
||||
for d in orders_data
|
||||
])
|
||||
|
||||
# 2. Sync run orders
|
||||
await db.executemany("""
|
||||
INSERT OR IGNORE INTO sync_run_orders (sync_run_id, order_number, status_at_run)
|
||||
VALUES (?, ?, ?)
|
||||
""", [(d["sync_run_id"], d["order_number"], d["status_at_run"]) for d in orders_data])
|
||||
|
||||
# 3. Order items
|
||||
all_items = []
|
||||
for d in orders_data:
|
||||
for item in d.get("items", []):
|
||||
all_items.append((
|
||||
d["order_number"],
|
||||
item.get("sku"), item.get("product_name"),
|
||||
item.get("quantity"), item.get("price"), item.get("vat"),
|
||||
item.get("mapping_status"), item.get("codmat"),
|
||||
item.get("id_articol"), item.get("cantitate_roa")
|
||||
))
|
||||
if all_items:
|
||||
await db.executemany("""
|
||||
INSERT OR IGNORE INTO order_items
|
||||
(order_number, sku, product_name, quantity, price, vat,
|
||||
mapping_status, codmat, id_articol, cantitate_roa)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", all_items)
|
||||
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def track_missing_sku(sku: str, product_name: str = "",
|
||||
order_count: int = 0, order_numbers: str = None,
|
||||
customers: str = None):
|
||||
@@ -338,6 +456,25 @@ async def upsert_web_product(sku: str, product_name: str):
|
||||
await db.close()
|
||||
|
||||
|
||||
async def upsert_web_products_batch(items: list[tuple[str, str]]):
|
||||
"""Batch upsert web products in a single transaction. items: list of (sku, product_name)."""
|
||||
if not items:
|
||||
return
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
await db.executemany("""
|
||||
INSERT INTO web_products (sku, product_name, order_count)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(sku) DO UPDATE SET
|
||||
product_name = COALESCE(NULLIF(excluded.product_name, ''), web_products.product_name),
|
||||
last_seen = datetime('now'),
|
||||
order_count = web_products.order_count + 1
|
||||
""", items)
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def get_web_product_name(sku: str) -> str:
|
||||
"""Lookup product name by SKU."""
|
||||
db = await get_sqlite()
|
||||
@@ -444,7 +581,7 @@ async def get_run_orders_filtered(run_id: str, status_filter: str = "all",
|
||||
params = [run_id]
|
||||
|
||||
if status_filter and status_filter != "all":
|
||||
where += " AND UPPER(o.status) = ?"
|
||||
where += " AND UPPER(sro.status_at_run) = ?"
|
||||
params.append(status_filter.upper())
|
||||
|
||||
allowed_sort = {"order_date", "order_number", "customer_name", "items_count",
|
||||
@@ -462,7 +599,7 @@ async def get_run_orders_filtered(run_id: str, status_filter: str = "all",
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
cursor = await db.execute(f"""
|
||||
SELECT o.* FROM orders o
|
||||
SELECT o.*, sro.status_at_run AS run_status FROM orders o
|
||||
INNER JOIN sync_run_orders sro ON sro.order_number = o.order_number
|
||||
{where}
|
||||
ORDER BY o.{sort_by} {sort_dir}
|
||||
@@ -471,16 +608,23 @@ async def get_run_orders_filtered(run_id: str, status_filter: str = "all",
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
cursor = await db.execute("""
|
||||
SELECT o.status, COUNT(*) as cnt
|
||||
SELECT sro.status_at_run AS status, COUNT(*) as cnt
|
||||
FROM orders o
|
||||
INNER JOIN sync_run_orders sro ON sro.order_number = o.order_number
|
||||
WHERE sro.sync_run_id = ?
|
||||
GROUP BY o.status
|
||||
GROUP BY sro.status_at_run
|
||||
""", (run_id,))
|
||||
status_counts = {row["status"]: row["cnt"] for row in await cursor.fetchall()}
|
||||
|
||||
# Use run_status (status_at_run) as the status field for each order row
|
||||
order_rows = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["status"] = d.pop("run_status", d.get("status"))
|
||||
order_rows.append(d)
|
||||
|
||||
return {
|
||||
"orders": [dict(r) for r in rows],
|
||||
"orders": order_rows,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
@@ -489,6 +633,8 @@ async def get_run_orders_filtered(run_id: str, status_filter: str = "all",
|
||||
"imported": status_counts.get("IMPORTED", 0),
|
||||
"skipped": status_counts.get("SKIPPED", 0),
|
||||
"error": status_counts.get("ERROR", 0),
|
||||
"already_imported": status_counts.get("ALREADY_IMPORTED", 0),
|
||||
"cancelled": status_counts.get("CANCELLED", 0),
|
||||
"total": sum(status_counts.values())
|
||||
}
|
||||
}
|
||||
@@ -508,25 +654,34 @@ async def get_orders(page: int = 1, per_page: int = 50,
|
||||
"""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
where_clauses = []
|
||||
params = []
|
||||
# Period + search clauses (used for counts — never include status filter)
|
||||
base_clauses = []
|
||||
base_params = []
|
||||
|
||||
if period_days and period_days > 0:
|
||||
where_clauses.append("order_date >= date('now', ?)")
|
||||
params.append(f"-{period_days} days")
|
||||
base_clauses.append("order_date >= date('now', ?)")
|
||||
base_params.append(f"-{period_days} days")
|
||||
elif period_days == 0 and period_start and period_end:
|
||||
where_clauses.append("order_date BETWEEN ? AND ?")
|
||||
params.extend([period_start, period_end])
|
||||
base_clauses.append("order_date BETWEEN ? AND ?")
|
||||
base_params.extend([period_start, period_end])
|
||||
|
||||
if search:
|
||||
where_clauses.append("(order_number LIKE ? OR customer_name LIKE ?)")
|
||||
params.extend([f"%{search}%", f"%{search}%"])
|
||||
base_clauses.append("(order_number LIKE ? OR customer_name LIKE ?)")
|
||||
base_params.extend([f"%{search}%", f"%{search}%"])
|
||||
|
||||
# Data query adds status filter on top of base filters
|
||||
data_clauses = list(base_clauses)
|
||||
data_params = list(base_params)
|
||||
|
||||
if status_filter and status_filter not in ("all", "UNINVOICED"):
|
||||
where_clauses.append("UPPER(status) = ?")
|
||||
params.append(status_filter.upper())
|
||||
if status_filter.upper() == "IMPORTED":
|
||||
data_clauses.append("UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')")
|
||||
else:
|
||||
data_clauses.append("UPPER(status) = ?")
|
||||
data_params.append(status_filter.upper())
|
||||
|
||||
where = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
||||
where = ("WHERE " + " AND ".join(data_clauses)) if data_clauses else ""
|
||||
counts_where = ("WHERE " + " AND ".join(base_clauses)) if base_clauses else ""
|
||||
|
||||
allowed_sort = {"order_date", "order_number", "customer_name", "items_count",
|
||||
"status", "first_seen_at", "updated_at"}
|
||||
@@ -535,7 +690,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
|
||||
if sort_dir.lower() not in ("asc", "desc"):
|
||||
sort_dir = "desc"
|
||||
|
||||
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {where}", params)
|
||||
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {where}", data_params)
|
||||
total = (await cursor.fetchone())[0]
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
@@ -544,17 +699,26 @@ async def get_orders(page: int = 1, per_page: int = 50,
|
||||
{where}
|
||||
ORDER BY {sort_by} {sort_dir}
|
||||
LIMIT ? OFFSET ?
|
||||
""", params + [per_page, offset])
|
||||
""", data_params + [per_page, offset])
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
# Counts by status (on full period, not just this page)
|
||||
# Counts by status — always on full period+search, never filtered by status
|
||||
cursor = await db.execute(f"""
|
||||
SELECT status, COUNT(*) as cnt FROM orders
|
||||
{where}
|
||||
{counts_where}
|
||||
GROUP BY status
|
||||
""", params)
|
||||
""", base_params)
|
||||
status_counts = {row["status"]: row["cnt"] for row in await cursor.fetchall()}
|
||||
|
||||
# Uninvoiced count: IMPORTED/ALREADY_IMPORTED with no cached invoice, same period+search
|
||||
uninv_clauses = list(base_clauses) + [
|
||||
"UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')",
|
||||
"(factura_numar IS NULL OR factura_numar = '')",
|
||||
]
|
||||
uninv_where = "WHERE " + " AND ".join(uninv_clauses)
|
||||
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_where}", base_params)
|
||||
uninvoiced_sqlite = (await cursor.fetchone())[0]
|
||||
|
||||
return {
|
||||
"orders": [dict(r) for r in rows],
|
||||
"total": total,
|
||||
@@ -563,9 +727,13 @@ async def get_orders(page: int = 1, per_page: int = 50,
|
||||
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
|
||||
"counts": {
|
||||
"imported": status_counts.get("IMPORTED", 0),
|
||||
"already_imported": status_counts.get("ALREADY_IMPORTED", 0),
|
||||
"imported_all": status_counts.get("IMPORTED", 0) + status_counts.get("ALREADY_IMPORTED", 0),
|
||||
"skipped": status_counts.get("SKIPPED", 0),
|
||||
"error": status_counts.get("ERROR", 0),
|
||||
"total": sum(status_counts.values())
|
||||
"cancelled": status_counts.get("CANCELLED", 0),
|
||||
"total": sum(status_counts.values()),
|
||||
"uninvoiced_sqlite": uninvoiced_sqlite,
|
||||
}
|
||||
}
|
||||
finally:
|
||||
@@ -588,3 +756,174 @@ async def update_import_order_addresses(order_number: str,
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
# ── Invoice cache ────────────────────────────────
|
||||
|
||||
async def get_uninvoiced_imported_orders() -> list:
|
||||
"""Get all imported orders that don't yet have invoice data cached."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
cursor = await db.execute("""
|
||||
SELECT order_number, id_comanda FROM orders
|
||||
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
|
||||
AND id_comanda IS NOT NULL
|
||||
AND factura_numar IS NULL
|
||||
""")
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def update_order_invoice(order_number: str, serie: str = None,
|
||||
numar: str = None, total_fara_tva: float = None,
|
||||
total_tva: float = None, total_cu_tva: float = None,
|
||||
data_act: str = None):
|
||||
"""Cache invoice data from Oracle onto the order record."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
await db.execute("""
|
||||
UPDATE orders SET
|
||||
factura_serie = ?,
|
||||
factura_numar = ?,
|
||||
factura_total_fara_tva = ?,
|
||||
factura_total_tva = ?,
|
||||
factura_total_cu_tva = ?,
|
||||
factura_data = ?,
|
||||
invoice_checked_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE order_number = ?
|
||||
""", (serie, numar, total_fara_tva, total_tva, total_cu_tva, data_act, order_number))
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def get_invoiced_imported_orders() -> list:
|
||||
"""Get imported orders that HAVE cached invoice data (for re-verification)."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
cursor = await db.execute("""
|
||||
SELECT order_number, id_comanda FROM orders
|
||||
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
|
||||
AND id_comanda IS NOT NULL
|
||||
AND factura_numar IS NOT NULL AND factura_numar != ''
|
||||
""")
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def get_all_imported_orders() -> list:
|
||||
"""Get ALL imported orders with id_comanda (for checking if deleted in ROA)."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
cursor = await db.execute("""
|
||||
SELECT order_number, id_comanda FROM orders
|
||||
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
|
||||
AND id_comanda IS NOT NULL
|
||||
""")
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def clear_order_invoice(order_number: str):
|
||||
"""Clear cached invoice data when invoice was deleted in ROA."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
await db.execute("""
|
||||
UPDATE orders SET
|
||||
factura_serie = NULL,
|
||||
factura_numar = NULL,
|
||||
factura_total_fara_tva = NULL,
|
||||
factura_total_tva = NULL,
|
||||
factura_total_cu_tva = NULL,
|
||||
factura_data = NULL,
|
||||
invoice_checked_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE order_number = ?
|
||||
""", (order_number,))
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def mark_order_deleted_in_roa(order_number: str):
|
||||
"""Mark an order as deleted in ROA — clears id_comanda and invoice cache."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
await db.execute("""
|
||||
UPDATE orders SET
|
||||
status = 'DELETED_IN_ROA',
|
||||
id_comanda = NULL,
|
||||
id_partener = NULL,
|
||||
factura_serie = NULL,
|
||||
factura_numar = NULL,
|
||||
factura_total_fara_tva = NULL,
|
||||
factura_total_tva = NULL,
|
||||
factura_total_cu_tva = NULL,
|
||||
factura_data = NULL,
|
||||
invoice_checked_at = NULL,
|
||||
error_message = 'Comanda stearsa din ROA',
|
||||
updated_at = datetime('now')
|
||||
WHERE order_number = ?
|
||||
""", (order_number,))
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def mark_order_cancelled(order_number: str, web_status: str = "Anulata"):
|
||||
"""Mark an order as cancelled from GoMag. Clears id_comanda and invoice cache."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
await db.execute("""
|
||||
UPDATE orders SET
|
||||
status = 'CANCELLED',
|
||||
id_comanda = NULL,
|
||||
id_partener = NULL,
|
||||
factura_serie = NULL,
|
||||
factura_numar = NULL,
|
||||
factura_total_fara_tva = NULL,
|
||||
factura_total_tva = NULL,
|
||||
factura_total_cu_tva = NULL,
|
||||
factura_data = NULL,
|
||||
invoice_checked_at = NULL,
|
||||
web_status = ?,
|
||||
error_message = 'Comanda anulata in GoMag',
|
||||
updated_at = datetime('now')
|
||||
WHERE order_number = ?
|
||||
""", (web_status, order_number))
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
# ── App Settings ─────────────────────────────────
|
||||
|
||||
async def get_app_settings() -> dict:
|
||||
"""Get all app settings as a dict."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
cursor = await db.execute("SELECT key, value FROM app_settings")
|
||||
rows = await cursor.fetchall()
|
||||
return {row["key"]: row["value"] for row in rows}
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def set_app_setting(key: str, value: str):
|
||||
"""Set a single app setting value."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
await db.execute("""
|
||||
INSERT OR REPLACE INTO app_settings (key, value)
|
||||
VALUES (?, ?)
|
||||
""", (key, value))
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
@@ -2,10 +2,19 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from . import order_reader, validation_service, import_service, sqlite_service
|
||||
_tz_bucharest = ZoneInfo("Europe/Bucharest")
|
||||
|
||||
|
||||
def _now():
|
||||
"""Return current time in Bucharest timezone (naive, for display/storage)."""
|
||||
return datetime.now(_tz_bucharest).replace(tzinfo=None)
|
||||
|
||||
from . import order_reader, validation_service, import_service, sqlite_service, invoice_service, gomag_client
|
||||
from ..config import settings
|
||||
from .. import database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,7 +30,7 @@ def _log_line(run_id: str, message: str):
|
||||
"""Append a timestamped line to the in-memory log buffer."""
|
||||
if run_id not in _run_logs:
|
||||
_run_logs[run_id] = []
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
ts = _now().strftime("%H:%M:%S")
|
||||
_run_logs[run_id].append(f"[{ts}] {message}")
|
||||
|
||||
|
||||
@@ -43,7 +52,7 @@ def _update_progress(phase: str, phase_text: str, current: int = 0, total: int =
|
||||
_current_sync["phase_text"] = phase_text
|
||||
_current_sync["progress_current"] = current
|
||||
_current_sync["progress_total"] = total
|
||||
_current_sync["counts"] = counts or {"imported": 0, "skipped": 0, "errors": 0}
|
||||
_current_sync["counts"] = counts or {"imported": 0, "skipped": 0, "errors": 0, "already_imported": 0}
|
||||
|
||||
|
||||
async def get_sync_status():
|
||||
@@ -61,21 +70,76 @@ async def prepare_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
||||
if _sync_lock.locked():
|
||||
return {"error": "Sync already running", "run_id": _current_sync.get("run_id") if _current_sync else None}
|
||||
|
||||
run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
|
||||
run_id = _now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
|
||||
_current_sync = {
|
||||
"run_id": run_id,
|
||||
"status": "running",
|
||||
"started_at": datetime.now().isoformat(),
|
||||
"started_at": _now().isoformat(),
|
||||
"finished_at": None,
|
||||
"phase": "starting",
|
||||
"phase_text": "Starting...",
|
||||
"progress_current": 0,
|
||||
"progress_total": 0,
|
||||
"counts": {"imported": 0, "skipped": 0, "errors": 0},
|
||||
"counts": {"imported": 0, "skipped": 0, "errors": 0, "already_imported": 0, "cancelled": 0},
|
||||
}
|
||||
return {"run_id": run_id, "status": "starting"}
|
||||
|
||||
|
||||
def _derive_customer_info(order):
|
||||
"""Extract shipping/billing names and customer from an order.
|
||||
customer = who appears on the invoice (partner in ROA):
|
||||
- company name if billing is on a company
|
||||
- shipping person name otherwise (consistent with import_service partner logic)
|
||||
"""
|
||||
shipping_name = ""
|
||||
if order.shipping:
|
||||
shipping_name = f"{getattr(order.shipping, 'firstname', '') or ''} {getattr(order.shipping, 'lastname', '') or ''}".strip()
|
||||
billing_name = f"{getattr(order.billing, 'firstname', '') or ''} {getattr(order.billing, 'lastname', '') or ''}".strip()
|
||||
if not shipping_name:
|
||||
shipping_name = billing_name
|
||||
if order.billing.is_company and order.billing.company_name:
|
||||
customer = order.billing.company_name
|
||||
else:
|
||||
customer = shipping_name or billing_name
|
||||
payment_method = getattr(order, 'payment_name', None) or None
|
||||
delivery_method = getattr(order, 'delivery_name', None) or None
|
||||
return shipping_name, billing_name, customer, payment_method, delivery_method
|
||||
|
||||
|
||||
async def _fix_stale_error_orders(existing_map: dict, run_id: str):
|
||||
"""Fix orders stuck in ERROR status that are actually in Oracle.
|
||||
|
||||
This can happen when a previous import committed partially (no rollback on error).
|
||||
If the order exists in Oracle COMENZI, update SQLite status to ALREADY_IMPORTED.
|
||||
"""
|
||||
from ..database import get_sqlite
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"SELECT order_number FROM orders WHERE status = 'ERROR'"
|
||||
)
|
||||
error_orders = [row["order_number"] for row in await cursor.fetchall()]
|
||||
fixed = 0
|
||||
for order_number in error_orders:
|
||||
if order_number in existing_map:
|
||||
id_comanda = existing_map[order_number]
|
||||
await db.execute("""
|
||||
UPDATE orders SET
|
||||
status = 'ALREADY_IMPORTED',
|
||||
id_comanda = ?,
|
||||
error_message = NULL,
|
||||
updated_at = datetime('now')
|
||||
WHERE order_number = ? AND status = 'ERROR'
|
||||
""", (id_comanda, order_number))
|
||||
fixed += 1
|
||||
_log_line(run_id, f"#{order_number} → status corectat ERROR → ALREADY_IMPORTED (ID: {id_comanda})")
|
||||
if fixed:
|
||||
await db.commit()
|
||||
logger.info(f"Fixed {fixed} stale ERROR orders that exist in Oracle")
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None) -> dict:
|
||||
"""Run a full sync cycle. Returns summary dict."""
|
||||
global _current_sync
|
||||
@@ -86,30 +150,53 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
||||
async with _sync_lock:
|
||||
# Use provided run_id or generate one
|
||||
if not run_id:
|
||||
run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
|
||||
run_id = _now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
|
||||
_current_sync = {
|
||||
"run_id": run_id,
|
||||
"status": "running",
|
||||
"started_at": datetime.now().isoformat(),
|
||||
"started_at": _now().isoformat(),
|
||||
"finished_at": None,
|
||||
"phase": "reading",
|
||||
"phase_text": "Reading JSON files...",
|
||||
"progress_current": 0,
|
||||
"progress_total": 0,
|
||||
"counts": {"imported": 0, "skipped": 0, "errors": 0},
|
||||
"counts": {"imported": 0, "skipped": 0, "errors": 0, "already_imported": 0, "cancelled": 0},
|
||||
}
|
||||
|
||||
_update_progress("reading", "Reading JSON files...")
|
||||
|
||||
started_dt = datetime.now()
|
||||
started_dt = _now()
|
||||
_run_logs[run_id] = [
|
||||
f"=== Sync Run {run_id} ===",
|
||||
f"Inceput: {started_dt.strftime('%d.%m.%Y %H:%M:%S')}",
|
||||
""
|
||||
]
|
||||
_log_line(run_id, "Citire fisiere JSON...")
|
||||
|
||||
json_dir = settings.JSON_OUTPUT_DIR
|
||||
|
||||
try:
|
||||
# Phase 0: Download orders from GoMag API
|
||||
_update_progress("downloading", "Descărcare comenzi din GoMag API...")
|
||||
_log_line(run_id, "Descărcare comenzi din GoMag API...")
|
||||
# Read GoMag settings from SQLite (override config defaults)
|
||||
dl_settings = await sqlite_service.get_app_settings()
|
||||
gomag_key = dl_settings.get("gomag_api_key") or None
|
||||
gomag_shop = dl_settings.get("gomag_api_shop") or None
|
||||
gomag_days_str = dl_settings.get("gomag_order_days_back")
|
||||
gomag_days = int(gomag_days_str) if gomag_days_str else None
|
||||
gomag_limit_str = dl_settings.get("gomag_limit")
|
||||
gomag_limit = int(gomag_limit_str) if gomag_limit_str else None
|
||||
dl_result = await gomag_client.download_orders(
|
||||
json_dir, log_fn=lambda msg: _log_line(run_id, msg),
|
||||
api_key=gomag_key, api_shop=gomag_shop,
|
||||
days_back=gomag_days, limit=gomag_limit,
|
||||
)
|
||||
if dl_result["files"]:
|
||||
_log_line(run_id, f"GoMag: {dl_result['total']} comenzi în {dl_result['pages']} pagini → {len(dl_result['files'])} fișiere")
|
||||
|
||||
_update_progress("reading", "Citire fisiere JSON...")
|
||||
_log_line(run_id, "Citire fisiere JSON...")
|
||||
|
||||
# Step 1: Read orders and sort chronologically (oldest first - R3)
|
||||
orders, json_count = order_reader.read_json_orders()
|
||||
orders.sort(key=lambda o: o.date or '')
|
||||
@@ -118,10 +205,13 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
||||
_log_line(run_id, f"Gasite {len(orders)} comenzi in {json_count} fisiere")
|
||||
|
||||
# Populate web_products catalog from all orders (R4)
|
||||
for order in orders:
|
||||
for item in order.items:
|
||||
if item.sku and item.name:
|
||||
await sqlite_service.upsert_web_product(item.sku, item.name)
|
||||
web_product_items = [
|
||||
(item.sku, item.name)
|
||||
for order in orders
|
||||
for item in order.items
|
||||
if item.sku and item.name
|
||||
]
|
||||
await sqlite_service.upsert_web_products_batch(web_product_items)
|
||||
|
||||
if not orders:
|
||||
_log_line(run_id, "Nicio comanda gasita.")
|
||||
@@ -130,28 +220,170 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
||||
summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count}
|
||||
return summary
|
||||
|
||||
_update_progress("validation", f"Validating {len(orders)} orders...", 0, len(orders))
|
||||
# ── Separate cancelled orders (GoMag status "Anulata" / statusId "7") ──
|
||||
cancelled_orders = [o for o in orders if o.status_id == "7" or (o.status and o.status.lower() == "anulata")]
|
||||
active_orders = [o for o in orders if o not in cancelled_orders]
|
||||
cancelled_count = len(cancelled_orders)
|
||||
|
||||
# Step 2a: Find new orders (not yet in Oracle)
|
||||
all_order_numbers = [o.number for o in orders]
|
||||
new_orders = await asyncio.to_thread(
|
||||
validation_service.find_new_orders, all_order_numbers
|
||||
if cancelled_orders:
|
||||
_log_line(run_id, f"Comenzi anulate in GoMag: {cancelled_count}")
|
||||
|
||||
# Record cancelled orders in SQLite
|
||||
cancelled_batch = []
|
||||
for order in cancelled_orders:
|
||||
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
|
||||
order_items_data = [
|
||||
{"sku": item.sku, "product_name": item.name,
|
||||
"quantity": item.quantity, "price": item.price, "vat": item.vat,
|
||||
"mapping_status": "unknown", "codmat": None,
|
||||
"id_articol": None, "cantitate_roa": None}
|
||||
for item in order.items
|
||||
]
|
||||
cancelled_batch.append({
|
||||
"sync_run_id": run_id, "order_number": order.number,
|
||||
"order_date": order.date, "customer_name": customer,
|
||||
"status": "CANCELLED", "status_at_run": "CANCELLED",
|
||||
"id_comanda": None, "id_partener": None,
|
||||
"error_message": "Comanda anulata in GoMag",
|
||||
"missing_skus": None,
|
||||
"items_count": len(order.items),
|
||||
"shipping_name": shipping_name, "billing_name": billing_name,
|
||||
"payment_method": payment_method, "delivery_method": delivery_method,
|
||||
"order_total": order.total or None,
|
||||
"delivery_cost": order.delivery_cost or None,
|
||||
"discount_total": order.discount_total or None,
|
||||
"web_status": order.status or "Anulata",
|
||||
"items": order_items_data,
|
||||
})
|
||||
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → ANULAT in GoMag")
|
||||
|
||||
await sqlite_service.save_orders_batch(cancelled_batch)
|
||||
|
||||
# Check if any cancelled orders were previously imported
|
||||
from ..database import get_sqlite as _get_sqlite
|
||||
db_check = await _get_sqlite()
|
||||
try:
|
||||
cancelled_numbers = [o.number for o in cancelled_orders]
|
||||
placeholders = ",".join("?" for _ in cancelled_numbers)
|
||||
cursor = await db_check.execute(f"""
|
||||
SELECT order_number, id_comanda FROM orders
|
||||
WHERE order_number IN ({placeholders})
|
||||
AND id_comanda IS NOT NULL
|
||||
AND status = 'CANCELLED'
|
||||
""", cancelled_numbers)
|
||||
previously_imported = [dict(r) for r in await cursor.fetchall()]
|
||||
finally:
|
||||
await db_check.close()
|
||||
|
||||
if previously_imported:
|
||||
_log_line(run_id, f"Verificare {len(previously_imported)} comenzi anulate care erau importate in Oracle...")
|
||||
# Check which have invoices
|
||||
id_comanda_list = [o["id_comanda"] for o in previously_imported]
|
||||
invoice_data = await asyncio.to_thread(
|
||||
invoice_service.check_invoices_for_orders, id_comanda_list
|
||||
)
|
||||
|
||||
# Step 2b: Validate SKUs (blocking Oracle call -> run in thread)
|
||||
for o in previously_imported:
|
||||
idc = o["id_comanda"]
|
||||
order_num = o["order_number"]
|
||||
if idc in invoice_data:
|
||||
# Invoiced — keep in Oracle, just log warning
|
||||
_log_line(run_id,
|
||||
f"#{order_num} → ANULAT dar FACTURAT (factura {invoice_data[idc].get('serie_act', '')}"
|
||||
f"{invoice_data[idc].get('numar_act', '')}) — NU se sterge din Oracle")
|
||||
# Update web_status but keep CANCELLED status (already set by batch above)
|
||||
else:
|
||||
# Not invoiced — soft-delete in Oracle
|
||||
del_result = await asyncio.to_thread(
|
||||
import_service.soft_delete_order_in_roa, idc
|
||||
)
|
||||
if del_result["success"]:
|
||||
# Clear id_comanda via mark_order_cancelled
|
||||
await sqlite_service.mark_order_cancelled(order_num, "Anulata")
|
||||
_log_line(run_id,
|
||||
f"#{order_num} → ANULAT + STERS din Oracle (ID: {idc}, "
|
||||
f"{del_result['details_deleted']} detalii)")
|
||||
else:
|
||||
_log_line(run_id,
|
||||
f"#{order_num} → ANULAT dar EROARE la stergere Oracle: {del_result['error']}")
|
||||
|
||||
orders = active_orders
|
||||
|
||||
if not orders:
|
||||
_log_line(run_id, "Nicio comanda activa dupa filtrare anulate.")
|
||||
await sqlite_service.update_sync_run(run_id, "completed", cancelled_count, 0, 0, 0)
|
||||
_update_progress("completed", f"No active orders ({cancelled_count} cancelled)")
|
||||
summary = {"run_id": run_id, "status": "completed",
|
||||
"message": f"No active orders ({cancelled_count} cancelled)",
|
||||
"json_files": json_count, "cancelled": cancelled_count}
|
||||
return summary
|
||||
|
||||
_update_progress("validation", f"Validating {len(orders)} orders...", 0, len(orders))
|
||||
|
||||
# ── Single Oracle connection for entire validation phase ──
|
||||
conn = await asyncio.to_thread(database.get_oracle_connection)
|
||||
try:
|
||||
# Step 2a: Find orders already in Oracle (date-range query)
|
||||
order_dates = [o.date for o in orders if o.date]
|
||||
if order_dates:
|
||||
min_date_str = min(order_dates)
|
||||
try:
|
||||
min_date = datetime.strptime(min_date_str[:10], "%Y-%m-%d") - timedelta(days=1)
|
||||
except (ValueError, TypeError):
|
||||
min_date = _now() - timedelta(days=90)
|
||||
else:
|
||||
min_date = _now() - timedelta(days=90)
|
||||
|
||||
existing_map = await asyncio.to_thread(
|
||||
validation_service.check_orders_in_roa, min_date, conn
|
||||
)
|
||||
|
||||
# Step 2a-fix: Fix ERROR orders that are actually in Oracle
|
||||
# (can happen if previous import committed partially without rollback)
|
||||
await _fix_stale_error_orders(existing_map, run_id)
|
||||
|
||||
# Load app settings early (needed for id_gestiune in SKU validation)
|
||||
app_settings = await sqlite_service.get_app_settings()
|
||||
id_pol = id_pol or int(app_settings.get("id_pol") or 0) or settings.ID_POL
|
||||
id_sectie = id_sectie or int(app_settings.get("id_sectie") or 0) or settings.ID_SECTIE
|
||||
# Parse multi-gestiune CSV: "1,3" → [1, 3], "" → None
|
||||
id_gestiune_raw = (app_settings.get("id_gestiune") or "").strip()
|
||||
if id_gestiune_raw and id_gestiune_raw != "0":
|
||||
id_gestiuni = [int(g) for g in id_gestiune_raw.split(",") if g.strip()]
|
||||
else:
|
||||
id_gestiuni = None # None = orice gestiune
|
||||
logger.info(f"Sync params: ID_POL={id_pol}, ID_SECTIE={id_sectie}, ID_GESTIUNI={id_gestiuni}")
|
||||
_log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}, ID_GESTIUNI={id_gestiuni}")
|
||||
|
||||
# Step 2b: Validate SKUs (reuse same connection)
|
||||
all_skus = order_reader.get_all_skus(orders)
|
||||
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus)
|
||||
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus, conn, id_gestiuni)
|
||||
importable, skipped = validation_service.classify_orders(orders, validation)
|
||||
|
||||
_update_progress("validation", f"{len(importable)} importable, {len(skipped)} skipped (missing SKUs)",
|
||||
0, len(importable))
|
||||
_log_line(run_id, f"Validare SKU-uri: {len(importable)} importabile, {len(skipped)} nemapate")
|
||||
# ── Split importable into truly_importable vs already_in_roa ──
|
||||
truly_importable = []
|
||||
already_in_roa = []
|
||||
for order in importable:
|
||||
if order.number in existing_map:
|
||||
already_in_roa.append(order)
|
||||
else:
|
||||
truly_importable.append(order)
|
||||
|
||||
_update_progress("validation",
|
||||
f"{len(truly_importable)} new, {len(already_in_roa)} already imported, {len(skipped)} skipped",
|
||||
0, len(truly_importable))
|
||||
_log_line(run_id, f"Validare: {len(truly_importable)} noi, {len(already_in_roa)} deja importate, {len(skipped)} nemapate")
|
||||
|
||||
# Step 2c: Build SKU context from skipped orders
|
||||
sku_context = {} # {sku: {"orders": [], "customers": []}}
|
||||
sku_context = {}
|
||||
for order, missing_skus_list in skipped:
|
||||
customer = order.billing.company_name or \
|
||||
f"{order.billing.firstname} {order.billing.lastname}"
|
||||
if order.billing.is_company and order.billing.company_name:
|
||||
customer = order.billing.company_name
|
||||
else:
|
||||
ship_name = ""
|
||||
if order.shipping:
|
||||
ship_name = f"{order.shipping.firstname} {order.shipping.lastname}".strip()
|
||||
customer = ship_name or f"{order.billing.firstname} {order.billing.lastname}"
|
||||
for sku in missing_skus_list:
|
||||
if sku not in sku_context:
|
||||
sku_context[sku] = {"orders": [], "customers": []}
|
||||
@@ -179,26 +411,40 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
||||
)
|
||||
|
||||
# Step 2d: Pre-validate prices for importable articles
|
||||
id_pol = id_pol or settings.ID_POL
|
||||
id_sectie = id_sectie or settings.ID_SECTIE
|
||||
logger.info(f"Sync params: ID_POL={id_pol}, ID_SECTIE={id_sectie}")
|
||||
_log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}")
|
||||
if id_pol and importable:
|
||||
_update_progress("validation", "Validating prices...", 0, len(importable))
|
||||
if id_pol and (truly_importable or already_in_roa):
|
||||
_update_progress("validation", "Validating prices...", 0, len(truly_importable))
|
||||
_log_line(run_id, "Validare preturi...")
|
||||
# Gather all CODMATs from importable orders
|
||||
all_codmats = set()
|
||||
for order in importable:
|
||||
for order in (truly_importable + already_in_roa):
|
||||
for item in order.items:
|
||||
if item.sku in validation["mapped"]:
|
||||
# Mapped SKUs resolve to codmat via ARTICOLE_TERTI (handled by import)
|
||||
pass
|
||||
elif item.sku in validation["direct"]:
|
||||
all_codmats.add(item.sku)
|
||||
# For mapped SKUs, we'd need the ARTICOLE_TERTI lookup - direct SKUs = codmat
|
||||
# Get standard VAT rate from settings for PROC_TVAV metadata
|
||||
cota_tva = float(app_settings.get("discount_vat") or 21)
|
||||
|
||||
# Dual pricing policy support
|
||||
id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None
|
||||
codmat_policy_map = {}
|
||||
|
||||
if all_codmats:
|
||||
if id_pol_productie:
|
||||
# Dual-policy: classify articles by cont (sales vs production)
|
||||
codmat_policy_map = await asyncio.to_thread(
|
||||
validation_service.validate_and_ensure_prices_dual,
|
||||
all_codmats, id_pol, id_pol_productie,
|
||||
conn, validation.get("direct_id_map"),
|
||||
cota_tva=cota_tva
|
||||
)
|
||||
_log_line(run_id,
|
||||
f"Politici duale: {sum(1 for v in codmat_policy_map.values() if v == id_pol)} vanzare, "
|
||||
f"{sum(1 for v in codmat_policy_map.values() if v == id_pol_productie)} productie")
|
||||
else:
|
||||
# Single-policy (backward compatible)
|
||||
price_result = await asyncio.to_thread(
|
||||
validation_service.validate_prices, all_codmats, id_pol
|
||||
validation_service.validate_prices, all_codmats, id_pol,
|
||||
conn, validation.get("direct_id_map")
|
||||
)
|
||||
if price_result["missing_price"]:
|
||||
logger.info(
|
||||
@@ -207,79 +453,139 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
validation_service.ensure_prices,
|
||||
price_result["missing_price"], id_pol
|
||||
price_result["missing_price"], id_pol,
|
||||
conn, validation.get("direct_id_map"),
|
||||
cota_tva=cota_tva
|
||||
)
|
||||
|
||||
# Step 3: Record skipped orders + store items
|
||||
skipped_count = 0
|
||||
for order, missing_skus in skipped:
|
||||
skipped_count += 1
|
||||
# Derive shipping / billing names
|
||||
shipping_name = ""
|
||||
if order.shipping:
|
||||
shipping_name = f"{getattr(order.shipping, 'firstname', '') or ''} {getattr(order.shipping, 'lastname', '') or ''}".strip()
|
||||
billing_name = f"{getattr(order.billing, 'firstname', '') or ''} {getattr(order.billing, 'lastname', '') or ''}".strip()
|
||||
if not shipping_name:
|
||||
shipping_name = billing_name
|
||||
customer = shipping_name or order.billing.company_name or billing_name
|
||||
payment_method = getattr(order, 'payment_name', None) or None
|
||||
delivery_method = getattr(order, 'delivery_name', None) or None
|
||||
|
||||
await sqlite_service.upsert_order(
|
||||
sync_run_id=run_id,
|
||||
order_number=order.number,
|
||||
order_date=order.date,
|
||||
customer_name=customer,
|
||||
status="SKIPPED",
|
||||
missing_skus=missing_skus,
|
||||
items_count=len(order.items),
|
||||
shipping_name=shipping_name,
|
||||
billing_name=billing_name,
|
||||
payment_method=payment_method,
|
||||
delivery_method=delivery_method,
|
||||
)
|
||||
await sqlite_service.add_sync_run_order(run_id, order.number, "SKIPPED")
|
||||
# Store order items with mapping status (R9)
|
||||
order_items_data = []
|
||||
# Also validate mapped SKU prices (cherry-pick 1)
|
||||
mapped_skus_in_orders = set()
|
||||
for order in (truly_importable + already_in_roa):
|
||||
for item in order.items:
|
||||
ms = "missing" if item.sku in validation["missing"] else \
|
||||
"mapped" if item.sku in validation["mapped"] else "direct"
|
||||
order_items_data.append({
|
||||
"sku": item.sku, "product_name": item.name,
|
||||
"quantity": item.quantity, "price": item.price, "vat": item.vat,
|
||||
"mapping_status": ms, "codmat": None, "id_articol": None,
|
||||
"cantitate_roa": None
|
||||
})
|
||||
await sqlite_service.add_order_items(order.number, order_items_data)
|
||||
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})")
|
||||
_update_progress("skipped", f"Skipped {skipped_count}/{len(skipped)}: #{order.number} {customer}",
|
||||
0, len(importable),
|
||||
{"imported": 0, "skipped": skipped_count, "errors": 0})
|
||||
if item.sku in validation["mapped"]:
|
||||
mapped_skus_in_orders.add(item.sku)
|
||||
|
||||
# Step 4: Import valid orders
|
||||
if mapped_skus_in_orders:
|
||||
mapped_codmat_data = await asyncio.to_thread(
|
||||
validation_service.resolve_mapped_codmats, mapped_skus_in_orders, conn
|
||||
)
|
||||
# Build id_map for mapped codmats and validate/ensure their prices
|
||||
mapped_id_map = {}
|
||||
for sku, entries in mapped_codmat_data.items():
|
||||
for entry in entries:
|
||||
mapped_id_map[entry["codmat"]] = {
|
||||
"id_articol": entry["id_articol"],
|
||||
"cont": entry.get("cont")
|
||||
}
|
||||
mapped_codmats = set(mapped_id_map.keys())
|
||||
if mapped_codmats:
|
||||
if id_pol_productie:
|
||||
mapped_policy_map = await asyncio.to_thread(
|
||||
validation_service.validate_and_ensure_prices_dual,
|
||||
mapped_codmats, id_pol, id_pol_productie,
|
||||
conn, mapped_id_map, cota_tva=cota_tva
|
||||
)
|
||||
codmat_policy_map.update(mapped_policy_map)
|
||||
else:
|
||||
mp_result = await asyncio.to_thread(
|
||||
validation_service.validate_prices,
|
||||
mapped_codmats, id_pol, conn, mapped_id_map
|
||||
)
|
||||
if mp_result["missing_price"]:
|
||||
await asyncio.to_thread(
|
||||
validation_service.ensure_prices,
|
||||
mp_result["missing_price"], id_pol,
|
||||
conn, mapped_id_map, cota_tva=cota_tva
|
||||
)
|
||||
|
||||
# Pass codmat_policy_map to import via app_settings
|
||||
if codmat_policy_map:
|
||||
app_settings["_codmat_policy_map"] = codmat_policy_map
|
||||
finally:
|
||||
await asyncio.to_thread(database.pool.release, conn)
|
||||
|
||||
# Step 3a: Record already-imported orders (batch)
|
||||
already_imported_count = len(already_in_roa)
|
||||
already_batch = []
|
||||
for order in already_in_roa:
|
||||
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
|
||||
id_comanda_roa = existing_map.get(order.number)
|
||||
order_items_data = [
|
||||
{"sku": item.sku, "product_name": item.name,
|
||||
"quantity": item.quantity, "price": item.price, "vat": item.vat,
|
||||
"mapping_status": "mapped" if item.sku in validation["mapped"] else "direct",
|
||||
"codmat": None, "id_articol": None, "cantitate_roa": None}
|
||||
for item in order.items
|
||||
]
|
||||
already_batch.append({
|
||||
"sync_run_id": run_id, "order_number": order.number,
|
||||
"order_date": order.date, "customer_name": customer,
|
||||
"status": "ALREADY_IMPORTED", "status_at_run": "ALREADY_IMPORTED",
|
||||
"id_comanda": id_comanda_roa, "id_partener": None,
|
||||
"error_message": None, "missing_skus": None,
|
||||
"items_count": len(order.items),
|
||||
"shipping_name": shipping_name, "billing_name": billing_name,
|
||||
"payment_method": payment_method, "delivery_method": delivery_method,
|
||||
"order_total": order.total or None,
|
||||
"delivery_cost": order.delivery_cost or None,
|
||||
"discount_total": order.discount_total or None,
|
||||
"web_status": order.status or None,
|
||||
"items": order_items_data,
|
||||
})
|
||||
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → DEJA IMPORTAT (ID: {id_comanda_roa})")
|
||||
await sqlite_service.save_orders_batch(already_batch)
|
||||
|
||||
# Step 3b: Record skipped orders + store items (batch)
|
||||
skipped_count = len(skipped)
|
||||
skipped_batch = []
|
||||
for order, missing_skus in skipped:
|
||||
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
|
||||
order_items_data = [
|
||||
{"sku": item.sku, "product_name": item.name,
|
||||
"quantity": item.quantity, "price": item.price, "vat": item.vat,
|
||||
"mapping_status": "missing" if item.sku in validation["missing"] else
|
||||
"mapped" if item.sku in validation["mapped"] else "direct",
|
||||
"codmat": None, "id_articol": None, "cantitate_roa": None}
|
||||
for item in order.items
|
||||
]
|
||||
skipped_batch.append({
|
||||
"sync_run_id": run_id, "order_number": order.number,
|
||||
"order_date": order.date, "customer_name": customer,
|
||||
"status": "SKIPPED", "status_at_run": "SKIPPED",
|
||||
"id_comanda": None, "id_partener": None,
|
||||
"error_message": None, "missing_skus": missing_skus,
|
||||
"items_count": len(order.items),
|
||||
"shipping_name": shipping_name, "billing_name": billing_name,
|
||||
"payment_method": payment_method, "delivery_method": delivery_method,
|
||||
"order_total": order.total or None,
|
||||
"delivery_cost": order.delivery_cost or None,
|
||||
"discount_total": order.discount_total or None,
|
||||
"web_status": order.status or None,
|
||||
"items": order_items_data,
|
||||
})
|
||||
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})")
|
||||
await sqlite_service.save_orders_batch(skipped_batch)
|
||||
_update_progress("skipped", f"Skipped {skipped_count}",
|
||||
0, len(truly_importable),
|
||||
{"imported": 0, "skipped": skipped_count, "errors": 0, "already_imported": already_imported_count})
|
||||
|
||||
# Step 4: Import only truly new orders
|
||||
imported_count = 0
|
||||
error_count = 0
|
||||
|
||||
for i, order in enumerate(importable):
|
||||
# Derive shipping / billing names
|
||||
shipping_name = ""
|
||||
if order.shipping:
|
||||
shipping_name = f"{getattr(order.shipping, 'firstname', '') or ''} {getattr(order.shipping, 'lastname', '') or ''}".strip()
|
||||
billing_name = f"{getattr(order.billing, 'firstname', '') or ''} {getattr(order.billing, 'lastname', '') or ''}".strip()
|
||||
if not shipping_name:
|
||||
shipping_name = billing_name
|
||||
customer = shipping_name or order.billing.company_name or billing_name
|
||||
payment_method = getattr(order, 'payment_name', None) or None
|
||||
delivery_method = getattr(order, 'delivery_name', None) or None
|
||||
for i, order in enumerate(truly_importable):
|
||||
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
|
||||
|
||||
_update_progress("import",
|
||||
f"Import {i+1}/{len(importable)}: #{order.number} {customer}",
|
||||
i + 1, len(importable),
|
||||
{"imported": imported_count, "skipped": len(skipped), "errors": error_count})
|
||||
f"Import {i+1}/{len(truly_importable)}: #{order.number} {customer}",
|
||||
i + 1, len(truly_importable),
|
||||
{"imported": imported_count, "skipped": len(skipped), "errors": error_count,
|
||||
"already_imported": already_imported_count})
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
import_service.import_single_order,
|
||||
order, id_pol=id_pol, id_sectie=id_sectie
|
||||
order, id_pol=id_pol, id_sectie=id_sectie,
|
||||
app_settings=app_settings, id_gestiuni=id_gestiuni
|
||||
)
|
||||
|
||||
# Build order items data for storage (R9)
|
||||
@@ -293,6 +599,10 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
||||
"cantitate_roa": None
|
||||
})
|
||||
|
||||
# Compute discount split for SQLite storage
|
||||
ds = import_service.compute_discount_split(order, app_settings)
|
||||
discount_split_json = json.dumps(ds) if ds else None
|
||||
|
||||
if result["success"]:
|
||||
imported_count += 1
|
||||
await sqlite_service.upsert_order(
|
||||
@@ -308,6 +618,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
||||
billing_name=billing_name,
|
||||
payment_method=payment_method,
|
||||
delivery_method=delivery_method,
|
||||
order_total=order.total or None,
|
||||
delivery_cost=order.delivery_cost or None,
|
||||
discount_total=order.discount_total or None,
|
||||
web_status=order.status or None,
|
||||
discount_split=discount_split_json,
|
||||
)
|
||||
await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED")
|
||||
# Store ROA address IDs (R9)
|
||||
@@ -333,6 +648,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
||||
billing_name=billing_name,
|
||||
payment_method=payment_method,
|
||||
delivery_method=delivery_method,
|
||||
order_total=order.total or None,
|
||||
delivery_cost=order.delivery_cost or None,
|
||||
discount_total=order.discount_total or None,
|
||||
web_status=order.status or None,
|
||||
discount_split=discount_split_json,
|
||||
)
|
||||
await sqlite_service.add_sync_run_order(run_id, order.number, "ERROR")
|
||||
await sqlite_service.add_order_items(order.number, order_items_data)
|
||||
@@ -343,40 +663,114 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
||||
logger.warning("Too many errors, stopping sync")
|
||||
break
|
||||
|
||||
# Step 4b: Invoice & order status check — sync with Oracle
|
||||
_update_progress("invoices", "Checking invoices & order status...", 0, 0)
|
||||
invoices_updated = 0
|
||||
invoices_cleared = 0
|
||||
orders_deleted = 0
|
||||
try:
|
||||
# 4b-1: Uninvoiced → check for new invoices
|
||||
uninvoiced = await sqlite_service.get_uninvoiced_imported_orders()
|
||||
if uninvoiced:
|
||||
id_comanda_list = [o["id_comanda"] for o in uninvoiced]
|
||||
invoice_data = await asyncio.to_thread(
|
||||
invoice_service.check_invoices_for_orders, id_comanda_list
|
||||
)
|
||||
id_to_order = {o["id_comanda"]: o["order_number"] for o in uninvoiced}
|
||||
for idc, inv in invoice_data.items():
|
||||
order_num = id_to_order.get(idc)
|
||||
if order_num and inv.get("facturat"):
|
||||
await sqlite_service.update_order_invoice(
|
||||
order_num,
|
||||
serie=inv.get("serie_act"),
|
||||
numar=str(inv.get("numar_act", "")),
|
||||
total_fara_tva=inv.get("total_fara_tva"),
|
||||
total_tva=inv.get("total_tva"),
|
||||
total_cu_tva=inv.get("total_cu_tva"),
|
||||
data_act=inv.get("data_act"),
|
||||
)
|
||||
invoices_updated += 1
|
||||
|
||||
# 4b-2: Invoiced → check for deleted invoices
|
||||
invoiced = await sqlite_service.get_invoiced_imported_orders()
|
||||
if invoiced:
|
||||
id_comanda_list = [o["id_comanda"] for o in invoiced]
|
||||
invoice_data = await asyncio.to_thread(
|
||||
invoice_service.check_invoices_for_orders, id_comanda_list
|
||||
)
|
||||
for o in invoiced:
|
||||
if o["id_comanda"] not in invoice_data:
|
||||
await sqlite_service.clear_order_invoice(o["order_number"])
|
||||
invoices_cleared += 1
|
||||
|
||||
# 4b-3: All imported → check for deleted orders in ROA
|
||||
all_imported = await sqlite_service.get_all_imported_orders()
|
||||
if all_imported:
|
||||
id_comanda_list = [o["id_comanda"] for o in all_imported]
|
||||
existing_ids = await asyncio.to_thread(
|
||||
invoice_service.check_orders_exist, id_comanda_list
|
||||
)
|
||||
for o in all_imported:
|
||||
if o["id_comanda"] not in existing_ids:
|
||||
await sqlite_service.mark_order_deleted_in_roa(o["order_number"])
|
||||
orders_deleted += 1
|
||||
|
||||
if invoices_updated:
|
||||
_log_line(run_id, f"Facturi noi: {invoices_updated} comenzi facturate")
|
||||
if invoices_cleared:
|
||||
_log_line(run_id, f"Facturi sterse: {invoices_cleared} facturi eliminate din cache")
|
||||
if orders_deleted:
|
||||
_log_line(run_id, f"Comenzi sterse din ROA: {orders_deleted}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Invoice/order status check failed: {e}")
|
||||
|
||||
# Step 5: Update sync run
|
||||
total_imported = imported_count + already_imported_count # backward-compat
|
||||
status = "completed" if error_count <= 10 else "failed"
|
||||
await sqlite_service.update_sync_run(
|
||||
run_id, status, len(orders), imported_count, len(skipped), error_count
|
||||
run_id, status, len(orders), total_imported, len(skipped), error_count,
|
||||
already_imported=already_imported_count, new_imported=imported_count
|
||||
)
|
||||
|
||||
summary = {
|
||||
"run_id": run_id,
|
||||
"status": status,
|
||||
"json_files": json_count,
|
||||
"total_orders": len(orders),
|
||||
"new_orders": len(new_orders),
|
||||
"imported": imported_count,
|
||||
"total_orders": len(orders) + cancelled_count,
|
||||
"new_orders": len(truly_importable),
|
||||
"imported": total_imported,
|
||||
"new_imported": imported_count,
|
||||
"already_imported": already_imported_count,
|
||||
"skipped": len(skipped),
|
||||
"errors": error_count,
|
||||
"missing_skus": len(validation["missing"])
|
||||
"cancelled": cancelled_count,
|
||||
"missing_skus": len(validation["missing"]),
|
||||
"invoices_updated": invoices_updated,
|
||||
"invoices_cleared": invoices_cleared,
|
||||
"orders_deleted_in_roa": orders_deleted,
|
||||
}
|
||||
|
||||
_update_progress("completed",
|
||||
f"Completed: {imported_count} imported, {len(skipped)} skipped, {error_count} errors",
|
||||
len(importable), len(importable),
|
||||
{"imported": imported_count, "skipped": len(skipped), "errors": error_count})
|
||||
f"Completed: {imported_count} new, {already_imported_count} already, {len(skipped)} skipped, {error_count} errors, {cancelled_count} cancelled",
|
||||
len(truly_importable), len(truly_importable),
|
||||
{"imported": imported_count, "skipped": len(skipped), "errors": error_count,
|
||||
"already_imported": already_imported_count, "cancelled": cancelled_count})
|
||||
if _current_sync:
|
||||
_current_sync["status"] = status
|
||||
_current_sync["finished_at"] = datetime.now().isoformat()
|
||||
_current_sync["finished_at"] = _now().isoformat()
|
||||
|
||||
logger.info(
|
||||
f"Sync {run_id} completed: {imported_count} imported, "
|
||||
f"{len(skipped)} skipped, {error_count} errors"
|
||||
f"Sync {run_id} completed: {imported_count} new, {already_imported_count} already imported, "
|
||||
f"{len(skipped)} skipped, {error_count} errors, {cancelled_count} cancelled"
|
||||
)
|
||||
|
||||
duration = (datetime.now() - started_dt).total_seconds()
|
||||
duration = (_now() - started_dt).total_seconds()
|
||||
_log_line(run_id, "")
|
||||
_run_logs[run_id].append(f"Finalizat: {imported_count} importate, {len(skipped)} nemapate, {error_count} erori din {len(orders)} comenzi | Durata: {int(duration)}s")
|
||||
cancelled_text = f", {cancelled_count} anulate" if cancelled_count else ""
|
||||
_run_logs[run_id].append(
|
||||
f"Finalizat: {imported_count} importate, {already_imported_count} deja importate, "
|
||||
f"{len(skipped)} nemapate, {error_count} erori{cancelled_text} din {len(orders) + cancelled_count} comenzi | Durata: {int(duration)}s"
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
@@ -386,7 +780,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
||||
await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1, error_message=str(e))
|
||||
if _current_sync:
|
||||
_current_sync["status"] = "failed"
|
||||
_current_sync["finished_at"] = datetime.now().isoformat()
|
||||
_current_sync["finished_at"] = _now().isoformat()
|
||||
_current_sync["error"] = str(e)
|
||||
return {"run_id": run_id, "status": "failed", "error": str(e)}
|
||||
finally:
|
||||
@@ -405,6 +799,4 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
||||
|
||||
def stop_sync():
|
||||
"""Signal sync to stop. Currently sync runs to completion."""
|
||||
# For now, sync runs are not cancellable mid-flight.
|
||||
# Future: use an asyncio.Event for cooperative cancellation.
|
||||
pass
|
||||
|
||||
@@ -3,20 +3,110 @@ from .. import database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def validate_skus(skus: set[str]) -> dict:
|
||||
def check_orders_in_roa(min_date, conn) -> dict:
|
||||
"""Check which orders already exist in Oracle COMENZI by date range.
|
||||
Returns: {comanda_externa: id_comanda} for all existing orders.
|
||||
Much faster than IN-clause batching — single query using date index.
|
||||
"""
|
||||
if conn is None:
|
||||
return {}
|
||||
|
||||
existing = {}
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT comanda_externa, id_comanda FROM COMENZI
|
||||
WHERE data_comanda >= :min_date
|
||||
AND comanda_externa IS NOT NULL AND sters = 0
|
||||
""", {"min_date": min_date})
|
||||
for row in cur:
|
||||
existing[str(row[0])] = row[1]
|
||||
except Exception as e:
|
||||
logger.error(f"check_orders_in_roa failed: {e}")
|
||||
|
||||
logger.info(f"ROA order check (since {min_date}): {len(existing)} existing orders found")
|
||||
return existing
|
||||
|
||||
|
||||
def resolve_codmat_ids(codmats: set[str], id_gestiuni: list[int] = None, conn=None) -> dict[str, dict]:
|
||||
"""Resolve CODMATs to best id_articol + cont: prefers article with stock, then MAX(id_articol).
|
||||
Filters: sters=0 AND inactiv=0.
|
||||
id_gestiuni: list of warehouse IDs to check stock in, or None for all.
|
||||
Returns: {codmat: {"id_articol": int, "cont": str|None}}
|
||||
"""
|
||||
if not codmats:
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
codmat_list = list(codmats)
|
||||
|
||||
# Build stoc subquery dynamically for index optimization
|
||||
if id_gestiuni:
|
||||
gest_placeholders = ",".join([f":g{k}" for k in range(len(id_gestiuni))])
|
||||
stoc_filter = f"AND s.id_gestiune IN ({gest_placeholders})"
|
||||
else:
|
||||
stoc_filter = ""
|
||||
|
||||
own_conn = conn is None
|
||||
if own_conn:
|
||||
conn = database.get_oracle_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for i in range(0, len(codmat_list), 500):
|
||||
batch = codmat_list[i:i+500]
|
||||
placeholders = ",".join([f":c{j}" for j in range(len(batch))])
|
||||
params = {f"c{j}": cm for j, cm in enumerate(batch)}
|
||||
if id_gestiuni:
|
||||
for k, gid in enumerate(id_gestiuni):
|
||||
params[f"g{k}"] = gid
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT codmat, id_articol, cont FROM (
|
||||
SELECT na.codmat, na.id_articol, na.cont,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY na.codmat
|
||||
ORDER BY
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM stoc s
|
||||
WHERE s.id_articol = na.id_articol
|
||||
{stoc_filter}
|
||||
AND s.an = EXTRACT(YEAR FROM SYSDATE)
|
||||
AND s.luna = EXTRACT(MONTH FROM SYSDATE)
|
||||
AND s.cants + s.cant - s.cante > 0
|
||||
) THEN 0 ELSE 1 END,
|
||||
na.id_articol DESC
|
||||
) AS rn
|
||||
FROM nom_articole na
|
||||
WHERE na.codmat IN ({placeholders})
|
||||
AND na.sters = 0 AND na.inactiv = 0
|
||||
) WHERE rn = 1
|
||||
""", params)
|
||||
for row in cur:
|
||||
result[row[0]] = {"id_articol": row[1], "cont": row[2]}
|
||||
finally:
|
||||
if own_conn:
|
||||
database.pool.release(conn)
|
||||
|
||||
logger.info(f"resolve_codmat_ids: {len(result)}/{len(codmats)} resolved (gestiuni={id_gestiuni})")
|
||||
return result
|
||||
|
||||
|
||||
def validate_skus(skus: set[str], conn=None, id_gestiuni: list[int] = None) -> dict:
|
||||
"""Validate a set of SKUs against Oracle.
|
||||
Returns: {mapped: set, direct: set, missing: set}
|
||||
Returns: {mapped: set, direct: set, missing: set, direct_id_map: {codmat: {"id_articol": int, "cont": str|None}}}
|
||||
- mapped: found in ARTICOLE_TERTI (active)
|
||||
- direct: found in NOM_ARTICOLE by codmat (not in ARTICOLE_TERTI)
|
||||
- missing: not found anywhere
|
||||
- direct_id_map: {codmat: {"id_articol": int, "cont": str|None}} for direct SKUs
|
||||
"""
|
||||
if not skus:
|
||||
return {"mapped": set(), "direct": set(), "missing": set()}
|
||||
return {"mapped": set(), "direct": set(), "missing": set(), "direct_id_map": {}}
|
||||
|
||||
mapped = set()
|
||||
direct = set()
|
||||
sku_list = list(skus)
|
||||
|
||||
own_conn = conn is None
|
||||
if own_conn:
|
||||
conn = database.get_oracle_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
@@ -34,24 +124,24 @@ def validate_skus(skus: set[str]) -> dict:
|
||||
for row in cur:
|
||||
mapped.add(row[0])
|
||||
|
||||
# Check NOM_ARTICOLE for remaining
|
||||
remaining = [s for s in batch if s not in mapped]
|
||||
if remaining:
|
||||
placeholders2 = ",".join([f":n{j}" for j in range(len(remaining))])
|
||||
params2 = {f"n{j}": sku for j, sku in enumerate(remaining)}
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT codmat FROM NOM_ARTICOLE
|
||||
WHERE codmat IN ({placeholders2}) AND sters = 0 AND inactiv = 0
|
||||
""", params2)
|
||||
for row in cur:
|
||||
direct.add(row[0])
|
||||
# Resolve remaining SKUs via resolve_codmat_ids (consistent id_articol selection)
|
||||
all_remaining = [s for s in sku_list if s not in mapped]
|
||||
if all_remaining:
|
||||
direct_id_map = resolve_codmat_ids(set(all_remaining), id_gestiuni, conn)
|
||||
direct = set(direct_id_map.keys())
|
||||
else:
|
||||
direct_id_map = {}
|
||||
direct = set()
|
||||
|
||||
finally:
|
||||
if own_conn:
|
||||
database.pool.release(conn)
|
||||
|
||||
missing = skus - mapped - direct
|
||||
|
||||
logger.info(f"SKU validation: {len(mapped)} mapped, {len(direct)} direct, {len(missing)} missing")
|
||||
return {"mapped": mapped, "direct": direct, "missing": missing}
|
||||
return {"mapped": mapped, "direct": direct, "missing": missing,
|
||||
"direct_id_map": direct_id_map}
|
||||
|
||||
def classify_orders(orders, validation_result):
|
||||
"""Classify orders as importable or skipped based on SKU validation.
|
||||
@@ -73,65 +163,36 @@ def classify_orders(orders, validation_result):
|
||||
|
||||
return importable, skipped
|
||||
|
||||
def find_new_orders(order_numbers: list[str]) -> set[str]:
|
||||
"""Check which order numbers do NOT already exist in Oracle COMENZI.
|
||||
Returns: set of order numbers that are truly new (not yet imported).
|
||||
"""
|
||||
if not order_numbers:
|
||||
return set()
|
||||
def _extract_id_map(direct_id_map: dict) -> dict:
|
||||
"""Extract {codmat: id_articol} from either enriched or simple format."""
|
||||
if not direct_id_map:
|
||||
return {}
|
||||
result = {}
|
||||
for cm, val in direct_id_map.items():
|
||||
if isinstance(val, dict):
|
||||
result[cm] = val["id_articol"]
|
||||
else:
|
||||
result[cm] = val
|
||||
return result
|
||||
|
||||
existing = set()
|
||||
num_list = list(order_numbers)
|
||||
|
||||
conn = database.get_oracle_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for i in range(0, len(num_list), 500):
|
||||
batch = num_list[i:i+500]
|
||||
placeholders = ",".join([f":o{j}" for j in range(len(batch))])
|
||||
params = {f"o{j}": num for j, num in enumerate(batch)}
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT comanda_externa FROM COMENZI
|
||||
WHERE comanda_externa IN ({placeholders}) AND sters = 0
|
||||
""", params)
|
||||
for row in cur:
|
||||
existing.add(row[0])
|
||||
finally:
|
||||
database.pool.release(conn)
|
||||
|
||||
new_orders = set(order_numbers) - existing
|
||||
logger.info(f"Order check: {len(new_orders)} new, {len(existing)} already exist out of {len(order_numbers)} total")
|
||||
return new_orders
|
||||
|
||||
def validate_prices(codmats: set[str], id_pol: int) -> dict:
|
||||
def validate_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict=None) -> dict:
|
||||
"""Check which CODMATs have a price entry in CRM_POLITICI_PRET_ART for the given policy.
|
||||
If direct_id_map is provided, skips the NOM_ARTICOLE lookup for those CODMATs.
|
||||
Returns: {"has_price": set_of_codmats, "missing_price": set_of_codmats}
|
||||
"""
|
||||
if not codmats:
|
||||
return {"has_price": set(), "missing_price": set()}
|
||||
|
||||
codmat_to_id = {}
|
||||
codmat_to_id = _extract_id_map(direct_id_map)
|
||||
ids_with_price = set()
|
||||
codmat_list = list(codmats)
|
||||
|
||||
own_conn = conn is None
|
||||
if own_conn:
|
||||
conn = database.get_oracle_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# Step 1: Get ID_ARTICOL for each CODMAT
|
||||
for i in range(0, len(codmat_list), 500):
|
||||
batch = codmat_list[i:i+500]
|
||||
placeholders = ",".join([f":c{j}" for j in range(len(batch))])
|
||||
params = {f"c{j}": cm for j, cm in enumerate(batch)}
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT id_articol, codmat FROM NOM_ARTICOLE
|
||||
WHERE codmat IN ({placeholders})
|
||||
""", params)
|
||||
for row in cur:
|
||||
codmat_to_id[row[1]] = row[0]
|
||||
|
||||
# Step 2: Check which ID_ARTICOLs have a price in the policy
|
||||
# Check which ID_ARTICOLs have a price in the policy
|
||||
id_list = list(codmat_to_id.values())
|
||||
for i in range(0, len(id_list), 500):
|
||||
batch = id_list[i:i+500]
|
||||
@@ -146,6 +207,7 @@ def validate_prices(codmats: set[str], id_pol: int) -> dict:
|
||||
for row in cur:
|
||||
ids_with_price.add(row[0])
|
||||
finally:
|
||||
if own_conn:
|
||||
database.pool.release(conn)
|
||||
|
||||
# Map back to CODMATs
|
||||
@@ -155,11 +217,20 @@ def validate_prices(codmats: set[str], id_pol: int) -> dict:
|
||||
logger.info(f"Price validation (policy {id_pol}): {len(has_price)} have price, {len(missing_price)} missing price")
|
||||
return {"has_price": has_price, "missing_price": missing_price}
|
||||
|
||||
def ensure_prices(codmats: set[str], id_pol: int):
|
||||
"""Insert price 0 entries for CODMATs missing from the given price policy."""
|
||||
def ensure_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict=None,
|
||||
cota_tva: float = None):
|
||||
"""Insert price 0 entries for CODMATs missing from the given price policy.
|
||||
Uses batch executemany instead of individual INSERTs.
|
||||
Relies on TRG_CRM_POLITICI_PRET_ART trigger for ID_POL_ART sequence.
|
||||
cota_tva: VAT rate from settings (e.g. 21) — used for PROC_TVAV metadata.
|
||||
"""
|
||||
if not codmats:
|
||||
return
|
||||
|
||||
proc_tvav = 1 + (cota_tva / 100) if cota_tva else 1.21
|
||||
|
||||
own_conn = conn is None
|
||||
if own_conn:
|
||||
conn = database.get_oracle_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
@@ -173,31 +244,158 @@ def ensure_prices(codmats: set[str], id_pol: int):
|
||||
return
|
||||
id_valuta = row[0]
|
||||
|
||||
# Build batch params using direct_id_map (already resolved via resolve_codmat_ids)
|
||||
batch_params = []
|
||||
codmat_id_map = _extract_id_map(direct_id_map)
|
||||
|
||||
for codmat in codmats:
|
||||
# Get ID_ARTICOL
|
||||
cur.execute("""
|
||||
SELECT id_articol FROM NOM_ARTICOLE WHERE codmat = :codmat
|
||||
""", {"codmat": codmat})
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
id_articol = codmat_id_map.get(codmat)
|
||||
if not id_articol:
|
||||
logger.warning(f"CODMAT {codmat} not found in NOM_ARTICOLE, skipping price insert")
|
||||
continue
|
||||
id_articol = row[0]
|
||||
batch_params.append({
|
||||
"id_pol": id_pol,
|
||||
"id_articol": id_articol,
|
||||
"id_valuta": id_valuta,
|
||||
"proc_tvav": proc_tvav
|
||||
})
|
||||
|
||||
cur.execute("""
|
||||
if batch_params:
|
||||
cur.executemany("""
|
||||
INSERT INTO CRM_POLITICI_PRET_ART
|
||||
(ID_POL_ART, ID_POL, ID_ARTICOL, PRET, ID_VALUTA,
|
||||
ID_UTIL, DATAORA, PROC_TVAV,
|
||||
PRETFTVA, PRETCTVA)
|
||||
(ID_POL, ID_ARTICOL, PRET, ID_VALUTA,
|
||||
ID_UTIL, DATAORA, PROC_TVAV, PRETFTVA, PRETCTVA)
|
||||
VALUES
|
||||
(SEQ_CRM_POLITICI_PRET_ART.NEXTVAL, :id_pol, :id_articol, 0, :id_valuta,
|
||||
-3, SYSDATE, 1.19,
|
||||
0, 0)
|
||||
""", {"id_pol": id_pol, "id_articol": id_articol, "id_valuta": id_valuta})
|
||||
logger.info(f"Pret 0 adaugat pentru CODMAT {codmat} in politica {id_pol}")
|
||||
(:id_pol, :id_articol, 0, :id_valuta,
|
||||
-3, SYSDATE, :proc_tvav, 0, 0)
|
||||
""", batch_params)
|
||||
logger.info(f"Batch inserted {len(batch_params)} price entries for policy {id_pol} (PROC_TVAV={proc_tvav})")
|
||||
|
||||
conn.commit()
|
||||
finally:
|
||||
if own_conn:
|
||||
database.pool.release(conn)
|
||||
|
||||
logger.info(f"Ensure prices done: {len(codmats)} CODMATs processed for policy {id_pol}")
|
||||
|
||||
|
||||
def validate_and_ensure_prices_dual(codmats: set[str], id_pol_vanzare: int,
|
||||
id_pol_productie: int, conn, direct_id_map: dict,
|
||||
cota_tva: float = 21) -> dict[str, int]:
|
||||
"""Dual-policy price validation: assign each CODMAT to sales or production policy.
|
||||
|
||||
Logic:
|
||||
1. Check both policies in one SQL
|
||||
2. If article in one policy → use that
|
||||
3. If article in BOTH → prefer id_pol_vanzare
|
||||
4. If article in NEITHER → check cont: 341/345 → production, else → sales; insert price 0
|
||||
|
||||
Returns: codmat_policy_map = {codmat: assigned_id_pol}
|
||||
"""
|
||||
if not codmats:
|
||||
return {}
|
||||
|
||||
codmat_policy_map = {}
|
||||
id_map = _extract_id_map(direct_id_map)
|
||||
|
||||
# Collect all id_articol values we need to check
|
||||
id_to_codmats = {} # {id_articol: [codmat, ...]}
|
||||
for cm in codmats:
|
||||
aid = id_map.get(cm)
|
||||
if aid:
|
||||
id_to_codmats.setdefault(aid, []).append(cm)
|
||||
|
||||
if not id_to_codmats:
|
||||
return {}
|
||||
|
||||
# Query both policies in one SQL
|
||||
existing = {} # {id_articol: set of id_pol}
|
||||
id_list = list(id_to_codmats.keys())
|
||||
with conn.cursor() as cur:
|
||||
for i in range(0, len(id_list), 500):
|
||||
batch = id_list[i:i+500]
|
||||
placeholders = ",".join([f":a{j}" for j in range(len(batch))])
|
||||
params = {f"a{j}": aid for j, aid in enumerate(batch)}
|
||||
params["id_pol_v"] = id_pol_vanzare
|
||||
params["id_pol_p"] = id_pol_productie
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT pa.ID_ARTICOL, pa.ID_POL FROM CRM_POLITICI_PRET_ART pa
|
||||
WHERE pa.ID_POL IN (:id_pol_v, :id_pol_p) AND pa.ID_ARTICOL IN ({placeholders})
|
||||
""", params)
|
||||
for row in cur:
|
||||
existing.setdefault(row[0], set()).add(row[1])
|
||||
|
||||
# Classify each codmat
|
||||
missing_vanzare = set() # CODMATs needing price 0 in sales policy
|
||||
missing_productie = set() # CODMATs needing price 0 in production policy
|
||||
|
||||
for aid, cms in id_to_codmats.items():
|
||||
pols = existing.get(aid, set())
|
||||
for cm in cms:
|
||||
if pols:
|
||||
if id_pol_vanzare in pols:
|
||||
codmat_policy_map[cm] = id_pol_vanzare
|
||||
elif id_pol_productie in pols:
|
||||
codmat_policy_map[cm] = id_pol_productie
|
||||
else:
|
||||
# Not in any policy — classify by cont
|
||||
info = direct_id_map.get(cm, {})
|
||||
cont = info.get("cont", "") if isinstance(info, dict) else ""
|
||||
cont_str = str(cont or "").strip()
|
||||
if cont_str in ("341", "345"):
|
||||
codmat_policy_map[cm] = id_pol_productie
|
||||
missing_productie.add(cm)
|
||||
else:
|
||||
codmat_policy_map[cm] = id_pol_vanzare
|
||||
missing_vanzare.add(cm)
|
||||
|
||||
# Ensure prices for missing articles in each policy
|
||||
if missing_vanzare:
|
||||
ensure_prices(missing_vanzare, id_pol_vanzare, conn, direct_id_map, cota_tva=cota_tva)
|
||||
if missing_productie:
|
||||
ensure_prices(missing_productie, id_pol_productie, conn, direct_id_map, cota_tva=cota_tva)
|
||||
|
||||
logger.info(
|
||||
f"Dual-policy: {len(codmat_policy_map)} CODMATs assigned "
|
||||
f"(vanzare={sum(1 for v in codmat_policy_map.values() if v == id_pol_vanzare)}, "
|
||||
f"productie={sum(1 for v in codmat_policy_map.values() if v == id_pol_productie)})"
|
||||
)
|
||||
return codmat_policy_map
|
||||
|
||||
|
||||
def resolve_mapped_codmats(mapped_skus: set[str], conn) -> dict[str, list[dict]]:
|
||||
"""For mapped SKUs, get their underlying CODMATs from ARTICOLE_TERTI + nom_articole.
|
||||
|
||||
Returns: {sku: [{"codmat": str, "id_articol": int, "cont": str|None}]}
|
||||
"""
|
||||
if not mapped_skus:
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
sku_list = list(mapped_skus)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
for i in range(0, len(sku_list), 500):
|
||||
batch = sku_list[i:i+500]
|
||||
placeholders = ",".join([f":s{j}" for j in range(len(batch))])
|
||||
params = {f"s{j}": sku for j, sku in enumerate(batch)}
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT at.sku, at.codmat, na.id_articol, na.cont
|
||||
FROM ARTICOLE_TERTI at
|
||||
JOIN NOM_ARTICOLE na ON na.codmat = at.codmat AND na.sters = 0 AND na.inactiv = 0
|
||||
WHERE at.sku IN ({placeholders}) AND at.activ = 1 AND at.sters = 0
|
||||
""", params)
|
||||
for row in cur:
|
||||
sku = row[0]
|
||||
if sku not in result:
|
||||
result[sku] = []
|
||||
result[sku].append({
|
||||
"codmat": row[1],
|
||||
"id_articol": row[2],
|
||||
"cont": row[3]
|
||||
})
|
||||
|
||||
logger.info(f"resolve_mapped_codmats: {len(result)} SKUs → {sum(len(v) for v in result.values())} CODMATs")
|
||||
return result
|
||||
|
||||
@@ -1,189 +1,249 @@
|
||||
/* ── Design tokens ───────────────────────────────── */
|
||||
:root {
|
||||
--sidebar-width: 220px;
|
||||
--sidebar-bg: #1e293b;
|
||||
--sidebar-text: #94a3b8;
|
||||
--sidebar-active: #ffffff;
|
||||
--sidebar-hover-bg: #334155;
|
||||
--body-bg: #f1f5f9;
|
||||
--card-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
/* Surfaces */
|
||||
--body-bg: #f9fafb;
|
||||
--card-bg: #ffffff;
|
||||
--card-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
|
||||
--card-radius: 0.5rem;
|
||||
|
||||
/* Semantic colors */
|
||||
--blue-600: #2563eb;
|
||||
--blue-700: #1d4ed8;
|
||||
--green-100: #dcfce7; --green-800: #166534;
|
||||
--yellow-100: #fef9c3; --yellow-800: #854d0e;
|
||||
--red-100: #fee2e2; --red-800: #991b1b;
|
||||
--blue-100: #dbeafe; --blue-800: #1e40af;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #4b5563;
|
||||
--text-muted: #6b7280;
|
||||
--border-color: #e5e7eb;
|
||||
|
||||
/* Dots */
|
||||
--dot-green: #22c55e;
|
||||
--dot-yellow: #eab308;
|
||||
--dot-red: #ef4444;
|
||||
}
|
||||
|
||||
/* ── Base ────────────────────────────────────────── */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 1rem;
|
||||
background-color: var(--body-bg);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
/* ── Top Navbar ──────────────────────────────────── */
|
||||
.top-navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
background-color: var(--sidebar-bg);
|
||||
padding: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
gap: 1.5rem;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: #111827;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-header h5 {
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
.navbar-links {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.navbar-links::-webkit-scrollbar { display: none; }
|
||||
|
||||
.nav-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
height: 48px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.nav-tab:hover {
|
||||
color: #111827;
|
||||
background: #f9fafb;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-tab.active {
|
||||
color: var(--blue-600);
|
||||
border-bottom-color: var(--blue-600);
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
color: var(--sidebar-text);
|
||||
padding: 0.65rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover {
|
||||
color: var(--sidebar-active);
|
||||
background-color: var(--sidebar-hover-bg);
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
color: var(--sidebar-active);
|
||||
background-color: var(--sidebar-hover-bg);
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.sidebar .nav-link i {
|
||||
margin-right: 0.5rem;
|
||||
width: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid #334155;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
/* ── Main content ────────────────────────────────── */
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: 1.5rem;
|
||||
padding-top: 64px;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
min-height: 100vh;
|
||||
max-width: 1280px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Sidebar toggle button for mobile */
|
||||
.sidebar-toggle {
|
||||
position: fixed;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
z-index: 1100;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
/* ── Cards ───────────────────────────────────────── */
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: var(--card-shadow);
|
||||
border-radius: 0.5rem;
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.9375rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge-imported { background-color: #22c55e; }
|
||||
.badge-skipped { background-color: #eab308; color: #000; }
|
||||
.badge-error { background-color: #ef4444; }
|
||||
.badge-pending { background-color: #94a3b8; }
|
||||
.badge-ready { background-color: #3b82f6; }
|
||||
|
||||
/* Tables */
|
||||
/* ── Tables ──────────────────────────────────────── */
|
||||
.table {
|
||||
font-size: 0.875rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
background: #f9fafb;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.15);
|
||||
.table td {
|
||||
padding: 0.625rem 1rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 767.98px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
.sidebar.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
.sidebar-toggle {
|
||||
display: block !important;
|
||||
}
|
||||
/* Zebra striping */
|
||||
.table tbody tr:nth-child(even) td { background-color: #f7f8fa; }
|
||||
.table-hover tbody tr:hover td { background-color: #eef2ff !important; }
|
||||
|
||||
/* ── Badges — soft pill style ────────────────────── */
|
||||
.badge {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* Autocomplete dropdown */
|
||||
.autocomplete-dropdown {
|
||||
position: absolute;
|
||||
z-index: 1050;
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
.badge.bg-success { background: var(--green-100) !important; color: var(--green-800) !important; }
|
||||
.badge.bg-info { background: var(--blue-100) !important; color: var(--blue-800) !important; }
|
||||
.badge.bg-warning { background: var(--yellow-100) !important; color: var(--yellow-800) !important; }
|
||||
.badge.bg-danger { background: var(--red-100) !important; color: var(--red-800) !important; }
|
||||
|
||||
/* Legacy badge classes */
|
||||
.badge-imported { background: var(--green-100); color: var(--green-800); }
|
||||
.badge-skipped { background: var(--yellow-100); color: var(--yellow-800); }
|
||||
.badge-error { background: var(--red-100); color: var(--red-800); }
|
||||
.badge-pending { background: #f3f4f6; color: #374151; }
|
||||
.badge-ready { background: var(--blue-100); color: var(--blue-800); }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────── */
|
||||
.btn {
|
||||
font-size: 0.9375rem;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.autocomplete-item {
|
||||
.btn-sm {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--blue-600);
|
||||
border-color: var(--blue-600);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--blue-700);
|
||||
border-color: var(--blue-700);
|
||||
}
|
||||
|
||||
/* ── Forms ───────────────────────────────────────── */
|
||||
.form-control, .form-select {
|
||||
font-size: 0.9375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: var(--blue-600);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
/* ── Unified Pagination Bar ──────────────────────── */
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.autocomplete-item:hover, .autocomplete-item.active {
|
||||
background-color: #f1f5f9;
|
||||
.page-btn:hover:not(:disabled):not(.active) {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.autocomplete-item .codmat {
|
||||
.page-btn.active {
|
||||
background: var(--blue-600);
|
||||
border-color: var(--blue-600);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.page-btn:disabled, .page-btn.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.autocomplete-item .denumire {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination .page-link {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
/* Loading spinner ────────────────────────────────── */
|
||||
.spinner-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
@@ -194,7 +254,44 @@ body {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Log viewer */
|
||||
/* ── Colored dots ────────────────────────────────── */
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-green { background: var(--dot-green); }
|
||||
.dot-yellow { background: var(--dot-yellow); }
|
||||
.dot-red { background: var(--dot-red); }
|
||||
.dot-gray { background: #9ca3af; }
|
||||
.dot-blue { background: #3b82f6; }
|
||||
|
||||
/* ── Flat row (mobile + desktop) ────────────────── */
|
||||
.flat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.flat-row:last-child { border-bottom: none; }
|
||||
.flat-row:hover { background: #f9fafb; cursor: pointer; }
|
||||
|
||||
.grow { flex: 1; min-width: 0; }
|
||||
.truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* ── Colored filter count - text color only ─────── */
|
||||
.fc-green { color: #16a34a; }
|
||||
.fc-yellow { color: #ca8a04; }
|
||||
.fc-red { color: #dc2626; }
|
||||
.fc-neutral { color: #6b7280; }
|
||||
.fc-blue { color: #2563eb; }
|
||||
.fc-dark { color: #374151; }
|
||||
|
||||
/* ── Log viewer (dark theme — keep as-is) ────────── */
|
||||
.log-viewer {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 0.8125rem;
|
||||
@@ -210,107 +307,86 @@ body {
|
||||
border-radius: 0 0 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Clickable table rows */
|
||||
/* ── Clickable table rows ────────────────────────── */
|
||||
.table-hover tbody tr[data-href] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-hover tbody tr[data-href]:hover {
|
||||
background-color: #e2e8f0;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Sortable table headers (R7) */
|
||||
/* ── Sortable table headers ──────────────────────── */
|
||||
.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.sortable:hover {
|
||||
background-color: #f1f5f9;
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
.sort-icon {
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.25rem;
|
||||
color: #3b82f6;
|
||||
color: var(--blue-600);
|
||||
}
|
||||
|
||||
/* SKU group visual grouping (R6) */
|
||||
.sku-group-even {
|
||||
/* default background */
|
||||
}
|
||||
/* ── SKU group visual grouping ───────────────────── */
|
||||
.sku-group-odd {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
/* Editable cells */
|
||||
.editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.editable:hover {
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
/* ── Editable cells ──────────────────────────────── */
|
||||
.editable { cursor: pointer; }
|
||||
.editable:hover { background-color: #f3f4f6; }
|
||||
|
||||
/* Order detail modal items */
|
||||
/* ── Order detail modal ──────────────────────────── */
|
||||
.modal-lg .table-sm td,
|
||||
.modal-lg .table-sm th {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Filter button badges */
|
||||
#orderFilterBtns .badge {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Modal stacking for quickMap over orderDetail */
|
||||
#quickMapModal {
|
||||
z-index: 1060;
|
||||
}
|
||||
/* ── Modal stacking (quickMap over orderDetail) ───── */
|
||||
#quickMapModal { z-index: 1060; }
|
||||
#quickMapModal + .modal-backdrop,
|
||||
.modal-backdrop ~ .modal-backdrop {
|
||||
z-index: 1055;
|
||||
}
|
||||
.modal-backdrop ~ .modal-backdrop { z-index: 1055; }
|
||||
|
||||
/* Deleted mapping rows */
|
||||
/* ── Quick Map compact lines ─────────────────────── */
|
||||
.qm-line { border-bottom: 1px solid #e5e7eb; padding: 6px 0; }
|
||||
.qm-line:last-child { border-bottom: none; }
|
||||
.qm-row { display: flex; gap: 6px; align-items: center; }
|
||||
.qm-codmat-wrap { flex: 1; min-width: 0; }
|
||||
.qm-rm-btn { padding: 2px 6px; line-height: 1; }
|
||||
#qmCodmatLines .qm-selected:empty { display: none; }
|
||||
#quickMapModal .modal-body { padding-top: 12px; padding-bottom: 8px; }
|
||||
#quickMapModal .modal-header { padding: 10px 16px; }
|
||||
#quickMapModal .modal-header h5 { font-size: 0.95rem; margin: 0; }
|
||||
#quickMapModal .modal-footer { padding: 8px 16px; }
|
||||
|
||||
/* ── Deleted mapping rows ────────────────────────── */
|
||||
tr.mapping-deleted td {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Map icon button (minimal, no border) */
|
||||
/* ── Map icon button ─────────────────────────────── */
|
||||
.btn-map-icon {
|
||||
color: #3b82f6;
|
||||
color: var(--blue-600);
|
||||
padding: 0.1rem 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-map-icon:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.btn-map-icon:hover { color: var(--blue-700); }
|
||||
|
||||
/* Last sync summary card columns */
|
||||
/* ── Last sync summary card columns ─────────────── */
|
||||
.last-sync-col {
|
||||
border-right: 1px solid #e2e8f0;
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Dashboard filter badges */
|
||||
#dashFilterBtns .badge {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
/* ── Cursor pointer utility ──────────────────────── */
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
|
||||
/* Cursor pointer utility */
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Typography scale ────────────────────────────── */
|
||||
.text-header { font-size: 1.25rem; font-weight: 600; }
|
||||
.text-card-head { font-size: 1rem; font-weight: 600; }
|
||||
.text-body { font-size: 0.8125rem; }
|
||||
.text-badge { font-size: 0.75rem; }
|
||||
.text-label { font-size: 0.6875rem; }
|
||||
|
||||
/* ── Filter bar — shared across dashboard, mappings, missing_skus pages ── */
|
||||
/* ── Filter bar ──────────────────────────────────── */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -318,49 +394,75 @@ tr.mapping-deleted td {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.625rem 0;
|
||||
}
|
||||
|
||||
.filter-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 999px;
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filter-pill:hover { background: #f3f4f6; }
|
||||
.filter-pill.active {
|
||||
background: #1d4ed8;
|
||||
border-color: #1d4ed8;
|
||||
background: var(--blue-700);
|
||||
border-color: var(--blue-700);
|
||||
color: #fff;
|
||||
}
|
||||
.filter-pill.active .filter-count { background: rgba(255,255,255,0.25); color: #fff; }
|
||||
.filter-count {
|
||||
display: inline-block;
|
||||
min-width: 1.25rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 999px;
|
||||
background: #e5e7eb;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
.filter-pill.active .filter-count {
|
||||
color: rgba(255,255,255,0.9);
|
||||
}
|
||||
|
||||
/* ── Search input (used in filter bars) ─────────── */
|
||||
.search-input {
|
||||
margin-left: auto;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
.filter-count {
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
min-width: 180px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Search input ────────────────────────────────── */
|
||||
.search-input {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.9375rem;
|
||||
outline: none;
|
||||
width: 160px;
|
||||
}
|
||||
.search-input:focus { border-color: var(--blue-600); }
|
||||
|
||||
/* ── Autocomplete dropdown (keep as-is) ──────────── */
|
||||
.autocomplete-dropdown {
|
||||
position: absolute;
|
||||
z-index: 1050;
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
.autocomplete-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9375rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.autocomplete-item:hover, .autocomplete-item.active {
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
.autocomplete-item .codmat {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.autocomplete-item .denumire {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.search-input:focus { border-color: #1d4ed8; }
|
||||
|
||||
/* ── Tooltip for Client/Cont ─────────────────────── */
|
||||
.tooltip-cont {
|
||||
@@ -389,8 +491,8 @@ tr.mapping-deleted td {
|
||||
/* ── Sync card ───────────────────────────────────── */
|
||||
.sync-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--card-radius);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -403,7 +505,7 @@ tr.mapping-deleted td {
|
||||
}
|
||||
.sync-card-divider {
|
||||
height: 1px;
|
||||
background: #e5e7eb;
|
||||
background: var(--border-color);
|
||||
margin: 0;
|
||||
}
|
||||
.sync-card-info {
|
||||
@@ -411,8 +513,8 @@ tr.mapping-deleted td {
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
@@ -423,12 +525,12 @@ tr.mapping-deleted td {
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 1rem;
|
||||
background: #eff6ff;
|
||||
font-size: 0.8125rem;
|
||||
color: #1d4ed8;
|
||||
font-size: 1rem;
|
||||
color: var(--blue-700);
|
||||
border-top: 1px solid #dbeafe;
|
||||
}
|
||||
|
||||
/* ── Pulsing live dot ────────────────────────────── */
|
||||
/* ── Pulsing live dot (keep as-is) ──────────────── */
|
||||
.sync-live-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
@@ -443,7 +545,7 @@ tr.mapping-deleted td {
|
||||
50% { opacity: 0.4; transform: scale(0.75); }
|
||||
}
|
||||
|
||||
/* ── Status dot (idle/running/completed/failed) ──── */
|
||||
/* ── Status dot (keep as-is) ─────────────────────── */
|
||||
.sync-status-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
@@ -461,32 +563,214 @@ tr.mapping-deleted td {
|
||||
display: none;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.period-custom-range.visible { display: flex; }
|
||||
|
||||
/* ── Compact button ──────────────────────────────── */
|
||||
.btn-compact {
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* ── Compact select ──────────────────────────────── */
|
||||
/* ── select-compact (used in filter bars) ─────────── */
|
||||
.select-compact {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── btn-compact (kept for backward compat) ──────── */
|
||||
.btn-compact {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
/* ── Result banner ───────────────────────────────── */
|
||||
.result-banner {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.9375rem;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border: 1px solid #6ee7b7;
|
||||
}
|
||||
|
||||
/* ── Badge-pct (mappings page) ───────────────────── */
|
||||
.badge-pct {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-pct.complete { background: #d1fae5; color: #065f46; }
|
||||
.badge-pct.incomplete { background: #fef3c7; color: #92400e; }
|
||||
|
||||
/* ── Context Menu ────────────────────────────────── */
|
||||
.context-menu-trigger {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
padding: 0.2rem 0.4rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
transition: color 0.12s, background 0.12s;
|
||||
}
|
||||
.context-menu-trigger:hover {
|
||||
color: var(--text-secondary);
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||
z-index: 1050;
|
||||
min-width: 150px;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.context-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.9rem;
|
||||
font-size: 0.9375rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.context-menu-item:hover { background: #f3f4f6; }
|
||||
.context-menu-item.text-danger { color: #dc2626; }
|
||||
.context-menu-item.text-danger:hover { background: #fee2e2; }
|
||||
|
||||
/* ── Pagination info strip ───────────────────────── */
|
||||
.pag-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pag-strip-bottom {
|
||||
border-bottom: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* ── Per page selector ───────────────────────────── */
|
||||
.per-page-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Mobile list vs desktop table ────────────────── */
|
||||
.mobile-list { display: none; }
|
||||
|
||||
/* ── Mappings flat-rows: always visible ────────────── */
|
||||
.mappings-flat-list { display: block; }
|
||||
|
||||
/* ── Mobile ⋯ dropdown ─────────────────────────── */
|
||||
.mobile-more-dropdown { position: relative; display: inline-block; }
|
||||
.mobile-more-dropdown .dropdown-toggle::after { display: none; }
|
||||
|
||||
/* ── Mobile segmented control (hidden on desktop) ── */
|
||||
.mobile-seg { display: none; }
|
||||
|
||||
/* ── Responsive ──────────────────────────────────── */
|
||||
@media (max-width: 767.98px) {
|
||||
.top-navbar {
|
||||
padding: 0 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.navbar-brand {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.nav-tab {
|
||||
padding: 0 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.main-content {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
.filter-bar {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.filter-pill { padding: 0.25rem 0.5rem; font-size: 0.8125rem; }
|
||||
.search-input { min-width: 0; width: auto; flex: 1; }
|
||||
.page-btn.page-number { display: none; }
|
||||
.page-btn.page-ellipsis { display: none; }
|
||||
.table-responsive { display: none; }
|
||||
.mobile-list { display: block; }
|
||||
|
||||
/* Segmented filter control (replaces pills on mobile) */
|
||||
.filter-bar .filter-pill { display: none; }
|
||||
.filter-bar .mobile-seg { display: flex; }
|
||||
|
||||
/* Sync card compact */
|
||||
.sync-card-controls {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.sync-card-info {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Hide per-page selector on mobile */
|
||||
.per-page-label { display: none; }
|
||||
}
|
||||
|
||||
/* Mobile article cards in order detail modal */
|
||||
.detail-item-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.detail-item-card .card-sku {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.detail-item-card .card-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.detail-item-card .card-details {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* Clickable CODMAT link in order detail modal */
|
||||
.codmat-link { color: #0d6efd; cursor: pointer; text-decoration: underline; }
|
||||
.codmat-link:hover { color: #0a58ca; }
|
||||
|
||||
/* Mobile article flat list in order detail modal */
|
||||
.detail-item-flat { font-size: 0.85rem; }
|
||||
.detail-item-flat .dif-item { }
|
||||
.detail-item-flat .dif-item:nth-child(even) .dif-row { background: #f7f8fa; }
|
||||
.detail-item-flat .dif-row {
|
||||
display: flex; align-items: baseline; gap: 0.5rem;
|
||||
padding: 0.2rem 0.75rem; flex-wrap: wrap;
|
||||
}
|
||||
.dif-sku { font-family: monospace; font-size: 0.78rem; color: #6b7280; }
|
||||
.dif-name { font-weight: 500; flex: 1; }
|
||||
.dif-qty { white-space: nowrap; color: #6b7280; }
|
||||
.dif-val { white-space: nowrap; font-weight: 600; }
|
||||
.dif-codmat-link { color: #0d6efd; cursor: pointer; font-size: 0.78rem; font-family: monospace; }
|
||||
.dif-codmat-link:hover { color: #0a58ca; text-decoration: underline; }
|
||||
|
||||
@@ -12,21 +12,33 @@ let qmAcTimeout = null;
|
||||
let _pollInterval = null;
|
||||
let _lastSyncStatus = null;
|
||||
let _lastRunId = null;
|
||||
let _currentRunId = null;
|
||||
let _pollIntervalMs = 5000; // default, overridden from settings
|
||||
let _knownLastRunId = null; // track last_run.run_id to detect missed syncs
|
||||
|
||||
// ── Init ──────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await initPollInterval();
|
||||
loadSchedulerStatus();
|
||||
loadDashOrders();
|
||||
startSyncPolling();
|
||||
wireFilterBar();
|
||||
});
|
||||
|
||||
async function initPollInterval() {
|
||||
try {
|
||||
const data = await fetchJSON('/api/settings');
|
||||
const sec = parseInt(data.dashboard_poll_seconds) || 5;
|
||||
_pollIntervalMs = sec * 1000;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Smart Sync Polling ────────────────────────────
|
||||
|
||||
function startSyncPolling() {
|
||||
if (_pollInterval) clearInterval(_pollInterval);
|
||||
_pollInterval = setInterval(pollSyncStatus, 30000);
|
||||
_pollInterval = setInterval(pollSyncStatus, _pollIntervalMs);
|
||||
pollSyncStatus(); // immediate first call
|
||||
}
|
||||
|
||||
@@ -36,6 +48,12 @@ async function pollSyncStatus() {
|
||||
updateSyncPanel(data);
|
||||
const isRunning = data.status === 'running';
|
||||
const wasRunning = _lastSyncStatus === 'running';
|
||||
|
||||
// Detect missed sync completions via last_run.run_id change
|
||||
const newLastRunId = data.last_run?.run_id || null;
|
||||
const missedSync = !isRunning && !wasRunning && _knownLastRunId && newLastRunId && newLastRunId !== _knownLastRunId;
|
||||
_knownLastRunId = newLastRunId;
|
||||
|
||||
if (isRunning && !wasRunning) {
|
||||
// Switched to running — speed up polling
|
||||
clearInterval(_pollInterval);
|
||||
@@ -43,7 +61,10 @@ async function pollSyncStatus() {
|
||||
} else if (!isRunning && wasRunning) {
|
||||
// Sync just completed — slow down and refresh orders
|
||||
clearInterval(_pollInterval);
|
||||
_pollInterval = setInterval(pollSyncStatus, 30000);
|
||||
_pollInterval = setInterval(pollSyncStatus, _pollIntervalMs);
|
||||
loadDashOrders();
|
||||
} else if (missedSync) {
|
||||
// Sync completed while we weren't watching (e.g. auto-sync) — refresh orders
|
||||
loadDashOrders();
|
||||
}
|
||||
_lastSyncStatus = data.status;
|
||||
@@ -66,6 +87,13 @@ function updateSyncPanel(data) {
|
||||
if (txt) txt.textContent = statusLabels[data.status] || data.status || 'Inactiv';
|
||||
if (startBtn) startBtn.disabled = data.status === 'running';
|
||||
|
||||
// Track current running sync run_id
|
||||
if (data.status === 'running' && data.run_id) {
|
||||
_currentRunId = data.run_id;
|
||||
} else {
|
||||
_currentRunId = null;
|
||||
}
|
||||
|
||||
// Live progress area
|
||||
if (progressArea) {
|
||||
progressArea.style.display = data.status === 'running' ? 'flex' : 'none';
|
||||
@@ -84,7 +112,15 @@ function updateSyncPanel(data) {
|
||||
const st = document.getElementById('lastSyncStatus');
|
||||
if (d) d.textContent = lr.started_at ? lr.started_at.replace('T', ' ').slice(0, 16) : '\u2014';
|
||||
if (dur) dur.textContent = lr.duration_seconds ? Math.round(lr.duration_seconds) + 's' : '\u2014';
|
||||
if (cnt) cnt.textContent = '\u2191' + (lr.imported || 0) + ' \u2298' + (lr.skipped || 0) + ' \u2715' + (lr.errors || 0);
|
||||
if (cnt) {
|
||||
const newImp = lr.new_imported || 0;
|
||||
const already = lr.already_imported || 0;
|
||||
if (already > 0) {
|
||||
cnt.innerHTML = `<span class="dot dot-green me-1"></span>${newImp} noi, ${already} deja <span class="dot dot-yellow me-1"></span>${lr.skipped || 0} omise <span class="dot dot-red me-1"></span>${lr.errors || 0} erori`;
|
||||
} else {
|
||||
cnt.innerHTML = `<span class="dot dot-green me-1"></span>${lr.imported || 0} imp. <span class="dot dot-yellow me-1"></span>${lr.skipped || 0} omise <span class="dot dot-red me-1"></span>${lr.errors || 0} erori`;
|
||||
}
|
||||
}
|
||||
if (st) {
|
||||
st.textContent = lr.status === 'completed' ? '\u2713' : '\u2715';
|
||||
st.style.color = lr.status === 'completed' ? '#10b981' : '#ef4444';
|
||||
@@ -92,14 +128,16 @@ function updateSyncPanel(data) {
|
||||
}
|
||||
}
|
||||
|
||||
// Wire last-sync-row click → journal
|
||||
// Wire last-sync-row click → journal (use current running sync if active)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('lastSyncRow')?.addEventListener('click', () => {
|
||||
if (_lastRunId) window.location = '/logs?run=' + _lastRunId;
|
||||
const targetId = _currentRunId || _lastRunId;
|
||||
if (targetId) window.location = (window.ROOT_PATH || '') + '/logs?run=' + targetId;
|
||||
});
|
||||
document.getElementById('lastSyncRow')?.addEventListener('keydown', (e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && _lastRunId) {
|
||||
window.location = '/logs?run=' + _lastRunId;
|
||||
const targetId = _currentRunId || _lastRunId;
|
||||
if ((e.key === 'Enter' || e.key === ' ') && targetId) {
|
||||
window.location = '/logs?run=' + targetId;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -260,63 +298,86 @@ async function loadDashOrders() {
|
||||
const c = data.counts || {};
|
||||
const el = (id) => document.getElementById(id);
|
||||
if (el('cntAll')) el('cntAll').textContent = c.total || 0;
|
||||
if (el('cntImp')) el('cntImp').textContent = c.imported || 0;
|
||||
if (el('cntImp')) el('cntImp').textContent = c.imported_all || c.imported || 0;
|
||||
if (el('cntSkip')) el('cntSkip').textContent = c.skipped || 0;
|
||||
if (el('cntErr')) el('cntErr').textContent = c.error || c.errors || 0;
|
||||
if (el('cntNef')) el('cntNef').textContent = c.uninvoiced || c.nefacturate || 0;
|
||||
if (el('cntFact')) el('cntFact').textContent = c.facturate || 0;
|
||||
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
|
||||
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0;
|
||||
|
||||
const tbody = document.getElementById('dashOrdersBody');
|
||||
const orders = data.orders || [];
|
||||
|
||||
if (orders.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = orders.map(o => {
|
||||
const dateStr = fmtDate(o.order_date);
|
||||
const statusBadge = orderStatusBadge(o.status);
|
||||
|
||||
// Invoice info
|
||||
let invoiceBadge = '';
|
||||
let invoiceTotal = '';
|
||||
if (o.status !== 'IMPORTED') {
|
||||
invoiceBadge = '<span class="text-muted">-</span>';
|
||||
} else if (o.invoice && o.invoice.facturat) {
|
||||
invoiceBadge = `<span class="badge bg-success">Facturat</span>`;
|
||||
if (o.invoice.serie_act || o.invoice.numar_act) {
|
||||
invoiceBadge += `<br><small>${esc(o.invoice.serie_act || '')} ${esc(String(o.invoice.numar_act || ''))}</small>`;
|
||||
}
|
||||
invoiceTotal = o.invoice.total_cu_tva ? Number(o.invoice.total_cu_tva).toFixed(2) : '-';
|
||||
} else {
|
||||
invoiceBadge = '<span class="badge bg-danger">Nefacturat</span>';
|
||||
}
|
||||
const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
|
||||
|
||||
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
|
||||
<td><code>${esc(o.order_number)}</code></td>
|
||||
<td>${dateStr}</td>
|
||||
<td>${statusDot(o.status)}</td>
|
||||
<td class="text-nowrap">${dateStr}</td>
|
||||
${renderClientCell(o)}
|
||||
<td><code>${esc(o.order_number)}</code></td>
|
||||
<td>${o.items_count || 0}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${o.id_comanda || '-'}</td>
|
||||
<td>${invoiceBadge}</td>
|
||||
<td>${invoiceTotal}</td>
|
||||
<td class="text-end text-muted">${fmtCost(o.delivery_cost)}</td>
|
||||
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
|
||||
<td class="text-end fw-bold">${orderTotal}</td>
|
||||
<td class="text-center">${invoiceDot(o)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Mobile flat rows
|
||||
const mobileList = document.getElementById('dashMobileList');
|
||||
if (mobileList) {
|
||||
if (orders.length === 0) {
|
||||
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
|
||||
} else {
|
||||
mobileList.innerHTML = orders.map(o => {
|
||||
const d = o.order_date || '';
|
||||
let dateFmt = '-';
|
||||
if (d.length >= 10) {
|
||||
dateFmt = d.slice(8, 10) + '.' + d.slice(5, 7) + '.' + d.slice(2, 4);
|
||||
if (d.length >= 16) dateFmt += ' ' + d.slice(11, 16);
|
||||
}
|
||||
const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014';
|
||||
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
|
||||
return `<div class="flat-row" onclick="openDashOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
||||
${statusDot(o.status)}
|
||||
<span style="color:#6b7280" class="text-nowrap">${dateFmt}</span>
|
||||
<span class="grow truncate fw-bold">${esc(name)}</span>
|
||||
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile segmented control
|
||||
renderMobileSegmented('dashMobileSeg', [
|
||||
{ label: 'Toate', count: c.total || 0, value: 'all', active: (activeStatus || 'all') === 'all', colorClass: 'fc-neutral' },
|
||||
{ label: 'Imp.', count: c.imported_all || c.imported || 0, value: 'IMPORTED', active: activeStatus === 'IMPORTED', colorClass: 'fc-green' },
|
||||
{ label: 'Omise', count: c.skipped || 0, value: 'SKIPPED', active: activeStatus === 'SKIPPED', colorClass: 'fc-yellow' },
|
||||
{ label: 'Erori', count: c.error || c.errors || 0, value: 'ERROR', active: activeStatus === 'ERROR', colorClass: 'fc-red' },
|
||||
{ label: 'Fact.', count: c.facturate || 0, value: 'INVOICED', active: activeStatus === 'INVOICED', colorClass: 'fc-green' },
|
||||
{ label: 'Nefact.', count: c.nefacturate || c.uninvoiced || 0, value: 'UNINVOICED', active: activeStatus === 'UNINVOICED', colorClass: 'fc-red' },
|
||||
{ label: 'Anulate', count: c.cancelled || 0, value: 'CANCELLED', active: activeStatus === 'CANCELLED', colorClass: 'fc-dark' }
|
||||
], (val) => {
|
||||
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
|
||||
const pill = document.querySelector(`.filter-pill[data-status="${val}"]`);
|
||||
if (pill) pill.classList.add('active');
|
||||
dashPage = 1;
|
||||
loadDashOrders();
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const pag = data.pagination || {};
|
||||
const totalPages = pag.total_pages || data.pages || 1;
|
||||
const totalOrders = (data.counts || {}).total || data.total || 0;
|
||||
const pageInfo = `${totalOrders} comenzi | Pagina ${dashPage} din ${totalPages}`;
|
||||
document.getElementById('dashPageInfo').textContent = pageInfo;
|
||||
const pagInfoTop = document.getElementById('dashPageInfoTop');
|
||||
if (pagInfoTop) pagInfoTop.textContent = pageInfo;
|
||||
|
||||
const pagHtml = totalPages > 1 ? `
|
||||
<button class="btn btn-sm btn-outline-secondary" ${dashPage <= 1 ? 'disabled' : ''} onclick="dashGoPage(${dashPage - 1})"><i class="bi bi-chevron-left"></i></button>
|
||||
<small class="text-muted">${dashPage} / ${totalPages}</small>
|
||||
<button class="btn btn-sm btn-outline-secondary" ${dashPage >= totalPages ? 'disabled' : ''} onclick="dashGoPage(${dashPage + 1})"><i class="bi bi-chevron-right"></i></button>
|
||||
` : '';
|
||||
const pagOpts = { perPage: dashPerPage, perPageFn: 'dashChangePerPage', perPageOptions: [25, 50, 100, 250] };
|
||||
const pagHtml = `<small class="text-muted me-auto">${totalOrders} comenzi | Pagina ${dashPage} din ${totalPages}</small>` + renderUnifiedPagination(dashPage, totalPages, 'dashGoPage', pagOpts);
|
||||
const pagDiv = document.getElementById('dashPagination');
|
||||
if (pagDiv) pagDiv.innerHTML = pagHtml;
|
||||
const pagDivTop = document.getElementById('dashPaginationTop');
|
||||
@@ -329,7 +390,7 @@ async function loadDashOrders() {
|
||||
});
|
||||
} catch (err) {
|
||||
document.getElementById('dashOrdersBody').innerHTML =
|
||||
`<tr><td colspan="8" class="text-center text-danger">${esc(err.message)}</td></tr>`;
|
||||
`<tr><td colspan="9" class="text-center text-danger">${esc(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,13 +408,14 @@ function dashChangePerPage(val) {
|
||||
// ── Client cell with Cont tooltip (Task F4) ───────
|
||||
|
||||
function renderClientCell(order) {
|
||||
const shipping = (order.shipping_name || order.customer_name || '').trim();
|
||||
const display = (order.customer_name || order.shipping_name || '').trim();
|
||||
const billing = (order.billing_name || '').trim();
|
||||
const isDiff = order.is_different_person && billing && shipping !== billing;
|
||||
const shipping = (order.shipping_name || '').trim();
|
||||
const isDiff = display !== shipping && shipping;
|
||||
if (isDiff) {
|
||||
return `<td class="tooltip-cont" data-tooltip="Cont: ${escHtml(billing)}">${escHtml(shipping)} <sup style="color:#6b7280;font-size:0.65rem">▲</sup></td>`;
|
||||
return `<td class="tooltip-cont fw-bold" data-tooltip="Livrare: ${escHtml(shipping)}">${escHtml(display)} <sup style="color:#6b7280;font-size:0.65rem">▲</sup></td>`;
|
||||
}
|
||||
return `<td>${escHtml(shipping || billing || '\u2014')}</td>`;
|
||||
return `<td class="fw-bold">${escHtml(display || billing || '\u2014')}</td>`;
|
||||
}
|
||||
|
||||
// ── Helper functions ──────────────────────────────
|
||||
@@ -377,33 +439,48 @@ function escHtml(s) {
|
||||
// Alias kept for backward compat with inline handlers in modal
|
||||
function esc(s) { return escHtml(s); }
|
||||
|
||||
function fmtDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
const hasTime = dateStr.includes(':');
|
||||
if (hasTime) {
|
||||
return d.toLocaleString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
function fmtCost(v) {
|
||||
return v > 0 ? Number(v).toFixed(2) : '–';
|
||||
}
|
||||
|
||||
|
||||
function statusLabelText(status) {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'IMPORTED': return 'Importat';
|
||||
case 'ALREADY_IMPORTED': return 'Deja imp.';
|
||||
case 'SKIPPED': return 'Omis';
|
||||
case 'ERROR': return 'Eroare';
|
||||
default: return esc(status);
|
||||
}
|
||||
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
} catch { return dateStr; }
|
||||
}
|
||||
|
||||
function orderStatusBadge(status) {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
|
||||
case 'SKIPPED': return '<span class="badge bg-warning text-dark">Omis</span>';
|
||||
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
|
||||
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
|
||||
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
|
||||
case 'CANCELLED': return '<span class="badge bg-secondary">Anulat</span>';
|
||||
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</span>';
|
||||
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function invoiceDot(order) {
|
||||
if (order.status !== 'IMPORTED' && order.status !== 'ALREADY_IMPORTED') return '–';
|
||||
if (order.invoice && order.invoice.facturat) return '<span class="dot dot-green" title="Facturat"></span>';
|
||||
return '<span class="dot dot-red" title="Nefacturat"></span>';
|
||||
}
|
||||
|
||||
function renderCodmatCell(item) {
|
||||
if (!item.codmat_details || item.codmat_details.length === 0) {
|
||||
return `<code>${esc(item.codmat || '-')}</code>`;
|
||||
}
|
||||
if (item.codmat_details.length === 1) {
|
||||
const d = item.codmat_details[0];
|
||||
if (d.direct) {
|
||||
return `<code>${esc(d.codmat)}</code> <span class="badge bg-secondary" style="font-size:0.6rem;vertical-align:middle">direct</span>`;
|
||||
}
|
||||
return `<code>${esc(d.codmat)}</code>`;
|
||||
}
|
||||
return item.codmat_details.map(d =>
|
||||
@@ -411,6 +488,29 @@ function renderCodmatCell(item) {
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ── Refresh Invoices ──────────────────────────────
|
||||
|
||||
async function refreshInvoices() {
|
||||
const btn = document.getElementById('btnRefreshInvoices');
|
||||
const btnM = document.getElementById('btnRefreshInvoicesMobile');
|
||||
if (btn) { btn.disabled = true; btn.textContent = '⟳ Se verifica...'; }
|
||||
if (btnM) { btnM.disabled = true; }
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/refresh-invoices', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
alert('Eroare: ' + data.error);
|
||||
} else {
|
||||
loadDashOrders();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + err.message);
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = '↻ Facturi'; }
|
||||
if (btnM) { btnM.disabled = false; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Order Detail Modal ────────────────────────────
|
||||
|
||||
async function openDashOrderDetail(orderNumber) {
|
||||
@@ -422,8 +522,16 @@ async function openDashOrderDetail(orderNumber) {
|
||||
document.getElementById('detailIdPartener').textContent = '-';
|
||||
document.getElementById('detailIdAdresaFact').textContent = '-';
|
||||
document.getElementById('detailIdAdresaLivr').textContent = '-';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="8" class="text-center">Se incarca...</td></tr>';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center">Se incarca...</td></tr>';
|
||||
document.getElementById('detailError').style.display = 'none';
|
||||
const invInfo = document.getElementById('detailInvoiceInfo');
|
||||
if (invInfo) invInfo.style.display = 'none';
|
||||
const detailItemsTotal = document.getElementById('detailItemsTotal');
|
||||
if (detailItemsTotal) detailItemsTotal.textContent = '-';
|
||||
const detailOrderTotal = document.getElementById('detailOrderTotal');
|
||||
if (detailOrderTotal) detailOrderTotal.textContent = '-';
|
||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||
if (mobileContainer) mobileContainer.innerHTML = '';
|
||||
|
||||
const modalEl = document.getElementById('orderDetailModal');
|
||||
const existing = bootstrap.Modal.getInstance(modalEl);
|
||||
@@ -448,39 +556,87 @@ async function openDashOrderDetail(orderNumber) {
|
||||
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
|
||||
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
|
||||
|
||||
// Invoice info
|
||||
const invInfo = document.getElementById('detailInvoiceInfo');
|
||||
const inv = order.invoice;
|
||||
if (inv && inv.facturat) {
|
||||
const serie = inv.serie_act || '';
|
||||
const numar = inv.numar_act || '';
|
||||
document.getElementById('detailInvoiceNumber').textContent = serie ? `${serie} ${numar}` : numar;
|
||||
document.getElementById('detailInvoiceDate').textContent = inv.data_act ? fmtDate(inv.data_act) : '-';
|
||||
if (invInfo) invInfo.style.display = '';
|
||||
} else {
|
||||
if (invInfo) invInfo.style.display = 'none';
|
||||
}
|
||||
|
||||
if (order.error_message) {
|
||||
document.getElementById('detailError').textContent = order.error_message;
|
||||
document.getElementById('detailError').style.display = '';
|
||||
}
|
||||
|
||||
const dlvEl = document.getElementById('detailDeliveryCost');
|
||||
if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : '–';
|
||||
|
||||
const dscEl = document.getElementById('detailDiscount');
|
||||
if (dscEl) {
|
||||
if (order.discount_total > 0 && order.discount_split && typeof order.discount_split === 'object') {
|
||||
const entries = Object.entries(order.discount_split);
|
||||
if (entries.length > 1) {
|
||||
const parts = entries.map(([vat, amt]) => `–${Number(amt).toFixed(2)} (TVA ${vat}%)`);
|
||||
dscEl.innerHTML = parts.join('<br>');
|
||||
} else {
|
||||
dscEl.textContent = '–' + Number(order.discount_total).toFixed(2) + ' lei';
|
||||
}
|
||||
} else {
|
||||
dscEl.textContent = order.discount_total > 0 ? '–' + Number(order.discount_total).toFixed(2) + ' lei' : '–';
|
||||
}
|
||||
}
|
||||
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) {
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="8" class="text-center text-muted">Niciun articol</td></tr>';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center text-muted">Niciun articol</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('detailItemsBody').innerHTML = items.map(item => {
|
||||
let statusBadge;
|
||||
switch (item.mapping_status) {
|
||||
case 'mapped': statusBadge = '<span class="badge bg-success">Mapat</span>'; break;
|
||||
case 'direct': statusBadge = '<span class="badge bg-info">Direct</span>'; break;
|
||||
case 'missing': statusBadge = '<span class="badge bg-warning text-dark">Lipsa</span>'; break;
|
||||
default: statusBadge = '<span class="badge bg-secondary">?</span>';
|
||||
// Update totals row
|
||||
const itemsTotal = items.reduce((sum, item) => sum + (Number(item.price || 0) * Number(item.quantity || 0)), 0);
|
||||
document.getElementById('detailItemsTotal').textContent = itemsTotal.toFixed(2) + ' lei';
|
||||
document.getElementById('detailOrderTotal').textContent = order.order_total != null ? Number(order.order_total).toFixed(2) + ' lei' : '-';
|
||||
|
||||
// Store items for quick map pre-population
|
||||
window._detailItems = items;
|
||||
|
||||
// Mobile article flat list
|
||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||
if (mobileContainer) {
|
||||
mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => {
|
||||
const codmatText = item.codmat_details?.length
|
||||
? item.codmat_details.map(d => `<code>${esc(d.codmat)}</code>${d.direct ? ' <span class="badge bg-secondary" style="font-size:0.55rem">direct</span>' : ''}`).join(' ')
|
||||
: `<code>${esc(item.codmat || '–')}</code>`;
|
||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
||||
return `<div class="dif-item">
|
||||
<div class="dif-row">
|
||||
<span class="dif-sku dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}', ${idx})">${esc(item.sku)}</span>
|
||||
${codmatText}
|
||||
</div>
|
||||
<div class="dif-row">
|
||||
<span class="dif-name">${esc(item.product_name || '–')}</span>
|
||||
<span class="dif-qty">x${item.quantity || 0}</span>
|
||||
<span class="dif-val">${valoare} lei</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
const action = item.mapping_status === 'missing'
|
||||
? `<a href="#" class="btn-map-icon" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}'); return false;" title="Mapeaza"><i class="bi bi-link-45deg"></i></a>`
|
||||
: '';
|
||||
|
||||
document.getElementById('detailItemsBody').innerHTML = items.map((item, idx) => {
|
||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
||||
return `<tr>
|
||||
<td><code>${esc(item.sku)}</code></td>
|
||||
<td><code class="codmat-link" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare">${esc(item.sku)}</code></td>
|
||||
<td>${esc(item.product_name || '-')}</td>
|
||||
<td>${renderCodmatCell(item)}</td>
|
||||
<td>${item.quantity || 0}</td>
|
||||
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
|
||||
<td>${item.vat != null ? Number(item.vat).toFixed(2) : '-'}</td>
|
||||
<td>${renderCodmatCell(item)}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${action}</td>
|
||||
<td class="text-end">${valoare}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
@@ -491,7 +647,7 @@ async function openDashOrderDetail(orderNumber) {
|
||||
|
||||
// ── Quick Map Modal ───────────────────────────────
|
||||
|
||||
function openQuickMap(sku, productName, orderNumber) {
|
||||
function openQuickMap(sku, productName, orderNumber, itemIdx) {
|
||||
currentQmSku = sku;
|
||||
currentQmOrderNumber = orderNumber;
|
||||
document.getElementById('qmSku').textContent = sku;
|
||||
@@ -500,36 +656,60 @@ function openQuickMap(sku, productName, orderNumber) {
|
||||
|
||||
const container = document.getElementById('qmCodmatLines');
|
||||
container.innerHTML = '';
|
||||
|
||||
// Check if this is a direct SKU (SKU=CODMAT in NOM_ARTICOLE)
|
||||
const item = (window._detailItems || [])[itemIdx];
|
||||
const details = item?.codmat_details;
|
||||
const isDirect = details?.length === 1 && details[0].direct === true;
|
||||
const directInfo = document.getElementById('qmDirectInfo');
|
||||
const saveBtn = document.getElementById('qmSaveBtn');
|
||||
|
||||
if (isDirect) {
|
||||
if (directInfo) {
|
||||
directInfo.innerHTML = `<i class="bi bi-info-circle"></i> SKU = CODMAT direct in nomenclator (<code>${escHtml(details[0].codmat)}</code> — ${escHtml(details[0].denumire || '')}).<br><small class="text-muted">Poti suprascrie cu un alt CODMAT daca e necesar (ex: reambalare).</small>`;
|
||||
directInfo.style.display = '';
|
||||
}
|
||||
if (saveBtn) {
|
||||
saveBtn.textContent = 'Suprascrie mapare';
|
||||
}
|
||||
addQmCodmatLine();
|
||||
} else {
|
||||
if (directInfo) directInfo.style.display = 'none';
|
||||
if (saveBtn) saveBtn.textContent = 'Salveaza';
|
||||
|
||||
// Pre-populate with existing codmat_details if available
|
||||
if (details && details.length > 0) {
|
||||
details.forEach(d => {
|
||||
addQmCodmatLine({ codmat: d.codmat, cantitate: d.cantitate_roa, procent: d.procent_pret, denumire: d.denumire });
|
||||
});
|
||||
} else {
|
||||
addQmCodmatLine();
|
||||
}
|
||||
}
|
||||
|
||||
new bootstrap.Modal(document.getElementById('quickMapModal')).show();
|
||||
}
|
||||
|
||||
function addQmCodmatLine() {
|
||||
function addQmCodmatLine(prefill) {
|
||||
const container = document.getElementById('qmCodmatLines');
|
||||
const idx = container.children.length;
|
||||
const codmatVal = prefill?.codmat || '';
|
||||
const cantVal = prefill?.cantitate || 1;
|
||||
const pctVal = prefill?.procent || 100;
|
||||
const denumireVal = prefill?.denumire || '';
|
||||
const div = document.createElement('div');
|
||||
div.className = 'border rounded p-2 mb-2 qm-line';
|
||||
div.className = 'qm-line';
|
||||
div.innerHTML = `
|
||||
<div class="mb-2 position-relative">
|
||||
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label>
|
||||
<input type="text" class="form-control form-control-sm qm-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
|
||||
<div class="qm-row">
|
||||
<div class="qm-codmat-wrap position-relative">
|
||||
<input type="text" class="form-control form-control-sm qm-codmat" placeholder="CODMAT..." autocomplete="off" value="${escHtml(codmatVal)}">
|
||||
<div class="autocomplete-dropdown d-none qm-ac-dropdown"></div>
|
||||
<small class="text-muted qm-selected"></small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
|
||||
<input type="number" class="form-control form-control-sm qm-cantitate" value="1" step="0.001" min="0.001">
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label>
|
||||
<input type="number" class="form-control form-control-sm qm-procent" value="100" step="0.01" min="0" max="100">
|
||||
</div>
|
||||
<div class="col-2 d-flex align-items-end">
|
||||
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.qm-line').remove()"><i class="bi bi-x"></i></button>` : ''}
|
||||
</div>
|
||||
<input type="number" class="form-control form-control-sm qm-cantitate" value="${cantVal}" step="0.001" min="0.001" title="Cantitate ROA" style="width:70px">
|
||||
<input type="number" class="form-control form-control-sm qm-procent" value="${pctVal}" step="0.01" min="0" max="100" title="Procent %" style="width:70px">
|
||||
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger qm-rm-btn" onclick="this.closest('.qm-line').remove()"><i class="bi bi-x"></i></button>` : '<span style="width:30px"></span>'}
|
||||
</div>
|
||||
<div class="qm-selected text-muted" style="font-size:0.75rem;padding-left:2px">${escHtml(denumireVal)}</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
|
||||
@@ -616,9 +796,12 @@ async function saveQuickMapping() {
|
||||
if (currentQmOrderNumber) openDashOrderDetail(currentQmOrderNumber);
|
||||
loadDashOrders();
|
||||
} else {
|
||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||
const msg = data.detail || data.error || 'Unknown';
|
||||
document.getElementById('qmPctWarning').textContent = msg;
|
||||
document.getElementById('qmPctWarning').style.display = '';
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,15 +7,11 @@ let currentFilter = 'all';
|
||||
let ordersPage = 1;
|
||||
let currentQmSku = '';
|
||||
let currentQmOrderNumber = '';
|
||||
let ordersSortColumn = 'created_at';
|
||||
let ordersSortDirection = 'asc';
|
||||
let ordersSortColumn = 'order_date';
|
||||
let ordersSortDirection = 'desc';
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
function fmtCost(v) {
|
||||
return v > 0 ? Number(v).toFixed(2) : '–';
|
||||
}
|
||||
|
||||
function fmtDuration(startedAt, finishedAt) {
|
||||
@@ -27,36 +23,38 @@ function fmtDuration(startedAt, finishedAt) {
|
||||
return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
|
||||
}
|
||||
|
||||
function fmtDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
const hasTime = dateStr.includes(':');
|
||||
if (hasTime) {
|
||||
return d.toLocaleString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
} catch { return dateStr; }
|
||||
}
|
||||
|
||||
function runStatusBadge(status) {
|
||||
switch ((status || '').toLowerCase()) {
|
||||
case 'completed': return '<span class="badge bg-success">completed</span>';
|
||||
case 'running': return '<span class="badge bg-primary">running</span>';
|
||||
case 'failed': return '<span class="badge bg-danger">failed</span>';
|
||||
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
||||
case 'completed': return '<span style="color:#16a34a;font-weight:600">completed</span>';
|
||||
case 'running': return '<span style="color:#2563eb;font-weight:600">running</span>';
|
||||
case 'failed': return '<span style="color:#dc2626;font-weight:600">failed</span>';
|
||||
default: return `<span style="font-weight:600">${esc(status)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function orderStatusBadge(status) {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
|
||||
case 'SKIPPED': return '<span class="badge bg-warning text-dark">Omis</span>';
|
||||
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
|
||||
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
|
||||
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
|
||||
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</span>';
|
||||
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function logStatusText(status) {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'IMPORTED': return 'Importat';
|
||||
case 'ALREADY_IMPORTED': return 'Deja imp.';
|
||||
case 'SKIPPED': return 'Omis';
|
||||
case 'ERROR': return 'Eroare';
|
||||
default: return esc(status);
|
||||
}
|
||||
}
|
||||
|
||||
function logsGoPage(p) { loadRunOrders(currentRunId, null, p); }
|
||||
|
||||
// ── Runs Dropdown ────────────────────────────────
|
||||
|
||||
async function loadRuns() {
|
||||
@@ -76,14 +74,19 @@ async function loadRuns() {
|
||||
const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '?';
|
||||
const st = (r.status || '').toUpperCase();
|
||||
const statusEmoji = st === 'COMPLETED' ? '✓' : st === 'RUNNING' ? '⟳' : '✗';
|
||||
const newImp = r.new_imported || 0;
|
||||
const already = r.already_imported || 0;
|
||||
const imp = r.imported || 0;
|
||||
const skip = r.skipped || 0;
|
||||
const err = r.errors || 0;
|
||||
const label = `${started} — ${statusEmoji} ${r.status} (${imp} imp, ${skip} skip, ${err} err)`;
|
||||
const impLabel = already > 0 ? `${newImp} noi, ${already} deja` : `${imp} imp`;
|
||||
const label = `${started} — ${statusEmoji} ${r.status} (${impLabel}, ${skip} skip, ${err} err)`;
|
||||
const selected = r.run_id === currentRunId ? 'selected' : '';
|
||||
return `<option value="${esc(r.run_id)}" ${selected}>${esc(label)}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
const ddMobile = document.getElementById('runsDropdownMobile');
|
||||
if (ddMobile) ddMobile.innerHTML = dd.innerHTML;
|
||||
} catch (err) {
|
||||
const dd = document.getElementById('runsDropdown');
|
||||
dd.innerHTML = `<option value="">Eroare: ${esc(err.message)}</option>`;
|
||||
@@ -106,6 +109,8 @@ async function selectRun(runId) {
|
||||
// Sync dropdown selection
|
||||
const dd = document.getElementById('runsDropdown');
|
||||
if (dd && dd.value !== runId) dd.value = runId;
|
||||
const ddMobile = document.getElementById('runsDropdownMobile');
|
||||
if (ddMobile && ddMobile.value !== runId) ddMobile.value = runId;
|
||||
|
||||
if (!runId) {
|
||||
document.getElementById('logViewerSection').style.display = 'none';
|
||||
@@ -113,8 +118,8 @@ async function selectRun(runId) {
|
||||
}
|
||||
|
||||
document.getElementById('logViewerSection').style.display = '';
|
||||
document.getElementById('logRunId').textContent = runId;
|
||||
document.getElementById('logStatusBadge').innerHTML = '<span class="badge bg-secondary">...</span>';
|
||||
const logRunIdEl = document.getElementById('logRunId'); if (logRunIdEl) logRunIdEl.textContent = runId;
|
||||
document.getElementById('logStatusBadge').innerHTML = '...';
|
||||
document.getElementById('textLogSection').style.display = 'none';
|
||||
|
||||
await loadRunOrders(runId, 'all', 1);
|
||||
@@ -129,12 +134,9 @@ async function loadRunOrders(runId, statusFilter, page) {
|
||||
if (statusFilter != null) currentFilter = statusFilter;
|
||||
if (page != null) ordersPage = page;
|
||||
|
||||
// Update filter button styles
|
||||
document.querySelectorAll('#orderFilterBtns button').forEach(btn => {
|
||||
btn.className = btn.className.replace(' btn-primary', ' btn-outline-primary')
|
||||
.replace(' btn-success', ' btn-outline-success')
|
||||
.replace(' btn-warning', ' btn-outline-warning')
|
||||
.replace(' btn-danger', ' btn-outline-danger');
|
||||
// Update filter pill active state
|
||||
document.querySelectorAll('#orderFilterPills .filter-pill').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.logStatus === currentFilter);
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -147,60 +149,87 @@ async function loadRunOrders(runId, statusFilter, page) {
|
||||
document.getElementById('countImported').textContent = counts.imported || 0;
|
||||
document.getElementById('countSkipped').textContent = counts.skipped || 0;
|
||||
document.getElementById('countError').textContent = counts.error || 0;
|
||||
|
||||
// Highlight active filter
|
||||
const filterMap = { 'all': 0, 'IMPORTED': 1, 'SKIPPED': 2, 'ERROR': 3 };
|
||||
const btns = document.querySelectorAll('#orderFilterBtns button');
|
||||
const idx = filterMap[currentFilter] || 0;
|
||||
if (btns[idx]) {
|
||||
const colorMap = ['primary', 'success', 'warning', 'danger'];
|
||||
btns[idx].className = btns[idx].className.replace(`btn-outline-${colorMap[idx]}`, `btn-${colorMap[idx]}`);
|
||||
}
|
||||
const alreadyEl = document.getElementById('countAlreadyImported');
|
||||
if (alreadyEl) alreadyEl.textContent = counts.already_imported || 0;
|
||||
|
||||
const tbody = document.getElementById('runOrdersBody');
|
||||
const orders = data.orders || [];
|
||||
|
||||
if (orders.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = orders.map((o, i) => {
|
||||
const dateStr = fmtDate(o.order_date);
|
||||
const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
|
||||
return `<tr style="cursor:pointer" onclick="openOrderDetail('${esc(o.order_number)}')">
|
||||
<td>${statusDot(o.status)}</td>
|
||||
<td>${(ordersPage - 1) * 50 + i + 1}</td>
|
||||
<td>${dateStr}</td>
|
||||
<td class="text-nowrap">${dateStr}</td>
|
||||
<td><code>${esc(o.order_number)}</code></td>
|
||||
<td>${esc(o.customer_name)}</td>
|
||||
<td class="fw-bold">${esc(o.customer_name)}</td>
|
||||
<td>${o.items_count || 0}</td>
|
||||
<td>${orderStatusBadge(o.status)}</td>
|
||||
<td class="text-end text-muted">${fmtCost(o.delivery_cost)}</td>
|
||||
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
|
||||
<td class="text-end fw-bold">${orderTotal}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Mobile flat rows
|
||||
const mobileList = document.getElementById('logsMobileList');
|
||||
if (mobileList) {
|
||||
if (orders.length === 0) {
|
||||
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
|
||||
} else {
|
||||
mobileList.innerHTML = orders.map(o => {
|
||||
const d = o.order_date || '';
|
||||
let dateFmt = '-';
|
||||
if (d.length >= 10) {
|
||||
dateFmt = d.slice(8, 10) + '.' + d.slice(5, 7) + '.' + d.slice(2, 4);
|
||||
if (d.length >= 16) dateFmt += ' ' + d.slice(11, 16);
|
||||
}
|
||||
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
|
||||
return `<div class="flat-row" onclick="openOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
||||
${statusDot(o.status)}
|
||||
<span style="color:#6b7280" class="text-nowrap">${dateFmt}</span>
|
||||
<span class="grow truncate fw-bold">${esc(o.customer_name || '—')}</span>
|
||||
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile segmented control
|
||||
renderMobileSegmented('logsMobileSeg', [
|
||||
{ label: 'Toate', count: counts.total || 0, value: 'all', active: currentFilter === 'all', colorClass: 'fc-neutral' },
|
||||
{ label: 'Imp.', count: counts.imported || 0, value: 'IMPORTED', active: currentFilter === 'IMPORTED', colorClass: 'fc-green' },
|
||||
{ label: 'Deja', count: counts.already_imported || 0, value: 'ALREADY_IMPORTED', active: currentFilter === 'ALREADY_IMPORTED', colorClass: 'fc-blue' },
|
||||
{ label: 'Omise', count: counts.skipped || 0, value: 'SKIPPED', active: currentFilter === 'SKIPPED', colorClass: 'fc-yellow' },
|
||||
{ label: 'Erori', count: counts.error || 0, value: 'ERROR', active: currentFilter === 'ERROR', colorClass: 'fc-red' }
|
||||
], (val) => filterOrders(val));
|
||||
|
||||
// Orders pagination
|
||||
const totalPages = data.pages || 1;
|
||||
const infoEl = document.getElementById('ordersPageInfo');
|
||||
infoEl.textContent = `${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}`;
|
||||
|
||||
if (infoEl) infoEl.textContent = `${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}`;
|
||||
const pagHtml = `<small class="text-muted me-auto">${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}</small>` + renderUnifiedPagination(ordersPage, totalPages, 'logsGoPage');
|
||||
const pagDiv = document.getElementById('ordersPagination');
|
||||
if (totalPages > 1) {
|
||||
pagDiv.innerHTML = `
|
||||
<button class="btn btn-sm btn-outline-secondary" ${ordersPage <= 1 ? 'disabled' : ''} onclick="loadRunOrders('${esc(runId)}', null, ${ordersPage - 1})"><i class="bi bi-chevron-left"></i></button>
|
||||
<small class="text-muted">${ordersPage} / ${totalPages}</small>
|
||||
<button class="btn btn-sm btn-outline-secondary" ${ordersPage >= totalPages ? 'disabled' : ''} onclick="loadRunOrders('${esc(runId)}', null, ${ordersPage + 1})"><i class="bi bi-chevron-right"></i></button>
|
||||
`;
|
||||
} else {
|
||||
pagDiv.innerHTML = '';
|
||||
}
|
||||
if (pagDiv) pagDiv.innerHTML = pagHtml;
|
||||
const pagDivTop = document.getElementById('ordersPaginationTop');
|
||||
if (pagDivTop) pagDivTop.innerHTML = pagHtml;
|
||||
|
||||
// Update run status badge
|
||||
const runRes = await fetch(`/api/sync/run/${encodeURIComponent(runId)}`);
|
||||
const runData = await runRes.json();
|
||||
if (runData.run) {
|
||||
document.getElementById('logStatusBadge').innerHTML = runStatusBadge(runData.run.status);
|
||||
// Update mobile run dot
|
||||
const mDot = document.getElementById('mobileRunDot');
|
||||
if (mDot) mDot.className = 'sync-status-dot ' + (runData.run.status || 'idle');
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('runOrdersBody').innerHTML =
|
||||
`<tr><td colspan="6" class="text-center text-danger">${esc(err.message)}</td></tr>`;
|
||||
`<tr><td colspan="9" class="text-center text-danger">${esc(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,8 +325,14 @@ async function openOrderDetail(orderNumber) {
|
||||
document.getElementById('detailIdPartener').textContent = '-';
|
||||
document.getElementById('detailIdAdresaFact').textContent = '-';
|
||||
document.getElementById('detailIdAdresaLivr').textContent = '-';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="8" class="text-center">Se incarca...</td></tr>';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center">Se incarca...</td></tr>';
|
||||
document.getElementById('detailError').style.display = 'none';
|
||||
const detailItemsTotal = document.getElementById('detailItemsTotal');
|
||||
if (detailItemsTotal) detailItemsTotal.textContent = '-';
|
||||
const detailOrderTotal = document.getElementById('detailOrderTotal');
|
||||
if (detailOrderTotal) detailOrderTotal.textContent = '-';
|
||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||
if (mobileContainer) mobileContainer.innerHTML = '';
|
||||
|
||||
const modalEl = document.getElementById('orderDetailModal');
|
||||
const existing = bootstrap.Modal.getInstance(modalEl);
|
||||
@@ -327,34 +362,55 @@ async function openOrderDetail(orderNumber) {
|
||||
document.getElementById('detailError').style.display = '';
|
||||
}
|
||||
|
||||
const dlvEl = document.getElementById('detailDeliveryCost');
|
||||
if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : '–';
|
||||
|
||||
const dscEl = document.getElementById('detailDiscount');
|
||||
if (dscEl) dscEl.textContent = order.discount_total > 0 ? '–' + Number(order.discount_total).toFixed(2) + ' lei' : '–';
|
||||
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) {
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="8" class="text-center text-muted">Niciun articol</td></tr>';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center text-muted">Niciun articol</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('detailItemsBody').innerHTML = items.map(item => {
|
||||
let statusBadge;
|
||||
switch (item.mapping_status) {
|
||||
case 'mapped': statusBadge = '<span class="badge bg-success">Mapat</span>'; break;
|
||||
case 'direct': statusBadge = '<span class="badge bg-info">Direct</span>'; break;
|
||||
case 'missing': statusBadge = '<span class="badge bg-warning text-dark">Lipsa</span>'; break;
|
||||
default: statusBadge = '<span class="badge bg-secondary">?</span>';
|
||||
// Update totals row
|
||||
const itemsTotal = items.reduce((sum, item) => sum + (Number(item.price || 0) * Number(item.quantity || 0)), 0);
|
||||
document.getElementById('detailItemsTotal').textContent = itemsTotal.toFixed(2) + ' lei';
|
||||
document.getElementById('detailOrderTotal').textContent = order.order_total != null ? Number(order.order_total).toFixed(2) + ' lei' : '-';
|
||||
|
||||
// Mobile article flat list
|
||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||
if (mobileContainer) {
|
||||
mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => {
|
||||
const codmatList = item.codmat_details?.length
|
||||
? item.codmat_details.map(d => `<span class="dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(d.codmat)}</span>`).join(' ')
|
||||
: `<span class="dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(item.codmat || '–')}</span>`;
|
||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
||||
return `<div class="dif-item">
|
||||
<div class="dif-row">
|
||||
<span class="dif-sku">${esc(item.sku)}</span>
|
||||
${codmatList}
|
||||
</div>
|
||||
<div class="dif-row">
|
||||
<span class="dif-name">${esc(item.product_name || '–')}</span>
|
||||
<span class="dif-qty">x${item.quantity || 0}</span>
|
||||
<span class="dif-val">${valoare} lei</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
const action = item.mapping_status === 'missing'
|
||||
? `<a href="#" class="btn-map-icon" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}'); return false;" title="Mapeaza"><i class="bi bi-link-45deg"></i></a>`
|
||||
: '';
|
||||
|
||||
document.getElementById('detailItemsBody').innerHTML = items.map(item => {
|
||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
||||
const codmatCell = `<span class="codmat-link" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}')" title="Click pentru mapare">${renderCodmatCell(item)}</span>`;
|
||||
return `<tr>
|
||||
<td><code>${esc(item.sku)}</code></td>
|
||||
<td>${esc(item.product_name || '-')}</td>
|
||||
<td>${codmatCell}</td>
|
||||
<td>${item.quantity || 0}</td>
|
||||
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
|
||||
<td>${item.vat != null ? Number(item.vat).toFixed(2) : '-'}</td>
|
||||
<td>${renderCodmatCell(item)}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${action}</td>
|
||||
<td class="text-end">${valoare}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
@@ -510,6 +566,12 @@ async function saveQuickMapping() {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadRuns();
|
||||
|
||||
document.querySelectorAll('#orderFilterPills .filter-pill').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
filterOrders(this.dataset.logStatus || 'all');
|
||||
});
|
||||
});
|
||||
|
||||
const preselected = document.getElementById('preselectedRun');
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : '');
|
||||
@@ -526,4 +588,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('autoRefreshToggleMobile')?.addEventListener('change', (e) => {
|
||||
const desktop = document.getElementById('autoRefreshToggle');
|
||||
if (desktop) desktop.checked = e.target.checked;
|
||||
desktop?.dispatchEvent(new Event('change'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
let currentPage = 1;
|
||||
let mappingsPerPage = 50;
|
||||
let currentSearch = '';
|
||||
let searchTimeout = null;
|
||||
let sortColumn = 'sku';
|
||||
@@ -69,6 +70,20 @@ function updatePctCounts(counts) {
|
||||
if (elAll) elAll.textContent = counts.total || 0;
|
||||
if (elComplete) elComplete.textContent = counts.complete || 0;
|
||||
if (elIncomplete) elIncomplete.textContent = counts.incomplete || 0;
|
||||
|
||||
// Mobile segmented control
|
||||
renderMobileSegmented('mappingsMobileSeg', [
|
||||
{ label: 'Toate', count: counts.total || 0, value: 'all', active: pctFilter === 'all', colorClass: 'fc-neutral' },
|
||||
{ label: 'Complete', count: counts.complete || 0, value: 'complete', active: pctFilter === 'complete', colorClass: 'fc-green' },
|
||||
{ label: 'Incompl.', count: counts.incomplete || 0, value: 'incomplete', active: pctFilter === 'incomplete', colorClass: 'fc-yellow' }
|
||||
], (val) => {
|
||||
document.querySelectorAll('.filter-pill[data-pct]').forEach(b => b.classList.remove('active'));
|
||||
const pill = document.querySelector(`.filter-pill[data-pct="${val}"]`);
|
||||
if (pill) pill.classList.add('active');
|
||||
pctFilter = val;
|
||||
currentPage = 1;
|
||||
loadMappings();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Load & Render ────────────────────────────────
|
||||
@@ -79,7 +94,7 @@ async function loadMappings() {
|
||||
const params = new URLSearchParams({
|
||||
search: currentSearch,
|
||||
page: currentPage,
|
||||
per_page: 50,
|
||||
per_page: mappingsPerPage,
|
||||
sort_by: sortColumn,
|
||||
sort_dir: sortDirection
|
||||
});
|
||||
@@ -103,116 +118,129 @@ async function loadMappings() {
|
||||
renderPagination(data);
|
||||
updateSortIcons();
|
||||
} catch (err) {
|
||||
document.getElementById('mappingsBody').innerHTML =
|
||||
`<tr><td colspan="9" class="text-center text-danger">Eroare: ${err.message}</td></tr>`;
|
||||
document.getElementById('mappingsFlatList').innerHTML =
|
||||
`<div class="flat-row text-danger py-3 justify-content-center">Eroare: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable(mappings, showDeleted) {
|
||||
const tbody = document.getElementById('mappingsBody');
|
||||
const container = document.getElementById('mappingsFlatList');
|
||||
|
||||
if (!mappings || mappings.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-4">Nu exista mapari</td></tr>';
|
||||
container.innerHTML = '<div class="flat-row text-muted py-4 justify-content-center">Nu exista mapari</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by SKU for visual grouping (R6)
|
||||
let html = '';
|
||||
let prevSku = null;
|
||||
let groupIdx = 0;
|
||||
let skuGroupCounts = {};
|
||||
|
||||
// Count items per SKU
|
||||
let html = '';
|
||||
mappings.forEach(m => {
|
||||
skuGroupCounts[m.sku] = (skuGroupCounts[m.sku] || 0) + 1;
|
||||
});
|
||||
|
||||
mappings.forEach((m, i) => {
|
||||
const isNewGroup = m.sku !== prevSku;
|
||||
if (isNewGroup) groupIdx++;
|
||||
const groupClass = groupIdx % 2 === 0 ? 'sku-group-even' : 'sku-group-odd';
|
||||
const isMulti = skuGroupCounts[m.sku] > 1;
|
||||
const inactiveClass = !m.activ && !m.sters ? 'table-secondary opacity-75' : '';
|
||||
const deletedClass = m.sters ? 'mapping-deleted' : '';
|
||||
|
||||
// SKU cell: show only on first row of group
|
||||
let skuCell, productCell;
|
||||
if (isNewGroup) {
|
||||
const badge = isMulti ? ` <span class="badge bg-info">Set (${skuGroupCounts[m.sku]})</span>` : '';
|
||||
// Percentage total badge
|
||||
let pctBadge = '';
|
||||
if (m.pct_total !== undefined) {
|
||||
if (m.is_complete) {
|
||||
pctBadge = ` <span class="badge-pct complete" title="100% alocat">✓ 100%</span>`;
|
||||
} else {
|
||||
const pctVal = typeof m.pct_total === 'number' ? m.pct_total.toFixed(0) : m.pct_total;
|
||||
pctBadge = ` <span class="badge-pct incomplete" title="${pctVal}% alocat">⚠ ${pctVal}%</span>`;
|
||||
pctBadge = m.is_complete
|
||||
? ` <span class="badge-pct complete">✓ 100%</span>`
|
||||
: ` <span class="badge-pct incomplete">${typeof m.pct_total === 'number' ? m.pct_total.toFixed(0) : m.pct_total}%</span>`;
|
||||
}
|
||||
const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : '';
|
||||
html += `<div class="flat-row" style="background:#f8fafc;font-weight:600;border-top:1px solid #e5e7eb;${inactiveStyle}">
|
||||
<span class="${m.activ ? 'dot dot-green' : 'dot dot-yellow'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
|
||||
${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}
|
||||
title="${m.activ ? 'Activ' : 'Inactiv'}"></span>
|
||||
<strong class="me-1 text-nowrap">${esc(m.sku)}</strong>${pctBadge}
|
||||
<span class="grow truncate text-muted" style="font-size:0.875rem">${esc(m.product_name || '')}</span>
|
||||
${m.sters
|
||||
? `<button class="btn btn-sm btn-outline-success" onclick="event.stopPropagation();restoreMapping('${esc(m.sku)}', '${esc(m.codmat)}')" title="Restaureaza" style="padding:0.1rem 0.4rem"><i class="bi bi-arrow-counterclockwise"></i></button>`
|
||||
: `<button class="context-menu-trigger" data-sku="${esc(m.sku)}" data-codmat="${esc(m.codmat)}" data-cantitate="${m.cantitate_roa}" data-procent="${m.procent_pret}">⋮</button>`
|
||||
}
|
||||
skuCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}"><strong>${esc(m.sku)}</strong>${badge}${pctBadge}</td>`;
|
||||
productCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}">${esc(m.product_name || '-')}</td>`;
|
||||
} else {
|
||||
skuCell = '';
|
||||
productCell = '';
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `<tr class="${groupClass} ${inactiveClass} ${deletedClass}">
|
||||
${skuCell}
|
||||
${productCell}
|
||||
<td><code>${esc(m.codmat)}</code></td>
|
||||
<td>${esc(m.denumire || '-')}</td>
|
||||
<td>${esc(m.um || '-')}</td>
|
||||
<td class="${m.sters ? '' : 'editable'}" ${m.sters ? '' : `onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>${m.cantitate_roa}</td>
|
||||
<td class="${m.sters ? '' : 'editable'}" ${m.sters ? '' : `onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})"`}>${m.procent_pret}%</td>
|
||||
<td>
|
||||
<span class="badge ${m.activ ? 'bg-success' : 'bg-secondary'}" ${m.sters ? '' : 'style="cursor:pointer"'}
|
||||
${m.sters ? '' : `onclick="toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}>
|
||||
${m.activ ? 'Activ' : 'Inactiv'}
|
||||
const deletedStyle = m.sters ? 'text-decoration:line-through;opacity:0.5;' : '';
|
||||
html += `<div class="flat-row" style="padding-left:1.5rem;font-size:0.9rem;${deletedStyle}">
|
||||
<code>${esc(m.codmat)}</code>
|
||||
<span class="grow truncate text-muted" style="font-size:0.85rem">${esc(m.denumire || '')}</span>
|
||||
<span class="text-nowrap" style="font-size:0.875rem">
|
||||
<span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
|
||||
${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>x${m.cantitate_roa}</span>
|
||||
· <span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
|
||||
${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})"`}>${m.procent_pret}%</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
${m.sters ? `<button class="btn btn-sm btn-outline-success" onclick="restoreMapping('${esc(m.sku)}', '${esc(m.codmat)}')" title="Restaureaza"><i class="bi bi-arrow-counterclockwise"></i></button>` : `
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEditModal('${esc(m.sku)}', '${esc(m.codmat)}', ${m.cantitate_roa}, ${m.procent_pret})" title="Editeaza">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteMappingConfirm('${esc(m.sku)}', '${esc(m.codmat)}')" title="Sterge">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>`}
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
</div>`;
|
||||
prevSku = m.sku;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
|
||||
tbody.innerHTML = html;
|
||||
// Wire context menu triggers
|
||||
container.querySelectorAll('.context-menu-trigger').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const { sku, codmat, cantitate, procent } = btn.dataset;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
showContextMenu(rect.left, rect.bottom + 2, [
|
||||
{ label: 'Editeaza', action: () => openEditModal(sku, codmat, parseFloat(cantitate), parseFloat(procent)) },
|
||||
{ label: 'Sterge', action: () => deleteMappingConfirm(sku, codmat), danger: true }
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Inline edit for flat-row values (cantitate / procent)
|
||||
function editFlatValue(span, sku, codmat, field, currentValue) {
|
||||
if (span.querySelector('input')) return;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = 'form-control form-control-sm d-inline';
|
||||
input.value = currentValue;
|
||||
input.step = field === 'cantitate_roa' ? '0.001' : '0.01';
|
||||
input.style.width = '70px';
|
||||
input.style.display = 'inline';
|
||||
|
||||
const originalText = span.textContent;
|
||||
span.textContent = '';
|
||||
span.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const save = async () => {
|
||||
const newValue = parseFloat(input.value);
|
||||
if (isNaN(newValue) || newValue === currentValue) {
|
||||
span.textContent = originalText;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = {};
|
||||
body[field] = newValue;
|
||||
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) { loadMappings(); }
|
||||
else { span.textContent = originalText; alert('Eroare: ' + (data.error || 'Update failed')); }
|
||||
} catch (err) { span.textContent = originalText; }
|
||||
};
|
||||
|
||||
input.addEventListener('blur', save);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); save(); }
|
||||
if (e.key === 'Escape') { span.textContent = originalText; }
|
||||
});
|
||||
}
|
||||
|
||||
function renderPagination(data) {
|
||||
const info = document.getElementById('pageInfo');
|
||||
info.textContent = `${data.total} mapari | Pagina ${data.page} din ${data.pages || 1}`;
|
||||
|
||||
const ul = document.getElementById('pagination');
|
||||
if (data.pages <= 1) { ul.innerHTML = ''; return; }
|
||||
|
||||
let html = '';
|
||||
html += `<li class="page-item ${data.page <= 1 ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="goPage(${data.page - 1}); return false;">«</a></li>`;
|
||||
|
||||
let start = Math.max(1, data.page - 3);
|
||||
let end = Math.min(data.pages, start + 6);
|
||||
start = Math.max(1, end - 6);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
html += `<li class="page-item ${i === data.page ? 'active' : ''}">
|
||||
<a class="page-link" href="#" onclick="goPage(${i}); return false;">${i}</a></li>`;
|
||||
}
|
||||
|
||||
html += `<li class="page-item ${data.page >= data.pages ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="goPage(${data.page + 1}); return false;">»</a></li>`;
|
||||
|
||||
ul.innerHTML = html;
|
||||
const pagOpts = { perPage: mappingsPerPage, perPageFn: 'mappingsChangePerPage', perPageOptions: [25, 50, 100, 250] };
|
||||
const infoHtml = `<small class="text-muted me-auto">${data.total} mapari | Pagina ${data.page} din ${data.pages || 1}</small>`;
|
||||
const pagHtml = infoHtml + renderUnifiedPagination(data.page, data.pages || 1, 'goPage', pagOpts);
|
||||
const top = document.getElementById('mappingsPagTop');
|
||||
const bot = document.getElementById('mappingsPagBottom');
|
||||
if (top) top.innerHTML = pagHtml;
|
||||
if (bot) bot.innerHTML = pagHtml;
|
||||
}
|
||||
|
||||
function mappingsChangePerPage(val) { mappingsPerPage = parseInt(val) || 50; currentPage = 1; loadMappings(); }
|
||||
|
||||
function goPage(p) {
|
||||
currentPage = p;
|
||||
loadMappings();
|
||||
@@ -248,7 +276,7 @@ function clearAddForm() {
|
||||
addCodmatLine();
|
||||
}
|
||||
|
||||
function openEditModal(sku, codmat, cantitate, procent) {
|
||||
async function openEditModal(sku, codmat, cantitate, procent) {
|
||||
editingMapping = { sku, codmat };
|
||||
document.getElementById('addModalTitle').textContent = 'Editare Mapare';
|
||||
document.getElementById('inputSku').value = sku;
|
||||
@@ -257,15 +285,54 @@ function openEditModal(sku, codmat, cantitate, procent) {
|
||||
|
||||
const container = document.getElementById('codmatLines');
|
||||
container.innerHTML = '';
|
||||
addCodmatLine();
|
||||
|
||||
// Pre-fill the CODMAT line
|
||||
try {
|
||||
// Fetch all CODMATs for this SKU
|
||||
const res = await fetch(`/api/mappings?search=${encodeURIComponent(sku)}&per_page=100`);
|
||||
const data = await res.json();
|
||||
const allMappings = (data.mappings || []).filter(m => m.sku === sku && !m.sters);
|
||||
|
||||
// Show product name if available
|
||||
const productName = allMappings[0]?.product_name || '';
|
||||
const productNameEl = document.getElementById('addModalProductName');
|
||||
const productNameText = document.getElementById('inputProductName');
|
||||
if (productName && productNameEl && productNameText) {
|
||||
productNameText.textContent = productName;
|
||||
productNameEl.style.display = '';
|
||||
}
|
||||
|
||||
if (allMappings.length === 0) {
|
||||
// Fallback to single line with passed values
|
||||
addCodmatLine();
|
||||
const line = container.querySelector('.codmat-line');
|
||||
if (line) {
|
||||
line.querySelector('.cl-codmat').value = codmat;
|
||||
line.querySelector('.cl-cantitate').value = cantitate;
|
||||
line.querySelector('.cl-procent').value = procent;
|
||||
}
|
||||
} else {
|
||||
for (const m of allMappings) {
|
||||
addCodmatLine();
|
||||
const lines = container.querySelectorAll('.codmat-line');
|
||||
const line = lines[lines.length - 1];
|
||||
line.querySelector('.cl-codmat').value = m.codmat;
|
||||
if (m.denumire) {
|
||||
line.querySelector('.cl-selected').textContent = m.denumire;
|
||||
}
|
||||
line.querySelector('.cl-cantitate').value = m.cantitate_roa;
|
||||
line.querySelector('.cl-procent').value = m.procent_pret;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback on error
|
||||
addCodmatLine();
|
||||
const line = container.querySelector('.codmat-line');
|
||||
if (line) {
|
||||
line.querySelector('.cl-codmat').value = codmat;
|
||||
line.querySelector('.cl-cantitate').value = cantitate;
|
||||
line.querySelector('.cl-procent').value = procent;
|
||||
}
|
||||
}
|
||||
|
||||
new bootstrap.Modal(document.getElementById('addModal')).show();
|
||||
}
|
||||
@@ -276,23 +343,20 @@ function addCodmatLine() {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'border rounded p-2 mb-2 codmat-line';
|
||||
div.innerHTML = `
|
||||
<div class="mb-2 position-relative">
|
||||
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label>
|
||||
<input type="text" class="form-control form-control-sm cl-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off" data-idx="${idx}">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col position-relative">
|
||||
<input type="text" class="form-control form-control-sm cl-codmat" placeholder="Cauta CODMAT..." autocomplete="off" data-idx="${idx}">
|
||||
<div class="autocomplete-dropdown d-none cl-ac-dropdown"></div>
|
||||
<small class="text-muted cl-selected"></small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
|
||||
<input type="number" class="form-control form-control-sm cl-cantitate" value="1" step="0.001" min="0.001">
|
||||
<div class="col-auto" style="width:90px">
|
||||
<input type="number" class="form-control form-control-sm cl-cantitate" value="1" step="0.001" min="0.001" placeholder="Cant." title="Cantitate ROA">
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label>
|
||||
<input type="number" class="form-control form-control-sm cl-procent" value="100" step="0.01" min="0" max="100">
|
||||
<div class="col-auto" style="width:90px">
|
||||
<input type="number" class="form-control form-control-sm cl-procent" value="100" step="0.01" min="0" max="100" placeholder="% Pret" title="Procent Pret">
|
||||
</div>
|
||||
<div class="col-2 d-flex align-items-end">
|
||||
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.codmat-line').remove()"><i class="bi bi-x-lg"></i></button>` : ''}
|
||||
<div class="col-auto">
|
||||
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.codmat-line').remove()"><i class="bi bi-x-lg"></i></button>` : '<div style="width:31px"></div>'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -370,7 +434,8 @@ async function saveMapping() {
|
||||
let res;
|
||||
|
||||
if (editingMapping) {
|
||||
// Edit mode: use PUT /api/mappings/{old_sku}/{old_codmat}/edit
|
||||
if (mappings.length === 1) {
|
||||
// Single CODMAT edit: use existing PUT endpoint
|
||||
res = await fetch(`/api/mappings/${encodeURIComponent(editingMapping.sku)}/${encodeURIComponent(editingMapping.codmat)}/edit`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -381,6 +446,27 @@ async function saveMapping() {
|
||||
procent_pret: mappings[0].procent_pret
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// Multi-CODMAT set: delete all existing then create new batch
|
||||
const oldSku = editingMapping.sku;
|
||||
const existRes = await fetch(`/api/mappings?search=${encodeURIComponent(oldSku)}&per_page=100`);
|
||||
const existData = await existRes.json();
|
||||
const existing = (existData.mappings || []).filter(m => m.sku === oldSku && !m.sters);
|
||||
|
||||
// Delete each existing CODMAT for old SKU
|
||||
for (const m of existing) {
|
||||
await fetch(`/api/mappings/${encodeURIComponent(m.sku)}/${encodeURIComponent(m.codmat)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
// Create new batch with auto_restore (handles just-soft-deleted records)
|
||||
res = await fetch('/api/mappings/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sku, mappings, auto_restore: true })
|
||||
});
|
||||
}
|
||||
} else if (mappings.length === 1) {
|
||||
res = await fetch('/api/mappings', {
|
||||
method: 'POST',
|
||||
@@ -414,36 +500,34 @@ async function saveMapping() {
|
||||
let inlineAddVisible = false;
|
||||
|
||||
function showInlineAddRow() {
|
||||
// On mobile, open the full modal instead
|
||||
if (window.innerWidth < 768) {
|
||||
new bootstrap.Modal(document.getElementById('addModal')).show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inlineAddVisible) return;
|
||||
inlineAddVisible = true;
|
||||
|
||||
const tbody = document.getElementById('mappingsBody');
|
||||
const row = document.createElement('tr');
|
||||
const container = document.getElementById('mappingsFlatList');
|
||||
const row = document.createElement('div');
|
||||
row.id = 'inlineAddRow';
|
||||
row.className = 'table-info';
|
||||
row.className = 'flat-row';
|
||||
row.style.background = '#eff6ff';
|
||||
row.style.gap = '0.5rem';
|
||||
row.innerHTML = `
|
||||
<td colspan="2">
|
||||
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:160px">
|
||||
</td>
|
||||
<td colspan="2" class="position-relative">
|
||||
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">
|
||||
<div class="position-relative" style="flex:1;min-width:0">
|
||||
<input type="text" class="form-control form-control-sm" id="inlineCodmat" placeholder="Cauta CODMAT..." autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none" id="inlineAcDropdown"></div>
|
||||
<small class="text-muted" id="inlineSelected"></small>
|
||||
</td>
|
||||
<td>-</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:80px">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm" id="inlineProcent" value="100" step="0.01" min="0" max="100" style="width:80px">
|
||||
</td>
|
||||
<td>-</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-success me-1" onclick="saveInlineMapping()" title="Salveaza"><i class="bi bi-check-lg"></i></button>
|
||||
</div>
|
||||
<input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:70px" placeholder="Cant.">
|
||||
<input type="number" class="form-control form-control-sm" id="inlineProcent" value="100" step="0.01" min="0" max="100" style="width:70px" placeholder="%">
|
||||
<button class="btn btn-sm btn-success" onclick="saveInlineMapping()" title="Salveaza"><i class="bi bi-check-lg"></i></button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="cancelInlineAdd()" title="Anuleaza"><i class="bi bi-x-lg"></i></button>
|
||||
</td>
|
||||
`;
|
||||
tbody.insertBefore(row, tbody.firstChild);
|
||||
container.insertBefore(row, container.firstChild);
|
||||
document.getElementById('inlineSku').focus();
|
||||
|
||||
// Setup autocomplete for inline CODMAT
|
||||
@@ -518,51 +602,6 @@ function cancelInlineAdd() {
|
||||
inlineAddVisible = false;
|
||||
}
|
||||
|
||||
// ── Inline Edit ──────────────────────────────────
|
||||
|
||||
function editCell(td, sku, codmat, field, currentValue) {
|
||||
if (td.querySelector('input')) return;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = 'form-control form-control-sm';
|
||||
input.value = currentValue;
|
||||
input.step = field === 'cantitate_roa' ? '0.001' : '0.01';
|
||||
input.style.width = '80px';
|
||||
|
||||
const originalText = td.textContent;
|
||||
td.textContent = '';
|
||||
td.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const save = async () => {
|
||||
const newValue = parseFloat(input.value);
|
||||
if (isNaN(newValue) || newValue === currentValue) {
|
||||
td.textContent = originalText;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = {};
|
||||
body[field] = newValue;
|
||||
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) { loadMappings(); }
|
||||
else { td.textContent = originalText; alert('Eroare: ' + (data.error || 'Update failed')); }
|
||||
} catch (err) { td.textContent = originalText; }
|
||||
};
|
||||
|
||||
input.addEventListener('blur', save);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') save();
|
||||
if (e.key === 'Escape') { td.textContent = originalText; }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Toggle Active with Toast Undo ────────────────
|
||||
|
||||
async function toggleActive(sku, codmat, currentActive) {
|
||||
@@ -672,9 +711,13 @@ async function importCsv() {
|
||||
try {
|
||||
const res = await fetch('/api/mappings/import-csv', { method: 'POST', body: formData });
|
||||
const data = await res.json();
|
||||
let html = `<div class="alert alert-success">Procesate: ${data.processed}</div>`;
|
||||
let msg = `${data.processed} mapări importate`;
|
||||
if (data.skipped_no_codmat > 0) {
|
||||
msg += `, ${data.skipped_no_codmat} rânduri fără CODMAT omise`;
|
||||
}
|
||||
let html = `<div class="alert alert-success">${msg}</div>`;
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
html += `<div class="alert alert-warning">Erori: <ul>${data.errors.map(e => `<li>${esc(e)}</li>`).join('')}</ul></div>`;
|
||||
html += `<div class="alert alert-warning">Erori (${data.errors.length}): <ul>${data.errors.map(e => `<li>${esc(e)}</li>`).join('')}</ul></div>`;
|
||||
}
|
||||
document.getElementById('importResult').innerHTML = html;
|
||||
loadMappings();
|
||||
@@ -683,8 +726,8 @@ async function importCsv() {
|
||||
}
|
||||
}
|
||||
|
||||
function exportCsv() { window.location.href = '/api/mappings/export-csv'; }
|
||||
function downloadTemplate() { window.location.href = '/api/mappings/csv-template'; }
|
||||
function exportCsv() { window.location.href = (window.ROOT_PATH || '') + '/api/mappings/export-csv'; }
|
||||
function downloadTemplate() { window.location.href = (window.ROOT_PATH || '') + '/api/mappings/csv-template'; }
|
||||
|
||||
// ── Duplicate / Conflict handling ────────────────
|
||||
|
||||
@@ -713,7 +756,3 @@ function handleMappingConflict(data) {
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
190
api/app/static/js/settings.js
Normal file
190
api/app/static/js/settings.js
Normal file
@@ -0,0 +1,190 @@
|
||||
let settAcTimeout = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadDropdowns();
|
||||
await loadSettings();
|
||||
wireAutocomplete('settTransportCodmat', 'settTransportAc');
|
||||
wireAutocomplete('settDiscountCodmat', 'settDiscountAc');
|
||||
});
|
||||
|
||||
async function loadDropdowns() {
|
||||
try {
|
||||
const [sectiiRes, politiciRes, gestiuniRes] = await Promise.all([
|
||||
fetch('/api/settings/sectii'),
|
||||
fetch('/api/settings/politici'),
|
||||
fetch('/api/settings/gestiuni')
|
||||
]);
|
||||
const sectii = await sectiiRes.json();
|
||||
const politici = await politiciRes.json();
|
||||
const gestiuni = await gestiuniRes.json();
|
||||
|
||||
const gestContainer = document.getElementById('settGestiuniContainer');
|
||||
if (gestContainer) {
|
||||
gestContainer.innerHTML = '';
|
||||
gestiuni.forEach(g => {
|
||||
gestContainer.innerHTML += `<div class="form-check mb-0"><input class="form-check-input" type="checkbox" value="${escHtml(g.id)}" id="gestChk_${escHtml(g.id)}"><label class="form-check-label" for="gestChk_${escHtml(g.id)}">${escHtml(g.label)}</label></div>`;
|
||||
});
|
||||
if (gestiuni.length === 0) gestContainer.innerHTML = '<span class="text-muted small">Nicio gestiune disponibilă</span>';
|
||||
}
|
||||
|
||||
const sectieEl = document.getElementById('settIdSectie');
|
||||
if (sectieEl) {
|
||||
sectieEl.innerHTML = '<option value="">— selectează secție —</option>';
|
||||
sectii.forEach(s => {
|
||||
sectieEl.innerHTML += `<option value="${escHtml(s.id)}">${escHtml(s.label)}</option>`;
|
||||
});
|
||||
}
|
||||
|
||||
const polEl = document.getElementById('settIdPol');
|
||||
if (polEl) {
|
||||
polEl.innerHTML = '<option value="">— selectează politică —</option>';
|
||||
politici.forEach(p => {
|
||||
polEl.innerHTML += `<option value="${escHtml(p.id)}">${escHtml(p.label)}</option>`;
|
||||
});
|
||||
}
|
||||
|
||||
const tPolEl = document.getElementById('settTransportIdPol');
|
||||
if (tPolEl) {
|
||||
tPolEl.innerHTML = '<option value="">— implicită —</option>';
|
||||
politici.forEach(p => {
|
||||
tPolEl.innerHTML += `<option value="${escHtml(p.id)}">${escHtml(p.label)}</option>`;
|
||||
});
|
||||
}
|
||||
|
||||
const dPolEl = document.getElementById('settDiscountIdPol');
|
||||
if (dPolEl) {
|
||||
dPolEl.innerHTML = '<option value="">— implicită —</option>';
|
||||
politici.forEach(p => {
|
||||
dPolEl.innerHTML += `<option value="${escHtml(p.id)}">${escHtml(p.label)}</option>`;
|
||||
});
|
||||
}
|
||||
|
||||
const pPolEl = document.getElementById('settIdPolProductie');
|
||||
if (pPolEl) {
|
||||
pPolEl.innerHTML = '<option value="">— fără politică producție —</option>';
|
||||
politici.forEach(p => {
|
||||
pPolEl.innerHTML += `<option value="${escHtml(p.id)}">${escHtml(p.label)}</option>`;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('loadDropdowns error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/settings');
|
||||
const data = await res.json();
|
||||
const el = (id) => document.getElementById(id);
|
||||
if (el('settTransportCodmat')) el('settTransportCodmat').value = data.transport_codmat || '';
|
||||
if (el('settTransportVat')) el('settTransportVat').value = data.transport_vat || '21';
|
||||
if (el('settTransportIdPol')) el('settTransportIdPol').value = data.transport_id_pol || '';
|
||||
if (el('settDiscountCodmat')) el('settDiscountCodmat').value = data.discount_codmat || '';
|
||||
if (el('settDiscountVat')) el('settDiscountVat').value = data.discount_vat || '21';
|
||||
if (el('settDiscountIdPol')) el('settDiscountIdPol').value = data.discount_id_pol || '';
|
||||
if (el('settSplitDiscountVat')) el('settSplitDiscountVat').checked = data.split_discount_vat === "1";
|
||||
if (el('settIdPol')) el('settIdPol').value = data.id_pol || '';
|
||||
if (el('settIdPolProductie')) el('settIdPolProductie').value = data.id_pol_productie || '';
|
||||
if (el('settIdSectie')) el('settIdSectie').value = data.id_sectie || '';
|
||||
// Multi-gestiune checkboxes
|
||||
const gestVal = data.id_gestiune || '';
|
||||
if (gestVal) {
|
||||
const selectedIds = gestVal.split(',').map(s => s.trim());
|
||||
selectedIds.forEach(id => {
|
||||
const chk = document.getElementById('gestChk_' + id);
|
||||
if (chk) chk.checked = true;
|
||||
});
|
||||
}
|
||||
if (el('settGomagApiKey')) el('settGomagApiKey').value = data.gomag_api_key || '';
|
||||
if (el('settGomagApiShop')) el('settGomagApiShop').value = data.gomag_api_shop || '';
|
||||
if (el('settGomagDaysBack')) el('settGomagDaysBack').value = data.gomag_order_days_back || '7';
|
||||
if (el('settGomagLimit')) el('settGomagLimit').value = data.gomag_limit || '100';
|
||||
if (el('settDashPollSeconds')) el('settDashPollSeconds').value = data.dashboard_poll_seconds || '5';
|
||||
} catch (err) {
|
||||
console.error('loadSettings error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const el = (id) => document.getElementById(id);
|
||||
const payload = {
|
||||
transport_codmat: el('settTransportCodmat')?.value?.trim() || '',
|
||||
transport_vat: el('settTransportVat')?.value || '21',
|
||||
transport_id_pol: el('settTransportIdPol')?.value?.trim() || '',
|
||||
discount_codmat: el('settDiscountCodmat')?.value?.trim() || '',
|
||||
discount_vat: el('settDiscountVat')?.value || '21',
|
||||
discount_id_pol: el('settDiscountIdPol')?.value?.trim() || '',
|
||||
split_discount_vat: el('settSplitDiscountVat')?.checked ? "1" : "",
|
||||
id_pol: el('settIdPol')?.value?.trim() || '',
|
||||
id_pol_productie: el('settIdPolProductie')?.value?.trim() || '',
|
||||
id_sectie: el('settIdSectie')?.value?.trim() || '',
|
||||
id_gestiune: Array.from(document.querySelectorAll('#settGestiuniContainer input:checked')).map(c => c.value).join(','),
|
||||
gomag_api_key: el('settGomagApiKey')?.value?.trim() || '',
|
||||
gomag_api_shop: el('settGomagApiShop')?.value?.trim() || '',
|
||||
gomag_order_days_back: el('settGomagDaysBack')?.value?.trim() || '7',
|
||||
gomag_limit: el('settGomagLimit')?.value?.trim() || '100',
|
||||
dashboard_poll_seconds: el('settDashPollSeconds')?.value?.trim() || '5',
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
const resultEl = document.getElementById('settSaveResult');
|
||||
if (data.success) {
|
||||
if (resultEl) { resultEl.textContent = 'Salvat!'; resultEl.style.color = '#16a34a'; }
|
||||
setTimeout(() => { if (resultEl) resultEl.textContent = ''; }, 3000);
|
||||
} else {
|
||||
if (resultEl) { resultEl.textContent = 'Eroare: ' + JSON.stringify(data); resultEl.style.color = '#dc2626'; }
|
||||
}
|
||||
} catch (err) {
|
||||
const resultEl = document.getElementById('settSaveResult');
|
||||
if (resultEl) { resultEl.textContent = 'Eroare: ' + err.message; resultEl.style.color = '#dc2626'; }
|
||||
}
|
||||
}
|
||||
|
||||
function wireAutocomplete(inputId, dropdownId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const dropdown = document.getElementById(dropdownId);
|
||||
if (!input || !dropdown) return;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(settAcTimeout);
|
||||
settAcTimeout = setTimeout(async () => {
|
||||
const q = input.value.trim();
|
||||
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
|
||||
const data = await res.json();
|
||||
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
||||
dropdown.innerHTML = data.results.map(r =>
|
||||
`<div class="autocomplete-item" onmousedown="settSelectArticle('${inputId}', '${dropdownId}', '${escHtml(r.codmat)}')">
|
||||
<span class="codmat">${escHtml(r.codmat)}</span> — <span class="denumire">${escHtml(r.denumire)}</span>
|
||||
</div>`
|
||||
).join('');
|
||||
dropdown.classList.remove('d-none');
|
||||
} catch { dropdown.classList.add('d-none'); }
|
||||
}, 250);
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
||||
});
|
||||
}
|
||||
|
||||
function settSelectArticle(inputId, dropdownId, codmat) {
|
||||
document.getElementById(inputId).value = codmat;
|
||||
document.getElementById(dropdownId).classList.add('d-none');
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
228
api/app/static/js/shared.js
Normal file
228
api/app/static/js/shared.js
Normal file
@@ -0,0 +1,228 @@
|
||||
// shared.js - Unified utilities for all pages
|
||||
|
||||
// ── Root path patch — prepend ROOT_PATH to all relative fetch calls ───────
|
||||
(function() {
|
||||
const _fetch = window.fetch.bind(window);
|
||||
window.fetch = function(url, ...args) {
|
||||
if (typeof url === 'string' && url.startsWith('/') && window.ROOT_PATH) {
|
||||
url = window.ROOT_PATH + url;
|
||||
}
|
||||
return _fetch(url, ...args);
|
||||
};
|
||||
})();
|
||||
|
||||
// ── HTML escaping ─────────────────────────────────
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ── Date formatting ───────────────────────────────
|
||||
function fmtDate(dateStr, includeSeconds) {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
const hasTime = dateStr.includes(':');
|
||||
if (hasTime) {
|
||||
const opts = { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' };
|
||||
if (includeSeconds) opts.second = '2-digit';
|
||||
return d.toLocaleString('ro-RO', opts);
|
||||
}
|
||||
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
} catch { return dateStr; }
|
||||
}
|
||||
|
||||
// ── Unified Pagination ────────────────────────────
|
||||
/**
|
||||
* Renders a full pagination bar with First/Prev/numbers/Next/Last.
|
||||
* @param {number} currentPage
|
||||
* @param {number} totalPages
|
||||
* @param {string} goToFnName - name of global function to call with page number
|
||||
* @param {object} [opts] - optional: { perPage, perPageFn, perPageOptions }
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
function renderUnifiedPagination(currentPage, totalPages, goToFnName, opts) {
|
||||
if (totalPages <= 1 && !(opts && opts.perPage)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let html = '<div class="d-flex align-items-center gap-2 flex-wrap">';
|
||||
|
||||
// Per-page selector
|
||||
if (opts && opts.perPage && opts.perPageFn) {
|
||||
const options = opts.perPageOptions || [25, 50, 100, 250];
|
||||
html += `<label class="per-page-label">Per pagina: <select class="select-compact ms-1" onchange="${opts.perPageFn}(this.value)">`;
|
||||
options.forEach(v => {
|
||||
html += `<option value="${v}"${v === opts.perPage ? ' selected' : ''}>${v}</option>`;
|
||||
});
|
||||
html += '</select></label>';
|
||||
}
|
||||
|
||||
if (totalPages <= 1) {
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
html += '<div class="pagination-bar">';
|
||||
|
||||
// First
|
||||
html += `<button class="page-btn" onclick="${goToFnName}(1)" ${currentPage <= 1 ? 'disabled' : ''}>«</button>`;
|
||||
// Prev
|
||||
html += `<button class="page-btn" onclick="${goToFnName}(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>‹</button>`;
|
||||
|
||||
// Page numbers with ellipsis
|
||||
const range = 2;
|
||||
let pages = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= currentPage - range && i <= currentPage + range)) {
|
||||
pages.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
let lastP = 0;
|
||||
pages.forEach(p => {
|
||||
if (lastP && p - lastP > 1) {
|
||||
html += `<span class="page-btn disabled page-ellipsis">…</span>`;
|
||||
}
|
||||
html += `<button class="page-btn page-number${p === currentPage ? ' active' : ''}" onclick="${goToFnName}(${p})">${p}</button>`;
|
||||
lastP = p;
|
||||
});
|
||||
|
||||
// Next
|
||||
html += `<button class="page-btn" onclick="${goToFnName}(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>›</button>`;
|
||||
// Last
|
||||
html += `<button class="page-btn" onclick="${goToFnName}(${totalPages})" ${currentPage >= totalPages ? 'disabled' : ''}>»</button>`;
|
||||
|
||||
html += '</div></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Context Menu ──────────────────────────────────
|
||||
let _activeContextMenu = null;
|
||||
|
||||
function closeAllContextMenus() {
|
||||
if (_activeContextMenu) {
|
||||
_activeContextMenu.remove();
|
||||
_activeContextMenu = null;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', closeAllContextMenus);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeAllContextMenus();
|
||||
});
|
||||
|
||||
/**
|
||||
* Show a context menu at the given position.
|
||||
* @param {number} x - clientX
|
||||
* @param {number} y - clientY
|
||||
* @param {Array} items - [{label, action, danger}]
|
||||
*/
|
||||
function showContextMenu(x, y, items) {
|
||||
closeAllContextMenus();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'context-menu';
|
||||
|
||||
items.forEach(item => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'context-menu-item' + (item.danger ? ' text-danger' : '');
|
||||
btn.textContent = item.label;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
closeAllContextMenus();
|
||||
item.action();
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
});
|
||||
|
||||
document.body.appendChild(menu);
|
||||
_activeContextMenu = menu;
|
||||
|
||||
// Position menu, keeping it within viewport
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
let left = x;
|
||||
let top = y;
|
||||
if (left + 160 > vw) left = vw - 165;
|
||||
if (top + rect.height > vh) top = vh - rect.height - 5;
|
||||
menu.style.left = left + 'px';
|
||||
menu.style.top = top + 'px';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire right-click on desktop + three-dots button on mobile for a table.
|
||||
* @param {string} rowSelector - CSS selector for clickable rows
|
||||
* @param {function} menuItemsFn - called with row element, returns [{label, action, danger}]
|
||||
*/
|
||||
function initContextMenus(rowSelector, menuItemsFn) {
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
const row = e.target.closest(rowSelector);
|
||||
if (!row) return;
|
||||
e.preventDefault();
|
||||
showContextMenu(e.clientX, e.clientY, menuItemsFn(row));
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const trigger = e.target.closest('.context-menu-trigger');
|
||||
if (!trigger) return;
|
||||
const row = trigger.closest(rowSelector);
|
||||
if (!row) return;
|
||||
e.stopPropagation();
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
showContextMenu(rect.left, rect.bottom + 2, menuItemsFn(row));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mobile segmented control ─────────────────────
|
||||
/**
|
||||
* Render a Bootstrap btn-group segmented control for mobile.
|
||||
* @param {string} containerId - ID of the container div
|
||||
* @param {Array} pills - [{label, count, colorClass, value, active}]
|
||||
* @param {function} onSelect - callback(value)
|
||||
*/
|
||||
function renderMobileSegmented(containerId, pills, onSelect) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
const btnStyle = 'font-size:0.75rem;height:32px;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:0.25rem;flex:1;padding:0 0.25rem';
|
||||
|
||||
container.innerHTML = `<div class="btn-group btn-group-sm w-100">${pills.map(p => {
|
||||
const cls = p.active ? 'btn btn-primary' : 'btn btn-outline-secondary';
|
||||
const countColor = (!p.active && p.colorClass) ? ` class="${p.colorClass}"` : '';
|
||||
return `<button type="button" class="${cls}" style="${btnStyle}" data-seg-value="${esc(p.value)}">${esc(p.label)} <b${countColor}>${p.count}</b></button>`;
|
||||
}).join('')}</div>`;
|
||||
|
||||
container.querySelectorAll('[data-seg-value]').forEach(btn => {
|
||||
btn.addEventListener('click', () => onSelect(btn.dataset.segValue));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Dot helper ────────────────────────────────────
|
||||
function statusDot(status) {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'IMPORTED':
|
||||
case 'ALREADY_IMPORTED':
|
||||
case 'COMPLETED':
|
||||
case 'RESOLVED':
|
||||
return '<span class="dot dot-green"></span>';
|
||||
case 'SKIPPED':
|
||||
case 'UNRESOLVED':
|
||||
case 'INCOMPLETE':
|
||||
return '<span class="dot dot-yellow"></span>';
|
||||
case 'ERROR':
|
||||
case 'FAILED':
|
||||
return '<span class="dot dot-red"></span>';
|
||||
case 'CANCELLED':
|
||||
case 'DELETED_IN_ROA':
|
||||
return '<span class="dot dot-gray"></span>';
|
||||
default:
|
||||
return '<span class="dot dot-gray"></span>';
|
||||
}
|
||||
}
|
||||
@@ -6,52 +6,30 @@
|
||||
<title>{% block title %}GoMag Import Manager{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="/static/css/style.css" rel="stylesheet">
|
||||
{% set rp = request.scope.get('root_path', '') %}
|
||||
<link href="{{ rp }}/static/css/style.css?v=14" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Sidebar -->
|
||||
<nav id="sidebar" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h5><i class="bi bi-box-seam"></i> GoMag Import</h5>
|
||||
</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% block nav_dashboard %}{% endblock %}" href="/">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% block nav_mappings %}{% endblock %}" href="/mappings">
|
||||
<i class="bi bi-link-45deg"></i> Mapari SKU
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% block nav_missing %}{% endblock %}" href="/missing-skus">
|
||||
<i class="bi bi-exclamation-triangle"></i> SKU-uri Lipsa
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% block nav_logs %}{% endblock %}" href="/logs">
|
||||
<i class="bi bi-journal-text"></i> Jurnale Import
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<small class="text-muted">v1.0</small>
|
||||
<!-- Top Navbar -->
|
||||
<nav class="top-navbar">
|
||||
<div class="navbar-brand">GoMag Import</div>
|
||||
<div class="navbar-links">
|
||||
<a href="{{ rp }}/" class="nav-tab {% block nav_dashboard %}{% endblock %}"><span class="d-none d-md-inline">Dashboard</span><span class="d-md-none">Acasa</span></a>
|
||||
<a href="{{ rp }}/mappings" class="nav-tab {% block nav_mappings %}{% endblock %}"><span class="d-none d-md-inline">Mapari SKU</span><span class="d-md-none">Mapari</span></a>
|
||||
<a href="{{ rp }}/missing-skus" class="nav-tab {% block nav_missing %}{% endblock %}"><span class="d-none d-md-inline">SKU-uri Lipsa</span><span class="d-md-none">Lipsa</span></a>
|
||||
<a href="{{ rp }}/logs" class="nav-tab {% block nav_logs %}{% endblock %}"><span class="d-none d-md-inline">Jurnale Import</span><span class="d-md-none">Jurnale</span></a>
|
||||
<a href="{{ rp }}/settings" class="nav-tab {% block nav_settings %}{% endblock %}"><span class="d-none d-md-inline">Setari</span><span class="d-md-none">Setari</span></a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile toggle -->
|
||||
<button class="btn btn-dark d-md-none sidebar-toggle" type="button" onclick="document.getElementById('sidebar').classList.toggle('show')">
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script>window.ROOT_PATH = "{{ rp }}";</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ rp }}/static/js/shared.js?v=11"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,28 +10,30 @@
|
||||
<!-- TOP ROW: Status + Controls -->
|
||||
<div class="sync-card-controls">
|
||||
<span id="syncStatusDot" class="sync-status-dot idle"></span>
|
||||
<span id="syncStatusText" style="font-size:0.8125rem;color:#374151;">Inactiv</span>
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-left:auto;">
|
||||
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.8125rem;color:#6b7280;">
|
||||
<span id="syncStatusText" class="text-secondary">Inactiv</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<label class="d-flex align-items-center gap-1 text-muted">
|
||||
Auto:
|
||||
<input type="checkbox" id="schedulerToggle" style="cursor:pointer;" onchange="toggleScheduler()">
|
||||
<input type="checkbox" id="schedulerToggle" class="cursor-pointer" onchange="toggleScheduler()">
|
||||
</label>
|
||||
<select id="schedulerInterval" class="select-compact" onchange="updateSchedulerInterval()">
|
||||
<option value="1">1 min</option>
|
||||
<option value="3">3 min</option>
|
||||
<option value="5">5 min</option>
|
||||
<option value="10" selected>10 min</option>
|
||||
<option value="30">30 min</option>
|
||||
</select>
|
||||
<button id="syncStartBtn" class="btn btn-primary btn-compact" onclick="startSync()">▶ Start Sync</button>
|
||||
<button id="syncStartBtn" class="btn btn-sm btn-primary" onclick="startSync()">▶ Start Sync</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sync-card-divider"></div>
|
||||
<!-- BOTTOM ROW: Last sync info (clickable → jurnal) -->
|
||||
<div class="sync-card-info" id="lastSyncRow" role="button" tabindex="0" title="Ver jurnal sync">
|
||||
<span id="lastSyncDate" style="font-weight:500;">—</span>
|
||||
<span id="lastSyncDuration" style="color:#9ca3af;">—</span>
|
||||
<span id="lastSyncDate" class="fw-medium">—</span>
|
||||
<span id="lastSyncDuration" class="text-muted">—</span>
|
||||
<span id="lastSyncCounts">—</span>
|
||||
<span id="lastSyncStatus">—</span>
|
||||
<span style="margin-left:auto;font-size:0.75rem;color:#9ca3af;">↗ jurnal</span>
|
||||
<span class="ms-auto small text-muted">↗ jurnal</span>
|
||||
</div>
|
||||
<!-- LIVE PROGRESS (shown only when sync is running) -->
|
||||
<div class="sync-card-progress" id="syncProgressArea" style="display:none;">
|
||||
@@ -49,6 +51,8 @@
|
||||
<div class="filter-bar" id="ordersFilterBar">
|
||||
<!-- Period dropdown -->
|
||||
<select id="periodSelect" class="select-compact">
|
||||
<option value="1">1 zi</option>
|
||||
<option value="2">2 zile</option>
|
||||
<option value="3">3 zile</option>
|
||||
<option value="7" selected>7 zile</option>
|
||||
<option value="30">30 zile</option>
|
||||
@@ -62,56 +66,47 @@
|
||||
<span>—</span>
|
||||
<input type="date" id="periodEnd" class="select-compact">
|
||||
</div>
|
||||
<input type="search" id="orderSearch" placeholder="Cauta comanda, client..." class="search-input">
|
||||
<!-- Status pills -->
|
||||
<button class="filter-pill active" data-status="all">Toate <span class="filter-count" id="cntAll">0</span></button>
|
||||
<button class="filter-pill" data-status="IMPORTED">Imp. <span class="filter-count" id="cntImp">0</span></button>
|
||||
<button class="filter-pill" data-status="SKIPPED">Omise <span class="filter-count" id="cntSkip">0</span></button>
|
||||
<button class="filter-pill" data-status="ERROR">Erori <span class="filter-count" id="cntErr">0</span></button>
|
||||
<button class="filter-pill" data-status="UNINVOICED">Nefact. <span class="filter-count" id="cntNef">0</span></button>
|
||||
<!-- Search (integrated, end of row) -->
|
||||
<input type="search" id="orderSearch" placeholder="Cauta..." class="search-input">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pagination top bar -->
|
||||
<div class="card-body py-1 px-3 border-bottom d-flex justify-content-between align-items-center" style="gap:0.5rem;">
|
||||
<small class="text-muted" id="dashPageInfoTop"></small>
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<label style="font-size:0.8125rem;color:#6b7280;white-space:nowrap;">Per pagina:
|
||||
<select id="perPageSelect" class="select-compact" style="margin-left:0.25rem;" onchange="dashChangePerPage(this.value)">
|
||||
<option value="25">25</option>
|
||||
<option value="50" selected>50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="250">250</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="dashPaginationTop" class="d-flex align-items-center gap-2"></div>
|
||||
<button class="filter-pill active d-none d-md-inline-flex" data-status="all">Toate <span class="filter-count fc-neutral" id="cntAll">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-status="IMPORTED">Importat <span class="filter-count fc-green" id="cntImp">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-status="SKIPPED">Omise <span class="filter-count fc-yellow" id="cntSkip">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-status="ERROR">Erori <span class="filter-count fc-red" id="cntErr">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-status="INVOICED">Facturate <span class="filter-count fc-green" id="cntFact">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-status="UNINVOICED">Nefacturate <span class="filter-count fc-red" id="cntNef">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-status="CANCELLED">Anulate <span class="filter-count fc-dark" id="cntCanc">0</span></button>
|
||||
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" id="btnRefreshInvoices" onclick="refreshInvoices()" title="Actualizeaza status facturi din Oracle">↻</button>
|
||||
</div>
|
||||
<div class="d-md-none mb-2 d-flex align-items-center gap-2">
|
||||
<div class="flex-grow-1" id="dashMobileSeg"></div>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="btnRefreshInvoicesMobile" onclick="refreshInvoices()" title="Actualizeaza facturi" style="padding:4px 8px; font-size:1rem; line-height:1">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dashPaginationTop" class="pag-strip"></div>
|
||||
<div class="card-body p-0">
|
||||
<div id="dashMobileList" class="mobile-list"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" onclick="dashSortBy('order_number')">Nr Comanda <span class="sort-icon" data-col="order_number"></span></th>
|
||||
<th style="width:24px"></th>
|
||||
<th class="sortable" onclick="dashSortBy('order_date')">Data <span class="sort-icon" data-col="order_date"></span></th>
|
||||
<th class="sortable" onclick="dashSortBy('customer_name')">Client <span class="sort-icon" data-col="customer_name"></span></th>
|
||||
<th class="sortable" onclick="dashSortBy('order_number')">Nr Comanda <span class="sort-icon" data-col="order_number"></span></th>
|
||||
<th class="sortable" onclick="dashSortBy('items_count')">Art. <span class="sort-icon" data-col="items_count"></span></th>
|
||||
<th class="sortable" onclick="dashSortBy('status')">Status Import <span class="sort-icon" data-col="status"></span></th>
|
||||
<th>ID ROA</th>
|
||||
<th>Factura</th>
|
||||
<th>Total</th>
|
||||
<th class="text-end">Transport</th>
|
||||
<th class="text-end">Discount</th>
|
||||
<th class="text-end">Total</th>
|
||||
<th style="width:28px" title="Facturat">F</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dashOrdersBody">
|
||||
<tr><td colspan="8" class="text-center text-muted py-3">Se incarca...</td></tr>
|
||||
<tr><td colspan="9" class="text-center text-muted py-3">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted" id="dashPageInfo"></small>
|
||||
<div id="dashPagination" class="d-flex align-items-center gap-2"></div>
|
||||
</div>
|
||||
<div id="dashPagination" class="pag-strip pag-strip-bottom"></div>
|
||||
</div>
|
||||
|
||||
<!-- Order Detail Modal -->
|
||||
@@ -134,26 +129,35 @@
|
||||
<small class="text-muted">ID Partener:</small> <span id="detailIdPartener">-</span><br>
|
||||
<small class="text-muted">ID Adr. Facturare:</small> <span id="detailIdAdresaFact">-</span><br>
|
||||
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
|
||||
<div id="detailInvoiceInfo" style="display:none; margin-top:4px;">
|
||||
<small class="text-muted">Factura:</small> <span id="detailInvoiceNumber"></span>
|
||||
<span class="ms-2"><small class="text-muted">din</small> <span id="detailInvoiceDate"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
</div>
|
||||
<div id="detailTotals" class="d-flex gap-3 mb-2 flex-wrap" style="font-size:0.875rem">
|
||||
<span><small class="text-muted">Valoare:</small> <strong id="detailItemsTotal">-</strong></span>
|
||||
<span id="detailDiscountWrap"><small class="text-muted">Discount:</small> <strong id="detailDiscount">-</strong></span>
|
||||
<span id="detailDeliveryWrap"><small class="text-muted">Transport:</small> <strong id="detailDeliveryCost">-</strong></span>
|
||||
<span><small class="text-muted">Total:</small> <strong id="detailOrderTotal">-</strong></span>
|
||||
</div>
|
||||
<div class="table-responsive d-none d-md-block">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Produs</th>
|
||||
<th>CODMAT</th>
|
||||
<th>Cant.</th>
|
||||
<th>Pret</th>
|
||||
<th>TVA</th>
|
||||
<th>CODMAT</th>
|
||||
<th>Status</th>
|
||||
<th>Actiune</th>
|
||||
<th class="text-end">Valoare</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="detailItemsBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-md-none" id="detailItemsMobile"></div>
|
||||
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -172,20 +176,27 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Produs web:</small> <strong id="qmProductName"></strong>
|
||||
<div style="margin-bottom:8px; font-size:0.85rem">
|
||||
<small class="text-muted">Produs:</small> <strong id="qmProductName"></strong>
|
||||
</div>
|
||||
<div class="qm-row" style="font-size:0.7rem; color:#9ca3af; padding:0 0 2px">
|
||||
<span style="flex:1">CODMAT</span>
|
||||
<span style="width:70px">Cant.</span>
|
||||
<span style="width:70px">%</span>
|
||||
<span style="width:30px"></span>
|
||||
</div>
|
||||
<div id="qmCodmatLines">
|
||||
<!-- Dynamic CODMAT lines -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addQmCodmatLine()">
|
||||
<i class="bi bi-plus"></i> Adauga CODMAT
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="addQmCodmatLine()" style="font-size:0.8rem; padding:2px 10px">
|
||||
+ CODMAT
|
||||
</button>
|
||||
<div id="qmDirectInfo" class="alert alert-info mt-2" style="display:none; font-size:0.85rem; padding:8px 12px;"></div>
|
||||
<div id="qmPctWarning" class="text-danger mt-2" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveQuickMapping()">Salveaza</button>
|
||||
<button type="button" class="btn btn-primary" id="qmSaveBtn" onclick="saveQuickMapping()">Salveaza</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,5 +204,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=17"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,26 +5,17 @@
|
||||
{% block content %}
|
||||
<h4 class="mb-4">Jurnale Import</h4>
|
||||
|
||||
<!-- Sync Run Selector -->
|
||||
<div class="card mb-4">
|
||||
<!-- Sync Run Selector + Status + Controls (single card) -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<!-- Desktop layout -->
|
||||
<div class="d-none d-md-flex align-items-center gap-3 flex-wrap">
|
||||
<label class="form-label mb-0 fw-bold text-nowrap">Sync Run:</label>
|
||||
<select class="form-select form-select-sm" id="runsDropdown" onchange="selectRun(this.value)">
|
||||
<select class="form-select form-select-sm" id="runsDropdown" onchange="selectRun(this.value)" style="max-width:400px">
|
||||
<option value="">Se incarca...</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-secondary text-nowrap" onclick="loadRuns()" title="Reincarca lista"><i class="bi bi-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Viewer (shown when run selected) -->
|
||||
<div id="logViewerSection" style="display:none;">
|
||||
<!-- Filter bar -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Run: <code id="logRunId"></code> <span class="badge bg-secondary" id="logStatusBadge">-</span></span>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span id="logStatusBadge" style="font-weight:600">-</span>
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="autoRefreshToggle" checked>
|
||||
<label class="form-check-label small" for="autoRefreshToggle">Auto-refresh</label>
|
||||
@@ -33,50 +24,67 @@
|
||||
<i class="bi bi-file-text"></i> Log text brut
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="btn-group" role="group" id="orderFilterBtns">
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="filterOrders('all')">
|
||||
Toate <span class="badge bg-light text-dark ms-1" id="countAll">0</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-success" onclick="filterOrders('IMPORTED')">
|
||||
Importate <span class="badge bg-light text-dark ms-1" id="countImported">0</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning" onclick="filterOrders('SKIPPED')">
|
||||
Omise <span class="badge bg-light text-dark ms-1" id="countSkipped">0</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="filterOrders('ERROR')">
|
||||
Erori <span class="badge bg-light text-dark ms-1" id="countError">0</span>
|
||||
</button>
|
||||
<!-- Mobile compact layout -->
|
||||
<div class="d-flex d-md-none align-items-center gap-2">
|
||||
<span id="mobileRunDot" class="sync-status-dot idle" style="width:8px;height:8px"></span>
|
||||
<select class="form-select form-select-sm flex-grow-1" id="runsDropdownMobile" onchange="selectRun(this.value)" style="font-size:0.8rem">
|
||||
<option value="">Se incarca...</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadRuns()" title="Reincarca"><i class="bi bi-arrow-clockwise"></i></button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown"><i class="bi bi-three-dots-vertical"></i></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<label class="dropdown-item d-flex align-items-center gap-2">
|
||||
<input class="form-check-input" type="checkbox" id="autoRefreshToggleMobile" checked> Auto-refresh
|
||||
</label>
|
||||
</li>
|
||||
<li><a class="dropdown-item" href="#" onclick="toggleTextLog();return false"><i class="bi bi-file-text me-1"></i> Log text brut</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Viewer (shown when run selected) -->
|
||||
<div id="logViewerSection" style="display:none;">
|
||||
<!-- Filter pills -->
|
||||
<div class="filter-bar mb-3" id="orderFilterPills">
|
||||
<button class="filter-pill active d-none d-md-inline-flex" data-log-status="all">Toate <span class="filter-count fc-neutral" id="countAll">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-log-status="IMPORTED">Importate <span class="filter-count fc-green" id="countImported">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-log-status="ALREADY_IMPORTED">Deja imp. <span class="filter-count fc-blue" id="countAlreadyImported">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-log-status="SKIPPED">Omise <span class="filter-count fc-yellow" id="countSkipped">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-log-status="ERROR">Erori <span class="filter-count fc-red" id="countError">0</span></button>
|
||||
</div>
|
||||
<div class="d-md-none mb-2" id="logsMobileSeg"></div>
|
||||
|
||||
<!-- Orders table -->
|
||||
<div class="card mb-3">
|
||||
<div id="ordersPaginationTop" class="pag-strip"></div>
|
||||
<div class="card-body p-0">
|
||||
<div id="logsMobileList" class="mobile-list"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:24px"></th>
|
||||
<th>#</th>
|
||||
<th class="sortable" onclick="sortOrdersBy('order_date')">Data comanda <span class="sort-icon" data-col="order_date"></span></th>
|
||||
<th class="sortable" onclick="sortOrdersBy('order_number')">Nr. comanda <span class="sort-icon" data-col="order_number"></span></th>
|
||||
<th class="sortable" onclick="sortOrdersBy('customer_name')">Client <span class="sort-icon" data-col="customer_name"></span></th>
|
||||
<th class="sortable" onclick="sortOrdersBy('items_count')">Articole <span class="sort-icon" data-col="items_count"></span></th>
|
||||
<th class="sortable" onclick="sortOrdersBy('status')">Status <span class="sort-icon" data-col="status"></span></th>
|
||||
<th class="text-end">Transport</th>
|
||||
<th class="text-end">Discount</th>
|
||||
<th class="text-end">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="runOrdersBody">
|
||||
<tr><td colspan="6" class="text-center text-muted py-3">Selecteaza un sync run</td></tr>
|
||||
<tr><td colspan="9" class="text-center text-muted py-3">Selecteaza un sync run</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted" id="ordersPageInfo"></small>
|
||||
<div id="ordersPagination" class="d-flex align-items-center gap-2"></div>
|
||||
</div>
|
||||
<div id="ordersPagination" class="pag-strip pag-strip-bottom"></div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible text log -->
|
||||
@@ -110,24 +118,29 @@
|
||||
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<div id="detailTotals" class="d-flex gap-3 mb-2 flex-wrap" style="font-size:0.875rem">
|
||||
<span><small class="text-muted">Valoare:</small> <strong id="detailItemsTotal">-</strong></span>
|
||||
<span id="detailDiscountWrap"><small class="text-muted">Discount:</small> <strong id="detailDiscount">-</strong></span>
|
||||
<span id="detailDeliveryWrap"><small class="text-muted">Transport:</small> <strong id="detailDeliveryCost">-</strong></span>
|
||||
<span><small class="text-muted">Total:</small> <strong id="detailOrderTotal">-</strong></span>
|
||||
</div>
|
||||
<div class="table-responsive d-none d-md-block">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Produs</th>
|
||||
<th>CODMAT</th>
|
||||
<th>Cant.</th>
|
||||
<th>Pret</th>
|
||||
<th>TVA</th>
|
||||
<th>CODMAT</th>
|
||||
<th>Status</th>
|
||||
<th>Actiune</th>
|
||||
<th class="text-end">Valoare</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="detailItemsBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-md-none" id="detailItemsMobile"></div>
|
||||
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -170,5 +183,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/logs.js"></script>
|
||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=9"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,19 +3,25 @@
|
||||
{% block nav_mappings %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.badge-pct { font-size: 0.7rem; padding: 0.1rem 0.35rem; border-radius: 4px; font-weight: 600; }
|
||||
.badge-pct.complete { background: #d1fae5; color: #065f46; }
|
||||
.badge-pct.incomplete { background: #fef3c7; color: #92400e; }
|
||||
</style>
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">Mapari SKU</h4>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="downloadTemplate()"><i class="bi bi-file-earmark-arrow-down"></i> Template CSV</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="exportCsv()"><i class="bi bi-download"></i> Export CSV</button>
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button>
|
||||
<button class="btn btn-sm btn-primary" onclick="showInlineAddRow()"><i class="bi bi-plus-lg"></i> Adauga Mapare</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right"></i> Formular complet</button>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<!-- Desktop buttons -->
|
||||
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="downloadTemplate()"><i class="bi bi-file-earmark-arrow-down"></i> Template CSV</button>
|
||||
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="exportCsv()"><i class="bi bi-download"></i> Export CSV</button>
|
||||
<button class="btn btn-sm btn-outline-primary d-none d-md-inline-flex" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button>
|
||||
<button class="btn btn-sm btn-primary" onclick="showInlineAddRow()"><i class="bi bi-plus-lg"></i> <span class="d-none d-md-inline">Adauga Mapare</span><span class="d-md-none">Mapare</span></button>
|
||||
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right"></i> Formular complet</button>
|
||||
<!-- Mobile ⋯ dropdown -->
|
||||
<div class="dropdown d-md-none">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-three-dots-vertical"></i></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="#" onclick="downloadTemplate();return false"><i class="bi bi-file-earmark-arrow-down me-1"></i> Template CSV</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="exportCsv();return false"><i class="bi bi-download me-1"></i> Export CSV</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload me-1"></i> Import CSV</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right me-1"></i> Formular complet</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,41 +49,23 @@
|
||||
|
||||
<!-- Percentage filter pills -->
|
||||
<div class="filter-bar" id="mappingsFilterBar">
|
||||
<button class="filter-pill active" data-pct="all">Toate <span class="filter-count" id="mCntAll">0</span></button>
|
||||
<button class="filter-pill" data-pct="complete">Complete ✓ <span class="filter-count" id="mCntComplete">0</span></button>
|
||||
<button class="filter-pill" data-pct="incomplete">Incomplete ⚠ <span class="filter-count" id="mCntIncomplete">0</span></button>
|
||||
<button class="filter-pill active d-none d-md-inline-flex" data-pct="all">Toate <span class="filter-count fc-neutral" id="mCntAll">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-pct="complete">Complete <span class="filter-count fc-green" id="mCntComplete">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-pct="incomplete">Incomplete <span class="filter-count fc-yellow" id="mCntIncomplete">0</span></button>
|
||||
</div>
|
||||
<div class="d-md-none mb-2" id="mappingsMobileSeg"></div>
|
||||
|
||||
<!-- Table -->
|
||||
<!-- Top pagination -->
|
||||
<div id="mappingsPagTop" class="pag-strip"></div>
|
||||
|
||||
<!-- Flat-row list (unified desktop + mobile) -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" onclick="sortBy('sku')">SKU <span class="sort-icon" data-col="sku"></span></th>
|
||||
<th>Produs Web</th>
|
||||
<th class="sortable" onclick="sortBy('codmat')">CODMAT <span class="sort-icon" data-col="codmat"></span></th>
|
||||
<th class="sortable" onclick="sortBy('denumire')">Denumire <span class="sort-icon" data-col="denumire"></span></th>
|
||||
<th>UM</th>
|
||||
<th class="sortable" onclick="sortBy('cantitate_roa')">Cantitate ROA <span class="sort-icon" data-col="cantitate_roa"></span></th>
|
||||
<th class="sortable" onclick="sortBy('procent_pret')">Procent Pret <span class="sort-icon" data-col="procent_pret"></span></th>
|
||||
<th class="sortable" onclick="sortBy('activ')">Activ <span class="sort-icon" data-col="activ"></span></th>
|
||||
<th style="width:100px">Actiuni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mappingsBody">
|
||||
<tr><td colspan="9" class="text-center text-muted py-4">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="mappingsFlatList" class="mappings-flat-list">
|
||||
<div class="flat-row text-muted py-4 justify-content-center">Se incarca...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted" id="pageInfo"></small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0" id="pagination"></ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div id="mappingsPagBottom" class="pag-strip pag-strip-bottom"></div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal with multi-CODMAT support (R11) -->
|
||||
@@ -166,5 +154,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/mappings.js"></script>
|
||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=7"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,63 +5,65 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">SKU-uri Lipsa</h4>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="exportMissingCsv()">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="exportMissingCsv()">
|
||||
<i class="bi bi-download"></i> Export CSV
|
||||
</button>
|
||||
<!-- Mobile ⋯ dropdown -->
|
||||
<div class="dropdown d-md-none">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-three-dots-vertical"></i></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="#" onclick="document.getElementById('rescanBtn').click();return false"><i class="bi bi-arrow-clockwise me-1"></i> Re-scan</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="exportMissingCsv();return false"><i class="bi bi-download me-1"></i> Export CSV</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unified filter bar -->
|
||||
<div class="filter-bar" id="skusFilterBar">
|
||||
<button class="filter-pill active" data-sku-status="unresolved">
|
||||
Nerezolvate <span class="filter-count" id="cntUnres">0</span>
|
||||
<button class="filter-pill active d-none d-md-inline-flex" data-sku-status="unresolved">
|
||||
Nerezolvate <span class="filter-count fc-yellow" id="cntUnres">0</span>
|
||||
</button>
|
||||
<button class="filter-pill" data-sku-status="resolved">
|
||||
Rezolvate <span class="filter-count" id="cntRes">0</span>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-sku-status="resolved">
|
||||
Rezolvate <span class="filter-count fc-green" id="cntRes">0</span>
|
||||
</button>
|
||||
<button class="filter-pill" data-sku-status="all">
|
||||
Toate <span class="filter-count" id="cntAllSkus">0</span>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-sku-status="all">
|
||||
Toate <span class="filter-count fc-neutral" id="cntAllSkus">0</span>
|
||||
</button>
|
||||
<input type="search" id="skuSearch" placeholder="Cauta SKU / produs..." class="search-input">
|
||||
<button id="rescanBtn" class="btn btn-secondary btn-compact" style="margin-left:0.5rem;">↻ Re-scan</button>
|
||||
<span id="rescanProgress" style="display:none;align-items:center;gap:0.4rem;font-size:0.8125rem;color:#1d4ed8;">
|
||||
<button id="rescanBtn" class="btn btn-sm btn-secondary ms-2 d-none d-md-inline-flex">↻ Re-scan</button>
|
||||
<span id="rescanProgress" class="align-items-center gap-2 text-primary" style="display:none;">
|
||||
<span class="sync-live-dot"></span>
|
||||
<span id="rescanProgressText">Scanare...</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-md-none mb-2" id="skusMobileSeg"></div>
|
||||
<!-- Result banner -->
|
||||
<div id="rescanResult" class="result-banner" style="display:none;margin-bottom:0.75rem;"></div>
|
||||
|
||||
<div id="skusPagTop" class="pag-strip mb-2"></div>
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div id="missingMobileList" class="mobile-list"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>SKU</th>
|
||||
<th>Produs</th>
|
||||
<th>Nr. Comenzi</th>
|
||||
<th>Client</th>
|
||||
<th>First Seen</th>
|
||||
<th>Status</th>
|
||||
<th>Actiune</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="missingBody">
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">Se incarca...</td></tr>
|
||||
<tr><td colspan="4" class="text-center text-muted py-4">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<small class="text-muted" id="missingInfo"></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="paginationNav" class="mt-3">
|
||||
<ul class="pagination justify-content-center" id="paginationControls"></ul>
|
||||
</nav>
|
||||
<div id="skusPagBottom" class="pag-strip pag-strip-bottom"></div>
|
||||
|
||||
<!-- Map SKU Modal with multi-CODMAT support (R11) -->
|
||||
<div class="modal fade" id="mapModal" tabindex="-1">
|
||||
@@ -98,7 +100,9 @@ let currentMapSku = '';
|
||||
let mapAcTimeout = null;
|
||||
let currentPage = 1;
|
||||
let skuStatusFilter = 'unresolved';
|
||||
const perPage = 20;
|
||||
let missingPerPage = 20;
|
||||
|
||||
function missingChangePerPage(val) { missingPerPage = parseInt(val) || 20; currentPage = 1; loadMissingSkus(); }
|
||||
|
||||
// ── Filter pills ──────────────────────────────────
|
||||
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(btn => {
|
||||
@@ -158,7 +162,7 @@ function loadMissingSkus(page) {
|
||||
const resolvedVal = resolvedParamFor(skuStatusFilter);
|
||||
params.set('resolved', resolvedVal);
|
||||
params.set('page', currentPage);
|
||||
params.set('per_page', perPage);
|
||||
params.set('per_page', missingPerPage);
|
||||
const search = document.getElementById('skuSearch')?.value?.trim();
|
||||
if (search) params.set('search', search);
|
||||
|
||||
@@ -170,12 +174,27 @@ function loadMissingSkus(page) {
|
||||
if (el('cntUnres')) el('cntUnres').textContent = c.unresolved || 0;
|
||||
if (el('cntRes')) el('cntRes').textContent = c.resolved || 0;
|
||||
if (el('cntAllSkus')) el('cntAllSkus').textContent = c.total || 0;
|
||||
|
||||
// Mobile segmented control
|
||||
renderMobileSegmented('skusMobileSeg', [
|
||||
{ label: 'Nerez.', count: c.unresolved || 0, value: 'unresolved', active: skuStatusFilter === 'unresolved', colorClass: 'fc-yellow' },
|
||||
{ label: 'Rez.', count: c.resolved || 0, value: 'resolved', active: skuStatusFilter === 'resolved', colorClass: 'fc-green' },
|
||||
{ label: 'Toate', count: c.total || 0, value: 'all', active: skuStatusFilter === 'all', colorClass: 'fc-neutral' }
|
||||
], (val) => {
|
||||
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(b => b.classList.remove('active'));
|
||||
const pill = document.querySelector(`.filter-pill[data-sku-status="${val}"]`);
|
||||
if (pill) pill.classList.add('active');
|
||||
skuStatusFilter = val;
|
||||
currentPage = 1;
|
||||
loadMissingSkus();
|
||||
});
|
||||
|
||||
renderMissingSkusTable(data.skus || data.missing_skus || [], data);
|
||||
renderPagination(data);
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('missingBody').innerHTML =
|
||||
`<tr><td colspan="7" class="text-center text-danger">${err.message}</td></tr>`;
|
||||
`<tr><td colspan="4" class="text-center text-danger">${err.message}</td></tr>`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,38 +203,24 @@ function loadMissing(page) { loadMissingSkus(page); }
|
||||
|
||||
function renderMissingSkusTable(skus, data) {
|
||||
const tbody = document.getElementById('missingBody');
|
||||
if (data) {
|
||||
document.getElementById('missingInfo').textContent =
|
||||
`Total: ${data.total || 0} | Pagina: ${data.page || 1} din ${data.pages || 1}`;
|
||||
}
|
||||
const mobileList = document.getElementById('missingMobileList');
|
||||
|
||||
if (!skus || skus.length === 0) {
|
||||
const msg = skuStatusFilter === 'unresolved' ? 'Toate SKU-urile sunt mapate!' :
|
||||
skuStatusFilter === 'resolved' ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit';
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">${msg}</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="4" class="text-center text-muted py-4">${msg}</td></tr>`;
|
||||
if (mobileList) mobileList.innerHTML = `<div class="flat-row text-muted py-3 justify-content-center">${msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = skus.map(s => {
|
||||
const statusBadge = s.resolved
|
||||
? '<span class="badge bg-success">Rezolvat</span>'
|
||||
: '<span class="badge bg-warning text-dark">Nerezolvat</span>';
|
||||
|
||||
let firstCustomer = '-';
|
||||
try {
|
||||
const customers = JSON.parse(s.customers || '[]');
|
||||
if (customers.length > 0) firstCustomer = customers[0];
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
const orderCount = s.order_count != null ? s.order_count : '-';
|
||||
|
||||
return `<tr class="${s.resolved ? 'table-light' : ''}">
|
||||
const trAttrs = !s.resolved
|
||||
? ` style="cursor:pointer" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')"`
|
||||
: '';
|
||||
return `<tr${trAttrs}>
|
||||
<td>${s.resolved ? '<span class="dot dot-green"></span>' : '<span class="dot dot-yellow"></span>'}</td>
|
||||
<td><code>${esc(s.sku)}</code></td>
|
||||
<td>${esc(s.product_name || '-')}</td>
|
||||
<td>${esc(orderCount)}</td>
|
||||
<td><small>${esc(firstCustomer)}</small></td>
|
||||
<td><small>${s.first_seen ? new Date(s.first_seen).toLocaleDateString('ro-RO') : '-'}</small></td>
|
||||
<td>${statusBadge}</td>
|
||||
<td class="truncate" style="max-width:300px">${esc(s.product_name || '-')}</td>
|
||||
<td>
|
||||
${!s.resolved
|
||||
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza">
|
||||
@@ -225,31 +230,33 @@ function renderMissingSkusTable(skus, data) {
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
if (mobileList) {
|
||||
mobileList.innerHTML = skus.map(s => {
|
||||
const actionHtml = !s.resolved
|
||||
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;"><i class="bi bi-link-45deg"></i></a>`
|
||||
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`;
|
||||
const flatRowAttrs = !s.resolved
|
||||
? ` onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')" style="cursor:pointer"`
|
||||
: '';
|
||||
return `<div class="flat-row"${flatRowAttrs}>
|
||||
${s.resolved ? '<span class="dot dot-green"></span>' : '<span class="dot dot-yellow"></span>'}
|
||||
<code class="me-1 text-nowrap">${esc(s.sku)}</code>
|
||||
<span class="grow truncate">${esc(s.product_name || '-')}</span>
|
||||
${actionHtml}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function renderPagination(data) {
|
||||
const ul = document.getElementById('paginationControls');
|
||||
const total = data.pages || 1;
|
||||
const page = data.page || 1;
|
||||
if (total <= 1) { ul.innerHTML = ''; return; }
|
||||
|
||||
let html = '';
|
||||
html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="loadMissingSkus(${page - 1}); return false;">Anterior</a></li>`;
|
||||
|
||||
const range = 2;
|
||||
for (let i = 1; i <= total; i++) {
|
||||
if (i === 1 || i === total || (i >= page - range && i <= page + range)) {
|
||||
html += `<li class="page-item ${i === page ? 'active' : ''}">
|
||||
<a class="page-link" href="#" onclick="loadMissingSkus(${i}); return false;">${i}</a></li>`;
|
||||
} else if (i === page - range - 1 || i === page + range + 1) {
|
||||
html += `<li class="page-item disabled"><span class="page-link">…</span></li>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += `<li class="page-item ${page >= total ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="loadMissingSkus(${page + 1}); return false;">Urmator</a></li>`;
|
||||
ul.innerHTML = html;
|
||||
const pagOpts = { perPage: missingPerPage, perPageFn: 'missingChangePerPage', perPageOptions: [20, 50, 100] };
|
||||
const infoHtml = `<small class="text-muted me-auto">Total: ${data.total || 0} | Pagina ${data.page || 1} din ${data.pages || 1}</small>`;
|
||||
const pagHtml = infoHtml + renderUnifiedPagination(data.page || 1, data.pages || 1, 'loadMissing', pagOpts);
|
||||
const top = document.getElementById('skusPagTop');
|
||||
const bot = document.getElementById('skusPagBottom');
|
||||
if (top) top.innerHTML = pagHtml;
|
||||
if (bot) bot.innerHTML = pagHtml;
|
||||
}
|
||||
|
||||
// ── Multi-CODMAT Map Modal ───────────────────────
|
||||
@@ -264,19 +271,6 @@ function openMapModal(sku, productName) {
|
||||
container.innerHTML = '';
|
||||
addMapCodmatLine();
|
||||
|
||||
// Pre-search with product name
|
||||
if (productName) {
|
||||
setTimeout(() => {
|
||||
const input = container.querySelector('.mc-codmat');
|
||||
if (input) {
|
||||
input.value = productName;
|
||||
mcAutocomplete(input,
|
||||
container.querySelector('.mc-ac-dropdown'),
|
||||
container.querySelector('.mc-selected'));
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
new bootstrap.Modal(document.getElementById('mapModal')).show();
|
||||
}
|
||||
|
||||
@@ -286,23 +280,20 @@ function addMapCodmatLine() {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'border rounded p-2 mb-2 mc-line';
|
||||
div.innerHTML = `
|
||||
<div class="mb-2 position-relative">
|
||||
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label>
|
||||
<input type="text" class="form-control form-control-sm mc-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col position-relative">
|
||||
<input type="text" class="form-control form-control-sm mc-codmat" placeholder="Cauta CODMAT..." autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none mc-ac-dropdown"></div>
|
||||
<small class="text-muted mc-selected"></small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
|
||||
<input type="number" class="form-control form-control-sm mc-cantitate" value="1" step="0.001" min="0.001">
|
||||
<div class="col-auto" style="width:90px">
|
||||
<input type="number" class="form-control form-control-sm mc-cantitate" value="1" step="0.001" min="0.001" placeholder="Cant." title="Cantitate ROA">
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label>
|
||||
<input type="number" class="form-control form-control-sm mc-procent" value="100" step="0.01" min="0" max="100">
|
||||
<div class="col-auto" style="width:90px">
|
||||
<input type="number" class="form-control form-control-sm mc-procent" value="100" step="0.01" min="0" max="100" placeholder="% Pret" title="Procent Pret">
|
||||
</div>
|
||||
<div class="col-2 d-flex align-items-end">
|
||||
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.mc-line').remove()"><i class="bi bi-x"></i></button>` : ''}
|
||||
<div class="col-auto">
|
||||
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.mc-line').remove()"><i class="bi bi-x"></i></button>` : '<div style="width:31px"></div>'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -400,9 +391,5 @@ function exportMissingCsv() {
|
||||
window.location.href = '/api/validate/missing-skus-csv';
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
171
api/app/templates/settings.html
Normal file
171
api/app/templates/settings.html
Normal file
@@ -0,0 +1,171 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Setari - GoMag Import{% endblock %}
|
||||
{% block nav_settings %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h4 class="mb-3">Setari</h4>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<!-- GoMag API card -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2 px-3 fw-semibold">GoMag API</div>
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-0 small">API Key</label>
|
||||
<input type="text" class="form-control form-control-sm" id="settGomagApiKey" placeholder="4c5e46...">
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-0 small">Shop URL</label>
|
||||
<input type="text" class="form-control form-control-sm" id="settGomagApiShop" placeholder="https://coffeepoint.ro">
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label mb-0 small">Zile înapoi</label>
|
||||
<input type="number" class="form-control form-control-sm" id="settGomagDaysBack" value="7" min="1">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label mb-0 small">Limită/pagină</label>
|
||||
<input type="number" class="form-control form-control-sm" id="settGomagLimit" value="100" min="1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import ROA card -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2 px-3 fw-semibold">Import ROA</div>
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-0 small">Gestiuni pentru verificare stoc</label>
|
||||
<div id="settGestiuniContainer" class="border rounded p-2" style="max-height:120px;overflow-y:auto;font-size:0.85rem">
|
||||
<span class="text-muted small">Se încarcă...</span>
|
||||
</div>
|
||||
<div class="form-text" style="font-size:0.75rem">Nicio selecție = orice gestiune</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-0 small">Secție (ID_SECTIE)</label>
|
||||
<select class="form-select form-select-sm" id="settIdSectie">
|
||||
<option value="">— selectează secție —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-0 small">Politică Preț Vânzare (ID_POL)</label>
|
||||
<select class="form-select form-select-sm" id="settIdPol">
|
||||
<option value="">— selectează politică —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-0 small">Politică Preț Producție</label>
|
||||
<select class="form-select form-select-sm" id="settIdPolProductie">
|
||||
<option value="">— fără politică producție —</option>
|
||||
</select>
|
||||
<div class="form-text" style="font-size:0.75rem">Pentru articole cu cont 341/345 (producție proprie)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<!-- Transport card -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2 px-3 fw-semibold">Transport</div>
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-0 small">CODMAT Transport</label>
|
||||
<div class="position-relative">
|
||||
<input type="text" class="form-control form-control-sm" id="settTransportCodmat" placeholder="ex: TRANSPORT" autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none" id="settTransportAc"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label mb-0 small">TVA Transport (%)</label>
|
||||
<select class="form-select form-select-sm" id="settTransportVat">
|
||||
<option value="5">5%</option>
|
||||
<option value="9">9%</option>
|
||||
<option value="19">19%</option>
|
||||
<option value="21" selected>21%</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label mb-0 small">Politică Transport</label>
|
||||
<select class="form-select form-select-sm" id="settTransportIdPol">
|
||||
<option value="">— implicită —</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discount card -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2 px-3 fw-semibold">Discount</div>
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-0 small">CODMAT Discount</label>
|
||||
<div class="position-relative">
|
||||
<input type="text" class="form-control form-control-sm" id="settDiscountCodmat" placeholder="ex: DISCOUNT" autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none" id="settDiscountAc"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label mb-0 small">TVA Discount (fallback %)</label>
|
||||
<select class="form-select form-select-sm" id="settDiscountVat">
|
||||
<option value="5">5%</option>
|
||||
<option value="9">9%</option>
|
||||
<option value="11">11%</option>
|
||||
<option value="19">19%</option>
|
||||
<option value="21" selected>21%</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label mb-0 small">Politică Discount</label>
|
||||
<select class="form-select form-select-sm" id="settDiscountIdPol">
|
||||
<option value="">— implicită —</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="settSplitDiscountVat">
|
||||
<label class="form-check-label small" for="settSplitDiscountVat">
|
||||
Împarte discount pe cote TVA (proporțional cu valoarea articolelor)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2 px-3 fw-semibold">Dashboard</div>
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-0 small">Interval polling (secunde)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="settDashPollSeconds" value="5" min="1" max="300">
|
||||
<div class="form-text" style="font-size:0.75rem">Cât de des verifică dashboard-ul starea sync-ului (implicit 5s)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<button class="btn btn-primary btn-sm" onclick="saveSettings()">Salvează Setările</button>
|
||||
<span id="settSaveResult" class="ms-2 small"></span>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=6"></script>
|
||||
{% endblock %}
|
||||
@@ -18,6 +18,8 @@
|
||||
-- p_json_articole accepta:
|
||||
-- - array JSON: [{"sku":"X","quantity":"1","price":"10","vat":"19"}, ...]
|
||||
-- - obiect JSON: {"sku":"X","quantity":"1","price":"10","vat":"19"}
|
||||
-- Optional per articol: "id_pol":"5" — politica de pret specifica
|
||||
-- (pentru transport/discount cu politica separata de cea a comenzii)
|
||||
-- Valorile sku, quantity, price, vat sunt extrase ca STRING si convertite.
|
||||
-- Daca comanda exista deja (comanda_externa), nu se dubleaza.
|
||||
-- La eroare ridica RAISE_APPLICATION_ERROR(-20001, mesaj).
|
||||
@@ -59,6 +61,7 @@ CREATE OR REPLACE PACKAGE PACK_IMPORT_COMENZI AS
|
||||
p_id_adresa_facturare IN NUMBER DEFAULT NULL,
|
||||
p_id_pol IN NUMBER DEFAULT NULL,
|
||||
p_id_sectie IN NUMBER DEFAULT NULL,
|
||||
p_id_gestiune IN VARCHAR2 DEFAULT NULL,
|
||||
v_id_comanda OUT NUMBER);
|
||||
|
||||
-- Functii pentru managementul erorilor (pentru orchestrator VFP)
|
||||
@@ -86,6 +89,60 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
||||
g_last_error := NULL;
|
||||
END clear_error;
|
||||
|
||||
-- ================================================================
|
||||
-- Functie helper: selecteaza id_articol corect pentru un CODMAT
|
||||
-- Prioritate: sters=0 AND inactiv=0, preferinta stoc, MAX(id_articol) fallback
|
||||
-- ================================================================
|
||||
FUNCTION resolve_id_articol(p_codmat IN VARCHAR2, p_id_gest IN VARCHAR2) RETURN NUMBER IS
|
||||
v_result NUMBER;
|
||||
BEGIN
|
||||
IF p_id_gest IS NOT NULL THEN
|
||||
-- Cu gestiuni specifice (CSV: "1,3") — split in subquery pentru IN clause
|
||||
BEGIN
|
||||
SELECT id_articol INTO v_result FROM (
|
||||
SELECT na.id_articol
|
||||
FROM nom_articole na
|
||||
WHERE na.codmat = p_codmat AND na.sters = 0 AND na.inactiv = 0
|
||||
ORDER BY
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM stoc s
|
||||
WHERE s.id_articol = na.id_articol
|
||||
AND s.id_gestiune IN (
|
||||
SELECT TO_NUMBER(REGEXP_SUBSTR(p_id_gest, '[^,]+', 1, LEVEL))
|
||||
FROM DUAL
|
||||
CONNECT BY LEVEL <= REGEXP_COUNT(p_id_gest, ',') + 1
|
||||
)
|
||||
AND s.an = EXTRACT(YEAR FROM SYSDATE)
|
||||
AND s.luna = EXTRACT(MONTH FROM SYSDATE)
|
||||
AND s.cants + s.cant - s.cante > 0
|
||||
) THEN 0 ELSE 1 END,
|
||||
na.id_articol DESC
|
||||
) WHERE ROWNUM = 1;
|
||||
EXCEPTION WHEN NO_DATA_FOUND THEN v_result := NULL;
|
||||
END;
|
||||
ELSE
|
||||
-- Fara gestiune — cauta stoc in orice gestiune
|
||||
BEGIN
|
||||
SELECT id_articol INTO v_result FROM (
|
||||
SELECT na.id_articol
|
||||
FROM nom_articole na
|
||||
WHERE na.codmat = p_codmat AND na.sters = 0 AND na.inactiv = 0
|
||||
ORDER BY
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM stoc s
|
||||
WHERE s.id_articol = na.id_articol
|
||||
AND s.an = EXTRACT(YEAR FROM SYSDATE)
|
||||
AND s.luna = EXTRACT(MONTH FROM SYSDATE)
|
||||
AND s.cants + s.cant - s.cante > 0
|
||||
) THEN 0 ELSE 1 END,
|
||||
na.id_articol DESC
|
||||
) WHERE ROWNUM = 1;
|
||||
EXCEPTION WHEN NO_DATA_FOUND THEN v_result := NULL;
|
||||
END;
|
||||
END IF;
|
||||
RETURN v_result;
|
||||
END resolve_id_articol;
|
||||
|
||||
-- ================================================================
|
||||
-- Procedura principala pentru importul unei comenzi
|
||||
-- ================================================================
|
||||
@@ -97,6 +154,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
||||
p_id_adresa_facturare IN NUMBER DEFAULT NULL,
|
||||
p_id_pol IN NUMBER DEFAULT NULL,
|
||||
p_id_sectie IN NUMBER DEFAULT NULL,
|
||||
p_id_gestiune IN VARCHAR2 DEFAULT NULL,
|
||||
v_id_comanda OUT NUMBER) IS
|
||||
v_data_livrare DATE;
|
||||
v_sku VARCHAR2(100);
|
||||
@@ -113,6 +171,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
||||
v_codmat VARCHAR2(50);
|
||||
v_cantitate_roa NUMBER;
|
||||
v_pret_unitar NUMBER;
|
||||
v_id_pol_articol NUMBER; -- id_pol per articol (din JSON), prioritar fata de p_id_pol
|
||||
|
||||
-- pljson
|
||||
l_json_articole CLOB := p_json_articole;
|
||||
@@ -189,19 +248,33 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
||||
v_pret_web := TO_NUMBER(v_json_obj.get_string('price'));
|
||||
v_vat := TO_NUMBER(v_json_obj.get_string('vat'));
|
||||
|
||||
-- id_pol per articol (optional, pentru transport/discount cu politica separata)
|
||||
BEGIN
|
||||
v_id_pol_articol := TO_NUMBER(v_json_obj.get_string('id_pol'));
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN v_id_pol_articol := NULL;
|
||||
END;
|
||||
|
||||
-- STEP 3: Gaseste articolele ROA pentru acest SKU
|
||||
-- Cauta mai intai in ARTICOLE_TERTI (mapari speciale / seturi)
|
||||
v_found_mapping := FALSE;
|
||||
|
||||
FOR rec IN (SELECT at.codmat, at.cantitate_roa, at.procent_pret, na.id_articol
|
||||
FOR rec IN (SELECT at.codmat, at.cantitate_roa, at.procent_pret
|
||||
FROM articole_terti at
|
||||
JOIN nom_articole na ON na.codmat = at.codmat
|
||||
WHERE at.sku = v_sku
|
||||
AND at.activ = 1
|
||||
AND at.sters = 0
|
||||
ORDER BY at.procent_pret DESC) LOOP
|
||||
|
||||
v_found_mapping := TRUE;
|
||||
v_id_articol := resolve_id_articol(rec.codmat, p_id_gestiune);
|
||||
IF v_id_articol IS NULL THEN
|
||||
v_articole_eroare := v_articole_eroare + 1;
|
||||
g_last_error := g_last_error || CHR(10) ||
|
||||
'Articol activ negasit pentru CODMAT: ' || rec.codmat;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
v_cantitate_roa := rec.cantitate_roa * v_cantitate_web;
|
||||
v_pret_unitar := CASE WHEN v_pret_web IS NOT NULL
|
||||
THEN (v_pret_web * rec.procent_pret / 100) / rec.cantitate_roa
|
||||
@@ -210,8 +283,8 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
||||
|
||||
BEGIN
|
||||
PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda,
|
||||
V_ID_ARTICOL => rec.id_articol,
|
||||
V_ID_POL => p_id_pol,
|
||||
V_ID_ARTICOL => v_id_articol,
|
||||
V_ID_POL => NVL(v_id_pol_articol, p_id_pol),
|
||||
V_CANTITATE => v_cantitate_roa,
|
||||
V_PRET => v_pret_unitar,
|
||||
V_ID_UTIL => c_id_util,
|
||||
@@ -226,19 +299,21 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
-- Daca nu s-a gasit mapare, cauta direct in NOM_ARTICOLE
|
||||
-- Daca nu s-a gasit mapare, cauta direct in NOM_ARTICOLE via resolve_id_articol
|
||||
IF NOT v_found_mapping THEN
|
||||
BEGIN
|
||||
SELECT id_articol, codmat
|
||||
INTO v_id_articol, v_codmat
|
||||
FROM nom_articole
|
||||
WHERE codmat = v_sku;
|
||||
|
||||
v_id_articol := resolve_id_articol(v_sku, p_id_gestiune);
|
||||
IF v_id_articol IS NULL THEN
|
||||
v_articole_eroare := v_articole_eroare + 1;
|
||||
g_last_error := g_last_error || CHR(10) ||
|
||||
'SKU negasit in ARTICOLE_TERTI si NOM_ARTICOLE (activ): ' || v_sku;
|
||||
ELSE
|
||||
v_codmat := v_sku;
|
||||
v_pret_unitar := NVL(v_pret_web, 0);
|
||||
|
||||
BEGIN
|
||||
PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda,
|
||||
V_ID_ARTICOL => v_id_articol,
|
||||
V_ID_POL => p_id_pol,
|
||||
V_ID_POL => NVL(v_id_pol_articol, p_id_pol),
|
||||
V_CANTITATE => v_cantitate_web,
|
||||
V_PRET => v_pret_unitar,
|
||||
V_ID_UTIL => c_id_util,
|
||||
@@ -246,20 +321,13 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
||||
V_PTVA => v_vat);
|
||||
v_articole_procesate := v_articole_procesate + 1;
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN
|
||||
v_articole_eroare := v_articole_eroare + 1;
|
||||
g_last_error := g_last_error || CHR(10) ||
|
||||
'SKU negasit in ARTICOLE_TERTI si NOM_ARTICOLE: ' || v_sku;
|
||||
WHEN TOO_MANY_ROWS THEN
|
||||
v_articole_eroare := v_articole_eroare + 1;
|
||||
g_last_error := g_last_error || CHR(10) ||
|
||||
'Multiple articole gasite pentru SKU: ' || v_sku;
|
||||
WHEN OTHERS THEN
|
||||
v_articole_eroare := v_articole_eroare + 1;
|
||||
g_last_error := g_last_error || CHR(10) ||
|
||||
'Eroare adaugare articol ' || v_sku || ' (CODMAT: ' || v_codmat || '): ' || SQLERRM;
|
||||
END;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
END; -- End BEGIN block pentru articol individual
|
||||
|
||||
|
||||
16928
api/database-scripts/08_PACK_FACTURARE.pck
Normal file
16928
api/database-scripts/08_PACK_FACTURARE.pck
Normal file
File diff suppressed because it is too large
Load Diff
528
deploy.ps1
Normal file
528
deploy.ps1
Normal file
@@ -0,0 +1,528 @@
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Deploy / update GoMag Import Manager pe Windows Server cu IIS.
|
||||
|
||||
.DESCRIPTION
|
||||
- Prima rulare: clone repo, setup venv, genereaza start.bat, configureaza IIS
|
||||
- Rulari ulterioare: git pull, reinstaleaza deps, restarteaza serviciul
|
||||
|
||||
.PARAMETER RepoPath
|
||||
Calea locala unde se cloneaza repo-ul. Default: C:\gomag-vending
|
||||
|
||||
.PARAMETER Port
|
||||
Portul pe care ruleaza FastAPI. Default: 5003
|
||||
|
||||
.PARAMETER IisSiteName
|
||||
Numele site-ului IIS parinte. Default: "Default Web Site"
|
||||
|
||||
.PARAMETER SkipIIS
|
||||
Sarit configurarea IIS (util daca nu ai ARR/URLRewrite instalate inca)
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy.ps1
|
||||
.\deploy.ps1 -RepoPath "D:\apps\gomag-vending" -Port 5003
|
||||
.\deploy.ps1 -SkipIIS
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$RepoPath = "C:\gomag-vending",
|
||||
[int] $Port = 5003,
|
||||
[string]$IisSiteName = "Default Web Site",
|
||||
[switch]$SkipIIS
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
function Write-Step { param([string]$msg) Write-Host "`n==> $msg" -ForegroundColor Cyan }
|
||||
function Write-OK { param([string]$msg) Write-Host " [OK] $msg" -ForegroundColor Green }
|
||||
function Write-Warn { param([string]$msg) Write-Host " [WARN] $msg" -ForegroundColor Yellow }
|
||||
function Write-Fail { param([string]$msg) Write-Host " [FAIL] $msg" -ForegroundColor Red }
|
||||
function Write-Info { param([string]$msg) Write-Host " $msg" -ForegroundColor Gray }
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1. Citire token Gitea
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Citire token Gitea"
|
||||
|
||||
$TokenFile = Join-Path $ScriptDir ".gittoken"
|
||||
$GitToken = ""
|
||||
|
||||
if (Test-Path $TokenFile) {
|
||||
$GitToken = (Get-Content $TokenFile -Raw).Trim()
|
||||
Write-OK "Token citit din $TokenFile"
|
||||
} else {
|
||||
Write-Warn ".gittoken nu exista langa deploy.ps1"
|
||||
Write-Info "Creeaza fisierul $TokenFile cu token-ul tau Gitea (fara newline)"
|
||||
Write-Info "Ex: echo -n 'ghp_xxxx' > .gittoken"
|
||||
Write-Info ""
|
||||
Write-Info "Continui fara token (merge doar daca repo-ul e public sau deja clonat)"
|
||||
}
|
||||
|
||||
$RepoUrl = if ($GitToken) {
|
||||
"https://$GitToken@gitea.romfast.ro/romfast/gomag-vending.git"
|
||||
} else {
|
||||
"https://gitea.romfast.ro/romfast/gomag-vending.git"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2. Git clone / pull
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Git clone / pull"
|
||||
|
||||
# Verifica git instalat
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
Write-Fail "Git nu este instalat!"
|
||||
Write-Info "Descarca Git for Windows de la: https://git-scm.com/download/win"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (Test-Path (Join-Path $RepoPath ".git")) {
|
||||
Write-Info "Repo exista, fac git pull..."
|
||||
Push-Location $RepoPath
|
||||
try {
|
||||
# Update remote URL cu tokenul curent (in caz ca s-a schimbat)
|
||||
if ($GitToken) {
|
||||
git remote set-url origin $RepoUrl 2>$null
|
||||
}
|
||||
git pull --ff-only
|
||||
Write-OK "git pull OK"
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Info "Clonez in $RepoPath ..."
|
||||
$ParentDir = Split-Path -Parent $RepoPath
|
||||
if (-not (Test-Path $ParentDir)) {
|
||||
New-Item -ItemType Directory -Path $ParentDir -Force | Out-Null
|
||||
}
|
||||
git clone $RepoUrl $RepoPath
|
||||
Write-OK "git clone OK"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 3. Verificare Python
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Verificare Python"
|
||||
|
||||
$PythonCmd = $null
|
||||
foreach ($candidate in @("python", "python3", "py")) {
|
||||
try {
|
||||
$ver = & $candidate --version 2>&1
|
||||
if ($ver -match "Python 3\.(\d+)") {
|
||||
$minor = [int]$Matches[1]
|
||||
if ($minor -ge 11) {
|
||||
$PythonCmd = $candidate
|
||||
Write-OK "Python gasit: $ver ($candidate)"
|
||||
break
|
||||
} else {
|
||||
Write-Warn "Python $ver prea vechi (necesar 3.11+)"
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if (-not $PythonCmd) {
|
||||
Write-Fail "Python 3.11+ nu este instalat sau nu e in PATH!"
|
||||
Write-Info "Descarca de la: https://www.python.org/downloads/"
|
||||
Write-Info "IMPORTANT: Bifeaza 'Add Python to PATH' la instalare"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 4. Creare venv si instalare dependinte
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Virtual environment + dependinte"
|
||||
|
||||
$VenvDir = Join-Path $RepoPath "venv"
|
||||
$VenvPip = Join-Path $VenvDir "Scripts\pip.exe"
|
||||
$VenvPy = Join-Path $VenvDir "Scripts\python.exe"
|
||||
$ReqFile = Join-Path $RepoPath "api\requirements.txt"
|
||||
$DepsFlag = Join-Path $VenvDir ".deps_installed"
|
||||
|
||||
if (-not (Test-Path $VenvDir)) {
|
||||
Write-Info "Creez venv..."
|
||||
& $PythonCmd -m venv $VenvDir
|
||||
Write-OK "venv creat"
|
||||
}
|
||||
|
||||
# Reinstaleaza daca requirements.txt e mai nou decat flag-ul
|
||||
$needInstall = $true
|
||||
if (Test-Path $DepsFlag) {
|
||||
$reqTime = (Get-Item $ReqFile).LastWriteTime
|
||||
$flagTime = (Get-Item $DepsFlag).LastWriteTime
|
||||
if ($flagTime -ge $reqTime) { $needInstall = $false }
|
||||
}
|
||||
|
||||
if ($needInstall) {
|
||||
Write-Info "Instalez dependinte din requirements.txt..."
|
||||
& $VenvPip install --upgrade pip --quiet
|
||||
& $VenvPip install -r $ReqFile
|
||||
New-Item -ItemType File -Path $DepsFlag -Force | Out-Null
|
||||
Write-OK "Dependinte instalate"
|
||||
} else {
|
||||
Write-OK "Dependinte deja up-to-date"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 5. Detectare Oracle Home → sugestie INSTANTCLIENTPATH
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Detectare Oracle"
|
||||
|
||||
$OracleHome = $env:ORACLE_HOME
|
||||
$OracleBinPath = ""
|
||||
|
||||
if ($OracleHome -and (Test-Path $OracleHome)) {
|
||||
$OracleBinPath = Join-Path $OracleHome "bin"
|
||||
Write-OK "ORACLE_HOME detectat: $OracleHome"
|
||||
Write-Info "Seteaza in api\.env: INSTANTCLIENTPATH=$OracleBinPath"
|
||||
} else {
|
||||
# Cauta Oracle in locatii comune
|
||||
$commonPaths = @(
|
||||
"C:\oracle\product\19c\dbhome_1\bin",
|
||||
"C:\oracle\product\21c\dbhome_1\bin",
|
||||
"C:\app\oracle\product\19.0.0\dbhome_1\bin",
|
||||
"C:\oracle\instantclient_19_15",
|
||||
"C:\oracle\instantclient_21_3"
|
||||
)
|
||||
foreach ($p in $commonPaths) {
|
||||
if (Test-Path "$p\oci.dll") {
|
||||
$OracleBinPath = $p
|
||||
Write-OK "Oracle gasit la: $p"
|
||||
Write-Info "Seteaza in api\.env: INSTANTCLIENTPATH=$p"
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $OracleBinPath) {
|
||||
Write-Warn "Oracle Instant Client nu a fost gasit automat"
|
||||
Write-Info "Optiuni:"
|
||||
Write-Info " 1. Thick mode: seteaza INSTANTCLIENTPATH=<cale_oracle_bin> in api\.env"
|
||||
Write-Info " 2. Thin mode: seteaza FORCE_THIN_MODE=true in api\.env"
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 6. Creare .env din template daca lipseste
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Fisier configurare api\.env"
|
||||
|
||||
$EnvFile = Join-Path $RepoPath "api\.env"
|
||||
$EnvExample = Join-Path $RepoPath "api\.env.example"
|
||||
|
||||
if (-not (Test-Path $EnvFile)) {
|
||||
if (Test-Path $EnvExample) {
|
||||
Copy-Item $EnvExample $EnvFile
|
||||
Write-OK "api\.env creat din .env.example"
|
||||
|
||||
# Actualizeaza TNS_ADMIN cu calea reala
|
||||
$ApiDir = Join-Path $RepoPath "api"
|
||||
(Get-Content $EnvFile) -replace "TNS_ADMIN=.*", "TNS_ADMIN=$ApiDir" |
|
||||
Set-Content $EnvFile
|
||||
|
||||
# Seteaza INSTANTCLIENTPATH daca am gasit Oracle
|
||||
if ($OracleBinPath) {
|
||||
(Get-Content $EnvFile) -replace "INSTANTCLIENTPATH=.*", "INSTANTCLIENTPATH=$OracleBinPath" |
|
||||
Set-Content $EnvFile
|
||||
}
|
||||
|
||||
Write-Warn "IMPORTANT: Editeaza $EnvFile cu credentialele Oracle si GoMag API!"
|
||||
Write-Info " ORACLE_USER, ORACLE_PASSWORD, ORACLE_DSN"
|
||||
Write-Info " GOMAG_API_KEY, GOMAG_API_SHOP"
|
||||
} else {
|
||||
Write-Warn ".env.example nu exista, sari pasul"
|
||||
}
|
||||
} else {
|
||||
Write-OK "api\.env exista deja"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 7. Creare directoare necesare
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Directoare date"
|
||||
|
||||
foreach ($dir in @("data", "output", "logs")) {
|
||||
$fullPath = Join-Path $RepoPath $dir
|
||||
if (-not (Test-Path $fullPath)) {
|
||||
New-Item -ItemType Directory -Path $fullPath -Force | Out-Null
|
||||
Write-OK "Creat: $dir\"
|
||||
} else {
|
||||
Write-OK "Exista: $dir\"
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 8. Generare start.bat
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Generare start.bat"
|
||||
|
||||
$StartBat = Join-Path $RepoPath "start.bat"
|
||||
|
||||
# Citeste TNS_ADMIN si INSTANTCLIENTPATH din .env daca exista
|
||||
$TnsAdmin = Join-Path $RepoPath "api"
|
||||
$InstantClient = ""
|
||||
if (Test-Path $EnvFile) {
|
||||
Get-Content $EnvFile | ForEach-Object {
|
||||
if ($_ -match "^TNS_ADMIN=(.+)") {
|
||||
$TnsAdmin = $Matches[1].Trim()
|
||||
}
|
||||
if ($_ -match "^INSTANTCLIENTPATH=(.+)" -and $_ -notmatch "^#") {
|
||||
$InstantClient = $Matches[1].Trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$OraclePathLine = ""
|
||||
if ($InstantClient) {
|
||||
$OraclePathLine = "set PATH=$InstantClient;%PATH%"
|
||||
}
|
||||
|
||||
$StartBatContent = @"
|
||||
@echo off
|
||||
:: GoMag Import Manager - Windows Launcher
|
||||
:: Generat de deploy.ps1 - nu edita manual, ruleaza deploy.ps1 din nou
|
||||
|
||||
cd /d "$RepoPath"
|
||||
set TNS_ADMIN=$TnsAdmin
|
||||
$OraclePathLine
|
||||
|
||||
echo Starting GoMag Import Manager on http://0.0.0.0:$Port (prefix /gomag)
|
||||
"$VenvPy" -m uvicorn app.main:app --host 0.0.0.0 --port $Port --root-path /gomag --app-dir api
|
||||
"@
|
||||
|
||||
Set-Content -Path $StartBat -Value $StartBatContent -Encoding UTF8
|
||||
Write-OK "start.bat generat: $StartBat"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 9. IIS — Verificare ARR + URL Rewrite
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Verificare module IIS"
|
||||
|
||||
if ($SkipIIS) {
|
||||
Write-Warn "SkipIIS activ — configurare IIS sarita"
|
||||
} else {
|
||||
$ArrPath = "$env:SystemRoot\System32\inetsrv\arr.dll"
|
||||
$UrlRewritePath = "$env:SystemRoot\System32\inetsrv\rewrite.dll"
|
||||
|
||||
$ArrOk = Test-Path $ArrPath
|
||||
$UrlRwOk = Test-Path $UrlRewritePath
|
||||
|
||||
if ($ArrOk) {
|
||||
Write-OK "Application Request Routing (ARR) instalat"
|
||||
} else {
|
||||
Write-Warn "ARR 3.0 NU este instalat"
|
||||
Write-Info "Descarca: https://www.iis.net/downloads/microsoft/application-request-routing"
|
||||
Write-Info "Sau: winget install Microsoft.ARR"
|
||||
}
|
||||
|
||||
if ($UrlRwOk) {
|
||||
Write-OK "URL Rewrite 2.1 instalat"
|
||||
} else {
|
||||
Write-Warn "URL Rewrite 2.1 NU este instalat"
|
||||
Write-Info "Descarca: https://www.iis.net/downloads/microsoft/url-rewrite"
|
||||
Write-Info "Sau: winget install Microsoft.URLRewrite"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 10. Configurare IIS — copiere web.config
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
if ($ArrOk -and $UrlRwOk) {
|
||||
Write-Step "Configurare IIS reverse proxy"
|
||||
|
||||
# Activeaza proxy in ARR (necesar o singura data)
|
||||
try {
|
||||
Import-Module WebAdministration -ErrorAction SilentlyContinue
|
||||
$proxyEnabled = (Get-WebConfigurationProperty `
|
||||
-pspath "MACHINE/WEBROOT/APPHOST" `
|
||||
-filter "system.webServer/proxy" `
|
||||
-name "enabled" `
|
||||
-ErrorAction SilentlyContinue).Value
|
||||
if (-not $proxyEnabled) {
|
||||
Set-WebConfigurationProperty `
|
||||
-pspath "MACHINE/WEBROOT/APPHOST" `
|
||||
-filter "system.webServer/proxy" `
|
||||
-name "enabled" `
|
||||
-value $true
|
||||
Write-OK "ARR proxy activat global"
|
||||
} else {
|
||||
Write-OK "ARR proxy deja activ"
|
||||
}
|
||||
} catch {
|
||||
Write-Warn "Nu am putut activa ARR proxy automat: $($_.Exception.Message)"
|
||||
Write-Info "Activeaza manual din IIS Manager → server root → Application Request Routing Cache → Enable Proxy"
|
||||
}
|
||||
|
||||
# Determina wwwroot site-ului IIS
|
||||
$IisRootPath = $null
|
||||
try {
|
||||
Import-Module WebAdministration -ErrorAction SilentlyContinue
|
||||
$site = Get-Website -Name $IisSiteName -ErrorAction SilentlyContinue
|
||||
if ($site) {
|
||||
$IisRootPath = [System.Environment]::ExpandEnvironmentVariables($site.PhysicalPath)
|
||||
Write-OK "Site IIS '$IisSiteName' gasit: $IisRootPath"
|
||||
} else {
|
||||
Write-Warn "Site IIS '$IisSiteName' nu a fost gasit"
|
||||
}
|
||||
} catch {
|
||||
# Fallback la locatia standard
|
||||
$IisRootPath = "$env:SystemDrive\inetpub\wwwroot"
|
||||
Write-Warn "WebAdministration unavailable, folosesc fallback: $IisRootPath"
|
||||
}
|
||||
|
||||
if ($IisRootPath) {
|
||||
$SourceWebConfig = Join-Path $RepoPath "iis-web.config"
|
||||
$DestWebConfig = Join-Path $IisRootPath "web.config"
|
||||
|
||||
if (Test-Path $SourceWebConfig) {
|
||||
# Inlocuieste portul in web.config cu cel configurat
|
||||
$wcContent = Get-Content $SourceWebConfig -Raw
|
||||
$wcContent = $wcContent -replace "localhost:5003", "localhost:$Port"
|
||||
|
||||
if (Test-Path $DestWebConfig) {
|
||||
# Backup web.config existent
|
||||
$backup = "$DestWebConfig.bak_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
|
||||
Copy-Item $DestWebConfig $backup
|
||||
Write-Info "Backup web.config: $backup"
|
||||
}
|
||||
|
||||
Set-Content -Path $DestWebConfig -Value $wcContent -Encoding UTF8
|
||||
Write-OK "web.config copiat in $IisRootPath"
|
||||
} else {
|
||||
Write-Warn "iis-web.config nu exista in repo, sarit"
|
||||
}
|
||||
|
||||
# Restart IIS
|
||||
try {
|
||||
iisreset /noforce 2>&1 | Out-Null
|
||||
Write-OK "IIS restartat"
|
||||
} catch {
|
||||
Write-Warn "IIS restart esuat: $($_.Exception.Message)"
|
||||
Write-Info "Ruleaza manual: iisreset"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Warn "IIS nu e configurat complet — instaleaza ARR si URL Rewrite, apoi ruleaza deploy.ps1 din nou"
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 11. Serviciu Windows (NSSM sau Task Scheduler)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Serviciu Windows"
|
||||
|
||||
$ServiceName = "GoMagVending"
|
||||
$NssmExe = ""
|
||||
|
||||
# Cauta NSSM
|
||||
foreach ($p in @("nssm", "C:\nssm\win64\nssm.exe", "C:\tools\nssm\nssm.exe")) {
|
||||
if (Get-Command $p -ErrorAction SilentlyContinue) {
|
||||
$NssmExe = $p
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($NssmExe) {
|
||||
Write-Info "NSSM gasit: $NssmExe"
|
||||
|
||||
$existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
|
||||
if ($existingService) {
|
||||
Write-Info "Serviciu existent, restarteaza..."
|
||||
& $NssmExe restart $ServiceName
|
||||
Write-OK "Serviciu $ServiceName restartat"
|
||||
} else {
|
||||
Write-Info "Instalez serviciu $ServiceName cu NSSM..."
|
||||
& $NssmExe install $ServiceName (Join-Path $RepoPath "start.bat")
|
||||
& $NssmExe set $ServiceName AppDirectory $RepoPath
|
||||
& $NssmExe set $ServiceName DisplayName "GoMag Vending Import Manager"
|
||||
& $NssmExe set $ServiceName Description "Import comenzi web GoMag -> ROA Oracle"
|
||||
& $NssmExe set $ServiceName Start SERVICE_AUTO_START
|
||||
& $NssmExe set $ServiceName AppStdout (Join-Path $RepoPath "logs\service_stdout.log")
|
||||
& $NssmExe set $ServiceName AppStderr (Join-Path $RepoPath "logs\service_stderr.log")
|
||||
& $NssmExe set $ServiceName AppRotateFiles 1
|
||||
& $NssmExe set $ServiceName AppRotateOnline 1
|
||||
& $NssmExe set $ServiceName AppRotateBytes 10485760
|
||||
& $NssmExe start $ServiceName
|
||||
Write-OK "Serviciu $ServiceName instalat si pornit"
|
||||
}
|
||||
|
||||
} else {
|
||||
# Fallback: Task Scheduler
|
||||
Write-Warn "NSSM nu este instalat"
|
||||
Write-Info "Optiuni:"
|
||||
Write-Info " 1. Descarca NSSM: https://nssm.cc/download si pune nssm.exe in PATH"
|
||||
Write-Info " 2. Sau foloseste Task Scheduler (creat mai jos)"
|
||||
|
||||
# Verifica daca task-ul exista deja
|
||||
$taskExists = Get-ScheduledTask -TaskName $ServiceName -ErrorAction SilentlyContinue
|
||||
|
||||
if (-not $taskExists) {
|
||||
Write-Info "Creez Task Scheduler task '$ServiceName'..."
|
||||
try {
|
||||
$action = New-ScheduledTaskAction -Execute (Join-Path $RepoPath "start.bat")
|
||||
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Days 365) `
|
||||
-RestartCount 3 `
|
||||
-RestartInterval (New-TimeSpan -Minutes 1)
|
||||
$principal = New-ScheduledTaskPrincipal `
|
||||
-UserId "SYSTEM" `
|
||||
-LogonType ServiceAccount `
|
||||
-RunLevel Highest
|
||||
|
||||
Register-ScheduledTask `
|
||||
-TaskName $ServiceName `
|
||||
-Action $action `
|
||||
-Trigger $trigger `
|
||||
-Settings $settings `
|
||||
-Principal $principal `
|
||||
-Description "GoMag Vending Import Manager" `
|
||||
-Force | Out-Null
|
||||
|
||||
Start-ScheduledTask -TaskName $ServiceName
|
||||
Write-OK "Task Scheduler '$ServiceName' creat si pornit"
|
||||
} catch {
|
||||
Write-Warn "Task Scheduler esuat: $($_.Exception.Message)"
|
||||
Write-Info "Porneste manual: .\start.bat"
|
||||
}
|
||||
} else {
|
||||
# Restart task
|
||||
Stop-ScheduledTask -TaskName $ServiceName -ErrorAction SilentlyContinue
|
||||
Start-ScheduledTask -TaskName $ServiceName
|
||||
Write-OK "Task '$ServiceName' restartat"
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Sumar final
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Host ""
|
||||
Write-Host "══════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
Write-Host " GoMag Vending Deploy — Sumar" -ForegroundColor Cyan
|
||||
Write-Host "══════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host " Repo: $RepoPath" -ForegroundColor White
|
||||
Write-Host " FastAPI: http://localhost:$Port/gomag" -ForegroundColor White
|
||||
Write-Host " start.bat generat" -ForegroundColor White
|
||||
Write-Host ""
|
||||
|
||||
if (-not (Test-Path $EnvFile)) {
|
||||
Write-Host " [!] api\.env lipseste — configureaza inainte de start!" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host " api\.env: OK" -ForegroundColor Green
|
||||
# Verifica daca mai are valori placeholder
|
||||
$envContent = Get-Content $EnvFile -Raw
|
||||
if ($envContent -match "your_api_key_here|USER_ORACLE|parola_oracle|TNS_ALIAS") {
|
||||
Write-Host " [!] api\.env contine valori placeholder — editeaza!" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " Acces app: http://SERVER/gomag" -ForegroundColor Cyan
|
||||
Write-Host " Test local: http://localhost:$Port/gomag/health" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
62
iis-web.config
Normal file
62
iis-web.config
Normal file
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
IIS web.config pentru GoMag Vending — URL Rewrite + ARR Reverse Proxy
|
||||
Copiat automat de deploy.ps1 in wwwroot site-ului IIS.
|
||||
|
||||
Prerequisite:
|
||||
- Application Request Routing (ARR) 3.0
|
||||
- URL Rewrite 2.1
|
||||
Ambele gratuite de la iis.net.
|
||||
|
||||
Configuratie:
|
||||
Browser → http://SERVER/gomag/...
|
||||
↓
|
||||
IIS (port 80)
|
||||
↓ (URL Rewrite)
|
||||
http://localhost:5003/...
|
||||
FastAPI/uvicorn
|
||||
-->
|
||||
<configuration>
|
||||
<system.webServer>
|
||||
|
||||
<!-- Activeaza proxy (ARR) -->
|
||||
<proxy enabled="true" preserveHostHeader="false" reverseRewriteHostInResponseHeaders="false" />
|
||||
|
||||
<rewrite>
|
||||
<rules>
|
||||
<!--
|
||||
Regula principala: /gomag/* → http://localhost:5003/*
|
||||
FastAPI ruleaza cu --root-path /gomag deci stie de prefix.
|
||||
-->
|
||||
<rule name="GoMag Reverse Proxy" stopProcessing="true">
|
||||
<match url="^gomag(.*)" />
|
||||
<conditions>
|
||||
<add input="{CACHE_URL}" pattern="^(https?)://" />
|
||||
</conditions>
|
||||
<action type="Rewrite" url="http://localhost:5003{R:1}" />
|
||||
</rule>
|
||||
</rules>
|
||||
|
||||
<!-- Rescrie Location header-ele din raspunsurile FastAPI -->
|
||||
<outboundRules>
|
||||
<rule name="GoMag Fix Location Header" preCondition="IsRedirect">
|
||||
<match serverVariable="RESPONSE_Location" pattern="^http://localhost:5003/(.*)" />
|
||||
<action type="Rewrite" value="/gomag/{R:1}" />
|
||||
</rule>
|
||||
<preConditions>
|
||||
<preCondition name="IsRedirect">
|
||||
<add input="{RESPONSE_STATUS}" pattern="3\d\d" />
|
||||
</preCondition>
|
||||
</preConditions>
|
||||
</outboundRules>
|
||||
</rewrite>
|
||||
|
||||
<!-- Securitate: ascunde versiunea IIS -->
|
||||
<httpProtocol>
|
||||
<customHeaders>
|
||||
<remove name="X-Powered-By" />
|
||||
</customHeaders>
|
||||
</httpProtocol>
|
||||
|
||||
</system.webServer>
|
||||
</configuration>
|
||||
94
scripts/HANDOFF_MAPPING.md
Normal file
94
scripts/HANDOFF_MAPPING.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Handoff: Matching GoMag SKU → ROA Articole pentru Mapari
|
||||
|
||||
## Context
|
||||
|
||||
Vending (coffeepoint.ro) are ~429 comenzi GoMag importate in SQLite, din care ~393 SKIPPED (lipsesc mapari SKU).
|
||||
Facturile pentru aceste comenzi exista deja in Oracle ROA, create manual independent de import.
|
||||
Scopul: descoperim corespondenta SKU GoMag → id_articol ROA din compararea comenzilor cu facturile.
|
||||
|
||||
## Ce s-a facut
|
||||
|
||||
### 1. Fix customer_name (COMPLETAT - commits pe main)
|
||||
- **Problema:** `customer_name` in SQLite era shipping person, nu firma de facturare
|
||||
- **Fix:** Cand billing e pe firma, `customer_name = company_name` (nu shipping person)
|
||||
- **Fix 2:** `customer_name` nu se actualiza la upsert SQLite (doar la INSERT)
|
||||
- **Fix 3:** Dashboard JS afisa `shipping_name` cu prioritate in loc de `customer_name`
|
||||
- **Commits:** `cc872cf`, `ecb4777`, `172debd`, `8020b2d`
|
||||
|
||||
### 2. Matching comenzi → facturi (FUNCTIONEAZA)
|
||||
- **Metoda:** Fuzzy match pe client name + total comanda + data (±3 zile)
|
||||
- **Rezultat:** 428/429 comenzi matched cu facturi Oracle (1 nematched)
|
||||
- **Script:** `scripts/match_all.py`, `scripts/match_by_price.py`
|
||||
|
||||
### 3. Matching linii comenzi → linii facturi (ESUAT - REZULTATE NESATISFACATOARE)
|
||||
|
||||
#### Ce s-a incercat:
|
||||
1. **Match pe CODMAT** (SKU == CODMAT din vanzari_detalii) → Multe articole ROA nu au CODMAT completat
|
||||
2. **Match pe valoare linie** (qty × pret) → Functioneaza cand comanda GoMag corespunde exact cu factura
|
||||
3. **Match pe pret unitar** (pret fara TVA) → Idem, functioneaza doar cand articolele coincid
|
||||
|
||||
#### De ce nu merge:
|
||||
- **Articolele din factura ROA sunt COMPLET DIFERITE** fata de comanda GoMag in multe cazuri
|
||||
- Exemplu: comanda GoMag are "Lavazza Crema E Aroma" dar factura ROA are "CAFEA FRESSO BLUE"
|
||||
- Asta se intampla probabil pentru ca vanzatorul ajusteaza comanda inainte de facturare (inlocuieste produse, adauga altele, modifica cantitati)
|
||||
- Matching-ul pe pret gaseste corespondente FALSE (produse diferite care au intamplator acelasi pret)
|
||||
- Rezultat: din 37 mapari "simple 1:1", unele sunt corecte, altele sunt nonsens
|
||||
- Repackaging si seturi sunt aproape toate false
|
||||
|
||||
#### Ce a produs:
|
||||
- `scripts/output/update_codmat.sql` — 37 UPDATE-uri nom_articole (TREBUIE VERIFICATE MANUAL, multe sunt gresite)
|
||||
- `scripts/output/repack_mappings.csv` — 16 repackaging (majoritatea gresite)
|
||||
- `scripts/output/set_mappings.csv` — 52 seturi (aproape toate gresite)
|
||||
- `scripts/output/inconsistent_skus.csv` — 11 SKU-uri cu match-uri contradictorii
|
||||
|
||||
## Ce NU a mers si de ce
|
||||
|
||||
Algoritmul actual face matching "in bulk" pe toate comenzile simultan, ceea ce produce prea mult zgomot.
|
||||
Cand o comanda are produse complet diferite fata de factura, algoritmul forteaza match-uri absurde pe baza de pret.
|
||||
|
||||
## Strategie propusa pentru sesiunea urmatoare
|
||||
|
||||
### Abordare: subset → confirmare → generalizare
|
||||
|
||||
**Pas 1: Identificare perechi comanda-factura cu CERTITUDINE**
|
||||
- Foloseste perechile unde clientul se potriveste EXACT (score > 0.9) si totalul e identic
|
||||
- Aceste perechi au sanse mai mari sa aiba si articole corespunzatoare
|
||||
|
||||
**Pas 2: Comparare manuala pe un subset mic (5-10 perechi)**
|
||||
- Alege perechi unde numarul de articole GoMag == numarul de articole ROA (fara transport/discount)
|
||||
- Afiseaza side-by-side: GoMag SKU+produs+qty vs ROA codmat+produs+qty
|
||||
- User-ul confirma manual care corespondente sunt corecte
|
||||
|
||||
**Pas 3: Validare croise**
|
||||
- Un SKU care apare in mai multe comenzi trebuie sa se mapeze mereu pe acelasi id_articol
|
||||
- Daca SKU X → id_articol Y in comanda A dar SKU X → id_articol Z in comanda B → marcheaza ca suspect
|
||||
|
||||
**Pas 4: Generalizare doar pe mapari confirmate**
|
||||
- Extinde doar maparile validate pe subset la restul comenzilor
|
||||
- Nu forta match-uri noi — lasa unresolved ce nu se confirma
|
||||
|
||||
### Alt approach posibil: match pe DENUMIRE (fuzzy name match)
|
||||
- In loc de pret, compara denumirea produsului GoMag cu denumirea articolului ROA
|
||||
- Exemplu: "Lavazza Crema E Aroma Cafea Boabe 1 Kg" vs "LAVAZZA BBE CREMA E AROMA"
|
||||
- Ar putea fi mai precis decat match pe pret, mai ales cand preturile coincid accidental
|
||||
|
||||
### Tools utile deja existente:
|
||||
- `scripts/compare_order.py <order_nr> <fact_nr>` — comparare detaliata o comanda vs o factura
|
||||
- `scripts/fetch_one_order.py <order_nr>` — fetch JSON complet din GoMag API
|
||||
- `scripts/match_all.py` — matching bulk (de refacut cu strategie noua)
|
||||
|
||||
## Structura Oracle relevanta
|
||||
|
||||
- `vanzari` — header factura (id_vanzare, numar_act, serie_act, data_act, total_cu_tva, id_part)
|
||||
- `vanzari_detalii` — linii factura (id_vanzare, id_articol, cantitate, pret, pret_cu_tva)
|
||||
- `nom_articole` — nomenclator articole (id_articol, codmat, denumire)
|
||||
- `comenzi` — header comanda ROA (id_comanda, id_part, nr_comanda)
|
||||
- `comenzi_elemente` — linii comanda ROA
|
||||
- `nom_parteneri` — parteneri (id_part, denumire, prenume)
|
||||
- `ARTICOLE_TERTI` — mapari SKU → CODMAT (sku, codmat, cantitate_roa, procent_pret)
|
||||
|
||||
## Server
|
||||
- SSH: `ssh -p 22122 gomag@79.119.86.134`
|
||||
- App: `C:\gomag-vending`
|
||||
- SQLite: `C:\gomag-vending\api\data\import.db`
|
||||
- Oracle user: VENDING / ROMFASTSOFT / DSN=ROA
|
||||
@@ -1,306 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Parser pentru log-urile sync_comenzi_web.
|
||||
Extrage comenzi esuate, SKU-uri lipsa, si genereaza un sumar.
|
||||
Suporta atat formatul vechi (verbose) cat si formatul nou (compact).
|
||||
|
||||
Utilizare:
|
||||
python parse_sync_log.py # Ultimul log din vfp/log/
|
||||
python parse_sync_log.py <fisier.log> # Log specific
|
||||
python parse_sync_log.py --skus # Doar lista SKU-uri lipsa
|
||||
python parse_sync_log.py --dir /path/to/logs # Director custom
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import glob
|
||||
import argparse
|
||||
|
||||
# Regex pentru linii cu timestamp (intrare noua in log)
|
||||
RE_TIMESTAMP = re.compile(r'^\[(\d{2}:\d{2}:\d{2})\]\s+\[(\w+\s*)\]\s*(.*)')
|
||||
|
||||
# Regex format NOU: [N/Total] OrderNumber P:X A:Y/Z -> OK/ERR details
|
||||
RE_COMPACT_OK = re.compile(r'\[(\d+)/(\d+)\]\s+(\S+)\s+.*->\s+OK\s+ID:(\S+)')
|
||||
RE_COMPACT_ERR = re.compile(r'\[(\d+)/(\d+)\]\s+(\S+)\s+.*->\s+ERR\s+(.*)')
|
||||
|
||||
# Regex format VECHI (backwards compat)
|
||||
RE_SKU_NOT_FOUND = re.compile(r'SKU negasit.*?:\s*(\S+)')
|
||||
RE_PRICE_POLICY = re.compile(r'Pretul pentru acest articol nu a fost gasit')
|
||||
RE_FAILED_ORDER = re.compile(r'Import comanda esuat pentru\s+(\S+)')
|
||||
RE_ARTICOL_ERR = re.compile(r'Eroare adaugare articol\s+(\S+)')
|
||||
RE_ORDER_PROCESS = re.compile(r'Procesez comanda:\s+(\S+)\s+din\s+(\S+)')
|
||||
RE_ORDER_SUCCESS = re.compile(r'SUCCES: Comanda importata.*?ID Oracle:\s+(\S+)')
|
||||
|
||||
# Regex comune
|
||||
RE_SYNC_END = re.compile(r'SYNC END\s*\|.*?(\d+)\s+processed.*?(\d+)\s+ok.*?(\d+)\s+err')
|
||||
RE_STATS_LINE = re.compile(r'Duration:\s*(\S+)\s*\|\s*Orders:\s*(\S+)')
|
||||
RE_STOPPED_EARLY = re.compile(r'Peste \d+.*ero|stopped early')
|
||||
|
||||
|
||||
def find_latest_log(log_dir):
|
||||
"""Gaseste cel mai recent log sync_comenzi din directorul specificat."""
|
||||
pattern = os.path.join(log_dir, 'sync_comenzi_*.log')
|
||||
files = glob.glob(pattern)
|
||||
if not files:
|
||||
return None
|
||||
return max(files, key=os.path.getmtime)
|
||||
|
||||
|
||||
def parse_log_entries(lines):
|
||||
"""Parseaza liniile log-ului in intrari structurate."""
|
||||
entries = []
|
||||
current = None
|
||||
|
||||
for line in lines:
|
||||
line = line.rstrip('\n\r')
|
||||
m = RE_TIMESTAMP.match(line)
|
||||
if m:
|
||||
if current:
|
||||
entries.append(current)
|
||||
current = {
|
||||
'time': m.group(1),
|
||||
'level': m.group(2).strip(),
|
||||
'text': m.group(3),
|
||||
'full': line,
|
||||
'continuation': []
|
||||
}
|
||||
elif current is not None:
|
||||
current['continuation'].append(line)
|
||||
current['text'] += '\n' + line
|
||||
|
||||
if current:
|
||||
entries.append(current)
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def extract_sku_from_error(err_text):
|
||||
"""Extrage SKU din textul erorii (diverse formate)."""
|
||||
# SKU_NOT_FOUND: 8714858424056
|
||||
m = re.search(r'SKU_NOT_FOUND:\s*(\S+)', err_text)
|
||||
if m:
|
||||
return ('SKU_NOT_FOUND', m.group(1))
|
||||
|
||||
# PRICE_POLICY: 8000070028685
|
||||
m = re.search(r'PRICE_POLICY:\s*(\S+)', err_text)
|
||||
if m:
|
||||
return ('PRICE_POLICY', m.group(1))
|
||||
|
||||
# Format vechi: SKU negasit...NOM_ARTICOLE: xxx
|
||||
m = RE_SKU_NOT_FOUND.search(err_text)
|
||||
if m:
|
||||
return ('SKU_NOT_FOUND', m.group(1))
|
||||
|
||||
# Format vechi: Eroare adaugare articol xxx
|
||||
m = RE_ARTICOL_ERR.search(err_text)
|
||||
if m:
|
||||
return ('ARTICOL_ERROR', m.group(1))
|
||||
|
||||
# Format vechi: Pretul...
|
||||
if RE_PRICE_POLICY.search(err_text):
|
||||
return ('PRICE_POLICY', '(SKU necunoscut)')
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
def analyze_entries(entries):
|
||||
"""Analizeaza intrarile si extrage informatii relevante."""
|
||||
result = {
|
||||
'start_time': None,
|
||||
'end_time': None,
|
||||
'duration': None,
|
||||
'total_orders': 0,
|
||||
'success_orders': 0,
|
||||
'error_orders': 0,
|
||||
'stopped_early': False,
|
||||
'failed': [],
|
||||
'missing_skus': [],
|
||||
}
|
||||
|
||||
seen_skus = set()
|
||||
current_order = None
|
||||
|
||||
for entry in entries:
|
||||
text = entry['text']
|
||||
level = entry['level']
|
||||
|
||||
# Start/end time
|
||||
if entry['time']:
|
||||
if result['start_time'] is None:
|
||||
result['start_time'] = entry['time']
|
||||
result['end_time'] = entry['time']
|
||||
|
||||
# Format NOU: SYNC END line cu statistici
|
||||
m = RE_SYNC_END.search(text)
|
||||
if m:
|
||||
result['total_orders'] = int(m.group(1))
|
||||
result['success_orders'] = int(m.group(2))
|
||||
result['error_orders'] = int(m.group(3))
|
||||
|
||||
# Format NOU: compact OK line
|
||||
m = RE_COMPACT_OK.search(text)
|
||||
if m:
|
||||
continue
|
||||
|
||||
# Format NOU: compact ERR line
|
||||
m = RE_COMPACT_ERR.search(text)
|
||||
if m:
|
||||
order_nr = m.group(3)
|
||||
err_detail = m.group(4).strip()
|
||||
err_type, sku = extract_sku_from_error(err_detail)
|
||||
if err_type and sku:
|
||||
result['failed'].append((order_nr, err_type, sku))
|
||||
if sku not in seen_skus and sku != '(SKU necunoscut)':
|
||||
seen_skus.add(sku)
|
||||
result['missing_skus'].append(sku)
|
||||
else:
|
||||
result['failed'].append((order_nr, 'ERROR', err_detail[:60]))
|
||||
continue
|
||||
|
||||
# Stopped early
|
||||
if RE_STOPPED_EARLY.search(text):
|
||||
result['stopped_early'] = True
|
||||
|
||||
# Format VECHI: statistici din sumar
|
||||
if 'Total comenzi procesate:' in text:
|
||||
try:
|
||||
result['total_orders'] = int(text.split(':')[-1].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
if 'Comenzi importate cu succes:' in text:
|
||||
try:
|
||||
result['success_orders'] = int(text.split(':')[-1].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
if 'Comenzi cu erori:' in text:
|
||||
try:
|
||||
result['error_orders'] = int(text.split(':')[-1].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Format VECHI: Duration line
|
||||
m = RE_STATS_LINE.search(text)
|
||||
if m:
|
||||
result['duration'] = m.group(1)
|
||||
|
||||
# Format VECHI: erori
|
||||
if level == 'ERROR':
|
||||
m_fail = RE_FAILED_ORDER.search(text)
|
||||
if m_fail:
|
||||
current_order = m_fail.group(1)
|
||||
|
||||
m = RE_ORDER_PROCESS.search(text)
|
||||
if m:
|
||||
current_order = m.group(1)
|
||||
|
||||
err_type, sku = extract_sku_from_error(text)
|
||||
if err_type and sku:
|
||||
order_nr = current_order or '?'
|
||||
result['failed'].append((order_nr, err_type, sku))
|
||||
if sku not in seen_skus and sku != '(SKU necunoscut)':
|
||||
seen_skus.add(sku)
|
||||
result['missing_skus'].append(sku)
|
||||
|
||||
# Duration din SYNC END
|
||||
m = re.search(r'\|\s*(\d+)s\s*$', text)
|
||||
if m:
|
||||
result['duration'] = m.group(1) + 's'
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def format_report(result, log_path):
|
||||
"""Formateaza raportul complet."""
|
||||
lines = []
|
||||
lines.append('=== SYNC LOG REPORT ===')
|
||||
lines.append(f'File: {os.path.basename(log_path)}')
|
||||
|
||||
duration = result["duration"] or "?"
|
||||
start = result["start_time"] or "?"
|
||||
end = result["end_time"] or "?"
|
||||
lines.append(f'Run: {start} - {end} ({duration})')
|
||||
lines.append('')
|
||||
|
||||
stopped = 'YES' if result['stopped_early'] else 'NO'
|
||||
lines.append(
|
||||
f'SUMMARY: {result["total_orders"]} processed, '
|
||||
f'{result["success_orders"]} success, '
|
||||
f'{result["error_orders"]} errors '
|
||||
f'(stopped early: {stopped})'
|
||||
)
|
||||
lines.append('')
|
||||
|
||||
if result['failed']:
|
||||
lines.append('FAILED ORDERS:')
|
||||
seen = set()
|
||||
for order_nr, err_type, sku in result['failed']:
|
||||
key = (order_nr, err_type, sku)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
lines.append(f' {order_nr:<12} {err_type:<18} {sku}')
|
||||
lines.append('')
|
||||
|
||||
if result['missing_skus']:
|
||||
lines.append(f'MISSING SKUs ({len(result["missing_skus"])} unique):')
|
||||
for sku in sorted(result['missing_skus']):
|
||||
lines.append(f' {sku}')
|
||||
lines.append('')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Parser pentru log-urile sync_comenzi_web'
|
||||
)
|
||||
parser.add_argument(
|
||||
'logfile', nargs='?', default=None,
|
||||
help='Fisier log specific (default: ultimul din vfp/log/)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--skus', action='store_true',
|
||||
help='Afiseaza doar lista SKU-uri lipsa (una pe linie)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dir', default=None,
|
||||
help='Director cu log-uri (default: vfp/log/ relativ la script)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.logfile:
|
||||
log_path = args.logfile
|
||||
else:
|
||||
if args.dir:
|
||||
log_dir = args.dir
|
||||
else:
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_dir = os.path.dirname(script_dir)
|
||||
log_dir = os.path.join(project_dir, 'vfp', 'log')
|
||||
|
||||
log_path = find_latest_log(log_dir)
|
||||
if not log_path:
|
||||
print(f'Nu am gasit fisiere sync_comenzi_*.log in {log_dir}',
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.isfile(log_path):
|
||||
print(f'Fisierul nu exista: {log_path}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
entries = parse_log_entries(lines)
|
||||
result = analyze_entries(entries)
|
||||
|
||||
if args.skus:
|
||||
for sku in sorted(result['missing_skus']):
|
||||
print(sku)
|
||||
else:
|
||||
print(format_report(result, log_path))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
30
start.sh
Normal file → Executable file
30
start.sh
Normal file → Executable file
@@ -19,16 +19,36 @@ if [ api/requirements.txt -nt venv/.deps_installed ] || [ ! -f venv/.deps_instal
|
||||
fi
|
||||
|
||||
# Stop any existing instance on port 5003
|
||||
EXISTING_PID=$(lsof -ti tcp:5003 2>/dev/null)
|
||||
if [ -n "$EXISTING_PID" ]; then
|
||||
echo "Stopping existing process on port 5003 (PID $EXISTING_PID)..."
|
||||
kill "$EXISTING_PID"
|
||||
EXISTING_PIDS=$(lsof -ti tcp:5003 2>/dev/null)
|
||||
if [ -n "$EXISTING_PIDS" ]; then
|
||||
echo "Stopping existing process(es) on port 5003 (PID $EXISTING_PIDS)..."
|
||||
echo "$EXISTING_PIDS" | xargs kill 2>/dev/null
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Oracle config
|
||||
export TNS_ADMIN="$(pwd)/api"
|
||||
export LD_LIBRARY_PATH=/opt/oracle/instantclient_21_15:$LD_LIBRARY_PATH
|
||||
|
||||
# Detect Oracle Instant Client path from .env or use default
|
||||
INSTANTCLIENT_PATH=""
|
||||
if [ -f "api/.env" ]; then
|
||||
INSTANTCLIENT_PATH=$(grep -E "^INSTANTCLIENTPATH=" api/.env | cut -d'=' -f2- | tr -d ' ')
|
||||
fi
|
||||
# Fallback to default path if not set in .env
|
||||
if [ -z "$INSTANTCLIENT_PATH" ]; then
|
||||
INSTANTCLIENT_PATH="/opt/oracle/instantclient_21_15"
|
||||
fi
|
||||
|
||||
if [ -d "$INSTANTCLIENT_PATH" ]; then
|
||||
echo "Oracle Instant Client found: $INSTANTCLIENT_PATH (thick mode)"
|
||||
export LD_LIBRARY_PATH="$INSTANTCLIENT_PATH:$LD_LIBRARY_PATH"
|
||||
else
|
||||
echo "WARN: Oracle Instant Client NOT found la: $INSTANTCLIENT_PATH"
|
||||
echo " Se va folosi thin mode (Oracle 12.1+ necesar)."
|
||||
echo " Pentru thick mode: instaleaza instantclient sau seteaza INSTANTCLIENTPATH in api/.env"
|
||||
# Force thin mode so app doesn't try to load missing libraries
|
||||
export FORCE_THIN_MODE=true
|
||||
fi
|
||||
|
||||
cd api
|
||||
echo "Starting GoMag Import Manager on http://0.0.0.0:5003"
|
||||
|
||||
79
update.ps1
Normal file
79
update.ps1
Normal file
@@ -0,0 +1,79 @@
|
||||
# GoMag Vending - Update Script
|
||||
# Ruleaza interactiv: .\update.ps1
|
||||
# Ruleaza din scheduler: .\update.ps1 -Silent
|
||||
|
||||
param(
|
||||
[switch]$Silent
|
||||
)
|
||||
|
||||
$RepoPath = "C:\gomag-vending"
|
||||
$TokenFile = Join-Path $RepoPath ".gittoken"
|
||||
$LogFile = Join-Path $RepoPath "update.log"
|
||||
|
||||
function Log($msg, $color = "White") {
|
||||
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
if ($Silent) {
|
||||
Add-Content -Path $LogFile -Value "$ts $msg"
|
||||
} else {
|
||||
Write-Host $msg -ForegroundColor $color
|
||||
}
|
||||
}
|
||||
|
||||
# Citire token
|
||||
if (-not (Test-Path $TokenFile)) {
|
||||
Log "EROARE: $TokenFile nu exista!" "Red"
|
||||
exit 1
|
||||
}
|
||||
$token = (Get-Content $TokenFile -Raw).Trim()
|
||||
|
||||
# Safe directory (necesar cand ruleaza ca SYSTEM)
|
||||
git config --global --add safe.directory $RepoPath 2>$null
|
||||
|
||||
# Fetch remote
|
||||
Set-Location $RepoPath
|
||||
$fetchUrl = "https://gomag-vending:$token@gitea.romfast.ro/romfast/gomag-vending.git"
|
||||
$env:GIT_TERMINAL_PROMPT = "0"
|
||||
$fetchOutput = & git -c credential.helper="" fetch $fetchUrl main 2>&1
|
||||
$fetchExit = $LASTEXITCODE
|
||||
if ($fetchExit -ne 0) {
|
||||
Log "EROARE: git fetch esuat (exit=$fetchExit): $fetchOutput" "Red"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Compara local vs remote
|
||||
$local = git rev-parse HEAD
|
||||
$remote = git rev-parse FETCH_HEAD
|
||||
|
||||
if ($local -eq $remote) {
|
||||
Log "Nicio actualizare disponibila." "Gray"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Exista update-uri
|
||||
$commits = git log --oneline "$local..$remote"
|
||||
Log "==> Update disponibil ($($commits.Count) commit-uri noi)" "Cyan"
|
||||
if (-not $Silent) {
|
||||
$commits | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
||||
}
|
||||
|
||||
# Git pull
|
||||
Log "==> Git pull..." "Cyan"
|
||||
$pullOutput = & git -c credential.helper="" pull $fetchUrl 2>&1
|
||||
$pullExit = $LASTEXITCODE
|
||||
if ($pullExit -ne 0) {
|
||||
Log "EROARE: git pull esuat (exit=$pullExit): $pullOutput" "Red"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Pip install (daca s-au schimbat dependintele)
|
||||
Log "==> Verificare dependinte..." "Cyan"
|
||||
& "$RepoPath\venv\Scripts\pip.exe" install -r "$RepoPath\api\requirements.txt" --quiet 2>&1 | Out-Null
|
||||
|
||||
# Restart serviciu
|
||||
Log "==> Restart GoMagVending..." "Cyan"
|
||||
nssm restart GoMagVending 2>&1 | Out-Null
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
$status = (nssm status GoMagVending 2>&1) -replace '\0',''
|
||||
Log "Serviciu: $status" "Green"
|
||||
Log "Update complet!" "Green"
|
||||
@@ -1,320 +0,0 @@
|
||||
*-- ApplicationSetup.prg - Clasa pentru configurarea si setup-ul aplicatiei
|
||||
*-- Contine toate functiile pentru settings.ini si configurare
|
||||
*-- Autor: Claude AI
|
||||
*-- Data: 10 septembrie 2025
|
||||
|
||||
DEFINE CLASS ApplicationSetup AS Custom
|
||||
|
||||
*-- Proprietati publice
|
||||
cAppPath = ""
|
||||
cIniFile = ""
|
||||
oSettings = NULL
|
||||
lInitialized = .F.
|
||||
|
||||
*-- Constructor
|
||||
PROCEDURE Init
|
||||
PARAMETERS tcAppPath
|
||||
IF !EMPTY(tcAppPath)
|
||||
THIS.cAppPath = ADDBS(tcAppPath)
|
||||
ELSE
|
||||
THIS.cAppPath = ADDBS(JUSTPATH(SYS(16,0)))
|
||||
ENDIF
|
||||
|
||||
THIS.cIniFile = THIS.cAppPath + "settings.ini"
|
||||
THIS.lInitialized = .F.
|
||||
ENDPROC
|
||||
|
||||
*-- Functie pentru incarcarea tuturor setarilor din fisierul INI
|
||||
PROCEDURE LoadSettings
|
||||
PARAMETERS tcIniFile
|
||||
LOCAL loSettings
|
||||
|
||||
IF EMPTY(tcIniFile)
|
||||
tcIniFile = THIS.cIniFile
|
||||
ENDIF
|
||||
|
||||
*-- Cream un obiect pentru toate setarile
|
||||
loSettings = CREATEOBJECT("Empty")
|
||||
|
||||
*-- Sectiunea API
|
||||
ADDPROPERTY(loSettings, "ApiBaseUrl", ReadPini("API", "ApiBaseUrl", tcIniFile))
|
||||
ADDPROPERTY(loSettings, "OrderApiUrl", ReadPini("API", "OrderApiUrl", tcIniFile))
|
||||
ADDPROPERTY(loSettings, "ApiKey", ReadPini("API", "ApiKey", tcIniFile))
|
||||
ADDPROPERTY(loSettings, "ApiShop", ReadPini("API", "ApiShop", tcIniFile))
|
||||
ADDPROPERTY(loSettings, "UserAgent", ReadPini("API", "UserAgent", tcIniFile))
|
||||
ADDPROPERTY(loSettings, "ContentType", ReadPini("API", "ContentType", tcIniFile))
|
||||
|
||||
*-- Sectiunea PAGINATION
|
||||
ADDPROPERTY(loSettings, "Limit", VAL(ReadPini("PAGINATION", "Limit", tcIniFile)))
|
||||
|
||||
*-- Sectiunea OPTIONS
|
||||
ADDPROPERTY(loSettings, "GetProducts", ReadPini("OPTIONS", "GetProducts", tcIniFile) = "1")
|
||||
ADDPROPERTY(loSettings, "GetOrders", ReadPini("OPTIONS", "GetOrders", tcIniFile) = "1")
|
||||
|
||||
*-- Sectiunea FILTERS
|
||||
ADDPROPERTY(loSettings, "OrderDaysBack", VAL(ReadPini("FILTERS", "OrderDaysBack", tcIniFile)))
|
||||
|
||||
*-- Sectiunea ORACLE - pentru conexiunea la database
|
||||
ADDPROPERTY(loSettings, "OracleUser", ReadPini("ORACLE", "OracleUser", tcIniFile))
|
||||
ADDPROPERTY(loSettings, "OraclePassword", ReadPini("ORACLE", "OraclePassword", tcIniFile))
|
||||
ADDPROPERTY(loSettings, "OracleDSN", ReadPini("ORACLE", "OracleDSN", tcIniFile))
|
||||
|
||||
*-- Sectiunea SYNC - pentru configurarea sincronizarii
|
||||
ADDPROPERTY(loSettings, "AdapterProgram", ReadPini("SYNC", "AdapterProgram", tcIniFile))
|
||||
ADDPROPERTY(loSettings, "JsonFilePattern", ReadPini("SYNC", "JsonFilePattern", tcIniFile))
|
||||
ADDPROPERTY(loSettings, "AutoRunAdapter", ReadPini("SYNC", "AutoRunAdapter", tcIniFile) = "1")
|
||||
|
||||
*-- Sectiunea ROA - pentru configurarea sistemului ROA
|
||||
LOCAL lcRoaValue
|
||||
|
||||
*-- IdPol - NULL sau valoare numerica
|
||||
lcRoaValue = UPPER(ALLTRIM(ReadPini("ROA", "IdPol", tcIniFile)))
|
||||
IF lcRoaValue = "NULL" OR EMPTY(lcRoaValue)
|
||||
ADDPROPERTY(loSettings, "IdPol", .NULL.)
|
||||
ELSE
|
||||
ADDPROPERTY(loSettings, "IdPol", VAL(lcRoaValue))
|
||||
ENDIF
|
||||
|
||||
*-- IdGestiune - NULL sau valoare numerica
|
||||
lcRoaValue = UPPER(ALLTRIM(ReadPini("ROA", "IdGestiune", tcIniFile)))
|
||||
IF lcRoaValue = "NULL" OR EMPTY(lcRoaValue)
|
||||
ADDPROPERTY(loSettings, "IdGestiune", .NULL.)
|
||||
ELSE
|
||||
ADDPROPERTY(loSettings, "IdGestiune", VAL(lcRoaValue))
|
||||
ENDIF
|
||||
|
||||
*-- IdSectie - NULL sau valoare numerica
|
||||
lcRoaValue = UPPER(ALLTRIM(ReadPini("ROA", "IdSectie", tcIniFile)))
|
||||
IF lcRoaValue = "NULL" OR EMPTY(lcRoaValue)
|
||||
ADDPROPERTY(loSettings, "IdSectie", .NULL.)
|
||||
ELSE
|
||||
ADDPROPERTY(loSettings, "IdSectie", VAL(lcRoaValue))
|
||||
ENDIF
|
||||
|
||||
*-- Salvare in proprietatea clasei
|
||||
THIS.oSettings = loSettings
|
||||
|
||||
RETURN loSettings
|
||||
ENDPROC
|
||||
|
||||
*-- Functie pentru crearea unui fisier INI implicit cu setari de baza
|
||||
PROCEDURE CreateDefaultIni
|
||||
PARAMETERS tcIniFile
|
||||
LOCAL llSuccess
|
||||
|
||||
IF EMPTY(tcIniFile)
|
||||
tcIniFile = THIS.cIniFile
|
||||
ENDIF
|
||||
|
||||
llSuccess = .T.
|
||||
|
||||
TRY
|
||||
*-- Sectiunea API
|
||||
WritePini("API", "ApiBaseUrl", "https://api.gomag.ro/api/v1/product/read/json?enabled=1", tcIniFile)
|
||||
WritePini("API", "OrderApiUrl", "https://api.gomag.ro/api/v1/order/read/json", tcIniFile)
|
||||
WritePini("API", "ApiKey", "YOUR_API_KEY_HERE", tcIniFile)
|
||||
WritePini("API", "ApiShop", "https://yourstore.gomag.ro", tcIniFile)
|
||||
WritePini("API", "UserAgent", "Mozilla/5.0", tcIniFile)
|
||||
WritePini("API", "ContentType", "application/json", tcIniFile)
|
||||
|
||||
*-- Sectiunea PAGINATION
|
||||
WritePini("PAGINATION", "Limit", "100", tcIniFile)
|
||||
|
||||
*-- Sectiunea OPTIONS
|
||||
WritePini("OPTIONS", "GetProducts", "1", tcIniFile)
|
||||
WritePini("OPTIONS", "GetOrders", "1", tcIniFile)
|
||||
|
||||
*-- Sectiunea FILTERS
|
||||
WritePini("FILTERS", "OrderDaysBack", "7", tcIniFile)
|
||||
|
||||
*-- Sectiunea ORACLE - conexiune database
|
||||
WritePini("ORACLE", "OracleUser", "MARIUSM_AUTO", tcIniFile)
|
||||
WritePini("ORACLE", "OraclePassword", "ROMFASTSOFT", tcIniFile)
|
||||
WritePini("ORACLE", "OracleDSN", "ROA_CENTRAL", tcIniFile)
|
||||
|
||||
*-- Sectiunea SYNC - configurare sincronizare
|
||||
WritePini("SYNC", "AdapterProgram", "gomag-adapter.prg", tcIniFile)
|
||||
WritePini("SYNC", "JsonFilePattern", "gomag_orders*.json", tcIniFile)
|
||||
WritePini("SYNC", "AutoRunAdapter", "1", tcIniFile)
|
||||
|
||||
*-- Sectiunea ROA - configurare sistem ROA
|
||||
WritePini("ROA", "IdPol", "NULL", tcIniFile)
|
||||
WritePini("ROA", "IdGestiune", "NULL", tcIniFile)
|
||||
WritePini("ROA", "IdSectie", "NULL", tcIniFile)
|
||||
|
||||
CATCH
|
||||
llSuccess = .F.
|
||||
ENDTRY
|
||||
|
||||
RETURN llSuccess
|
||||
ENDPROC
|
||||
|
||||
*-- Functie pentru validarea setarilor obligatorii
|
||||
PROCEDURE ValidateSettings
|
||||
LPARAMETERS toSettings
|
||||
LOCAL llValid, lcErrors
|
||||
|
||||
IF PCOUNT() = 0
|
||||
toSettings = THIS.oSettings
|
||||
ENDIF
|
||||
|
||||
IF TYPE('toSettings') <> 'O' OR ISNULL(toSettings)
|
||||
RETURN .F.
|
||||
ENDIF
|
||||
|
||||
llValid = .T.
|
||||
lcErrors = ""
|
||||
|
||||
*-- Verificare setari API obligatorii
|
||||
IF EMPTY(toSettings.ApiKey) OR toSettings.ApiKey = "YOUR_API_KEY_HERE"
|
||||
llValid = .F.
|
||||
lcErrors = lcErrors + "ApiKey nu este setat corect in settings.ini" + CHR(13) + CHR(10)
|
||||
ENDIF
|
||||
|
||||
IF EMPTY(toSettings.ApiShop) OR "yourstore.gomag.ro" $ toSettings.ApiShop
|
||||
llValid = .F.
|
||||
lcErrors = lcErrors + "ApiShop nu este setat corect in settings.ini" + CHR(13) + CHR(10)
|
||||
ENDIF
|
||||
|
||||
*-- Verificare setari Oracle obligatorii (doar pentru sync)
|
||||
IF TYPE('toSettings.OracleUser') = 'C' AND EMPTY(toSettings.OracleUser)
|
||||
llValid = .F.
|
||||
lcErrors = lcErrors + "OracleUser nu este setat in settings.ini" + CHR(13) + CHR(10)
|
||||
ENDIF
|
||||
|
||||
IF TYPE('toSettings.OracleDSN') = 'C' AND EMPTY(toSettings.OracleDSN)
|
||||
llValid = .F.
|
||||
lcErrors = lcErrors + "OracleDSN nu este setat in settings.ini" + CHR(13) + CHR(10)
|
||||
ENDIF
|
||||
|
||||
*-- Log erorile daca exista
|
||||
IF !llValid AND TYPE('gcLogFile') = 'C'
|
||||
LogMessage("Erori validare settings.ini:", "ERROR", gcLogFile)
|
||||
LogMessage(lcErrors, "ERROR", gcLogFile)
|
||||
ENDIF
|
||||
|
||||
RETURN llValid
|
||||
ENDPROC
|
||||
|
||||
*-- Functie pentru configurarea initiala a aplicatiei
|
||||
PROCEDURE Setup
|
||||
LOCAL llSetupOk
|
||||
|
||||
llSetupOk = .T.
|
||||
|
||||
*-- Verificare existenta settings.ini
|
||||
IF !CheckIniFile(THIS.cIniFile)
|
||||
IF TYPE('gcLogFile') = 'C'
|
||||
LogMessage("ATENTIE: Fisierul settings.ini nu a fost gasit!", "WARN", gcLogFile)
|
||||
LogMessage("Cream un fisier settings.ini implicit...", "INFO", gcLogFile)
|
||||
ENDIF
|
||||
|
||||
IF THIS.CreateDefaultIni()
|
||||
IF TYPE('gcLogFile') = 'C'
|
||||
LogMessage("Fisier settings.ini creat cu succes.", "INFO", gcLogFile)
|
||||
LogMessage("IMPORTANT: Modifica setarile din settings.ini (ApiKey, ApiShop) inainte de a rula scriptul din nou!", "INFO", gcLogFile)
|
||||
ENDIF
|
||||
llSetupOk = .F. && Opreste executia pentru a permite configurarea
|
||||
ELSE
|
||||
IF TYPE('gcLogFile') = 'C'
|
||||
LogMessage("EROARE: Nu s-a putut crea fisierul settings.ini!", "ERROR", gcLogFile)
|
||||
ENDIF
|
||||
llSetupOk = .F.
|
||||
ENDIF
|
||||
ENDIF
|
||||
|
||||
*-- Incarca setarile daca setup-ul este OK
|
||||
IF llSetupOk
|
||||
THIS.LoadSettings()
|
||||
THIS.lInitialized = .T.
|
||||
ENDIF
|
||||
|
||||
RETURN llSetupOk
|
||||
ENDPROC
|
||||
|
||||
*-- Functie pentru afisarea informatiilor despre configuratie
|
||||
PROCEDURE DisplaySettingsInfo
|
||||
LPARAMETERS toSettings
|
||||
LOCAL lcInfo
|
||||
|
||||
IF PCOUNT() = 0
|
||||
toSettings = THIS.oSettings
|
||||
ENDIF
|
||||
|
||||
IF TYPE('toSettings') <> 'O' OR ISNULL(toSettings)
|
||||
RETURN .F.
|
||||
ENDIF
|
||||
|
||||
IF TYPE('gcLogFile') != 'C'
|
||||
RETURN .F.
|
||||
ENDIF
|
||||
|
||||
lcInfo = "=== CONFIGURATIE APLICATIE ==="
|
||||
LogMessage(lcInfo, "INFO", gcLogFile)
|
||||
|
||||
*-- API Settings
|
||||
LogMessage("API: " + toSettings.ApiShop, "INFO", gcLogFile)
|
||||
LogMessage("Orders Days Back: " + TRANSFORM(toSettings.OrderDaysBack), "INFO", gcLogFile)
|
||||
LogMessage("Get Products: " + IIF(toSettings.GetProducts, "DA", "NU"), "INFO", gcLogFile)
|
||||
LogMessage("Get Orders: " + IIF(toSettings.GetOrders, "DA", "NU"), "INFO", gcLogFile)
|
||||
|
||||
*-- Oracle Settings (doar daca exista)
|
||||
IF TYPE('toSettings.OracleUser') = 'C' AND !EMPTY(toSettings.OracleUser)
|
||||
LogMessage("Oracle User: " + toSettings.OracleUser, "INFO", gcLogFile)
|
||||
LogMessage("Oracle DSN: " + toSettings.OracleDSN, "INFO", gcLogFile)
|
||||
ENDIF
|
||||
|
||||
*-- Sync Settings (doar daca exista)
|
||||
IF TYPE('toSettings.AdapterProgram') = 'C' AND !EMPTY(toSettings.AdapterProgram)
|
||||
LogMessage("Adapter Program: " + toSettings.AdapterProgram, "INFO", gcLogFile)
|
||||
LogMessage("JSON Pattern: " + toSettings.JsonFilePattern, "INFO", gcLogFile)
|
||||
LogMessage("Auto Run Adapter: " + IIF(toSettings.AutoRunAdapter, "DA", "NU"), "INFO", gcLogFile)
|
||||
ENDIF
|
||||
|
||||
LogMessage("=== SFARSIT CONFIGURATIE ===", "INFO", gcLogFile)
|
||||
|
||||
RETURN .T.
|
||||
ENDPROC
|
||||
|
||||
*-- Metoda pentru setup complet cu validare
|
||||
PROCEDURE Initialize
|
||||
LOCAL llSuccess
|
||||
|
||||
llSuccess = THIS.Setup()
|
||||
|
||||
IF llSuccess
|
||||
llSuccess = THIS.ValidateSettings()
|
||||
|
||||
IF llSuccess
|
||||
THIS.DisplaySettingsInfo()
|
||||
ENDIF
|
||||
ENDIF
|
||||
|
||||
RETURN llSuccess
|
||||
ENDPROC
|
||||
|
||||
*-- Functie pentru obtinerea setarilor
|
||||
PROCEDURE GetSettings
|
||||
RETURN THIS.oSettings
|
||||
ENDPROC
|
||||
|
||||
*-- Functie pentru obtinerea path-ului aplicatiei
|
||||
PROCEDURE GetAppPath
|
||||
RETURN THIS.cAppPath
|
||||
ENDPROC
|
||||
|
||||
*-- Functie pentru obtinerea path-ului fisierului INI
|
||||
PROCEDURE GetIniFile
|
||||
RETURN THIS.cIniFile
|
||||
ENDPROC
|
||||
|
||||
ENDDEFINE
|
||||
|
||||
*-- ApplicationSetup Class - Clasa pentru configurarea si setup-ul aplicatiei
|
||||
*-- Caracteristici:
|
||||
*-- - Gestionare completa a settings.ini cu toate sectiunile
|
||||
*-- - Creare fisier implicit cu valori default
|
||||
*-- - Validare setari obligatorii pentru functionare
|
||||
*-- - Setup si initializare completa cu o singura metoda
|
||||
*-- - Afisarea informatiilor despre configuratia curenta
|
||||
*-- - Proprietati pentru acces facil la configuratii si paths
|
||||
@@ -1,425 +0,0 @@
|
||||
*-- Script Visual FoxPro 9 pentru accesul la GoMag API cu paginare completa
|
||||
*-- Autor: Claude AI
|
||||
*-- Data: 26.08.2025
|
||||
|
||||
SET SAFETY OFF
|
||||
SET CENTURY ON
|
||||
SET DATE DMY
|
||||
SET EXACT ON
|
||||
SET ANSI ON
|
||||
SET DELETED ON
|
||||
|
||||
*-- Setari principale
|
||||
LOCAL lcApiBaseUrl, lcApiUrl, lcApiKey, lcUserAgent, lcContentType
|
||||
LOCAL loHttp, lcResponse, lcJsonResponse
|
||||
LOCAL laHeaders[10], lnHeaderCount
|
||||
Local lcApiShop, lcCsvFileName, lcErrorResponse, lcFileName, lcLogContent, lcLogFileName, lcPath
|
||||
Local lcStatusText, lnStatusCode, loError
|
||||
Local lnLimit, lnCurrentPage, llHasMorePages, loAllJsonData, lnTotalPages, lnTotalProducts
|
||||
Local lcOrderApiUrl, loAllOrderData, lcOrderCsvFileName, lcOrderJsonFileName
|
||||
Local ldStartDate, lcStartDateStr
|
||||
Local lcIniFile
|
||||
LOCAL llGetProducts, llGetOrders
|
||||
PRIVATE loJsonData, gcLogFile, gnStartTime, gnProductsProcessed, gnOrdersProcessed
|
||||
|
||||
|
||||
*-- Initializare logging si statistici
|
||||
gnStartTime = SECONDS()
|
||||
gnProductsProcessed = 0
|
||||
gnOrdersProcessed = 0
|
||||
gcLogFile = InitLog("gomag_sync")
|
||||
|
||||
*-- Cream directorul output daca nu existe
|
||||
LOCAL lcOutputDir
|
||||
lcOutputDir = gcAppPath + "output"
|
||||
IF !DIRECTORY(lcOutputDir)
|
||||
MKDIR (lcOutputDir)
|
||||
ENDIF
|
||||
|
||||
*-- Creare si initializare clasa setup aplicatie
|
||||
LOCAL loAppSetup
|
||||
loAppSetup = CREATEOBJECT("ApplicationSetup", gcAppPath)
|
||||
|
||||
*-- Setup complet cu validare
|
||||
IF !loAppSetup.Initialize()
|
||||
LogMessage("EROARE: Setup-ul aplicatiei a esuat sau necesita configurare!", "ERROR", gcLogFile)
|
||||
RETURN .F.
|
||||
ENDIF
|
||||
|
||||
*-- Configurare API din settings.ini
|
||||
lcApiBaseUrl = goSettings.ApiBaseUrl
|
||||
lcOrderApiUrl = goSettings.OrderApiUrl
|
||||
lcApiKey = goSettings.ApiKey
|
||||
lcApiShop = goSettings.ApiShop
|
||||
lcUserAgent = goSettings.UserAgent
|
||||
lcContentType = goSettings.ContentType
|
||||
lnLimit = goSettings.Limit
|
||||
llGetProducts = goSettings.GetProducts
|
||||
llGetOrders = goSettings.GetOrders
|
||||
lnCurrentPage = 1 && Pagina de start
|
||||
llHasMorePages = .T. && Flag pentru paginare
|
||||
loAllJsonData = NULL && Obiect pentru toate datele
|
||||
|
||||
*-- Calculare data pentru ultimele X zile (din settings.ini)
|
||||
ldStartDate = DATE() - goSettings.OrderDaysBack
|
||||
lcStartDateStr = TRANSFORM(YEAR(ldStartDate)) + "-" + ;
|
||||
RIGHT("0" + TRANSFORM(MONTH(ldStartDate)), 2) + "-" + ;
|
||||
RIGHT("0" + TRANSFORM(DAY(ldStartDate)), 2)
|
||||
|
||||
*******************************************
|
||||
*-- Sterg fisiere JSON comenzi anterioare
|
||||
lcDirJson = gcAppPath + "output\"
|
||||
lcJsonPattern = m.lcDirJson + goSettings.JsonFilePattern
|
||||
lnJsonFiles = ADIR(laJsonFiles, lcJsonPattern)
|
||||
FOR lnFile = 1 TO m.lnJsonFiles
|
||||
lcFile = m.lcDirJson + laJsonFiles[m.lnFile,1]
|
||||
IF FILE(m.lcFile)
|
||||
DELETE FILE (m.lcFile)
|
||||
ENDIF
|
||||
ENDFOR
|
||||
*******************************************
|
||||
|
||||
*-- Verificare daca avem WinHttp disponibil
|
||||
TRY
|
||||
loHttp = CREATEOBJECT("WinHttp.WinHttpRequest.5.1")
|
||||
CATCH TO loError
|
||||
LogMessage("Eroare la crearea obiectului WinHttp: " + loError.Message, "ERROR", gcLogFile)
|
||||
RETURN .F.
|
||||
ENDTRY
|
||||
*-- SECTIUNEA PRODUSE - se executa doar daca llGetProducts = .T.
|
||||
IF llGetProducts
|
||||
LogMessage("[PRODUCTS] Starting product retrieval", "INFO", gcLogFile)
|
||||
|
||||
*-- Bucla pentru preluarea tuturor produselor (paginare)
|
||||
loAllJsonData = CREATEOBJECT("Empty")
|
||||
ADDPROPERTY(loAllJsonData, "products", CREATEOBJECT("Empty"))
|
||||
ADDPROPERTY(loAllJsonData, "total", 0)
|
||||
ADDPROPERTY(loAllJsonData, "pages", 0)
|
||||
lnTotalProducts = 0
|
||||
|
||||
DO WHILE llHasMorePages
|
||||
*-- Construire URL cu paginare
|
||||
lcApiUrl = lcApiBaseUrl + "&page=" + TRANSFORM(lnCurrentPage) + "&limit=" + TRANSFORM(lnLimit)
|
||||
|
||||
LogMessage("[PRODUCTS] Page " + TRANSFORM(lnCurrentPage) + " fetching...", "INFO", gcLogFile)
|
||||
|
||||
*-- Configurare request
|
||||
TRY
|
||||
*-- Initializare request GET
|
||||
loHttp.Open("GET", lcApiUrl, .F.)
|
||||
|
||||
*-- Setare headers conform documentatiei GoMag
|
||||
loHttp.SetRequestHeader("User-Agent", lcUserAgent)
|
||||
loHttp.SetRequestHeader("Content-Type", lcContentType)
|
||||
loHttp.SetRequestHeader("Accept", "application/json")
|
||||
loHttp.SetRequestHeader("Apikey", lcApiKey) && Header pentru API Key
|
||||
loHttp.SetRequestHeader("ApiShop", lcApiShop) && Header pentru shop URL
|
||||
|
||||
*-- Setari timeout
|
||||
loHttp.SetTimeouts(30000, 30000, 30000, 30000) && 30 secunde pentru fiecare
|
||||
|
||||
*-- Trimitere request
|
||||
loHttp.Send()
|
||||
|
||||
*-- Verificare status code
|
||||
lnStatusCode = loHttp.Status
|
||||
lcStatusText = loHttp.StatusText
|
||||
|
||||
IF lnStatusCode = 200
|
||||
*-- Success - preluare raspuns
|
||||
lcResponse = loHttp.ResponseText
|
||||
|
||||
*-- Parsare JSON cu nfjson
|
||||
SET PATH TO nfjson ADDITIVE
|
||||
loJsonData = nfJsonRead(lcResponse)
|
||||
|
||||
IF !ISNULL(loJsonData)
|
||||
*-- Prima pagina - setam informatiile generale
|
||||
IF lnCurrentPage = 1
|
||||
LogMessage("[PRODUCTS] Analyzing JSON structure...", "INFO", gcLogFile)
|
||||
LOCAL ARRAY laJsonProps[1]
|
||||
lnPropCount = AMEMBERS(laJsonProps, loJsonData, 0)
|
||||
FOR lnDebugIndex = 1 TO MIN(lnPropCount, 10) && Primele 10 proprietati
|
||||
lcPropName = laJsonProps(lnDebugIndex)
|
||||
lcPropType = TYPE('loJsonData.' + lcPropName)
|
||||
LogMessage("[PRODUCTS] Property: " + lcPropName + " (Type: " + lcPropType + ")", "DEBUG", gcLogFile)
|
||||
ENDFOR
|
||||
|
||||
IF TYPE('loJsonData.total') = 'C' OR TYPE('loJsonData.total') = 'N'
|
||||
loAllJsonData.total = VAL(TRANSFORM(loJsonData.total))
|
||||
ENDIF
|
||||
IF TYPE('loJsonData.pages') = 'C' OR TYPE('loJsonData.pages') = 'N'
|
||||
loAllJsonData.pages = VAL(TRANSFORM(loJsonData.pages))
|
||||
ENDIF
|
||||
LogMessage("[PRODUCTS] Total items: " + TRANSFORM(loAllJsonData.total) + " | Pages: " + TRANSFORM(loAllJsonData.pages), "INFO", gcLogFile)
|
||||
ENDIF
|
||||
|
||||
*-- Adaugare produse din pagina curenta
|
||||
LOCAL llHasProducts, lnProductsFound
|
||||
llHasProducts = .F.
|
||||
lnProductsFound = 0
|
||||
|
||||
IF TYPE('loJsonData.products') = 'O'
|
||||
*-- Numaram produsele din obiectul products
|
||||
lnProductsFound = AMEMBERS(laProductsPage, loJsonData.products, 0)
|
||||
IF lnProductsFound > 0
|
||||
DO MergeProducts WITH loAllJsonData, loJsonData
|
||||
llHasProducts = .T.
|
||||
LogMessage("[PRODUCTS] Found: " + TRANSFORM(lnProductsFound) + " products in page " + TRANSFORM(lnCurrentPage), "INFO", gcLogFile)
|
||||
gnProductsProcessed = gnProductsProcessed + lnProductsFound
|
||||
ENDIF
|
||||
ENDIF
|
||||
|
||||
IF !llHasProducts
|
||||
LogMessage("[PRODUCTS] WARNING: No products found in JSON response for page " + TRANSFORM(lnCurrentPage), "WARN", gcLogFile)
|
||||
ENDIF
|
||||
|
||||
*-- Verificare daca mai sunt pagini
|
||||
IF TYPE('loJsonData.pages') = 'C' OR TYPE('loJsonData.pages') = 'N'
|
||||
lnTotalPages = VAL(TRANSFORM(loJsonData.pages))
|
||||
IF lnCurrentPage >= lnTotalPages
|
||||
llHasMorePages = .F.
|
||||
ENDIF
|
||||
ELSE
|
||||
*-- Daca nu avem info despre pagini, verificam daca sunt produse
|
||||
IF TYPE('loJsonData.products') != 'O'
|
||||
llHasMorePages = .F.
|
||||
ENDIF
|
||||
ENDIF
|
||||
|
||||
lnCurrentPage = lnCurrentPage + 1
|
||||
|
||||
ELSE
|
||||
*-- Salvare raspuns JSON raw in caz de eroare de parsare
|
||||
lcFileName = "gomag_error_page" + TRANSFORM(lnCurrentPage) + "_" + DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "") + ".json"
|
||||
STRTOFILE(lcResponse, lcFileName)
|
||||
llHasMorePages = .F.
|
||||
ENDIF
|
||||
|
||||
ELSE
|
||||
*-- Eroare HTTP - salvare in fisier de log
|
||||
lcLogFileName = "gomag_error_page" + TRANSFORM(lnCurrentPage) + "_" + DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "") + ".log"
|
||||
lcLogContent = "HTTP Error " + TRANSFORM(lnStatusCode) + ": " + lcStatusText + CHR(13) + CHR(10)
|
||||
|
||||
*-- Incearca sa citesti raspunsul pentru detalii despre eroare
|
||||
TRY
|
||||
lcErrorResponse = loHttp.ResponseText
|
||||
IF !EMPTY(lcErrorResponse)
|
||||
lcLogContent = lcLogContent + "Error Details:" + CHR(13) + CHR(10) + lcErrorResponse
|
||||
ENDIF
|
||||
CATCH
|
||||
lcLogContent = lcLogContent + "Could not read error details"
|
||||
ENDTRY
|
||||
|
||||
STRTOFILE(lcLogContent, lcLogFileName)
|
||||
llHasMorePages = .F.
|
||||
ENDIF
|
||||
|
||||
CATCH TO loError
|
||||
*-- Salvare erori in fisier de log pentru pagina curenta
|
||||
lcLogFileName = "gomag_error_page" + TRANSFORM(lnCurrentPage) + "_" + DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "") + ".log"
|
||||
lcLogContent = "Script Error on page " + TRANSFORM(lnCurrentPage) + ":" + CHR(13) + CHR(10) +;
|
||||
"Error Number: " + TRANSFORM(loError.ErrorNo) + CHR(13) + CHR(10) +;
|
||||
"Error Message: " + loError.Message + CHR(13) + CHR(10) +;
|
||||
"Error Line: " + TRANSFORM(loError.LineNo)
|
||||
STRTOFILE(lcLogContent, lcLogFileName)
|
||||
llHasMorePages = .F.
|
||||
ENDTRY
|
||||
|
||||
IF llHasMorePages
|
||||
INKEY(1) && Pauza de 10 secunde pentru a evita "Limitele API depasite"
|
||||
ENDIF
|
||||
|
||||
ENDDO
|
||||
|
||||
*-- Salvare array JSON cu toate produsele
|
||||
IF !ISNULL(loAllJsonData) AND TYPE('loAllJsonData.products') = 'O'
|
||||
lcJsonFileName = lcOutputDir + "\gomag_all_products_" + DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "") + ".json"
|
||||
DO SaveProductsArray WITH loAllJsonData, lcJsonFileName
|
||||
LogMessage("[PRODUCTS] JSON saved: " + lcJsonFileName, "INFO", gcLogFile)
|
||||
*-- Calculam numarul de produse procesate
|
||||
IF TYPE('loAllJsonData.products') = 'O'
|
||||
LOCAL ARRAY laProducts[1]
|
||||
lnPropCount = AMEMBERS(laProducts, loAllJsonData.products, 0)
|
||||
gnProductsProcessed = lnPropCount
|
||||
ENDIF
|
||||
ENDIF
|
||||
|
||||
ELSE
|
||||
LogMessage("[PRODUCTS] Skipped product retrieval (llGetProducts = .F.)", "INFO", gcLogFile)
|
||||
ENDIF
|
||||
|
||||
*-- SECTIUNEA COMENZI - se executa doar daca llGetOrders = .T.
|
||||
IF llGetOrders
|
||||
LogMessage("[ORDERS] =======================================", "INFO", gcLogFile)
|
||||
LogMessage("[ORDERS] RETRIEVING ORDERS FROM LAST " + TRANSFORM(goSettings.OrderDaysBack) + " DAYS", "INFO", gcLogFile)
|
||||
LogMessage("[ORDERS] Start date: " + lcStartDateStr, "INFO", gcLogFile)
|
||||
LogMessage("[ORDERS] =======================================", "INFO", gcLogFile)
|
||||
|
||||
*-- Reinitializare pentru comenzi
|
||||
lnCurrentPage = 1
|
||||
llHasMorePages = .T.
|
||||
loAllOrderData = CREATEOBJECT("Empty")
|
||||
ADDPROPERTY(loAllOrderData, "orders", CREATEOBJECT("Empty"))
|
||||
ADDPROPERTY(loAllOrderData, "total", 0)
|
||||
ADDPROPERTY(loAllOrderData, "pages", 0)
|
||||
|
||||
*-- Bucla pentru preluarea comenzilor
|
||||
DO WHILE llHasMorePages
|
||||
*-- Construire URL cu paginare si filtrare pe data (folosind startDate conform documentatiei GoMag)
|
||||
lcApiUrl = lcOrderApiUrl + "?startDate=" + lcStartDateStr + "&page=" + TRANSFORM(lnCurrentPage) + "&limit=" + TRANSFORM(lnLimit)
|
||||
|
||||
LogMessage("[ORDERS] Page " + TRANSFORM(lnCurrentPage) + " fetching...", "INFO", gcLogFile)
|
||||
|
||||
*-- Configurare request
|
||||
TRY
|
||||
*-- Initializare request GET
|
||||
loHttp.Open("GET", lcApiUrl, .F.)
|
||||
|
||||
*-- Setare headers conform documentatiei GoMag
|
||||
loHttp.SetRequestHeader("User-Agent", lcUserAgent)
|
||||
loHttp.SetRequestHeader("Content-Type", lcContentType)
|
||||
loHttp.SetRequestHeader("Accept", "application/json")
|
||||
loHttp.SetRequestHeader("Apikey", lcApiKey) && Header pentru API Key
|
||||
loHttp.SetRequestHeader("ApiShop", lcApiShop) && Header pentru shop URL
|
||||
|
||||
*-- Setari timeout
|
||||
loHttp.SetTimeouts(30000, 30000, 30000, 30000) && 30 secunde pentru fiecare
|
||||
|
||||
*-- Trimitere request
|
||||
loHttp.Send()
|
||||
|
||||
*-- Verificare status code
|
||||
lnStatusCode = loHttp.Status
|
||||
lcStatusText = loHttp.StatusText
|
||||
|
||||
IF lnStatusCode = 200
|
||||
*-- Success - preluare raspuns
|
||||
lcResponse = loHttp.ResponseText
|
||||
|
||||
*-- SALVARE DIRECTA: Salveaza raspunsul RAW exact cum vine din API, pe pagini
|
||||
lcOrderJsonFileName = lcOutputDir + "\gomag_orders_page" + TRANSFORM(lnCurrentPage) + "_" + DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "") + ".json"
|
||||
STRTOFILE(lcResponse, lcOrderJsonFileName)
|
||||
LogMessage("[ORDERS] JSON RAW salvat: " + lcOrderJsonFileName, "INFO", gcLogFile)
|
||||
|
||||
*-- Parsare JSON pentru obtinerea numarului de pagini
|
||||
SET PATH TO nfjson ADDITIVE
|
||||
loOrdersJsonData = nfJsonRead(lcResponse)
|
||||
|
||||
IF !ISNULL(loOrdersJsonData)
|
||||
*-- Extragere informatii paginare din JSON procesat
|
||||
IF lnCurrentPage = 1
|
||||
IF TYPE('loOrdersJsonData.total') = 'C' OR TYPE('loOrdersJsonData.total') = 'N'
|
||||
LOCAL lnTotalOrders
|
||||
lnTotalOrders = VAL(TRANSFORM(loOrdersJsonData.total))
|
||||
LogMessage("[ORDERS] Total orders: " + TRANSFORM(lnTotalOrders), "INFO", gcLogFile)
|
||||
ENDIF
|
||||
ENDIF
|
||||
|
||||
IF TYPE('loOrdersJsonData.pages') = 'C' OR TYPE('loOrdersJsonData.pages') = 'N'
|
||||
lnTotalPages = VAL(TRANSFORM(loOrdersJsonData.pages))
|
||||
IF lnCurrentPage = 1
|
||||
LogMessage("[ORDERS] Total pages: " + TRANSFORM(lnTotalPages), "INFO", gcLogFile)
|
||||
ENDIF
|
||||
|
||||
IF lnCurrentPage >= lnTotalPages
|
||||
llHasMorePages = .F.
|
||||
LogMessage("[ORDERS] Reached last page (" + TRANSFORM(lnCurrentPage) + "/" + TRANSFORM(lnTotalPages) + ")", "INFO", gcLogFile)
|
||||
ENDIF
|
||||
ELSE
|
||||
*-- Fallback: verificare daca mai sunt comenzi in pagina
|
||||
IF TYPE('loOrdersJsonData.orders') != 'O'
|
||||
llHasMorePages = .F.
|
||||
LogMessage("[ORDERS] No orders found in response, stopping pagination", "INFO", gcLogFile)
|
||||
ENDIF
|
||||
ENDIF
|
||||
|
||||
*-- Numarare comenzi din pagina curenta
|
||||
IF TYPE('loOrdersJsonData.orders') = 'O'
|
||||
LOCAL lnOrdersInPage
|
||||
lnOrdersInPage = AMEMBERS(laOrdersPage, loOrdersJsonData.orders, 0)
|
||||
gnOrdersProcessed = gnOrdersProcessed + lnOrdersInPage
|
||||
LogMessage("[ORDERS] Found " + TRANSFORM(lnOrdersInPage) + " orders in page " + TRANSFORM(lnCurrentPage), "INFO", gcLogFile)
|
||||
ENDIF
|
||||
|
||||
ELSE
|
||||
*-- Eroare la parsarea JSON
|
||||
LogMessage("[ORDERS] ERROR: Could not parse JSON response for page " + TRANSFORM(lnCurrentPage), "ERROR", gcLogFile)
|
||||
llHasMorePages = .F.
|
||||
ENDIF
|
||||
|
||||
lnCurrentPage = lnCurrentPage + 1
|
||||
|
||||
ELSE
|
||||
*-- Eroare HTTP - salvare in fisier de log
|
||||
lcLogFileName = "gomag_order_error_page" + TRANSFORM(lnCurrentPage) + "_" + DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "") + ".log"
|
||||
lcLogContent = "HTTP Error " + TRANSFORM(lnStatusCode) + ": " + lcStatusText + CHR(13) + CHR(10)
|
||||
|
||||
*-- Incearca sa citesti raspunsul pentru detalii despre eroare
|
||||
TRY
|
||||
lcErrorResponse = loHttp.ResponseText
|
||||
IF !EMPTY(lcErrorResponse)
|
||||
lcLogContent = lcLogContent + "Error Details:" + CHR(13) + CHR(10) + lcErrorResponse
|
||||
ENDIF
|
||||
CATCH
|
||||
lcLogContent = lcLogContent + "Could not read error details"
|
||||
ENDTRY
|
||||
|
||||
STRTOFILE(lcLogContent, lcLogFileName)
|
||||
llHasMorePages = .F.
|
||||
ENDIF
|
||||
|
||||
CATCH TO loError
|
||||
*-- Salvare erori in fisier de log pentru pagina curenta
|
||||
lcLogFileName = "gomag_order_error_page" + TRANSFORM(lnCurrentPage) + "_" + DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "") + ".log"
|
||||
lcLogContent = "Script Error on page " + TRANSFORM(lnCurrentPage) + ":" + CHR(13) + CHR(10) +;
|
||||
"Error Number: " + TRANSFORM(loError.ErrorNo) + CHR(13) + CHR(10) +;
|
||||
"Error Message: " + loError.Message + CHR(13) + CHR(10) +;
|
||||
"Error Line: " + TRANSFORM(loError.LineNo)
|
||||
STRTOFILE(lcLogContent, lcLogFileName)
|
||||
llHasMorePages = .F.
|
||||
ENDTRY
|
||||
|
||||
IF llHasMorePages
|
||||
INKEY(1) && Pauza de 10 secunde pentru a evita "Limitele API depasite"
|
||||
ENDIF
|
||||
|
||||
ENDDO
|
||||
|
||||
LogMessage("[ORDERS] JSON files salvate pe pagini separate in directorul output/", "INFO", gcLogFile)
|
||||
LogMessage("[ORDERS] Total orders processed: " + TRANSFORM(gnOrdersProcessed), "INFO", gcLogFile)
|
||||
|
||||
ELSE
|
||||
LogMessage("[ORDERS] Skipped order retrieval (llGetOrders = .F.)", "INFO", gcLogFile)
|
||||
ENDIF
|
||||
|
||||
*-- Curatare
|
||||
loHttp = NULL
|
||||
|
||||
*-- Inchidere logging cu statistici finale
|
||||
CloseLog(gnStartTime, gnProductsProcessed, gnOrdersProcessed, gcLogFile)
|
||||
|
||||
|
||||
|
||||
*-- Functiile utilitare au fost mutate in utils.prg
|
||||
|
||||
*-- Scriptul cu paginare completa pentru preluarea tuturor produselor si comenzilor
|
||||
*-- Caracteristici principale:
|
||||
*-- - Paginare automata pentru toate produsele si comenzile
|
||||
*-- - Pauze intre cereri pentru respectarea rate limiting
|
||||
*-- - Salvare JSON array-uri pure (fara metadata de paginare)
|
||||
*-- - Utilizare nfjsoncreate pentru generare JSON corecta
|
||||
*-- - Logging separat pentru fiecare pagina in caz de eroare
|
||||
*-- - Afisare progres in timpul executiei
|
||||
|
||||
*-- INSTRUCTIUNI DE UTILIZARE:
|
||||
*-- 1. Modifica settings.ini cu setarile tale:
|
||||
*-- - ApiKey: cheia ta API de la GoMag
|
||||
*-- - ApiShop: URL-ul magazinului tau
|
||||
*-- - GetProducts: 1 pentru a prelua produse, 0 pentru a sari peste
|
||||
*-- - GetOrders: 1 pentru a prelua comenzi, 0 pentru a sari peste
|
||||
*-- - OrderDaysBack: numarul de zile pentru preluarea comenzilor
|
||||
*-- 2. Ruleaza scriptul - va prelua doar ce ai selectat
|
||||
*-- 3. Verifica fisierele JSON generate cu array-uri pure
|
||||
|
||||
*-- Script optimizat cu salvare JSON array-uri - verificati fisierele generate
|
||||
@@ -1,7 +0,0 @@
|
||||
alter table COMENZI_ELEMENTE add ptva number(5,2);
|
||||
comment on column COMENZI_ELEMENTE.ptva is 'PROCENT TVA (11,21)';
|
||||
|
||||
-- ARTICOLE_TERTI
|
||||
-- PACK_COMENZI
|
||||
-- PACK_IMPORT_PARTENERI
|
||||
-- PACK_IMPORT_COMENZI
|
||||
@@ -1,50 +0,0 @@
|
||||
PL/SQL Developer Test script 3.0
|
||||
12
|
||||
begin
|
||||
-- Call the procedure
|
||||
PACK_IMPORT_COMENZI.importa_comanda(p_nr_comanda_ext => '479317993',
|
||||
p_data_comanda => TO_DATE('03032026','DDMMYYYY'),
|
||||
p_id_partener => 1424,
|
||||
p_json_articole => '{"baseprice":"46","ean":"5941623003366","id":"137","name":"Coffee Creamer Doncafe Lapte Praf 1 Kg","price":"40.99","quantity":"1.00","sku":"5941623003366","type":"product","vat":"21"}',
|
||||
p_id_adresa_livrare => 1213,
|
||||
p_id_adresa_facturare => 1213,
|
||||
p_id_pol => 39,
|
||||
p_id_sectie => NULL,
|
||||
v_id_comanda => :v_id_comanda);
|
||||
end;
|
||||
9
|
||||
p_nr_comanda_ext
|
||||
1
|
||||
479317993
|
||||
-5
|
||||
p_data_comanda
|
||||
1
|
||||
3/3/2026
|
||||
-12
|
||||
p_id_partener
|
||||
1
|
||||
1424
|
||||
-4
|
||||
p_json_articole
|
||||
1
|
||||
<CLOB>
|
||||
-112
|
||||
p_id_adresa_livrare
|
||||
1
|
||||
1213
|
||||
-4
|
||||
p_id_adresa_facturare
|
||||
1
|
||||
1213
|
||||
-4
|
||||
p_id_pol
|
||||
1
|
||||
39
|
||||
-4
|
||||
p_id_sectie
|
||||
0
|
||||
-4
|
||||
v_id_comanda
|
||||
0
|
||||
4
|
||||
0
|
||||
@@ -1,381 +0,0 @@
|
||||
*-------------------------------------------------------------------
|
||||
* Created by Marco Plaza @vfp2Nofox
|
||||
* ver 1.100 - 24/02/2016
|
||||
* enabled collection processing
|
||||
* ver 1.101 - 24/02/2016
|
||||
* solved indentation on nested collections
|
||||
* ver 1.110 -11/03/2016
|
||||
* -added support for collections inside arrays
|
||||
* -user can pass aMemembersFlag value
|
||||
* ( since Json is intended for DTO creation default value is 'U' )
|
||||
* check amembers topic on vfp help file for usage
|
||||
* changed cr to crlf
|
||||
* Added Json validation ; throws error for invalid Json.
|
||||
* ver 1.120
|
||||
* encode control characters ( chr(0) ~ chr(31) )
|
||||
*-----------------------------------------------------------------------
|
||||
Parameters ovfp,FormattedOutput,nonullarrayitem,crootName,aMembersFlag
|
||||
|
||||
#Define crlf Chr(13)+Chr(10)
|
||||
|
||||
Private All
|
||||
|
||||
aMembersFlag = Evl(m.aMembersFlag,'U')
|
||||
|
||||
esarray = Type('oVfp',1) = 'A'
|
||||
esobjeto = Vartype(m.ovfp) = 'O'
|
||||
|
||||
If !m.esarray And !m.esobjeto
|
||||
Error 'must supply a vfp object/array'
|
||||
Endif
|
||||
|
||||
_nivel = Iif( Cast(m.formattedOutput As l ) , 1, -1)
|
||||
|
||||
Do Case
|
||||
Case esarray
|
||||
|
||||
ojson = Createobject('empty')
|
||||
|
||||
AddProperty(ojson,'array(1)')
|
||||
Acopy(ovfp,ojson.Array)
|
||||
cjson = procobject(ojson,.F.,m.nonullarrayitem,m.aMembersFlag)
|
||||
cjson = Substr( m.cjson,At('[',m.cjson))
|
||||
|
||||
|
||||
Case Type('oVfp.BaseClass')='C' And ovfp.BaseClass = 'Collection'
|
||||
cjson = procobject(ovfp,.T.,m.nonullarrayitem,m.aMembersFlag)
|
||||
|
||||
crootName = Evl(m.crootName,'collection')
|
||||
cjson = '{"'+m.crootName+collTagName(ovfp)+'": '+cjson+'}'+Iif(FormattedOutput,crlf,'')+'}'
|
||||
|
||||
Otherwise
|
||||
cjson = '{'+procobject(ovfp,.F.,m.nonullarrayitem,m.aMembersFlag)+'}'
|
||||
|
||||
Endcase
|
||||
|
||||
|
||||
Return Ltrim(cjson)
|
||||
|
||||
*----------------------------------------
|
||||
Function collTagName(thiscoll)
|
||||
*----------------------------------------
|
||||
Return Iif( m.thiscoll.Count > 0 And !Empty( m.thiscoll.GetKey(1) ), '_kv_collection','_kl_collection' )
|
||||
|
||||
*----------------------------------------------------------------------------------
|
||||
Function procobject(obt,iscollection,nonullarrayitem,aMembersFlag)
|
||||
*----------------------------------------------------------------------------------
|
||||
|
||||
If Isnull(obt)
|
||||
Return 'null'
|
||||
Endif
|
||||
|
||||
Private All Except _nivel
|
||||
|
||||
este = ''
|
||||
|
||||
xtabs = nivel(2)
|
||||
|
||||
bc = Iif(Type('m.obt.class')='C',m.obt.Class,'?')
|
||||
|
||||
iscollection = bc = 'Collection'
|
||||
|
||||
If m.iscollection
|
||||
|
||||
|
||||
este = este+'{ '+xtabs
|
||||
xtabs = nivel(2)
|
||||
este = este+'"collectionitems": ['+xtabs
|
||||
|
||||
procCollection(obt,m.nonullarrayitem,m.aMembersFlag)
|
||||
|
||||
xtabs = nivel(-2)
|
||||
este = este+xtabs+']'
|
||||
|
||||
Else
|
||||
|
||||
Amembers(am,m.obt,0,m.aMembersFlag)
|
||||
|
||||
If Vartype(m.am) = 'U'
|
||||
xtabs=m.nivel(-2)
|
||||
Return ''
|
||||
Endif
|
||||
|
||||
|
||||
nm = Alen(am)
|
||||
|
||||
For x1 = 1 To m.nm
|
||||
|
||||
Var = Lower(am(m.x1))
|
||||
|
||||
este = m.este+Iif(m.x1>1,',','')+m.xtabs
|
||||
|
||||
este = m.este+["]+Strtran(m.var,'_vfpsafe_','')+[":]
|
||||
|
||||
esobjeto = Type('m.obt.&Var')='O'
|
||||
|
||||
If Type('m.obt.&var') = 'U'
|
||||
este = m.este+["unable to evaluate expression"]
|
||||
Loop
|
||||
Endif
|
||||
|
||||
esarray = Type('m.obt.&Var',1) = 'A'
|
||||
|
||||
Do Case
|
||||
|
||||
Case m.esarray
|
||||
|
||||
procarray(obt,m.var,m.nonullarrayitem)
|
||||
|
||||
Case m.esobjeto
|
||||
|
||||
thiso=m.obt.&Var
|
||||
|
||||
bc = Iif(Type('m.thiso.class')='C',m.thiso.Class,'?')
|
||||
|
||||
If bc = 'Collection'
|
||||
|
||||
este = Rtrim(m.este,1,'":')+ collTagName( m.thiso )+'":'
|
||||
|
||||
este = m.este+procobject(m.obt.&Var,.T.,m.nonullarrayitem,m.aMembersFlag)+[}]
|
||||
|
||||
Else
|
||||
|
||||
este = m.este+[{]+procobject(m.obt.&Var,.F.,m.nonullarrayitem,m.aMembersFlag)+[}]
|
||||
|
||||
Endif
|
||||
|
||||
Otherwise
|
||||
|
||||
|
||||
este = este+concatval(m.obt.&Var)
|
||||
|
||||
Endcase
|
||||
|
||||
Endfor
|
||||
|
||||
|
||||
Endif
|
||||
|
||||
xtabs = nivel(-2)
|
||||
este = este+m.xtabs
|
||||
|
||||
|
||||
Return m.este
|
||||
|
||||
|
||||
*----------------------------------------------------
|
||||
Procedure procarray(obt,arrayName,nonullarrayitem)
|
||||
*----------------------------------------------------
|
||||
nrows = Alen(m.obt.&arrayName,1)
|
||||
ncols = Alen(m.obt.&arrayName,2)
|
||||
bidim = m.ncols > 0
|
||||
ncols = Iif(m.ncols=0,m.nrows,m.ncols)
|
||||
titems = Alen(m.obt.&arrayName)
|
||||
|
||||
xtabs=nivel(2)
|
||||
|
||||
este = m.este+'['+m.xtabs
|
||||
nelem = 1
|
||||
|
||||
Do While nelem <= m.titems
|
||||
|
||||
este = este+Iif(m.nelem>1,','+m.xtabs,'')
|
||||
|
||||
If m.bidim
|
||||
xtabs = nivel(2)
|
||||
este = m.este+'['+m.xtabs
|
||||
Endif
|
||||
|
||||
For pn = m.nelem To m.nelem+m.ncols-1
|
||||
|
||||
elem = m.obt.&arrayName( m.pn )
|
||||
|
||||
este = m.este+Iif(m.pn>m.nelem,','+m.xtabs,'')
|
||||
|
||||
If Vartype(m.elem) # 'O'
|
||||
|
||||
If m.nelem+m.ncols-1 = 1 And Isnull(m.elem) And m.nonullarrayitem
|
||||
|
||||
este = m.este+""
|
||||
|
||||
Else
|
||||
este = m.este+concatval(m.elem)
|
||||
|
||||
Endif
|
||||
|
||||
Else
|
||||
|
||||
|
||||
bc = Iif(Type('m.elem.class')='C',m.elem.Class,'?')
|
||||
|
||||
If bc = 'Collection'
|
||||
|
||||
este = m.este+' { "collection'+ collTagName( m.elem )+'":'
|
||||
|
||||
este = m.este+procobject(m.elem ,.T.,m.nonullarrayitem,m.aMembersFlag)
|
||||
|
||||
este = este + '}'+m.xtabs+'}'
|
||||
|
||||
Else
|
||||
|
||||
este = m.este+[{]+procobject(m.elem ,.F.,m.nonullarrayitem,m.aMembersFlag)+[}]
|
||||
|
||||
Endif
|
||||
|
||||
|
||||
Endif
|
||||
|
||||
Endfor
|
||||
|
||||
nelem = m.pn
|
||||
|
||||
If m.bidim
|
||||
xtabs=nivel(-2)
|
||||
este = m.este+m.xtabs+']'
|
||||
Endif
|
||||
|
||||
Enddo
|
||||
|
||||
|
||||
xtabs=nivel(-2)
|
||||
|
||||
este = m.este+m.xtabs+']'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*-----------------------------
|
||||
Function nivel(N)
|
||||
*-----------------------------
|
||||
If m._nivel = -1
|
||||
Return ''
|
||||
Else
|
||||
_nivel= m._nivel+m.n
|
||||
Return crlf+Replicate(' ',m._nivel)
|
||||
Endif
|
||||
|
||||
*-----------------------------
|
||||
Function concatval(valor)
|
||||
*-----------------------------
|
||||
|
||||
#Define specialChars ["\/]+Chr(127)+Chr(12)+Chr(10)+Chr(13)+Chr(9)+Chr(0)+Chr(1)+Chr(2)+Chr(3)+Chr(4)+Chr(5)+Chr(6)+Chr(7)+Chr(8)+Chr(9)+Chr(10)+Chr(11)+Chr(12)+Chr(13)+Chr(14)+Chr(15)+Chr(16)+Chr(17)+Chr(18)+Chr(19)+Chr(20)+Chr(21)+Chr(22)+Chr(23)+Chr(24)+Chr(25)+Chr(26)+Chr(27)+Chr(28)+Chr(29)+Chr(30)+Chr(31)
|
||||
|
||||
If Isnull(m.valor)
|
||||
|
||||
Return 'null'
|
||||
|
||||
Else
|
||||
|
||||
|
||||
tvar = Vartype(m.valor)
|
||||
** no cambiar el orden de ejecuci<63>n!
|
||||
Do Case
|
||||
Case m.tvar $ 'FBYINQ'
|
||||
vc = Rtrim(Cast( m.valor As c(32)))
|
||||
Case m.tvar = 'L'
|
||||
vc = Iif(m.valor,'true','false')
|
||||
Case m.tvar $ 'DT'
|
||||
vc = ["]+Ttoc(m.valor,3)+["]
|
||||
Case mustEncode(m.valor)
|
||||
vc = ["]+escapeandencode(m.valor)+["]
|
||||
Case m.tvar $ 'CVM'
|
||||
vc = ["]+Rtrim(m.valor)+["]
|
||||
Case m.tvar $ 'GQW'
|
||||
vc = ["]+Strconv(m.valor,13)+["]
|
||||
Endcase
|
||||
|
||||
Return m.vc
|
||||
|
||||
Endif
|
||||
|
||||
*-----------------------------------
|
||||
Function mustEncode(valor)
|
||||
*-----------------------------------
|
||||
Return Len(Chrtran(m.valor,specialChars,'')) <> Len(m.valor)
|
||||
|
||||
*-------------------------------
|
||||
Function escapeandencode(valun)
|
||||
*-------------------------------
|
||||
valun = Strtran(m.valun,'\','\\')
|
||||
valun = Strtran(m.valun,'"','\"')
|
||||
*valun = Strtran(m.valun,'/','\/')
|
||||
|
||||
If !mustEncode(m.valun)
|
||||
Return
|
||||
Endif
|
||||
|
||||
valun = Strtran(m.valun,Chr(127),'\b')
|
||||
valun = Strtran(m.valun,Chr(12),'\f')
|
||||
valun = Strtran(m.valun,Chr(10),'\n')
|
||||
valun = Strtran(m.valun,Chr(13),'\r')
|
||||
valun = Strtran(m.valun,Chr(9),'\t')
|
||||
|
||||
If !mustEncode(m.valun)
|
||||
Return
|
||||
Endif
|
||||
|
||||
Local x
|
||||
For x = 0 To 31
|
||||
valun = Strtran(m.valun,Chr(m.x),'\u'+Right(Transform(m.x,'@0'),4))
|
||||
Endfor
|
||||
|
||||
Return Rtrim(m.valun)
|
||||
|
||||
|
||||
|
||||
*---------------------------------------------------------------
|
||||
Function procCollection(obt,nonullArrayItems,aMembersFlag )
|
||||
*---------------------------------------------------------------
|
||||
|
||||
Local iscollection
|
||||
|
||||
With obt
|
||||
|
||||
nm = .Count
|
||||
|
||||
conllave = .Count > 0 And !Empty(.GetKey(1))
|
||||
|
||||
For x1 = 1 To .Count
|
||||
|
||||
If conllave
|
||||
elem = Createobject('empty')
|
||||
AddProperty(elem,'Key', .GetKey(x1) )
|
||||
AddProperty(elem,'Value',.Item(x1))
|
||||
Else
|
||||
elem = .Item(x1)
|
||||
Endif
|
||||
|
||||
este = este+Iif(x1>1,','+xtabs,'')
|
||||
|
||||
If Vartype(elem) # 'O'
|
||||
|
||||
este = este+concatval(m.elem)
|
||||
|
||||
Else
|
||||
|
||||
If Vartype( m.elem.BaseClass ) = 'C' And m.elem.BaseClass = 'Collection'
|
||||
iscollection = .T.
|
||||
este = m.este+'{ '+m.xtabs+'"collection'+collTagName(m.elem)+'" :'
|
||||
xtabs = nivel(2)
|
||||
Else
|
||||
iscollection = .F.
|
||||
m.este = m.este+'{'
|
||||
Endif
|
||||
|
||||
este = este+procobject(m.elem, m.iscollection , m.nonullarrayitem, m.aMembersFlag )
|
||||
|
||||
este = este+'}'
|
||||
|
||||
If m.iscollection
|
||||
xtabs = nivel(-2)
|
||||
este = este+m.xtabs+'}'
|
||||
Endif
|
||||
|
||||
Endif
|
||||
|
||||
Endfor
|
||||
|
||||
este = Rtrim(m.este,1,m.xtabs)
|
||||
|
||||
Endwith
|
||||
@@ -1,775 +0,0 @@
|
||||
*-------------------------------------------------------------------
|
||||
* Created by Marco Plaza vfp2nofox@gmail.com / @vfp2Nofox
|
||||
* ver 2.000 - 26/03/2016
|
||||
* ver 2.090 - 22/07/2016 :
|
||||
* improved error management
|
||||
* nfjsonread will return .null. for invalid json
|
||||
*-------------------------------------------------------------------
|
||||
Lparameters cjsonstr,isFileName,reviveCollection
|
||||
|
||||
#Define crlf Chr(13)+Chr(10)
|
||||
|
||||
Private All
|
||||
|
||||
stackLevels=Astackinfo(aerrs)
|
||||
|
||||
If m.stackLevels > 1
|
||||
calledFrom = 'called From '+aerrs(m.stackLevels-1,4)+' line '+Transform(aerrs(m.stackLevels-1,5))
|
||||
Else
|
||||
calledFrom = ''
|
||||
Endif
|
||||
|
||||
oJson = nfJsonCreate2(cjsonstr,isFileName,reviveCollection)
|
||||
|
||||
Return Iif(Vartype(m.oJson)='O',m.oJson,.Null.)
|
||||
|
||||
|
||||
*-------------------------------------------------------------------------
|
||||
Function nfJsonCreate2(cjsonstr,isFileName,reviveCollection)
|
||||
*-------------------------------------------------------------------------
|
||||
* validate parameters:
|
||||
|
||||
Do Case
|
||||
Case ;
|
||||
Vartype(m.cjsonstr) # 'C' Or;
|
||||
Vartype(m.reviveCollection) # 'L' Or ;
|
||||
Vartype(m.isFileName) # 'L'
|
||||
|
||||
jERROR('invalid parameter type')
|
||||
|
||||
Case m.isFileName And !File(m.cjsonstr)
|
||||
|
||||
jERROR('File "'+Rtrim(Left(m.cjsonstr,255))+'" does not exist')
|
||||
|
||||
|
||||
Endcase
|
||||
|
||||
* process json:
|
||||
|
||||
If m.isFileName
|
||||
cjsonstr = Filetostr(m.cjsonstr)
|
||||
Endif
|
||||
|
||||
|
||||
cJson = Rtrim(Chrtran(m.cjsonstr,Chr(13)+Chr(9)+Chr(10),''))
|
||||
pChar = Left(Ltrim(m.cJson),1)
|
||||
|
||||
|
||||
nl = Alines(aj,m.cJson,20,'{','}','"',',',':','[',']')
|
||||
|
||||
For xx = 1 To Alen(aj)
|
||||
If Left(Ltrim(aj(m.xx)),1) $ '{}",:[]' Or Left(Ltrim(m.aj(m.xx)),4) $ 'true/false/null'
|
||||
aj(m.xx) = Ltrim(aj(m.xx))
|
||||
Endif
|
||||
Endfor
|
||||
|
||||
|
||||
Try
|
||||
|
||||
x = 1
|
||||
cError = ''
|
||||
oStack = Createobject('stack')
|
||||
|
||||
oJson = Createobject('empty')
|
||||
|
||||
Do Case
|
||||
Case aj(1)='{'
|
||||
x = 1
|
||||
oStack.pushObject()
|
||||
procstring(m.oJson)
|
||||
|
||||
Case aj(1) = '['
|
||||
x = 0
|
||||
procstring(m.oJson,.T.)
|
||||
|
||||
Otherwise
|
||||
Error 'Invalid Json: expecting [{ received '+m.pChar
|
||||
|
||||
Endcase
|
||||
|
||||
|
||||
If m.reviveCollection
|
||||
oJson = reviveCollection(m.oJson)
|
||||
Endif
|
||||
|
||||
|
||||
Catch To oerr
|
||||
|
||||
strp = ''
|
||||
|
||||
For Y = 1 To m.x
|
||||
strp = m.strp+aj(m.y)
|
||||
Endfor
|
||||
|
||||
Do Case
|
||||
Case oerr.ErrorNo = 1098
|
||||
|
||||
cError = ' Invalid Json: '+ m.oerr.Message+crlf+' Parsing: '+Right(m.strp,80)
|
||||
|
||||
*+' program line: '+Transform(oerr.Lineno)+' array item '+Transform(m.x)
|
||||
|
||||
Case oerr.ErrorNo = 2034
|
||||
|
||||
cError = ' INVALID DATE: '+crlf+' Parsing: '+Right(m.strp,80)
|
||||
|
||||
|
||||
Otherwise
|
||||
|
||||
cError = 'program error # '+Transform(m.oerr.ErrorNo)+crlf+m.oerr.Message+' at: '+Transform(oerr.Lineno)+crlf+' Parsing ('+Transform(m.x)+') '
|
||||
|
||||
Endcase
|
||||
|
||||
Endtry
|
||||
|
||||
If !Empty(m.cError)
|
||||
jERROR(m.cError)
|
||||
Endif
|
||||
|
||||
Return m.oJson
|
||||
|
||||
|
||||
|
||||
*------------------------------------------------
|
||||
Procedure jERROR( cMessage )
|
||||
*------------------------------------------------
|
||||
Error 'nfJson ('+m.calledFrom+'):'+crlf+m.cMessage
|
||||
Return To nfJsonRead
|
||||
|
||||
|
||||
|
||||
*--------------------------------------------------------------------------------
|
||||
Procedure procstring(obj,eValue)
|
||||
*--------------------------------------------------------------------------------
|
||||
#Define cvalid 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890_'
|
||||
#Define creem '_______________________________________________________________'
|
||||
|
||||
Private rowpos,colpos,bidim,ncols,arrayName,expecting,arrayLevel,vari
|
||||
Private expectingPropertyName,expectingValue,objectOpen
|
||||
|
||||
expectingPropertyName = !m.eValue
|
||||
expectingValue = m.eValue
|
||||
expecting = Iif(expectingPropertyName,'"}','')
|
||||
objectOpen = .T.
|
||||
bidim = .F.
|
||||
colpos = 0
|
||||
rowpos = 0
|
||||
arrayLevel = 0
|
||||
arrayName = ''
|
||||
vari = ''
|
||||
ncols = 0
|
||||
|
||||
Do While m.objectOpen
|
||||
|
||||
x = m.x+1
|
||||
|
||||
Do Case
|
||||
|
||||
Case m.x > m.nl
|
||||
|
||||
m.x = m.nl
|
||||
|
||||
If oStack.Count > 0
|
||||
Error 'expecting '+m.expecting
|
||||
Endif
|
||||
|
||||
Return
|
||||
|
||||
Case aj(m.x) = '}' And '}' $ m.expecting
|
||||
closeObject()
|
||||
|
||||
Case aj(x) = ']' And ']' $ m.expecting
|
||||
closeArray()
|
||||
|
||||
Case m.expecting = ':'
|
||||
If aj(m.x) = ':'
|
||||
expecting = ''
|
||||
Loop
|
||||
Else
|
||||
Error 'expecting : received '+aj(m.x)
|
||||
Endif
|
||||
|
||||
Case ',' $ m.expecting
|
||||
|
||||
Do Case
|
||||
Case aj(x) = ','
|
||||
expecting = Iif( '[' $ m.expecting , '[' , '' )
|
||||
Case Not aj(m.x) $ m.expecting
|
||||
Error 'expecting '+m.expecting+' received '+aj(m.x)
|
||||
Otherwise
|
||||
expecting = Strtran(m.expecting,',','')
|
||||
Endcase
|
||||
|
||||
|
||||
Case m.expectingPropertyName
|
||||
|
||||
If aj(m.x) = '"'
|
||||
propertyName(m.obj)
|
||||
Else
|
||||
Error 'expecting "'+m.expecting+' received '+aj(m.x)
|
||||
Endif
|
||||
|
||||
|
||||
Case m.expectingValue
|
||||
|
||||
If m.expecting == '[' And m.aj(m.x) # '['
|
||||
Error 'expecting [ received '+aj(m.x)
|
||||
Else
|
||||
procValue(m.obj)
|
||||
Endif
|
||||
|
||||
|
||||
Endcase
|
||||
|
||||
|
||||
Enddo
|
||||
|
||||
|
||||
*----------------------------------------------------------
|
||||
Function anuevoel(obj,arrayName,valasig,bidim,colpos,rowpos)
|
||||
*----------------------------------------------------------
|
||||
|
||||
|
||||
If m.bidim
|
||||
|
||||
colpos = m.colpos+1
|
||||
|
||||
If colpos > m.ncols
|
||||
ncols = m.colpos
|
||||
Endif
|
||||
|
||||
Dimension obj.&arrayName(m.rowpos,m.ncols)
|
||||
|
||||
obj.&arrayName(m.rowpos,m.colpos) = m.valasig
|
||||
|
||||
If Vartype(m.valasig) = 'O'
|
||||
procstring(obj.&arrayName(m.rowpos,m.colpos))
|
||||
Endif
|
||||
|
||||
Else
|
||||
|
||||
rowpos = m.rowpos+1
|
||||
Dimension obj.&arrayName(m.rowpos)
|
||||
|
||||
obj.&arrayName(m.rowpos) = m.valasig
|
||||
|
||||
If Vartype(m.valasig) = 'O'
|
||||
procstring(obj.&arrayName(m.rowpos))
|
||||
Endif
|
||||
|
||||
Endif
|
||||
|
||||
|
||||
*-----------------------------------------
|
||||
Function unescunicode( Value )
|
||||
*-----------------------------------------
|
||||
|
||||
|
||||
noc=1
|
||||
|
||||
Do While .T.
|
||||
|
||||
posunicode = At('\u',m.value,m.noc)
|
||||
|
||||
If m.posunicode = 0
|
||||
Return
|
||||
Endif
|
||||
|
||||
If Substr(m.value,m.posunicode-1,1) = '\' And Substr(m.value,m.posunicode-2,1) # '\'
|
||||
noc=m.noc+1
|
||||
Loop
|
||||
Endif
|
||||
|
||||
nunic = Evaluate('0x'+ Substr(m.value,m.posunicode+2,4) )
|
||||
|
||||
If Between(m.nunic,0,255)
|
||||
unicodec = Chr(m.nunic)
|
||||
Else
|
||||
unicodec = '&#'+Transform(m.nunic)+';'
|
||||
Endif
|
||||
|
||||
Value = Stuff(m.value,m.posunicode,6,m.unicodec)
|
||||
|
||||
|
||||
Enddo
|
||||
|
||||
*-----------------------------------
|
||||
Function unescapecontrolc( Value )
|
||||
*-----------------------------------
|
||||
|
||||
If At('\', m.value) = 0
|
||||
Return
|
||||
Endif
|
||||
|
||||
* unescape special characters:
|
||||
|
||||
Private aa,elem,unesc
|
||||
|
||||
|
||||
Declare aa(1)
|
||||
=Alines(m.aa,m.value,18,'\\','\b','\f','\n','\r','\t','\"','\/')
|
||||
|
||||
unesc =''
|
||||
|
||||
#Define sustb 'bnrt/"'
|
||||
#Define sustr Chr(127)+Chr(10)+Chr(13)+Chr(9)+Chr(47)+Chr(34)
|
||||
|
||||
For Each elem In m.aa
|
||||
|
||||
If ! m.elem == '\\' And Right(m.elem,2) = '\'
|
||||
elem = Left(m.elem,Len(m.elem)-2)+Chrtran(Right(m.elem,1),sustb,sustr)
|
||||
Endif
|
||||
|
||||
unesc = m.unesc+m.elem
|
||||
|
||||
Endfor
|
||||
|
||||
Value = m.unesc
|
||||
|
||||
*--------------------------------------------
|
||||
Procedure propertyName(obj)
|
||||
*--------------------------------------------
|
||||
|
||||
vari=''
|
||||
|
||||
Do While ( Right(m.vari,1) # '"' Or ( Right(m.vari,2) = '\"' And Right(m.vari,3) # '\\"' ) ) And Alen(aj) > m.x
|
||||
x=m.x+1
|
||||
vari = m.vari+aj(m.x)
|
||||
Enddo
|
||||
|
||||
If Right(m.vari,1) # '"'
|
||||
Error ' expecting " received '+ Right(Rtrim(m.vari),1)
|
||||
Endif
|
||||
|
||||
vari = Left(m.vari,Len(m.vari)-1)
|
||||
vari = Iif(Isalpha(m.vari),'','_')+m.vari
|
||||
vari = Chrtran( vari, Chrtran( vari, cvalid,'' ) , creem )
|
||||
|
||||
If vari = 'tabindex'
|
||||
vari = '_tabindex'
|
||||
Endif
|
||||
|
||||
|
||||
expecting = ':'
|
||||
expectingValue = .T.
|
||||
expectingPropertyName = .F.
|
||||
|
||||
|
||||
*-------------------------------------------------------------
|
||||
Procedure procValue(obj)
|
||||
*-------------------------------------------------------------
|
||||
|
||||
Do Case
|
||||
Case aj(m.x) = '{'
|
||||
|
||||
oStack.pushObject()
|
||||
|
||||
If m.arrayLevel = 0
|
||||
|
||||
AddProperty(obj,m.vari,Createobject('empty'))
|
||||
|
||||
procstring(obj.&vari)
|
||||
expectingPropertyName = .T.
|
||||
expecting = ',}'
|
||||
expectingValue = .F.
|
||||
|
||||
Else
|
||||
|
||||
anuevoel(m.obj,m.arrayName,Createobject('empty'),m.bidim,@colpos,@rowpos)
|
||||
expectingPropertyName = .F.
|
||||
expecting = ',]'
|
||||
expectingValue = .T.
|
||||
|
||||
Endif
|
||||
|
||||
|
||||
Case aj(x) = '['
|
||||
|
||||
oStack.pushArray()
|
||||
|
||||
Do Case
|
||||
|
||||
Case m.arrayLevel = 0
|
||||
|
||||
arrayName = Evl(m.vari,'array')
|
||||
rowpos = 0
|
||||
colpos = 0
|
||||
bidim = .F.
|
||||
|
||||
#DEFINE EMPTYARRAYFLAG '_EMPTY_ARRAY_FLAG_'
|
||||
|
||||
Try
|
||||
AddProperty(obj,(m.arrayName+'(1)'),EMPTYARRAYFLAG)
|
||||
Catch
|
||||
m.arrayName = m.arrayName+'_vfpSafe_'
|
||||
AddProperty(obj,(m.arrayName+'(1)'),EMPTYARRAYFLAG)
|
||||
Endtry
|
||||
|
||||
|
||||
Case m.arrayLevel = 1 And !m.bidim
|
||||
|
||||
rowpos = 1
|
||||
colpos = 0
|
||||
ncols = 1
|
||||
|
||||
Dime obj.&arrayName(1,2)
|
||||
bidim = .T.
|
||||
|
||||
Endcase
|
||||
|
||||
arrayLevel = m.arrayLevel+1
|
||||
|
||||
vari=''
|
||||
|
||||
expecting = Iif(!m.bidim,'[]{',']')
|
||||
expectingValue = .T.
|
||||
expectingPropertyName = .F.
|
||||
|
||||
Otherwise
|
||||
|
||||
isstring = aj(m.x)='"'
|
||||
x = m.x + Iif(m.isstring,1,0)
|
||||
|
||||
Value = ''
|
||||
|
||||
Do While .T.
|
||||
|
||||
Value = m.value+m.aj(m.x)
|
||||
|
||||
If m.isstring
|
||||
If Right(m.value,1) = '"' And ( Right(m.value,2) # '\"' Or Right(m.value,3) = '\\' )
|
||||
Exit
|
||||
Endif
|
||||
Else
|
||||
If Right(m.value,1) $ '}],' And ( Left(Right(m.value,2),1) # '\' Or Left(Right(Value,3),2) = '\\')
|
||||
Exit
|
||||
Endif
|
||||
Endif
|
||||
|
||||
If m.x < Alen(aj)
|
||||
x = m.x+1
|
||||
Else
|
||||
Exit
|
||||
Endif
|
||||
|
||||
Enddo
|
||||
|
||||
closeChar = Right(m.value,1)
|
||||
|
||||
Value = Rtrim(m.value,1,m.closeChar)
|
||||
|
||||
If Empty(Value) And Not ( m.isstring And m.closeChar = '"' )
|
||||
Error 'Expecting value received '+m.closeChar
|
||||
Endif
|
||||
|
||||
Do Case
|
||||
|
||||
Case m.isstring
|
||||
If m.closeChar # '"'
|
||||
Error 'expecting " received '+m.closeChar
|
||||
Endif
|
||||
|
||||
Case oStack.isObject() And Not m.closeChar $ ',}'
|
||||
Error 'expecting ,} received '+m.closeChar
|
||||
|
||||
Case oStack.isArray() And Not m.closeChar $ ',]'
|
||||
Error 'expecting ,] received '+m.closeChar
|
||||
|
||||
Endcase
|
||||
|
||||
|
||||
|
||||
If m.isstring
|
||||
|
||||
* don't change this lines sequence!:
|
||||
unescunicode(@Value) && 1
|
||||
unescapecontrolc(@Value) && 2
|
||||
Value = Strtran(m.value,'\\','\') && 3
|
||||
|
||||
** check for Json Date:
|
||||
If isJsonDt( m.value )
|
||||
Value = jsonDateToDT( m.value )
|
||||
Endif
|
||||
|
||||
Else
|
||||
|
||||
Value = Alltrim(m.value)
|
||||
|
||||
Do Case
|
||||
Case m.value == 'null'
|
||||
Value = .Null.
|
||||
Case m.value == 'true' Or m.value == 'false'
|
||||
Value = Value='true'
|
||||
Case Empty(Chrtran(m.value,'-1234567890.E','')) And Occurs('.',m.value) <= 1 And Occurs('-',m.value) <= 1 And Occurs('E',m.value)<=1
|
||||
If Not 'E' $ m.value
|
||||
Value = Cast( m.value As N( Len(m.value) , Iif(At('.',m.value)>0,Len(m.value)-At( '.',m.value) ,0) ))
|
||||
Endif
|
||||
Otherwise
|
||||
Error 'expecting "|number|null|true|false| received '+aj(m.x)
|
||||
Endcase
|
||||
|
||||
|
||||
Endif
|
||||
|
||||
|
||||
If m.arrayLevel = 0
|
||||
|
||||
|
||||
AddProperty(obj,m.vari,m.value)
|
||||
|
||||
expecting = '}'
|
||||
expectingValue = .F.
|
||||
expectingPropertyName = .T.
|
||||
|
||||
Else
|
||||
|
||||
anuevoel(obj,m.arrayName,m.value,m.bidim,@colpos,@rowpos)
|
||||
expecting = ']'
|
||||
expectingValue = .T.
|
||||
expectingPropertyName = .F.
|
||||
|
||||
Endif
|
||||
|
||||
expecting = Iif(m.isstring,',','')+m.expecting
|
||||
|
||||
|
||||
Do Case
|
||||
Case m.closeChar = ']'
|
||||
closeArray()
|
||||
Case m.closeChar = '}'
|
||||
closeObject()
|
||||
Endcase
|
||||
|
||||
Endcase
|
||||
|
||||
|
||||
*------------------------------
|
||||
Function closeArray()
|
||||
*------------------------------
|
||||
|
||||
If oStack.Pop() # 'A'
|
||||
Error 'unexpected ] '
|
||||
Endif
|
||||
|
||||
If m.arrayLevel = 0
|
||||
Error 'unexpected ] '
|
||||
Endif
|
||||
|
||||
arrayLevel = m.arrayLevel-1
|
||||
|
||||
If m.arrayLevel = 0
|
||||
|
||||
arrayName = ''
|
||||
rowpos = 0
|
||||
colpos = 0
|
||||
|
||||
expecting = Iif(oStack.isObject(),',}','')
|
||||
expectingPropertyName = .T.
|
||||
expectingValue = .F.
|
||||
|
||||
Else
|
||||
|
||||
If m.bidim
|
||||
rowpos = m.rowpos+1
|
||||
colpos = 0
|
||||
expecting = ',]['
|
||||
Else
|
||||
expecting = ',]'
|
||||
Endif
|
||||
|
||||
expectingValue = .T.
|
||||
expectingPropertyName = .F.
|
||||
|
||||
Endif
|
||||
|
||||
|
||||
|
||||
*-------------------------------------
|
||||
Procedure closeObject
|
||||
*-------------------------------------
|
||||
|
||||
If oStack.Pop() # 'O'
|
||||
Error 'unexpected }'
|
||||
Endif
|
||||
|
||||
If m.arrayLevel = 0
|
||||
expecting = ',}'
|
||||
expectingValue = .F.
|
||||
expectingPropertyName = .T.
|
||||
objectOpen = .F.
|
||||
Else
|
||||
expecting = ',]'
|
||||
expectingValue = .T.
|
||||
expectingPropertyName = .F.
|
||||
Endif
|
||||
|
||||
|
||||
*----------------------------------------------
|
||||
Function reviveCollection( o )
|
||||
*----------------------------------------------
|
||||
|
||||
Private All
|
||||
|
||||
oConv = Createobject('empty')
|
||||
|
||||
nProp = Amembers(elem,m.o,0,'U')
|
||||
|
||||
For x = 1 To m.nProp
|
||||
|
||||
estaVar = m.elem(x)
|
||||
|
||||
esArray = .F.
|
||||
esColeccion = Type('m.o.'+m.estaVar) = 'O' And Right( m.estaVar , 14 ) $ '_KV_COLLECTION,_KL_COLLECTION' And Type( 'm.o.'+m.estaVar+'.collectionitems',1) = 'A'
|
||||
|
||||
Do Case
|
||||
Case m.esColeccion
|
||||
|
||||
estaProp = Createobject('collection')
|
||||
|
||||
tv = m.o.&estaVar
|
||||
|
||||
m.keyValColl = Right( m.estaVar , 14 ) = '_KV_COLLECTION'
|
||||
|
||||
For T = 1 To Alen(m.tv.collectionItems)
|
||||
|
||||
If m.keyValColl
|
||||
esteval = m.tv.collectionItems(m.T).Value
|
||||
Else
|
||||
esteval = m.tv.collectionItems(m.T)
|
||||
ENDIF
|
||||
|
||||
IF VARTYPE(m.esteval) = 'C' AND m.esteval = emptyarrayflag
|
||||
loop
|
||||
ENDIF
|
||||
|
||||
If Vartype(m.esteval) = 'O' Or Type('esteVal',1) = 'A'
|
||||
esteval = reviveCollection(m.esteval)
|
||||
Endif
|
||||
|
||||
If m.keyValColl
|
||||
estaProp.Add(esteval,m.tv.collectionItems(m.T).Key)
|
||||
Else
|
||||
estaProp.Add(m.esteval)
|
||||
Endif
|
||||
|
||||
Endfor
|
||||
|
||||
Case Type('m.o.'+m.estaVar,1) = 'A'
|
||||
|
||||
esArray = .T.
|
||||
|
||||
For T = 1 To Alen(m.o.&estaVar)
|
||||
|
||||
Dimension &estaVar(m.T)
|
||||
|
||||
If Type('m.o.&estaVar(m.T)') = 'O'
|
||||
&estaVar(m.T) = reviveCollection(m.o.&estaVar(m.T))
|
||||
Else
|
||||
&estaVar(m.T) = m.o.&estaVar(m.T)
|
||||
Endif
|
||||
|
||||
Endfor
|
||||
|
||||
Case Type('m.o.'+estaVar) = 'O'
|
||||
estaProp = reviveCollection(m.o.&estaVar)
|
||||
|
||||
Otherwise
|
||||
estaProp = m.o.&estaVar
|
||||
|
||||
Endcase
|
||||
|
||||
|
||||
estaVar = Strtran( m.estaVar,'_KV_COLLECTION', '' )
|
||||
estaVar = Strtran( m.estaVar, '_KL_COLLECTION', '' )
|
||||
|
||||
Do Case
|
||||
Case m.esColeccion
|
||||
AddProperty(m.oConv,m.estaVar,m.estaProp)
|
||||
Case m.esArray
|
||||
AddProperty(m.oConv,m.estaVar+'(1)')
|
||||
Acopy(&estaVar,m.oConv.&estaVar)
|
||||
Otherwise
|
||||
AddProperty(m.oConv,m.estaVar,m.estaProp)
|
||||
Endcase
|
||||
|
||||
Endfor
|
||||
|
||||
Try
|
||||
retCollection = m.oConv.Collection.BaseClass = 'Collection'
|
||||
Catch
|
||||
retCollection = .F.
|
||||
Endtry
|
||||
|
||||
If m.retCollection
|
||||
Return m.oConv.Collection
|
||||
Else
|
||||
Return m.oConv
|
||||
Endif
|
||||
|
||||
|
||||
*----------------------------------
|
||||
Function isJsonDt( cstr )
|
||||
*----------------------------------
|
||||
Return Iif( Len(m.cstr) = 19 ;
|
||||
AND Len(Chrtran(m.cstr,'01234567890:T-','')) = 0 ;
|
||||
and Substr(m.cstr,5,1) = '-' ;
|
||||
and Substr(m.cstr,8,1) = '-' ;
|
||||
and Substr(m.cstr,11,1) = 'T' ;
|
||||
and Substr(m.cstr,14,1) = ':' ;
|
||||
and Substr(m.cstr,17,1) = ':' ;
|
||||
and Occurs('T',m.cstr) = 1 ;
|
||||
and Occurs('-',m.cstr) = 2 ;
|
||||
and Occurs(':',m.cstr) = 2 ,.T.,.F. )
|
||||
|
||||
|
||||
*-----------------------------------
|
||||
Procedure jsonDateToDT( cJsonDate )
|
||||
*-----------------------------------
|
||||
Return Eval("{^"+m.cJsonDate+"}")
|
||||
|
||||
|
||||
|
||||
******************************************
|
||||
Define Class Stack As Collection
|
||||
******************************************
|
||||
|
||||
*---------------------------
|
||||
Function pushObject()
|
||||
*---------------------------
|
||||
This.Add('O')
|
||||
|
||||
*---------------------------
|
||||
Function pushArray()
|
||||
*---------------------------
|
||||
This.Add('A')
|
||||
|
||||
*--------------------------------------
|
||||
Function isObject()
|
||||
*--------------------------------------
|
||||
If This.Count > 0
|
||||
Return This.Item( This.Count ) = 'O'
|
||||
Else
|
||||
Return .F.
|
||||
Endif
|
||||
|
||||
|
||||
*--------------------------------------
|
||||
Function isArray()
|
||||
*--------------------------------------
|
||||
If This.Count > 0
|
||||
Return This.Item( This.Count ) = 'A'
|
||||
Else
|
||||
Return .F.
|
||||
Endif
|
||||
|
||||
*----------------------------
|
||||
Function Pop()
|
||||
*----------------------------
|
||||
cret = This.Item( This.Count )
|
||||
This.Remove( This.Count )
|
||||
Return m.cret
|
||||
|
||||
******************************************
|
||||
Enddefine
|
||||
******************************************
|
||||
|
||||
|
||||
268
vfp/regex.prg
268
vfp/regex.prg
@@ -1,268 +0,0 @@
|
||||
*!* CLEAR
|
||||
*!* ?strtranx([ana are 1234567890.1234 lei], [\s\d+\.\d\s], [=TRANSFORM($1, "999 999 999 999.99")])
|
||||
*?strtranx([ana are <<1234567890.1234>> lei], [<<], [=TRANSFORM($1, "AA")])
|
||||
*!* RETURN
|
||||
CLEAR
|
||||
|
||||
|
||||
*-- http://www.cornerstonenw.com/article_id_parsing3.htm
|
||||
SET STEP ON
|
||||
|
||||
lcSourceString = [ana are mere 123,345 678 ad]
|
||||
LOCAL laItems[10]
|
||||
|
||||
lnResults = GetRegExpAll(lcSourceString, '\d+', @laItems)
|
||||
|
||||
SET STEP ON
|
||||
RETURN
|
||||
strTest = [ab cd2""$$<24>]
|
||||
?strTest
|
||||
?StripNonAscii(strTest)
|
||||
|
||||
*-- replace non a-z09 with "" case-insensitive
|
||||
? strtranx([Ab ra /ca\d&abr'a],"[^a-z0-9]",[],1,,1)
|
||||
RETURN
|
||||
|
||||
*-- count words
|
||||
? OccursRegExp("\b(\w+)\b", [the then quick quick brown fox fox])
|
||||
&& prints 7
|
||||
|
||||
*-- count repeatedwords
|
||||
? OccursRegExp("\b(\w+)\s\1\b", [the then quick quick brown fox fox])
|
||||
&& prints 2
|
||||
|
||||
|
||||
*-- replace first and second lower-case "a"
|
||||
? strtranx([Abracadabra],[a],[*],1,2)
|
||||
&& prints Abr*c*dabra
|
||||
|
||||
*-- replace first and second "a" case-insensitive
|
||||
? strtranx([Abracadabra],[a],[*],1,2,1)
|
||||
&& prints *br*cadabra
|
||||
|
||||
*-- locate the replacement targets
|
||||
? strtranx([Abracadabra],[^a|a$],[*],1,2,0)
|
||||
&& Abracadabr*
|
||||
? strtranx([Abracadabra],[^a|a$],[*],1,2,1)
|
||||
&& *bracadabr*
|
||||
|
||||
|
||||
lcText = "The cost, is $123,345.75. "
|
||||
*-- convert the commas
|
||||
lcText = strtranx( m.lcText, "(\d{1,3})\,(\d{1,}) ","$1 $2" )
|
||||
|
||||
*-- convert the decimals
|
||||
? strtranx( m.lcText, "(\d{1,3})\.(\d{1,})", "$1,$2" )
|
||||
|
||||
** prints "The cost, is $123 345,75."
|
||||
|
||||
*-- add 1 to all digits
|
||||
? strtranx( [ABC123], "(\d)", [=TRANSFORM(VAL($1)+1)] )
|
||||
** prints "ABC234"
|
||||
|
||||
*-- convert all dates to long format
|
||||
? strtranx( [the date is: 7/18/2004 ] , [(\d{1,2}/\d{1,2}/\d{4})], [=TRANSFORM(CTOD($1),"@YL")])
|
||||
** prints "the date is: Sunday, July 18, 2004"
|
||||
|
||||
|
||||
*----------------------------------------------------------
|
||||
FUNCTION StrtranRegExp( tcSourceString, tcPattern, tcReplace )
|
||||
LOCAL loRE
|
||||
loRE = CREATEOBJECT("vbscript.regexp")
|
||||
WITH loRE
|
||||
.PATTERN = tcPattern
|
||||
.GLOBAL = .T.
|
||||
.multiline = .T.
|
||||
RETURN .REPLACE( tcSourceString , tcReplace )
|
||||
ENDWITH
|
||||
ENDFUNC
|
||||
|
||||
*----------------------------------------------------------
|
||||
FUNCTION OccursRegExp(tcPattern, tcText)
|
||||
LOCAL loRE, loMatches, lnResult
|
||||
loRE = CREATEOBJECT("vbscript.regexp")
|
||||
WITH loRE
|
||||
.PATTERN = m.tcPattern
|
||||
.GLOBAL = .T.
|
||||
.multiline = .T.
|
||||
loMatches = loRE.Execute( m.tcText )
|
||||
lnResult = loMatches.COUNT
|
||||
loMatches = NULL
|
||||
ENDWITH
|
||||
RETURN m.lnResult
|
||||
ENDFUNC
|
||||
|
||||
|
||||
|
||||
*----------------------------------------------------------
|
||||
FUNCTION strtranx(tcSearched, ;
|
||||
tcSearchFor, ;
|
||||
tcReplacement, ;
|
||||
tnStart, tnNumber, ;
|
||||
tnFlag )
|
||||
|
||||
*-- the final version of the UDF
|
||||
LOCAL loRE, lcText, lnShift, lcCommand,;
|
||||
loMatch, loMatches, lnI, lnK, lcSubMatch,;
|
||||
llevaluate, lcMatchDelim, lcReplaceText, lcReplacement,;
|
||||
lnStart, lnNumber, loCol, lcKey
|
||||
|
||||
IF EMPTY(NVL(tcSearched, ''))
|
||||
RETURN NVL(tcSearched, '')
|
||||
ENDIF
|
||||
|
||||
loRE = CREATEOBJECT("vbscript.regexp")
|
||||
|
||||
WITH loRE
|
||||
.PATTERN = m.tcSearchFor
|
||||
.GLOBAL = .T.
|
||||
.multiline = .T.
|
||||
.ignorecase = IIF(VARTYPE(m.tnFlag)=[N],m.tnFlag = 1,.F.)
|
||||
ENDWITH
|
||||
|
||||
lcReplacement = m.tcReplacement
|
||||
|
||||
*--- are we evaluating?
|
||||
IF m.lcReplacement = [=]
|
||||
llevaluate = .T.
|
||||
lcReplacement = SUBSTR( m.lcReplacement, 2 )
|
||||
ENDIF
|
||||
|
||||
IF VARTYPE( m.tnStart )=[N]
|
||||
lnStart = m.tnStart
|
||||
ELSE
|
||||
lnStart = 1
|
||||
ENDIF
|
||||
|
||||
IF VARTYPE( m.tnNumber) =[N]
|
||||
lnNumber = m.tnNumber
|
||||
ELSE
|
||||
lnNumber = -1
|
||||
ENDIF
|
||||
|
||||
IF m.lnStart>1 OR m.lnNumber#-1 OR m.llevaluate
|
||||
|
||||
lcText = m.tcSearched
|
||||
lnShift = 1
|
||||
loMatches = loRE.execute( m.lcText )
|
||||
loCol = CREATEOBJECT([collection])
|
||||
lnNumber = IIF( lnNumber=-1,loMatches.COUNT,MIN(lnNumber,loMatches.COUNT))
|
||||
|
||||
FOR lnK = m.lnStart TO m.lnNumber
|
||||
loMatch = loMatches.ITEM(m.lnK-1) && zero based
|
||||
lcCommand = m.lcReplacement
|
||||
FOR lnI= 1 TO loMatch.submatches.COUNT
|
||||
lcSubMatch = loMatch.submatches(m.lnI-1) && zero based
|
||||
IF m.llevaluate
|
||||
* "escape" the string we are about to use in an evaluation.
|
||||
* it is important to escape due to possible delim chars (like ", ' etc)
|
||||
* malicious content, or VFP line-length violations.
|
||||
lcKey = ALLTRIM(TRANSFORM(m.lnK)+[_]+TRANSFORM(m.lnI))
|
||||
loCol.ADD( m.lcSubMatch, m.lcKey )
|
||||
lcSubMatch = [loCol.item(']+m.lcKey+[')]
|
||||
ENDIF
|
||||
lcCommand = STRTRAN( m.lcCommand, "$" + ALLTRIM( STR( m.lnI ) ) , m.lcSubMatch)
|
||||
ENDFOR
|
||||
|
||||
IF m.llevaluate
|
||||
TRY
|
||||
lcReplaceText = EVALUATE( m.lcCommand )
|
||||
CATCH TO loErr
|
||||
lcReplaceText="[[ERROR #"+TRANSFORM(loErr.ERRORNO)+[ ]+loErr.MESSAGE+"]]"
|
||||
ENDTRY
|
||||
ELSE
|
||||
lcReplaceText = m.lcCommand
|
||||
ENDIF
|
||||
lcText = STUFF( m.lcText, loMatch.FirstIndex + m.lnShift, m.loMatch.LENGTH, m.lcReplaceText )
|
||||
lnShift = m.lnShift + LEN( m.lcReplaceText ) - m.loMatch.LENGTH
|
||||
ENDFOR
|
||||
ELSE
|
||||
lcText = loRE.REPLACE( m.tcSearched, m.tcReplacement )
|
||||
ENDIF
|
||||
RETURN m.lcText
|
||||
ENDFUNC
|
||||
|
||||
*=====================
|
||||
FUNCTION StripNonAscii
|
||||
LPARAMETERS tcSourceString, tcReplaceString
|
||||
|
||||
TEXT TO lcPattern NOSHOW
|
||||
[^A-Za-z 0-9 \.,\?'""!@#\$%\^&\*\(\)-_=\+;:<>\/\\\|\}\{\[\]`~]
|
||||
ENDTEXT
|
||||
lcReplace = IIF(TYPE('tcReplaceString') <> 'C', "", tcReplaceString)
|
||||
lcReturn = strtranx( m.tcSourceString, m.lcPattern, m.lcReplace,1,,1)
|
||||
|
||||
RETURN m.lcReturn
|
||||
ENDFUNC && StripNonAscii
|
||||
|
||||
*=====================
|
||||
* Intoarce un text care se potriveste cu pattern-ul
|
||||
* Ex. Localitatea din textul: STRADA NR LOCALITATE
|
||||
*=====================
|
||||
FUNCTION GetRegExp
|
||||
LPARAMETERS tcSourceString, tcPattern, tnOccurence
|
||||
|
||||
* tcSourceString: Bld. Stefan cel Mare 14 Tirgu Neamt
|
||||
* tcPattern: [A-Za-z\s]+$ = (caracter sau spatiu) de cel putin o data la sfarsitul liniei = Tirgu Neamt
|
||||
* tcPattern: \d+[A-Za-z\s]+$ = oricate cifre (caracter sau spatiu) de cel putin o data la sfarsitul liniei = 14 Tirgu Neamt
|
||||
|
||||
LOCAL loRE, loMatches, lcResult, lnOccurence
|
||||
lcResult = ''
|
||||
lnOccurence = IIF(!EMPTY(m.tnOccurence) and TYPE('tnOccurence') = 'N', m.tnOccurence, 1)
|
||||
|
||||
loRE = CREATEOBJECT("vbscript.regexp")
|
||||
WITH loRE
|
||||
.PATTERN = m.tcPattern
|
||||
.GLOBAL = .T.
|
||||
.multiline = .T.
|
||||
loMatches = loRE.Execute( m.tcSourceString)
|
||||
IF loMatches.COUNT >= m.lnOccurence
|
||||
lcResult = loMatches.Item(m.lnOccurence - 1).Value
|
||||
ENDIF
|
||||
loMatches = NULL
|
||||
ENDWITH
|
||||
|
||||
RETURN m.lcResult
|
||||
ENDFUNC && GetRegExp
|
||||
|
||||
*=====================
|
||||
* Intoarce numarul potrivirilor si un parametru OUT array sau lista de numere facturi separate prin ","
|
||||
* Ex. Toate numerele dintr-un text lnMatches = GetRegExpAll(lcSourceString, '\d+', @loMatches)
|
||||
*=====================
|
||||
FUNCTION GetRegExpAll
|
||||
LPARAMETERS tcSourceString, tcPattern, taItems
|
||||
|
||||
* tcSourceString: Bld. Stefan cel Mare 14 Tirgu Neamt
|
||||
* tcPattern: [A-Za-z\s]+$ = (caracter sau spatiu) de cel putin o data la sfarsitul liniei = Tirgu Neamt
|
||||
* tcPattern: \d+[A-Za-z\s]+$ = oricate cifre (caracter sau spatiu) de cel putin o data la sfarsitul liniei = 14 Tirgu Neamt
|
||||
* taItems "A">taItems : array cu rezultatele (OUT) taItems[1..Result] sau taItems "C" lista facturi separate prin virgula
|
||||
LOCAL loRE, loMatches, lnResults, lnItem
|
||||
IF TYPE('taItems') = "A"
|
||||
EXTERNAL ARRAY taItems
|
||||
ELSE
|
||||
taItems = ""
|
||||
ENDIF
|
||||
lnResult = 0
|
||||
|
||||
loRE = CREATEOBJECT("vbscript.regexp")
|
||||
WITH loRE
|
||||
.PATTERN = m.tcPattern
|
||||
.GLOBAL = .T.
|
||||
.multiline = .T.
|
||||
loMatches = loRE.Execute( m.tcSourceString)
|
||||
lnResults = loMatches.COUNT
|
||||
IF TYPE('taItems') = "A"
|
||||
DIMENSION taItems[m.lnResult]
|
||||
FOR lnItem = 1 TO m.lnResult
|
||||
taItems[m.lnItem] = loMatches.Item(m.lnItem-1).Value
|
||||
ENDFOR
|
||||
ELSE
|
||||
FOR lnItem = 1 TO m.lnResults
|
||||
taItems = taItems + IIF(m.lnItem > 1, ",", "") + loMatches.Item(m.lnItem-1).Value
|
||||
ENDFOR
|
||||
ENDIF
|
||||
loMatches = NULL
|
||||
ENDWITH
|
||||
|
||||
RETURN m.lnResults
|
||||
ENDFUNC && GetRegExp
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,4 +0,0 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
"C:\Program Files (x86)\Microsoft Visual FoxPro 9\vfp9.exe" -T "%~dp0sync-comenzi-web.prg"
|
||||
pause
|
||||
@@ -1,70 +0,0 @@
|
||||
[API]
|
||||
ApiBaseUrl=https://api.gomag.ro/api/v1/product/read/json?enabled=1
|
||||
OrderApiUrl=https://api.gomag.ro/api/v1/order/read/json
|
||||
ApiKey=YOUR_API_KEY_HERE
|
||||
ApiShop=https://yourstore.gomag.ro
|
||||
UserAgent=Mozilla/5.0
|
||||
ContentType=application/json
|
||||
|
||||
[PAGINATION]
|
||||
Limit=100
|
||||
|
||||
[OPTIONS]
|
||||
GetProducts=1
|
||||
GetOrders=1
|
||||
|
||||
[FILTERS]
|
||||
OrderDaysBack=7
|
||||
|
||||
[ORACLE]
|
||||
OracleUser=MARIUSM_AUTO
|
||||
OraclePassword=ROMFASTSOFT
|
||||
OracleDSN=ROA_CENTRAL
|
||||
|
||||
[SYNC]
|
||||
AdapterProgram=gomag-adapter.prg
|
||||
JsonFilePattern=gomag_orders*.json
|
||||
AutoRunAdapter=1
|
||||
|
||||
[ROA]
|
||||
IdPol=39
|
||||
IdGestiune=NULL
|
||||
IdSectie=NULL
|
||||
|
||||
# ===============================================
|
||||
# CONFIGURATIE SYNC COMENZI WEB → ORACLE ROA
|
||||
# ===============================================
|
||||
#
|
||||
# [API] - Configurari pentru GoMag API
|
||||
# - ApiKey: Cheia API de la GoMag (OBLIGATORIU)
|
||||
# - ApiShop: URL-ul magazinului GoMag (OBLIGATORIU)
|
||||
#
|
||||
# [OPTIONS]
|
||||
# - GetProducts: 1=descarca produse, 0=skip
|
||||
# - GetOrders: 1=descarca comenzi, 0=skip
|
||||
#
|
||||
# [ORACLE] - Conexiune la database ROA
|
||||
# - OracleUser: Utilizatorul Oracle (OBLIGATORIU)
|
||||
# - OraclePassword: Parola Oracle (OBLIGATORIU)
|
||||
# - OracleDSN: Data Source Name (OBLIGATORIU)
|
||||
#
|
||||
# [SYNC] - Configurari sincronizare
|
||||
# - AdapterProgram: Numele programului adapter (ex: gomag-adapter.prg)
|
||||
# - JsonFilePattern: Pattern pentru fisiere JSON (ex: gomag_orders*.json)
|
||||
# - AutoRunAdapter: 1=ruleaza automat adapter, 0=foloseste doar JSON existent
|
||||
#
|
||||
# [ROA] - Configurari sistem ROA
|
||||
# - IdPol: ID politica de preturi (NULL=fara politica, numar=ID specific)
|
||||
# - IdGestiune: ID gestiune pentru comenzi (NULL=automat, numar=ID specific)
|
||||
# - IdSectie: ID sectie pentru comenzi (NULL=automat, numar=ID specific)
|
||||
#
|
||||
# Pentru utilizare:
|
||||
# 1. Copiaza settings.ini.example → settings.ini
|
||||
# 2. Configureaza ApiKey si ApiShop pentru GoMag
|
||||
# 3. Verifica datele Oracle (default: schema MARIUSM_AUTO)
|
||||
# 4. Ruleaza sync-comenzi-web.prg
|
||||
#
|
||||
# Pentru scheduled task Windows:
|
||||
# - Creeaza task care ruleaza sync-comenzi-web.prg la interval
|
||||
# - Nu mai este nevoie de auto-sync-timer.prg
|
||||
# - sync-comenzi-web.prg va apela automat gomag-adapter.prg
|
||||
@@ -1,682 +0,0 @@
|
||||
*-- sync-comenzi-web.prg - Orchestrator pentru sincronizarea comenzilor web cu Oracle ROA
|
||||
*-- Autor: Claude AI
|
||||
*-- Data: 10 septembrie 2025
|
||||
*-- Dependency: gomag-vending.prg trebuie rulat mai intai pentru generarea JSON-urilor
|
||||
|
||||
Set Safety Off
|
||||
Set Century On
|
||||
Set Date Dmy
|
||||
Set Exact On
|
||||
Set Ansi On
|
||||
Set Deleted On
|
||||
|
||||
*-- Variabile globale
|
||||
Private gcAppPath, gcLogFile, gnStartTime, gnOrdersProcessed, gnOrdersSuccess, gnOrdersErrors, gcFailedSKUs
|
||||
Private goConnectie, goSettings, goAppSetup, gcStepError
|
||||
Local lcJsonPattern, laJsonFiles[1], lnJsonFiles, lnIndex, lcJsonFile
|
||||
Local loJsonData, lcJsonContent, lnOrderCount, lnOrderIndex
|
||||
Local loOrder, lcResult, llProcessSuccess, lcPath
|
||||
|
||||
goConnectie = Null
|
||||
gcStepError = ""
|
||||
|
||||
*-- Initializare
|
||||
gcAppPath = Addbs(Justpath(Sys(16,0)))
|
||||
Set Default To (m.gcAppPath)
|
||||
|
||||
lcPath = gcAppPath + 'nfjson;'
|
||||
Set Path To &lcPath Additive
|
||||
|
||||
Set Procedure To utils.prg Additive
|
||||
Set Procedure To ApplicationSetup.prg Additive
|
||||
Set Procedure To nfjsonread.prg Additive
|
||||
Set Procedure To nfjsoncreate.prg Additive
|
||||
Set Procedure To regex.prg Additive
|
||||
|
||||
*-- Statistici
|
||||
gnStartTime = Seconds()
|
||||
gnOrdersProcessed = 0
|
||||
gnOrdersSuccess = 0
|
||||
gnOrdersErrors = 0
|
||||
gcFailedSKUs = ""
|
||||
|
||||
*-- Initializare logging
|
||||
gcLogFile = InitLog("sync_comenzi")
|
||||
|
||||
*-- Creare si initializare clasa setup aplicatie
|
||||
goAppSetup = Createobject("ApplicationSetup", gcAppPath)
|
||||
|
||||
If !goAppSetup.Initialize()
|
||||
LogMessage("EROARE: Setup-ul aplicatiei a esuat sau necesita configurare!", "ERROR", gcLogFile)
|
||||
Return .F.
|
||||
Endif
|
||||
|
||||
goSettings = goAppSetup.GetSettings()
|
||||
|
||||
*-- Verificare directoare necesare
|
||||
If !Directory(gcAppPath + "output")
|
||||
LogMessage("EROARE: Directorul output/ nu exista!", "ERROR", gcLogFile)
|
||||
Return .F.
|
||||
Endif
|
||||
|
||||
*-- Rulare automata adapter pentru obtinere comenzi
|
||||
If goSettings.AutoRunAdapter
|
||||
If !ExecuteAdapter()
|
||||
LogMessage("EROARE adapter, continuez cu JSON existente", "WARN", gcLogFile)
|
||||
Endif
|
||||
Endif
|
||||
|
||||
*-- Gasire fisiere JSON comenzi
|
||||
lcJsonPattern = gcAppPath + "output\" + goSettings.JsonFilePattern
|
||||
lnJsonFiles = Adir(laJsonFiles, lcJsonPattern)
|
||||
|
||||
If lnJsonFiles = 0
|
||||
LogMessage("Nu au fost gasite fisiere JSON cu comenzi web", "WARN", gcLogFile)
|
||||
Return .T.
|
||||
Endif
|
||||
|
||||
*-- Conectare Oracle
|
||||
If !ConnectToOracle()
|
||||
LogMessage("EROARE: Nu s-a putut conecta la Oracle ROA", "ERROR", gcLogFile)
|
||||
Return .F.
|
||||
Endif
|
||||
|
||||
*-- Header compact
|
||||
LogMessage("SYNC START | " + goSettings.OracleDSN + " " + goSettings.OracleUser + " | " + Transform(lnJsonFiles) + " JSON files", "INFO", gcLogFile)
|
||||
|
||||
*-- Procesare fiecare fisier JSON gasit
|
||||
For lnIndex = 1 To lnJsonFiles
|
||||
lcJsonFile = gcAppPath + "output\" + laJsonFiles[lnIndex, 1]
|
||||
|
||||
Try
|
||||
lcJsonContent = Filetostr(lcJsonFile)
|
||||
If Empty(lcJsonContent)
|
||||
Loop
|
||||
Endif
|
||||
|
||||
loJsonData = nfjsonread(lcJsonContent)
|
||||
If Isnull(loJsonData) Or Type('loJsonData') != 'O' Or Type('loJsonData.orders') != 'O'
|
||||
LogMessage("EROARE JSON: " + laJsonFiles[lnIndex, 1], "ERROR", gcLogFile)
|
||||
Loop
|
||||
Endif
|
||||
|
||||
Local Array laOrderProps[1]
|
||||
lnOrderCount = Amembers(laOrderProps, loJsonData.orders, 0)
|
||||
|
||||
*-- Procesare fiecare comanda
|
||||
For lnOrderIndex = 1 To lnOrderCount
|
||||
Local lcOrderId, loOrder
|
||||
lcOrderId = laOrderProps[lnOrderIndex]
|
||||
loOrder = Evaluate('loJsonData.orders.' + lcOrderId)
|
||||
|
||||
If Type('loOrder') = 'O'
|
||||
gnOrdersProcessed = gnOrdersProcessed + 1
|
||||
|
||||
llProcessSuccess = ProcessWebOrder(loOrder, lnOrderIndex, lnOrderCount)
|
||||
|
||||
If llProcessSuccess
|
||||
gnOrdersSuccess = gnOrdersSuccess + 1
|
||||
Else
|
||||
gnOrdersErrors = gnOrdersErrors + 1
|
||||
Endif
|
||||
Endif
|
||||
|
||||
If m.gnOrdersErrors > 10
|
||||
Exit
|
||||
Endif
|
||||
Endfor
|
||||
|
||||
Catch To loError
|
||||
LogMessage("EROARE fisier " + laJsonFiles[lnIndex, 1] + ": " + loError.Message, "ERROR", gcLogFile)
|
||||
gnOrdersErrors = gnOrdersErrors + 1
|
||||
Endtry
|
||||
|
||||
If m.gnOrdersErrors > 10
|
||||
LogMessage("Peste 10 erori, stop import", "ERROR", gcLogFile)
|
||||
Exit
|
||||
Endif
|
||||
Endfor
|
||||
|
||||
*-- Inchidere conexiune Oracle
|
||||
DisconnectFromOracle()
|
||||
|
||||
*-- Sumar SKU-uri lipsa
|
||||
If !Empty(gcFailedSKUs)
|
||||
LogMessage("=== SKU-URI LIPSA ===", "INFO", gcLogFile)
|
||||
Local lnSkuCount, lnSkuIdx
|
||||
Local Array laSkus[1]
|
||||
lnSkuCount = Alines(laSkus, gcFailedSKUs, .T., CHR(10))
|
||||
For lnSkuIdx = 1 To lnSkuCount
|
||||
If !Empty(laSkus[lnSkuIdx])
|
||||
LogMessage(Alltrim(laSkus[lnSkuIdx]), "INFO", gcLogFile)
|
||||
Endif
|
||||
Endfor
|
||||
LogMessage("=== SFARSIT SKU-URI LIPSA ===", "INFO", gcLogFile)
|
||||
Endif
|
||||
|
||||
*-- Footer compact
|
||||
Local lcStopped, lnSkuTotal, lnDuration
|
||||
lnDuration = Int(Seconds() - gnStartTime)
|
||||
lnSkuTotal = Iif(Empty(gcFailedSKUs), 0, Occurs(CHR(10), gcFailedSKUs) + 1)
|
||||
lcStopped = Iif(gnOrdersErrors > 10, " (stopped early)", "")
|
||||
LogMessage("SYNC END | " + Transform(gnOrdersProcessed) + " processed: " + Transform(gnOrdersSuccess) + " ok, " + Transform(gnOrdersErrors) + " err" + lcStopped + " | " + Transform(lnSkuTotal) + " SKUs lipsa | " + Transform(lnDuration) + "s", "INFO", gcLogFile)
|
||||
CloseLog(gnStartTime, 0, gnOrdersProcessed, gcLogFile)
|
||||
|
||||
Return .T.
|
||||
|
||||
*-- ===================================================================
|
||||
*-- HELPER FUNCTIONS
|
||||
*-- ===================================================================
|
||||
|
||||
*-- Conectare la Oracle
|
||||
Function ConnectToOracle
|
||||
Local llSuccess, lnHandle
|
||||
|
||||
llSuccess = .F.
|
||||
|
||||
Try
|
||||
lnHandle = SQLConnect(goSettings.OracleDSN, goSettings.OracleUser, goSettings.OraclePassword)
|
||||
|
||||
If lnHandle > 0
|
||||
goConnectie = lnHandle
|
||||
llSuccess = .T.
|
||||
Else
|
||||
LogMessage("EROARE conectare Oracle: Handle=" + Transform(lnHandle), "ERROR", gcLogFile)
|
||||
Endif
|
||||
|
||||
Catch To loError
|
||||
LogMessage("EROARE conectare Oracle: " + loError.Message, "ERROR", gcLogFile)
|
||||
Endtry
|
||||
|
||||
Return llSuccess
|
||||
Endfunc
|
||||
|
||||
*-- Deconectare de la Oracle
|
||||
Function DisconnectFromOracle
|
||||
If Type('goConnectie') = 'N' And goConnectie > 0
|
||||
SQLDisconnect(goConnectie)
|
||||
Endif
|
||||
Return .T.
|
||||
Endfunc
|
||||
|
||||
*-- Procesare comanda web - logeaza O SINGURA LINIE per comanda
|
||||
*-- Format: [N/Total] OrderNumber P:PartnerID A:AddrFact/AddrLivr -> OK/ERR details
|
||||
*-- NOTA: VFP nu permite RETURN in TRY/CATCH, se foloseste flag llContinue
|
||||
Function ProcessWebOrder
|
||||
Lparameters loOrder, tnIndex, tnTotal
|
||||
Local llSuccess, lcOrderNumber, lcOrderDate, lnPartnerID, lcArticlesJSON
|
||||
Local lcSQL, lnResult, lcErrorDetails, lnIdComanda
|
||||
Local ldOrderDate, loError
|
||||
Local lnIdAdresaFacturare, lnIdAdresaLivrare
|
||||
Local lcPrefix, lcSummary, lcErrDetail, llContinue
|
||||
|
||||
lnIdAdresaLivrare = NULL
|
||||
lnIdAdresaFacturare = NULL
|
||||
lnIdComanda = 0
|
||||
llSuccess = .F.
|
||||
llContinue = .T.
|
||||
lnPartnerID = 0
|
||||
lcOrderNumber = "?"
|
||||
|
||||
*-- Prefix: [N/Total] OrderNumber
|
||||
lcPrefix = "[" + Transform(tnIndex) + "/" + Transform(tnTotal) + "]"
|
||||
|
||||
Try
|
||||
*-- Validare comanda
|
||||
If !ValidateWebOrder(loOrder)
|
||||
LogMessage(lcPrefix + " ? -> ERR VALIDARE: date obligatorii lipsa", "ERROR", gcLogFile)
|
||||
llContinue = .F.
|
||||
Endif
|
||||
|
||||
*-- Extragere date comanda
|
||||
If llContinue
|
||||
lcOrderNumber = CleanWebText(Transform(loOrder.Number))
|
||||
lcOrderDate = ConvertWebDate(loOrder.Date)
|
||||
ldOrderDate = String2Date(m.lcOrderDate, 'yyyymmdd')
|
||||
lcPrefix = lcPrefix + " " + lcOrderNumber
|
||||
|
||||
*-- Procesare partener
|
||||
gcStepError = ""
|
||||
lnPartnerID = ProcessPartner(loOrder.billing)
|
||||
If lnPartnerID <= 0
|
||||
LogMessage(lcPrefix + " -> ERR PARTENER: " + Iif(Empty(gcStepError), "nu s-a putut procesa", gcStepError), "ERROR", gcLogFile)
|
||||
llContinue = .F.
|
||||
Endif
|
||||
Endif
|
||||
|
||||
*-- Adrese + JSON articole
|
||||
If llContinue
|
||||
lnIdAdresaFacturare = ProcessAddress(m.lnPartnerID, loOrder.billing)
|
||||
If Type('loOrder.shipping') = 'O'
|
||||
lnIdAdresaLivrare = ProcessAddress(m.lnPartnerID, loOrder.shipping)
|
||||
Endif
|
||||
|
||||
lcArticlesJSON = BuildArticlesJSON(loOrder.items)
|
||||
If Empty(m.lcArticlesJSON)
|
||||
LogMessage(lcPrefix + " P:" + Transform(lnPartnerID) + " -> ERR JSON_ARTICOLE", "ERROR", gcLogFile)
|
||||
llContinue = .F.
|
||||
Endif
|
||||
Endif
|
||||
|
||||
*-- Import comanda in Oracle
|
||||
If llContinue
|
||||
lcSQL = "BEGIN PACK_IMPORT_COMENZI.importa_comanda(?lcOrderNumber, ?ldOrderDate, ?lnPartnerID, ?lcArticlesJSON, ?lnIdAdresaLivrare, ?lnIdAdresaFacturare, ?goSettings.IdPol, ?goSettings.IdSectie, ?@lnIdComanda); END;"
|
||||
lnResult = SQLExec(goConnectie, lcSQL)
|
||||
|
||||
lcSummary = lcPrefix + " P:" + Transform(lnPartnerID) + ;
|
||||
" A:" + Transform(Nvl(lnIdAdresaFacturare, 0)) + "/" + Transform(Nvl(lnIdAdresaLivrare, 0))
|
||||
|
||||
If lnResult > 0 And Nvl(m.lnIdComanda, 0) > 0
|
||||
LogMessage(lcSummary + " -> OK ID:" + Transform(m.lnIdComanda), "INFO", gcLogFile)
|
||||
llSuccess = .T.
|
||||
Else
|
||||
lcErrorDetails = GetOracleErrorDetails()
|
||||
lcErrDetail = ClassifyImportError(lcErrorDetails)
|
||||
CollectFailedSKUs(lcErrorDetails)
|
||||
LogMessage(lcSummary + " -> ERR " + lcErrDetail, "ERROR", gcLogFile)
|
||||
Endif
|
||||
Endif
|
||||
|
||||
Catch To loError
|
||||
LogMessage(lcPrefix + " -> ERR EXCEPTIE: " + loError.Message, "ERROR", gcLogFile)
|
||||
Endtry
|
||||
|
||||
Return llSuccess
|
||||
Endfunc
|
||||
|
||||
*-- Validare comanda web
|
||||
Function ValidateWebOrder
|
||||
Parameters loOrder
|
||||
Local llValid
|
||||
|
||||
llValid = .T.
|
||||
|
||||
If Type('loOrder.number') != 'C' Or Empty(loOrder.Number)
|
||||
llValid = .F.
|
||||
Endif
|
||||
|
||||
If Type('loOrder.date') != 'C' Or Empty(loOrder.Date)
|
||||
llValid = .F.
|
||||
Endif
|
||||
|
||||
If Type('loOrder.billing') != 'O'
|
||||
llValid = .F.
|
||||
Endif
|
||||
|
||||
If Type('loOrder.items') != 'O'
|
||||
llValid = .F.
|
||||
Endif
|
||||
|
||||
Return llValid
|
||||
Endfunc
|
||||
|
||||
*-- Procesare partener (fara logging, seteaza gcStepError la eroare)
|
||||
Function ProcessPartner
|
||||
Lparameters toBilling
|
||||
Local lcDenumire, lcCodFiscal, lcRegistru, lcAdresa, lcTelefon, lcEmail, lcRegistru
|
||||
Local lcSQL, lnResult
|
||||
Local lnIdPart, lnIdAdresa, lnIsPersoanaJuridica
|
||||
lnIdPart = 0
|
||||
lnIdAdresa = 0
|
||||
lcDenumire = ''
|
||||
lcCodFiscal = ''
|
||||
lcRegistru = ''
|
||||
lnIsPersoanaJuridica = 0
|
||||
lcCodFiscal = Null
|
||||
|
||||
Try
|
||||
If Type('toBilling.company') = 'O' And !Empty(toBilling.company.Name)
|
||||
loCompany = toBilling.company
|
||||
lcDenumire = CleanWebText(loCompany.Name)
|
||||
lcCodFiscal = Iif(Type('loCompany.code') = 'C', loCompany.Code, Null)
|
||||
lcCodFiscal = CleanWebText(m.lcCodFiscal)
|
||||
lcRegistru = Iif(Type('loCompany.registrationNo') = 'C', loCompany.registrationNo, Null)
|
||||
lcRegistru = CleanWebText(m.lcRegistru)
|
||||
lnIsPersoanaJuridica = 1
|
||||
Else
|
||||
If Type('toBilling.firstname') = 'C'
|
||||
lcDenumire = CleanWebText(Alltrim(toBilling.firstname) + " " + Alltrim(toBilling.lastname))
|
||||
lnIsPersoanaJuridica = 0
|
||||
Endif
|
||||
Endif
|
||||
|
||||
lcSQL = "BEGIN PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener(?lcCodFiscal, ?lcDenumire, ?lcRegistru, ?lnIsPersoanaJuridica, ?@lnIdPart); END;"
|
||||
lnResult = SQLExec(goConnectie, lcSQL)
|
||||
|
||||
If lnResult <= 0
|
||||
gcStepError = lcDenumire + " | " + GetOracleErrorDetails()
|
||||
Endif
|
||||
|
||||
Catch To loError
|
||||
gcStepError = loError.Message
|
||||
Endtry
|
||||
|
||||
Return m.lnIdPart
|
||||
Endfunc
|
||||
|
||||
*-- Procesare adresa (fara logging)
|
||||
Function ProcessAddress
|
||||
Lparameters tnIdPart, toAdresa
|
||||
|
||||
Local lcAdresa, lcTelefon, lcEmail, lcSQL, lnResult, lnIdAdresa
|
||||
lnIdAdresa = 0
|
||||
|
||||
Try
|
||||
If !Empty(Nvl(m.tnIdPart, 0))
|
||||
lcAdresa = FormatAddressForOracle(toAdresa)
|
||||
lcTelefon = Iif(Type('toAdresa.phone') = 'C', toAdresa.phone, "")
|
||||
lcEmail = Iif(Type('toAdresa.email') = 'C', toAdresa.email, "")
|
||||
|
||||
lcSQL = "BEGIN PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa(?tnIdPart, ?lcAdresa, ?lcTelefon, ?lcEmail, ?@lnIdAdresa); END;"
|
||||
lnResult = SQLExec(goConnectie, lcSQL)
|
||||
Endif
|
||||
|
||||
Catch To loError
|
||||
Endtry
|
||||
|
||||
Return m.lnIdAdresa
|
||||
Endfunc
|
||||
|
||||
|
||||
*-- Construire JSON articole
|
||||
Function BuildArticlesJSON
|
||||
Lparameters loItems
|
||||
|
||||
Local lcJSON, loError
|
||||
|
||||
lcJSON = ""
|
||||
Try
|
||||
lcJSON = nfjsoncreate(loItems)
|
||||
Catch To loError
|
||||
lcJSON = ""
|
||||
Endtry
|
||||
|
||||
Return lcJSON
|
||||
Endfunc
|
||||
|
||||
*-- Curatare text web (HTML entities -> ASCII simplu)
|
||||
Function CleanWebText
|
||||
Parameters tcText
|
||||
Local lcResult
|
||||
|
||||
If Empty(tcText) Or Type('tcText') != 'C'
|
||||
Return ""
|
||||
Endif
|
||||
|
||||
lcResult = tcText
|
||||
|
||||
lcResult = Strtran(lcResult, 'ă', 'a')
|
||||
lcResult = Strtran(lcResult, 'ș', 's')
|
||||
lcResult = Strtran(lcResult, 'ț', 't')
|
||||
lcResult = Strtran(lcResult, 'î', 'i')
|
||||
lcResult = Strtran(lcResult, 'â', 'a')
|
||||
lcResult = Strtran(lcResult, '&', '&')
|
||||
lcResult = Strtran(lcResult, '<', '<')
|
||||
lcResult = Strtran(lcResult, '>', '>')
|
||||
lcResult = Strtran(lcResult, '"', '"')
|
||||
|
||||
lcResult = Strtran(lcResult, '<br>', ' ')
|
||||
lcResult = Strtran(lcResult, '<br/>', ' ')
|
||||
lcResult = Strtran(lcResult, '<br />', ' ')
|
||||
|
||||
lcResult = Strtran(lcResult, '\/', '/')
|
||||
|
||||
Return Alltrim(lcResult)
|
||||
Endfunc
|
||||
|
||||
*-- Conversie data web in format YYYYMMDD
|
||||
Function ConvertWebDate
|
||||
Parameters tcWebDate
|
||||
Local lcResult
|
||||
|
||||
If Empty(tcWebDate) Or Type('tcWebDate') != 'C'
|
||||
Return Dtos(Date())
|
||||
Endif
|
||||
|
||||
lcResult = Strtran(Left(tcWebDate, 10), "-", "",1,10,1)
|
||||
|
||||
If Len(lcResult) = 8
|
||||
Return lcResult
|
||||
Else
|
||||
Return Dtos(Date())
|
||||
Endif
|
||||
Endfunc
|
||||
|
||||
*-- Conversie string in Date
|
||||
Function String2Date
|
||||
Lparameters tcDate, tcFormat
|
||||
|
||||
Local lcAn, lcDate, lcFormat, lcLuna, lcZi, ldData, lnAn, lnLuna, lnZi, loEx
|
||||
ldData = {}
|
||||
|
||||
lcDate = m.tcDate
|
||||
lcFormat = Iif(!Empty(m.tcFormat), Alltrim(Lower(m.tcFormat)), 'yyyymmdd')
|
||||
|
||||
lcDate = Chrtran(m.lcDate, '-/\','...')
|
||||
lcDate = Strtran(m.lcDate, '.', '', 1, 2, 1)
|
||||
lcFormat = Chrtran(m.lcFormat, '.-/\','...')
|
||||
lcFormat = Strtran(m.lcFormat, '.', '', 1, 2, 1)
|
||||
|
||||
Do Case
|
||||
Case m.lcFormat = 'ddmmyyyy'
|
||||
lcAn = Substr(m.tcDate, 5, 4)
|
||||
lcLuna = Substr(m.tcDate, 3, 2)
|
||||
lcZi = Substr(m.tcDate, 1, 2)
|
||||
Otherwise
|
||||
lcAn = Substr(m.tcDate, 1, 4)
|
||||
lcLuna = Substr(m.tcDate, 5, 2)
|
||||
lcZi = Substr(m.tcDate, 7, 2)
|
||||
Endcase
|
||||
lnAn = Int(Val(m.lcAn))
|
||||
lnLuna = Int(Val(m.lcLuna))
|
||||
lnZi = Int(Val(m.lcZi))
|
||||
|
||||
Try
|
||||
ldData = Date(m.lnAn, m.lnLuna, m.lnZi)
|
||||
Catch To loEx
|
||||
ldData = {}
|
||||
Endtry
|
||||
|
||||
Return m.ldData
|
||||
Endfunc
|
||||
|
||||
*-- Formatare adresa in format semicolon pentru Oracle
|
||||
Function FormatAddressForOracle
|
||||
Parameters loBilling
|
||||
Local lcAdresa, lcJudet, lcOras, lcStrada
|
||||
|
||||
lcJudet = Iif(Type('loBilling.region') = 'C', CleanWebText(loBilling.Region), "")
|
||||
lcOras = Iif(Type('loBilling.city') = 'C', CleanWebText(loBilling.city), "")
|
||||
lcStrada = Iif(Type('loBilling.address') = 'C', CleanWebText(loBilling.address), "")
|
||||
|
||||
lcAdresa = "JUD:" + lcJudet + ";" + lcOras + ";" + lcStrada
|
||||
|
||||
Return lcAdresa
|
||||
Endfunc
|
||||
|
||||
*-- Construire observatii comanda
|
||||
Function BuildOrderObservations
|
||||
Parameters loOrder
|
||||
Local lcObservatii
|
||||
|
||||
lcObservatii = ""
|
||||
|
||||
If Type('loOrder.payment') = 'O' And Type('loOrder.payment.name') = 'C'
|
||||
lcObservatii = lcObservatii + "Payment: " + CleanWebText(loOrder.Payment.Name) + "; "
|
||||
Endif
|
||||
|
||||
If Type('loOrder.delivery') = 'O' And Type('loOrder.delivery.name') = 'C'
|
||||
lcObservatii = lcObservatii + "Delivery: " + CleanWebText(loOrder.delivery.Name) + "; "
|
||||
Endif
|
||||
|
||||
If Type('loOrder.status') = 'C'
|
||||
lcObservatii = lcObservatii + "Status: " + CleanWebText(loOrder.Status) + "; "
|
||||
Endif
|
||||
|
||||
If Type('loOrder.source') = 'C'
|
||||
lcObservatii = lcObservatii + "Source: " + CleanWebText(loOrder.Source)
|
||||
|
||||
If Type('loOrder.sales_channel') = 'C'
|
||||
lcObservatii = lcObservatii + " " + CleanWebText(loOrder.sales_channel)
|
||||
Endif
|
||||
lcObservatii = lcObservatii + "; "
|
||||
Endif
|
||||
|
||||
If Type('loOrder.shipping') = 'O' And Type('loOrder.billing') = 'O'
|
||||
If Type('loOrder.shipping.address') = 'C' And Type('loOrder.billing.address') = 'C'
|
||||
If !Alltrim(loOrder.shipping.address) == Alltrim(loOrder.billing.address)
|
||||
lcObservatii = lcObservatii + "Shipping: " + CleanWebText(loOrder.shipping.address)
|
||||
|
||||
If Type('loOrder.shipping.city') = 'C'
|
||||
lcObservatii = lcObservatii + ", " + CleanWebText(loOrder.shipping.city)
|
||||
Endif
|
||||
lcObservatii = lcObservatii + "; "
|
||||
Endif
|
||||
Endif
|
||||
Endif
|
||||
|
||||
If Len(lcObservatii) > 500
|
||||
lcObservatii = Left(lcObservatii, 497) + "..."
|
||||
Endif
|
||||
|
||||
Return lcObservatii
|
||||
Endfunc
|
||||
|
||||
*-- Obtinere detalii eroare Oracle (single-line, fara SQL)
|
||||
Function GetOracleErrorDetails
|
||||
Local lcError, laError[1], lnErrorLines, lnIndex
|
||||
|
||||
lcError = ""
|
||||
|
||||
lnErrorLines = Aerror(laError)
|
||||
If lnErrorLines > 0
|
||||
For lnIndex = 1 To lnErrorLines
|
||||
If lnIndex > 1
|
||||
lcError = lcError + " | "
|
||||
Endif
|
||||
lcError = lcError + Alltrim(Str(laError[lnIndex, 1])) + ": " + laError[lnIndex, 2]
|
||||
Endfor
|
||||
Endif
|
||||
|
||||
If Empty(lcError)
|
||||
lcError = "Eroare Oracle nedefinita"
|
||||
Endif
|
||||
|
||||
*-- Compact: inlocuieste newlines cu spatii
|
||||
lcError = Strtran(lcError, CHR(13) + CHR(10), " ")
|
||||
lcError = Strtran(lcError, CHR(10), " ")
|
||||
lcError = Strtran(lcError, CHR(13), " ")
|
||||
|
||||
Return lcError
|
||||
Endfunc
|
||||
|
||||
*-- Clasifica eroarea Oracle intr-un format compact
|
||||
*-- Returneaza: "SKU_NOT_FOUND: sku" / "PRICE_POLICY: sku" / eroarea bruta
|
||||
Function ClassifyImportError
|
||||
Lparameters tcErrorDetails
|
||||
Local lcText, lcSku, lnPos, lcSearch
|
||||
|
||||
lcText = Iif(Empty(tcErrorDetails), "", tcErrorDetails)
|
||||
|
||||
*-- SKU negasit
|
||||
lcSearch = "NOM_ARTICOLE: "
|
||||
lnPos = Atc(lcSearch, lcText)
|
||||
If lnPos > 0
|
||||
lcSku = Alltrim(Getwordnum(Substr(lcText, lnPos + Len(lcSearch)), 1))
|
||||
Return "SKU_NOT_FOUND: " + lcSku
|
||||
Endif
|
||||
|
||||
*-- Eroare adaugare articol (include pretul)
|
||||
lcSearch = "Eroare adaugare articol "
|
||||
lnPos = Atc(lcSearch, lcText)
|
||||
If lnPos > 0
|
||||
lcSku = Alltrim(Getwordnum(Substr(lcText, lnPos + Len(lcSearch)), 1))
|
||||
Return "PRICE_POLICY: " + lcSku
|
||||
Endif
|
||||
|
||||
*-- Eroare pret fara SKU (inainte de fix-ul Oracle)
|
||||
If Atc("Pretul pentru acest articol", lcText) > 0
|
||||
Return "PRICE_POLICY: (SKU necunoscut)"
|
||||
Endif
|
||||
|
||||
*-- Eroare generica - primele 100 caractere
|
||||
Return Left(lcText, 100)
|
||||
Endfunc
|
||||
|
||||
*-- Colectare SKU-uri lipsa din mesajele de eroare Oracle
|
||||
Function CollectFailedSKUs
|
||||
Lparameters tcErrorDetails
|
||||
Local lcSku, lnPos, lcSearch, lcText
|
||||
|
||||
If Empty(tcErrorDetails)
|
||||
Return
|
||||
Endif
|
||||
|
||||
lcText = tcErrorDetails
|
||||
|
||||
*-- Pattern 1: "SKU negasit in ARTICOLE_TERTI si NOM_ARTICOLE: XXXXX"
|
||||
lcSearch = "NOM_ARTICOLE: "
|
||||
lnPos = Atc(lcSearch, lcText)
|
||||
If lnPos > 0
|
||||
lcSku = Alltrim(Getwordnum(Substr(lcText, lnPos + Len(lcSearch)), 1))
|
||||
If !Empty(lcSku)
|
||||
AddUniqueSKU(lcSku)
|
||||
Endif
|
||||
Endif
|
||||
|
||||
*-- Pattern 2: "Eroare adaugare articol XXXXX (CODMAT:" sau "Eroare adaugare articol XXXXX:"
|
||||
lcSearch = "Eroare adaugare articol "
|
||||
lnPos = Atc(lcSearch, lcText)
|
||||
If lnPos > 0
|
||||
lcSku = Alltrim(Getwordnum(Substr(lcText, lnPos + Len(lcSearch)), 1))
|
||||
If !Empty(lcSku)
|
||||
AddUniqueSKU(lcSku)
|
||||
Endif
|
||||
Endif
|
||||
|
||||
Return
|
||||
Endfunc
|
||||
|
||||
*-- Adauga un SKU in gcFailedSKUs daca nu exista deja
|
||||
Function AddUniqueSKU
|
||||
Lparameters tcSku
|
||||
Local lcSku
|
||||
lcSku = Alltrim(tcSku)
|
||||
|
||||
If Empty(lcSku)
|
||||
Return
|
||||
Endif
|
||||
|
||||
If Empty(gcFailedSKUs)
|
||||
gcFailedSKUs = lcSku
|
||||
Else
|
||||
If !(CHR(10) + lcSku + CHR(10)) $ (CHR(10) + gcFailedSKUs + CHR(10))
|
||||
gcFailedSKUs = gcFailedSKUs + CHR(10) + lcSku
|
||||
Endif
|
||||
Endif
|
||||
|
||||
Return
|
||||
Endfunc
|
||||
|
||||
*-- Executie adapter configurat
|
||||
Function ExecuteAdapter
|
||||
Local llSuccess, lcAdapterPath
|
||||
|
||||
llSuccess = .F.
|
||||
|
||||
Try
|
||||
lcAdapterPath = gcAppPath + goSettings.AdapterProgram
|
||||
|
||||
If File(lcAdapterPath)
|
||||
Do (lcAdapterPath)
|
||||
llSuccess = .T.
|
||||
Else
|
||||
LogMessage("EROARE: Adapter negasit: " + lcAdapterPath, "ERROR", gcLogFile)
|
||||
Endif
|
||||
|
||||
Catch To loError
|
||||
LogMessage("EROARE adapter: " + loError.Message, "ERROR", gcLogFile)
|
||||
Endtry
|
||||
|
||||
Return llSuccess
|
||||
Endfunc
|
||||
203
vfp/utils.prg
203
vfp/utils.prg
@@ -1,203 +0,0 @@
|
||||
*-- utils.prg - Functii utilitare generale
|
||||
*-- Contine doar functii utilitare reutilizabile (INI, HTTP, logging, encoding)
|
||||
*-- Autor: Claude AI
|
||||
*-- Data: 10 septembrie 2025
|
||||
|
||||
*-- Functie pentru citirea fisierelor INI private
|
||||
*-- Returneaza valoarea din sectiunea si intrarea specificata sau blank daca nu e gasita
|
||||
FUNCTION ReadPini
|
||||
PARAMETERS cSection, cEntry, cINIFile
|
||||
LOCAL cDefault, cRetVal, nRetLen
|
||||
|
||||
cDefault = ""
|
||||
cRetVal = SPACE(255)
|
||||
nRetLen = LEN(cRetVal)
|
||||
|
||||
DECLARE INTEGER GetPrivateProfileString IN WIN32API ;
|
||||
STRING cSection, ;
|
||||
STRING cEntry, ;
|
||||
STRING cDefault, ;
|
||||
STRING @cRetVal, ;
|
||||
INTEGER nRetLen, ;
|
||||
STRING cINIFile
|
||||
|
||||
nRetLen = GetPrivateProfileString(cSection, ;
|
||||
cEntry, ;
|
||||
cDefault, ;
|
||||
@cRetVal, ;
|
||||
nRetLen, ;
|
||||
cINIFile)
|
||||
|
||||
RETURN LEFT(cRetVal, nRetLen)
|
||||
ENDFUNC
|
||||
|
||||
*-- Functie pentru scrierea in fisierele INI private
|
||||
*-- Returneaza .T. daca e successful, .F. daca nu
|
||||
FUNCTION WritePini
|
||||
PARAMETERS cSection, cEntry, cValue, cINIFile
|
||||
LOCAL nRetVal
|
||||
|
||||
DECLARE INTEGER WritePrivateProfileString IN WIN32API ;
|
||||
STRING cSection, ;
|
||||
STRING cEntry, ;
|
||||
STRING cValue, ;
|
||||
STRING cINIFile
|
||||
|
||||
nRetVal = WritePrivateProfileString(cSection, ;
|
||||
cEntry, ;
|
||||
cValue, ;
|
||||
cINIFile)
|
||||
|
||||
RETURN nRetVal = 1
|
||||
ENDFUNC
|
||||
|
||||
|
||||
*-- Test conectivitate internet
|
||||
FUNCTION TestConnectivity
|
||||
LOCAL loHttp, llResult
|
||||
|
||||
llResult = .T.
|
||||
|
||||
TRY
|
||||
loHttp = CREATEOBJECT("WinHttp.WinHttpRequest.5.1")
|
||||
loHttp.Open("GET", "https://www.google.com", .F.)
|
||||
loHttp.SetTimeouts(5000, 5000, 5000, 5000)
|
||||
loHttp.Send()
|
||||
|
||||
IF loHttp.Status != 200
|
||||
llResult = .F.
|
||||
ENDIF
|
||||
|
||||
CATCH
|
||||
llResult = .F.
|
||||
ENDTRY
|
||||
|
||||
loHttp = NULL
|
||||
RETURN llResult
|
||||
ENDFUNC
|
||||
|
||||
*-- Functie pentru codificare URL
|
||||
FUNCTION UrlEncode
|
||||
PARAMETERS tcString
|
||||
|
||||
LOCAL lcResult, lcChar, lnI
|
||||
|
||||
lcResult = ""
|
||||
|
||||
FOR lnI = 1 TO LEN(tcString)
|
||||
lcChar = SUBSTR(tcString, lnI, 1)
|
||||
|
||||
DO CASE
|
||||
CASE ISALPHA(lcChar) OR ISDIGIT(lcChar) OR INLIST(lcChar, "-", "_", ".", "~")
|
||||
lcResult = lcResult + lcChar
|
||||
OTHERWISE
|
||||
lcResult = lcResult + "%" + RIGHT("0" + TRANSFORM(ASC(lcChar), "@0"), 2)
|
||||
ENDCASE
|
||||
ENDFOR
|
||||
|
||||
RETURN lcResult
|
||||
ENDFUNC
|
||||
|
||||
*-- Functie pentru verificarea existentei fisierului INI
|
||||
FUNCTION CheckIniFile
|
||||
PARAMETERS cINIFile
|
||||
LOCAL llExists
|
||||
|
||||
TRY
|
||||
llExists = FILE(cINIFile)
|
||||
CATCH
|
||||
llExists = .F.
|
||||
ENDTRY
|
||||
|
||||
RETURN llExists
|
||||
ENDFUNC
|
||||
|
||||
*-- Functie pentru initializarea logging-ului
|
||||
FUNCTION InitLog
|
||||
PARAMETERS cBaseName
|
||||
LOCAL lcLogFile, lcStartTime, lcLogHeader, lcLogDir
|
||||
|
||||
*-- Cream directorul log daca nu existe
|
||||
lcLogDir = gcAppPath + "log"
|
||||
IF !DIRECTORY(lcLogDir)
|
||||
MKDIR (lcLogDir)
|
||||
ENDIF
|
||||
|
||||
*-- Generam numele fisierului log cu timestamp in directorul log
|
||||
lcStartTime = DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "")
|
||||
lcLogFile = lcLogDir + "\" + cBaseName + "_" + lcStartTime + ".log"
|
||||
|
||||
*-- Header pentru log
|
||||
lcLogHeader = "[" + TIME() + "] [START] === GoMag Sync Started ===" + CHR(13) + CHR(10)
|
||||
lcLogHeader = lcLogHeader + "[" + TIME() + "] [INFO ] Date: " + DTOC(DATE()) + " | VFP: " + VERSION() + CHR(13) + CHR(10)
|
||||
|
||||
*-- Cream fisierul log
|
||||
STRTOFILE(lcLogHeader, lcLogFile)
|
||||
|
||||
RETURN lcLogFile
|
||||
ENDFUNC
|
||||
|
||||
*-- Functie pentru logging cu nivel si timestamp
|
||||
FUNCTION LogMessage
|
||||
PARAMETERS cMessage, cLevel, cLogFile
|
||||
LOCAL lcTimeStamp, lcLogEntry, lcExistingContent
|
||||
|
||||
*-- Setam nivel implicit daca nu e specificat
|
||||
IF EMPTY(cLevel)
|
||||
cLevel = "INFO "
|
||||
ELSE
|
||||
*-- Formatam nivelul pentru a avea 5 caractere
|
||||
cLevel = LEFT(cLevel + " ", 5)
|
||||
ENDIF
|
||||
|
||||
*-- Cream timestamp-ul
|
||||
lcTimeStamp = TIME()
|
||||
|
||||
*-- Formatam mesajul pentru log
|
||||
lcLogEntry = "[" + lcTimeStamp + "] [" + cLevel + "] " + cMessage + CHR(13) + CHR(10)
|
||||
|
||||
*-- Adaugam la fisierul existent
|
||||
lcExistingContent = ""
|
||||
IF FILE(cLogFile)
|
||||
lcExistingContent = FILETOSTR(cLogFile)
|
||||
ENDIF
|
||||
|
||||
STRTOFILE(lcExistingContent + lcLogEntry, cLogFile)
|
||||
|
||||
RETURN .T.
|
||||
ENDFUNC
|
||||
|
||||
*-- Functie pentru inchiderea logging-ului cu statistici
|
||||
FUNCTION CloseLog
|
||||
PARAMETERS nStartTime, nProductsCount, nOrdersCount, cLogFile
|
||||
LOCAL lcEndTime, lcDuration, lcStatsEntry
|
||||
|
||||
lcEndTime = TIME()
|
||||
*-- Calculam durata in secunde
|
||||
nDuration = SECONDS() - nStartTime
|
||||
|
||||
*-- Formatam statisticile finale
|
||||
lcStatsEntry = "[" + lcEndTime + "] [END ] === GoMag Sync Completed ===" + CHR(13) + CHR(10)
|
||||
lcStatsEntry = lcStatsEntry + "[" + lcEndTime + "] [STATS] Duration: " + TRANSFORM(INT(nDuration)) + "s"
|
||||
|
||||
IF nProductsCount > 0
|
||||
lcStatsEntry = lcStatsEntry + " | Products: " + TRANSFORM(nProductsCount)
|
||||
ENDIF
|
||||
|
||||
IF nOrdersCount > 0
|
||||
lcStatsEntry = lcStatsEntry + " | Orders: " + TRANSFORM(nOrdersCount)
|
||||
ENDIF
|
||||
|
||||
lcStatsEntry = lcStatsEntry + CHR(13) + CHR(10)
|
||||
|
||||
*-- Adaugam la log
|
||||
LOCAL lcExistingContent
|
||||
lcExistingContent = ""
|
||||
IF FILE(cLogFile)
|
||||
lcExistingContent = FILETOSTR(cLogFile)
|
||||
ENDIF
|
||||
|
||||
STRTOFILE(lcExistingContent + lcStatsEntry, cLogFile)
|
||||
|
||||
RETURN .T.
|
||||
ENDFUNC
|
||||
Reference in New Issue
Block a user