From 3bd0556f73770bf9ac092ceae304a8ad88791d25 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Fri, 27 Mar 2026 12:25:02 +0000 Subject: [PATCH] feat(safety): price comparison on order detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ROA price comparison to order detail modal — operators can now see if GoMag prices match Oracle before invoicing. Eliminates the #1 risk of invoicing with wrong prices. Backend: - New get_prices_for_order() in validation_service.py — batch Oracle query with dual-policy routing (sales/production by cont 341/345), PRETURI_CU_TVA handling, kit total calculation - Extend GET /api/sync/order/{orderNumber} with per-item pret_roa and order-level price_check summary - GET /api/dashboard/orders returns price_match=null (lightweight) Frontend: - Modal: price check badge (green/red/grey), "Pret GoMag" + "Pret ROA" columns, match dot per row, mismatch rows highlighted - Dashboard: price dot column (₽) in orders table - Mobile: inline mismatch indicator Cache-bust: shared.js?v=16, dashboard.js?v=28 Co-Authored-By: Claude Opus 4.6 (1M context) --- api/app/routers/sync.py | 24 +++- api/app/services/validation_service.py | 186 +++++++++++++++++++++++++ api/app/static/js/dashboard.js | 13 +- api/app/static/js/shared.js | 64 +++++++-- api/app/templates/base.html | 8 +- api/app/templates/dashboard.html | 5 +- api/tests/e2e/test_order_detail.py | 2 +- 7 files changed, 280 insertions(+), 22 deletions(-) diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py index ae18285..aab6e69 100644 --- a/api/app/routers/sync.py +++ b/api/app/routers/sync.py @@ -12,7 +12,7 @@ from pydantic import BaseModel from pathlib import Path from typing import Optional -from ..services import sync_service, scheduler_service, sqlite_service, invoice_service +from ..services import sync_service, scheduler_service, sqlite_service, invoice_service, validation_service from .. import database router = APIRouter(tags=["sync"]) @@ -405,8 +405,26 @@ async def order_detail(order_number: str): "direct": True }] + # Price comparison against ROA Oracle + app_settings = await sqlite_service.get_app_settings() + try: + price_data = await asyncio.to_thread( + validation_service.get_prices_for_order, items, app_settings + ) + price_items = price_data.get("items", {}) + for idx, item in enumerate(items): + pi = price_items.get(idx) + if pi: + item["pret_roa"] = pi.get("pret_roa") + item["price_match"] = pi.get("match") + order_price_check = price_data.get("summary", {}) + except Exception as e: + logger.warning(f"Price comparison failed for order {order_number}: {e}") + order_price_check = {"mismatches": 0, "checked": 0, "oracle_available": False} + # Enrich with invoice data order = detail.get("order", {}) + order["price_check"] = order_price_check if order.get("factura_numar") and order.get("factura_data"): order["invoice"] = { "facturat": True, @@ -445,8 +463,7 @@ async def order_detail(order_number: str): except (json.JSONDecodeError, TypeError): pass - # Add settings for receipt display - app_settings = await sqlite_service.get_app_settings() + # Add settings for receipt display (app_settings already fetched above) order["transport_vat"] = app_settings.get("transport_vat") or "21" order["transport_codmat"] = app_settings.get("transport_codmat") or "" order["discount_codmat"] = app_settings.get("discount_codmat") or "" @@ -484,6 +501,7 @@ async def dashboard_orders(page: int = 1, per_page: int = 50, # Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle all_orders = result["orders"] for o in all_orders: + o["price_match"] = None # Populated when order detail is opened if o.get("factura_numar") and o.get("factura_data"): # Use cached invoice data from SQLite (only if complete) o["invoice"] = { diff --git a/api/app/services/validation_service.py b/api/app/services/validation_service.py index 317e45d..6bc3bb8 100644 --- a/api/app/services/validation_service.py +++ b/api/app/services/validation_service.py @@ -586,3 +586,189 @@ def sync_prices_from_order(orders, mapped_codmat_data: dict, direct_id_map: dict database.pool.release(conn) return updated + + +def get_prices_for_order(items: list[dict], app_settings: dict, conn=None) -> dict: + """Compare GoMag prices with ROA prices for order items. + + Args: + items: list of order items, each with 'sku', 'price', 'quantity', 'codmat_details' + (codmat_details = [{"codmat", "cantitate_roa", "id_articol"?, "cont"?, "direct"?}]) + app_settings: dict with 'id_pol', 'id_pol_productie' + conn: Oracle connection (optional, will acquire if None) + + Returns: { + "items": {idx: {"pret_roa": float|None, "match": bool|None, "pret_gomag": float}}, + "summary": {"mismatches": int, "checked": int, "oracle_available": bool} + } + """ + try: + id_pol = int(app_settings.get("id_pol", 0) or 0) + id_pol_productie = int(app_settings.get("id_pol_productie", 0) or 0) + except (ValueError, TypeError): + id_pol = 0 + id_pol_productie = 0 + + def _empty_result(oracle_available: bool) -> dict: + return { + "items": { + idx: {"pret_roa": None, "match": None, "pret_gomag": float(item.get("price") or 0)} + for idx, item in enumerate(items) + }, + "summary": {"mismatches": 0, "checked": 0, "oracle_available": oracle_available} + } + + if not items or not id_pol: + return _empty_result(oracle_available=False) + + own_conn = conn is None + try: + if own_conn: + conn = database.get_oracle_connection() + + # Step 1: Collect codmats; use id_articol/cont from codmat_details when already known + pre_resolved = {} # {codmat: {"id_articol": int, "cont": str}} + all_codmats = set() + for item in items: + for cd in (item.get("codmat_details") or []): + codmat = cd.get("codmat") + if not codmat: + continue + all_codmats.add(codmat) + if cd.get("id_articol") and codmat not in pre_resolved: + pre_resolved[codmat] = { + "id_articol": cd["id_articol"], + "cont": cd.get("cont") or "", + } + + # Step 2: Resolve missing id_articols via nom_articole + need_resolve = all_codmats - set(pre_resolved.keys()) + if need_resolve: + db_resolved = resolve_codmat_ids(need_resolve, conn=conn) + pre_resolved.update(db_resolved) + + codmat_info = pre_resolved # {codmat: {"id_articol": int, "cont": str}} + + # Step 3: Get PRETURI_CU_TVA flag once per policy + policies = {id_pol} + if id_pol_productie and id_pol_productie != id_pol: + policies.add(id_pol_productie) + + pol_cu_tva = {} # {id_pol: bool} + with conn.cursor() as cur: + for pol in policies: + cur.execute( + "SELECT PRETURI_CU_TVA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :pol", + {"pol": pol}, + ) + row = cur.fetchone() + pol_cu_tva[pol] = (int(row[0] or 0) == 1) if row else False + + # Step 4: Batch query PRET + PROC_TVAV for all id_articols across both policies + all_id_articols = list({ + info["id_articol"] + for info in codmat_info.values() + if info.get("id_articol") + }) + price_map = {} # {(id_pol, id_articol): (pret, proc_tvav)} + + if all_id_articols: + pol_list = list(policies) + pol_placeholders = ",".join([f":p{k}" for k in range(len(pol_list))]) + with conn.cursor() as cur: + for i in range(0, len(all_id_articols), 500): + batch = all_id_articols[i:i + 500] + art_placeholders = ",".join([f":a{j}" for j in range(len(batch))]) + params = {f"a{j}": aid for j, aid in enumerate(batch)} + for k, pol in enumerate(pol_list): + params[f"p{k}"] = pol + cur.execute(f""" + SELECT ID_POL, ID_ARTICOL, PRET, PROC_TVAV + FROM CRM_POLITICI_PRET_ART + WHERE ID_POL IN ({pol_placeholders}) AND ID_ARTICOL IN ({art_placeholders}) + """, params) + for row in cur: + price_map[(row[0], row[1])] = (row[2], row[3]) + + # Step 5: Compute pret_roa per item and compare with GoMag price + result_items = {} + mismatches = 0 + checked = 0 + + for idx, item in enumerate(items): + pret_gomag = float(item.get("price") or 0) + result_items[idx] = {"pret_gomag": pret_gomag, "pret_roa": None, "match": None} + + codmat_details = item.get("codmat_details") or [] + if not codmat_details: + continue + + is_kit = len(codmat_details) > 1 or ( + len(codmat_details) == 1 + and float(codmat_details[0].get("cantitate_roa") or 1) > 1 + ) + + pret_roa_total = 0.0 + all_resolved = True + + for cd in codmat_details: + codmat = cd.get("codmat") + if not codmat: + all_resolved = False + break + + info = codmat_info.get(codmat, {}) + id_articol = info.get("id_articol") + if not id_articol: + all_resolved = False + break + + # Dual-policy routing: cont 341/345 → production, else → sales + cont = str(info.get("cont") or cd.get("cont") or "").strip() + if cont in ("341", "345") and id_pol_productie: + pol = id_pol_productie + else: + pol = id_pol + + price_entry = price_map.get((pol, id_articol)) + if price_entry is None: + all_resolved = False + break + + pret, proc_tvav = price_entry + proc_tvav = float(proc_tvav or 1.19) + + if pol_cu_tva.get(pol): + pret_cu_tva = float(pret or 0) + else: + pret_cu_tva = float(pret or 0) * proc_tvav + + cantitate_roa = float(cd.get("cantitate_roa") or 1) + if is_kit: + pret_roa_total += pret_cu_tva * cantitate_roa + else: + pret_roa_total = pret_cu_tva # cantitate_roa==1 for simple items + + if not all_resolved: + continue + + pret_roa = round(pret_roa_total, 4) + match = abs(pret_gomag - pret_roa) < 0.01 + result_items[idx]["pret_roa"] = pret_roa + result_items[idx]["match"] = match + checked += 1 + if not match: + mismatches += 1 + + logger.info(f"get_prices_for_order: {checked}/{len(items)} checked, {mismatches} mismatches") + return { + "items": result_items, + "summary": {"mismatches": mismatches, "checked": checked, "oracle_available": True}, + } + + except Exception as e: + logger.error(f"get_prices_for_order failed: {e}") + return _empty_result(oracle_available=False) + finally: + if own_conn and conn: + database.pool.release(conn) diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js index fee1790..ee7f760 100644 --- a/api/app/static/js/dashboard.js +++ b/api/app/static/js/dashboard.js @@ -305,7 +305,7 @@ async function loadDashOrders() { const orders = data.orders || []; if (orders.length === 0) { - tbody.innerHTML = 'Nicio comanda'; + tbody.innerHTML = 'Nicio comanda'; } else { tbody.innerHTML = orders.map(o => { const dateStr = fmtDate(o.order_date); @@ -321,6 +321,7 @@ async function loadDashOrders() { ${fmtCost(o.discount_total)} ${orderTotal} ${invoiceDot(o)} + ${priceDot(o)} `; }).join(''); } @@ -340,11 +341,12 @@ async function loadDashOrders() { } const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014'; const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : ''; + const priceMismatch = o.price_match === false ? ' ' : ''; return `
${statusDot(o.status)} ${dateFmt} ${esc(name)} - x${o.items_count || 0}${totalStr ? ' · ' + totalStr + '' : ''} + x${o.items_count || 0}${totalStr ? ' · ' + priceMismatch + '' + totalStr + '' : ''}
`; }).join(''); } @@ -442,6 +444,13 @@ function statusLabelText(status) { } } +function priceDot(order) { + if (order.price_match === true) return ''; + if (order.price_match === false) return ''; + if (order.price_match === null) return ''; + return '–'; +} + function invoiceDot(order) { if (order.status !== 'IMPORTED' && order.status !== 'ALREADY_IMPORTED') return '–'; if (order.invoice && order.invoice.facturat) return ''; diff --git a/api/app/static/js/shared.js b/api/app/static/js/shared.js index dd72a2c..09177e5 100644 --- a/api/app/static/js/shared.js +++ b/api/app/static/js/shared.js @@ -469,7 +469,7 @@ async function renderOrderDetailModal(orderNumber, opts) { document.getElementById('detailIdPartener').textContent = '-'; document.getElementById('detailIdAdresaFact').textContent = '-'; document.getElementById('detailIdAdresaLivr').textContent = '-'; - document.getElementById('detailItemsBody').innerHTML = 'Se incarca...'; + document.getElementById('detailItemsBody').innerHTML = 'Se incarca...'; document.getElementById('detailError').style.display = 'none'; const receiptEl = document.getElementById('detailReceipt'); if (receiptEl) receiptEl.innerHTML = ''; @@ -479,6 +479,8 @@ async function renderOrderDetailModal(orderNumber, opts) { if (invInfo) invInfo.style.display = 'none'; const mobileContainer = document.getElementById('detailItemsMobile'); if (mobileContainer) mobileContainer.innerHTML = ''; + const priceCheckEl = document.getElementById('detailPriceCheck'); + if (priceCheckEl) priceCheckEl.innerHTML = ''; const modalEl = document.getElementById('orderDetailModal'); const existing = bootstrap.Modal.getInstance(modalEl); @@ -498,6 +500,20 @@ async function renderOrderDetailModal(orderNumber, opts) { document.getElementById('detailCustomer').textContent = order.customer_name || '-'; document.getElementById('detailDate').textContent = fmtDate(order.order_date); document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status); + + // Price check badge + const priceCheckEl = document.getElementById('detailPriceCheck'); + if (priceCheckEl) { + const pc = order.price_check; + if (!pc || pc.oracle_available === false) { + priceCheckEl.innerHTML = 'Preturi ROA indisponibile'; + } else if (pc.mismatches === 0) { + priceCheckEl.innerHTML = '✓ Preturi OK'; + } else { + priceCheckEl.innerHTML = `${pc.mismatches} diferente de pret`; + } + } + document.getElementById('detailIdComanda').textContent = order.id_comanda || '-'; document.getElementById('detailIdPartener').textContent = order.id_partener || '-'; document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-'; @@ -520,7 +536,7 @@ async function renderOrderDetailModal(orderNumber, opts) { const items = data.items || []; if (items.length === 0) { - document.getElementById('detailItemsBody').innerHTML = 'Niciun articol'; + document.getElementById('detailItemsBody').innerHTML = 'Niciun articol'; return; } @@ -537,6 +553,10 @@ async function renderOrderDetailModal(orderNumber, opts) { : `${esc(item.codmat || '–')}`; const valoare = (Number(item.price || 0) * Number(item.quantity || 0)); const clickAttr = opts.onQuickMap ? `onclick="_sharedModalQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}',${idx})"` : ''; + const priceInfo = (order.price_check?.items || {})[idx]; + const priceMismatchHtml = priceInfo?.match === false + ? `
ROA: ${fmtNum(priceInfo.pret_roa)} lei
` + : ''; return `
${esc(item.sku)} @@ -548,6 +568,7 @@ async function renderOrderDetailModal(orderNumber, opts) { ${fmtNum(valoare)} lei TVA ${item.vat != null ? Number(item.vat) : '?'}
+ ${priceMismatchHtml}
`; }).join(''); @@ -601,14 +622,29 @@ async function renderOrderDetailModal(orderNumber, opts) { let tableHtml = items.map((item, idx) => { const valoare = Number(item.price || 0) * Number(item.quantity || 0); - return ` + const priceInfo = (order.price_check?.items || {})[idx]; + const pretRoaHtml = priceInfo?.pret_roa != null ? fmtNum(priceInfo.pret_roa) : '–'; + let matchDot, rowStyle; + if (priceInfo == null) { + matchDot = ''; + rowStyle = ''; + } else if (priceInfo.match === false) { + matchDot = ''; + rowStyle = ' style="background:var(--error-light)"'; + } else { + matchDot = ''; + rowStyle = ''; + } + return ` ${esc(item.sku)} ${esc(item.product_name || '-')} ${renderCodmatCell(item)} ${item.quantity || 0} - ${item.price != null ? fmtNum(item.price) : '-'} + ${item.price != null ? fmtNum(item.price) : '-'} + ${pretRoaHtml} ${item.vat != null ? Number(item.vat) : '-'} - ${fmtNum(valoare)} + ${fmtNum(valoare)} + ${matchDot} `; }).join(''); @@ -619,8 +655,10 @@ async function renderOrderDetailModal(orderNumber, opts) { tableHtml += ` Transport ${tCodmat ? '' + esc(tCodmat) + '' : ''} - 1${fmtNum(order.delivery_cost)} - ${tVat}${fmtNum(order.delivery_cost)} + 1${fmtNum(order.delivery_cost)} + + ${tVat}${fmtNum(order.delivery_cost)} + `; } @@ -635,16 +673,20 @@ async function renderOrderDetailModal(orderNumber, opts) { if (amt > 0) tableHtml += ` Discount ${dCodmat ? '' + esc(dCodmat) + '' : ''} - \u20131${fmtNum(amt)} - ${Number(rate)}\u2013${fmtNum(amt)} + \u20131${fmtNum(amt)} + + ${Number(rate)}\u2013${fmtNum(amt)} + `; }); } else { tableHtml += ` Discount ${dCodmat ? '' + esc(dCodmat) + '' : ''} - \u20131${fmtNum(order.discount_total)} - -\u2013${fmtNum(order.discount_total)} + \u20131${fmtNum(order.discount_total)} + + -\u2013${fmtNum(order.discount_total)} + `; } } diff --git a/api/app/templates/base.html b/api/app/templates/base.html index 355c187..f4d1a4c 100644 --- a/api/app/templates/base.html +++ b/api/app/templates/base.html @@ -96,7 +96,7 @@
Client:
Data comanda:
- Status: + Status:
ID Comanda ROA: -
@@ -117,9 +117,11 @@ Produs CODMAT Cant. - Pret + Pret GoMag + Pret ROA TVA% Valoare + ✓ @@ -140,7 +142,7 @@ - + + {% endblock %} diff --git a/api/tests/e2e/test_order_detail.py b/api/tests/e2e/test_order_detail.py index 3dbab25..c1421d9 100644 --- a/api/tests/e2e/test_order_detail.py +++ b/api/tests/e2e/test_order_detail.py @@ -29,7 +29,7 @@ def test_order_detail_items_table_columns(page: Page, app_url: str): texts = headers.all_text_contents() # Current columns (may evolve — check dashboard.html for source of truth) - required_columns = ["SKU", "Produs", "CODMAT", "Cant.", "Pret", "Valoare"] + required_columns = ["SKU", "Produs", "CODMAT", "Cant.", "Pret GoMag", "Pret ROA", "Valoare"] for col in required_columns: assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"