From c534a972a9a99ba8ca631dd087e980040c02da51 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 18 Mar 2026 16:15:40 +0000 Subject: [PATCH] feat: multi-gestiune stock verification setting Replace single-select gestiune dropdown with multi-select checkboxes. Settings stores comma-separated IDs, Python builds IN clause with bind variables, Oracle PL/SQL splits CSV via REGEXP_SUBSTR for stock lookup. Empty selection = all warehouses (unchanged behavior). Co-Authored-By: Claude Opus 4.6 --- api/app/services/import_service.py | 7 +++++-- api/app/services/sync_service.py | 15 ++++++++----- api/app/services/validation_service.py | 19 ++++++++++------- api/app/static/js/settings.js | 21 +++++++++++++------ api/app/templates/settings.html | 11 +++++----- .../06_pack_import_comenzi.pck | 14 ++++++++----- 6 files changed, 56 insertions(+), 31 deletions(-) diff --git a/api/app/services/import_service.py b/api/app/services/import_service.py index 2000f48..5eaf8e7 100644 --- a/api/app/services/import_service.py +++ b/api/app/services/import_service.py @@ -201,7 +201,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, id_gestiune: int = None) -> dict: +def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_settings: dict = None, id_gestiuni: list[int] = None) -> dict: """Import a single order into Oracle ROA. Returns dict with: @@ -339,6 +339,9 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se id_comanda = cur.var(oracledb.DB_TYPE_NUMBER) + # Convert list[int] to CSV string for Oracle VARCHAR2 param + id_gestiune_csv = ",".join(str(g) for g in id_gestiuni) if id_gestiuni else None + cur.callproc("PACK_IMPORT_COMENZI.importa_comanda", [ order_number, # p_nr_comanda_ext order_date, # p_data_comanda @@ -348,7 +351,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_gestiune_csv, # p_id_gestiune (CSV string) id_comanda # v_id_comanda (OUT) ]) diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index 2e13a31..88d8dcb 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -346,13 +346,18 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None 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}") + # Parse multi-gestiune CSV: "1,3" → [1, 3], "" → None + id_gestiune_raw = (app_settings.get("id_gestiune") or "").strip() + if id_gestiune_raw and id_gestiune_raw != "0": + id_gestiuni = [int(g) for g in id_gestiune_raw.split(",") if g.strip()] + else: + id_gestiuni = None # None = orice gestiune + logger.info(f"Sync params: ID_POL={id_pol}, ID_SECTIE={id_sectie}, ID_GESTIUNI={id_gestiuni}") + _log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}, ID_GESTIUNI={id_gestiuni}") # 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, id_gestiune) + validation = await asyncio.to_thread(validation_service.validate_skus, all_skus, conn, id_gestiuni) importable, skipped = validation_service.classify_orders(orders, validation) # ── Split importable into truly_importable vs already_in_roa ── @@ -580,7 +585,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, id_gestiune=id_gestiune + app_settings=app_settings, id_gestiuni=id_gestiuni ) # Build order items data for storage (R9) diff --git a/api/app/services/validation_service.py b/api/app/services/validation_service.py index 8361484..0b484da 100644 --- a/api/app/services/validation_service.py +++ b/api/app/services/validation_service.py @@ -28,9 +28,10 @@ def check_orders_in_roa(min_date, conn) -> dict: return existing -def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) -> dict[str, dict]: +def resolve_codmat_ids(codmats: set[str], id_gestiuni: list[int] = None, conn=None) -> dict[str, dict]: """Resolve CODMATs to best id_articol + cont: prefers article with stock, then MAX(id_articol). Filters: sters=0 AND inactiv=0. + id_gestiuni: list of warehouse IDs to check stock in, or None for all. Returns: {codmat: {"id_articol": int, "cont": str|None}} """ if not codmats: @@ -40,8 +41,9 @@ def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) -> 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" + if id_gestiuni: + gest_placeholders = ",".join([f":g{k}" for k in range(len(id_gestiuni))]) + stoc_filter = f"AND s.id_gestiune IN ({gest_placeholders})" else: stoc_filter = "" @@ -54,8 +56,9 @@ def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) -> 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 + if id_gestiuni: + for k, gid in enumerate(id_gestiuni): + params[f"g{k}"] = gid cur.execute(f""" SELECT codmat, id_articol, cont FROM ( @@ -84,11 +87,11 @@ def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) -> if own_conn: database.pool.release(conn) - logger.info(f"resolve_codmat_ids: {len(result)}/{len(codmats)} resolved (gestiune={id_gestiune})") + logger.info(f"resolve_codmat_ids: {len(result)}/{len(codmats)} resolved (gestiuni={id_gestiuni})") return result -def validate_skus(skus: set[str], conn=None, id_gestiune: int = None) -> dict: +def validate_skus(skus: set[str], conn=None, id_gestiuni: list[int] = None) -> dict: """Validate a set of SKUs against Oracle. Returns: {mapped: set, direct: set, missing: set, direct_id_map: {codmat: {"id_articol": int, "cont": str|None}}} - mapped: found in ARTICOLE_TERTI (active) @@ -124,7 +127,7 @@ def validate_skus(skus: set[str], conn=None, id_gestiune: int = None) -> dict: # 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_id_map = resolve_codmat_ids(set(all_remaining), id_gestiuni, conn) direct = set(direct_id_map.keys()) else: direct_id_map = {} diff --git a/api/app/static/js/settings.js b/api/app/static/js/settings.js index 6531fae..7992f83 100644 --- a/api/app/static/js/settings.js +++ b/api/app/static/js/settings.js @@ -18,12 +18,13 @@ async function loadDropdowns() { const politici = await politiciRes.json(); const gestiuni = await gestiuniRes.json(); - const gestiuneEl = document.getElementById('settIdGestiune'); - if (gestiuneEl) { - gestiuneEl.innerHTML = ''; + const gestContainer = document.getElementById('settGestiuniContainer'); + if (gestContainer) { + gestContainer.innerHTML = ''; gestiuni.forEach(g => { - gestiuneEl.innerHTML += ``; + gestContainer.innerHTML += `
`; }); + if (gestiuni.length === 0) gestContainer.innerHTML = 'Nicio gestiune disponibilă'; } const sectieEl = document.getElementById('settIdSectie'); @@ -85,7 +86,15 @@ async function loadSettings() { if (el('settIdPol')) el('settIdPol').value = data.id_pol || ''; if (el('settIdPolProductie')) el('settIdPolProductie').value = data.id_pol_productie || ''; if (el('settIdSectie')) el('settIdSectie').value = data.id_sectie || ''; - if (el('settIdGestiune')) el('settIdGestiune').value = data.id_gestiune || ''; + // Multi-gestiune checkboxes + const gestVal = data.id_gestiune || ''; + if (gestVal) { + const selectedIds = gestVal.split(',').map(s => s.trim()); + selectedIds.forEach(id => { + const chk = document.getElementById('gestChk_' + id); + if (chk) chk.checked = true; + }); + } 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'; @@ -109,7 +118,7 @@ async function saveSettings() { id_pol: el('settIdPol')?.value?.trim() || '', id_pol_productie: el('settIdPolProductie')?.value?.trim() || '', id_sectie: el('settIdSectie')?.value?.trim() || '', - id_gestiune: el('settIdGestiune')?.value?.trim() || '', + id_gestiune: Array.from(document.querySelectorAll('#settGestiuniContainer input:checked')).map(c => c.value).join(','), 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 dacbeef..b3ff169 100644 --- a/api/app/templates/settings.html +++ b/api/app/templates/settings.html @@ -39,10 +39,11 @@
Import ROA
- - + +
+ Se încarcă... +
+
Nicio selecție = orice gestiune
@@ -166,5 +167,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/api/database-scripts/06_pack_import_comenzi.pck b/api/database-scripts/06_pack_import_comenzi.pck index bff6ca7..bbd8016 100644 --- a/api/database-scripts/06_pack_import_comenzi.pck +++ b/api/database-scripts/06_pack_import_comenzi.pck @@ -61,7 +61,7 @@ CREATE OR REPLACE PACKAGE PACK_IMPORT_COMENZI AS p_id_adresa_facturare IN NUMBER DEFAULT NULL, p_id_pol IN NUMBER DEFAULT NULL, p_id_sectie IN NUMBER DEFAULT NULL, - p_id_gestiune IN NUMBER DEFAULT NULL, + p_id_gestiune IN VARCHAR2 DEFAULT NULL, v_id_comanda OUT NUMBER); -- Functii pentru managementul erorilor (pentru orchestrator VFP) @@ -93,11 +93,11 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS -- Functie helper: selecteaza id_articol corect pentru un CODMAT -- Prioritate: sters=0 AND inactiv=0, preferinta stoc, MAX(id_articol) fallback -- ================================================================ - FUNCTION resolve_id_articol(p_codmat IN VARCHAR2, p_id_gest IN NUMBER) RETURN NUMBER IS + FUNCTION resolve_id_articol(p_codmat IN VARCHAR2, p_id_gest IN VARCHAR2) RETURN NUMBER IS v_result NUMBER; BEGIN IF p_id_gest IS NOT NULL THEN - -- Cu gestiune specifica — Oracle poate folosi index pe stoc(id_gestiune, an, luna) + -- Cu gestiuni specifice (CSV: "1,3") — split in subquery pentru IN clause BEGIN SELECT id_articol INTO v_result FROM ( SELECT na.id_articol @@ -107,7 +107,11 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS CASE WHEN EXISTS ( SELECT 1 FROM stoc s WHERE s.id_articol = na.id_articol - AND s.id_gestiune = p_id_gest + AND s.id_gestiune IN ( + SELECT TO_NUMBER(REGEXP_SUBSTR(p_id_gest, '[^,]+', 1, LEVEL)) + FROM DUAL + CONNECT BY LEVEL <= REGEXP_COUNT(p_id_gest, ',') + 1 + ) AND s.an = EXTRACT(YEAR FROM SYSDATE) AND s.luna = EXTRACT(MONTH FROM SYSDATE) AND s.cants + s.cant - s.cante > 0 @@ -150,7 +154,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS p_id_adresa_facturare IN NUMBER DEFAULT NULL, p_id_pol IN NUMBER DEFAULT NULL, p_id_sectie IN NUMBER DEFAULT NULL, - p_id_gestiune IN NUMBER DEFAULT NULL, + p_id_gestiune IN VARCHAR2 DEFAULT NULL, v_id_comanda OUT NUMBER) IS v_data_livrare DATE; v_sku VARCHAR2(100);