feat(safety): price comparison on order detail

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) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-03-27 12:25:02 +00:00
parent f6b6b863bd
commit 3bd0556f73
7 changed files with 280 additions and 22 deletions

View File

@@ -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"] = {

View File

@@ -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)

View File

@@ -305,7 +305,7 @@ async function loadDashOrders() {
const orders = data.orders || [];
if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted py-3">Nicio comanda</td></tr>';
} else {
tbody.innerHTML = orders.map(o => {
const dateStr = fmtDate(o.order_date);
@@ -321,6 +321,7 @@ async function loadDashOrders() {
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
<td class="text-end fw-bold">${orderTotal}</td>
<td class="text-center">${invoiceDot(o)}</td>
<td class="text-center">${priceDot(o)}</td>
</tr>`;
}).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 ? '<span class="dot dot-red" style="width:6px;height:6px" title="Pret!="></span> ' : '';
return `<div class="flat-row" onclick="openDashOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
${statusDot(o.status)}
<span style="color:var(--text-muted)" class="text-nowrap">${dateFmt}</span>
<span class="grow truncate fw-bold">${esc(name)}</span>
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · ' + priceMismatch + '<strong>' + totalStr + '</strong>' : ''}</span>
</div>`;
}).join('');
}
@@ -442,6 +444,13 @@ function statusLabelText(status) {
}
}
function priceDot(order) {
if (order.price_match === true) return '<span class="dot dot-green" title="Preturi OK"></span>';
if (order.price_match === false) return '<span class="dot dot-red" title="Diferenta de pret"></span>';
if (order.price_match === null) return '<span class="dot dot-gray" title="Preturi ROA indisponibile"></span>';
return '';
}
function invoiceDot(order) {
if (order.status !== 'IMPORTED' && order.status !== 'ALREADY_IMPORTED') return '';
if (order.invoice && order.invoice.facturat) return '<span class="dot dot-green" title="Facturat"></span>';

View File

@@ -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 = '<tr><td colspan="7" class="text-center">Se incarca...</td></tr>';
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center">Se incarca...</td></tr>';
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 = '<span class="badge" style="background:var(--cancelled-light);color:var(--text-muted)">Preturi ROA indisponibile</span>';
} else if (pc.mismatches === 0) {
priceCheckEl.innerHTML = '<span class="badge" style="background:var(--success-light);color:var(--success-text)">✓ Preturi OK</span>';
} else {
priceCheckEl.innerHTML = `<span class="badge" style="background:var(--error-light);color:var(--error-text)">${pc.mismatches} diferente de pret</span>`;
}
}
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 = '<tr><td colspan="7" class="text-center text-muted">Niciun articol</td></tr>';
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center text-muted">Niciun articol</td></tr>';
return;
}
@@ -537,6 +553,10 @@ async function renderOrderDetailModal(orderNumber, opts) {
: `<code>${esc(item.codmat || '')}</code>`;
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
? `<div class="text-danger" style="font-size:0.7rem">ROA: ${fmtNum(priceInfo.pret_roa)} lei</div>`
: '';
return `<div class="dif-item">
<div class="dif-row">
<span class="dif-sku${opts.onQuickMap ? ' dif-codmat-link' : ''}" ${clickAttr}>${esc(item.sku)}</span>
@@ -548,6 +568,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
<span class="dif-val">${fmtNum(valoare)} lei</span>
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
</div>
${priceMismatchHtml}
</div>`;
}).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 `<tr>
const priceInfo = (order.price_check?.items || {})[idx];
const pretRoaHtml = priceInfo?.pret_roa != null ? fmtNum(priceInfo.pret_roa) : '';
let matchDot, rowStyle;
if (priceInfo == null) {
matchDot = '<span class="dot dot-gray"></span>';
rowStyle = '';
} else if (priceInfo.match === false) {
matchDot = '<span class="dot dot-red"></span>';
rowStyle = ' style="background:var(--error-light)"';
} else {
matchDot = '<span class="dot dot-green"></span>';
rowStyle = '';
}
return `<tr${rowStyle}>
<td><code class="${opts.onQuickMap ? 'codmat-link' : ''}" ${clickAttrFn(item, idx)}>${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</td>
<td>${renderCodmatCell(item)}</td>
<td class="text-end">${item.quantity || 0}</td>
<td class="text-end">${item.price != null ? fmtNum(item.price) : '-'}</td>
<td class="text-end font-data">${item.price != null ? fmtNum(item.price) : '-'}</td>
<td class="text-end font-data">${pretRoaHtml}</td>
<td class="text-end">${item.vat != null ? Number(item.vat) : '-'}</td>
<td class="text-end">${fmtNum(valoare)}</td>
<td class="text-end font-data">${fmtNum(valoare)}</td>
<td class="text-center">${matchDot}</td>
</tr>`;
}).join('');
@@ -619,8 +655,10 @@ async function renderOrderDetailModal(orderNumber, opts) {
tableHtml += `<tr class="table-light">
<td></td><td class="text-muted">Transport</td>
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</td>
<td class="text-end">1</td><td class="text-end">${fmtNum(order.delivery_cost)}</td>
<td class="text-end">${tVat}</td><td class="text-end">${fmtNum(order.delivery_cost)}</td>
<td class="text-end">1</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
<td></td>
<td class="text-end">${tVat}</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
<td></td>
</tr>`;
}
@@ -635,16 +673,20 @@ async function renderOrderDetailModal(orderNumber, opts) {
if (amt > 0) tableHtml += `<tr class="table-light">
<td></td><td class="text-muted">Discount</td>
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
<td class="text-end">\u20131</td><td class="text-end">${fmtNum(amt)}</td>
<td class="text-end">${Number(rate)}</td><td class="text-end">\u2013${fmtNum(amt)}</td>
<td class="text-end">\u20131</td><td class="text-end font-data">${fmtNum(amt)}</td>
<td></td>
<td class="text-end">${Number(rate)}</td><td class="text-end font-data">\u2013${fmtNum(amt)}</td>
<td></td>
</tr>`;
});
} else {
tableHtml += `<tr class="table-light">
<td></td><td class="text-muted">Discount</td>
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
<td class="text-end">\u20131</td><td class="text-end">${fmtNum(order.discount_total)}</td>
<td class="text-end">-</td><td class="text-end">\u2013${fmtNum(order.discount_total)}</td>
<td class="text-end">\u20131</td><td class="text-end font-data">${fmtNum(order.discount_total)}</td>
<td></td>
<td class="text-end">-</td><td class="text-end font-data">\u2013${fmtNum(order.discount_total)}</td>
<td></td>
</tr>`;
}
}

View File

@@ -96,7 +96,7 @@
<div class="col-md-6">
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
<small class="text-muted">Status:</small> <span id="detailStatus"></span>
<small class="text-muted">Status:</small> <span id="detailStatus"></span><span id="detailPriceCheck" class="ms-2"></span>
</div>
<div class="col-md-6">
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
@@ -117,9 +117,11 @@
<th>Produs</th>
<th>CODMAT</th>
<th class="text-end">Cant.</th>
<th class="text-end">Pret</th>
<th class="text-end">Pret GoMag</th>
<th class="text-end">Pret ROA</th>
<th class="text-end">TVA%</th>
<th class="text-end">Valoare</th>
<th class="text-center"></th>
</tr>
</thead>
<tbody id="detailItemsBody">
@@ -140,7 +142,7 @@
<script>window.ROOT_PATH = "{{ rp }}";</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ rp }}/static/js/shared.js?v=15"></script>
<script src="{{ rp }}/static/js/shared.js?v=16"></script>
<script>
// Dark mode toggle
function toggleDarkMode() {

View File

@@ -99,10 +99,11 @@
<th class="text-end">Discount</th>
<th class="text-end">Total</th>
<th style="width:28px" title="Facturat">F</th>
<th class="text-center" style="width:30px" title="Preturi ROA"></th>
</tr>
</thead>
<tbody id="dashOrdersBody">
<tr><td colspan="9" class="text-center text-muted py-3">Se incarca...</td></tr>
<tr><td colspan="10" class="text-center text-muted py-3">Se incarca...</td></tr>
</tbody>
</table>
</div>
@@ -113,5 +114,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=27"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=28"></script>
{% endblock %}

View File

@@ -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}"