From 84b24b1434f52a2d43a73ebc4dd91c842e59675f Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Mon, 16 Mar 2026 17:30:23 +0000 Subject: [PATCH] feat(invoice+import): refresh facturi, detalii factura, fix duplicate CODMAT + rollback - PL/SQL: handle duplicate CODMAT in nom_articole with MAX(id_articol) - import_service: add explicit conn.rollback() on Oracle errors - sync_service: auto-fix stale ERROR orders that exist in Oracle - invoice_service: add data_act (invoice date) from vanzari table - sync router: new POST /api/dashboard/refresh-invoices endpoint - order detail: enrich with invoice data (serie, numar, data factura) - dashboard: refresh invoices button (desktop + mobile icon) - quick map modal: compact single-row layout, pre-populate existing mappings - quick map: link on SKU column instead of CODMAT Co-Authored-By: Claude Opus 4.6 (1M context) --- api/app/database.py | 2 + api/app/routers/sync.py | 85 +++++++++++++- api/app/services/import_service.py | 21 +++- api/app/services/invoice_service.py | 4 +- api/app/services/sqlite_service.py | 6 +- api/app/services/sync_service.py | 39 +++++++ api/app/static/css/style.css | 12 ++ api/app/static/js/dashboard.js | 107 +++++++++++++----- api/app/templates/base.html | 2 +- api/app/templates/dashboard.html | 26 ++++- .../06_pack_import_comenzi.pck | 7 +- 11 files changed, 263 insertions(+), 48 deletions(-) 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 = 'Se incarca...'; document.getElementById('detailError').style.display = 'none'; + const invInfo = document.getElementById('detailInvoiceInfo'); + if (invInfo) invInfo.style.display = 'none'; const detailItemsTotal = document.getElementById('detailItemsTotal'); if (detailItemsTotal) detailItemsTotal.textContent = '-'; const detailOrderTotal = document.getElementById('detailOrderTotal'); @@ -503,6 +528,19 @@ async function openDashOrderDetail(orderNumber) { document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-'; document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-'; + // Invoice info + const invInfo = document.getElementById('detailInvoiceInfo'); + const inv = order.invoice; + if (inv && inv.facturat) { + const serie = inv.serie_act || ''; + const numar = inv.numar_act || ''; + document.getElementById('detailInvoiceNumber').textContent = serie ? `${serie} ${numar}` : numar; + document.getElementById('detailInvoiceDate').textContent = inv.data_act ? fmtDate(inv.data_act) : '-'; + if (invInfo) invInfo.style.display = ''; + } else { + if (invInfo) invInfo.style.display = 'none'; + } + if (order.error_message) { document.getElementById('detailError').textContent = order.error_message; document.getElementById('detailError').style.display = ''; @@ -525,18 +563,21 @@ async function openDashOrderDetail(orderNumber) { document.getElementById('detailItemsTotal').textContent = itemsTotal.toFixed(2) + ' lei'; document.getElementById('detailOrderTotal').textContent = order.order_total != null ? Number(order.order_total).toFixed(2) + ' lei' : '-'; + // Store items for quick map pre-population + window._detailItems = items; + // Mobile article flat list const mobileContainer = document.getElementById('detailItemsMobile'); if (mobileContainer) { mobileContainer.innerHTML = '
' + items.map((item, idx) => { - const codmatList = item.codmat_details?.length - ? item.codmat_details.map(d => `${esc(d.codmat)}`).join(' ') - : `${esc(item.codmat || '–')}`; + const codmatText = item.codmat_details?.length + ? item.codmat_details.map(d => `${esc(d.codmat)}`).join(' ') + : `${esc(item.codmat || '–')}`; const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2); return `
- ${esc(item.sku)} - ${codmatList} + ${esc(item.sku)} + ${codmatText}
${esc(item.product_name || '–')} @@ -547,13 +588,12 @@ async function openDashOrderDetail(orderNumber) { }).join('') + '
'; } - document.getElementById('detailItemsBody').innerHTML = items.map(item => { + document.getElementById('detailItemsBody').innerHTML = items.map((item, idx) => { const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2); - const codmatCell = `${renderCodmatCell(item)}`; return ` - ${esc(item.sku)} + ${esc(item.sku)} ${esc(item.product_name || '-')} - ${codmatCell} + ${renderCodmatCell(item)} ${item.quantity || 0} ${item.price != null ? Number(item.price).toFixed(2) : '-'} ${valoare} @@ -567,7 +607,7 @@ async function openDashOrderDetail(orderNumber) { // ── Quick Map Modal ─────────────────────────────── -function openQuickMap(sku, productName, orderNumber) { +function openQuickMap(sku, productName, orderNumber, itemIdx) { currentQmSku = sku; currentQmOrderNumber = orderNumber; document.getElementById('qmSku').textContent = sku; @@ -576,36 +616,41 @@ function openQuickMap(sku, productName, orderNumber) { const container = document.getElementById('qmCodmatLines'); container.innerHTML = ''; - addQmCodmatLine(); + + // Pre-populate with existing codmat_details if available + const item = (window._detailItems || [])[itemIdx]; + const details = item?.codmat_details; + if (details && details.length > 0) { + details.forEach(d => { + addQmCodmatLine({ codmat: d.codmat, cantitate: d.cantitate_roa, procent: d.procent_pret, denumire: d.denumire }); + }); + } else { + addQmCodmatLine(); + } new bootstrap.Modal(document.getElementById('quickMapModal')).show(); } -function addQmCodmatLine() { +function addQmCodmatLine(prefill) { const container = document.getElementById('qmCodmatLines'); const idx = container.children.length; + const codmatVal = prefill?.codmat || ''; + const cantVal = prefill?.cantitate || 1; + const pctVal = prefill?.procent || 100; + const denumireVal = prefill?.denumire || ''; const div = document.createElement('div'); - div.className = 'border rounded p-2 mb-2 qm-line'; + div.className = 'qm-line'; div.innerHTML = ` -
- - -
- -
-
-
- - -
-
- - -
-
- ${idx > 0 ? `` : ''} -
+
+
+ +
+
+ + + ${idx > 0 ? `` : ''}
+
${escHtml(denumireVal)}
`; container.appendChild(div); diff --git a/api/app/templates/base.html b/api/app/templates/base.html index cbc2e65..e719402 100644 --- a/api/app/templates/base.html +++ b/api/app/templates/base.html @@ -7,7 +7,7 @@ {% set rp = request.scope.get('root_path', '') %} - + diff --git a/api/app/templates/dashboard.html b/api/app/templates/dashboard.html index 70b0ed0..452ae53 100644 --- a/api/app/templates/dashboard.html +++ b/api/app/templates/dashboard.html @@ -69,10 +69,14 @@ +
-
+
+
+ +
@@ -115,6 +119,10 @@ Client:
Data comanda:
Status: +
ID Comanda ROA: -
@@ -164,14 +172,20 @@