Remove validation that blocked creating mappings when SKU matches an existing CODMAT. Users need this for unit quantity conversion (e.g., website sells 50 units per SKU but ROA tracks 100, requiring cantitate_roa=0.5). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
406 lines
17 KiB
Python
406 lines
17 KiB
Python
import oracledb
|
|
import csv
|
|
import io
|
|
import logging
|
|
from fastapi import HTTPException
|
|
from .. import database
|
|
|
|
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,
|
|
id_pol: int = None, id_pol_productie: int = None):
|
|
"""Get paginated mappings with optional search and sorting."""
|
|
if database.pool is None:
|
|
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
|
|
|
offset = (page - 1) * per_page
|
|
|
|
# Validate and resolve sort parameters
|
|
allowed_sort = {
|
|
"sku": "at.sku",
|
|
"codmat": "at.codmat",
|
|
"denumire": "na.denumire",
|
|
"um": "na.um",
|
|
"cantitate_roa": "at.cantitate_roa",
|
|
"activ": "at.activ",
|
|
}
|
|
sort_col = allowed_sort.get(sort_by, "at.sku")
|
|
if sort_dir.lower() not in ("asc", "desc"):
|
|
sort_dir = "asc"
|
|
order_clause = f"{sort_col} {sort_dir}"
|
|
# Always add secondary sort to keep groups together
|
|
if sort_col not in ("at.sku",):
|
|
order_clause += ", at.sku"
|
|
order_clause += ", at.codmat"
|
|
|
|
with database.pool.acquire() as conn:
|
|
with conn.cursor() as cur:
|
|
# Build WHERE clause
|
|
where_clauses = []
|
|
params = {}
|
|
if not show_deleted:
|
|
where_clauses.append("at.sters = 0")
|
|
if search:
|
|
where_clauses.append("""(UPPER(at.sku) LIKE '%' || UPPER(:search) || '%'
|
|
OR UPPER(at.codmat) LIKE '%' || UPPER(:search) || '%'
|
|
OR UPPER(na.denumire) LIKE '%' || UPPER(:search) || '%')""")
|
|
params["search"] = search
|
|
where = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
|
|
|
|
# Add price policy params
|
|
params["id_pol"] = id_pol
|
|
params["id_pol_prod"] = id_pol_productie
|
|
|
|
# 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.activ, at.sters,
|
|
TO_CHAR(at.data_creare, 'YYYY-MM-DD HH24:MI') as data_creare,
|
|
ROUND(CASE WHEN pp.preturi_cu_tva = 1
|
|
THEN NVL(ppa.pret, 0)
|
|
ELSE NVL(ppa.pret, 0) * NVL(ppa.proc_tvav, 1.19)
|
|
END, 2) AS pret_cu_tva
|
|
FROM ARTICOLE_TERTI at
|
|
LEFT JOIN nom_articole na ON na.codmat = at.codmat
|
|
LEFT JOIN crm_politici_pret_art ppa
|
|
ON ppa.id_articol = na.id_articol
|
|
AND ppa.id_pol = CASE
|
|
WHEN TRIM(na.cont) IN ('341','345') AND :id_pol_prod IS NOT NULL
|
|
THEN :id_pol_prod ELSE :id_pol END
|
|
LEFT JOIN crm_politici_preturi pp
|
|
ON pp.id_pol = ppa.id_pol
|
|
{where}
|
|
ORDER BY {order_clause}
|
|
"""
|
|
cur.execute(data_sql, params)
|
|
columns = [col[0].lower() for col in cur.description]
|
|
all_rows = [dict(zip(columns, row)) for row in cur.fetchall()]
|
|
|
|
# Group by SKU
|
|
from collections import OrderedDict
|
|
groups = OrderedDict()
|
|
for row in all_rows:
|
|
sku = row["sku"]
|
|
if sku not in groups:
|
|
groups[sku] = []
|
|
groups[sku].append(row)
|
|
|
|
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]
|
|
|
|
return {
|
|
"mappings": page_rows,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": per_page,
|
|
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
|
|
"counts": counts,
|
|
}
|
|
|
|
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.
|
|
"""
|
|
if not sku or not sku.strip():
|
|
raise HTTPException(status_code=400, detail="SKU este obligatoriu")
|
|
if not codmat or not codmat.strip():
|
|
raise HTTPException(status_code=400, detail="CODMAT este obligatoriu")
|
|
if database.pool is None:
|
|
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
|
|
|
with database.pool.acquire() as conn:
|
|
with conn.cursor() as cur:
|
|
# Validate CODMAT exists in NOM_ARTICOLE
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM NOM_ARTICOLE
|
|
WHERE codmat = :codmat AND sters = 0 AND inactiv = 0
|
|
""", {"codmat": codmat})
|
|
if cur.fetchone()[0] == 0:
|
|
raise HTTPException(status_code=400, detail="CODMAT-ul nu exista in nomenclator")
|
|
|
|
# Check for active duplicate
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM ARTICOLE_TERTI
|
|
WHERE sku = :sku AND codmat = :codmat AND NVL(sters, 0) = 0
|
|
""", {"sku": sku, "codmat": codmat})
|
|
if cur.fetchone()[0] > 0:
|
|
raise HTTPException(status_code=409, detail="Maparea SKU-CODMAT există deja")
|
|
|
|
# Check for soft-deleted record that could be restored
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM ARTICOLE_TERTI
|
|
WHERE sku = :sku AND codmat = :codmat AND sters = 1
|
|
""", {"sku": sku, "codmat": codmat})
|
|
if cur.fetchone()[0] > 0:
|
|
if auto_restore:
|
|
cur.execute("""
|
|
UPDATE ARTICOLE_TERTI SET sters = 0, activ = 1,
|
|
cantitate_roa = :cantitate_roa,
|
|
data_modif = SYSDATE
|
|
WHERE sku = :sku AND codmat = :codmat AND sters = 1
|
|
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa})
|
|
conn.commit()
|
|
return {"sku": sku, "codmat": codmat}
|
|
else:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Maparea a fost ștearsă anterior",
|
|
headers={"X-Can-Restore": "true"}
|
|
)
|
|
|
|
cur.execute("""
|
|
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, activ: int = None):
|
|
"""Update an existing mapping."""
|
|
if database.pool is None:
|
|
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
|
|
|
sets = []
|
|
params = {"sku": sku, "codmat": codmat}
|
|
|
|
if cantitate_roa is not None:
|
|
sets.append("cantitate_roa = :cantitate_roa")
|
|
params["cantitate_roa"] = cantitate_roa
|
|
if activ is not None:
|
|
sets.append("activ = :activ")
|
|
params["activ"] = activ
|
|
|
|
if not sets:
|
|
return False
|
|
|
|
sets.append("data_modif = SYSDATE")
|
|
set_clause = ", ".join(sets)
|
|
|
|
with database.pool.acquire() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(f"""
|
|
UPDATE ARTICOLE_TERTI SET {set_clause}
|
|
WHERE sku = :sku AND codmat = :codmat
|
|
""", params)
|
|
conn.commit()
|
|
return cur.rowcount > 0
|
|
|
|
def delete_mapping(sku: str, codmat: str):
|
|
"""Soft delete (set sters=1)."""
|
|
if database.pool is None:
|
|
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
|
|
|
with database.pool.acquire() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("""
|
|
UPDATE ARTICOLE_TERTI SET sters = 1, data_modif = SYSDATE
|
|
WHERE sku = :sku AND codmat = :codmat
|
|
""", {"sku": sku, "codmat": codmat})
|
|
conn.commit()
|
|
return cur.rowcount > 0
|
|
|
|
def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str,
|
|
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")
|
|
if not new_codmat or not new_codmat.strip():
|
|
raise HTTPException(status_code=400, detail="CODMAT este obligatoriu")
|
|
if database.pool is None:
|
|
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
|
|
|
if old_sku == new_sku and old_codmat == new_codmat:
|
|
# 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:
|
|
with conn.cursor() as cur:
|
|
# Mark old record as deleted
|
|
cur.execute("""
|
|
UPDATE ARTICOLE_TERTI SET sters = 1, data_modif = SYSDATE
|
|
WHERE sku = :sku AND codmat = :codmat
|
|
""", {"sku": old_sku, "codmat": old_codmat})
|
|
# Upsert new record (MERGE in case target PK exists as soft-deleted)
|
|
cur.execute("""
|
|
MERGE INTO ARTICOLE_TERTI t
|
|
USING (SELECT :sku AS sku, :codmat AS codmat FROM DUAL) s
|
|
ON (t.sku = s.sku AND t.codmat = s.codmat)
|
|
WHEN MATCHED THEN UPDATE SET
|
|
cantitate_roa = :cantitate_roa,
|
|
activ = 1, sters = 0,
|
|
data_modif = SYSDATE
|
|
WHEN NOT MATCHED THEN INSERT
|
|
(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
|
|
|
|
def restore_mapping(sku: str, codmat: str):
|
|
"""Restore a soft-deleted mapping (set sters=0)."""
|
|
if database.pool is None:
|
|
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
|
|
|
with database.pool.acquire() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("""
|
|
UPDATE ARTICOLE_TERTI SET sters = 0, data_modif = SYSDATE
|
|
WHERE sku = :sku AND codmat = :codmat
|
|
""", {"sku": sku, "codmat": codmat})
|
|
conn.commit()
|
|
return cur.rowcount > 0
|
|
|
|
def import_csv(file_content: str):
|
|
"""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")
|
|
|
|
reader = csv.DictReader(io.StringIO(file_content))
|
|
created = 0
|
|
skipped_no_codmat = 0
|
|
errors = []
|
|
|
|
with database.pool.acquire() as conn:
|
|
with conn.cursor() as cur:
|
|
for i, row in enumerate(reader, 1):
|
|
sku = row.get("sku", "").strip()
|
|
codmat = row.get("codmat", "").strip()
|
|
|
|
if not sku:
|
|
errors.append(f"Rând {i}: SKU lipsă")
|
|
continue
|
|
|
|
if not codmat:
|
|
skipped_no_codmat += 1
|
|
continue
|
|
|
|
try:
|
|
cantitate = float(row.get("cantitate_roa", "1") or "1")
|
|
# procent_pret column ignored if present (backward compat)
|
|
|
|
cur.execute("""
|
|
MERGE INTO ARTICOLE_TERTI t
|
|
USING (SELECT :sku AS sku, :codmat AS codmat FROM DUAL) s
|
|
ON (t.sku = s.sku AND t.codmat = s.codmat)
|
|
WHEN MATCHED THEN UPDATE SET
|
|
cantitate_roa = :cantitate_roa,
|
|
activ = 1,
|
|
sters = 0,
|
|
data_modif = SYSDATE
|
|
WHEN NOT MATCHED THEN INSERT
|
|
(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:
|
|
errors.append(f"Rând {i}: {str(e)}")
|
|
|
|
conn.commit()
|
|
|
|
return {"processed": created, "skipped_no_codmat": skipped_no_codmat, "errors": errors}
|
|
|
|
def export_csv():
|
|
"""Export all mappings as CSV string."""
|
|
if database.pool is None:
|
|
raise HTTPException(status_code=503, detail="Oracle unavailable")
|
|
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
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, activ
|
|
FROM ARTICOLE_TERTI WHERE sters = 0 ORDER BY sku, codmat
|
|
""")
|
|
for row in cur:
|
|
writer.writerow(row)
|
|
|
|
return output.getvalue()
|
|
|
|
def get_csv_template():
|
|
"""Return empty CSV template."""
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
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) == 0:
|
|
return []
|
|
if len(components) == 1 and (components[0][1] or 1) <= 1:
|
|
return [] # True 1:1 mapping, no kit pricing needed
|
|
|
|
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
|