import logging from .. import database logger = logging.getLogger(__name__) def check_orders_in_roa(min_date, conn) -> dict: """Check which orders already exist in Oracle COMENZI by date range. Returns: {comanda_externa: id_comanda} for all existing orders. Much faster than IN-clause batching — single query using date index. """ if conn is None: return {} existing = {} try: with conn.cursor() as cur: cur.execute(""" SELECT comanda_externa, id_comanda FROM COMENZI WHERE data_comanda >= :min_date AND comanda_externa IS NOT NULL AND sters = 0 """, {"min_date": min_date}) for row in cur: existing[str(row[0])] = row[1] except Exception as e: logger.error(f"check_orders_in_roa failed: {e}") logger.info(f"ROA order check (since {min_date}): {len(existing)} existing orders found") return existing 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: return {} result = {} codmat_list = list(codmats) # Build stoc subquery dynamically for index optimization 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 = "" 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_gestiuni: for k, gid in enumerate(id_gestiuni): params[f"g{k}"] = gid cur.execute(f""" SELECT codmat, id_articol, cont FROM ( SELECT na.codmat, na.id_articol, na.cont, 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]] = {"id_articol": row[1], "cont": row[2]} finally: if own_conn: database.pool.release(conn) 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_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) - direct: found in NOM_ARTICOLE by codmat (not in ARTICOLE_TERTI) - missing: not found anywhere - direct_id_map: {codmat: {"id_articol": int, "cont": str|None}} for direct SKUs """ if not skus: return {"mapped": set(), "direct": set(), "missing": set(), "direct_id_map": {}} mapped = set() sku_list = list(skus) own_conn = conn is None if own_conn: conn = database.get_oracle_connection() try: with conn.cursor() as cur: # Check in batches of 500 for i in range(0, len(sku_list), 500): batch = sku_list[i:i+500] placeholders = ",".join([f":s{j}" for j in range(len(batch))]) params = {f"s{j}": sku for j, sku in enumerate(batch)} # Check ARTICOLE_TERTI cur.execute(f""" SELECT DISTINCT sku FROM ARTICOLE_TERTI WHERE sku IN ({placeholders}) AND activ = 1 AND sters = 0 """, params) for row in cur: mapped.add(row[0]) # 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_gestiuni, conn) direct = set(direct_id_map.keys()) else: direct_id_map = {} direct = set() finally: if own_conn: database.pool.release(conn) missing = skus - mapped - direct logger.info(f"SKU validation: {len(mapped)} mapped, {len(direct)} direct, {len(missing)} missing") return {"mapped": mapped, "direct": direct, "missing": missing, "direct_id_map": direct_id_map} def classify_orders(orders, validation_result): """Classify orders as importable or skipped based on SKU validation. Returns: (importable_orders, skipped_orders) Each skipped entry is a tuple of (order, list_of_missing_skus). """ ok_skus = validation_result["mapped"] | validation_result["direct"] importable = [] skipped = [] for order in orders: order_skus = {item.sku for item in order.items if item.sku} order_missing = order_skus - ok_skus if order_missing: skipped.append((order, list(order_missing))) else: importable.append(order) return importable, skipped def _extract_id_map(direct_id_map: dict) -> dict: """Extract {codmat: id_articol} from either enriched or simple format.""" if not direct_id_map: return {} result = {} for cm, val in direct_id_map.items(): if isinstance(val, dict): result[cm] = val["id_articol"] else: result[cm] = val return result def validate_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict=None) -> dict: """Check which CODMATs have a price entry in CRM_POLITICI_PRET_ART for the given policy. If direct_id_map is provided, skips the NOM_ARTICOLE lookup for those CODMATs. Returns: {"has_price": set_of_codmats, "missing_price": set_of_codmats} """ if not codmats: return {"has_price": set(), "missing_price": set()} codmat_to_id = _extract_id_map(direct_id_map) ids_with_price = set() own_conn = conn is None if own_conn: conn = database.get_oracle_connection() try: with conn.cursor() as cur: # 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] placeholders = ",".join([f":a{j}" for j in range(len(batch))]) params = {f"a{j}": aid for j, aid in enumerate(batch)} params["id_pol"] = id_pol cur.execute(f""" SELECT DISTINCT pa.ID_ARTICOL FROM CRM_POLITICI_PRET_ART pa WHERE pa.ID_POL = :id_pol AND pa.ID_ARTICOL IN ({placeholders}) """, params) for row in cur: ids_with_price.add(row[0]) finally: if own_conn: database.pool.release(conn) # Map back to CODMATs has_price = {cm for cm, aid in codmat_to_id.items() if aid in ids_with_price} missing_price = codmats - has_price logger.info(f"Price validation (policy {id_pol}): {len(has_price)} have price, {len(missing_price)} missing price") return {"has_price": has_price, "missing_price": missing_price} def ensure_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict=None, cota_tva: float = None): """Insert price 0 entries for CODMATs missing from the given price policy. Uses batch executemany instead of individual INSERTs. Relies on TRG_CRM_POLITICI_PRET_ART trigger for ID_POL_ART sequence. cota_tva: VAT rate from settings (e.g. 21) — used for PROC_TVAV metadata. """ if not codmats: return proc_tvav = 1 + (cota_tva / 100) if cota_tva else 1.21 own_conn = conn is None if own_conn: conn = database.get_oracle_connection() try: with conn.cursor() as cur: # Get ID_VALUTA for this policy cur.execute(""" SELECT ID_VALUTA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :id_pol """, {"id_pol": id_pol}) row = cur.fetchone() if not row: logger.error(f"Price policy {id_pol} not found in CRM_POLITICI_PRETURI") return id_valuta = row[0] # Build batch params using direct_id_map (already resolved via resolve_codmat_ids) batch_params = [] codmat_id_map = _extract_id_map(direct_id_map) for codmat in codmats: id_articol = codmat_id_map.get(codmat) if not id_articol: logger.warning(f"CODMAT {codmat} not found in NOM_ARTICOLE, skipping price insert") continue batch_params.append({ "id_pol": id_pol, "id_articol": id_articol, "id_valuta": id_valuta, "proc_tvav": proc_tvav }) if batch_params: cur.executemany(""" INSERT INTO CRM_POLITICI_PRET_ART (ID_POL, ID_ARTICOL, PRET, ID_VALUTA, ID_UTIL, DATAORA, PROC_TVAV, PRETFTVA, PRETCTVA) VALUES (:id_pol, :id_articol, 0, :id_valuta, -3, SYSDATE, :proc_tvav, 0, 0) """, batch_params) logger.info(f"Batch inserted {len(batch_params)} price entries for policy {id_pol} (PROC_TVAV={proc_tvav})") conn.commit() finally: if own_conn: database.pool.release(conn) logger.info(f"Ensure prices done: {len(codmats)} CODMATs processed for policy {id_pol}") def validate_and_ensure_prices_dual(codmats: set[str], id_pol_vanzare: int, id_pol_productie: int, conn, direct_id_map: dict, cota_tva: float = 21) -> dict[str, int]: """Dual-policy price validation: assign each CODMAT to sales or production policy. Logic: 1. Check both policies in one SQL 2. If article in one policy → use that 3. If article in BOTH → prefer id_pol_vanzare 4. If article in NEITHER → check cont: 341/345 → production, else → sales; insert price 0 Returns: codmat_policy_map = {codmat: assigned_id_pol} """ if not codmats: return {} codmat_policy_map = {} id_map = _extract_id_map(direct_id_map) # Collect all id_articol values we need to check id_to_codmats = {} # {id_articol: [codmat, ...]} for cm in codmats: aid = id_map.get(cm) if aid: id_to_codmats.setdefault(aid, []).append(cm) if not id_to_codmats: return {} # Query both policies in one SQL existing = {} # {id_articol: set of id_pol} id_list = list(id_to_codmats.keys()) with conn.cursor() as cur: for i in range(0, len(id_list), 500): batch = id_list[i:i+500] placeholders = ",".join([f":a{j}" for j in range(len(batch))]) params = {f"a{j}": aid for j, aid in enumerate(batch)} params["id_pol_v"] = id_pol_vanzare params["id_pol_p"] = id_pol_productie cur.execute(f""" SELECT pa.ID_ARTICOL, pa.ID_POL FROM CRM_POLITICI_PRET_ART pa WHERE pa.ID_POL IN (:id_pol_v, :id_pol_p) AND pa.ID_ARTICOL IN ({placeholders}) """, params) for row in cur: existing.setdefault(row[0], set()).add(row[1]) # Classify each codmat missing_vanzare = set() # CODMATs needing price 0 in sales policy missing_productie = set() # CODMATs needing price 0 in production policy for aid, cms in id_to_codmats.items(): pols = existing.get(aid, set()) for cm in cms: if pols: if id_pol_vanzare in pols: codmat_policy_map[cm] = id_pol_vanzare elif id_pol_productie in pols: codmat_policy_map[cm] = id_pol_productie else: # Not in any policy — classify by cont info = direct_id_map.get(cm, {}) cont = info.get("cont", "") if isinstance(info, dict) else "" cont_str = str(cont or "").strip() if cont_str in ("341", "345"): codmat_policy_map[cm] = id_pol_productie missing_productie.add(cm) else: codmat_policy_map[cm] = id_pol_vanzare missing_vanzare.add(cm) # Ensure prices for missing articles in each policy if missing_vanzare: ensure_prices(missing_vanzare, id_pol_vanzare, conn, direct_id_map, cota_tva=cota_tva) if missing_productie: ensure_prices(missing_productie, id_pol_productie, conn, direct_id_map, cota_tva=cota_tva) logger.info( f"Dual-policy: {len(codmat_policy_map)} CODMATs assigned " f"(vanzare={sum(1 for v in codmat_policy_map.values() if v == id_pol_vanzare)}, " f"productie={sum(1 for v in codmat_policy_map.values() if v == id_pol_productie)})" ) return codmat_policy_map def resolve_mapped_codmats(mapped_skus: set[str], conn, id_gestiuni: list[int] = None) -> dict[str, list[dict]]: """For mapped SKUs, get their underlying CODMATs from ARTICOLE_TERTI + nom_articole. Uses ROW_NUMBER to pick the best id_articol per (SKU, CODMAT) pair: prefers article with stock in current month, then MAX(id_articol) as fallback. This avoids inflating results when a CODMAT has multiple NOM_ARTICOLE entries. Returns: {sku: [{"codmat": str, "id_articol": int, "cont": str|None, "cantitate_roa": float|None}]} """ if not mapped_skus: return {} # Build stoc subquery gestiune filter (same pattern as resolve_codmat_ids) 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 = "" result = {} sku_list = list(mapped_skus) with conn.cursor() as cur: for i in range(0, len(sku_list), 500): batch = sku_list[i:i+500] placeholders = ",".join([f":s{j}" for j in range(len(batch))]) params = {f"s{j}": sku for j, sku in enumerate(batch)} if id_gestiuni: for k, gid in enumerate(id_gestiuni): params[f"g{k}"] = gid cur.execute(f""" SELECT sku, codmat, id_articol, cont, cantitate_roa FROM ( SELECT at.sku, at.codmat, na.id_articol, na.cont, at.cantitate_roa, ROW_NUMBER() OVER ( PARTITION BY at.sku, at.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 ARTICOLE_TERTI at JOIN NOM_ARTICOLE na ON na.codmat = at.codmat AND na.sters = 0 AND na.inactiv = 0 WHERE at.sku IN ({placeholders}) AND at.activ = 1 AND at.sters = 0 ) WHERE rn = 1 """, params) for row in cur: sku = row[0] if sku not in result: result[sku] = [] result[sku].append({ "codmat": row[1], "id_articol": row[2], "cont": row[3], "cantitate_roa": row[4] }) logger.info(f"resolve_mapped_codmats: {len(result)} SKUs → {sum(len(v) for v in result.values())} CODMATs") return result def validate_kit_component_prices(mapped_codmat_data: dict, id_pol: int, id_pol_productie: int = None, conn=None) -> dict: """Pre-validate that kit components have non-zero prices in crm_politici_pret_art. Args: mapped_codmat_data: {sku: [{"codmat", "id_articol", "cont"}, ...]} from resolve_mapped_codmats id_pol: default sales price policy id_pol_productie: production price policy (for cont 341/345) Returns: {sku: [missing_codmats]} for SKUs with missing prices, {} if all OK """ missing = {} own_conn = conn is None if own_conn: conn = database.get_oracle_connection() try: with conn.cursor() as cur: for sku, components in mapped_codmat_data.items(): if len(components) == 0: continue if len(components) == 1 and (components[0].get("cantitate_roa") or 1) <= 1: continue # True 1:1 mapping, no kit pricing needed sku_missing = [] for comp in components: cont = str(comp.get("cont") or "").strip() if cont in ("341", "345") and id_pol_productie: pol = id_pol_productie else: pol = id_pol cur.execute(""" SELECT PRET FROM crm_politici_pret_art WHERE id_pol = :pol AND id_articol = :id_art """, {"pol": pol, "id_art": comp["id_articol"]}) row = cur.fetchone() if not row or (row[0] is not None and row[0] == 0): sku_missing.append(comp["codmat"]) if sku_missing: missing[sku] = sku_missing finally: if own_conn: database.pool.release(conn) return missing def compare_and_update_price(id_articol: int, id_pol: int, web_price_cu_tva: float, conn, tolerance: float = 0.01) -> dict | None: """Compare web price with ROA price and update if different. Handles PRETURI_CU_TVA flag per policy. Returns: {"updated": bool, "old_price": float, "new_price": float, "codmat": str} or None if no price entry. """ with conn.cursor() as cur: cur.execute("SELECT PRETURI_CU_TVA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :pol", {"pol": id_pol}) pol_row = cur.fetchone() if not pol_row: return None preturi_cu_tva = pol_row[0] # 1 or 0 cur.execute(""" SELECT PRET, PROC_TVAV, na.codmat FROM crm_politici_pret_art pa JOIN nom_articole na ON na.id_articol = pa.id_articol WHERE pa.id_pol = :pol AND pa.id_articol = :id_art """, {"pol": id_pol, "id_art": id_articol}) row = cur.fetchone() if not row: return None pret_roa, proc_tvav, codmat = row[0], row[1], row[2] proc_tvav = proc_tvav or 1.19 if preturi_cu_tva == 1: pret_roa_cu_tva = pret_roa else: pret_roa_cu_tva = pret_roa * proc_tvav if abs(pret_roa_cu_tva - web_price_cu_tva) <= tolerance: return {"updated": False, "old_price": pret_roa_cu_tva, "new_price": web_price_cu_tva, "codmat": codmat} if preturi_cu_tva == 1: new_pret = web_price_cu_tva else: new_pret = round(web_price_cu_tva / proc_tvav, 4) cur.execute(""" UPDATE crm_politici_pret_art SET PRET = :pret, DATAORA = SYSDATE WHERE id_pol = :pol AND id_articol = :id_art """, {"pret": new_pret, "pol": id_pol, "id_art": id_articol}) conn.commit() return {"updated": True, "old_price": pret_roa_cu_tva, "new_price": web_price_cu_tva, "codmat": codmat} def sync_prices_from_order(orders, mapped_codmat_data: dict, direct_id_map: dict, codmat_policy_map: dict, id_pol: int, id_pol_productie: int = None, conn=None, settings: dict = None) -> list: """Sync prices from order items to ROA for direct/1:1 mappings. Skips kit components and transport/discount CODMATs. Returns: list of {"codmat", "old_price", "new_price"} for updated prices. """ if settings and settings.get("price_sync_enabled") != "1": return [] transport_codmat = (settings or {}).get("transport_codmat", "") discount_codmat = (settings or {}).get("discount_codmat", "") kit_discount_codmat = (settings or {}).get("kit_discount_codmat", "") skip_codmats = {transport_codmat, discount_codmat, kit_discount_codmat} - {""} # Build set of kit SKUs (>1 component) kit_skus = {sku for sku, comps in mapped_codmat_data.items() if len(comps) > 1} updated = [] own_conn = conn is None if own_conn: conn = database.get_oracle_connection() try: for order in orders: for item in order.items: sku = item.sku if not sku or sku in skip_codmats: continue if sku in kit_skus: continue # Don't sync prices from kit orders web_price = item.price # already with TVA if not web_price or web_price <= 0: continue # Determine id_articol and price policy for this SKU if sku in mapped_codmat_data and len(mapped_codmat_data[sku]) == 1: # 1:1 mapping via ARTICOLE_TERTI comp = mapped_codmat_data[sku][0] id_articol = comp["id_articol"] cantitate_roa = comp.get("cantitate_roa") or 1 web_price_per_unit = web_price / cantitate_roa if cantitate_roa != 1 else web_price elif sku in (direct_id_map or {}): info = direct_id_map[sku] id_articol = info["id_articol"] if isinstance(info, dict) else info web_price_per_unit = web_price else: continue pol = codmat_policy_map.get(sku, id_pol) result = compare_and_update_price(id_articol, pol, web_price_per_unit, conn) if result and result["updated"]: updated.append(result) finally: if own_conn: database.pool.release(conn) return updated