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) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-03-17 12:18:18 +00:00
parent 09a5403f83
commit 7a2408e310
7 changed files with 215 additions and 105 deletions

View File

@@ -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)
])

View File

@@ -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)

View File

@@ -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: