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") # Warn if SKU is already a direct CODMAT in NOM_ARTICOLE if sku == codmat: cur.execute(""" SELECT COUNT(*) FROM NOM_ARTICOLE WHERE codmat = :sku AND sters = 0 AND inactiv = 0 """, {"sku": sku}) if cur.fetchone()[0] > 0: raise HTTPException(status_code=409, detail="SKU-ul exista direct in nomenclator ca CODMAT, nu necesita mapare") # 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