From 5dfd7959084cf123832795ade37f327089a1c17d Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Mon, 16 Mar 2026 18:18:36 +0000 Subject: [PATCH] fix(sync): detect deleted orders and invoices in ROA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, orders deleted from Oracle (sters=1) remained as IMPORTED in SQLite, and deleted invoices kept stale cache data. Now the refresh button and sync cycle re-verify all imported orders against Oracle: - Deleted orders → marked DELETED_IN_ROA with cleared id_comanda - Deleted invoices → invoice cache fields cleared - New status badge for DELETED_IN_ROA in dashboard and logs Co-Authored-By: Claude Opus 4.6 (1M context) --- api/app/routers/sync.py | 91 ++++++++++++++++++++--------- api/app/services/invoice_service.py | 30 ++++++++++ api/app/services/sqlite_service.py | 77 ++++++++++++++++++++++++ api/app/services/sync_service.py | 45 ++++++++++++-- api/app/static/js/dashboard.js | 1 + api/app/static/js/logs.js | 1 + 6 files changed, 213 insertions(+), 32 deletions(-) diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py index d416589..cd47a19 100644 --- a/api/app/routers/sync.py +++ b/api/app/routers/sync.py @@ -492,34 +492,73 @@ 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"} + """Force-refresh invoice/order status from Oracle. - 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)} + Checks: + 1. Uninvoiced orders → did they get invoiced? + 2. Invoiced orders → was the invoice deleted? + 3. All imported orders → was the order deleted from ROA? + """ + try: + invoices_added = 0 + invoices_cleared = 0 + orders_deleted = 0 + + # 1. Check uninvoiced → new invoices + uninvoiced = await sqlite_service.get_uninvoiced_imported_orders() + if uninvoiced: + id_comanda_list = [o["id_comanda"] for o in uninvoiced] + invoice_data = await asyncio.to_thread( + invoice_service.check_invoices_for_orders, id_comanda_list + ) + id_to_order = {o["id_comanda"]: o["order_number"] for o in uninvoiced} + for idc, inv in invoice_data.items(): + order_num = id_to_order.get(idc) + if order_num and inv.get("facturat"): + await sqlite_service.update_order_invoice( + order_num, + serie=inv.get("serie_act"), + numar=str(inv.get("numar_act", "")), + total_fara_tva=inv.get("total_fara_tva"), + total_tva=inv.get("total_tva"), + total_cu_tva=inv.get("total_cu_tva"), + data_act=inv.get("data_act"), + ) + invoices_added += 1 + + # 2. Check invoiced → deleted invoices + invoiced = await sqlite_service.get_invoiced_imported_orders() + if invoiced: + id_comanda_list = [o["id_comanda"] for o in invoiced] + invoice_data = await asyncio.to_thread( + invoice_service.check_invoices_for_orders, id_comanda_list + ) + for o in invoiced: + if o["id_comanda"] not in invoice_data: + await sqlite_service.clear_order_invoice(o["order_number"]) + invoices_cleared += 1 + + # 3. Check all imported → deleted orders in ROA + all_imported = await sqlite_service.get_all_imported_orders() + if all_imported: + id_comanda_list = [o["id_comanda"] for o in all_imported] + existing_ids = await asyncio.to_thread( + invoice_service.check_orders_exist, id_comanda_list + ) + for o in all_imported: + if o["id_comanda"] not in existing_ids: + await sqlite_service.mark_order_deleted_in_roa(o["order_number"]) + orders_deleted += 1 + + checked = len(uninvoiced) + len(invoiced) + len(all_imported) + return { + "checked": checked, + "invoices_added": invoices_added, + "invoices_cleared": invoices_cleared, + "orders_deleted": orders_deleted, + } except Exception as e: - return {"error": str(e), "updated": 0} + return {"error": str(e), "invoices_added": 0} @router.put("/api/sync/schedule") diff --git a/api/app/services/invoice_service.py b/api/app/services/invoice_service.py index 3c41f16..05b4e89 100644 --- a/api/app/services/invoice_service.py +++ b/api/app/services/invoice_service.py @@ -43,3 +43,33 @@ def check_invoices_for_orders(id_comanda_list: list) -> dict: database.pool.release(conn) return result + + +def check_orders_exist(id_comanda_list: list) -> set: + """Check which id_comanda values still exist in Oracle COMENZI (sters=0). + Returns set of id_comanda that exist. + """ + if not id_comanda_list or database.pool is None: + return set() + + existing = set() + conn = database.get_oracle_connection() + try: + with conn.cursor() as cur: + for i in range(0, len(id_comanda_list), 500): + batch = id_comanda_list[i:i+500] + placeholders = ",".join([f":c{j}" for j in range(len(batch))]) + params = {f"c{j}": cid for j, cid in enumerate(batch)} + + cur.execute(f""" + SELECT id_comanda FROM COMENZI + WHERE id_comanda IN ({placeholders}) AND sters = 0 + """, params) + for row in cur: + existing.add(row[0]) + except Exception as e: + logger.warning(f"Order existence check failed: {e}") + finally: + database.pool.release(conn) + + return existing diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index a872470..e96d1d9 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -781,6 +781,83 @@ async def update_order_invoice(order_number: str, serie: str = None, await db.close() +async def get_invoiced_imported_orders() -> list: + """Get imported orders that HAVE cached invoice data (for re-verification).""" + db = await get_sqlite() + try: + cursor = await db.execute(""" + SELECT order_number, id_comanda FROM orders + WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED') + AND id_comanda IS NOT NULL + AND factura_numar IS NOT NULL AND factura_numar != '' + """) + rows = await cursor.fetchall() + return [dict(r) for r in rows] + finally: + await db.close() + + +async def get_all_imported_orders() -> list: + """Get ALL imported orders with id_comanda (for checking if deleted in ROA).""" + db = await get_sqlite() + try: + cursor = await db.execute(""" + SELECT order_number, id_comanda FROM orders + WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED') + AND id_comanda IS NOT NULL + """) + rows = await cursor.fetchall() + return [dict(r) for r in rows] + finally: + await db.close() + + +async def clear_order_invoice(order_number: str): + """Clear cached invoice data when invoice was deleted in ROA.""" + db = await get_sqlite() + try: + await db.execute(""" + UPDATE orders SET + factura_serie = NULL, + factura_numar = NULL, + factura_total_fara_tva = NULL, + factura_total_tva = NULL, + factura_total_cu_tva = NULL, + factura_data = NULL, + invoice_checked_at = datetime('now'), + updated_at = datetime('now') + WHERE order_number = ? + """, (order_number,)) + await db.commit() + finally: + await db.close() + + +async def mark_order_deleted_in_roa(order_number: str): + """Mark an order as deleted in ROA — clears id_comanda and invoice cache.""" + db = await get_sqlite() + try: + await db.execute(""" + UPDATE orders SET + status = 'DELETED_IN_ROA', + id_comanda = NULL, + id_partener = NULL, + factura_serie = NULL, + factura_numar = NULL, + factura_total_fara_tva = NULL, + factura_total_tva = NULL, + factura_total_cu_tva = NULL, + factura_data = NULL, + invoice_checked_at = NULL, + error_message = 'Comanda stearsa din ROA', + updated_at = datetime('now') + WHERE order_number = ? + """, (order_number,)) + await db.commit() + finally: + await db.close() + + # ── App Settings ───────────────────────────────── async def get_app_settings() -> dict: diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index 6b8104a..1c52892 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -463,17 +463,19 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None logger.warning("Too many errors, stopping sync") break - # Step 4b: Invoice check — update cached invoice data - _update_progress("invoices", "Checking invoices...", 0, 0) + # Step 4b: Invoice & order status check — sync with Oracle + _update_progress("invoices", "Checking invoices & order status...", 0, 0) invoices_updated = 0 + invoices_cleared = 0 + orders_deleted = 0 try: + # 4b-1: Uninvoiced → check for new invoices uninvoiced = await sqlite_service.get_uninvoiced_imported_orders() if uninvoiced: id_comanda_list = [o["id_comanda"] for o in uninvoiced] invoice_data = await asyncio.to_thread( invoice_service.check_invoices_for_orders, id_comanda_list ) - # Build reverse map: id_comanda → order_number id_to_order = {o["id_comanda"]: o["order_number"] for o in uninvoiced} for idc, inv in invoice_data.items(): order_num = id_to_order.get(idc) @@ -488,10 +490,39 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None data_act=inv.get("data_act"), ) invoices_updated += 1 - if invoices_updated: - _log_line(run_id, f"Facturi actualizate: {invoices_updated} comenzi facturate") + + # 4b-2: Invoiced → check for deleted invoices + invoiced = await sqlite_service.get_invoiced_imported_orders() + if invoiced: + id_comanda_list = [o["id_comanda"] for o in invoiced] + invoice_data = await asyncio.to_thread( + invoice_service.check_invoices_for_orders, id_comanda_list + ) + for o in invoiced: + if o["id_comanda"] not in invoice_data: + await sqlite_service.clear_order_invoice(o["order_number"]) + invoices_cleared += 1 + + # 4b-3: All imported → check for deleted orders in ROA + all_imported = await sqlite_service.get_all_imported_orders() + if all_imported: + id_comanda_list = [o["id_comanda"] for o in all_imported] + existing_ids = await asyncio.to_thread( + invoice_service.check_orders_exist, id_comanda_list + ) + for o in all_imported: + if o["id_comanda"] not in existing_ids: + await sqlite_service.mark_order_deleted_in_roa(o["order_number"]) + orders_deleted += 1 + + if invoices_updated: + _log_line(run_id, f"Facturi noi: {invoices_updated} comenzi facturate") + if invoices_cleared: + _log_line(run_id, f"Facturi sterse: {invoices_cleared} facturi eliminate din cache") + if orders_deleted: + _log_line(run_id, f"Comenzi sterse din ROA: {orders_deleted}") except Exception as e: - logger.warning(f"Invoice check failed: {e}") + logger.warning(f"Invoice/order status check failed: {e}") # Step 5: Update sync run total_imported = imported_count + already_imported_count # backward-compat @@ -514,6 +545,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None "errors": error_count, "missing_skus": len(validation["missing"]), "invoices_updated": invoices_updated, + "invoices_cleared": invoices_cleared, + "orders_deleted_in_roa": orders_deleted, } _update_progress("completed", diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js index 0e3bf5a..9cb5fe3 100644 --- a/api/app/static/js/dashboard.js +++ b/api/app/static/js/dashboard.js @@ -437,6 +437,7 @@ function orderStatusBadge(status) { case 'ALREADY_IMPORTED': return 'Deja importat'; case 'SKIPPED': return 'Omis'; case 'ERROR': return 'Eroare'; + case 'DELETED_IN_ROA': return 'Sters din ROA'; default: return `${esc(status)}`; } } diff --git a/api/app/static/js/logs.js b/api/app/static/js/logs.js index 0a3f0e3..cc17658 100644 --- a/api/app/static/js/logs.js +++ b/api/app/static/js/logs.js @@ -38,6 +38,7 @@ function orderStatusBadge(status) { case 'ALREADY_IMPORTED': return 'Deja importat'; case 'SKIPPED': return 'Omis'; case 'ERROR': return 'Eroare'; + case 'DELETED_IN_ROA': return 'Sters din ROA'; default: return `${esc(status)}`; } }