feat(price): detect quantity discounts via baseprice, show Disc. badge

GoMag sends baseprice (catalog price) alongside price (discounted).
When baseprice > price, the item is volume-discounted — skip ROA
price comparison and show amber "Disc." badge instead of false
mismatch. Strikethrough baseprice in price column for transparency.

Pipeline: parse baseprice → store in SQLite → skip in validation →
pass flag to frontend → render badge (desktop + mobile pill badge
with aria-label, opacity 0.6 for dark mode).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-07 17:06:37 +00:00
parent 0f817b2130
commit 057e62fc04
10 changed files with 103 additions and 18 deletions

View File

@@ -27,3 +27,10 @@
**Effort:** S (human: ~2h / CC: ~10min) **Effort:** S (human: ~2h / CC: ~10min)
**Context:** TIER 2 matched county+city without street, reusing VFP-era addresses with wrong streets. After removal (2026-04-06), new imports create correct addresses. Old wrong addresses stay. Could identify them by: address has id_loc but no linked order rows, and was last modified before 2026-04-06. **Context:** TIER 2 matched county+city without street, reusing VFP-era addresses with wrong streets. After removal (2026-04-06), new imports create correct addresses. Old wrong addresses stay. Could identify them by: address has id_loc but no linked order rows, and was last modified before 2026-04-06.
**Depends on:** TIER 2 removal deployed and verified. **Depends on:** TIER 2 removal deployed and verified.
## P3: Extract match-column badge styles to CSS classes
**What:** Replace inline styles on Kit and Disc. badges (in shared.js) with CSS classes (e.g., `.match-badge-kit`, `.match-badge-disc`).
**Why:** Currently both badges use identical inline `style="background:var(--X-light);color:var(--X-text);font-size:10px;padding:2px 6px"`. If a 3rd badge type appears, inline styles become a maintenance burden.
**Effort:** XS (human: ~30min / CC: ~5min)
**Context:** Low priority. Two inline-styled badges is fine. Trigger: when a 3rd badge type is needed in the price match column.
**Depends on:** Quantity discount feature shipped.

View File

@@ -169,6 +169,7 @@ CREATE TABLE IF NOT EXISTS order_items (
product_name TEXT, product_name TEXT,
quantity REAL, quantity REAL,
price REAL, price REAL,
baseprice REAL,
vat REAL, vat REAL,
mapping_status TEXT, mapping_status TEXT,
codmat TEXT, codmat TEXT,
@@ -357,6 +358,14 @@ def init_sqlite():
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}") conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
logger.info(f"Migrated orders: added column {col}") logger.info(f"Migrated orders: added column {col}")
# Migrate order_items: add baseprice column
cursor = conn.execute("PRAGMA table_info(order_items)")
oi_cols = {row[1] for row in cursor.fetchall()}
if "baseprice" not in oi_cols:
conn.execute("ALTER TABLE order_items ADD COLUMN baseprice REAL")
conn.execute("UPDATE orders SET price_match = NULL WHERE price_match = 0")
logger.info("Migrated order_items: added baseprice; reset price_match for re-check")
conn.commit() conn.commit()
# Backfill address_mismatch from stored address JSON # Backfill address_mismatch from stored address JSON

View File

@@ -465,6 +465,9 @@ async def order_detail(order_number: str):
item["price_match"] = pi.get("match") item["price_match"] = pi.get("match")
if pi.get("kit"): if pi.get("kit"):
item["kit"] = True item["kit"] = True
if pi.get("quantity_discount"):
item["quantity_discount"] = True
item["baseprice"] = pi.get("baseprice")
order_price_check = price_data.get("summary", {}) order_price_check = price_data.get("summary", {})
# Cache price_match in SQLite if changed # Cache price_match in SQLite if changed
if order_price_check.get("oracle_available") is not False: if order_price_check.get("oracle_available") is not False:

View File

@@ -17,6 +17,7 @@ class OrderItem:
price: float price: float
quantity: float quantity: float
vat: float vat: float
baseprice: float = 0.0
@dataclass @dataclass
class OrderBilling: class OrderBilling:
@@ -116,7 +117,8 @@ def _parse_order(order_id: str, data: dict, source_file: str) -> OrderData:
name=str(item.get("name", "")), name=str(item.get("name", "")),
price=float(item.get("price", 0) or 0), price=float(item.get("price", 0) or 0),
quantity=float(item.get("quantity", 0) or 0), quantity=float(item.get("quantity", 0) or 0),
vat=float(item.get("vat", 0) or 0) vat=float(item.get("vat", 0) or 0),
baseprice=float(item.get("baseprice", 0) or 0)
)) ))
# Parse billing # Parse billing

View File

@@ -200,16 +200,17 @@ async def save_orders_batch(orders_data: list[dict]):
all_items.append(( all_items.append((
d["order_number"], d["order_number"],
item.get("sku"), item.get("product_name"), item.get("sku"), item.get("product_name"),
item.get("quantity"), item.get("price"), item.get("vat"), item.get("quantity"), item.get("price"), item.get("baseprice"),
item.get("vat"),
item.get("mapping_status"), item.get("codmat"), item.get("mapping_status"), item.get("codmat"),
item.get("id_articol"), item.get("cantitate_roa") item.get("id_articol"), item.get("cantitate_roa")
)) ))
if all_items: if all_items:
await db.executemany(""" await db.executemany("""
INSERT OR IGNORE INTO order_items INSERT OR IGNORE INTO order_items
(order_number, sku, product_name, quantity, price, vat, (order_number, sku, product_name, quantity, price, baseprice,
mapping_status, codmat, id_articol, cantitate_roa) vat, mapping_status, codmat, id_articol, cantitate_roa)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", all_items) """, all_items)
await db.commit() await db.commit()
@@ -535,13 +536,14 @@ async def add_order_items(order_number: str, items: list):
try: try:
await db.executemany(""" await db.executemany("""
INSERT OR IGNORE INTO order_items INSERT OR IGNORE INTO order_items
(order_number, sku, product_name, quantity, price, vat, (order_number, sku, product_name, quantity, price, baseprice,
mapping_status, codmat, id_articol, cantitate_roa) vat, mapping_status, codmat, id_articol, cantitate_roa)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", [ """, [
(order_number, (order_number,
item.get("sku"), item.get("product_name"), item.get("sku"), item.get("product_name"),
item.get("quantity"), item.get("price"), item.get("vat"), item.get("quantity"), item.get("price"), item.get("baseprice"),
item.get("vat"),
item.get("mapping_status"), item.get("codmat"), item.get("mapping_status"), item.get("codmat"),
item.get("id_articol"), item.get("cantitate_roa")) item.get("id_articol"), item.get("cantitate_roa"))
for item in items for item in items

View File

@@ -265,7 +265,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order) shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
order_items_data = [ order_items_data = [
{"sku": item.sku, "product_name": item.name, {"sku": item.sku, "product_name": item.name,
"quantity": item.quantity, "price": item.price, "vat": item.vat, "quantity": item.quantity, "price": item.price,
"baseprice": item.baseprice, "vat": item.vat,
"mapping_status": "unknown", "codmat": None, "mapping_status": "unknown", "codmat": None,
"id_articol": None, "cantitate_roa": None} "id_articol": None, "cantitate_roa": None}
for item in order.items for item in order.items
@@ -590,7 +591,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
id_comanda_roa = existing_map.get(order.number) id_comanda_roa = existing_map.get(order.number)
order_items_data = [ order_items_data = [
{"sku": item.sku, "product_name": item.name, {"sku": item.sku, "product_name": item.name,
"quantity": item.quantity, "price": item.price, "vat": item.vat, "quantity": item.quantity, "price": item.price,
"baseprice": item.baseprice, "vat": item.vat,
"mapping_status": "mapped" if item.sku in validation["mapped"] else "direct", "mapping_status": "mapped" if item.sku in validation["mapped"] else "direct",
"codmat": None, "id_articol": None, "cantitate_roa": None} "codmat": None, "id_articol": None, "cantitate_roa": None}
for item in order.items for item in order.items
@@ -630,7 +632,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order) shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
order_items_data = [ order_items_data = [
{"sku": item.sku, "product_name": item.name, {"sku": item.sku, "product_name": item.name,
"quantity": item.quantity, "price": item.price, "vat": item.vat, "quantity": item.quantity, "price": item.price,
"baseprice": item.baseprice, "vat": item.vat,
"mapping_status": "missing" if item.sku in validation["missing"] else "mapping_status": "missing" if item.sku in validation["missing"] else
"mapped" if item.sku in validation["mapped"] else "direct", "mapped" if item.sku in validation["mapped"] else "direct",
"codmat": None, "id_articol": None, "cantitate_roa": None} "codmat": None, "id_articol": None, "cantitate_roa": None}
@@ -778,7 +781,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
ms = "mapped" if item.sku in validation["mapped"] else "direct" ms = "mapped" if item.sku in validation["mapped"] else "direct"
order_items_data.append({ order_items_data.append({
"sku": item.sku, "product_name": item.name, "sku": item.sku, "product_name": item.name,
"quantity": item.quantity, "price": item.price, "vat": item.vat, "quantity": item.quantity, "price": item.price,
"baseprice": item.baseprice, "vat": item.vat,
"mapping_status": ms, "codmat": None, "id_articol": None, "mapping_status": ms, "codmat": None, "id_articol": None,
"cantitate_roa": None "cantitate_roa": None
}) })

View File

@@ -714,6 +714,13 @@ def get_prices_for_order(items: list[dict], app_settings: dict, conn=None) -> di
result_items[idx]["kit"] = True result_items[idx]["kit"] = True
continue continue
# Quantity discount: baseprice > price means GoMag applied a volume discount
baseprice = float(item.get("baseprice") or 0)
if baseprice > 0 and baseprice > pret_gomag + 0.01:
result_items[idx]["quantity_discount"] = True
result_items[idx]["baseprice"] = baseprice
continue
pret_roa_total = 0.0 pret_roa_total = 0.0
all_resolved = True all_resolved = True

View File

@@ -614,9 +614,11 @@ async function renderOrderDetailModal(orderNumber, opts) {
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 = { pret_roa: item.pret_roa, match: item.price_match }; const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
const priceMismatchHtml = priceInfo.match === false const priceMismatchHtml = item.quantity_discount
? `<div style="font-size:0.7rem"><span class="badge" style="background:var(--warning-light);color:var(--warning-text);font-size:9px;padding:1px 5px" aria-label="Discount aplicat, pret catalog ${fmtNum(item.baseprice)} lei">Disc.</span> <span style="text-decoration:line-through;opacity:0.6">${fmtNum(item.baseprice)}</span> ${fmtNum(item.price)} lei</div>`
: (priceInfo.match === false
? `<div class="text-danger" style="font-size:0.7rem">ROA: ${fmtNum(priceInfo.pret_roa)} lei</div>` ? `<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>
@@ -688,6 +690,10 @@ async function renderOrderDetailModal(orderNumber, opts) {
if (item.kit) { if (item.kit) {
matchDot = '<span class="badge" style="background:var(--info-light);color:var(--info-text);font-size:10px;padding:2px 6px">Kit</span>'; matchDot = '<span class="badge" style="background:var(--info-light);color:var(--info-text);font-size:10px;padding:2px 6px">Kit</span>';
rowStyle = ''; rowStyle = '';
} else if (item.quantity_discount) {
const bpTitle = item.baseprice ? `Catalog: ${fmtNum(item.baseprice)} lei` : 'Discount GoMag';
matchDot = `<span class="badge" style="background:var(--warning-light);color:var(--warning-text);font-size:10px;padding:2px 6px" title="${bpTitle}" aria-label="Discount aplicat, pret catalog ${fmtNum(item.baseprice)} lei">Disc.</span>`;
rowStyle = '';
} else if (priceInfo.pret_roa == null && priceInfo.match == null) { } else if (priceInfo.pret_roa == null && priceInfo.match == null) {
matchDot = '<span class="dot dot-gray"></span>'; matchDot = '<span class="dot dot-gray"></span>';
rowStyle = ''; rowStyle = '';
@@ -703,7 +709,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
<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 font-data">${item.price != null ? fmtNum(item.price) : '-'}</td> <td class="text-end font-data">${item.quantity_discount && item.baseprice ? `<span style="text-decoration:line-through;opacity:0.6;font-size:0.8em">${fmtNum(item.baseprice)}</span> ${fmtNum(item.price)}` : (item.price != null ? fmtNum(item.price) : '-')}</td>
<td class="text-end font-data">${pretRoaHtml}</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 font-data">${fmtNum(valoare)}</td> <td class="text-end font-data">${fmtNum(valoare)}</td>

View File

@@ -168,7 +168,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=33"></script> <script src="{{ rp }}/static/js/shared.js?v=34"></script>
<script> <script>
// Dark mode toggle // Dark mode toggle
function toggleDarkMode() { function toggleDarkMode() {

View File

@@ -636,6 +636,51 @@ class TestGetPricesForOrderCantitateRoa:
assert result["summary"]["mismatches"] == 0 assert result["summary"]["mismatches"] == 0
class TestGetPricesForOrderQuantityDiscount:
"""baseprice > price means GoMag applied a discount — skip price comparison."""
def test_discount_detected(self):
"""baseprice > price: quantity_discount=True, match=None, mismatches=0."""
items = [{"sku": "SKU-DISC", "price": 28.59, "baseprice": 33.0, "quantity": 48,
"codmat_details": [{"codmat": "COD1", "cantitate_roa": 1,
"id_articol": 100, "cont": "345"}]}]
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={100: (28.99, 1.19)})
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
assert result["items"][0].get("quantity_discount") is True
assert result["items"][0]["match"] is None
assert result["summary"]["mismatches"] == 0
def test_no_discount_when_baseprice_equals_price(self):
"""baseprice == price: normal comparison."""
items = [{"sku": "SKU-FULL", "price": 28.99, "baseprice": 28.99, "quantity": 1,
"codmat_details": [{"codmat": "COD2", "cantitate_roa": 1,
"id_articol": 200, "cont": "345"}]}]
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={200: (28.99, 1.19)})
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
assert result["items"][0].get("quantity_discount") is not True
assert result["items"][0]["match"] is True
def test_no_discount_when_baseprice_missing(self):
"""baseprice=0 (missing): normal comparison."""
items = [{"sku": "SKU-OLD", "price": 28.99, "quantity": 1,
"codmat_details": [{"codmat": "COD3", "cantitate_roa": 1,
"id_articol": 300, "cont": "345"}]}]
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={300: (28.99, 1.19)})
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
assert result["items"][0].get("quantity_discount") is not True
assert result["items"][0]["match"] is True
def test_kit_takes_precedence_over_discount(self):
"""Kit check runs before discount check — kit wins."""
items = [{"sku": "SKU-KITDISC", "price": 20.0, "baseprice": 25.0, "quantity": 10,
"codmat_details": [{"codmat": "COD4", "cantitate_roa": 2,
"id_articol": 400, "cont": "345"}]}]
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={400: (10.0, 1.19)})
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
assert result["items"][0].get("kit") is True
assert result["items"][0].get("quantity_discount") is not True
# ── normalize_company_name (II, PFA, INTREPRINDERE INDIVIDUALA) ── # ── normalize_company_name (II, PFA, INTREPRINDERE INDIVIDUALA) ──