From 7a2408e31021ca55880e5ee1ea59a3f46d5f23b3 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Tue, 17 Mar 2026 12:18:18 +0000 Subject: [PATCH] fix(import): resolve correct id_articol for duplicate CODMATs + gestiune setting Unified id_articol selection logic in Python (resolve_codmat_ids) and PL/SQL (resolve_id_articol): filters sters=0 AND inactiv=0, prefers article with stock in configured gestiune, falls back to MAX(id_articol). Eliminates mismatch where Python and PL/SQL could pick different id_articol for the same CODMAT, causing ORA-20000 price-not-found errors. - Add resolve_codmat_ids helper in validation_service.py (single batch query) - Refactor validate_skus/validate_prices/ensure_prices to use it - Add resolve_id_articol function in PL/SQL package body - Add p_id_gestiune parameter to importa_comanda (spec + body) - Add /api/settings/gestiuni endpoint and id_gestiune setting - Add gestiune dropdown in settings UI Co-Authored-By: Claude Opus 4.6 (1M context) --- api/app/routers/sync.py | 23 +++ api/app/services/import_service.py | 3 +- api/app/services/sync_service.py | 18 +-- api/app/services/validation_service.py | 131 ++++++++++-------- api/app/static/js/settings.js | 16 ++- api/app/templates/settings.html | 8 +- .../06_pack_import_comenzi.pck | 121 +++++++++++----- 7 files changed, 215 insertions(+), 105 deletions(-) diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py index c0be7bd..6ef3a14 100644 --- a/api/app/routers/sync.py +++ b/api/app/routers/sync.py @@ -33,6 +33,7 @@ class AppSettingsUpdate(BaseModel): discount_id_pol: str = "" id_pol: str = "" id_sectie: str = "" + id_gestiune: str = "" gomag_api_key: str = "" gomag_api_shop: str = "" gomag_order_days_back: str = "7" @@ -597,6 +598,7 @@ async def get_app_settings(): "discount_id_pol": s.get("discount_id_pol", ""), "id_pol": s.get("id_pol", ""), "id_sectie": s.get("id_sectie", ""), + "id_gestiune": s.get("id_gestiune", ""), "gomag_api_key": s.get("gomag_api_key", "") or config_settings.GOMAG_API_KEY, "gomag_api_shop": s.get("gomag_api_shop", "") or config_settings.GOMAG_API_SHOP, "gomag_order_days_back": s.get("gomag_order_days_back", "") or str(config_settings.GOMAG_ORDER_DAYS_BACK), @@ -616,6 +618,7 @@ async def update_app_settings(config: AppSettingsUpdate): await sqlite_service.set_app_setting("discount_id_pol", config.discount_id_pol) await sqlite_service.set_app_setting("id_pol", config.id_pol) await sqlite_service.set_app_setting("id_sectie", config.id_sectie) + await sqlite_service.set_app_setting("id_gestiune", config.id_gestiune) await sqlite_service.set_app_setting("gomag_api_key", config.gomag_api_key) await sqlite_service.set_app_setting("gomag_api_shop", config.gomag_api_shop) await sqlite_service.set_app_setting("gomag_order_days_back", config.gomag_order_days_back) @@ -624,6 +627,26 @@ async def update_app_settings(config: AppSettingsUpdate): return {"success": True} +@router.get("/api/settings/gestiuni") +async def get_gestiuni(): + """Get list of warehouses from Oracle for dropdown.""" + def _query(): + conn = database.get_oracle_connection() + try: + with conn.cursor() as cur: + cur.execute( + "SELECT id_gestiune, nume_gestiune FROM nom_gestiuni WHERE sters=0 AND inactiv=0 ORDER BY id_gestiune" + ) + return [{"id": str(row[0]), "label": f"{row[0]} - {row[1]}"} for row in cur] + finally: + database.pool.release(conn) + try: + return await asyncio.to_thread(_query) + except Exception as e: + logger.error(f"get_gestiuni error: {e}") + return [] + + @router.get("/api/settings/sectii") async def get_sectii(): """Get list of sections from Oracle for dropdown.""" diff --git a/api/app/services/import_service.py b/api/app/services/import_service.py index 0222c4b..272897b 100644 --- a/api/app/services/import_service.py +++ b/api/app/services/import_service.py @@ -108,7 +108,7 @@ def build_articles_json(items, order=None, settings=None) -> str: return json.dumps(articles) -def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_settings: dict = None) -> dict: +def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_settings: dict = None, id_gestiune: int = None) -> dict: """Import a single order into Oracle ROA. Returns dict with: @@ -255,6 +255,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se addr_fact_id, # p_id_adresa_facturare id_pol, # p_id_pol id_sectie, # p_id_sectie + id_gestiune, # p_id_gestiune id_comanda # v_id_comanda (OUT) ]) diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index 817e446..cc2e61d 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -334,9 +334,17 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None # (can happen if previous import committed partially without rollback) await _fix_stale_error_orders(existing_map, run_id) + # Load app settings early (needed for id_gestiune in SKU validation) + app_settings = await sqlite_service.get_app_settings() + id_pol = id_pol or int(app_settings.get("id_pol") or 0) or settings.ID_POL + id_sectie = id_sectie or int(app_settings.get("id_sectie") or 0) or settings.ID_SECTIE + id_gestiune = int(app_settings.get("id_gestiune") or 0) or None # None = orice gestiune + logger.info(f"Sync params: ID_POL={id_pol}, ID_SECTIE={id_sectie}, ID_GESTIUNE={id_gestiune}") + _log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}, ID_GESTIUNE={id_gestiune}") + # Step 2b: Validate SKUs (reuse same connection) all_skus = order_reader.get_all_skus(orders) - validation = await asyncio.to_thread(validation_service.validate_skus, all_skus, conn) + validation = await asyncio.to_thread(validation_service.validate_skus, all_skus, conn, id_gestiune) importable, skipped = validation_service.classify_orders(orders, validation) # ── Split importable into truly_importable vs already_in_roa ── @@ -390,12 +398,6 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None ) # Step 2d: Pre-validate prices for importable articles - # Load app settings (for transport/discount CODMAT config AND id_pol/id_sectie override) - app_settings = await sqlite_service.get_app_settings() - id_pol = id_pol or int(app_settings.get("id_pol") or 0) or settings.ID_POL - id_sectie = id_sectie or int(app_settings.get("id_sectie") or 0) or settings.ID_SECTIE - logger.info(f"Sync params: ID_POL={id_pol}, ID_SECTIE={id_sectie}") - _log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}") if id_pol and (truly_importable or already_in_roa): _update_progress("validation", "Validating prices...", 0, len(truly_importable)) _log_line(run_id, "Validare preturi...") @@ -505,7 +507,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None result = await asyncio.to_thread( import_service.import_single_order, order, id_pol=id_pol, id_sectie=id_sectie, - app_settings=app_settings + app_settings=app_settings, id_gestiune=id_gestiune ) # Build order items data for storage (R9) diff --git a/api/app/services/validation_service.py b/api/app/services/validation_service.py index 8c2f866..15a6016 100644 --- a/api/app/services/validation_service.py +++ b/api/app/services/validation_service.py @@ -1,5 +1,4 @@ import logging -from datetime import datetime, timedelta from .. import database logger = logging.getLogger(__name__) @@ -29,7 +28,67 @@ def check_orders_in_roa(min_date, conn) -> dict: return existing -def validate_skus(skus: set[str], conn=None) -> dict: +def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) -> dict[str, int]: + """Resolve CODMATs to best id_articol: prefers article with stock, then MAX(id_articol). + Filters: sters=0 AND inactiv=0. + Returns: {codmat: id_articol} + """ + if not codmats: + return {} + + result = {} + codmat_list = list(codmats) + + # Build stoc subquery dynamically for index optimization + if id_gestiune is not None: + stoc_filter = "AND s.id_gestiune = :id_gestiune" + else: + stoc_filter = "" + + own_conn = conn is None + if own_conn: + conn = database.get_oracle_connection() + try: + with conn.cursor() as cur: + for i in range(0, len(codmat_list), 500): + batch = codmat_list[i:i+500] + placeholders = ",".join([f":c{j}" for j in range(len(batch))]) + params = {f"c{j}": cm for j, cm in enumerate(batch)} + if id_gestiune is not None: + params["id_gestiune"] = id_gestiune + + cur.execute(f""" + SELECT codmat, id_articol FROM ( + SELECT na.codmat, na.id_articol, + ROW_NUMBER() OVER ( + PARTITION BY na.codmat + ORDER BY + CASE WHEN EXISTS ( + SELECT 1 FROM stoc s + WHERE s.id_articol = na.id_articol + {stoc_filter} + AND s.an = EXTRACT(YEAR FROM SYSDATE) + AND s.luna = EXTRACT(MONTH FROM SYSDATE) + AND s.cants + s.cant - s.cante > 0 + ) THEN 0 ELSE 1 END, + na.id_articol DESC + ) AS rn + FROM nom_articole na + WHERE na.codmat IN ({placeholders}) + AND na.sters = 0 AND na.inactiv = 0 + ) WHERE rn = 1 + """, params) + for row in cur: + result[row[0]] = row[1] + finally: + if own_conn: + database.pool.release(conn) + + logger.info(f"resolve_codmat_ids: {len(result)}/{len(codmats)} resolved (gestiune={id_gestiune})") + return result + + +def validate_skus(skus: set[str], conn=None, id_gestiune: int = None) -> dict: """Validate a set of SKUs against Oracle. Returns: {mapped: set, direct: set, missing: set, direct_id_map: {codmat: id_articol}} - mapped: found in ARTICOLE_TERTI (active) @@ -41,8 +100,6 @@ def validate_skus(skus: set[str], conn=None) -> dict: return {"mapped": set(), "direct": set(), "missing": set(), "direct_id_map": {}} mapped = set() - direct = set() - direct_id_map = {} sku_list = list(skus) own_conn = conn is None @@ -64,18 +121,14 @@ def validate_skus(skus: set[str], conn=None) -> dict: for row in cur: mapped.add(row[0]) - # Check NOM_ARTICOLE for remaining — also fetch id_articol - remaining = [s for s in batch if s not in mapped] - if remaining: - placeholders2 = ",".join([f":n{j}" for j in range(len(remaining))]) - params2 = {f"n{j}": sku for j, sku in enumerate(remaining)} - cur.execute(f""" - SELECT codmat, id_articol FROM NOM_ARTICOLE - WHERE codmat IN ({placeholders2}) AND sters = 0 AND inactiv = 0 - """, params2) - for row in cur: - direct.add(row[0]) - direct_id_map[row[0]] = row[1] + # Resolve remaining SKUs via resolve_codmat_ids (consistent id_articol selection) + all_remaining = [s for s in sku_list if s not in mapped] + if all_remaining: + direct_id_map = resolve_codmat_ids(set(all_remaining), id_gestiune, conn) + direct = set(direct_id_map.keys()) + else: + direct_id_map = {} + direct = set() finally: if own_conn: database.pool.release(conn) @@ -113,37 +166,15 @@ def validate_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: di if not codmats: return {"has_price": set(), "missing_price": set()} - codmat_to_id = {} + codmat_to_id = dict(direct_id_map) if direct_id_map else {} ids_with_price = set() - codmat_list = list(codmats) - - # Pre-populate from direct_id_map if available - if direct_id_map: - for cm in codmat_list: - if cm in direct_id_map: - codmat_to_id[cm] = direct_id_map[cm] own_conn = conn is None if own_conn: conn = database.get_oracle_connection() try: with conn.cursor() as cur: - # Step 1: Get ID_ARTICOL for CODMATs not already in direct_id_map - remaining = [cm for cm in codmat_list if cm not in codmat_to_id] - if remaining: - for i in range(0, len(remaining), 500): - batch = remaining[i:i+500] - placeholders = ",".join([f":c{j}" for j in range(len(batch))]) - params = {f"c{j}": cm for j, cm in enumerate(batch)} - - cur.execute(f""" - SELECT id_articol, codmat FROM NOM_ARTICOLE - WHERE codmat IN ({placeholders}) - """, params) - for row in cur: - codmat_to_id[row[1]] = row[0] - - # Step 2: Check which ID_ARTICOLs have a price in the policy + # Check which ID_ARTICOLs have a price in the policy id_list = list(codmat_to_id.values()) for i in range(0, len(id_list), 500): batch = id_list[i:i+500] @@ -191,28 +222,10 @@ def ensure_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict return id_valuta = row[0] - # Build batch params using direct_id_map where available + # Build batch params using direct_id_map (already resolved via resolve_codmat_ids) batch_params = [] - need_lookup = [] codmat_id_map = dict(direct_id_map) if direct_id_map else {} - for codmat in codmats: - if codmat not in codmat_id_map: - need_lookup.append(codmat) - - # Batch lookup remaining CODMATs - if need_lookup: - for i in range(0, len(need_lookup), 500): - batch = need_lookup[i:i+500] - placeholders = ",".join([f":c{j}" for j in range(len(batch))]) - params = {f"c{j}": cm for j, cm in enumerate(batch)} - cur.execute(f""" - SELECT codmat, id_articol FROM NOM_ARTICOLE - WHERE codmat IN ({placeholders}) AND sters = 0 AND inactiv = 0 - """, params) - for r in cur: - codmat_id_map[r[0]] = r[1] - for codmat in codmats: id_articol = codmat_id_map.get(codmat) if not id_articol: diff --git a/api/app/static/js/settings.js b/api/app/static/js/settings.js index dfd4bfa..c57c190 100644 --- a/api/app/static/js/settings.js +++ b/api/app/static/js/settings.js @@ -9,12 +9,22 @@ document.addEventListener('DOMContentLoaded', async () => { async function loadDropdowns() { try { - const [sectiiRes, politiciRes] = await Promise.all([ + const [sectiiRes, politiciRes, gestiuniRes] = await Promise.all([ fetch('/api/settings/sectii'), - fetch('/api/settings/politici') + fetch('/api/settings/politici'), + fetch('/api/settings/gestiuni') ]); const sectii = await sectiiRes.json(); const politici = await politiciRes.json(); + const gestiuni = await gestiuniRes.json(); + + const gestiuneEl = document.getElementById('settIdGestiune'); + if (gestiuneEl) { + gestiuneEl.innerHTML = ''; + gestiuni.forEach(g => { + gestiuneEl.innerHTML += ``; + }); + } const sectieEl = document.getElementById('settIdSectie'); if (sectieEl) { @@ -65,6 +75,7 @@ async function loadSettings() { if (el('settDiscountIdPol')) el('settDiscountIdPol').value = data.discount_id_pol || ''; if (el('settIdPol')) el('settIdPol').value = data.id_pol || ''; if (el('settIdSectie')) el('settIdSectie').value = data.id_sectie || ''; + if (el('settIdGestiune')) el('settIdGestiune').value = data.id_gestiune || ''; if (el('settGomagApiKey')) el('settGomagApiKey').value = data.gomag_api_key || ''; if (el('settGomagApiShop')) el('settGomagApiShop').value = data.gomag_api_shop || ''; if (el('settGomagDaysBack')) el('settGomagDaysBack').value = data.gomag_order_days_back || '7'; @@ -86,6 +97,7 @@ async function saveSettings() { discount_id_pol: el('settDiscountIdPol')?.value?.trim() || '', id_pol: el('settIdPol')?.value?.trim() || '', id_sectie: el('settIdSectie')?.value?.trim() || '', + id_gestiune: el('settIdGestiune')?.value?.trim() || '', gomag_api_key: el('settGomagApiKey')?.value?.trim() || '', gomag_api_shop: el('settGomagApiShop')?.value?.trim() || '', gomag_order_days_back: el('settGomagDaysBack')?.value?.trim() || '7', diff --git a/api/app/templates/settings.html b/api/app/templates/settings.html index 53e4bc0..ee03890 100644 --- a/api/app/templates/settings.html +++ b/api/app/templates/settings.html @@ -38,6 +38,12 @@
Import ROA
+
+ + +