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:
@@ -12,7 +12,7 @@ from pydantic import BaseModel
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
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
|
from .. import database
|
||||||
|
|
||||||
router = APIRouter(tags=["sync"])
|
router = APIRouter(tags=["sync"])
|
||||||
@@ -405,8 +405,26 @@ async def order_detail(order_number: str):
|
|||||||
"direct": True
|
"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
|
# Enrich with invoice data
|
||||||
order = detail.get("order", {})
|
order = detail.get("order", {})
|
||||||
|
order["price_check"] = order_price_check
|
||||||
if order.get("factura_numar") and order.get("factura_data"):
|
if order.get("factura_numar") and order.get("factura_data"):
|
||||||
order["invoice"] = {
|
order["invoice"] = {
|
||||||
"facturat": True,
|
"facturat": True,
|
||||||
@@ -445,8 +463,7 @@ async def order_detail(order_number: str):
|
|||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Add settings for receipt display
|
# Add settings for receipt display (app_settings already fetched above)
|
||||||
app_settings = await sqlite_service.get_app_settings()
|
|
||||||
order["transport_vat"] = app_settings.get("transport_vat") or "21"
|
order["transport_vat"] = app_settings.get("transport_vat") or "21"
|
||||||
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
|
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
|
||||||
order["discount_codmat"] = app_settings.get("discount_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
|
# Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle
|
||||||
all_orders = result["orders"]
|
all_orders = result["orders"]
|
||||||
for o in all_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"):
|
if o.get("factura_numar") and o.get("factura_data"):
|
||||||
# Use cached invoice data from SQLite (only if complete)
|
# Use cached invoice data from SQLite (only if complete)
|
||||||
o["invoice"] = {
|
o["invoice"] = {
|
||||||
|
|||||||
@@ -586,3 +586,189 @@ def sync_prices_from_order(orders, mapped_codmat_data: dict, direct_id_map: dict
|
|||||||
database.pool.release(conn)
|
database.pool.release(conn)
|
||||||
|
|
||||||
return updated
|
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)
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ async function loadDashOrders() {
|
|||||||
const orders = data.orders || [];
|
const orders = data.orders || [];
|
||||||
|
|
||||||
if (orders.length === 0) {
|
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 {
|
} else {
|
||||||
tbody.innerHTML = orders.map(o => {
|
tbody.innerHTML = orders.map(o => {
|
||||||
const dateStr = fmtDate(o.order_date);
|
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 text-muted">${fmtCost(o.discount_total)}</td>
|
||||||
<td class="text-end fw-bold">${orderTotal}</td>
|
<td class="text-end fw-bold">${orderTotal}</td>
|
||||||
<td class="text-center">${invoiceDot(o)}</td>
|
<td class="text-center">${invoiceDot(o)}</td>
|
||||||
|
<td class="text-center">${priceDot(o)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -340,11 +341,12 @@ async function loadDashOrders() {
|
|||||||
}
|
}
|
||||||
const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014';
|
const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014';
|
||||||
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
|
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">
|
return `<div class="flat-row" onclick="openDashOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
||||||
${statusDot(o.status)}
|
${statusDot(o.status)}
|
||||||
<span style="color:var(--text-muted)" class="text-nowrap">${dateFmt}</span>
|
<span style="color:var(--text-muted)" class="text-nowrap">${dateFmt}</span>
|
||||||
<span class="grow truncate fw-bold">${esc(name)}</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>`;
|
</div>`;
|
||||||
}).join('');
|
}).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) {
|
function invoiceDot(order) {
|
||||||
if (order.status !== 'IMPORTED' && order.status !== 'ALREADY_IMPORTED') return '–';
|
if (order.status !== 'IMPORTED' && order.status !== 'ALREADY_IMPORTED') return '–';
|
||||||
if (order.invoice && order.invoice.facturat) return '<span class="dot dot-green" title="Facturat"></span>';
|
if (order.invoice && order.invoice.facturat) return '<span class="dot dot-green" title="Facturat"></span>';
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
document.getElementById('detailIdPartener').textContent = '-';
|
document.getElementById('detailIdPartener').textContent = '-';
|
||||||
document.getElementById('detailIdAdresaFact').textContent = '-';
|
document.getElementById('detailIdAdresaFact').textContent = '-';
|
||||||
document.getElementById('detailIdAdresaLivr').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';
|
document.getElementById('detailError').style.display = 'none';
|
||||||
const receiptEl = document.getElementById('detailReceipt');
|
const receiptEl = document.getElementById('detailReceipt');
|
||||||
if (receiptEl) receiptEl.innerHTML = '';
|
if (receiptEl) receiptEl.innerHTML = '';
|
||||||
@@ -479,6 +479,8 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
if (invInfo) invInfo.style.display = 'none';
|
if (invInfo) invInfo.style.display = 'none';
|
||||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||||
if (mobileContainer) mobileContainer.innerHTML = '';
|
if (mobileContainer) mobileContainer.innerHTML = '';
|
||||||
|
const priceCheckEl = document.getElementById('detailPriceCheck');
|
||||||
|
if (priceCheckEl) priceCheckEl.innerHTML = '';
|
||||||
|
|
||||||
const modalEl = document.getElementById('orderDetailModal');
|
const modalEl = document.getElementById('orderDetailModal');
|
||||||
const existing = bootstrap.Modal.getInstance(modalEl);
|
const existing = bootstrap.Modal.getInstance(modalEl);
|
||||||
@@ -498,6 +500,20 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
document.getElementById('detailCustomer').textContent = order.customer_name || '-';
|
document.getElementById('detailCustomer').textContent = order.customer_name || '-';
|
||||||
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
|
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
|
||||||
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
|
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('detailIdComanda').textContent = order.id_comanda || '-';
|
||||||
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
|
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
|
||||||
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
|
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
|
||||||
@@ -520,7 +536,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
|
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
if (items.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,6 +553,10 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
: `<code>${esc(item.codmat || '–')}</code>`;
|
: `<code>${esc(item.codmat || '–')}</code>`;
|
||||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
|
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 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">
|
return `<div class="dif-item">
|
||||||
<div class="dif-row">
|
<div class="dif-row">
|
||||||
<span class="dif-sku${opts.onQuickMap ? ' dif-codmat-link' : ''}" ${clickAttr}>${esc(item.sku)}</span>
|
<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-val">${fmtNum(valoare)} lei</span>
|
||||||
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
|
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${priceMismatchHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -601,14 +622,29 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
|
|
||||||
let tableHtml = items.map((item, idx) => {
|
let tableHtml = items.map((item, idx) => {
|
||||||
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
|
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><code class="${opts.onQuickMap ? 'codmat-link' : ''}" ${clickAttrFn(item, idx)}>${esc(item.sku)}</code></td>
|
||||||
<td>${esc(item.product_name || '-')}</td>
|
<td>${esc(item.product_name || '-')}</td>
|
||||||
<td>${renderCodmatCell(item)}</td>
|
<td>${renderCodmatCell(item)}</td>
|
||||||
<td class="text-end">${item.quantity || 0}</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">${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>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -619,8 +655,10 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
tableHtml += `<tr class="table-light">
|
tableHtml += `<tr class="table-light">
|
||||||
<td></td><td class="text-muted">Transport</td>
|
<td></td><td class="text-muted">Transport</td>
|
||||||
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</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">1</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
|
||||||
<td class="text-end">${tVat}</td><td class="text-end">${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>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,16 +673,20 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
if (amt > 0) tableHtml += `<tr class="table-light">
|
if (amt > 0) tableHtml += `<tr class="table-light">
|
||||||
<td></td><td class="text-muted">Discount</td>
|
<td></td><td class="text-muted">Discount</td>
|
||||||
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</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">\u20131</td><td class="text-end font-data">${fmtNum(amt)}</td>
|
||||||
<td class="text-end">${Number(rate)}</td><td class="text-end">\u2013${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>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
tableHtml += `<tr class="table-light">
|
tableHtml += `<tr class="table-light">
|
||||||
<td></td><td class="text-muted">Discount</td>
|
<td></td><td class="text-muted">Discount</td>
|
||||||
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</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">\u20131</td><td class="text-end font-data">${fmtNum(order.discount_total)}</td>
|
||||||
<td class="text-end">-</td><td class="text-end">\u2013${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>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
|
<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">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>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
|
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
|
||||||
@@ -117,9 +117,11 @@
|
|||||||
<th>Produs</th>
|
<th>Produs</th>
|
||||||
<th>CODMAT</th>
|
<th>CODMAT</th>
|
||||||
<th class="text-end">Cant.</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">TVA%</th>
|
||||||
<th class="text-end">Valoare</th>
|
<th class="text-end">Valoare</th>
|
||||||
|
<th class="text-center">✓</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="detailItemsBody">
|
<tbody id="detailItemsBody">
|
||||||
@@ -140,7 +142,7 @@
|
|||||||
|
|
||||||
<script>window.ROOT_PATH = "{{ rp }}";</script>
|
<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="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>
|
<script>
|
||||||
// Dark mode toggle
|
// Dark mode toggle
|
||||||
function toggleDarkMode() {
|
function toggleDarkMode() {
|
||||||
|
|||||||
@@ -99,10 +99,11 @@
|
|||||||
<th class="text-end">Discount</th>
|
<th class="text-end">Discount</th>
|
||||||
<th class="text-end">Total</th>
|
<th class="text-end">Total</th>
|
||||||
<th style="width:28px" title="Facturat">F</th>
|
<th style="width:28px" title="Facturat">F</th>
|
||||||
|
<th class="text-center" style="width:30px" title="Preturi ROA">₽</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="dashOrdersBody">
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,5 +114,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def test_order_detail_items_table_columns(page: Page, app_url: str):
|
|||||||
texts = headers.all_text_contents()
|
texts = headers.all_text_contents()
|
||||||
|
|
||||||
# Current columns (may evolve — check dashboard.html for source of truth)
|
# 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:
|
for col in required_columns:
|
||||||
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"
|
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user