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:
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = '<option value="">— orice gestiune —</option>';
|
||||
gestiuni.forEach(g => {
|
||||
gestiuneEl.innerHTML += `<option value="${escHtml(g.id)}">${escHtml(g.label)}</option>`;
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2 px-3 fw-semibold">Import ROA</div>
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-0 small">Gestiune (ID_GESTIUNE)</label>
|
||||
<select class="form-select form-select-sm" id="settIdGestiune">
|
||||
<option value="">— orice gestiune —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-0 small">Secție (ID_SECTIE)</label>
|
||||
<select class="form-select form-select-sm" id="settIdSectie">
|
||||
@@ -146,5 +152,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=3"></script>
|
||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=4"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user