Compare commits
3 Commits
82196b9dc0
...
f52c504c2b
| Author | SHA1 | Date | |
|---|---|---|---|
| f52c504c2b | |||
| 77a89f4b16 | |||
| 5f8b9b6003 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,6 +33,9 @@ venv/
|
|||||||
|
|
||||||
# SQLite databases
|
# SQLite databases
|
||||||
*.db
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
# Generated/duplicate directories
|
# Generated/duplicate directories
|
||||||
api/api/
|
api/api/
|
||||||
|
|||||||
210
README.md
210
README.md
@@ -16,18 +16,35 @@ System automat de import comenzi din platforma GoMag in sistemul ERP ROA Oracle.
|
|||||||
- **Admin/Dashboard:** FastAPI + Jinja2 + Oracle pool + SQLite
|
- **Admin/Dashboard:** FastAPI + Jinja2 + Oracle pool + SQLite
|
||||||
- **Date:** Oracle 11g/12c (schema ROA), SQLite (tracking local)
|
- **Date:** Oracle 11g/12c (schema ROA), SQLite (tracking local)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisite
|
### Prerequisite
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- Oracle Instant Client (optional - suporta si thin mode)
|
- Oracle Instant Client 21.x (optional — suporta si thin mode pentru Oracle 12.1+)
|
||||||
|
|
||||||
|
### Instalare
|
||||||
|
|
||||||
### Instalare si pornire
|
|
||||||
```bash
|
```bash
|
||||||
cd api
|
# Din project root (gomag/)
|
||||||
pip install -r requirements.txt
|
pip install -r api/requirements.txt
|
||||||
# Configureaza .env (vezi api/.env.example)
|
cp api/.env.example api/.env
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 5003 --reload
|
# Editeaza api/.env cu datele de conectare Oracle
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pornire server
|
||||||
|
|
||||||
|
**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
|
||||||
|
```
|
||||||
|
|
||||||
|
Sau folosind scriptul inclus:
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Deschide `http://localhost:5003` in browser.
|
Deschide `http://localhost:5003` in browser.
|
||||||
@@ -36,93 +53,154 @@ Deschide `http://localhost:5003` in browser.
|
|||||||
|
|
||||||
**Test A - Basic (fara Oracle):**
|
**Test A - Basic (fara Oracle):**
|
||||||
```bash
|
```bash
|
||||||
cd api
|
python api/test_app_basic.py
|
||||||
python test_app_basic.py
|
|
||||||
```
|
```
|
||||||
Verifica 17 importuri de module + 13 rute GET. Asteptat: 30/30 PASS.
|
Verifica importuri de module + rute GET. Asteptat: 32/33 PASS (1 fail pre-existent `/sync` HTML).
|
||||||
|
|
||||||
**Test C - Integrare Oracle:**
|
**Test C - Integrare Oracle:**
|
||||||
```bash
|
```bash
|
||||||
python api/test_integration.py
|
python api/test_integration.py
|
||||||
```
|
```
|
||||||
Necesita Oracle activ. Verifica health, mappings CRUD, article search, validation, sync. Asteptat: 9/9 PASS.
|
Necesita Oracle activ. Verifica health, mappings CRUD, article search, validation, sync.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configurare (.env)
|
||||||
|
|
||||||
|
Copiaza `.env.example` si completeaza:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp api/.env.example api/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variabila | Descriere | Exemplu |
|
||||||
|
|-----------|-----------|---------|
|
||||||
|
| `ORACLE_USER` | User Oracle | `MARIUSM_AUTO` |
|
||||||
|
| `ORACLE_PASSWORD` | Parola Oracle | `secret` |
|
||||||
|
| `ORACLE_DSN` | TNS alias | `ROA_CENTRAL` |
|
||||||
|
| `TNS_ADMIN` | Cale absoluta la tnsnames.ora | `/mnt/e/.../gomag/api` |
|
||||||
|
| `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` |
|
||||||
|
| `APP_PORT` | Port HTTP | `5003` |
|
||||||
|
| `ID_POL` | ID Politica ROA | `39` |
|
||||||
|
| `ID_GESTIUNE` | ID Gestiune ROA | `0` |
|
||||||
|
| `ID_SECTIE` | ID Sectie ROA | `6` |
|
||||||
|
|
||||||
|
**Nota Oracle mode:**
|
||||||
|
- **Thick mode** (Oracle 10g/11g): seteaza `INSTANTCLIENTPATH`
|
||||||
|
- **Thin mode** (Oracle 12.1+): seteaza `FORCE_THIN_MODE=true`, sterge `INSTANTCLIENTPATH`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Structura Proiect
|
## Structura Proiect
|
||||||
|
|
||||||
```
|
```
|
||||||
/
|
gomag/
|
||||||
├── api/ # FastAPI Admin + Database
|
├── api/ # FastAPI Admin + Dashboard
|
||||||
│ ├── app/ # Aplicatia FastAPI
|
│ ├── app/
|
||||||
│ │ ├── main.py # Entry point, lifespan, logging
|
│ │ ├── main.py # Entry point, lifespan, logging
|
||||||
│ │ ├── config.py # Settings (pydantic-settings, .env)
|
│ │ ├── config.py # Settings (pydantic-settings + .env)
|
||||||
│ │ ├── database.py # Oracle pool + SQLite init
|
│ │ ├── database.py # Oracle pool + SQLite schema + migrari
|
||||||
│ │ ├── routers/ # Endpoint-uri HTTP
|
│ │ ├── routers/ # Endpoint-uri HTTP
|
||||||
│ │ │ ├── health.py # /health, /api/health
|
│ │ │ ├── health.py # GET /health
|
||||||
│ │ │ ├── dashboard.py # / (dashboard HTML)
|
│ │ │ ├── dashboard.py # GET / (HTML)
|
||||||
│ │ │ ├── mappings.py # /mappings, /api/mappings
|
│ │ │ ├── mappings.py # /mappings, /api/mappings
|
||||||
│ │ │ ├── articles.py # /api/articles/search
|
│ │ │ ├── articles.py # /api/articles/search
|
||||||
│ │ │ ├── validation.py # /api/validate/*
|
│ │ │ ├── validation.py # /api/validate/*
|
||||||
│ │ │ └── sync.py # /api/sync/*
|
│ │ │ └── sync.py # /api/sync/* + /api/dashboard/orders
|
||||||
│ │ ├── services/ # Business logic
|
│ │ ├── services/
|
||||||
│ │ │ ├── mapping_service # CRUD ARTICOLE_TERTI
|
│ │ │ ├── sync_service.py # Orchestrare: JSON→validate→import
|
||||||
│ │ │ ├── article_service # Cautare NOM_ARTICOLE
|
│ │ │ ├── import_service.py # Import comanda in Oracle ROA
|
||||||
│ │ │ ├── import_service # Import comanda in Oracle
|
│ │ │ ├── mapping_service.py # CRUD ARTICOLE_TERTI + pct_total
|
||||||
│ │ │ ├── sync_service # Orchestrare: JSON→validate→import
|
│ │ │ ├── sqlite_service.py # Tracking runs/orders/missing SKUs
|
||||||
│ │ │ ├── validation_service # Validare SKU-uri
|
│ │ │ ├── order_reader.py # Citire gomag_orders_page*.json
|
||||||
│ │ │ ├── order_reader # Citire JSON-uri din vfp/output/
|
│ │ │ ├── validation_service.py
|
||||||
│ │ │ ├── sqlite_service # Tracking runs/orders/missing SKUs
|
│ │ │ ├── article_service.py
|
||||||
│ │ │ └── scheduler_service # APScheduler timer
|
│ │ │ └── scheduler_service.py # APScheduler timer
|
||||||
│ │ ├── templates/ # Jinja2 HTML (dashboard, mappings, etc.)
|
│ │ ├── templates/ # Jinja2 HTML
|
||||||
│ │ └── static/ # CSS + JS
|
│ │ └── static/ # CSS + JS
|
||||||
│ ├── database-scripts/ # Oracle SQL scripts
|
│ ├── database-scripts/ # Oracle SQL (ARTICOLE_TERTI, packages)
|
||||||
|
│ ├── data/ # SQLite DB (import.db)
|
||||||
|
│ ├── .env # Configurare locala (nu in git)
|
||||||
|
│ ├── .env.example # Template configurare
|
||||||
│ ├── test_app_basic.py # Test A - fara Oracle
|
│ ├── test_app_basic.py # Test A - fara Oracle
|
||||||
│ ├── test_integration.py # Test C - cu Oracle
|
│ ├── test_integration.py # Test C - cu Oracle
|
||||||
│ └── requirements.txt # Python dependencies
|
│ └── requirements.txt
|
||||||
├── vfp/ # VFP Integration
|
├── vfp/ # VFP Integration
|
||||||
│ ├── gomag-vending.prg # Client GoMag API
|
│ ├── gomag-vending.prg # Client GoMag API (descarca JSON-uri)
|
||||||
│ ├── sync-comenzi-web.prg # Orchestrator VFP
|
│ ├── sync-comenzi-web.prg # Orchestrator VFP
|
||||||
│ └── utils.prg # Utilitare VFP
|
│ ├── utils.prg # Utilitare (log, settings, connectivity)
|
||||||
├── docs/ # Documentatie
|
│ └── output/ # JSON-uri descarcate (gomag_orders_page*.json)
|
||||||
│ ├── PRD.md # Product Requirements
|
├── logs/ # Log-uri aplicatie (sync_comenzi_*.log)
|
||||||
│ └── stories/ # User Stories
|
├── docs/ # Documentatie (PRD, stories)
|
||||||
└── logs/ # Log-uri aplicatie
|
├── start.sh # Script pornire (Linux/WSL)
|
||||||
|
└── CLAUDE.md # Instructiuni pentru AI assistants
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configurare (.env)
|
---
|
||||||
|
|
||||||
|
## Dashboard Features
|
||||||
|
|
||||||
|
### Sync Panel
|
||||||
|
- Start sync manual sau scheduler automat (5/10/30 min)
|
||||||
|
- Progress live: `"Import 45/80: #CMD-1234 Ion Popescu"`
|
||||||
|
- Smart polling: 30s idle → 3s cand ruleaza → auto-refresh tabela
|
||||||
|
- Last sync clickabil → jurnal detaliat
|
||||||
|
|
||||||
|
### Comenzi
|
||||||
|
- Filtru perioada: 3z / 7z / 30z / 3 luni / toate / custom
|
||||||
|
- Status pills cu conturi totale pe perioada (nu per-pagina)
|
||||||
|
- Cautare integrata in bara de filtre
|
||||||
|
- Coloana Client cu tooltip `▲` cand persoana livrare ≠ facturare
|
||||||
|
- Paginare sus + jos, selector rezultate per pagina (25/50/100/250)
|
||||||
|
|
||||||
|
### Mapari SKU
|
||||||
|
- Badge `✓ 100%` / `⚠ 80%` per grup SKU
|
||||||
|
- Filtru Complete / Incomplete
|
||||||
|
- Verificare duplicat SKU-CODMAT (409 cu optiune de restaurare)
|
||||||
|
|
||||||
|
### SKU-uri Lipsa
|
||||||
|
- Cautare dupa SKU sau nume produs
|
||||||
|
- Filtru Nerezolvate / Rezolvate / Toate cu conturi
|
||||||
|
- Re-scan cu progress inline si banner rezultat
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fluxul de Import
|
||||||
|
|
||||||
```env
|
|
||||||
ORACLE_USER=MARIUSM_AUTO
|
|
||||||
ORACLE_PASSWORD=********
|
|
||||||
ORACLE_DSN=ROA_CENTRAL
|
|
||||||
FORCE_THIN_MODE=true # sau INSTANTCLIENTPATH=C:\oracle\instantclient
|
|
||||||
SQLITE_DB_PATH=data/import.db
|
|
||||||
APP_PORT=5003
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
JSON_OUTPUT_DIR=../vfp/output
|
|
||||||
```
|
```
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Status Implementare
|
## Status Implementare
|
||||||
|
|
||||||
### Phase 1: Database Foundation - COMPLET
|
| Faza | Status | Descriere |
|
||||||
- ARTICOLE_TERTI table + Docker setup
|
|------|--------|-----------|
|
||||||
- PACK_IMPORT_PARTENERI package
|
| Phase 1: Database Foundation | ✅ Complet | ARTICOLE_TERTI, PACK_IMPORT_PARTENERI, PACK_IMPORT_COMENZI |
|
||||||
- PACK_IMPORT_COMENZI package
|
| 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 ⏳ |
|
||||||
|
|
||||||
### Phase 2: VFP Integration - COMPLET
|
---
|
||||||
- gomag-vending.prg (GoMag API client)
|
|
||||||
- sync-comenzi-web.prg (orchestrator cu logging)
|
|
||||||
|
|
||||||
### Phase 3-4: FastAPI Admin + Dashboard - COMPLET
|
## WSL2 Note
|
||||||
- Mappings CRUD + CSV import/export
|
|
||||||
- Article autocomplete (NOM_ARTICOLE)
|
|
||||||
- Pre-validation SKU-uri
|
|
||||||
- Import orchestration (JSON→Oracle)
|
|
||||||
- Dashboard cu stat cards, sync control, history
|
|
||||||
- Missing SKUs management page
|
|
||||||
- File logging (logs/sync_comenzi_*.log)
|
|
||||||
|
|
||||||
### Phase 5: Production - IN PROGRESS
|
- `uvicorn --reload` **nu functioneaza** pe `/mnt/e/` (WSL2 limitation) — restarta manual
|
||||||
- [x] File logging
|
- Serverul trebuie pornit din **project root** (`gomag/`), nu din `api/`
|
||||||
- [ ] Email notifications (SMTP)
|
- `JSON_OUTPUT_DIR` si `SQLITE_DB_PATH` sunt relative la project root
|
||||||
- [ ] HTTP Basic Auth
|
|
||||||
- [ ] NSSM Windows service
|
|
||||||
|
|||||||
@@ -1,15 +1,75 @@
|
|||||||
# Oracle Database Configuration
|
# =============================================================================
|
||||||
ORACLE_USER=YOUR_ORACLE_USERNAME
|
# GoMag Import Manager - Configurare
|
||||||
ORACLE_PASSWORD=YOUR_ORACLE_PASSWORD
|
# Copiaza in api/.env si completeaza cu datele reale
|
||||||
ORACLE_DSN=YOUR_TNS_CONNECTION_NAME
|
# =============================================================================
|
||||||
TNS_ADMIN=/app
|
|
||||||
INSTANTCLIENTPATH=/opt/oracle/instantclient_21_1
|
|
||||||
|
|
||||||
# Flask Configuration
|
# =============================================================================
|
||||||
FLASK_ENV=development
|
# ORACLE MODE - Alege una din urmatoarele doua optiuni:
|
||||||
FLASK_DEBUG=1
|
# =============================================================================
|
||||||
PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
# Application Settings
|
# THICK MODE (Oracle 10g/11g/12.1+) - Recomandat pentru compatibilitate maxima
|
||||||
APP_PORT=5000
|
# Necesita Oracle Instant Client instalat
|
||||||
LOG_LEVEL=DEBUG
|
INSTANTCLIENTPATH=/opt/oracle/instantclient_21_15
|
||||||
|
|
||||||
|
# THIN MODE (Oracle 12.1+ only) - Fara Instant Client, mai simplu
|
||||||
|
# Comenteaza INSTANTCLIENTPATH de sus si decommenteaza urmatoarea linie:
|
||||||
|
# FORCE_THIN_MODE=true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ORACLE - Credentiale baza de date
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
ORACLE_USER=USER_ORACLE
|
||||||
|
ORACLE_PASSWORD=parola_oracle
|
||||||
|
ORACLE_DSN=TNS_ALIAS
|
||||||
|
|
||||||
|
# Calea absoluta la directorul cu tnsnames.ora
|
||||||
|
# De obicei: directorul api/ al proiectului
|
||||||
|
TNS_ADMIN=/cale/absoluta/la/gomag/api
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# APLICATIE
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
APP_PORT=5003
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CALE FISIERE (relative la project root - directorul gomag/)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# JSON-uri descarcate de VFP
|
||||||
|
JSON_OUTPUT_DIR=vfp/output
|
||||||
|
|
||||||
|
# SQLite tracking DB
|
||||||
|
SQLITE_DB_PATH=api/data/import.db
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ROA - Setari import comenzi (din vfp/settings.ini sectiunea [ROA])
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Politica de pret
|
||||||
|
ID_POL=39
|
||||||
|
|
||||||
|
# Gestiune implicita
|
||||||
|
ID_GESTIUNE=0
|
||||||
|
|
||||||
|
# Sectie implicita
|
||||||
|
ID_SECTIE=6
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SMTP - Notificari email (optional)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# SMTP_HOST=smtp.gmail.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=email@exemplu.com
|
||||||
|
# SMTP_PASSWORD=parola_app
|
||||||
|
# SMTP_TO=destinatar@exemplu.com
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AUTH - HTTP Basic Auth pentru dashboard (optional)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# API_USERNAME=admin
|
||||||
|
# API_PASSWORD=parola_sigura
|
||||||
|
|||||||
@@ -91,7 +91,11 @@ CREATE TABLE IF NOT EXISTS orders (
|
|||||||
times_skipped INTEGER DEFAULT 0,
|
times_skipped INTEGER DEFAULT 0,
|
||||||
first_seen_at TEXT DEFAULT (datetime('now')),
|
first_seen_at TEXT DEFAULT (datetime('now')),
|
||||||
last_sync_run_id TEXT REFERENCES sync_runs(run_id),
|
last_sync_run_id TEXT REFERENCES sync_runs(run_id),
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
shipping_name TEXT,
|
||||||
|
billing_name TEXT,
|
||||||
|
payment_method TEXT,
|
||||||
|
delivery_method TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date);
|
CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date);
|
||||||
@@ -195,18 +199,15 @@ def init_sqlite():
|
|||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
# Copy latest record per order_number into orders
|
# Copy latest record per order_number into orders
|
||||||
|
# Note: old import_orders didn't have address columns — those stay NULL
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO orders
|
INSERT INTO orders
|
||||||
(order_number, order_date, customer_name, status,
|
(order_number, order_date, customer_name, status,
|
||||||
id_comanda, id_partener, id_adresa_facturare, id_adresa_livrare,
|
id_comanda, id_partener, error_message, missing_skus,
|
||||||
error_message, missing_skus, items_count, last_sync_run_id)
|
items_count, last_sync_run_id)
|
||||||
SELECT io.order_number, io.order_date, io.customer_name, io.status,
|
SELECT io.order_number, io.order_date, io.customer_name, io.status,
|
||||||
io.id_comanda, io.id_partener,
|
io.id_comanda, io.id_partener, io.error_message, io.missing_skus,
|
||||||
CASE WHEN io.order_number IN (SELECT order_number FROM import_orders WHERE id_adresa_facturare IS NOT NULL) THEN
|
io.items_count, io.sync_run_id
|
||||||
(SELECT id_adresa_facturare FROM import_orders WHERE order_number = io.order_number AND id_adresa_facturare IS NOT NULL LIMIT 1) ELSE NULL END,
|
|
||||||
CASE WHEN io.order_number IN (SELECT order_number FROM import_orders WHERE id_adresa_livrare IS NOT NULL) THEN
|
|
||||||
(SELECT id_adresa_livrare FROM import_orders WHERE order_number = io.order_number AND id_adresa_livrare IS NOT NULL LIMIT 1) ELSE NULL END,
|
|
||||||
io.error_message, io.missing_skus, io.items_count, io.sync_run_id
|
|
||||||
FROM import_orders io
|
FROM import_orders io
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT order_number, MAX(id) as max_id
|
SELECT order_number, MAX(id) as max_id
|
||||||
@@ -272,6 +273,19 @@ def init_sqlite():
|
|||||||
conn.execute("ALTER TABLE sync_runs ADD COLUMN error_message TEXT")
|
conn.execute("ALTER TABLE sync_runs ADD COLUMN error_message TEXT")
|
||||||
logger.info("Migrated sync_runs: added column error_message")
|
logger.info("Migrated sync_runs: added column error_message")
|
||||||
|
|
||||||
|
# Migrate orders: add shipping/billing/payment/delivery columns
|
||||||
|
cursor = conn.execute("PRAGMA table_info(orders)")
|
||||||
|
order_cols = {row[1] for row in cursor.fetchall()}
|
||||||
|
for col, typedef in [
|
||||||
|
("shipping_name", "TEXT"),
|
||||||
|
("billing_name", "TEXT"),
|
||||||
|
("payment_method", "TEXT"),
|
||||||
|
("delivery_method", "TEXT"),
|
||||||
|
]:
|
||||||
|
if col not in order_cols:
|
||||||
|
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
|
||||||
|
logger.info(f"Migrated orders: added column {col}")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Migration check failed: {e}")
|
logger.warning(f"Migration check failed: {e}")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter, Query, Request, UploadFile, File
|
from fastapi import APIRouter, Query, Request, UploadFile, File
|
||||||
from fastapi.responses import StreamingResponse, HTMLResponse
|
from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi import HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -49,15 +50,19 @@ async def mappings_page(request: Request):
|
|||||||
@router.get("/api/mappings")
|
@router.get("/api/mappings")
|
||||||
async def list_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
async def list_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
||||||
sort_by: str = "sku", sort_dir: str = "asc",
|
sort_by: str = "sku", sort_dir: str = "asc",
|
||||||
show_deleted: bool = False):
|
show_deleted: bool = False, pct_filter: str = None):
|
||||||
result = mapping_service.get_mappings(search=search, page=page, per_page=per_page,
|
result = mapping_service.get_mappings(search=search, page=page, per_page=per_page,
|
||||||
sort_by=sort_by, sort_dir=sort_dir,
|
sort_by=sort_by, sort_dir=sort_dir,
|
||||||
show_deleted=show_deleted)
|
show_deleted=show_deleted,
|
||||||
|
pct_filter=pct_filter)
|
||||||
# Merge product names from web_products (R4)
|
# Merge product names from web_products (R4)
|
||||||
skus = list({m["sku"] for m in result.get("mappings", [])})
|
skus = list({m["sku"] for m in result.get("mappings", [])})
|
||||||
product_names = await sqlite_service.get_web_products_batch(skus)
|
product_names = await sqlite_service.get_web_products_batch(skus)
|
||||||
for m in result.get("mappings", []):
|
for m in result.get("mappings", []):
|
||||||
m["product_name"] = product_names.get(m["sku"], "")
|
m["product_name"] = product_names.get(m["sku"], "")
|
||||||
|
# Ensure counts key is always present
|
||||||
|
if "counts" not in result:
|
||||||
|
result["counts"] = {"total": 0, "complete": 0, "incomplete": 0}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@router.post("/api/mappings")
|
@router.post("/api/mappings")
|
||||||
@@ -67,6 +72,12 @@ async def create_mapping(data: MappingCreate):
|
|||||||
# Mark SKU as resolved in missing_skus tracking
|
# Mark SKU as resolved in missing_skus tracking
|
||||||
await sqlite_service.resolve_missing_sku(data.sku)
|
await sqlite_service.resolve_missing_sku(data.sku)
|
||||||
return {"success": True, **result}
|
return {"success": True, **result}
|
||||||
|
except HTTPException as e:
|
||||||
|
can_restore = e.headers.get("X-Can-Restore") == "true" if e.headers else False
|
||||||
|
resp: dict = {"error": e.detail}
|
||||||
|
if can_restore:
|
||||||
|
resp["can_restore"] = True
|
||||||
|
return JSONResponse(status_code=e.status_code, content=resp)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from datetime import datetime
|
|||||||
from fastapi import APIRouter, Request, BackgroundTasks
|
from fastapi import APIRouter, Request, BackgroundTasks
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from starlette.responses import StreamingResponse
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -21,35 +20,6 @@ class ScheduleConfig(BaseModel):
|
|||||||
interval_minutes: int = 5
|
interval_minutes: int = 5
|
||||||
|
|
||||||
|
|
||||||
# SSE streaming endpoint
|
|
||||||
@router.get("/api/sync/stream")
|
|
||||||
async def sync_stream(request: Request):
|
|
||||||
"""SSE stream for real-time sync progress."""
|
|
||||||
q = sync_service.subscribe()
|
|
||||||
|
|
||||||
async def event_generator():
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
# Check if client disconnected
|
|
||||||
if await request.is_disconnected():
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
event = await asyncio.wait_for(q.get(), timeout=15.0)
|
|
||||||
yield f"data: {json.dumps(event)}\n\n"
|
|
||||||
if event.get("type") in ("completed", "failed"):
|
|
||||||
break
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
|
||||||
finally:
|
|
||||||
sync_service.unsubscribe(q)
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
event_generator(),
|
|
||||||
media_type="text/event-stream",
|
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# API endpoints
|
# API endpoints
|
||||||
@router.post("/api/sync/start")
|
@router.post("/api/sync/start")
|
||||||
async def start_sync(background_tasks: BackgroundTasks):
|
async def start_sync(background_tasks: BackgroundTasks):
|
||||||
@@ -72,10 +42,68 @@ async def stop_sync():
|
|||||||
|
|
||||||
@router.get("/api/sync/status")
|
@router.get("/api/sync/status")
|
||||||
async def sync_status():
|
async def sync_status():
|
||||||
"""Get current sync status."""
|
"""Get current sync status with progress details and last_run info."""
|
||||||
status = await sync_service.get_sync_status()
|
status = await sync_service.get_sync_status()
|
||||||
stats = await sqlite_service.get_dashboard_stats()
|
|
||||||
return {**status, "stats": stats}
|
# Build last_run from most recent completed/failed sync_runs row
|
||||||
|
current_run_id = status.get("run_id")
|
||||||
|
last_run = None
|
||||||
|
try:
|
||||||
|
from ..database import get_sqlite
|
||||||
|
db = await get_sqlite()
|
||||||
|
try:
|
||||||
|
if current_run_id:
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT * FROM sync_runs
|
||||||
|
WHERE status IN ('completed', 'failed') AND run_id != ?
|
||||||
|
ORDER BY started_at DESC LIMIT 1
|
||||||
|
""", (current_run_id,))
|
||||||
|
else:
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT * FROM sync_runs
|
||||||
|
WHERE status IN ('completed', 'failed')
|
||||||
|
ORDER BY started_at DESC LIMIT 1
|
||||||
|
""")
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
row_dict = dict(row)
|
||||||
|
duration_seconds = None
|
||||||
|
if row_dict.get("started_at") and row_dict.get("finished_at"):
|
||||||
|
try:
|
||||||
|
dt_start = datetime.fromisoformat(row_dict["started_at"])
|
||||||
|
dt_end = datetime.fromisoformat(row_dict["finished_at"])
|
||||||
|
duration_seconds = int((dt_end - dt_start).total_seconds())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
last_run = {
|
||||||
|
"run_id": row_dict.get("run_id"),
|
||||||
|
"started_at": row_dict.get("started_at"),
|
||||||
|
"finished_at": row_dict.get("finished_at"),
|
||||||
|
"duration_seconds": duration_seconds,
|
||||||
|
"status": row_dict.get("status"),
|
||||||
|
"imported": row_dict.get("imported", 0),
|
||||||
|
"skipped": row_dict.get("skipped", 0),
|
||||||
|
"errors": row_dict.get("errors", 0),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Ensure all expected keys are present
|
||||||
|
result = {
|
||||||
|
"status": status.get("status", "idle"),
|
||||||
|
"run_id": status.get("run_id"),
|
||||||
|
"started_at": status.get("started_at"),
|
||||||
|
"finished_at": status.get("finished_at"),
|
||||||
|
"phase": status.get("phase"),
|
||||||
|
"phase_text": status.get("phase_text"),
|
||||||
|
"progress_current": status.get("progress_current", 0),
|
||||||
|
"progress_total": status.get("progress_total", 0),
|
||||||
|
"counts": status.get("counts", {"imported": 0, "skipped": 0, "errors": 0}),
|
||||||
|
"last_run": last_run,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/sync/history")
|
@router.get("/api/sync/history")
|
||||||
@@ -277,8 +305,13 @@ async def order_detail(order_number: str):
|
|||||||
async def dashboard_orders(page: int = 1, per_page: int = 50,
|
async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||||
search: str = "", status: str = "all",
|
search: str = "", status: str = "all",
|
||||||
sort_by: str = "order_date", sort_dir: str = "desc",
|
sort_by: str = "order_date", sort_dir: str = "desc",
|
||||||
period_days: int = 7):
|
period_days: int = 7,
|
||||||
"""Get orders for dashboard, enriched with invoice data. period_days=0 means all time."""
|
period_start: str = "", period_end: str = ""):
|
||||||
|
"""Get orders for dashboard, enriched with invoice data.
|
||||||
|
|
||||||
|
period_days=0 with period_start/period_end uses custom date range.
|
||||||
|
period_days=0 without dates means all time.
|
||||||
|
"""
|
||||||
is_uninvoiced_filter = (status == "UNINVOICED")
|
is_uninvoiced_filter = (status == "UNINVOICED")
|
||||||
|
|
||||||
# For UNINVOICED: fetch all IMPORTED orders, then filter post-invoice-check
|
# For UNINVOICED: fetch all IMPORTED orders, then filter post-invoice-check
|
||||||
@@ -289,7 +322,9 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
|||||||
result = await sqlite_service.get_orders(
|
result = await sqlite_service.get_orders(
|
||||||
page=fetch_page, per_page=fetch_per_page, search=search,
|
page=fetch_page, per_page=fetch_per_page, search=search,
|
||||||
status_filter=fetch_status, sort_by=sort_by, sort_dir=sort_dir,
|
status_filter=fetch_status, sort_by=sort_by, sort_dir=sort_dir,
|
||||||
period_days=period_days
|
period_days=period_days,
|
||||||
|
period_start=period_start if period_days == 0 else "",
|
||||||
|
period_end=period_end if period_days == 0 else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Enrich imported orders with invoice data from Oracle
|
# Enrich imported orders with invoice data from Oracle
|
||||||
@@ -309,12 +344,22 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
|||||||
else:
|
else:
|
||||||
o["invoice"] = None
|
o["invoice"] = None
|
||||||
|
|
||||||
# Count uninvoiced (IMPORTED without invoice)
|
# Add shipping/billing name fields + is_different_person flag
|
||||||
uninvoiced_count = sum(
|
s_name = o.get("shipping_name") or ""
|
||||||
|
b_name = o.get("billing_name") or ""
|
||||||
|
o["shipping_name"] = s_name
|
||||||
|
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
|
1 for o in all_orders
|
||||||
if o.get("status") == "IMPORTED" and not o.get("invoice")
|
if o.get("status") == "IMPORTED" and not o.get("invoice")
|
||||||
)
|
)
|
||||||
result["counts"]["uninvoiced"] = uninvoiced_count
|
# Use counts from sqlite_service (already period-scoped) and add nefacturate
|
||||||
|
counts = result.get("counts", {})
|
||||||
|
counts["nefacturate"] = nefacturate_count
|
||||||
|
counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0))
|
||||||
|
|
||||||
# For UNINVOICED filter: apply server-side filtering + pagination
|
# For UNINVOICED filter: apply server-side filtering + pagination
|
||||||
if is_uninvoiced_filter:
|
if is_uninvoiced_filter:
|
||||||
@@ -327,7 +372,16 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
|||||||
result["per_page"] = per_page
|
result["per_page"] = per_page
|
||||||
result["pages"] = (total + per_page - 1) // per_page if total > 0 else 0
|
result["pages"] = (total + per_page - 1) // per_page if total > 0 else 0
|
||||||
|
|
||||||
return result
|
# Reshape response
|
||||||
|
return {
|
||||||
|
"orders": result["orders"],
|
||||||
|
"pagination": {
|
||||||
|
"page": result.get("page", page),
|
||||||
|
"per_page": result.get("per_page", per_page),
|
||||||
|
"total_pages": result.get("pages", 0),
|
||||||
|
},
|
||||||
|
"counts": counts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/sync/schedule")
|
@router.put("/api/sync/schedule")
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ async def scan_and_validate():
|
|||||||
orders, json_count = order_reader.read_json_orders()
|
orders, json_count = order_reader.read_json_orders()
|
||||||
|
|
||||||
if not orders:
|
if not orders:
|
||||||
return {"orders": 0, "json_files": json_count, "skus": {}, "message": "No orders found"}
|
return {
|
||||||
|
"orders": 0, "json_files": json_count, "skus": {}, "message": "No orders found",
|
||||||
|
"total_skus_scanned": 0, "new_missing": 0, "auto_resolved": 0, "unchanged": 0,
|
||||||
|
}
|
||||||
|
|
||||||
all_skus = order_reader.get_all_skus(orders)
|
all_skus = order_reader.get_all_skus(orders)
|
||||||
result = validation_service.validate_skus(all_skus)
|
result = validation_service.validate_skus(all_skus)
|
||||||
@@ -37,6 +40,7 @@ async def scan_and_validate():
|
|||||||
if customer not in sku_context[sku]["customers"]:
|
if customer not in sku_context[sku]["customers"]:
|
||||||
sku_context[sku]["customers"].append(customer)
|
sku_context[sku]["customers"].append(customer)
|
||||||
|
|
||||||
|
new_missing = 0
|
||||||
for sku in result["missing"]:
|
for sku in result["missing"]:
|
||||||
# Find product name from orders
|
# Find product name from orders
|
||||||
product_name = ""
|
product_name = ""
|
||||||
@@ -49,13 +53,19 @@ async def scan_and_validate():
|
|||||||
break
|
break
|
||||||
|
|
||||||
ctx = sku_context.get(sku, {})
|
ctx = sku_context.get(sku, {})
|
||||||
await sqlite_service.track_missing_sku(
|
tracked = await sqlite_service.track_missing_sku(
|
||||||
sku=sku,
|
sku=sku,
|
||||||
product_name=product_name,
|
product_name=product_name,
|
||||||
order_count=len(ctx.get("order_numbers", [])),
|
order_count=len(ctx.get("order_numbers", [])),
|
||||||
order_numbers=json.dumps(ctx.get("order_numbers", [])),
|
order_numbers=json.dumps(ctx.get("order_numbers", [])),
|
||||||
customers=json.dumps(ctx.get("customers", []))
|
customers=json.dumps(ctx.get("customers", []))
|
||||||
)
|
)
|
||||||
|
if tracked:
|
||||||
|
new_missing += 1
|
||||||
|
|
||||||
|
total_skus_scanned = len(all_skus)
|
||||||
|
new_missing_count = len(result["missing"])
|
||||||
|
unchanged = total_skus_scanned - new_missing_count
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"json_files": json_count,
|
"json_files": json_count,
|
||||||
@@ -64,6 +74,11 @@ async def scan_and_validate():
|
|||||||
"importable": len(importable),
|
"importable": len(importable),
|
||||||
"skipped": len(skipped),
|
"skipped": len(skipped),
|
||||||
"new_orders": len(new_orders),
|
"new_orders": len(new_orders),
|
||||||
|
# Fields consumed by the rescan progress banner in missing_skus.html
|
||||||
|
"total_skus_scanned": total_skus_scanned,
|
||||||
|
"new_missing": new_missing_count,
|
||||||
|
"auto_resolved": 0,
|
||||||
|
"unchanged": unchanged,
|
||||||
"skus": {
|
"skus": {
|
||||||
"mapped": len(result["mapped"]),
|
"mapped": len(result["mapped"]),
|
||||||
"direct": len(result["direct"]),
|
"direct": len(result["direct"]),
|
||||||
@@ -88,20 +103,35 @@ async def scan_and_validate():
|
|||||||
async def get_missing_skus(
|
async def get_missing_skus(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
per_page: int = Query(20, ge=1, le=100),
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
resolved: int = Query(0, ge=-1, le=1)
|
resolved: int = Query(0, ge=-1, le=1),
|
||||||
|
search: str = Query(None)
|
||||||
):
|
):
|
||||||
"""Get paginated missing SKUs. resolved=-1 means show all (R10)."""
|
"""Get paginated missing SKUs. resolved=-1 means show all (R10).
|
||||||
result = await sqlite_service.get_missing_skus_paginated(page, per_page, resolved)
|
Optional search filters by sku or product_name."""
|
||||||
# Backward compat: also include 'unresolved' count
|
|
||||||
db = await get_sqlite()
|
db = await get_sqlite()
|
||||||
try:
|
try:
|
||||||
cursor = await db.execute(
|
# Compute counts across ALL records (unfiltered by search)
|
||||||
"SELECT COUNT(*) FROM missing_skus WHERE resolved = 0"
|
cursor = await db.execute("SELECT COUNT(*) FROM missing_skus WHERE resolved = 0")
|
||||||
)
|
unresolved_count = (await cursor.fetchone())[0]
|
||||||
unresolved = (await cursor.fetchone())[0]
|
cursor = await db.execute("SELECT COUNT(*) FROM missing_skus WHERE resolved = 1")
|
||||||
|
resolved_count = (await cursor.fetchone())[0]
|
||||||
|
cursor = await db.execute("SELECT COUNT(*) FROM missing_skus")
|
||||||
|
total_count = (await cursor.fetchone())[0]
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
result["unresolved"] = unresolved
|
|
||||||
|
counts = {
|
||||||
|
"total": total_count,
|
||||||
|
"unresolved": unresolved_count,
|
||||||
|
"resolved": resolved_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await sqlite_service.get_missing_skus_paginated(page, per_page, resolved, search=search)
|
||||||
|
# Backward compat
|
||||||
|
result["unresolved"] = unresolved_count
|
||||||
|
result["counts"] = counts
|
||||||
|
# rename key for JS consistency
|
||||||
|
result["skus"] = result.get("missing_skus", [])
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@router.get("/missing-skus-csv")
|
@router.get("/missing-skus-csv")
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
|
|||||||
raise RuntimeError("Oracle pool not initialized")
|
raise RuntimeError("Oracle pool not initialized")
|
||||||
with database.pool.acquire() as conn:
|
with database.pool.acquire() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
# Step 1: Process partner
|
# Step 1: Process partner — use shipping person data for name
|
||||||
id_partener = cur.var(oracledb.DB_TYPE_NUMBER)
|
id_partener = cur.var(oracledb.DB_TYPE_NUMBER)
|
||||||
|
|
||||||
if order.billing.is_company:
|
if order.billing.is_company:
|
||||||
@@ -114,6 +114,12 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
|
|||||||
cod_fiscal = clean_web_text(order.billing.company_code) or None
|
cod_fiscal = clean_web_text(order.billing.company_code) or None
|
||||||
registru = clean_web_text(order.billing.company_reg) or None
|
registru = clean_web_text(order.billing.company_reg) or None
|
||||||
is_pj = 1
|
is_pj = 1
|
||||||
|
else:
|
||||||
|
# Use shipping person for partner name (person on shipping label)
|
||||||
|
if order.shipping and (order.shipping.lastname or order.shipping.firstname):
|
||||||
|
denumire = clean_web_text(
|
||||||
|
f"{order.shipping.lastname} {order.shipping.firstname}"
|
||||||
|
).upper()
|
||||||
else:
|
else:
|
||||||
denumire = clean_web_text(
|
denumire = clean_web_text(
|
||||||
f"{order.billing.lastname} {order.billing.firstname}"
|
f"{order.billing.lastname} {order.billing.firstname}"
|
||||||
@@ -133,7 +139,52 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
|
|||||||
|
|
||||||
result["id_partener"] = int(partner_id)
|
result["id_partener"] = int(partner_id)
|
||||||
|
|
||||||
# Step 2: Process billing address
|
# Determine if billing and shipping are different persons
|
||||||
|
billing_name = clean_web_text(
|
||||||
|
f"{order.billing.lastname} {order.billing.firstname}"
|
||||||
|
).strip().upper()
|
||||||
|
shipping_name = ""
|
||||||
|
if order.shipping:
|
||||||
|
shipping_name = clean_web_text(
|
||||||
|
f"{order.shipping.lastname} {order.shipping.firstname}"
|
||||||
|
).strip().upper()
|
||||||
|
different_person = bool(
|
||||||
|
shipping_name and billing_name and shipping_name != billing_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 2: Process shipping address (primary — person on shipping label)
|
||||||
|
# Use shipping person phone/email for partner contact
|
||||||
|
shipping_phone = ""
|
||||||
|
shipping_email = ""
|
||||||
|
if order.shipping:
|
||||||
|
shipping_phone = order.shipping.phone or ""
|
||||||
|
shipping_email = order.shipping.email or ""
|
||||||
|
if not shipping_phone:
|
||||||
|
shipping_phone = order.billing.phone or ""
|
||||||
|
if not shipping_email:
|
||||||
|
shipping_email = order.billing.email or ""
|
||||||
|
|
||||||
|
addr_livr_id = None
|
||||||
|
if order.shipping:
|
||||||
|
id_adresa_livr = cur.var(oracledb.DB_TYPE_NUMBER)
|
||||||
|
shipping_addr = format_address_for_oracle(
|
||||||
|
order.shipping.address, order.shipping.city,
|
||||||
|
order.shipping.region
|
||||||
|
)
|
||||||
|
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
|
||||||
|
partner_id, shipping_addr,
|
||||||
|
shipping_phone,
|
||||||
|
shipping_email,
|
||||||
|
id_adresa_livr
|
||||||
|
])
|
||||||
|
addr_livr_id = id_adresa_livr.getvalue()
|
||||||
|
|
||||||
|
# Step 3: Process billing address
|
||||||
|
if different_person:
|
||||||
|
# Different person: use shipping address for BOTH billing and shipping in ROA
|
||||||
|
addr_fact_id = addr_livr_id
|
||||||
|
else:
|
||||||
|
# Same person: use billing address as-is
|
||||||
id_adresa_fact = cur.var(oracledb.DB_TYPE_NUMBER)
|
id_adresa_fact = cur.var(oracledb.DB_TYPE_NUMBER)
|
||||||
billing_addr = format_address_for_oracle(
|
billing_addr = format_address_for_oracle(
|
||||||
order.billing.address, order.billing.city, order.billing.region
|
order.billing.address, order.billing.city, order.billing.region
|
||||||
@@ -146,22 +197,6 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
|
|||||||
])
|
])
|
||||||
addr_fact_id = id_adresa_fact.getvalue()
|
addr_fact_id = id_adresa_fact.getvalue()
|
||||||
|
|
||||||
# Step 3: Process shipping address (if different)
|
|
||||||
addr_livr_id = None
|
|
||||||
if order.shipping:
|
|
||||||
id_adresa_livr = cur.var(oracledb.DB_TYPE_NUMBER)
|
|
||||||
shipping_addr = format_address_for_oracle(
|
|
||||||
order.shipping.address, order.shipping.city,
|
|
||||||
order.shipping.region
|
|
||||||
)
|
|
||||||
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
|
|
||||||
partner_id, shipping_addr,
|
|
||||||
order.shipping.phone or "",
|
|
||||||
order.shipping.email or "",
|
|
||||||
id_adresa_livr
|
|
||||||
])
|
|
||||||
addr_livr_id = id_adresa_livr.getvalue()
|
|
||||||
|
|
||||||
if addr_fact_id is not None:
|
if addr_fact_id is not None:
|
||||||
result["id_adresa_facturare"] = int(addr_fact_id)
|
result["id_adresa_facturare"] = int(addr_fact_id)
|
||||||
if addr_livr_id is not None:
|
if addr_livr_id is not None:
|
||||||
|
|||||||
@@ -9,8 +9,14 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
||||||
sort_by: str = "sku", sort_dir: str = "asc",
|
sort_by: str = "sku", sort_dir: str = "asc",
|
||||||
show_deleted: bool = False):
|
show_deleted: bool = False, pct_filter: str = None):
|
||||||
"""Get paginated mappings with optional search and sorting."""
|
"""Get paginated mappings with optional search, sorting, and pct_filter.
|
||||||
|
|
||||||
|
pct_filter values:
|
||||||
|
'complete' – only SKU groups where sum(procent_pret for active rows) == 100
|
||||||
|
'incomplete' – only SKU groups where sum < 100
|
||||||
|
None / 'all' – no filter
|
||||||
|
"""
|
||||||
if database.pool is None:
|
if database.pool is None:
|
||||||
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
||||||
|
|
||||||
@@ -49,16 +55,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
|||||||
params["search"] = search
|
params["search"] = search
|
||||||
where = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
|
where = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
|
||||||
|
|
||||||
# Count total
|
# Fetch ALL matching rows (no pagination yet — we need to group by SKU first)
|
||||||
count_sql = f"""
|
|
||||||
SELECT COUNT(*) FROM ARTICOLE_TERTI at
|
|
||||||
LEFT JOIN nom_articole na ON na.codmat = at.codmat
|
|
||||||
{where}
|
|
||||||
"""
|
|
||||||
cur.execute(count_sql, params)
|
|
||||||
total = cur.fetchone()[0]
|
|
||||||
|
|
||||||
# Get page
|
|
||||||
data_sql = f"""
|
data_sql = f"""
|
||||||
SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa,
|
SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa,
|
||||||
at.procent_pret, at.activ, at.sters,
|
at.procent_pret, at.activ, at.sters,
|
||||||
@@ -67,30 +64,114 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
|||||||
LEFT JOIN nom_articole na ON na.codmat = at.codmat
|
LEFT JOIN nom_articole na ON na.codmat = at.codmat
|
||||||
{where}
|
{where}
|
||||||
ORDER BY {order_clause}
|
ORDER BY {order_clause}
|
||||||
OFFSET :offset ROWS FETCH NEXT :per_page ROWS ONLY
|
|
||||||
"""
|
"""
|
||||||
params["offset"] = offset
|
|
||||||
params["per_page"] = per_page
|
|
||||||
cur.execute(data_sql, params)
|
cur.execute(data_sql, params)
|
||||||
|
|
||||||
columns = [col[0].lower() for col in cur.description]
|
columns = [col[0].lower() for col in cur.description]
|
||||||
rows = [dict(zip(columns, row)) for row in cur.fetchall()]
|
all_rows = [dict(zip(columns, row)) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
# Group by SKU and compute pct_total for each group
|
||||||
|
from collections import OrderedDict
|
||||||
|
groups = OrderedDict()
|
||||||
|
for row in all_rows:
|
||||||
|
sku = row["sku"]
|
||||||
|
if sku not in groups:
|
||||||
|
groups[sku] = []
|
||||||
|
groups[sku].append(row)
|
||||||
|
|
||||||
|
# Compute counts across ALL groups (before pct_filter)
|
||||||
|
total_skus = len(groups)
|
||||||
|
complete_skus = 0
|
||||||
|
incomplete_skus = 0
|
||||||
|
for sku, rows in groups.items():
|
||||||
|
pct_total = sum(
|
||||||
|
(r["procent_pret"] or 0)
|
||||||
|
for r in rows
|
||||||
|
if r.get("activ") == 1
|
||||||
|
)
|
||||||
|
if pct_total >= 99.99:
|
||||||
|
complete_skus += 1
|
||||||
|
else:
|
||||||
|
incomplete_skus += 1
|
||||||
|
|
||||||
|
counts = {
|
||||||
|
"total": total_skus,
|
||||||
|
"complete": complete_skus,
|
||||||
|
"incomplete": incomplete_skus,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Apply pct_filter
|
||||||
|
if pct_filter in ("complete", "incomplete"):
|
||||||
|
filtered_groups = {}
|
||||||
|
for sku, rows in groups.items():
|
||||||
|
pct_total = sum(
|
||||||
|
(r["procent_pret"] or 0)
|
||||||
|
for r in rows
|
||||||
|
if r.get("activ") == 1
|
||||||
|
)
|
||||||
|
is_complete = pct_total >= 99.99
|
||||||
|
if pct_filter == "complete" and is_complete:
|
||||||
|
filtered_groups[sku] = rows
|
||||||
|
elif pct_filter == "incomplete" and not is_complete:
|
||||||
|
filtered_groups[sku] = rows
|
||||||
|
groups = filtered_groups
|
||||||
|
|
||||||
|
# Flatten back to rows for pagination (paginate by raw row count)
|
||||||
|
filtered_rows = [row for rows in groups.values() for row in rows]
|
||||||
|
total = len(filtered_rows)
|
||||||
|
page_rows = filtered_rows[offset: offset + per_page]
|
||||||
|
|
||||||
|
# Attach pct_total and is_complete to each row for the renderer
|
||||||
|
# Re-compute per visible group
|
||||||
|
sku_pct = {}
|
||||||
|
for sku, rows in groups.items():
|
||||||
|
pct_total = sum(
|
||||||
|
(r["procent_pret"] or 0)
|
||||||
|
for r in rows
|
||||||
|
if r.get("activ") == 1
|
||||||
|
)
|
||||||
|
sku_pct[sku] = {"pct_total": pct_total, "is_complete": pct_total >= 99.99}
|
||||||
|
|
||||||
|
for row in page_rows:
|
||||||
|
meta = sku_pct.get(row["sku"], {"pct_total": 0, "is_complete": False})
|
||||||
|
row["pct_total"] = meta["pct_total"]
|
||||||
|
row["is_complete"] = meta["is_complete"]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"mappings": rows,
|
"mappings": page_rows,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": per_page,
|
"per_page": per_page,
|
||||||
"pages": (total + per_page - 1) // per_page
|
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
|
||||||
|
"counts": counts,
|
||||||
}
|
}
|
||||||
|
|
||||||
def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100):
|
def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100):
|
||||||
"""Create a new mapping."""
|
"""Create a new mapping. Returns dict or raises HTTPException on duplicate."""
|
||||||
if database.pool is None:
|
if database.pool is None:
|
||||||
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
||||||
|
|
||||||
with database.pool.acquire() as conn:
|
with database.pool.acquire() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
# Check for active duplicate
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM ARTICOLE_TERTI
|
||||||
|
WHERE sku = :sku AND codmat = :codmat AND NVL(sters, 0) = 0
|
||||||
|
""", {"sku": sku, "codmat": codmat})
|
||||||
|
if cur.fetchone()[0] > 0:
|
||||||
|
raise HTTPException(status_code=409, detail="Maparea SKU-CODMAT există deja")
|
||||||
|
|
||||||
|
# Check for soft-deleted record that could be restored
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM ARTICOLE_TERTI
|
||||||
|
WHERE sku = :sku AND codmat = :codmat AND sters = 1
|
||||||
|
""", {"sku": sku, "codmat": codmat})
|
||||||
|
if cur.fetchone()[0] > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Maparea a fost ștearsă anterior",
|
||||||
|
headers={"X-Can-Restore": "true"}
|
||||||
|
)
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
|
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
|
||||||
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
|
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
|
|||||||
async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
|
async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
|
||||||
customer_name: str, status: str, id_comanda: int = None,
|
customer_name: str, status: str, id_comanda: int = None,
|
||||||
id_partener: int = None, error_message: str = None,
|
id_partener: int = None, error_message: str = None,
|
||||||
missing_skus: list = None, items_count: int = 0):
|
missing_skus: list = None, items_count: int = 0,
|
||||||
|
shipping_name: str = None, billing_name: str = None,
|
||||||
|
payment_method: str = None, delivery_method: str = None):
|
||||||
"""Upsert a single order — one row per order_number, status updated in place."""
|
"""Upsert a single order — one row per order_number, status updated in place."""
|
||||||
db = await get_sqlite()
|
db = await get_sqlite()
|
||||||
try:
|
try:
|
||||||
@@ -52,8 +54,9 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
|
|||||||
INSERT INTO orders
|
INSERT INTO orders
|
||||||
(order_number, order_date, customer_name, status,
|
(order_number, order_date, customer_name, status,
|
||||||
id_comanda, id_partener, error_message, missing_skus, items_count,
|
id_comanda, id_partener, error_message, missing_skus, items_count,
|
||||||
last_sync_run_id)
|
last_sync_run_id, shipping_name, billing_name,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
payment_method, delivery_method)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(order_number) DO UPDATE SET
|
ON CONFLICT(order_number) DO UPDATE SET
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
error_message = excluded.error_message,
|
error_message = excluded.error_message,
|
||||||
@@ -65,11 +68,16 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
|
|||||||
THEN orders.times_skipped + 1
|
THEN orders.times_skipped + 1
|
||||||
ELSE orders.times_skipped END,
|
ELSE orders.times_skipped END,
|
||||||
last_sync_run_id = excluded.last_sync_run_id,
|
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),
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
""", (order_number, order_date, customer_name, status,
|
""", (order_number, order_date, customer_name, status,
|
||||||
id_comanda, id_partener, error_message,
|
id_comanda, id_partener, error_message,
|
||||||
json.dumps(missing_skus) if missing_skus else None,
|
json.dumps(missing_skus) if missing_skus else None,
|
||||||
items_count, sync_run_id))
|
items_count, sync_run_id, shipping_name, billing_name,
|
||||||
|
payment_method, delivery_method))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
@@ -124,35 +132,52 @@ async def resolve_missing_sku(sku: str):
|
|||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
async def get_missing_skus_paginated(page: int = 1, per_page: int = 20, resolved: int = 0):
|
async def get_missing_skus_paginated(page: int = 1, per_page: int = 20,
|
||||||
"""Get paginated missing SKUs. resolved=-1 means show all."""
|
resolved: int = 0, search: str = None):
|
||||||
|
"""Get paginated missing SKUs. resolved=-1 means show all.
|
||||||
|
Optional search filters by sku or product_name (LIKE)."""
|
||||||
db = await get_sqlite()
|
db = await get_sqlite()
|
||||||
try:
|
try:
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
if resolved == -1:
|
# Build WHERE clause parts
|
||||||
cursor = await db.execute("SELECT COUNT(*) FROM missing_skus")
|
where_parts = []
|
||||||
total = (await cursor.fetchone())[0]
|
params_count = []
|
||||||
cursor = await db.execute("""
|
params_data = []
|
||||||
SELECT sku, product_name, first_seen, resolved, resolved_at,
|
|
||||||
order_count, order_numbers, customers
|
if resolved != -1:
|
||||||
FROM missing_skus
|
where_parts.append("resolved = ?")
|
||||||
ORDER BY resolved ASC, order_count DESC, first_seen DESC
|
params_count.append(resolved)
|
||||||
LIMIT ? OFFSET ?
|
params_data.append(resolved)
|
||||||
""", (per_page, offset))
|
|
||||||
else:
|
if search:
|
||||||
|
like = f"%{search}%"
|
||||||
|
where_parts.append("(LOWER(sku) LIKE LOWER(?) OR LOWER(COALESCE(product_name,'')) LIKE LOWER(?))")
|
||||||
|
params_count.extend([like, like])
|
||||||
|
params_data.extend([like, like])
|
||||||
|
|
||||||
|
where_clause = ("WHERE " + " AND ".join(where_parts)) if where_parts else ""
|
||||||
|
|
||||||
|
order_clause = (
|
||||||
|
"ORDER BY resolved ASC, order_count DESC, first_seen DESC"
|
||||||
|
if resolved == -1
|
||||||
|
else "ORDER BY order_count DESC, first_seen DESC"
|
||||||
|
)
|
||||||
|
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT COUNT(*) FROM missing_skus WHERE resolved = ?", (resolved,)
|
f"SELECT COUNT(*) FROM missing_skus {where_clause}",
|
||||||
|
params_count
|
||||||
)
|
)
|
||||||
total = (await cursor.fetchone())[0]
|
total = (await cursor.fetchone())[0]
|
||||||
cursor = await db.execute("""
|
|
||||||
|
cursor = await db.execute(f"""
|
||||||
SELECT sku, product_name, first_seen, resolved, resolved_at,
|
SELECT sku, product_name, first_seen, resolved, resolved_at,
|
||||||
order_count, order_numbers, customers
|
order_count, order_numbers, customers
|
||||||
FROM missing_skus
|
FROM missing_skus
|
||||||
WHERE resolved = ?
|
{where_clause}
|
||||||
ORDER BY order_count DESC, first_seen DESC
|
{order_clause}
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
""", (resolved, per_page, offset))
|
""", params_data + [per_page, offset])
|
||||||
|
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
@@ -474,8 +499,13 @@ async def get_run_orders_filtered(run_id: str, status_filter: str = "all",
|
|||||||
async def get_orders(page: int = 1, per_page: int = 50,
|
async def get_orders(page: int = 1, per_page: int = 50,
|
||||||
search: str = "", status_filter: str = "all",
|
search: str = "", status_filter: str = "all",
|
||||||
sort_by: str = "order_date", sort_dir: str = "desc",
|
sort_by: str = "order_date", sort_dir: str = "desc",
|
||||||
period_days: int = 7):
|
period_days: int = 7,
|
||||||
"""Get orders with filters, sorting, and period. period_days=0 means all time."""
|
period_start: str = "", period_end: str = ""):
|
||||||
|
"""Get orders with filters, sorting, and period.
|
||||||
|
|
||||||
|
period_days=0 with period_start/period_end uses custom date range.
|
||||||
|
period_days=0 without dates means all time.
|
||||||
|
"""
|
||||||
db = await get_sqlite()
|
db = await get_sqlite()
|
||||||
try:
|
try:
|
||||||
where_clauses = []
|
where_clauses = []
|
||||||
@@ -484,6 +514,9 @@ async def get_orders(page: int = 1, per_page: int = 50,
|
|||||||
if period_days and period_days > 0:
|
if period_days and period_days > 0:
|
||||||
where_clauses.append("order_date >= date('now', ?)")
|
where_clauses.append("order_date >= date('now', ?)")
|
||||||
params.append(f"-{period_days} days")
|
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])
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
where_clauses.append("(order_number LIKE ? OR customer_name LIKE ?)")
|
where_clauses.append("(order_number LIKE ? OR customer_name LIKE ?)")
|
||||||
|
|||||||
@@ -13,28 +13,10 @@ logger = logging.getLogger(__name__)
|
|||||||
_sync_lock = asyncio.Lock()
|
_sync_lock = asyncio.Lock()
|
||||||
_current_sync = None # dict with run_id, status, progress info
|
_current_sync = None # dict with run_id, status, progress info
|
||||||
|
|
||||||
# SSE subscriber system
|
|
||||||
_subscribers: list[asyncio.Queue] = []
|
|
||||||
|
|
||||||
# In-memory text log buffer per run
|
# In-memory text log buffer per run
|
||||||
_run_logs: dict[str, list[str]] = {}
|
_run_logs: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
|
||||||
def subscribe() -> asyncio.Queue:
|
|
||||||
"""Subscribe to sync events. Returns a queue that will receive event dicts."""
|
|
||||||
q = asyncio.Queue()
|
|
||||||
_subscribers.append(q)
|
|
||||||
return q
|
|
||||||
|
|
||||||
|
|
||||||
def unsubscribe(q: asyncio.Queue):
|
|
||||||
"""Unsubscribe from sync events."""
|
|
||||||
try:
|
|
||||||
_subscribers.remove(q)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _log_line(run_id: str, message: str):
|
def _log_line(run_id: str, message: str):
|
||||||
"""Append a timestamped line to the in-memory log buffer."""
|
"""Append a timestamped line to the in-memory log buffer."""
|
||||||
if run_id not in _run_logs:
|
if run_id not in _run_logs:
|
||||||
@@ -51,13 +33,17 @@ def get_run_text_log(run_id: str) -> str | None:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
async def _emit(event: dict):
|
def _update_progress(phase: str, phase_text: str, current: int = 0, total: int = 0,
|
||||||
"""Push an event to all subscriber queues."""
|
counts: dict = None):
|
||||||
for q in _subscribers:
|
"""Update _current_sync with progress details for polling."""
|
||||||
try:
|
global _current_sync
|
||||||
q.put_nowait(event)
|
if _current_sync is None:
|
||||||
except asyncio.QueueFull:
|
return
|
||||||
pass
|
_current_sync["phase"] = phase
|
||||||
|
_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}
|
||||||
|
|
||||||
|
|
||||||
async def get_sync_status():
|
async def get_sync_status():
|
||||||
@@ -80,7 +66,12 @@ async def prepare_sync(id_pol: int = None, id_sectie: int = None) -> dict:
|
|||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"started_at": datetime.now().isoformat(),
|
"started_at": datetime.now().isoformat(),
|
||||||
"progress": "Starting..."
|
"finished_at": None,
|
||||||
|
"phase": "starting",
|
||||||
|
"phase_text": "Starting...",
|
||||||
|
"progress_current": 0,
|
||||||
|
"progress_total": 0,
|
||||||
|
"counts": {"imported": 0, "skipped": 0, "errors": 0},
|
||||||
}
|
}
|
||||||
return {"run_id": run_id, "status": "starting"}
|
return {"run_id": run_id, "status": "starting"}
|
||||||
|
|
||||||
@@ -100,11 +91,15 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"started_at": datetime.now().isoformat(),
|
"started_at": datetime.now().isoformat(),
|
||||||
"progress": "Reading JSON files..."
|
"finished_at": None,
|
||||||
|
"phase": "reading",
|
||||||
|
"phase_text": "Reading JSON files...",
|
||||||
|
"progress_current": 0,
|
||||||
|
"progress_total": 0,
|
||||||
|
"counts": {"imported": 0, "skipped": 0, "errors": 0},
|
||||||
}
|
}
|
||||||
|
|
||||||
_current_sync["progress"] = "Reading JSON files..."
|
_update_progress("reading", "Reading JSON files...")
|
||||||
await _emit({"type": "phase", "run_id": run_id, "message": "Reading JSON files..."})
|
|
||||||
|
|
||||||
started_dt = datetime.now()
|
started_dt = datetime.now()
|
||||||
_run_logs[run_id] = [
|
_run_logs[run_id] = [
|
||||||
@@ -119,7 +114,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
orders, json_count = order_reader.read_json_orders()
|
orders, json_count = order_reader.read_json_orders()
|
||||||
orders.sort(key=lambda o: o.date or '')
|
orders.sort(key=lambda o: o.date or '')
|
||||||
await sqlite_service.create_sync_run(run_id, json_count)
|
await sqlite_service.create_sync_run(run_id, json_count)
|
||||||
await _emit({"type": "phase", "run_id": run_id, "message": f"Found {len(orders)} orders in {json_count} files"})
|
_update_progress("reading", f"Found {len(orders)} orders in {json_count} files", 0, len(orders))
|
||||||
_log_line(run_id, f"Gasite {len(orders)} comenzi in {json_count} fisiere")
|
_log_line(run_id, f"Gasite {len(orders)} comenzi in {json_count} fisiere")
|
||||||
|
|
||||||
# Populate web_products catalog from all orders (R4)
|
# Populate web_products catalog from all orders (R4)
|
||||||
@@ -131,12 +126,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
if not orders:
|
if not orders:
|
||||||
_log_line(run_id, "Nicio comanda gasita.")
|
_log_line(run_id, "Nicio comanda gasita.")
|
||||||
await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0)
|
await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0)
|
||||||
|
_update_progress("completed", "No orders found")
|
||||||
summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count}
|
summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count}
|
||||||
await _emit({"type": "completed", "run_id": run_id, "summary": summary})
|
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
_current_sync["progress"] = f"Validating {len(orders)} orders..."
|
_update_progress("validation", f"Validating {len(orders)} orders...", 0, len(orders))
|
||||||
await _emit({"type": "phase", "run_id": run_id, "message": f"Validating {len(orders)} orders..."})
|
|
||||||
|
|
||||||
# Step 2a: Find new orders (not yet in Oracle)
|
# Step 2a: Find new orders (not yet in Oracle)
|
||||||
all_order_numbers = [o.number for o in orders]
|
all_order_numbers = [o.number for o in orders]
|
||||||
@@ -149,7 +143,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus)
|
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus)
|
||||||
importable, skipped = validation_service.classify_orders(orders, validation)
|
importable, skipped = validation_service.classify_orders(orders, validation)
|
||||||
|
|
||||||
await _emit({"type": "phase", "run_id": run_id, "message": f"{len(importable)} importable, {len(skipped)} skipped (missing SKUs)"})
|
_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")
|
_log_line(run_id, f"Validare SKU-uri: {len(importable)} importabile, {len(skipped)} nemapate")
|
||||||
|
|
||||||
# Step 2c: Build SKU context from skipped orders
|
# Step 2c: Build SKU context from skipped orders
|
||||||
@@ -189,8 +184,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
logger.info(f"Sync params: ID_POL={id_pol}, ID_SECTIE={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}")
|
_log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}")
|
||||||
if id_pol and importable:
|
if id_pol and importable:
|
||||||
_current_sync["progress"] = "Validating prices..."
|
_update_progress("validation", "Validating prices...", 0, len(importable))
|
||||||
await _emit({"type": "phase", "run_id": run_id, "message": "Validating prices..."})
|
|
||||||
_log_line(run_id, "Validare preturi...")
|
_log_line(run_id, "Validare preturi...")
|
||||||
# Gather all CODMATs from importable orders
|
# Gather all CODMATs from importable orders
|
||||||
all_codmats = set()
|
all_codmats = set()
|
||||||
@@ -216,10 +210,21 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
price_result["missing_price"], id_pol
|
price_result["missing_price"], id_pol
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 3: Record skipped orders + emit events + store items
|
# Step 3: Record skipped orders + store items
|
||||||
|
skipped_count = 0
|
||||||
for order, missing_skus in skipped:
|
for order, missing_skus in skipped:
|
||||||
customer = order.billing.company_name or \
|
skipped_count += 1
|
||||||
f"{order.billing.firstname} {order.billing.lastname}"
|
# 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(
|
await sqlite_service.upsert_order(
|
||||||
sync_run_id=run_id,
|
sync_run_id=run_id,
|
||||||
order_number=order.number,
|
order_number=order.number,
|
||||||
@@ -227,7 +232,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
customer_name=customer,
|
customer_name=customer,
|
||||||
status="SKIPPED",
|
status="SKIPPED",
|
||||||
missing_skus=missing_skus,
|
missing_skus=missing_skus,
|
||||||
items_count=len(order.items)
|
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")
|
await sqlite_service.add_sync_run_order(run_id, order.number, "SKIPPED")
|
||||||
# Store order items with mapping status (R9)
|
# Store order items with mapping status (R9)
|
||||||
@@ -243,28 +252,35 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
})
|
})
|
||||||
await sqlite_service.add_order_items(order.number, order_items_data)
|
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)})")
|
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})")
|
||||||
await _emit({
|
_update_progress("skipped", f"Skipped {skipped_count}/{len(skipped)}: #{order.number} {customer}",
|
||||||
"type": "order_result", "run_id": run_id,
|
0, len(importable),
|
||||||
"order_number": order.number, "customer_name": customer,
|
{"imported": 0, "skipped": skipped_count, "errors": 0})
|
||||||
"order_date": order.date,
|
|
||||||
"status": "SKIPPED", "missing_skus": missing_skus,
|
|
||||||
"items_count": len(order.items), "progress": f"0/{len(importable)}"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Step 4: Import valid orders
|
# Step 4: Import valid orders
|
||||||
imported_count = 0
|
imported_count = 0
|
||||||
error_count = 0
|
error_count = 0
|
||||||
|
|
||||||
for i, order in enumerate(importable):
|
for i, order in enumerate(importable):
|
||||||
progress_str = f"{i+1}/{len(importable)}"
|
# Derive shipping / billing names
|
||||||
_current_sync["progress"] = f"Importing {progress_str}: #{order.number}"
|
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
|
||||||
|
|
||||||
|
_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})
|
||||||
|
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
import_service.import_single_order,
|
import_service.import_single_order,
|
||||||
order, id_pol=id_pol, id_sectie=id_sectie
|
order, id_pol=id_pol, id_sectie=id_sectie
|
||||||
)
|
)
|
||||||
customer = order.billing.company_name or \
|
|
||||||
f"{order.billing.firstname} {order.billing.lastname}"
|
|
||||||
|
|
||||||
# Build order items data for storage (R9)
|
# Build order items data for storage (R9)
|
||||||
order_items_data = []
|
order_items_data = []
|
||||||
@@ -287,7 +303,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
status="IMPORTED",
|
status="IMPORTED",
|
||||||
id_comanda=result["id_comanda"],
|
id_comanda=result["id_comanda"],
|
||||||
id_partener=result["id_partener"],
|
id_partener=result["id_partener"],
|
||||||
items_count=len(order.items)
|
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, "IMPORTED")
|
await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED")
|
||||||
# Store ROA address IDs (R9)
|
# Store ROA address IDs (R9)
|
||||||
@@ -298,13 +318,6 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
)
|
)
|
||||||
await sqlite_service.add_order_items(order.number, order_items_data)
|
await sqlite_service.add_order_items(order.number, order_items_data)
|
||||||
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → IMPORTAT (ID: {result['id_comanda']})")
|
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → IMPORTAT (ID: {result['id_comanda']})")
|
||||||
await _emit({
|
|
||||||
"type": "order_result", "run_id": run_id,
|
|
||||||
"order_number": order.number, "customer_name": customer,
|
|
||||||
"order_date": order.date,
|
|
||||||
"status": "IMPORTED", "items_count": len(order.items),
|
|
||||||
"id_comanda": result["id_comanda"], "progress": progress_str
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
error_count += 1
|
error_count += 1
|
||||||
await sqlite_service.upsert_order(
|
await sqlite_service.upsert_order(
|
||||||
@@ -315,18 +328,15 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
status="ERROR",
|
status="ERROR",
|
||||||
id_partener=result.get("id_partener"),
|
id_partener=result.get("id_partener"),
|
||||||
error_message=result["error"],
|
error_message=result["error"],
|
||||||
items_count=len(order.items)
|
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, "ERROR")
|
await sqlite_service.add_sync_run_order(run_id, order.number, "ERROR")
|
||||||
await sqlite_service.add_order_items(order.number, order_items_data)
|
await sqlite_service.add_order_items(order.number, order_items_data)
|
||||||
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → EROARE: {result['error']}")
|
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → EROARE: {result['error']}")
|
||||||
await _emit({
|
|
||||||
"type": "order_result", "run_id": run_id,
|
|
||||||
"order_number": order.number, "customer_name": customer,
|
|
||||||
"order_date": order.date,
|
|
||||||
"status": "ERROR", "error_message": result["error"],
|
|
||||||
"items_count": len(order.items), "progress": progress_str
|
|
||||||
})
|
|
||||||
|
|
||||||
# Safety: stop if too many errors
|
# Safety: stop if too many errors
|
||||||
if error_count > 10:
|
if error_count > 10:
|
||||||
@@ -351,11 +361,18 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
"missing_skus": len(validation["missing"])
|
"missing_skus": len(validation["missing"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_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})
|
||||||
|
if _current_sync:
|
||||||
|
_current_sync["status"] = status
|
||||||
|
_current_sync["finished_at"] = datetime.now().isoformat()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Sync {run_id} completed: {imported_count} imported, "
|
f"Sync {run_id} completed: {imported_count} imported, "
|
||||||
f"{len(skipped)} skipped, {error_count} errors"
|
f"{len(skipped)} skipped, {error_count} errors"
|
||||||
)
|
)
|
||||||
await _emit({"type": "completed", "run_id": run_id, "summary": summary})
|
|
||||||
|
|
||||||
duration = (datetime.now() - started_dt).total_seconds()
|
duration = (datetime.now() - started_dt).total_seconds()
|
||||||
_log_line(run_id, "")
|
_log_line(run_id, "")
|
||||||
@@ -367,8 +384,10 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
logger.error(f"Sync {run_id} failed: {e}")
|
logger.error(f"Sync {run_id} failed: {e}")
|
||||||
_log_line(run_id, f"EROARE FATALA: {e}")
|
_log_line(run_id, f"EROARE FATALA: {e}")
|
||||||
await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1, error_message=str(e))
|
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["error"] = str(e)
|
_current_sync["error"] = str(e)
|
||||||
await _emit({"type": "failed", "run_id": run_id, "error": str(e)})
|
|
||||||
return {"run_id": run_id, "status": "failed", "error": str(e)}
|
return {"run_id": run_id, "status": "failed", "error": str(e)}
|
||||||
finally:
|
finally:
|
||||||
# Keep _current_sync for 10 seconds so status endpoint can show final result
|
# Keep _current_sync for 10 seconds so status endpoint can show final result
|
||||||
|
|||||||
@@ -302,3 +302,191 @@ tr.mapping-deleted td {
|
|||||||
.cursor-pointer {
|
.cursor-pointer {
|
||||||
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 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.625rem 0;
|
||||||
|
}
|
||||||
|
.filter-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Search input (used in filter bars) ─────────── */
|
||||||
|
.search-input {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
outline: none;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
.search-input:focus { border-color: #1d4ed8; }
|
||||||
|
|
||||||
|
/* ── Tooltip for Client/Cont ─────────────────────── */
|
||||||
|
.tooltip-cont {
|
||||||
|
position: relative;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.tooltip-cont::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 125%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #1f2937;
|
||||||
|
color: #f9fafb;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.tooltip-cont:hover::after { opacity: 1; }
|
||||||
|
|
||||||
|
/* ── Sync card ───────────────────────────────────── */
|
||||||
|
.sync-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.sync-card-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sync-card-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sync-card-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.sync-card-info:hover { background: #f9fafb; }
|
||||||
|
.sync-card-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
background: #eff6ff;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border-top: 1px solid #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pulsing live dot ────────────────────────────── */
|
||||||
|
.sync-live-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #3b82f6;
|
||||||
|
animation: pulse-dot 1.2s ease-in-out infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.4; transform: scale(0.75); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status dot (idle/running/completed/failed) ──── */
|
||||||
|
.sync-status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sync-status-dot.idle { background: #9ca3af; }
|
||||||
|
.sync-status-dot.running { background: #3b82f6; animation: pulse-dot 1.2s ease-in-out infinite; }
|
||||||
|
.sync-status-dot.completed { background: #10b981; }
|
||||||
|
.sync-status-dot.failed { background: #ef4444; }
|
||||||
|
|
||||||
|
/* ── Custom period range inputs ──────────────────── */
|
||||||
|
.period-custom-range {
|
||||||
|
display: none;
|
||||||
|
gap: 0.375rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.period-custom-range.visible { display: flex; }
|
||||||
|
|
||||||
|
/* ── Compact button ──────────────────────────────── */
|
||||||
|
.btn-compact {
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Compact select ──────────────────────────────── */
|
||||||
|
.select-compact {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Result banner ───────────────────────────────── */
|
||||||
|
.result-banner {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
border: 1px solid #6ee7b7;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,138 +1,215 @@
|
|||||||
let refreshInterval = null;
|
// ── State ─────────────────────────────────────────
|
||||||
let dashPage = 1;
|
let dashPage = 1;
|
||||||
let dashFilter = 'all';
|
let dashPerPage = 50;
|
||||||
let dashSearch = '';
|
|
||||||
let dashSortCol = 'order_date';
|
let dashSortCol = 'order_date';
|
||||||
let dashSortDir = 'desc';
|
let dashSortDir = 'desc';
|
||||||
let dashSearchTimeout = null;
|
let dashSearchTimeout = null;
|
||||||
let dashPeriodDays = 7;
|
|
||||||
let currentQmSku = '';
|
let currentQmSku = '';
|
||||||
let currentQmOrderNumber = '';
|
let currentQmOrderNumber = '';
|
||||||
let qmAcTimeout = null;
|
let qmAcTimeout = null;
|
||||||
let syncEventSource = null;
|
|
||||||
|
// Sync polling state
|
||||||
|
let _pollInterval = null;
|
||||||
|
let _lastSyncStatus = null;
|
||||||
|
let _lastRunId = null;
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadSchedulerStatus();
|
loadSchedulerStatus();
|
||||||
loadSyncStatus();
|
|
||||||
loadLastSync();
|
|
||||||
loadDashOrders();
|
loadDashOrders();
|
||||||
refreshInterval = setInterval(() => {
|
startSyncPolling();
|
||||||
loadSyncStatus();
|
wireFilterBar();
|
||||||
}, 10000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Sync Status ──────────────────────────────────
|
// ── Smart Sync Polling ────────────────────────────
|
||||||
|
|
||||||
async function loadSyncStatus() {
|
function startSyncPolling() {
|
||||||
|
if (_pollInterval) clearInterval(_pollInterval);
|
||||||
|
_pollInterval = setInterval(pollSyncStatus, 30000);
|
||||||
|
pollSyncStatus(); // immediate first call
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollSyncStatus() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/sync/status');
|
const data = await fetchJSON('/api/sync/status');
|
||||||
const data = await res.json();
|
updateSyncPanel(data);
|
||||||
|
const isRunning = data.status === 'running';
|
||||||
const badge = document.getElementById('syncStatusBadge');
|
const wasRunning = _lastSyncStatus === 'running';
|
||||||
const status = data.status || 'idle';
|
if (isRunning && !wasRunning) {
|
||||||
badge.textContent = status;
|
// Switched to running — speed up polling
|
||||||
badge.className = 'badge ' + (status === 'running' ? 'bg-primary' : status === 'failed' ? 'bg-danger' : 'bg-secondary');
|
clearInterval(_pollInterval);
|
||||||
|
_pollInterval = setInterval(pollSyncStatus, 3000);
|
||||||
if (status === 'running') {
|
} else if (!isRunning && wasRunning) {
|
||||||
document.getElementById('btnStartSync').classList.add('d-none');
|
// Sync just completed — slow down and refresh orders
|
||||||
document.getElementById('btnStopSync').classList.remove('d-none');
|
clearInterval(_pollInterval);
|
||||||
document.getElementById('syncProgressText').textContent = data.progress || 'Running...';
|
_pollInterval = setInterval(pollSyncStatus, 30000);
|
||||||
} else {
|
loadDashOrders();
|
||||||
document.getElementById('btnStartSync').classList.remove('d-none');
|
|
||||||
document.getElementById('btnStopSync').classList.add('d-none');
|
|
||||||
|
|
||||||
const stats = data.stats || {};
|
|
||||||
if (stats.last_run) {
|
|
||||||
const lr = stats.last_run;
|
|
||||||
const started = lr.started_at ? new Date(lr.started_at).toLocaleString('ro-RO') : '';
|
|
||||||
document.getElementById('syncProgressText').textContent =
|
|
||||||
`Ultimul: ${started} | ${lr.imported || 0} ok, ${lr.skipped || 0} nemapate, ${lr.errors || 0} erori`;
|
|
||||||
} else {
|
|
||||||
document.getElementById('syncProgressText').textContent = '';
|
|
||||||
}
|
}
|
||||||
}
|
_lastSyncStatus = data.status;
|
||||||
} catch (err) {
|
} catch (e) {
|
||||||
console.error('loadSyncStatus error:', err);
|
console.warn('Sync status poll failed:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Last Sync Summary Card ───────────────────────
|
function updateSyncPanel(data) {
|
||||||
|
const dot = document.getElementById('syncStatusDot');
|
||||||
|
const txt = document.getElementById('syncStatusText');
|
||||||
|
const progressArea = document.getElementById('syncProgressArea');
|
||||||
|
const progressText = document.getElementById('syncProgressText');
|
||||||
|
const startBtn = document.getElementById('syncStartBtn');
|
||||||
|
|
||||||
async function loadLastSync() {
|
if (dot) {
|
||||||
|
dot.className = 'sync-status-dot ' + (data.status || 'idle');
|
||||||
|
}
|
||||||
|
const statusLabels = { running: 'A ruleaza...', idle: 'Inactiv', completed: 'Finalizat', failed: 'Eroare' };
|
||||||
|
if (txt) txt.textContent = statusLabels[data.status] || data.status || 'Inactiv';
|
||||||
|
if (startBtn) startBtn.disabled = data.status === 'running';
|
||||||
|
|
||||||
|
// Live progress area
|
||||||
|
if (progressArea) {
|
||||||
|
progressArea.style.display = data.status === 'running' ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
if (progressText && data.phase_text) {
|
||||||
|
progressText.textContent = data.phase_text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last run info
|
||||||
|
const lr = data.last_run;
|
||||||
|
if (lr) {
|
||||||
|
_lastRunId = lr.run_id;
|
||||||
|
const d = document.getElementById('lastSyncDate');
|
||||||
|
const dur = document.getElementById('lastSyncDuration');
|
||||||
|
const cnt = document.getElementById('lastSyncCounts');
|
||||||
|
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 (st) {
|
||||||
|
st.textContent = lr.status === 'completed' ? '\u2713' : '\u2715';
|
||||||
|
st.style.color = lr.status === 'completed' ? '#10b981' : '#ef4444';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire last-sync-row click → journal
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.getElementById('lastSyncRow')?.addEventListener('click', () => {
|
||||||
|
if (_lastRunId) window.location = '/logs?run=' + _lastRunId;
|
||||||
|
});
|
||||||
|
document.getElementById('lastSyncRow')?.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.key === 'Enter' || e.key === ' ') && _lastRunId) {
|
||||||
|
window.location = '/logs?run=' + _lastRunId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sync Controls ─────────────────────────────────
|
||||||
|
|
||||||
|
async function startSync() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/sync/history?per_page=1');
|
const res = await fetch('/api/sync/start', { method: 'POST' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const runs = data.runs || [];
|
if (data.error) {
|
||||||
|
alert(data.error);
|
||||||
if (runs.length === 0) {
|
|
||||||
document.getElementById('lastSyncDate').textContent = '-';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Polling will detect the running state — just speed it up immediately
|
||||||
|
pollSyncStatus();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Eroare: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const r = runs[0];
|
async function stopSync() {
|
||||||
document.getElementById('lastSyncDate').textContent = r.started_at
|
try {
|
||||||
? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})
|
await fetch('/api/sync/stop', { method: 'POST' });
|
||||||
: '-';
|
pollSyncStatus();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Eroare: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
|
async function toggleScheduler() {
|
||||||
document.getElementById('lastSyncStatus').innerHTML = `<span class="badge ${statusClass}">${esc(r.status)}</span>`;
|
const enabled = document.getElementById('schedulerToggle').checked;
|
||||||
document.getElementById('lastSyncImported').textContent = r.imported || 0;
|
const interval = parseInt(document.getElementById('schedulerInterval').value) || 10;
|
||||||
document.getElementById('lastSyncSkipped').textContent = r.skipped || 0;
|
try {
|
||||||
document.getElementById('lastSyncErrors').textContent = r.errors || 0;
|
await fetch('/api/sync/schedule', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled, interval_minutes: interval })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
alert('Eroare scheduler: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (r.started_at && r.finished_at) {
|
async function updateSchedulerInterval() {
|
||||||
const sec = Math.round((new Date(r.finished_at) - new Date(r.started_at)) / 1000);
|
const enabled = document.getElementById('schedulerToggle').checked;
|
||||||
document.getElementById('lastSyncDuration').textContent = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`;
|
if (enabled) {
|
||||||
} else {
|
await toggleScheduler();
|
||||||
document.getElementById('lastSyncDuration').textContent = '-';
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSchedulerStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sync/schedule');
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('schedulerToggle').checked = data.enabled || false;
|
||||||
|
if (data.interval_minutes) {
|
||||||
|
document.getElementById('schedulerInterval').value = data.interval_minutes;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('loadLastSync error:', err);
|
console.error('loadSchedulerStatus error:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dashboard Orders Table ───────────────────────
|
// ── Filter Bar wiring ─────────────────────────────
|
||||||
|
|
||||||
function debounceDashSearch() {
|
function wireFilterBar() {
|
||||||
|
// Period dropdown
|
||||||
|
document.getElementById('periodSelect')?.addEventListener('change', function () {
|
||||||
|
const cr = document.getElementById('customRangeInputs');
|
||||||
|
if (this.value === 'custom') {
|
||||||
|
cr?.classList.add('visible');
|
||||||
|
} else {
|
||||||
|
cr?.classList.remove('visible');
|
||||||
|
dashPage = 1;
|
||||||
|
loadDashOrders();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom range inputs
|
||||||
|
['periodStart', 'periodEnd'].forEach(id => {
|
||||||
|
document.getElementById(id)?.addEventListener('change', () => {
|
||||||
|
const s = document.getElementById('periodStart')?.value;
|
||||||
|
const e = document.getElementById('periodEnd')?.value;
|
||||||
|
if (s && e) { dashPage = 1; loadDashOrders(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status pills
|
||||||
|
document.querySelectorAll('.filter-pill[data-status]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
|
||||||
|
this.classList.add('active');
|
||||||
|
dashPage = 1;
|
||||||
|
loadDashOrders();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search — 300ms debounce
|
||||||
|
document.getElementById('orderSearch')?.addEventListener('input', () => {
|
||||||
clearTimeout(dashSearchTimeout);
|
clearTimeout(dashSearchTimeout);
|
||||||
dashSearchTimeout = setTimeout(() => {
|
dashSearchTimeout = setTimeout(() => {
|
||||||
dashSearch = document.getElementById('dashSearchInput').value;
|
|
||||||
dashPage = 1;
|
dashPage = 1;
|
||||||
loadDashOrders();
|
loadDashOrders();
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
|
||||||
|
|
||||||
function dashFilterOrders(filter) {
|
|
||||||
dashFilter = filter;
|
|
||||||
dashPage = 1;
|
|
||||||
|
|
||||||
// Update button styles
|
|
||||||
const colorMap = {
|
|
||||||
'all': 'primary',
|
|
||||||
'IMPORTED': 'success',
|
|
||||||
'SKIPPED': 'warning',
|
|
||||||
'ERROR': 'danger',
|
|
||||||
'UNINVOICED': 'info'
|
|
||||||
};
|
|
||||||
document.querySelectorAll('#dashFilterBtns button').forEach(btn => {
|
|
||||||
const text = btn.textContent.trim().split(' ')[0];
|
|
||||||
let btnFilter = 'all';
|
|
||||||
if (text === 'Importate') btnFilter = 'IMPORTED';
|
|
||||||
else if (text === 'Omise') btnFilter = 'SKIPPED';
|
|
||||||
else if (text === 'Erori') btnFilter = 'ERROR';
|
|
||||||
else if (text === 'Nefacturate') btnFilter = 'UNINVOICED';
|
|
||||||
|
|
||||||
const color = colorMap[btnFilter] || 'primary';
|
|
||||||
if (btnFilter === filter) {
|
|
||||||
btn.className = `btn btn-sm btn-${color}`;
|
|
||||||
} else {
|
|
||||||
btn.className = `btn btn-sm btn-outline-${color}`;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
loadDashOrders();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dashboard Orders Table ────────────────────────
|
||||||
|
|
||||||
function dashSortBy(col) {
|
function dashSortBy(col) {
|
||||||
if (dashSortCol === col) {
|
if (dashSortCol === col) {
|
||||||
dashSortDir = dashSortDir === 'asc' ? 'desc' : 'asc';
|
dashSortDir = dashSortDir === 'asc' ? 'desc' : 'asc';
|
||||||
@@ -140,8 +217,6 @@ function dashSortBy(col) {
|
|||||||
dashSortCol = col;
|
dashSortCol = col;
|
||||||
dashSortDir = 'asc';
|
dashSortDir = 'asc';
|
||||||
}
|
}
|
||||||
// Update sort icons
|
|
||||||
document.querySelectorAll('#dashOrdersBody').forEach(() => {}); // noop
|
|
||||||
document.querySelectorAll('.sort-icon').forEach(span => {
|
document.querySelectorAll('.sort-icon').forEach(span => {
|
||||||
const c = span.dataset.col;
|
const c = span.dataset.col;
|
||||||
span.textContent = c === dashSortCol ? (dashSortDir === 'asc' ? '\u2191' : '\u2193') : '';
|
span.textContent = c === dashSortCol ? (dashSortDir === 'asc' ? '\u2191' : '\u2193') : '';
|
||||||
@@ -150,39 +225,45 @@ function dashSortBy(col) {
|
|||||||
loadDashOrders();
|
loadDashOrders();
|
||||||
}
|
}
|
||||||
|
|
||||||
function dashSetPeriod(days) {
|
async function loadDashOrders() {
|
||||||
dashPeriodDays = days;
|
const periodVal = document.getElementById('periodSelect')?.value || '7';
|
||||||
dashPage = 1;
|
const params = new URLSearchParams();
|
||||||
document.querySelectorAll('#dashPeriodBtns button').forEach(btn => {
|
|
||||||
const val = parseInt(btn.dataset.days);
|
if (periodVal === 'custom') {
|
||||||
btn.className = val === days
|
const s = document.getElementById('periodStart')?.value;
|
||||||
? 'btn btn-sm btn-secondary'
|
const e = document.getElementById('periodEnd')?.value;
|
||||||
: 'btn btn-sm btn-outline-secondary';
|
if (s && e) {
|
||||||
});
|
params.set('period_start', s);
|
||||||
loadDashOrders();
|
params.set('period_end', e);
|
||||||
|
params.set('period_days', '0');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
params.set('period_days', periodVal);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDashOrders() {
|
const activeStatus = document.querySelector('.filter-pill.active')?.dataset.status;
|
||||||
const params = new URLSearchParams({
|
if (activeStatus && activeStatus !== 'all') params.set('status', activeStatus);
|
||||||
page: dashPage,
|
|
||||||
per_page: 50,
|
const search = document.getElementById('orderSearch')?.value?.trim();
|
||||||
search: dashSearch,
|
if (search) params.set('search', search);
|
||||||
status: dashFilter,
|
|
||||||
sort_by: dashSortCol,
|
params.set('page', dashPage);
|
||||||
sort_dir: dashSortDir,
|
params.set('per_page', dashPerPage);
|
||||||
period_days: dashPeriodDays
|
params.set('sort_by', dashSortCol);
|
||||||
});
|
params.set('sort_dir', dashSortDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/dashboard/orders?${params}`);
|
const res = await fetch(`/api/dashboard/orders?${params}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
const counts = data.counts || {};
|
// Update filter-pill badge counts
|
||||||
document.getElementById('dashCountAll').textContent = counts.total || 0;
|
const c = data.counts || {};
|
||||||
document.getElementById('dashCountImported').textContent = counts.imported || 0;
|
const el = (id) => document.getElementById(id);
|
||||||
document.getElementById('dashCountSkipped').textContent = counts.skipped || 0;
|
if (el('cntAll')) el('cntAll').textContent = c.total || 0;
|
||||||
document.getElementById('dashCountError').textContent = counts.error || 0;
|
if (el('cntImp')) el('cntImp').textContent = c.imported || 0;
|
||||||
document.getElementById('dashCountUninvoiced').textContent = counts.uninvoiced || 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;
|
||||||
|
|
||||||
const tbody = document.getElementById('dashOrdersBody');
|
const tbody = document.getElementById('dashOrdersBody');
|
||||||
const orders = data.orders || [];
|
const orders = data.orders || [];
|
||||||
@@ -212,7 +293,7 @@ async function loadDashOrders() {
|
|||||||
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
|
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
|
||||||
<td><code>${esc(o.order_number)}</code></td>
|
<td><code>${esc(o.order_number)}</code></td>
|
||||||
<td>${dateStr}</td>
|
<td>${dateStr}</td>
|
||||||
<td>${esc(o.customer_name)}</td>
|
${renderClientCell(o)}
|
||||||
<td>${o.items_count || 0}</td>
|
<td>${o.items_count || 0}</td>
|
||||||
<td>${statusBadge}</td>
|
<td>${statusBadge}</td>
|
||||||
<td>${o.id_comanda || '-'}</td>
|
<td>${o.id_comanda || '-'}</td>
|
||||||
@@ -223,19 +304,23 @@ async function loadDashOrders() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
const totalPages = data.pages || 1;
|
const pag = data.pagination || {};
|
||||||
document.getElementById('dashPageInfo').textContent = `${data.total || 0} comenzi | Pagina ${dashPage} din ${totalPages}`;
|
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 pagDiv = document.getElementById('dashPagination');
|
const pagHtml = totalPages > 1 ? `
|
||||||
if (totalPages > 1) {
|
|
||||||
pagDiv.innerHTML = `
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" ${dashPage <= 1 ? 'disabled' : ''} onclick="dashGoPage(${dashPage - 1})"><i class="bi bi-chevron-left"></i></button>
|
<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>
|
<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>
|
<button class="btn btn-sm btn-outline-secondary" ${dashPage >= totalPages ? 'disabled' : ''} onclick="dashGoPage(${dashPage + 1})"><i class="bi bi-chevron-right"></i></button>
|
||||||
`;
|
` : '';
|
||||||
} else {
|
const pagDiv = document.getElementById('dashPagination');
|
||||||
pagDiv.innerHTML = '';
|
if (pagDiv) pagDiv.innerHTML = pagHtml;
|
||||||
}
|
const pagDivTop = document.getElementById('dashPaginationTop');
|
||||||
|
if (pagDivTop) pagDivTop.innerHTML = pagHtml;
|
||||||
|
|
||||||
// Update sort icons
|
// Update sort icons
|
||||||
document.querySelectorAll('.sort-icon').forEach(span => {
|
document.querySelectorAll('.sort-icon').forEach(span => {
|
||||||
@@ -253,7 +338,44 @@ function dashGoPage(p) {
|
|||||||
loadDashOrders();
|
loadDashOrders();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helper functions ─────────────────────────────
|
function dashChangePerPage(val) {
|
||||||
|
dashPerPage = parseInt(val) || 50;
|
||||||
|
dashPage = 1;
|
||||||
|
loadDashOrders();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client cell with Cont tooltip (Task F4) ───────
|
||||||
|
|
||||||
|
function renderClientCell(order) {
|
||||||
|
const shipping = (order.shipping_name || order.customer_name || '').trim();
|
||||||
|
const billing = (order.billing_name || '').trim();
|
||||||
|
const isDiff = order.is_different_person && billing && shipping !== billing;
|
||||||
|
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>${escHtml(shipping || billing || '\u2014')}</td>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper functions ──────────────────────────────
|
||||||
|
|
||||||
|
async function fetchJSON(url) {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias kept for backward compat with inline handlers in modal
|
||||||
|
function esc(s) { return escHtml(s); }
|
||||||
|
|
||||||
function fmtDate(dateStr) {
|
function fmtDate(dateStr) {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
@@ -289,7 +411,7 @@ function renderCodmatCell(item) {
|
|||||||
).join('');
|
).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Order Detail Modal ───────────────────────────
|
// ── Order Detail Modal ────────────────────────────
|
||||||
|
|
||||||
async function openDashOrderDetail(orderNumber) {
|
async function openDashOrderDetail(orderNumber) {
|
||||||
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
|
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
|
||||||
@@ -367,7 +489,7 @@ async function openDashOrderDetail(orderNumber) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Quick Map Modal ──────────────────────────────
|
// ── Quick Map Modal ───────────────────────────────
|
||||||
|
|
||||||
function openQuickMap(sku, productName, orderNumber) {
|
function openQuickMap(sku, productName, orderNumber) {
|
||||||
currentQmSku = sku;
|
currentQmSku = sku;
|
||||||
@@ -435,7 +557,7 @@ async function qmAutocomplete(input, dropdown, selectedEl) {
|
|||||||
|
|
||||||
dropdown.innerHTML = data.results.map(r =>
|
dropdown.innerHTML = data.results.map(r =>
|
||||||
`<div class="autocomplete-item" onmousedown="qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
`<div class="autocomplete-item" onmousedown="qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
||||||
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
|
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
|
||||||
</div>`
|
</div>`
|
||||||
).join('');
|
).join('');
|
||||||
dropdown.classList.remove('d-none');
|
dropdown.classList.remove('d-none');
|
||||||
@@ -500,126 +622,3 @@ async function saveQuickMapping() {
|
|||||||
alert('Eroare: ' + err.message);
|
alert('Eroare: ' + err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sync Controls ────────────────────────────────
|
|
||||||
|
|
||||||
async function startSync() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/sync/start', { method: 'POST' });
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.error) {
|
|
||||||
alert(data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.run_id) {
|
|
||||||
const banner = document.getElementById('syncStartedBanner');
|
|
||||||
const link = document.getElementById('syncRunLink');
|
|
||||||
if (banner && link) {
|
|
||||||
link.href = '/logs?run=' + encodeURIComponent(data.run_id);
|
|
||||||
banner.classList.remove('d-none');
|
|
||||||
}
|
|
||||||
// Subscribe to SSE for live progress + auto-refresh on completion
|
|
||||||
listenToSyncStream(data.run_id);
|
|
||||||
}
|
|
||||||
loadSyncStatus();
|
|
||||||
} catch (err) {
|
|
||||||
alert('Eroare: ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function listenToSyncStream(runId) {
|
|
||||||
// Close any previous SSE connection
|
|
||||||
if (syncEventSource) { syncEventSource.close(); syncEventSource = null; }
|
|
||||||
|
|
||||||
syncEventSource = new EventSource('/api/sync/stream');
|
|
||||||
|
|
||||||
syncEventSource.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
|
|
||||||
if (data.type === 'phase') {
|
|
||||||
document.getElementById('syncProgressText').textContent = data.message || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === 'order_result') {
|
|
||||||
// Update progress text with current order info
|
|
||||||
const status = data.status === 'IMPORTED' ? 'OK' : data.status === 'SKIPPED' ? 'OMIS' : 'ERR';
|
|
||||||
document.getElementById('syncProgressText').textContent =
|
|
||||||
`[${data.progress || ''}] #${data.order_number} ${data.customer_name || ''} → ${status}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === 'completed' || data.type === 'failed') {
|
|
||||||
syncEventSource.close();
|
|
||||||
syncEventSource = null;
|
|
||||||
// Refresh all dashboard sections
|
|
||||||
loadLastSync();
|
|
||||||
loadDashOrders();
|
|
||||||
loadSyncStatus();
|
|
||||||
// Hide banner after 5s
|
|
||||||
setTimeout(() => {
|
|
||||||
document.getElementById('syncStartedBanner')?.classList.add('d-none');
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('SSE parse error:', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
syncEventSource.onerror = () => {
|
|
||||||
syncEventSource.close();
|
|
||||||
syncEventSource = null;
|
|
||||||
// Refresh anyway — sync may have finished
|
|
||||||
loadLastSync();
|
|
||||||
loadDashOrders();
|
|
||||||
loadSyncStatus();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopSync() {
|
|
||||||
try {
|
|
||||||
await fetch('/api/sync/stop', { method: 'POST' });
|
|
||||||
loadSyncStatus();
|
|
||||||
} catch (err) {
|
|
||||||
alert('Eroare: ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleScheduler() {
|
|
||||||
const enabled = document.getElementById('schedulerToggle').checked;
|
|
||||||
const interval = parseInt(document.getElementById('schedulerInterval').value) || 5;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch('/api/sync/schedule', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ enabled, interval_minutes: interval })
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
alert('Eroare scheduler: ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateSchedulerInterval() {
|
|
||||||
const enabled = document.getElementById('schedulerToggle').checked;
|
|
||||||
if (enabled) {
|
|
||||||
await toggleScheduler();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSchedulerStatus() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/sync/schedule');
|
|
||||||
const data = await res.json();
|
|
||||||
document.getElementById('schedulerToggle').checked = data.enabled || false;
|
|
||||||
if (data.interval_minutes) {
|
|
||||||
document.getElementById('schedulerInterval').value = data.interval_minutes;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('loadSchedulerStatus error:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(s) {
|
|
||||||
if (s == null) return '';
|
|
||||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ let searchTimeout = null;
|
|||||||
let sortColumn = 'sku';
|
let sortColumn = 'sku';
|
||||||
let sortDirection = 'asc';
|
let sortDirection = 'asc';
|
||||||
let editingMapping = null; // {sku, codmat} when editing
|
let editingMapping = null; // {sku, codmat} when editing
|
||||||
|
let pctFilter = 'all';
|
||||||
|
|
||||||
// Load on page ready
|
// Load on page ready
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadMappings();
|
loadMappings();
|
||||||
initAddModal();
|
initAddModal();
|
||||||
initDeleteModal();
|
initDeleteModal();
|
||||||
|
initPctFilterPills();
|
||||||
});
|
});
|
||||||
|
|
||||||
function debounceSearch() {
|
function debounceSearch() {
|
||||||
@@ -45,6 +47,30 @@ function updateSortIcons() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pct Filter Pills ─────────────────────────────
|
||||||
|
|
||||||
|
function initPctFilterPills() {
|
||||||
|
document.querySelectorAll('.filter-pill[data-pct]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('.filter-pill[data-pct]').forEach(b => b.classList.remove('active'));
|
||||||
|
this.classList.add('active');
|
||||||
|
pctFilter = this.dataset.pct;
|
||||||
|
currentPage = 1;
|
||||||
|
loadMappings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePctCounts(counts) {
|
||||||
|
if (!counts) return;
|
||||||
|
const elAll = document.getElementById('mCntAll');
|
||||||
|
const elComplete = document.getElementById('mCntComplete');
|
||||||
|
const elIncomplete = document.getElementById('mCntIncomplete');
|
||||||
|
if (elAll) elAll.textContent = counts.total || 0;
|
||||||
|
if (elComplete) elComplete.textContent = counts.complete || 0;
|
||||||
|
if (elIncomplete) elIncomplete.textContent = counts.incomplete || 0;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Load & Render ────────────────────────────────
|
// ── Load & Render ────────────────────────────────
|
||||||
|
|
||||||
async function loadMappings() {
|
async function loadMappings() {
|
||||||
@@ -58,6 +84,7 @@ async function loadMappings() {
|
|||||||
sort_dir: sortDirection
|
sort_dir: sortDirection
|
||||||
});
|
});
|
||||||
if (showDeleted) params.set('show_deleted', 'true');
|
if (showDeleted) params.set('show_deleted', 'true');
|
||||||
|
if (pctFilter && pctFilter !== 'all') params.set('pct_filter', pctFilter);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/mappings?${params}`);
|
const res = await fetch(`/api/mappings?${params}`);
|
||||||
@@ -71,6 +98,7 @@ async function loadMappings() {
|
|||||||
mappings = mappings.filter(m => m.activ || m.sters);
|
mappings = mappings.filter(m => m.activ || m.sters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatePctCounts(data.counts);
|
||||||
renderTable(mappings, showDeleted);
|
renderTable(mappings, showDeleted);
|
||||||
renderPagination(data);
|
renderPagination(data);
|
||||||
updateSortIcons();
|
updateSortIcons();
|
||||||
@@ -111,7 +139,17 @@ function renderTable(mappings, showDeleted) {
|
|||||||
let skuCell, productCell;
|
let skuCell, productCell;
|
||||||
if (isNewGroup) {
|
if (isNewGroup) {
|
||||||
const badge = isMulti ? ` <span class="badge bg-info">Set (${skuGroupCounts[m.sku]})</span>` : '';
|
const badge = isMulti ? ` <span class="badge bg-info">Set (${skuGroupCounts[m.sku]})</span>` : '';
|
||||||
skuCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}"><strong>${esc(m.sku)}</strong>${badge}</td>`;
|
// 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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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>`;
|
productCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}">${esc(m.product_name || '-')}</td>`;
|
||||||
} else {
|
} else {
|
||||||
skuCell = '';
|
skuCell = '';
|
||||||
@@ -361,6 +399,8 @@ async function saveMapping() {
|
|||||||
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
|
||||||
editingMapping = null;
|
editingMapping = null;
|
||||||
loadMappings();
|
loadMappings();
|
||||||
|
} else if (res.status === 409) {
|
||||||
|
handleMappingConflict(data);
|
||||||
} else {
|
} else {
|
||||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||||
}
|
}
|
||||||
@@ -462,6 +502,8 @@ async function saveInlineMapping() {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
cancelInlineAdd();
|
cancelInlineAdd();
|
||||||
loadMappings();
|
loadMappings();
|
||||||
|
} else if (res.status === 409) {
|
||||||
|
handleMappingConflict(data);
|
||||||
} else {
|
} else {
|
||||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||||
}
|
}
|
||||||
@@ -555,12 +597,17 @@ function showUndoToast(message, undoCallback) {
|
|||||||
const newBtn = undoBtn.cloneNode(true);
|
const newBtn = undoBtn.cloneNode(true);
|
||||||
undoBtn.parentNode.replaceChild(newBtn, undoBtn);
|
undoBtn.parentNode.replaceChild(newBtn, undoBtn);
|
||||||
newBtn.id = 'toastUndoBtn';
|
newBtn.id = 'toastUndoBtn';
|
||||||
|
if (undoCallback) {
|
||||||
|
newBtn.style.display = '';
|
||||||
newBtn.addEventListener('click', () => {
|
newBtn.addEventListener('click', () => {
|
||||||
undoCallback();
|
undoCallback();
|
||||||
const toastEl = document.getElementById('undoToast');
|
const toastEl = document.getElementById('undoToast');
|
||||||
const inst = bootstrap.Toast.getInstance(toastEl);
|
const inst = bootstrap.Toast.getInstance(toastEl);
|
||||||
if (inst) inst.hide();
|
if (inst) inst.hide();
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
newBtn.style.display = 'none';
|
||||||
|
}
|
||||||
const toast = new bootstrap.Toast(document.getElementById('undoToast'));
|
const toast = new bootstrap.Toast(document.getElementById('undoToast'));
|
||||||
toast.show();
|
toast.show();
|
||||||
}
|
}
|
||||||
@@ -639,6 +686,33 @@ async function importCsv() {
|
|||||||
function exportCsv() { window.location.href = '/api/mappings/export-csv'; }
|
function exportCsv() { window.location.href = '/api/mappings/export-csv'; }
|
||||||
function downloadTemplate() { window.location.href = '/api/mappings/csv-template'; }
|
function downloadTemplate() { window.location.href = '/api/mappings/csv-template'; }
|
||||||
|
|
||||||
|
// ── Duplicate / Conflict handling ────────────────
|
||||||
|
|
||||||
|
function handleMappingConflict(data) {
|
||||||
|
const msg = data.error || 'Conflict la salvare';
|
||||||
|
if (data.can_restore) {
|
||||||
|
const restore = confirm(`${msg}\n\nDoriti sa restaurati maparea stearsa?`);
|
||||||
|
if (restore) {
|
||||||
|
// Find sku/codmat from the inline row or modal
|
||||||
|
const sku = (document.getElementById('inlineSku') || document.getElementById('inputSku'))?.value?.trim();
|
||||||
|
const codmat = (document.getElementById('inlineCodmat') || document.querySelector('.cl-codmat'))?.value?.trim();
|
||||||
|
if (sku && codmat) {
|
||||||
|
fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}/restore`, { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
if (d.success) { cancelInlineAdd(); loadMappings(); }
|
||||||
|
else alert('Eroare la restaurare: ' + (d.error || ''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showUndoToast(msg, null);
|
||||||
|
// Show non-dismissible inline error
|
||||||
|
const warn = document.getElementById('pctWarning');
|
||||||
|
if (warn) { warn.textContent = msg; warn.style.display = ''; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
if (s == null) return '';
|
if (s == null) return '';
|
||||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
|||||||
@@ -5,102 +5,86 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h4 class="mb-4">Panou de Comanda</h4>
|
<h4 class="mb-4">Panou de Comanda</h4>
|
||||||
|
|
||||||
<!-- Sync Control -->
|
<!-- Sync Card (unified two-row panel) -->
|
||||||
<div class="card mb-4">
|
<div class="sync-card">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<!-- TOP ROW: Status + Controls -->
|
||||||
<span>Sync Control</span>
|
<div class="sync-card-controls">
|
||||||
<span class="badge bg-secondary" id="syncStatusBadge">idle</span>
|
<span id="syncStatusDot" class="sync-status-dot idle"></span>
|
||||||
</div>
|
<span id="syncStatusText" style="font-size:0.8125rem;color:#374151;">Inactiv</span>
|
||||||
<div class="card-body">
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-left:auto;">
|
||||||
<div class="row align-items-center">
|
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.8125rem;color:#6b7280;">
|
||||||
<div class="col-auto">
|
Auto:
|
||||||
<button class="btn btn-success btn-sm" id="btnStartSync" onclick="startSync()">
|
<input type="checkbox" id="schedulerToggle" style="cursor:pointer;" onchange="toggleScheduler()">
|
||||||
<i class="bi bi-play-fill"></i> Start Sync
|
</label>
|
||||||
</button>
|
<select id="schedulerInterval" class="select-compact" onchange="updateSchedulerInterval()">
|
||||||
<button class="btn btn-danger btn-sm d-none" id="btnStopSync" onclick="stopSync()">
|
<option value="5">5 min</option>
|
||||||
<i class="bi bi-stop-fill"></i> Stop
|
<option value="10" selected>10 min</option>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<div class="form-check form-switch d-inline-block me-2">
|
|
||||||
<input class="form-check-input" type="checkbox" id="schedulerToggle" onchange="toggleScheduler()">
|
|
||||||
<label class="form-check-label" for="schedulerToggle">Scheduler</label>
|
|
||||||
</div>
|
|
||||||
<select class="form-select form-select-sm d-inline-block" style="width:auto" id="schedulerInterval" onchange="updateSchedulerInterval()">
|
|
||||||
<option value="1">1 min</option>
|
|
||||||
<option value="5" selected>5 min</option>
|
|
||||||
<option value="10">10 min</option>
|
|
||||||
<option value="15">15 min</option>
|
|
||||||
<option value="30">30 min</option>
|
<option value="30">30 min</option>
|
||||||
<option value="60">60 min</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
<button id="syncStartBtn" class="btn btn-primary btn-compact" onclick="startSync()">▶ Start Sync</button>
|
||||||
<div class="col">
|
|
||||||
<small class="text-muted" id="syncProgressText"></small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 d-none" id="syncStartedBanner">
|
<div class="sync-card-divider"></div>
|
||||||
<div class="alert alert-info alert-sm py-1 px-2 mb-0 d-inline-block">
|
<!-- BOTTOM ROW: Last sync info (clickable → jurnal) -->
|
||||||
<small><i class="bi bi-broadcast"></i> Sync pornit — <a href="#" id="syncRunLink">vezi progresul live</a></small>
|
<div class="sync-card-info" id="lastSyncRow" role="button" tabindex="0" title="Ver jurnal sync">
|
||||||
</div>
|
<span id="lastSyncDate" style="font-weight:500;">—</span>
|
||||||
</div>
|
<span id="lastSyncDuration" style="color:#9ca3af;">—</span>
|
||||||
</div>
|
<span id="lastSyncCounts">—</span>
|
||||||
</div>
|
<span id="lastSyncStatus">—</span>
|
||||||
|
<span style="margin-left:auto;font-size:0.75rem;color:#9ca3af;">↗ jurnal</span>
|
||||||
<!-- Last Sync Summary Card -->
|
|
||||||
<div class="card mb-4" id="lastSyncCard">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center cursor-pointer" data-bs-toggle="collapse" data-bs-target="#lastSyncBody">
|
|
||||||
<span>Ultimul Sync</span>
|
|
||||||
<i class="bi bi-chevron-down"></i>
|
|
||||||
</div>
|
|
||||||
<div class="collapse show" id="lastSyncBody">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row text-center" id="lastSyncRow">
|
|
||||||
<div class="col last-sync-col"><small class="text-muted">Data</small><br><strong id="lastSyncDate">-</strong></div>
|
|
||||||
<div class="col last-sync-col"><small class="text-muted">Status</small><br><span id="lastSyncStatus">-</span></div>
|
|
||||||
<div class="col last-sync-col"><small class="text-muted">Importate</small><br><strong class="text-success" id="lastSyncImported">0</strong></div>
|
|
||||||
<div class="col last-sync-col"><small class="text-muted">Omise</small><br><strong class="text-warning" id="lastSyncSkipped">0</strong></div>
|
|
||||||
<div class="col last-sync-col"><small class="text-muted">Erori</small><br><strong class="text-danger" id="lastSyncErrors">0</strong></div>
|
|
||||||
<div class="col"><small class="text-muted">Durata</small><br><strong id="lastSyncDuration">-</strong></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<!-- LIVE PROGRESS (shown only when sync is running) -->
|
||||||
|
<div class="sync-card-progress" id="syncProgressArea" style="display:none;">
|
||||||
|
<span class="sync-live-dot"></span>
|
||||||
|
<span id="syncProgressText">Se proceseaza...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Orders Table -->
|
<!-- Orders Table -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header">
|
||||||
<div class="d-flex align-items-center gap-2">
|
|
||||||
<span>Comenzi</span>
|
<span>Comenzi</span>
|
||||||
<div class="btn-group btn-group-sm" role="group" id="dashPeriodBtns">
|
</div>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-days="3" onclick="dashSetPeriod(3)">3 zile</button>
|
<div class="card-body py-2 px-3">
|
||||||
<button type="button" class="btn btn-sm btn-secondary" data-days="7" onclick="dashSetPeriod(7)">7 zile</button>
|
<div class="filter-bar" id="ordersFilterBar">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-days="30" onclick="dashSetPeriod(30)">30 zile</button>
|
<!-- Period dropdown -->
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-days="0" onclick="dashSetPeriod(0)">Toate</button>
|
<select id="periodSelect" class="select-compact">
|
||||||
|
<option value="3">3 zile</option>
|
||||||
|
<option value="7" selected>7 zile</option>
|
||||||
|
<option value="30">30 zile</option>
|
||||||
|
<option value="90">3 luni</option>
|
||||||
|
<option value="0">Toate</option>
|
||||||
|
<option value="custom">Perioada personalizata...</option>
|
||||||
|
</select>
|
||||||
|
<!-- Custom date range (hidden until 'custom' selected) -->
|
||||||
|
<div class="period-custom-range" id="customRangeInputs">
|
||||||
|
<input type="date" id="periodStart" class="select-compact">
|
||||||
|
<span>—</span>
|
||||||
|
<input type="date" id="periodEnd" class="select-compact">
|
||||||
|
</div>
|
||||||
|
<!-- 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>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group input-group-sm" style="width:250px">
|
<!-- Pagination top bar -->
|
||||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
<div class="card-body py-1 px-3 border-bottom d-flex justify-content-between align-items-center" style="gap:0.5rem;">
|
||||||
<input type="text" class="form-control" id="dashSearchInput" placeholder="Cauta..." oninput="debounceDashSearch()">
|
<small class="text-muted" id="dashPageInfoTop"></small>
|
||||||
</div>
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
</div>
|
<label style="font-size:0.8125rem;color:#6b7280;white-space:nowrap;">Per pagina:
|
||||||
<div class="card-body py-2">
|
<select id="perPageSelect" class="select-compact" style="margin-left:0.25rem;" onchange="dashChangePerPage(this.value)">
|
||||||
<div class="btn-group" role="group" id="dashFilterBtns">
|
<option value="25">25</option>
|
||||||
<button type="button" class="btn btn-sm btn-primary" onclick="dashFilterOrders('all')">
|
<option value="50" selected>50</option>
|
||||||
Toate <span class="badge bg-light text-dark ms-1" id="dashCountAll">0</span>
|
<option value="100">100</option>
|
||||||
</button>
|
<option value="250">250</option>
|
||||||
<button type="button" class="btn btn-sm btn-outline-success" onclick="dashFilterOrders('IMPORTED')">
|
</select>
|
||||||
Importate <span class="badge bg-light text-dark ms-1" id="dashCountImported">0</span>
|
</label>
|
||||||
</button>
|
<div id="dashPaginationTop" class="d-flex align-items-center gap-2"></div>
|
||||||
<button type="button" class="btn btn-sm btn-outline-warning" onclick="dashFilterOrders('SKIPPED')">
|
|
||||||
Omise <span class="badge bg-light text-dark ms-1" id="dashCountSkipped">0</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="dashFilterOrders('ERROR')">
|
|
||||||
Erori <span class="badge bg-light text-dark ms-1" id="dashCountError">0</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-info" onclick="dashFilterOrders('UNINVOICED')">
|
|
||||||
Nefacturate <span class="badge bg-light text-dark ms-1" id="dashCountUninvoiced">0</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
{% block nav_mappings %}active{% endblock %}
|
{% block nav_mappings %}active{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h4 class="mb-0">Mapari SKU</h4>
|
<h4 class="mb-0">Mapari SKU</h4>
|
||||||
<div>
|
<div>
|
||||||
@@ -36,6 +41,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
|
|||||||
@@ -9,24 +9,29 @@
|
|||||||
<button class="btn btn-sm btn-outline-secondary" onclick="exportMissingCsv()">
|
<button class="btn btn-sm btn-outline-secondary" onclick="exportMissingCsv()">
|
||||||
<i class="bi bi-download"></i> Export CSV
|
<i class="bi bi-download"></i> Export CSV
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="scanForMissing()">
|
|
||||||
<i class="bi bi-search"></i> Re-Scan
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Resolved toggle (R10) -->
|
<!-- Unified filter bar -->
|
||||||
<div class="btn-group mb-3" role="group">
|
<div class="filter-bar" id="skusFilterBar">
|
||||||
<button type="button" class="btn btn-sm btn-primary" id="btnUnresolved" onclick="setResolvedFilter(0)">
|
<button class="filter-pill active" data-sku-status="unresolved">
|
||||||
Nerezolvate
|
Nerezolvate <span class="filter-count" id="cntUnres">0</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-success" id="btnResolved" onclick="setResolvedFilter(1)">
|
<button class="filter-pill" data-sku-status="resolved">
|
||||||
Rezolvate
|
Rezolvate <span class="filter-count" id="cntRes">0</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="btnAll" onclick="setResolvedFilter(-1)">
|
<button class="filter-pill" data-sku-status="all">
|
||||||
Toate
|
Toate <span class="filter-count" id="cntAllSkus">0</span>
|
||||||
</button>
|
</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;">
|
||||||
|
<span class="sync-live-dot"></span>
|
||||||
|
<span id="rescanProgressText">Scanare...</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Result banner -->
|
||||||
|
<div id="rescanResult" class="result-banner" style="display:none;margin-bottom:0.75rem;"></div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@@ -92,39 +97,102 @@
|
|||||||
let currentMapSku = '';
|
let currentMapSku = '';
|
||||||
let mapAcTimeout = null;
|
let mapAcTimeout = null;
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let currentResolved = 0;
|
let skuStatusFilter = 'unresolved';
|
||||||
const perPage = 20;
|
const perPage = 20;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
// ── Filter pills ──────────────────────────────────
|
||||||
loadMissing(1);
|
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(b => b.classList.remove('active'));
|
||||||
|
this.classList.add('active');
|
||||||
|
skuStatusFilter = this.dataset.skuStatus;
|
||||||
|
currentPage = 1;
|
||||||
|
loadMissingSkus();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function setResolvedFilter(val) {
|
// ── Search with debounce ─────────────────────────
|
||||||
currentResolved = val;
|
let skuSearchTimer = null;
|
||||||
currentPage = 1;
|
document.getElementById('skuSearch')?.addEventListener('input', function() {
|
||||||
// Update button styles
|
clearTimeout(skuSearchTimer);
|
||||||
document.getElementById('btnUnresolved').className = 'btn btn-sm ' + (val === 0 ? 'btn-primary' : 'btn-outline-primary');
|
skuSearchTimer = setTimeout(() => { currentPage = 1; loadMissingSkus(); }, 300);
|
||||||
document.getElementById('btnResolved').className = 'btn btn-sm ' + (val === 1 ? 'btn-success' : 'btn-outline-success');
|
});
|
||||||
document.getElementById('btnAll').className = 'btn btn-sm ' + (val === -1 ? 'btn-secondary' : 'btn-outline-secondary');
|
|
||||||
loadMissing(1);
|
// ── Rescan ────────────────────────────────────────
|
||||||
|
document.getElementById('rescanBtn')?.addEventListener('click', async function() {
|
||||||
|
this.disabled = true;
|
||||||
|
const prog = document.getElementById('rescanProgress');
|
||||||
|
const result = document.getElementById('rescanResult');
|
||||||
|
const progText = document.getElementById('rescanProgressText');
|
||||||
|
if (prog) { prog.style.display = 'flex'; }
|
||||||
|
if (result) result.style.display = 'none';
|
||||||
|
try {
|
||||||
|
const data = await fetch('/api/validate/scan', { method: 'POST' }).then(r => r.json());
|
||||||
|
if (progText) progText.textContent = 'Gata.';
|
||||||
|
if (result) {
|
||||||
|
result.innerHTML = `✓ ${data.total_skus_scanned || 0} scanate | ${data.new_missing || 0} noi lipsa | ${data.auto_resolved || 0} rezolvate`;
|
||||||
|
result.style.display = 'block';
|
||||||
|
}
|
||||||
|
loadMissingSkus();
|
||||||
|
} catch(e) {
|
||||||
|
if (progText) progText.textContent = 'Eroare.';
|
||||||
|
} finally {
|
||||||
|
this.disabled = false;
|
||||||
|
setTimeout(() => { if (prog) prog.style.display = 'none'; }, 2500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadMissingSkus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function resolvedParamFor(statusFilter) {
|
||||||
|
if (statusFilter === 'resolved') return 1;
|
||||||
|
if (statusFilter === 'all') return -1;
|
||||||
|
return 0; // unresolved (default)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMissing(page) {
|
function loadMissingSkus(page) {
|
||||||
currentPage = page || 1;
|
currentPage = page || currentPage;
|
||||||
try {
|
const params = new URLSearchParams();
|
||||||
const res = await fetch(`/api/validate/missing-skus?page=${currentPage}&per_page=${perPage}&resolved=${currentResolved}`);
|
const resolvedVal = resolvedParamFor(skuStatusFilter);
|
||||||
const data = await res.json();
|
params.set('resolved', resolvedVal);
|
||||||
const tbody = document.getElementById('missingBody');
|
params.set('page', currentPage);
|
||||||
|
params.set('per_page', perPage);
|
||||||
|
const search = document.getElementById('skuSearch')?.value?.trim();
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
|
||||||
|
fetch('/api/validate/missing-skus?' + params.toString())
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const c = data.counts || {};
|
||||||
|
const el = id => document.getElementById(id);
|
||||||
|
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;
|
||||||
|
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>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep backward compat alias
|
||||||
|
function loadMissing(page) { loadMissingSkus(page); }
|
||||||
|
|
||||||
|
function renderMissingSkusTable(skus, data) {
|
||||||
|
const tbody = document.getElementById('missingBody');
|
||||||
|
if (data) {
|
||||||
document.getElementById('missingInfo').textContent =
|
document.getElementById('missingInfo').textContent =
|
||||||
`Total: ${data.total || 0} | Pagina: ${data.page || 1} din ${data.pages || 1}`;
|
`Total: ${data.total || 0} | Pagina: ${data.page || 1} din ${data.pages || 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
const skus = data.missing_skus || [];
|
if (!skus || skus.length === 0) {
|
||||||
if (skus.length === 0) {
|
const msg = skuStatusFilter === 'unresolved' ? 'Toate SKU-urile sunt mapate!' :
|
||||||
const msg = currentResolved === 0 ? 'Toate SKU-urile sunt mapate!' :
|
skuStatusFilter === 'resolved' ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit';
|
||||||
currentResolved === 1 ? '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="7" class="text-center text-muted py-4">${msg}</td></tr>`;
|
||||||
renderPagination(data);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,12 +225,6 @@ async function loadMissing(page) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
renderPagination(data);
|
|
||||||
} catch (err) {
|
|
||||||
document.getElementById('missingBody').innerHTML =
|
|
||||||
`<tr><td colspan="7" class="text-center text-danger">${err.message}</td></tr>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPagination(data) {
|
function renderPagination(data) {
|
||||||
@@ -173,20 +235,20 @@ function renderPagination(data) {
|
|||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}">
|
html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}">
|
||||||
<a class="page-link" href="#" onclick="loadMissing(${page - 1}); return false;">Anterior</a></li>`;
|
<a class="page-link" href="#" onclick="loadMissingSkus(${page - 1}); return false;">Anterior</a></li>`;
|
||||||
|
|
||||||
const range = 2;
|
const range = 2;
|
||||||
for (let i = 1; i <= total; i++) {
|
for (let i = 1; i <= total; i++) {
|
||||||
if (i === 1 || i === total || (i >= page - range && i <= page + range)) {
|
if (i === 1 || i === total || (i >= page - range && i <= page + range)) {
|
||||||
html += `<li class="page-item ${i === page ? 'active' : ''}">
|
html += `<li class="page-item ${i === page ? 'active' : ''}">
|
||||||
<a class="page-link" href="#" onclick="loadMissing(${i}); return false;">${i}</a></li>`;
|
<a class="page-link" href="#" onclick="loadMissingSkus(${i}); return false;">${i}</a></li>`;
|
||||||
} else if (i === page - range - 1 || i === page + range + 1) {
|
} 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 disabled"><span class="page-link">…</span></li>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `<li class="page-item ${page >= total ? 'disabled' : ''}">
|
html += `<li class="page-item ${page >= total ? 'disabled' : ''}">
|
||||||
<a class="page-link" href="#" onclick="loadMissing(${page + 1}); return false;">Urmator</a></li>`;
|
<a class="page-link" href="#" onclick="loadMissingSkus(${page + 1}); return false;">Urmator</a></li>`;
|
||||||
ul.innerHTML = html;
|
ul.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +387,7 @@ async function saveQuickMap() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
|
||||||
loadMissing(currentPage);
|
loadMissingSkus(currentPage);
|
||||||
} else {
|
} else {
|
||||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||||
}
|
}
|
||||||
@@ -334,15 +396,6 @@ async function saveQuickMap() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanForMissing() {
|
|
||||||
try {
|
|
||||||
await fetch('/api/validate/scan', { method: 'POST' });
|
|
||||||
loadMissing(1);
|
|
||||||
} catch (err) {
|
|
||||||
alert('Eroare scan: ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportMissingCsv() {
|
function exportMissingCsv() {
|
||||||
window.location.href = '/api/validate/missing-skus-csv';
|
window.location.href = '/api/validate/missing-skus-csv';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user