feat(pricing): kit/pachet pricing with price list lookup, replace procent_pret
- Oracle PL/SQL: kit pricing logic with Mode A (distributed discount) and
Mode B (separate discount line), dual policy support, PRETURI_CU_TVA flag
- Eliminate procent_pret from entire stack (Oracle, Python, JS, HTML)
- New settings: kit_pricing_mode, kit_discount_codmat, price_sync_enabled
- Settings UI: cards for Kit Pricing and Price Sync configuration
- Mappings UI: kit badges with lazy-loaded component prices from price list
- Price sync from orders: auto-update ROA prices when web prices differ
- Catalog price sync: new service to sync all GoMag product prices to ROA
- Kit component price validation: pre-check prices before import
- New endpoint GET /api/mappings/{sku}/prices for component price display
- New endpoints POST /api/price-sync/start, GET status, GET history
- DDL script 07_drop_procent_pret.sql (run after deploy confirmation)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,14 +9,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
||||
sort_by: str = "sku", sort_dir: str = "asc",
|
||||
show_deleted: bool = False, pct_filter: str = None):
|
||||
"""Get paginated mappings with optional search, sorting, and pct_filter.
|
||||
|
||||
pct_filter values:
|
||||
'complete' – only SKU groups where sum(procent_pret for active rows) == 100
|
||||
'incomplete' – only SKU groups where sum < 100
|
||||
None / 'all' – no filter
|
||||
"""
|
||||
show_deleted: bool = False):
|
||||
"""Get paginated mappings with optional search and sorting."""
|
||||
if database.pool is None:
|
||||
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
||||
|
||||
@@ -29,7 +23,6 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
||||
"denumire": "na.denumire",
|
||||
"um": "na.um",
|
||||
"cantitate_roa": "at.cantitate_roa",
|
||||
"procent_pret": "at.procent_pret",
|
||||
"activ": "at.activ",
|
||||
}
|
||||
sort_col = allowed_sort.get(sort_by, "at.sku")
|
||||
@@ -58,7 +51,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
||||
# Fetch ALL matching rows (no pagination yet — we need to group by SKU first)
|
||||
data_sql = f"""
|
||||
SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa,
|
||||
at.procent_pret, at.activ, at.sters,
|
||||
at.activ, at.sters,
|
||||
TO_CHAR(at.data_creare, 'YYYY-MM-DD HH24:MI') as data_creare
|
||||
FROM ARTICOLE_TERTI at
|
||||
LEFT JOIN nom_articole na ON na.codmat = at.codmat
|
||||
@@ -69,7 +62,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
||||
columns = [col[0].lower() for col in cur.description]
|
||||
all_rows = [dict(zip(columns, row)) for row in cur.fetchall()]
|
||||
|
||||
# Group by SKU and compute pct_total for each group
|
||||
# Group by SKU
|
||||
from collections import OrderedDict
|
||||
groups = OrderedDict()
|
||||
for row in all_rows:
|
||||
@@ -78,64 +71,13 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
||||
groups[sku] = []
|
||||
groups[sku].append(row)
|
||||
|
||||
# Compute counts across ALL groups (before pct_filter)
|
||||
total_skus = len(groups)
|
||||
complete_skus = 0
|
||||
incomplete_skus = 0
|
||||
for sku, rows in groups.items():
|
||||
pct_total = sum(
|
||||
(r["procent_pret"] or 0)
|
||||
for r in rows
|
||||
if r.get("activ") == 1
|
||||
)
|
||||
if abs(pct_total - 100) <= 0.01:
|
||||
complete_skus += 1
|
||||
else:
|
||||
incomplete_skus += 1
|
||||
|
||||
counts = {
|
||||
"total": total_skus,
|
||||
"complete": complete_skus,
|
||||
"incomplete": incomplete_skus,
|
||||
}
|
||||
|
||||
# Apply pct_filter
|
||||
if pct_filter in ("complete", "incomplete"):
|
||||
filtered_groups = {}
|
||||
for sku, rows in groups.items():
|
||||
pct_total = sum(
|
||||
(r["procent_pret"] or 0)
|
||||
for r in rows
|
||||
if r.get("activ") == 1
|
||||
)
|
||||
is_complete = abs(pct_total - 100) <= 0.01
|
||||
if pct_filter == "complete" and is_complete:
|
||||
filtered_groups[sku] = rows
|
||||
elif pct_filter == "incomplete" and not is_complete:
|
||||
filtered_groups[sku] = rows
|
||||
groups = filtered_groups
|
||||
counts = {"total": len(groups)}
|
||||
|
||||
# Flatten back to rows for pagination (paginate by raw row count)
|
||||
filtered_rows = [row for rows in groups.values() for row in rows]
|
||||
total = len(filtered_rows)
|
||||
page_rows = filtered_rows[offset: offset + per_page]
|
||||
|
||||
# Attach pct_total and is_complete to each row for the renderer
|
||||
# Re-compute per visible group
|
||||
sku_pct = {}
|
||||
for sku, rows in groups.items():
|
||||
pct_total = sum(
|
||||
(r["procent_pret"] or 0)
|
||||
for r in rows
|
||||
if r.get("activ") == 1
|
||||
)
|
||||
sku_pct[sku] = {"pct_total": pct_total, "is_complete": abs(pct_total - 100) <= 0.01}
|
||||
|
||||
for row in page_rows:
|
||||
meta = sku_pct.get(row["sku"], {"pct_total": 0, "is_complete": False})
|
||||
row["pct_total"] = meta["pct_total"]
|
||||
row["is_complete"] = meta["is_complete"]
|
||||
|
||||
return {
|
||||
"mappings": page_rows,
|
||||
"total": total,
|
||||
@@ -145,7 +87,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
|
||||
"counts": counts,
|
||||
}
|
||||
|
||||
def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100, auto_restore: bool = False):
|
||||
def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, auto_restore: bool = False):
|
||||
"""Create a new mapping. Returns dict or raises HTTPException on duplicate.
|
||||
|
||||
When auto_restore=True, soft-deleted records are restored+updated instead of raising 409.
|
||||
@@ -194,11 +136,10 @@ def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret
|
||||
if auto_restore:
|
||||
cur.execute("""
|
||||
UPDATE ARTICOLE_TERTI SET sters = 0, activ = 1,
|
||||
cantitate_roa = :cantitate_roa, procent_pret = :procent_pret,
|
||||
cantitate_roa = :cantitate_roa,
|
||||
data_modif = SYSDATE
|
||||
WHERE sku = :sku AND codmat = :codmat AND sters = 1
|
||||
""", {"sku": sku, "codmat": codmat,
|
||||
"cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
|
||||
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa})
|
||||
conn.commit()
|
||||
return {"sku": sku, "codmat": codmat}
|
||||
else:
|
||||
@@ -209,13 +150,13 @@ def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret
|
||||
)
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
|
||||
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
|
||||
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
|
||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare)
|
||||
VALUES (:sku, :codmat, :cantitate_roa, 1, 0, SYSDATE, -3)
|
||||
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa})
|
||||
conn.commit()
|
||||
return {"sku": sku, "codmat": codmat}
|
||||
|
||||
def update_mapping(sku: str, codmat: str, cantitate_roa: float = None, procent_pret: float = None, activ: int = None):
|
||||
def update_mapping(sku: str, codmat: str, cantitate_roa: float = None, activ: int = None):
|
||||
"""Update an existing mapping."""
|
||||
if database.pool is None:
|
||||
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
||||
@@ -226,9 +167,6 @@ def update_mapping(sku: str, codmat: str, cantitate_roa: float = None, procent_p
|
||||
if cantitate_roa is not None:
|
||||
sets.append("cantitate_roa = :cantitate_roa")
|
||||
params["cantitate_roa"] = cantitate_roa
|
||||
if procent_pret is not None:
|
||||
sets.append("procent_pret = :procent_pret")
|
||||
params["procent_pret"] = procent_pret
|
||||
if activ is not None:
|
||||
sets.append("activ = :activ")
|
||||
params["activ"] = activ
|
||||
@@ -263,7 +201,7 @@ def delete_mapping(sku: str, codmat: str):
|
||||
return cur.rowcount > 0
|
||||
|
||||
def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str,
|
||||
cantitate_roa: float = 1, procent_pret: float = 100):
|
||||
cantitate_roa: float = 1):
|
||||
"""Edit a mapping. If PK changed, soft-delete old and insert new."""
|
||||
if not new_sku or not new_sku.strip():
|
||||
raise HTTPException(status_code=400, detail="SKU este obligatoriu")
|
||||
@@ -273,8 +211,8 @@ def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str,
|
||||
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
||||
|
||||
if old_sku == new_sku and old_codmat == new_codmat:
|
||||
# Simple update - only cantitate/procent changed
|
||||
return update_mapping(new_sku, new_codmat, cantitate_roa, procent_pret)
|
||||
# Simple update - only cantitate changed
|
||||
return update_mapping(new_sku, new_codmat, cantitate_roa)
|
||||
else:
|
||||
# PK changed: soft-delete old, upsert new (MERGE handles existing soft-deleted target)
|
||||
with database.pool.acquire() as conn:
|
||||
@@ -291,14 +229,12 @@ def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str,
|
||||
ON (t.sku = s.sku AND t.codmat = s.codmat)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
cantitate_roa = :cantitate_roa,
|
||||
procent_pret = :procent_pret,
|
||||
activ = 1, sters = 0,
|
||||
data_modif = SYSDATE
|
||||
WHEN NOT MATCHED THEN INSERT
|
||||
(sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
|
||||
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
|
||||
""", {"sku": new_sku, "codmat": new_codmat,
|
||||
"cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
|
||||
(sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare)
|
||||
VALUES (:sku, :codmat, :cantitate_roa, 1, 0, SYSDATE, -3)
|
||||
""", {"sku": new_sku, "codmat": new_codmat, "cantitate_roa": cantitate_roa})
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
@@ -317,7 +253,9 @@ def restore_mapping(sku: str, codmat: str):
|
||||
return cur.rowcount > 0
|
||||
|
||||
def import_csv(file_content: str):
|
||||
"""Import mappings from CSV content. Returns summary."""
|
||||
"""Import mappings from CSV content. Returns summary.
|
||||
Backward compatible: if procent_pret column exists in CSV, it is silently ignored.
|
||||
"""
|
||||
if database.pool is None:
|
||||
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
||||
|
||||
@@ -342,7 +280,7 @@ def import_csv(file_content: str):
|
||||
|
||||
try:
|
||||
cantitate = float(row.get("cantitate_roa", "1") or "1")
|
||||
procent = float(row.get("procent_pret", "100") or "100")
|
||||
# procent_pret column ignored if present (backward compat)
|
||||
|
||||
cur.execute("""
|
||||
MERGE INTO ARTICOLE_TERTI t
|
||||
@@ -350,14 +288,13 @@ def import_csv(file_content: str):
|
||||
ON (t.sku = s.sku AND t.codmat = s.codmat)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
cantitate_roa = :cantitate_roa,
|
||||
procent_pret = :procent_pret,
|
||||
activ = 1,
|
||||
sters = 0,
|
||||
data_modif = SYSDATE
|
||||
WHEN NOT MATCHED THEN INSERT
|
||||
(sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
|
||||
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
|
||||
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent})
|
||||
(sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare)
|
||||
VALUES (:sku, :codmat, :cantitate_roa, 1, 0, SYSDATE, -3)
|
||||
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate})
|
||||
created += 1
|
||||
|
||||
except Exception as e:
|
||||
@@ -374,12 +311,12 @@ def export_csv():
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["sku", "codmat", "cantitate_roa", "procent_pret", "activ"])
|
||||
writer.writerow(["sku", "codmat", "cantitate_roa", "activ"])
|
||||
|
||||
with database.pool.acquire() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT sku, codmat, cantitate_roa, procent_pret, activ
|
||||
SELECT sku, codmat, cantitate_roa, activ
|
||||
FROM ARTICOLE_TERTI WHERE sters = 0 ORDER BY sku, codmat
|
||||
""")
|
||||
for row in cur:
|
||||
@@ -391,6 +328,70 @@ def get_csv_template():
|
||||
"""Return empty CSV template."""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["sku", "codmat", "cantitate_roa", "procent_pret"])
|
||||
writer.writerow(["EXAMPLE_SKU", "EXAMPLE_CODMAT", "1", "100"])
|
||||
writer.writerow(["sku", "codmat", "cantitate_roa"])
|
||||
writer.writerow(["EXAMPLE_SKU", "EXAMPLE_CODMAT", "1"])
|
||||
return output.getvalue()
|
||||
|
||||
def get_component_prices(sku: str, id_pol: int, id_pol_productie: int = None) -> list:
|
||||
"""Get prices from crm_politici_pret_art for kit components.
|
||||
Returns: [{"codmat", "denumire", "cantitate_roa", "pret", "pret_cu_tva", "proc_tvav", "ptva", "id_pol_used"}]
|
||||
"""
|
||||
if database.pool is None:
|
||||
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
||||
|
||||
with database.pool.acquire() as conn:
|
||||
with conn.cursor() as cur:
|
||||
# Get components from ARTICOLE_TERTI
|
||||
cur.execute("""
|
||||
SELECT at.codmat, at.cantitate_roa, na.id_articol, na.cont, na.denumire
|
||||
FROM ARTICOLE_TERTI at
|
||||
JOIN NOM_ARTICOLE na ON na.codmat = at.codmat AND na.sters = 0 AND na.inactiv = 0
|
||||
WHERE at.sku = :sku AND at.activ = 1 AND at.sters = 0
|
||||
ORDER BY at.codmat
|
||||
""", {"sku": sku})
|
||||
components = cur.fetchall()
|
||||
|
||||
if len(components) <= 1:
|
||||
return [] # Not a kit
|
||||
|
||||
result = []
|
||||
for codmat, cant_roa, id_art, cont, denumire in components:
|
||||
# Determine policy based on account
|
||||
cont_str = str(cont or "").strip()
|
||||
pol = id_pol_productie if (cont_str in ("341", "345") and id_pol_productie) else id_pol
|
||||
|
||||
# Get PRETURI_CU_TVA flag
|
||||
cur.execute("SELECT PRETURI_CU_TVA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :pol", {"pol": pol})
|
||||
pol_row = cur.fetchone()
|
||||
preturi_cu_tva_flag = pol_row[0] if pol_row else 0
|
||||
|
||||
# Get price
|
||||
cur.execute("""
|
||||
SELECT PRET, PROC_TVAV FROM crm_politici_pret_art
|
||||
WHERE id_pol = :pol AND id_articol = :id_art
|
||||
""", {"pol": pol, "id_art": id_art})
|
||||
price_row = cur.fetchone()
|
||||
|
||||
if price_row:
|
||||
pret, proc_tvav = price_row
|
||||
proc_tvav = proc_tvav or 1.19
|
||||
pret_cu_tva = pret if preturi_cu_tva_flag == 1 else round(pret * proc_tvav, 2)
|
||||
ptva = round((proc_tvav - 1) * 100)
|
||||
else:
|
||||
pret = 0
|
||||
pret_cu_tva = 0
|
||||
proc_tvav = 1.19
|
||||
ptva = 19
|
||||
|
||||
result.append({
|
||||
"codmat": codmat,
|
||||
"denumire": denumire or "",
|
||||
"cantitate_roa": float(cant_roa) if cant_roa else 1,
|
||||
"pret": float(pret) if pret else 0,
|
||||
"pret_cu_tva": float(pret_cu_tva),
|
||||
"proc_tvav": float(proc_tvav),
|
||||
"ptva": int(ptva),
|
||||
"id_pol_used": pol
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user