diff --git a/api/app/database.py b/api/app/database.py index 310f4ef..bfe4673 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -105,6 +105,7 @@ CREATE TABLE IF NOT EXISTS orders ( factura_total_fara_tva REAL, factura_total_tva REAL, factura_total_cu_tva REAL, + factura_data TEXT, invoice_checked_at TEXT, order_total REAL, delivery_cost REAL, @@ -310,6 +311,7 @@ def init_sqlite(): ("factura_total_fara_tva", "REAL"), ("factura_total_tva", "REAL"), ("factura_total_cu_tva", "REAL"), + ("factura_data", "TEXT"), ("invoice_checked_at", "TEXT"), ("order_total", "REAL"), ("delivery_cost", "REAL"), diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py index 5863fa9..ed8e7ba 100644 --- a/api/app/routers/sync.py +++ b/api/app/routers/sync.py @@ -330,6 +330,39 @@ async def order_detail(order_number: str): if sku and sku in codmat_map: item["codmat_details"] = codmat_map[sku] + # Enrich with invoice data + order = detail.get("order", {}) + if order.get("factura_numar"): + order["invoice"] = { + "facturat": True, + "serie_act": order.get("factura_serie"), + "numar_act": order.get("factura_numar"), + "data_act": order.get("factura_data"), + "total_fara_tva": order.get("factura_total_fara_tva"), + "total_tva": order.get("factura_total_tva"), + "total_cu_tva": order.get("factura_total_cu_tva"), + } + elif order.get("id_comanda"): + # Check Oracle live + try: + inv_data = await asyncio.to_thread( + invoice_service.check_invoices_for_orders, [order["id_comanda"]] + ) + inv = inv_data.get(order["id_comanda"]) + if inv and inv.get("facturat"): + order["invoice"] = inv + await sqlite_service.update_order_invoice( + order_number, + serie=inv.get("serie_act"), + numar=str(inv.get("numar_act", "")), + total_fara_tva=inv.get("total_fara_tva"), + total_tva=inv.get("total_tva"), + total_cu_tva=inv.get("total_cu_tva"), + data_act=inv.get("data_act"), + ) + except Exception: + pass + return detail @@ -372,6 +405,7 @@ async def dashboard_orders(page: int = 1, per_page: int = 50, "total_fara_tva": o.get("factura_total_fara_tva"), "total_tva": o.get("factura_total_tva"), "total_cu_tva": o.get("factura_total_cu_tva"), + "data_act": o.get("factura_data"), } else: o["invoice"] = None @@ -388,6 +422,18 @@ async def dashboard_orders(page: int = 1, per_page: int = 50, idc = o.get("id_comanda") if idc and idc in invoice_data: o["invoice"] = invoice_data[idc] + # Update SQLite cache so counts stay accurate + inv = invoice_data[idc] + if inv.get("facturat"): + await sqlite_service.update_order_invoice( + o["order_number"], + serie=inv.get("serie_act"), + numar=str(inv.get("numar_act", "")), + total_fara_tva=inv.get("total_fara_tva"), + total_tva=inv.get("total_tva"), + total_cu_tva=inv.get("total_cu_tva"), + data_act=inv.get("data_act"), + ) except Exception: pass @@ -400,11 +446,14 @@ async def dashboard_orders(page: int = 1, per_page: int = 50, # Use counts from sqlite_service (already period-scoped) counts = result.get("counts", {}) - # Prefer SQLite-based uninvoiced count (covers full period, not just current page) - counts["nefacturate"] = counts.get("uninvoiced_sqlite", sum( + # Count newly-cached invoices found during this request + newly_invoiced = sum(1 for o in uncached_orders if o.get("invoice") and o["invoice"].get("facturat")) + # Adjust uninvoiced count: start from SQLite count, subtract newly-found invoices + uninvoiced_base = counts.get("uninvoiced_sqlite", sum( 1 for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice") )) + counts["nefacturate"] = max(0, uninvoiced_base - newly_invoiced) imported_total = counts.get("imported_all") or counts.get("imported", 0) counts["facturate"] = max(0, imported_total - counts["nefacturate"]) counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0)) @@ -441,6 +490,38 @@ async def dashboard_orders(page: int = 1, per_page: int = 50, } +@router.post("/api/dashboard/refresh-invoices") +async def refresh_invoices(): + """Force-refresh invoice status from Oracle for all uninvoiced imported orders.""" + try: + uninvoiced = await sqlite_service.get_uninvoiced_imported_orders() + if not uninvoiced: + return {"updated": 0, "message": "Nicio comanda de verificat"} + + id_comanda_list = [o["id_comanda"] for o in uninvoiced] + invoice_data = await asyncio.to_thread( + invoice_service.check_invoices_for_orders, id_comanda_list + ) + id_to_order = {o["id_comanda"]: o["order_number"] for o in uninvoiced} + updated = 0 + for idc, inv in invoice_data.items(): + order_num = id_to_order.get(idc) + if order_num and inv.get("facturat"): + await sqlite_service.update_order_invoice( + order_num, + serie=inv.get("serie_act"), + numar=str(inv.get("numar_act", "")), + total_fara_tva=inv.get("total_fara_tva"), + total_tva=inv.get("total_tva"), + total_cu_tva=inv.get("total_cu_tva"), + data_act=inv.get("data_act"), + ) + updated += 1 + return {"updated": updated, "checked": len(uninvoiced)} + except Exception as e: + return {"error": str(e), "updated": 0} + + @router.put("/api/sync/schedule") async def update_schedule(config: ScheduleConfig): """Update scheduler configuration.""" diff --git a/api/app/services/import_service.py b/api/app/services/import_service.py index 0aa58e1..b822c0d 100644 --- a/api/app/services/import_service.py +++ b/api/app/services/import_service.py @@ -128,6 +128,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se "error": None } + conn = None try: order_number = clean_web_text(order.number) order_date = convert_web_date(order.date) @@ -138,8 +139,8 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se if database.pool is None: raise RuntimeError("Oracle pool not initialized") - with database.pool.acquire() as conn: - with conn.cursor() as cur: + conn = database.pool.acquire() + with conn.cursor() as cur: # Step 1: Process partner — use shipping person data for name id_partener = cur.var(oracledb.DB_TYPE_NUMBER) @@ -272,8 +273,24 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se error_msg = str(e) result["error"] = error_msg logger.error(f"Oracle error importing order {order.number}: {error_msg}") + if conn: + try: + conn.rollback() + except Exception: + pass except Exception as e: result["error"] = str(e) logger.error(f"Error importing order {order.number}: {e}") + if conn: + try: + conn.rollback() + except Exception: + pass + finally: + if conn: + try: + database.pool.release(conn) + except Exception: + pass return result diff --git a/api/app/services/invoice_service.py b/api/app/services/invoice_service.py index 149f88d..3c41f16 100644 --- a/api/app/services/invoice_service.py +++ b/api/app/services/invoice_service.py @@ -22,7 +22,8 @@ def check_invoices_for_orders(id_comanda_list: list) -> dict: cur.execute(f""" SELECT id_comanda, numar_act, serie_act, - total_fara_tva, total_tva, total_cu_tva + total_fara_tva, total_tva, total_cu_tva, + TO_CHAR(data_act, 'YYYY-MM-DD') AS data_act FROM vanzari WHERE id_comanda IN ({placeholders}) AND sters = 0 """, params) @@ -34,6 +35,7 @@ def check_invoices_for_orders(id_comanda_list: list) -> dict: "total_fara_tva": float(row[3]) if row[3] else 0, "total_tva": float(row[4]) if row[4] else 0, "total_cu_tva": float(row[5]) if row[5] else 0, + "data_act": row[6], } except Exception as e: logger.warning(f"Invoice check failed (table may not exist): {e}") diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index 6093f70..a872470 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -759,7 +759,8 @@ async def get_uninvoiced_imported_orders() -> list: async def update_order_invoice(order_number: str, serie: str = None, numar: str = None, total_fara_tva: float = None, - total_tva: float = None, total_cu_tva: float = None): + total_tva: float = None, total_cu_tva: float = None, + data_act: str = None): """Cache invoice data from Oracle onto the order record.""" db = await get_sqlite() try: @@ -770,10 +771,11 @@ async def update_order_invoice(order_number: str, serie: str = None, factura_total_fara_tva = ?, factura_total_tva = ?, factura_total_cu_tva = ?, + factura_data = ?, invoice_checked_at = datetime('now'), updated_at = datetime('now') WHERE order_number = ? - """, (serie, numar, total_fara_tva, total_tva, total_cu_tva, order_number)) + """, (serie, numar, total_fara_tva, total_tva, total_cu_tva, data_act, order_number)) await db.commit() finally: await db.close() diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index 21a236d..6b8104a 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -91,6 +91,40 @@ def _derive_customer_info(order): return shipping_name, billing_name, customer, payment_method, delivery_method +async def _fix_stale_error_orders(existing_map: dict, run_id: str): + """Fix orders stuck in ERROR status that are actually in Oracle. + + This can happen when a previous import committed partially (no rollback on error). + If the order exists in Oracle COMENZI, update SQLite status to ALREADY_IMPORTED. + """ + from ..database import get_sqlite + db = await get_sqlite() + try: + cursor = await db.execute( + "SELECT order_number FROM orders WHERE status = 'ERROR'" + ) + error_orders = [row["order_number"] for row in await cursor.fetchall()] + fixed = 0 + for order_number in error_orders: + if order_number in existing_map: + id_comanda = existing_map[order_number] + await db.execute(""" + UPDATE orders SET + status = 'ALREADY_IMPORTED', + id_comanda = ?, + error_message = NULL, + updated_at = datetime('now') + WHERE order_number = ? AND status = 'ERROR' + """, (id_comanda, order_number)) + fixed += 1 + _log_line(run_id, f"#{order_number} → status corectat ERROR → ALREADY_IMPORTED (ID: {id_comanda})") + if fixed: + await db.commit() + logger.info(f"Fixed {fixed} stale ERROR orders that exist in Oracle") + finally: + await db.close() + + async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None) -> dict: """Run a full sync cycle. Returns summary dict.""" global _current_sync @@ -191,6 +225,10 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None validation_service.check_orders_in_roa, min_date, conn ) + # Step 2a-fix: Fix ERROR orders that are actually in Oracle + # (can happen if previous import committed partially without rollback) + await _fix_stale_error_orders(existing_map, run_id) + # Step 2b: Validate SKUs (reuse same connection) all_skus = order_reader.get_all_skus(orders) validation = await asyncio.to_thread(validation_service.validate_skus, all_skus, conn) @@ -447,6 +485,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None total_fara_tva=inv.get("total_fara_tva"), total_tva=inv.get("total_tva"), total_cu_tva=inv.get("total_cu_tva"), + data_act=inv.get("data_act"), ) invoices_updated += 1 if invoices_updated: diff --git a/api/app/static/css/style.css b/api/app/static/css/style.css index bef488c..efe9b92 100644 --- a/api/app/static/css/style.css +++ b/api/app/static/css/style.css @@ -349,6 +349,18 @@ body { #quickMapModal + .modal-backdrop, .modal-backdrop ~ .modal-backdrop { z-index: 1055; } +/* ── Quick Map compact lines ─────────────────────── */ +.qm-line { border-bottom: 1px solid #e5e7eb; padding: 6px 0; } +.qm-line:last-child { border-bottom: none; } +.qm-row { display: flex; gap: 6px; align-items: center; } +.qm-codmat-wrap { flex: 1; min-width: 0; } +.qm-rm-btn { padding: 2px 6px; line-height: 1; } +#qmCodmatLines .qm-selected:empty { display: none; } +#quickMapModal .modal-body { padding-top: 12px; padding-bottom: 8px; } +#quickMapModal .modal-header { padding: 10px 16px; } +#quickMapModal .modal-header h5 { font-size: 0.95rem; margin: 0; } +#quickMapModal .modal-footer { padding: 8px 16px; } + /* ── Deleted mapping rows ────────────────────────── */ tr.mapping-deleted td { text-decoration: line-through; diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js index 7b213a6..0e3bf5a 100644 --- a/api/app/static/js/dashboard.js +++ b/api/app/static/js/dashboard.js @@ -460,6 +460,29 @@ function renderCodmatCell(item) { ).join(''); } +// ── Refresh Invoices ────────────────────────────── + +async function refreshInvoices() { + const btn = document.getElementById('btnRefreshInvoices'); + const btnM = document.getElementById('btnRefreshInvoicesMobile'); + if (btn) { btn.disabled = true; btn.textContent = '⟳ Se verifica...'; } + if (btnM) { btnM.disabled = true; } + try { + const res = await fetch('/api/dashboard/refresh-invoices', { method: 'POST' }); + const data = await res.json(); + if (data.error) { + alert('Eroare: ' + data.error); + } else { + loadDashOrders(); + } + } catch (err) { + alert('Eroare: ' + err.message); + } finally { + if (btn) { btn.disabled = false; btn.textContent = '↻ Facturi'; } + if (btnM) { btnM.disabled = false; } + } +} + // ── Order Detail Modal ──────────────────────────── async function openDashOrderDetail(orderNumber) { @@ -473,6 +496,8 @@ async function openDashOrderDetail(orderNumber) { document.getElementById('detailIdAdresaLivr').textContent = '-'; document.getElementById('detailItemsBody').innerHTML = '
${esc(d.codmat)}`).join(' ')
+ : `${esc(item.codmat || '–')}`;
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
return `${esc(item.sku)}${esc(item.sku)}