From 5e01fefd4cc28eb8153f8f5bd10bdffe17ad0b64 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Mon, 16 Mar 2026 21:50:38 +0000 Subject: [PATCH] feat(sync): handle cancelled GoMag orders (status Anulata / statusId 7) - Add web_status column to orders table (generic name for platform status) - Filter cancelled orders during sync, record as CANCELLED in SQLite - Soft-delete previously-imported cancelled orders in Oracle (if not invoiced) - Add CANCELLED filter pill + badge in dashboard UI - New soft_delete_order_in_roa() and mark_order_cancelled() functions Co-Authored-By: Claude Opus 4.6 (1M context) --- api/app/database.py | 4 +- api/app/services/import_service.py | 48 ++++++++++++ api/app/services/sqlite_service.py | 49 ++++++++++-- api/app/services/sync_service.py | 118 +++++++++++++++++++++++++++-- api/app/static/css/style.css | 1 + api/app/static/js/dashboard.js | 5 +- api/app/static/js/shared.js | 3 + api/app/templates/base.html | 4 +- api/app/templates/dashboard.html | 3 +- 9 files changed, 215 insertions(+), 20 deletions(-) diff --git a/api/app/database.py b/api/app/database.py index bfe4673..89b42ea 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -109,7 +109,8 @@ CREATE TABLE IF NOT EXISTS orders ( invoice_checked_at TEXT, order_total REAL, delivery_cost REAL, - discount_total REAL + discount_total REAL, + web_status TEXT ); CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date); @@ -316,6 +317,7 @@ def init_sqlite(): ("order_total", "REAL"), ("delivery_cost", "REAL"), ("discount_total", "REAL"), + ("web_status", "TEXT"), ]: if col not in order_cols: conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}") diff --git a/api/app/services/import_service.py b/api/app/services/import_service.py index b822c0d..0222c4b 100644 --- a/api/app/services/import_service.py +++ b/api/app/services/import_service.py @@ -294,3 +294,51 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se pass return result + + +def soft_delete_order_in_roa(id_comanda: int) -> dict: + """Soft-delete an order in Oracle ROA (set sters=1 on comenzi + comenzi_detalii). + Returns {"success": bool, "error": str|None, "details_deleted": int} + """ + result = {"success": False, "error": None, "details_deleted": 0} + + if database.pool is None: + result["error"] = "Oracle pool not initialized" + return result + + conn = None + try: + conn = database.pool.acquire() + with conn.cursor() as cur: + # Soft-delete order details + cur.execute( + "UPDATE comenzi_detalii SET sters = 1 WHERE id_comanda = :1 AND sters = 0", + [id_comanda] + ) + result["details_deleted"] = cur.rowcount + + # Soft-delete the order itself + cur.execute( + "UPDATE comenzi SET sters = 1 WHERE id_comanda = :1 AND sters = 0", + [id_comanda] + ) + + conn.commit() + result["success"] = True + logger.info(f"Soft-deleted order ID={id_comanda} in Oracle ROA ({result['details_deleted']} details)") + except Exception as e: + result["error"] = str(e) + logger.error(f"Error soft-deleting order ID={id_comanda}: {e}") + if conn: + try: + conn.rollback() + except Exception: + pass + finally: + if conn: + try: + database.pool.release(conn) + except Exception: + pass + + return result diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index 682e458..d5d7a9e 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -52,7 +52,8 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str, shipping_name: str = None, billing_name: str = None, payment_method: str = None, delivery_method: str = None, order_total: float = None, - delivery_cost: float = None, discount_total: float = None): + delivery_cost: float = None, discount_total: float = None, + web_status: str = None): """Upsert a single order — one row per order_number, status updated in place.""" db = await get_sqlite() try: @@ -62,8 +63,8 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str, id_comanda, id_partener, error_message, missing_skus, items_count, last_sync_run_id, shipping_name, billing_name, payment_method, delivery_method, order_total, - delivery_cost, discount_total) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + delivery_cost, discount_total, web_status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(order_number) DO UPDATE SET customer_name = excluded.customer_name, status = CASE @@ -87,13 +88,14 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str, order_total = COALESCE(excluded.order_total, orders.order_total), delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost), discount_total = COALESCE(excluded.discount_total, orders.discount_total), + web_status = COALESCE(excluded.web_status, orders.web_status), updated_at = datetime('now') """, (order_number, order_date, customer_name, status, id_comanda, id_partener, error_message, json.dumps(missing_skus) if missing_skus else None, items_count, sync_run_id, shipping_name, billing_name, payment_method, delivery_method, order_total, - delivery_cost, discount_total)) + delivery_cost, discount_total, web_status)) await db.commit() finally: await db.close() @@ -118,7 +120,8 @@ async def save_orders_batch(orders_data: list[dict]): Each dict must have: sync_run_id, order_number, order_date, customer_name, status, id_comanda, id_partener, error_message, missing_skus (list|None), items_count, shipping_name, billing_name, payment_method, delivery_method, status_at_run, - items (list of item dicts), delivery_cost (optional), discount_total (optional). + items (list of item dicts), delivery_cost (optional), discount_total (optional), + web_status (optional). """ if not orders_data: return @@ -131,8 +134,8 @@ async def save_orders_batch(orders_data: list[dict]): id_comanda, id_partener, error_message, missing_skus, items_count, last_sync_run_id, shipping_name, billing_name, payment_method, delivery_method, order_total, - delivery_cost, discount_total) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + delivery_cost, discount_total, web_status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(order_number) DO UPDATE SET customer_name = excluded.customer_name, status = CASE @@ -156,6 +159,7 @@ async def save_orders_batch(orders_data: list[dict]): order_total = COALESCE(excluded.order_total, orders.order_total), delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost), discount_total = COALESCE(excluded.discount_total, orders.discount_total), + web_status = COALESCE(excluded.web_status, orders.web_status), updated_at = datetime('now') """, [ (d["order_number"], d["order_date"], d["customer_name"], d["status"], @@ -165,7 +169,8 @@ async def save_orders_batch(orders_data: list[dict]): d.get("shipping_name"), d.get("billing_name"), d.get("payment_method"), d.get("delivery_method"), d.get("order_total"), - d.get("delivery_cost"), d.get("discount_total")) + d.get("delivery_cost"), d.get("discount_total"), + d.get("web_status")) for d in orders_data ]) @@ -619,6 +624,7 @@ async def get_run_orders_filtered(run_id: str, status_filter: str = "all", "skipped": status_counts.get("SKIPPED", 0), "error": status_counts.get("ERROR", 0), "already_imported": status_counts.get("ALREADY_IMPORTED", 0), + "cancelled": status_counts.get("CANCELLED", 0), "total": sum(status_counts.values()) } } @@ -715,6 +721,7 @@ async def get_orders(page: int = 1, per_page: int = 50, "imported_all": status_counts.get("IMPORTED", 0) + status_counts.get("ALREADY_IMPORTED", 0), "skipped": status_counts.get("SKIPPED", 0), "error": status_counts.get("ERROR", 0), + "cancelled": status_counts.get("CANCELLED", 0), "total": sum(status_counts.values()), "uninvoiced_sqlite": uninvoiced_sqlite, } @@ -860,6 +867,32 @@ async def mark_order_deleted_in_roa(order_number: str): await db.close() +async def mark_order_cancelled(order_number: str, web_status: str = "Anulata"): + """Mark an order as cancelled from GoMag. Clears id_comanda and invoice cache.""" + db = await get_sqlite() + try: + await db.execute(""" + UPDATE orders SET + status = 'CANCELLED', + id_comanda = NULL, + id_partener = NULL, + factura_serie = NULL, + factura_numar = NULL, + factura_total_fara_tva = NULL, + factura_total_tva = NULL, + factura_total_cu_tva = NULL, + factura_data = NULL, + invoice_checked_at = NULL, + web_status = ?, + error_message = 'Comanda anulata in GoMag', + updated_at = datetime('now') + WHERE order_number = ? + """, (web_status, order_number)) + await db.commit() + finally: + await db.close() + + # ── App Settings ───────────────────────────────── async def get_app_settings() -> dict: diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index c9ad4ca..817e446 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -72,7 +72,7 @@ async def prepare_sync(id_pol: int = None, id_sectie: int = None) -> dict: "phase_text": "Starting...", "progress_current": 0, "progress_total": 0, - "counts": {"imported": 0, "skipped": 0, "errors": 0, "already_imported": 0}, + "counts": {"imported": 0, "skipped": 0, "errors": 0, "already_imported": 0, "cancelled": 0}, } return {"run_id": run_id, "status": "starting"} @@ -152,7 +152,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None "phase_text": "Reading JSON files...", "progress_current": 0, "progress_total": 0, - "counts": {"imported": 0, "skipped": 0, "errors": 0, "already_imported": 0}, + "counts": {"imported": 0, "skipped": 0, "errors": 0, "already_imported": 0, "cancelled": 0}, } _update_progress("reading", "Reading JSON files...") @@ -212,6 +212,104 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count} return summary + # ── Separate cancelled orders (GoMag status "Anulata" / statusId "7") ── + cancelled_orders = [o for o in orders if o.status_id == "7" or (o.status and o.status.lower() == "anulata")] + active_orders = [o for o in orders if o not in cancelled_orders] + cancelled_count = len(cancelled_orders) + + if cancelled_orders: + _log_line(run_id, f"Comenzi anulate in GoMag: {cancelled_count}") + + # Record cancelled orders in SQLite + cancelled_batch = [] + for order in cancelled_orders: + shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order) + order_items_data = [ + {"sku": item.sku, "product_name": item.name, + "quantity": item.quantity, "price": item.price, "vat": item.vat, + "mapping_status": "unknown", "codmat": None, + "id_articol": None, "cantitate_roa": None} + for item in order.items + ] + cancelled_batch.append({ + "sync_run_id": run_id, "order_number": order.number, + "order_date": order.date, "customer_name": customer, + "status": "CANCELLED", "status_at_run": "CANCELLED", + "id_comanda": None, "id_partener": None, + "error_message": "Comanda anulata in GoMag", + "missing_skus": None, + "items_count": len(order.items), + "shipping_name": shipping_name, "billing_name": billing_name, + "payment_method": payment_method, "delivery_method": delivery_method, + "order_total": order.total or None, + "delivery_cost": order.delivery_cost or None, + "discount_total": order.discount_total or None, + "web_status": order.status or "Anulata", + "items": order_items_data, + }) + _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → ANULAT in GoMag") + + await sqlite_service.save_orders_batch(cancelled_batch) + + # Check if any cancelled orders were previously imported + from ..database import get_sqlite as _get_sqlite + db_check = await _get_sqlite() + try: + cancelled_numbers = [o.number for o in cancelled_orders] + placeholders = ",".join("?" for _ in cancelled_numbers) + cursor = await db_check.execute(f""" + SELECT order_number, id_comanda FROM orders + WHERE order_number IN ({placeholders}) + AND id_comanda IS NOT NULL + AND status = 'CANCELLED' + """, cancelled_numbers) + previously_imported = [dict(r) for r in await cursor.fetchall()] + finally: + await db_check.close() + + if previously_imported: + _log_line(run_id, f"Verificare {len(previously_imported)} comenzi anulate care erau importate in Oracle...") + # Check which have invoices + id_comanda_list = [o["id_comanda"] for o in previously_imported] + invoice_data = await asyncio.to_thread( + invoice_service.check_invoices_for_orders, id_comanda_list + ) + + for o in previously_imported: + idc = o["id_comanda"] + order_num = o["order_number"] + if idc in invoice_data: + # Invoiced — keep in Oracle, just log warning + _log_line(run_id, + f"#{order_num} → ANULAT dar FACTURAT (factura {invoice_data[idc].get('serie_act', '')}" + f"{invoice_data[idc].get('numar_act', '')}) — NU se sterge din Oracle") + # Update web_status but keep CANCELLED status (already set by batch above) + else: + # Not invoiced — soft-delete in Oracle + del_result = await asyncio.to_thread( + import_service.soft_delete_order_in_roa, idc + ) + if del_result["success"]: + # Clear id_comanda via mark_order_cancelled + await sqlite_service.mark_order_cancelled(order_num, "Anulata") + _log_line(run_id, + f"#{order_num} → ANULAT + STERS din Oracle (ID: {idc}, " + f"{del_result['details_deleted']} detalii)") + else: + _log_line(run_id, + f"#{order_num} → ANULAT dar EROARE la stergere Oracle: {del_result['error']}") + + orders = active_orders + + if not orders: + _log_line(run_id, "Nicio comanda activa dupa filtrare anulate.") + await sqlite_service.update_sync_run(run_id, "completed", cancelled_count, 0, 0, 0) + _update_progress("completed", f"No active orders ({cancelled_count} cancelled)") + summary = {"run_id": run_id, "status": "completed", + "message": f"No active orders ({cancelled_count} cancelled)", + "json_files": json_count, "cancelled": cancelled_count} + return summary + _update_progress("validation", f"Validating {len(orders)} orders...", 0, len(orders)) # ── Single Oracle connection for entire validation phase ── @@ -351,6 +449,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None "order_total": order.total or None, "delivery_cost": order.delivery_cost or None, "discount_total": order.discount_total or None, + "web_status": order.status or None, "items": order_items_data, }) _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → DEJA IMPORTAT (ID: {id_comanda_roa})") @@ -381,6 +480,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None "order_total": order.total or None, "delivery_cost": order.delivery_cost or None, "discount_total": order.discount_total or None, + "web_status": order.status or None, "items": order_items_data, }) _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})") @@ -437,6 +537,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None order_total=order.total or None, delivery_cost=order.delivery_cost or None, discount_total=order.discount_total or None, + web_status=order.status or None, ) await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED") # Store ROA address IDs (R9) @@ -465,6 +566,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None order_total=order.total or None, delivery_cost=order.delivery_cost or None, discount_total=order.discount_total or None, + web_status=order.status or None, ) await sqlite_service.add_sync_run_order(run_id, order.number, "ERROR") await sqlite_service.add_order_items(order.number, order_items_data) @@ -548,13 +650,14 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None "run_id": run_id, "status": status, "json_files": json_count, - "total_orders": len(orders), + "total_orders": len(orders) + cancelled_count, "new_orders": len(truly_importable), "imported": total_imported, "new_imported": imported_count, "already_imported": already_imported_count, "skipped": len(skipped), "errors": error_count, + "cancelled": cancelled_count, "missing_skus": len(validation["missing"]), "invoices_updated": invoices_updated, "invoices_cleared": invoices_cleared, @@ -562,24 +665,25 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None } _update_progress("completed", - f"Completed: {imported_count} new, {already_imported_count} already, {len(skipped)} skipped, {error_count} errors", + f"Completed: {imported_count} new, {already_imported_count} already, {len(skipped)} skipped, {error_count} errors, {cancelled_count} cancelled", len(truly_importable), len(truly_importable), {"imported": imported_count, "skipped": len(skipped), "errors": error_count, - "already_imported": already_imported_count}) + "already_imported": already_imported_count, "cancelled": cancelled_count}) if _current_sync: _current_sync["status"] = status _current_sync["finished_at"] = datetime.now().isoformat() logger.info( f"Sync {run_id} completed: {imported_count} new, {already_imported_count} already imported, " - f"{len(skipped)} skipped, {error_count} errors" + f"{len(skipped)} skipped, {error_count} errors, {cancelled_count} cancelled" ) duration = (datetime.now() - started_dt).total_seconds() _log_line(run_id, "") + cancelled_text = f", {cancelled_count} anulate" if cancelled_count else "" _run_logs[run_id].append( f"Finalizat: {imported_count} importate, {already_imported_count} deja importate, " - f"{len(skipped)} nemapate, {error_count} erori din {len(orders)} comenzi | Durata: {int(duration)}s" + f"{len(skipped)} nemapate, {error_count} erori{cancelled_text} din {len(orders) + cancelled_count} comenzi | Durata: {int(duration)}s" ) return summary diff --git a/api/app/static/css/style.css b/api/app/static/css/style.css index efe9b92..81cc2d4 100644 --- a/api/app/static/css/style.css +++ b/api/app/static/css/style.css @@ -289,6 +289,7 @@ body { .fc-red { color: #dc2626; } .fc-neutral { color: #6b7280; } .fc-blue { color: #2563eb; } +.fc-dark { color: #374151; } /* ── Log viewer (dark theme — keep as-is) ────────── */ .log-viewer { diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js index 49c7f19..7af2b19 100644 --- a/api/app/static/js/dashboard.js +++ b/api/app/static/js/dashboard.js @@ -283,6 +283,7 @@ async function loadDashOrders() { if (el('cntErr')) el('cntErr').textContent = c.error || c.errors || 0; if (el('cntFact')) el('cntFact').textContent = c.facturate || 0; if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0; + if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0; const tbody = document.getElementById('dashOrdersBody'); const orders = data.orders || []; @@ -340,7 +341,8 @@ async function loadDashOrders() { { label: 'Omise', count: c.skipped || 0, value: 'SKIPPED', active: activeStatus === 'SKIPPED', colorClass: 'fc-yellow' }, { label: 'Erori', count: c.error || c.errors || 0, value: 'ERROR', active: activeStatus === 'ERROR', colorClass: 'fc-red' }, { label: 'Fact.', count: c.facturate || 0, value: 'INVOICED', active: activeStatus === 'INVOICED', colorClass: 'fc-green' }, - { label: 'Nefact.', count: c.nefacturate || c.uninvoiced || 0, value: 'UNINVOICED', active: activeStatus === 'UNINVOICED', colorClass: 'fc-red' } + { label: 'Nefact.', count: c.nefacturate || c.uninvoiced || 0, value: 'UNINVOICED', active: activeStatus === 'UNINVOICED', colorClass: 'fc-red' }, + { label: 'Anulate', count: c.cancelled || 0, value: 'CANCELLED', active: activeStatus === 'CANCELLED', colorClass: 'fc-dark' } ], (val) => { document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active')); const pill = document.querySelector(`.filter-pill[data-status="${val}"]`); @@ -438,6 +440,7 @@ function orderStatusBadge(status) { case 'ALREADY_IMPORTED': return 'Deja importat'; case 'SKIPPED': return 'Omis'; case 'ERROR': return 'Eroare'; + case 'CANCELLED': return 'Anulat'; case 'DELETED_IN_ROA': return 'Sters din ROA'; default: return `${esc(status)}`; } diff --git a/api/app/static/js/shared.js b/api/app/static/js/shared.js index 2078d7b..075cc32 100644 --- a/api/app/static/js/shared.js +++ b/api/app/static/js/shared.js @@ -219,6 +219,9 @@ function statusDot(status) { case 'ERROR': case 'FAILED': return ''; + case 'CANCELLED': + case 'DELETED_IN_ROA': + return ''; default: return ''; } diff --git a/api/app/templates/base.html b/api/app/templates/base.html index e719402..e4147ee 100644 --- a/api/app/templates/base.html +++ b/api/app/templates/base.html @@ -7,7 +7,7 @@ {% set rp = request.scope.get('root_path', '') %} - + @@ -29,7 +29,7 @@ - + {% block scripts %}{% endblock %} diff --git a/api/app/templates/dashboard.html b/api/app/templates/dashboard.html index 8ad24da..29f9b94 100644 --- a/api/app/templates/dashboard.html +++ b/api/app/templates/dashboard.html @@ -69,6 +69,7 @@ + @@ -199,5 +200,5 @@ {% endblock %} {% block scripts %} - + {% endblock %}