From 9e5901a8fb6ad4b39f82ac765d139358a33950fa Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 19 Mar 2026 22:29:18 +0000 Subject: [PATCH] feat(pricing): kit/pachet pricing with price list lookup, replace procent_pret - Oracle PL/SQL: kit pricing logic with Mode A (distributed discount) and Mode B (separate discount line), dual policy support, PRETURI_CU_TVA flag - Eliminate procent_pret from entire stack (Oracle, Python, JS, HTML) - New settings: kit_pricing_mode, kit_discount_codmat, price_sync_enabled - Settings UI: cards for Kit Pricing and Price Sync configuration - Mappings UI: kit badges with lazy-loaded component prices from price list - Price sync from orders: auto-update ROA prices when web prices differ - Catalog price sync: new service to sync all GoMag product prices to ROA - Kit component price validation: pre-check prices before import - New endpoint GET /api/mappings/{sku}/prices for component price display - New endpoints POST /api/price-sync/start, GET status, GET history - DDL script 07_drop_procent_pret.sql (run after deploy confirmation) Co-Authored-By: Claude Opus 4.6 (1M context) --- api/app/database.py | 12 + api/app/routers/mappings.py | 43 +- api/app/routers/sync.py | 54 ++- api/app/services/gomag_client.py | 74 ++++ api/app/services/import_service.py | 12 +- api/app/services/mapping_service.py | 185 ++++---- api/app/services/price_sync_service.py | 220 ++++++++++ api/app/services/sqlite_service.py | 22 + api/app/services/sync_service.py | 50 +++ api/app/services/validation_service.py | 160 ++++++- api/app/static/js/mappings.js | 174 ++++---- api/app/static/js/settings.js | 91 ++++ api/app/templates/mappings.html | 12 +- api/app/templates/settings.html | 68 ++- api/database-scripts/04_pack_comenzi.pck | 4 + .../06_pack_import_comenzi.pck | 397 +++++++++++++++--- api/database-scripts/07_drop_procent_pret.sql | 3 + 17 files changed, 1313 insertions(+), 268 deletions(-) create mode 100644 api/app/services/price_sync_service.py create mode 100644 api/database-scripts/07_drop_procent_pret.sql diff --git a/api/app/database.py b/api/app/database.py index f711c3b..984f0cb 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -152,6 +152,18 @@ CREATE TABLE IF NOT EXISTS app_settings ( value TEXT ); +CREATE TABLE IF NOT EXISTS price_sync_runs ( + run_id TEXT PRIMARY KEY, + started_at TEXT, + finished_at TEXT, + status TEXT DEFAULT 'running', + products_total INTEGER DEFAULT 0, + matched INTEGER DEFAULT 0, + updated INTEGER DEFAULT 0, + errors INTEGER DEFAULT 0, + log_text TEXT +); + CREATE TABLE IF NOT EXISTS order_items ( order_number TEXT, sku TEXT, diff --git a/api/app/routers/mappings.py b/api/app/routers/mappings.py index 1ba68c9..77a4be9 100644 --- a/api/app/routers/mappings.py +++ b/api/app/routers/mappings.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, validator from pathlib import Path from typing import Optional import io +import asyncio from ..services import mapping_service, sqlite_service @@ -19,7 +20,6 @@ class MappingCreate(BaseModel): sku: str codmat: str cantitate_roa: float = 1 - procent_pret: float = 100 @validator('sku', 'codmat') def not_empty(cls, v): @@ -29,14 +29,12 @@ class MappingCreate(BaseModel): class MappingUpdate(BaseModel): cantitate_roa: Optional[float] = None - procent_pret: Optional[float] = None activ: Optional[int] = None class MappingEdit(BaseModel): new_sku: str new_codmat: str cantitate_roa: float = 1 - procent_pret: float = 100 @validator('new_sku', 'new_codmat') def not_empty(cls, v): @@ -47,7 +45,6 @@ class MappingEdit(BaseModel): class MappingLine(BaseModel): codmat: str cantitate_roa: float = 1 - procent_pret: float = 100 class MappingBatchCreate(BaseModel): sku: str @@ -63,11 +60,10 @@ async def mappings_page(request: Request): @router.get("/api/mappings") async def list_mappings(search: str = "", page: int = 1, per_page: int = 50, sort_by: str = "sku", sort_dir: str = "asc", - show_deleted: bool = False, pct_filter: str = None): + show_deleted: bool = False): result = mapping_service.get_mappings(search=search, page=page, per_page=per_page, sort_by=sort_by, sort_dir=sort_dir, - show_deleted=show_deleted, - pct_filter=pct_filter) + show_deleted=show_deleted) # Merge product names from web_products (R4) skus = list({m["sku"] for m in result.get("mappings", [])}) product_names = await sqlite_service.get_web_products_batch(skus) @@ -75,13 +71,13 @@ async def list_mappings(search: str = "", page: int = 1, per_page: int = 50, m["product_name"] = product_names.get(m["sku"], "") # Ensure counts key is always present if "counts" not in result: - result["counts"] = {"total": 0, "complete": 0, "incomplete": 0} + result["counts"] = {"total": 0} return result @router.post("/api/mappings") async def create_mapping(data: MappingCreate): try: - result = mapping_service.create_mapping(data.sku, data.codmat, data.cantitate_roa, data.procent_pret) + result = mapping_service.create_mapping(data.sku, data.codmat, data.cantitate_roa) # Mark SKU as resolved in missing_skus tracking await sqlite_service.resolve_missing_sku(data.sku) return {"success": True, **result} @@ -97,7 +93,7 @@ async def create_mapping(data: MappingCreate): @router.put("/api/mappings/{sku}/{codmat}") def update_mapping(sku: str, codmat: str, data: MappingUpdate): try: - updated = mapping_service.update_mapping(sku, codmat, data.cantitate_roa, data.procent_pret, data.activ) + updated = mapping_service.update_mapping(sku, codmat, data.cantitate_roa, data.activ) return {"success": updated} except Exception as e: return {"success": False, "error": str(e)} @@ -106,7 +102,7 @@ def update_mapping(sku: str, codmat: str, data: MappingUpdate): def edit_mapping(sku: str, codmat: str, data: MappingEdit): try: result = mapping_service.edit_mapping(sku, codmat, data.new_sku, data.new_codmat, - data.cantitate_roa, data.procent_pret) + data.cantitate_roa) return {"success": result} except Exception as e: return {"success": False, "error": str(e)} @@ -133,16 +129,10 @@ async def create_batch_mapping(data: MappingBatchCreate): if not data.mappings: return {"success": False, "error": "No mappings provided"} - # Validate procent_pret sums to 100 for multi-line sets - if len(data.mappings) > 1: - total_pct = sum(m.procent_pret for m in data.mappings) - if abs(total_pct - 100) > 0.01: - return {"success": False, "error": f"Procent pret trebuie sa fie 100% (actual: {total_pct}%)"} - try: results = [] for m in data.mappings: - r = mapping_service.create_mapping(data.sku, m.codmat, m.cantitate_roa, m.procent_pret, auto_restore=data.auto_restore) + r = mapping_service.create_mapping(data.sku, m.codmat, m.cantitate_roa, auto_restore=data.auto_restore) results.append(r) # Mark SKU as resolved in missing_skus tracking await sqlite_service.resolve_missing_sku(data.sku) @@ -151,6 +141,23 @@ async def create_batch_mapping(data: MappingBatchCreate): return {"success": False, "error": str(e)} +@router.get("/api/mappings/{sku}/prices") +async def get_mapping_prices(sku: str): + """Get component prices from crm_politici_pret_art for a kit SKU.""" + app_settings = await sqlite_service.get_app_settings() + id_pol = int(app_settings.get("id_pol") or 0) or None + id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None + if not id_pol: + return {"error": "Politica de pret nu este configurata", "prices": []} + try: + prices = await asyncio.to_thread( + mapping_service.get_component_prices, sku, id_pol, id_pol_productie + ) + return {"prices": prices} + except Exception as e: + return {"error": str(e), "prices": []} + + @router.post("/api/mappings/import-csv") async def import_csv(file: UploadFile = File(...)): content = await file.read() diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py index 7ca278d..2c83c6c 100644 --- a/api/app/routers/sync.py +++ b/api/app/routers/sync.py @@ -41,6 +41,13 @@ class AppSettingsUpdate(BaseModel): gomag_order_days_back: str = "7" gomag_limit: str = "100" dashboard_poll_seconds: str = "5" + kit_pricing_mode: str = "" + kit_discount_codmat: str = "" + kit_discount_id_pol: str = "" + price_sync_enabled: str = "1" + catalog_sync_enabled: str = "0" + price_sync_schedule: str = "" + gomag_products_url: str = "" # API endpoints @@ -139,6 +146,31 @@ async def sync_history(page: int = 1, per_page: int = 20): return await sqlite_service.get_sync_runs(page, per_page) +@router.post("/api/price-sync/start") +async def start_price_sync(background_tasks: BackgroundTasks): + """Trigger manual catalog price sync.""" + from ..services import price_sync_service + result = await price_sync_service.prepare_price_sync() + if result.get("error"): + return {"error": result["error"]} + run_id = result["run_id"] + background_tasks.add_task(price_sync_service.run_catalog_price_sync, run_id=run_id) + return {"message": "Price sync started", "run_id": run_id} + + +@router.get("/api/price-sync/status") +async def price_sync_status(): + """Get current price sync status.""" + from ..services import price_sync_service + return await price_sync_service.get_price_sync_status() + + +@router.get("/api/price-sync/history") +async def price_sync_history(page: int = 1, per_page: int = 20): + """Get price sync run history.""" + return await sqlite_service.get_price_sync_runs(page, per_page) + + @router.get("/logs", response_class=HTMLResponse) async def logs_page(request: Request, run: str = None): return templates.TemplateResponse("logs.html", {"request": request, "selected_run": run or ""}) @@ -285,7 +317,7 @@ async def sync_run_orders(run_id: str, status: str = "all", page: int = 1, per_p def _get_articole_terti_for_skus(skus: set) -> dict: - """Query ARTICOLE_TERTI for all active codmat/cantitate/procent per SKU.""" + """Query ARTICOLE_TERTI for all active codmat/cantitate per SKU.""" from .. import database result = {} sku_list = list(skus) @@ -297,7 +329,7 @@ def _get_articole_terti_for_skus(skus: set) -> dict: placeholders = ",".join([f":s{j}" for j in range(len(batch))]) params = {f"s{j}": sku for j, sku in enumerate(batch)} cur.execute(f""" - SELECT at.sku, at.codmat, at.cantitate_roa, at.procent_pret, + SELECT at.sku, at.codmat, at.cantitate_roa, na.denumire FROM ARTICOLE_TERTI at LEFT JOIN NOM_ARTICOLE na ON na.codmat = at.codmat AND na.sters = 0 AND na.inactiv = 0 @@ -311,8 +343,7 @@ def _get_articole_terti_for_skus(skus: set) -> dict: result[sku].append({ "codmat": row[1], "cantitate_roa": float(row[2]) if row[2] else 1, - "procent_pret": float(row[3]) if row[3] else 100, - "denumire": row[4] or "" + "denumire": row[3] or "" }) finally: database.pool.release(conn) @@ -371,7 +402,6 @@ async def order_detail(order_number: str): item["codmat_details"] = [{ "codmat": sku, "cantitate_roa": 1, - "procent_pret": 100, "denumire": nom_map[sku], "direct": True }] @@ -663,6 +693,13 @@ async def get_app_settings(): "gomag_order_days_back": s.get("gomag_order_days_back", "") or str(config_settings.GOMAG_ORDER_DAYS_BACK), "gomag_limit": s.get("gomag_limit", "") or str(config_settings.GOMAG_LIMIT), "dashboard_poll_seconds": s.get("dashboard_poll_seconds", "5"), + "kit_pricing_mode": s.get("kit_pricing_mode", ""), + "kit_discount_codmat": s.get("kit_discount_codmat", ""), + "kit_discount_id_pol": s.get("kit_discount_id_pol", ""), + "price_sync_enabled": s.get("price_sync_enabled", "1"), + "catalog_sync_enabled": s.get("catalog_sync_enabled", "0"), + "price_sync_schedule": s.get("price_sync_schedule", ""), + "gomag_products_url": s.get("gomag_products_url", ""), } @@ -685,6 +722,13 @@ async def update_app_settings(config: AppSettingsUpdate): await sqlite_service.set_app_setting("gomag_order_days_back", config.gomag_order_days_back) await sqlite_service.set_app_setting("gomag_limit", config.gomag_limit) await sqlite_service.set_app_setting("dashboard_poll_seconds", config.dashboard_poll_seconds) + await sqlite_service.set_app_setting("kit_pricing_mode", config.kit_pricing_mode) + await sqlite_service.set_app_setting("kit_discount_codmat", config.kit_discount_codmat) + await sqlite_service.set_app_setting("kit_discount_id_pol", config.kit_discount_id_pol) + await sqlite_service.set_app_setting("price_sync_enabled", config.price_sync_enabled) + await sqlite_service.set_app_setting("catalog_sync_enabled", config.catalog_sync_enabled) + await sqlite_service.set_app_setting("price_sync_schedule", config.price_sync_schedule) + await sqlite_service.set_app_setting("gomag_products_url", config.gomag_products_url) return {"success": True} diff --git a/api/app/services/gomag_client.py b/api/app/services/gomag_client.py index ccdd778..e1f7bd4 100644 --- a/api/app/services/gomag_client.py +++ b/api/app/services/gomag_client.py @@ -101,3 +101,77 @@ async def download_orders( await asyncio.sleep(1) return {"pages": total_pages, "total": total_orders, "files": saved_files} + + +async def download_products( + api_key: str = None, + api_shop: str = None, + products_url: str = None, + log_fn: Callable[[str], None] = None, +) -> list[dict]: + """Download all products from GoMag Products API. + Returns list of product dicts with: sku, price, vat, vat_included, bundleItems. + """ + def _log(msg: str): + logger.info(msg) + if log_fn: + log_fn(msg) + + effective_key = api_key or settings.GOMAG_API_KEY + effective_shop = api_shop or settings.GOMAG_API_SHOP + default_url = "https://api.gomag.ro/api/v1/product/read/json" + effective_url = products_url or default_url + + if not effective_key or not effective_shop: + _log("GoMag API keys neconfigurați, skip product download") + return [] + + headers = { + "Apikey": effective_key, + "ApiShop": effective_shop, + "User-Agent": "Mozilla/5.0", + "Content-Type": "application/json", + } + + all_products = [] + total_pages = 1 + + async with httpx.AsyncClient(timeout=30) as client: + page = 1 + while page <= total_pages: + params = {"page": page, "limit": 100} + try: + response = await client.get(effective_url, headers=headers, params=params) + response.raise_for_status() + data = response.json() + except httpx.HTTPError as e: + _log(f"GoMag Products API eroare pagina {page}: {e}") + break + except Exception as e: + _log(f"GoMag Products eroare neașteptată pagina {page}: {e}") + break + + if page == 1: + total_pages = int(data.get("pages", 1)) + _log(f"GoMag Products: {data.get('total', '?')} produse în {total_pages} pagini") + + products = data.get("products", []) + if isinstance(products, dict): + products = [products] + if isinstance(products, list): + for p in products: + if isinstance(p, dict) and p.get("sku"): + all_products.append({ + "sku": p["sku"], + "price": p.get("price", "0"), + "vat": p.get("vat", "19"), + "vat_included": p.get("vat_included", "1"), + "bundleItems": p.get("bundleItems", []), + }) + + page += 1 + if page <= total_pages: + await asyncio.sleep(1) + + _log(f"GoMag Products: {len(all_products)} produse cu SKU descărcate") + return all_products diff --git a/api/app/services/import_service.py b/api/app/services/import_service.py index 5eaf8e7..610a604 100644 --- a/api/app/services/import_service.py +++ b/api/app/services/import_service.py @@ -342,6 +342,12 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se # 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 + # Kit pricing parameters from settings + kit_mode = (app_settings or {}).get("kit_pricing_mode") or None + kit_id_pol_prod = int((app_settings or {}).get("id_pol_productie") or 0) or None + kit_discount_codmat = (app_settings or {}).get("kit_discount_codmat") or None + kit_discount_id_pol = int((app_settings or {}).get("kit_discount_id_pol") or 0) or None + cur.callproc("PACK_IMPORT_COMENZI.importa_comanda", [ order_number, # p_nr_comanda_ext order_date, # p_data_comanda @@ -352,7 +358,11 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se id_pol, # p_id_pol id_sectie, # p_id_sectie id_gestiune_csv, # p_id_gestiune (CSV string) - id_comanda # v_id_comanda (OUT) + kit_mode, # p_kit_mode + kit_id_pol_prod, # p_id_pol_productie + kit_discount_codmat, # p_kit_discount_codmat + kit_discount_id_pol, # p_kit_discount_id_pol + id_comanda # v_id_comanda (OUT) — MUST STAY LAST ]) comanda_id = id_comanda.getvalue() diff --git a/api/app/services/mapping_service.py b/api/app/services/mapping_service.py index 25bfc6a..0c69766 100644 --- a/api/app/services/mapping_service.py +++ b/api/app/services/mapping_service.py @@ -9,14 +9,8 @@ logger = logging.getLogger(__name__) def get_mappings(search: str = "", page: int = 1, per_page: int = 50, sort_by: str = "sku", sort_dir: str = "asc", - show_deleted: bool = False, pct_filter: str = None): - """Get paginated mappings with optional search, sorting, and pct_filter. - - pct_filter values: - 'complete' – only SKU groups where sum(procent_pret for active rows) == 100 - 'incomplete' – only SKU groups where sum < 100 - None / 'all' – no filter - """ + show_deleted: bool = False): + """Get paginated mappings with optional search and sorting.""" if database.pool is None: raise HTTPException(status_code=503, detail="Oracle unavailable") @@ -29,7 +23,6 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50, "denumire": "na.denumire", "um": "na.um", "cantitate_roa": "at.cantitate_roa", - "procent_pret": "at.procent_pret", "activ": "at.activ", } sort_col = allowed_sort.get(sort_by, "at.sku") @@ -58,7 +51,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50, # Fetch ALL matching rows (no pagination yet — we need to group by SKU first) data_sql = f""" SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa, - at.procent_pret, at.activ, at.sters, + at.activ, at.sters, TO_CHAR(at.data_creare, 'YYYY-MM-DD HH24:MI') as data_creare FROM ARTICOLE_TERTI at LEFT JOIN nom_articole na ON na.codmat = at.codmat @@ -69,7 +62,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50, columns = [col[0].lower() for col in cur.description] all_rows = [dict(zip(columns, row)) for row in cur.fetchall()] - # Group by SKU and compute pct_total for each group + # Group by SKU from collections import OrderedDict groups = OrderedDict() for row in all_rows: @@ -78,64 +71,13 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50, groups[sku] = [] groups[sku].append(row) - # Compute counts across ALL groups (before pct_filter) - total_skus = len(groups) - complete_skus = 0 - incomplete_skus = 0 - for sku, rows in groups.items(): - pct_total = sum( - (r["procent_pret"] or 0) - for r in rows - if r.get("activ") == 1 - ) - if abs(pct_total - 100) <= 0.01: - complete_skus += 1 - else: - incomplete_skus += 1 - - counts = { - "total": total_skus, - "complete": complete_skus, - "incomplete": incomplete_skus, - } - - # Apply pct_filter - if pct_filter in ("complete", "incomplete"): - filtered_groups = {} - for sku, rows in groups.items(): - pct_total = sum( - (r["procent_pret"] or 0) - for r in rows - if r.get("activ") == 1 - ) - is_complete = abs(pct_total - 100) <= 0.01 - if pct_filter == "complete" and is_complete: - filtered_groups[sku] = rows - elif pct_filter == "incomplete" and not is_complete: - filtered_groups[sku] = rows - groups = filtered_groups + counts = {"total": len(groups)} # Flatten back to rows for pagination (paginate by raw row count) filtered_rows = [row for rows in groups.values() for row in rows] total = len(filtered_rows) page_rows = filtered_rows[offset: offset + per_page] - # Attach pct_total and is_complete to each row for the renderer - # Re-compute per visible group - sku_pct = {} - for sku, rows in groups.items(): - pct_total = sum( - (r["procent_pret"] or 0) - for r in rows - if r.get("activ") == 1 - ) - sku_pct[sku] = {"pct_total": pct_total, "is_complete": abs(pct_total - 100) <= 0.01} - - for row in page_rows: - meta = sku_pct.get(row["sku"], {"pct_total": 0, "is_complete": False}) - row["pct_total"] = meta["pct_total"] - row["is_complete"] = meta["is_complete"] - return { "mappings": page_rows, "total": total, @@ -145,7 +87,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50, "counts": counts, } -def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100, auto_restore: bool = False): +def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, auto_restore: bool = False): """Create a new mapping. Returns dict or raises HTTPException on duplicate. When auto_restore=True, soft-deleted records are restored+updated instead of raising 409. @@ -194,11 +136,10 @@ def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret if auto_restore: cur.execute(""" UPDATE ARTICOLE_TERTI SET sters = 0, activ = 1, - cantitate_roa = :cantitate_roa, procent_pret = :procent_pret, + cantitate_roa = :cantitate_roa, data_modif = SYSDATE WHERE sku = :sku AND codmat = :codmat AND sters = 1 - """, {"sku": sku, "codmat": codmat, - "cantitate_roa": cantitate_roa, "procent_pret": procent_pret}) + """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa}) conn.commit() return {"sku": sku, "codmat": codmat} else: @@ -209,13 +150,13 @@ def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret ) cur.execute(""" - INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare) - VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3) - """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa, "procent_pret": procent_pret}) + INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare) + VALUES (:sku, :codmat, :cantitate_roa, 1, 0, SYSDATE, -3) + """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa}) conn.commit() return {"sku": sku, "codmat": codmat} -def update_mapping(sku: str, codmat: str, cantitate_roa: float = None, procent_pret: float = None, activ: int = None): +def update_mapping(sku: str, codmat: str, cantitate_roa: float = None, activ: int = None): """Update an existing mapping.""" if database.pool is None: raise HTTPException(status_code=503, detail="Oracle unavailable") @@ -226,9 +167,6 @@ def update_mapping(sku: str, codmat: str, cantitate_roa: float = None, procent_p if cantitate_roa is not None: sets.append("cantitate_roa = :cantitate_roa") params["cantitate_roa"] = cantitate_roa - if procent_pret is not None: - sets.append("procent_pret = :procent_pret") - params["procent_pret"] = procent_pret if activ is not None: sets.append("activ = :activ") params["activ"] = activ @@ -263,7 +201,7 @@ def delete_mapping(sku: str, codmat: str): return cur.rowcount > 0 def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str, - cantitate_roa: float = 1, procent_pret: float = 100): + cantitate_roa: float = 1): """Edit a mapping. If PK changed, soft-delete old and insert new.""" if not new_sku or not new_sku.strip(): raise HTTPException(status_code=400, detail="SKU este obligatoriu") @@ -273,8 +211,8 @@ def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str, raise HTTPException(status_code=503, detail="Oracle unavailable") if old_sku == new_sku and old_codmat == new_codmat: - # Simple update - only cantitate/procent changed - return update_mapping(new_sku, new_codmat, cantitate_roa, procent_pret) + # Simple update - only cantitate changed + return update_mapping(new_sku, new_codmat, cantitate_roa) else: # PK changed: soft-delete old, upsert new (MERGE handles existing soft-deleted target) with database.pool.acquire() as conn: @@ -291,14 +229,12 @@ def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str, ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET cantitate_roa = :cantitate_roa, - procent_pret = :procent_pret, activ = 1, sters = 0, data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT - (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare) - VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3) - """, {"sku": new_sku, "codmat": new_codmat, - "cantitate_roa": cantitate_roa, "procent_pret": procent_pret}) + (sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare) + VALUES (:sku, :codmat, :cantitate_roa, 1, 0, SYSDATE, -3) + """, {"sku": new_sku, "codmat": new_codmat, "cantitate_roa": cantitate_roa}) conn.commit() return True @@ -317,7 +253,9 @@ def restore_mapping(sku: str, codmat: str): return cur.rowcount > 0 def import_csv(file_content: str): - """Import mappings from CSV content. Returns summary.""" + """Import mappings from CSV content. Returns summary. + Backward compatible: if procent_pret column exists in CSV, it is silently ignored. + """ if database.pool is None: raise HTTPException(status_code=503, detail="Oracle unavailable") @@ -342,7 +280,7 @@ def import_csv(file_content: str): try: cantitate = float(row.get("cantitate_roa", "1") or "1") - procent = float(row.get("procent_pret", "100") or "100") + # procent_pret column ignored if present (backward compat) cur.execute(""" MERGE INTO ARTICOLE_TERTI t @@ -350,14 +288,13 @@ def import_csv(file_content: str): ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET cantitate_roa = :cantitate_roa, - procent_pret = :procent_pret, activ = 1, sters = 0, data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT - (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare) - VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3) - """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent}) + (sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare) + VALUES (:sku, :codmat, :cantitate_roa, 1, 0, SYSDATE, -3) + """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate}) created += 1 except Exception as e: @@ -374,12 +311,12 @@ def export_csv(): output = io.StringIO() writer = csv.writer(output) - writer.writerow(["sku", "codmat", "cantitate_roa", "procent_pret", "activ"]) + writer.writerow(["sku", "codmat", "cantitate_roa", "activ"]) with database.pool.acquire() as conn: with conn.cursor() as cur: cur.execute(""" - SELECT sku, codmat, cantitate_roa, procent_pret, activ + SELECT sku, codmat, cantitate_roa, activ FROM ARTICOLE_TERTI WHERE sters = 0 ORDER BY sku, codmat """) for row in cur: @@ -391,6 +328,70 @@ def get_csv_template(): """Return empty CSV template.""" output = io.StringIO() writer = csv.writer(output) - writer.writerow(["sku", "codmat", "cantitate_roa", "procent_pret"]) - writer.writerow(["EXAMPLE_SKU", "EXAMPLE_CODMAT", "1", "100"]) + writer.writerow(["sku", "codmat", "cantitate_roa"]) + writer.writerow(["EXAMPLE_SKU", "EXAMPLE_CODMAT", "1"]) return output.getvalue() + +def get_component_prices(sku: str, id_pol: int, id_pol_productie: int = None) -> list: + """Get prices from crm_politici_pret_art for kit components. + Returns: [{"codmat", "denumire", "cantitate_roa", "pret", "pret_cu_tva", "proc_tvav", "ptva", "id_pol_used"}] + """ + if database.pool is None: + raise HTTPException(status_code=503, detail="Oracle unavailable") + + with database.pool.acquire() as conn: + with conn.cursor() as cur: + # Get components from ARTICOLE_TERTI + cur.execute(""" + SELECT at.codmat, at.cantitate_roa, na.id_articol, na.cont, na.denumire + FROM ARTICOLE_TERTI at + JOIN NOM_ARTICOLE na ON na.codmat = at.codmat AND na.sters = 0 AND na.inactiv = 0 + WHERE at.sku = :sku AND at.activ = 1 AND at.sters = 0 + ORDER BY at.codmat + """, {"sku": sku}) + components = cur.fetchall() + + if len(components) <= 1: + return [] # Not a kit + + result = [] + for codmat, cant_roa, id_art, cont, denumire in components: + # Determine policy based on account + cont_str = str(cont or "").strip() + pol = id_pol_productie if (cont_str in ("341", "345") and id_pol_productie) else id_pol + + # Get PRETURI_CU_TVA flag + cur.execute("SELECT PRETURI_CU_TVA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :pol", {"pol": pol}) + pol_row = cur.fetchone() + preturi_cu_tva_flag = pol_row[0] if pol_row else 0 + + # Get price + cur.execute(""" + SELECT PRET, PROC_TVAV FROM crm_politici_pret_art + WHERE id_pol = :pol AND id_articol = :id_art + """, {"pol": pol, "id_art": id_art}) + price_row = cur.fetchone() + + if price_row: + pret, proc_tvav = price_row + proc_tvav = proc_tvav or 1.19 + pret_cu_tva = pret if preturi_cu_tva_flag == 1 else round(pret * proc_tvav, 2) + ptva = round((proc_tvav - 1) * 100) + else: + pret = 0 + pret_cu_tva = 0 + proc_tvav = 1.19 + ptva = 19 + + result.append({ + "codmat": codmat, + "denumire": denumire or "", + "cantitate_roa": float(cant_roa) if cant_roa else 1, + "pret": float(pret) if pret else 0, + "pret_cu_tva": float(pret_cu_tva), + "proc_tvav": float(proc_tvav), + "ptva": int(ptva), + "id_pol_used": pol + }) + + return result diff --git a/api/app/services/price_sync_service.py b/api/app/services/price_sync_service.py new file mode 100644 index 0000000..9a6df99 --- /dev/null +++ b/api/app/services/price_sync_service.py @@ -0,0 +1,220 @@ +"""Catalog price sync service — syncs product prices from GoMag catalog to ROA Oracle.""" +import asyncio +import logging +import uuid +from datetime import datetime +from zoneinfo import ZoneInfo + +from . import gomag_client, validation_service, sqlite_service +from .. import database +from ..config import settings + +logger = logging.getLogger(__name__) +_tz = ZoneInfo("Europe/Bucharest") + +_price_sync_lock = asyncio.Lock() +_current_price_sync = None + + +def _now(): + return datetime.now(_tz).replace(tzinfo=None) + + +async def prepare_price_sync() -> dict: + global _current_price_sync + if _price_sync_lock.locked(): + return {"error": "Price sync already running"} + run_id = _now().strftime("%Y%m%d_%H%M%S") + "_ps_" + uuid.uuid4().hex[:6] + _current_price_sync = { + "run_id": run_id, "status": "running", + "started_at": _now().isoformat(), "finished_at": None, + "phase_text": "Starting...", + } + # Create SQLite record + db = await sqlite_service.get_sqlite() + try: + await db.execute( + "INSERT INTO price_sync_runs (run_id, started_at, status) VALUES (?, ?, 'running')", + (run_id, _now().isoformat()) + ) + await db.commit() + finally: + await db.close() + return {"run_id": run_id} + + +async def get_price_sync_status() -> dict: + if _current_price_sync and _current_price_sync.get("status") == "running": + return _current_price_sync + # Return last run from SQLite + db = await sqlite_service.get_sqlite() + try: + cursor = await db.execute( + "SELECT * FROM price_sync_runs ORDER BY started_at DESC LIMIT 1" + ) + row = await cursor.fetchone() + if row: + return {"status": "idle", "last_run": dict(row)} + return {"status": "idle"} + except Exception: + return {"status": "idle"} + finally: + await db.close() + + +async def run_catalog_price_sync(run_id: str): + global _current_price_sync + async with _price_sync_lock: + log_lines = [] + def _log(msg): + logger.info(msg) + log_lines.append(f"[{_now().strftime('%H:%M:%S')}] {msg}") + if _current_price_sync: + _current_price_sync["phase_text"] = msg + + try: + app_settings = await sqlite_service.get_app_settings() + id_pol = int(app_settings.get("id_pol") or 0) or None + id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None + + if not id_pol: + _log("Politica de preț nu e configurată — skip sync") + await _finish_run(run_id, "error", log_lines, error="No price policy") + return + + # Fetch products from GoMag + _log("Descărcare produse din GoMag API...") + products = await gomag_client.download_products( + api_key=app_settings.get("gomag_api_key"), + api_shop=app_settings.get("gomag_api_shop"), + products_url=app_settings.get("gomag_products_url") or None, + log_fn=_log, + ) + + if not products: + _log("Niciun produs descărcat") + await _finish_run(run_id, "completed", log_lines, products_total=0) + return + + # Connect to Oracle + conn = await asyncio.to_thread(database.get_oracle_connection) + try: + # Get all mappings from ARTICOLE_TERTI + _log("Citire mapări ARTICOLE_TERTI...") + mapped_data = await asyncio.to_thread( + validation_service.resolve_mapped_codmats, + {p["sku"] for p in products}, conn + ) + + # Get direct articles from NOM_ARTICOLE + _log("Identificare articole directe...") + direct_id_map = {} + with conn.cursor() as cur: + all_skus = list({p["sku"] for p in products}) + for i in range(0, len(all_skus), 500): + batch = all_skus[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)} + cur.execute(f""" + SELECT codmat, id_articol, cont FROM nom_articole + WHERE codmat IN ({placeholders}) AND sters = 0 AND inactiv = 0 + """, params) + for row in cur: + if row[0] not in mapped_data: + direct_id_map[row[0]] = {"id_articol": row[1], "cont": row[2]} + + matched = 0 + updated = 0 + errors = 0 + + for product in products: + sku = product["sku"] + try: + price_str = product.get("price", "0") + price = float(price_str) if price_str else 0 + if price <= 0: + continue + + vat = float(product.get("vat", "19")) + vat_included = product.get("vat_included", "1") + + # Calculate price with TVA + if vat_included == "1": + price_cu_tva = price + else: + price_cu_tva = price * (1 + vat / 100) + + # Skip kits (>1 CODMAT) + if sku in mapped_data and len(mapped_data[sku]) > 1: + continue + + # Determine id_articol and policy + id_articol = None + cantitate_roa = 1 + + if sku in mapped_data and len(mapped_data[sku]) == 1: + comp = mapped_data[sku][0] + id_articol = comp["id_articol"] + cantitate_roa = comp.get("cantitate_roa") or 1 + elif sku in direct_id_map: + id_articol = direct_id_map[sku]["id_articol"] + else: + continue # SKU not in ROA + + matched += 1 + price_per_unit = price_cu_tva / cantitate_roa if cantitate_roa != 1 else price_cu_tva + + # Determine policy + cont = None + if sku in mapped_data and len(mapped_data[sku]) == 1: + cont = mapped_data[sku][0].get("cont") + elif sku in direct_id_map: + cont = direct_id_map[sku].get("cont") + + cont_str = str(cont or "").strip() + pol = id_pol_productie if (cont_str in ("341", "345") and id_pol_productie) else id_pol + + result = await asyncio.to_thread( + validation_service.compare_and_update_price, + id_articol, pol, price_per_unit, conn + ) + if result and result["updated"]: + updated += 1 + _log(f" {result['codmat']}: {result['old_price']:.2f} → {result['new_price']:.2f}") + + except Exception as e: + errors += 1 + _log(f"Eroare produs {sku}: {e}") + + _log(f"Sync complet: {len(products)} produse, {matched} potrivite, {updated} actualizate, {errors} erori") + + finally: + await asyncio.to_thread(database.pool.release, conn) + + await _finish_run(run_id, "completed", log_lines, + products_total=len(products), matched=matched, + updated=updated, errors=errors) + + except Exception as e: + _log(f"Eroare critică: {e}") + logger.error(f"Catalog price sync error: {e}", exc_info=True) + await _finish_run(run_id, "error", log_lines, error=str(e)) + + +async def _finish_run(run_id, status, log_lines, products_total=0, + matched=0, updated=0, errors=0, error=None): + global _current_price_sync + db = await sqlite_service.get_sqlite() + try: + await db.execute(""" + UPDATE price_sync_runs SET + finished_at = ?, status = ?, products_total = ?, + matched = ?, updated = ?, errors = ?, + log_text = ? + WHERE run_id = ? + """, (_now().isoformat(), status, products_total, matched, updated, errors, + "\n".join(log_lines), run_id)) + await db.commit() + finally: + await db.close() + _current_price_sync = None diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index a071c18..b11ef9c 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -4,6 +4,9 @@ from datetime import datetime from zoneinfo import ZoneInfo from ..database import get_sqlite, get_sqlite_sync +# Re-export so other services can import get_sqlite from sqlite_service +__all__ = ["get_sqlite", "get_sqlite_sync"] + _tz_bucharest = ZoneInfo("Europe/Bucharest") @@ -927,3 +930,22 @@ async def set_app_setting(key: str, value: str): await db.commit() finally: await db.close() + + +# ── Price Sync Runs ─────────────────────────────── + +async def get_price_sync_runs(page: int = 1, per_page: int = 20): + """Get paginated price sync run history.""" + db = await get_sqlite() + try: + offset = (page - 1) * per_page + cursor = await db.execute("SELECT COUNT(*) FROM price_sync_runs") + total = (await cursor.fetchone())[0] + cursor = await db.execute( + "SELECT * FROM price_sync_runs ORDER BY started_at DESC LIMIT ? OFFSET ?", + (per_page, offset) + ) + runs = [dict(r) for r in await cursor.fetchall()] + return {"runs": runs, "total": total, "page": page, "pages": (total + per_page - 1) // per_page} + finally: + await db.close() diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index 88d8dcb..1c385da 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -465,6 +465,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None if item.sku in validation["mapped"]: mapped_skus_in_orders.add(item.sku) + mapped_codmat_data = {} if mapped_skus_in_orders: mapped_codmat_data = await asyncio.to_thread( validation_service.resolve_mapped_codmats, mapped_skus_in_orders, conn @@ -501,6 +502,33 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None # Pass codmat_policy_map to import via app_settings if codmat_policy_map: app_settings["_codmat_policy_map"] = codmat_policy_map + + # ── Kit component price validation ── + kit_pricing_mode = app_settings.get("kit_pricing_mode") + if kit_pricing_mode and mapped_codmat_data: + id_pol_prod = int(app_settings.get("id_pol_productie") or 0) or None + kit_missing = await asyncio.to_thread( + validation_service.validate_kit_component_prices, + mapped_codmat_data, id_pol, id_pol_prod, conn + ) + if kit_missing: + kit_skus_missing = set(kit_missing.keys()) + for sku, missing_codmats in kit_missing.items(): + _log_line(run_id, f"Kit {sku}: prețuri lipsă pentru {', '.join(missing_codmats)}") + new_truly = [] + for order in truly_importable: + order_skus = {item.sku for item in order.items} + if order_skus & kit_skus_missing: + missing_list = list(order_skus & kit_skus_missing) + skipped.append((order, missing_list)) + else: + new_truly.append(order) + truly_importable = new_truly + + # Mode B config validation + if kit_pricing_mode == "separate_line": + if not app_settings.get("kit_discount_codmat"): + _log_line(run_id, "EROARE: Kit mode 'separate_line' dar kit_discount_codmat nu e configurat!") finally: await asyncio.to_thread(database.pool.release, conn) @@ -565,6 +593,28 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None }) _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})") await sqlite_service.save_orders_batch(skipped_batch) + + # ── Price sync from orders ── + if app_settings.get("price_sync_enabled") == "1": + try: + all_sync_orders = truly_importable + already_in_roa + direct_id_map = validation.get("direct_id_map", {}) + id_pol_prod = int(app_settings.get("id_pol_productie") or 0) or None + price_updates = await asyncio.to_thread( + validation_service.sync_prices_from_order, + all_sync_orders, mapped_codmat_data, + direct_id_map, codmat_policy_map, id_pol, + id_pol_productie=id_pol_prod, + settings=app_settings + ) + if price_updates: + _log_line(run_id, f"Sync prețuri: {len(price_updates)} prețuri actualizate") + for pu in price_updates: + _log_line(run_id, f" {pu['codmat']}: {pu['old_price']:.2f} → {pu['new_price']:.2f}") + except Exception as e: + _log_line(run_id, f"Eroare sync prețuri din comenzi: {e}") + logger.error(f"Price sync error: {e}") + _update_progress("skipped", f"Skipped {skipped_count}", 0, len(truly_importable), {"imported": 0, "skipped": skipped_count, "errors": 0, "already_imported": already_imported_count}) diff --git a/api/app/services/validation_service.py b/api/app/services/validation_service.py index 0b484da..f07a667 100644 --- a/api/app/services/validation_service.py +++ b/api/app/services/validation_service.py @@ -367,7 +367,7 @@ def validate_and_ensure_prices_dual(codmats: set[str], id_pol_vanzare: int, def resolve_mapped_codmats(mapped_skus: set[str], conn) -> dict[str, list[dict]]: """For mapped SKUs, get their underlying CODMATs from ARTICOLE_TERTI + nom_articole. - Returns: {sku: [{"codmat": str, "id_articol": int, "cont": str|None}]} + Returns: {sku: [{"codmat": str, "id_articol": int, "cont": str|None, "cantitate_roa": float|None}]} """ if not mapped_skus: return {} @@ -382,7 +382,7 @@ def resolve_mapped_codmats(mapped_skus: set[str], conn) -> dict[str, list[dict]] params = {f"s{j}": sku for j, sku in enumerate(batch)} cur.execute(f""" - SELECT at.sku, at.codmat, na.id_articol, na.cont + SELECT at.sku, at.codmat, na.id_articol, na.cont, at.cantitate_roa 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 @@ -394,8 +394,162 @@ def resolve_mapped_codmats(mapped_skus: set[str], conn) -> dict[str, list[dict]] result[sku].append({ "codmat": row[1], "id_articol": row[2], - "cont": row[3] + "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) <= 1: + continue # Not a kit + 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 diff --git a/api/app/static/js/mappings.js b/api/app/static/js/mappings.js index 5c8f49a..5a7c5d7 100644 --- a/api/app/static/js/mappings.js +++ b/api/app/static/js/mappings.js @@ -5,14 +5,14 @@ let searchTimeout = null; let sortColumn = 'sku'; let sortDirection = 'asc'; let editingMapping = null; // {sku, codmat} when editing -let pctFilter = 'all'; + +const kitPriceCache = new Map(); // Load on page ready document.addEventListener('DOMContentLoaded', () => { loadMappings(); initAddModal(); initDeleteModal(); - initPctFilterPills(); }); function debounceSearch() { @@ -48,44 +48,6 @@ function updateSortIcons() { }); } -// ── Pct Filter Pills ───────────────────────────── - -function initPctFilterPills() { - document.querySelectorAll('.filter-pill[data-pct]').forEach(btn => { - btn.addEventListener('click', function() { - document.querySelectorAll('.filter-pill[data-pct]').forEach(b => b.classList.remove('active')); - this.classList.add('active'); - pctFilter = this.dataset.pct; - currentPage = 1; - loadMappings(); - }); - }); -} - -function updatePctCounts(counts) { - if (!counts) return; - const elAll = document.getElementById('mCntAll'); - const elComplete = document.getElementById('mCntComplete'); - const elIncomplete = document.getElementById('mCntIncomplete'); - if (elAll) elAll.textContent = counts.total || 0; - if (elComplete) elComplete.textContent = counts.complete || 0; - if (elIncomplete) elIncomplete.textContent = counts.incomplete || 0; - - // Mobile segmented control - renderMobileSegmented('mappingsMobileSeg', [ - { label: 'Toate', count: counts.total || 0, value: 'all', active: pctFilter === 'all', colorClass: 'fc-neutral' }, - { label: 'Complete', count: counts.complete || 0, value: 'complete', active: pctFilter === 'complete', colorClass: 'fc-green' }, - { label: 'Incompl.', count: counts.incomplete || 0, value: 'incomplete', active: pctFilter === 'incomplete', colorClass: 'fc-yellow' } - ], (val) => { - document.querySelectorAll('.filter-pill[data-pct]').forEach(b => b.classList.remove('active')); - const pill = document.querySelector(`.filter-pill[data-pct="${val}"]`); - if (pill) pill.classList.add('active'); - pctFilter = val; - currentPage = 1; - loadMappings(); - }); -} - // ── Load & Render ──────────────────────────────── async function loadMappings() { @@ -99,7 +61,6 @@ async function loadMappings() { sort_dir: sortDirection }); if (showDeleted) params.set('show_deleted', 'true'); - if (pctFilter && pctFilter !== 'all') params.set('pct_filter', pctFilter); try { const res = await fetch(`/api/mappings?${params}`); @@ -113,7 +74,6 @@ async function loadMappings() { mappings = mappings.filter(m => m.activ || m.sters); } - updatePctCounts(data.counts); renderTable(mappings, showDeleted); renderPagination(data); updateSortIcons(); @@ -131,41 +91,52 @@ function renderTable(mappings, showDeleted) { return; } + // Count CODMATs per SKU for kit detection + const skuCodmatCount = {}; + mappings.forEach(m => { + skuCodmatCount[m.sku] = (skuCodmatCount[m.sku] || 0) + 1; + }); + let prevSku = null; let html = ''; - mappings.forEach(m => { + mappings.forEach((m, i) => { const isNewGroup = m.sku !== prevSku; if (isNewGroup) { - let pctBadge = ''; - if (m.pct_total !== undefined) { - pctBadge = m.is_complete - ? ` ✓ 100%` - : ` ${typeof m.pct_total === 'number' ? m.pct_total.toFixed(0) : m.pct_total}%`; - } + const isKit = (skuCodmatCount[m.sku] || 0) > 1; + const kitBadge = isKit + ? ` Kit · ${skuCodmatCount[m.sku]}` + : ''; const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : ''; html += `
- ${esc(m.sku)}${pctBadge} + ${esc(m.sku)}${kitBadge} ${esc(m.product_name || '')} ${m.sters ? `` - : `` + : `` }
`; } const deletedStyle = m.sters ? 'text-decoration:line-through;opacity:0.5;' : ''; + const isKitRow = (skuCodmatCount[m.sku] || 0) > 1; + const priceSlot = isKitRow ? `` : ''; html += `
${esc(m.codmat)} ${esc(m.denumire || '')} x${m.cantitate_roa} - · ${m.procent_pret}% + ${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>x${m.cantitate_roa}${priceSlot}
`; + + // After last CODMAT of a kit, add total row + const isLastOfKit = isKitRow && (i === mappings.length - 1 || mappings[i + 1].sku !== m.sku); + if (isLastOfKit) { + html += ``; + } + prevSku = m.sku; }); container.innerHTML = html; @@ -174,17 +145,76 @@ function renderTable(mappings, showDeleted) { container.querySelectorAll('.context-menu-trigger').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); - const { sku, codmat, cantitate, procent } = btn.dataset; + const { sku, codmat, cantitate } = btn.dataset; const rect = btn.getBoundingClientRect(); showContextMenu(rect.left, rect.bottom + 2, [ - { label: 'Editeaza', action: () => openEditModal(sku, codmat, parseFloat(cantitate), parseFloat(procent)) }, + { label: 'Editeaza', action: () => openEditModal(sku, codmat, parseFloat(cantitate)) }, { label: 'Sterge', action: () => deleteMappingConfirm(sku, codmat), danger: true } ]); }); }); + + // Load prices for visible kits + const loadedKits = new Set(); + container.querySelectorAll('.kit-price-loading').forEach(el => { + const sku = el.dataset.sku; + if (!loadedKits.has(sku)) { + loadedKits.add(sku); + loadKitPrices(sku, container); + } + }); } -// Inline edit for flat-row values (cantitate / procent) +async function loadKitPrices(sku, container) { + if (kitPriceCache.has(sku)) { + renderKitPrices(sku, kitPriceCache.get(sku), container); + return; + } + // Show loading spinner + const spinner = container.querySelector(`.kit-price-loading[data-sku="${CSS.escape(sku)}"]`); + if (spinner) spinner.style.display = ''; + + try { + const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/prices`); + const data = await res.json(); + if (data.error) { + if (spinner) spinner.innerHTML = `${esc(data.error)}`; + return; + } + kitPriceCache.set(sku, data.prices || []); + renderKitPrices(sku, data.prices || [], container); + } catch (err) { + if (spinner) spinner.innerHTML = `Eroare la încărcarea prețurilor`; + } +} + +function renderKitPrices(sku, prices, container) { + if (!prices || prices.length === 0) return; + // Update each codmat row with price info + const rows = container.querySelectorAll(`.kit-price-slot[data-sku="${CSS.escape(sku)}"]`); + let total = 0; + rows.forEach(slot => { + const codmat = slot.dataset.codmat; + const p = prices.find(pr => pr.codmat === codmat); + if (p && p.pret_cu_tva > 0) { + slot.innerHTML = `${p.pret_cu_tva.toFixed(2)} lei (${p.ptva}%)`; + total += p.pret_cu_tva * (p.cantitate_roa || 1); + } else if (p) { + slot.innerHTML = `fără preț`; + } + }); + // Show total + const totalSlot = container.querySelector(`.kit-total-slot[data-sku="${CSS.escape(sku)}"]`); + if (totalSlot && total > 0) { + totalSlot.innerHTML = `Total componente: ${total.toFixed(2)} lei`; + totalSlot.style.display = ''; + } + // Hide loading spinner + const spinner = container.querySelector(`.kit-price-loading[data-sku="${CSS.escape(sku)}"]`); + if (spinner) spinner.style.display = 'none'; +} + +// Inline edit for flat-row values (cantitate) function editFlatValue(span, sku, codmat, field, currentValue) { if (span.querySelector('input')) return; @@ -276,7 +306,7 @@ function clearAddForm() { addCodmatLine(); } -async function openEditModal(sku, codmat, cantitate, procent) { +async function openEditModal(sku, codmat, cantitate) { editingMapping = { sku, codmat }; document.getElementById('addModalTitle').textContent = 'Editare Mapare'; document.getElementById('inputSku').value = sku; @@ -308,7 +338,6 @@ async function openEditModal(sku, codmat, cantitate, procent) { if (line) { line.querySelector('.cl-codmat').value = codmat; line.querySelector('.cl-cantitate').value = cantitate; - line.querySelector('.cl-procent').value = procent; } } else { for (const m of allMappings) { @@ -320,7 +349,6 @@ async function openEditModal(sku, codmat, cantitate, procent) { line.querySelector('.cl-selected').textContent = m.denumire; } line.querySelector('.cl-cantitate').value = m.cantitate_roa; - line.querySelector('.cl-procent').value = m.procent_pret; } } } catch (e) { @@ -330,7 +358,6 @@ async function openEditModal(sku, codmat, cantitate, procent) { if (line) { line.querySelector('.cl-codmat').value = codmat; line.querySelector('.cl-cantitate').value = cantitate; - line.querySelector('.cl-procent').value = procent; } } @@ -352,9 +379,6 @@ function addCodmatLine() {
-
- -
${idx > 0 ? `` : '
'}
@@ -412,22 +436,12 @@ async function saveMapping() { for (const line of lines) { const codmat = line.querySelector('.cl-codmat').value.trim(); const cantitate = parseFloat(line.querySelector('.cl-cantitate').value) || 1; - const procent = parseFloat(line.querySelector('.cl-procent').value) || 100; if (!codmat) continue; - mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent }); + mappings.push({ codmat, cantitate_roa: cantitate }); } if (mappings.length === 0) { alert('Adauga cel putin un CODMAT'); return; } - // Validate percentage for multi-line - if (mappings.length > 1) { - const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0); - if (Math.abs(totalPct - 100) > 0.01) { - document.getElementById('pctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`; - document.getElementById('pctWarning').style.display = ''; - return; - } - } document.getElementById('pctWarning').style.display = 'none'; try { @@ -442,8 +456,7 @@ async function saveMapping() { body: JSON.stringify({ new_sku: sku, new_codmat: mappings[0].codmat, - cantitate_roa: mappings[0].cantitate_roa, - procent_pret: mappings[0].procent_pret + cantitate_roa: mappings[0].cantitate_roa }) }); } else { @@ -471,7 +484,7 @@ async function saveMapping() { res = await fetch('/api/mappings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret }) + body: JSON.stringify({ sku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa }) }); } else { res = await fetch('/api/mappings/batch', { @@ -523,7 +536,6 @@ function showInlineAddRow() { - `; @@ -571,7 +583,6 @@ async function saveInlineMapping() { const sku = document.getElementById('inlineSku').value.trim(); const codmat = document.getElementById('inlineCodmat').value.trim(); const cantitate = parseFloat(document.getElementById('inlineCantitate').value) || 1; - const procent = parseFloat(document.getElementById('inlineProcent').value) || 100; if (!sku) { alert('SKU este obligatoriu'); return; } if (!codmat) { alert('CODMAT este obligatoriu'); return; } @@ -580,7 +591,7 @@ async function saveInlineMapping() { const res = await fetch('/api/mappings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate, procent_pret: procent }) + body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate }) }); const data = await res.json(); if (data.success) { @@ -755,4 +766,3 @@ function handleMappingConflict(data) { if (warn) { warn.textContent = msg; warn.style.display = ''; } } } - diff --git a/api/app/static/js/settings.js b/api/app/static/js/settings.js index 7992f83..2bdd066 100644 --- a/api/app/static/js/settings.js +++ b/api/app/static/js/settings.js @@ -5,6 +5,21 @@ document.addEventListener('DOMContentLoaded', async () => { await loadSettings(); wireAutocomplete('settTransportCodmat', 'settTransportAc'); wireAutocomplete('settDiscountCodmat', 'settDiscountAc'); + wireAutocomplete('settKitDiscountCodmat', 'settKitDiscountAc'); + + // Kit pricing mode radio toggle + document.querySelectorAll('input[name="kitPricingMode"]').forEach(r => { + r.addEventListener('change', () => { + document.getElementById('kitModeBFields').style.display = + document.getElementById('kitModeSeparate').checked ? '' : 'none'; + }); + }); + + // Catalog sync toggle + const catChk = document.getElementById('settCatalogSyncEnabled'); + if (catChk) catChk.addEventListener('change', () => { + document.getElementById('catalogSyncOptions').style.display = catChk.checked ? '' : 'none'; + }); }); async function loadDropdowns() { @@ -66,6 +81,14 @@ async function loadDropdowns() { pPolEl.innerHTML += ``; }); } + + const kdPolEl = document.getElementById('settKitDiscountIdPol'); + if (kdPolEl) { + kdPolEl.innerHTML = ''; + politici.forEach(p => { + kdPolEl.innerHTML += ``; + }); + } } catch (err) { console.error('loadDropdowns error:', err); } @@ -100,6 +123,33 @@ async function loadSettings() { if (el('settGomagDaysBack')) el('settGomagDaysBack').value = data.gomag_order_days_back || '7'; if (el('settGomagLimit')) el('settGomagLimit').value = data.gomag_limit || '100'; if (el('settDashPollSeconds')) el('settDashPollSeconds').value = data.dashboard_poll_seconds || '5'; + + // Kit pricing + const kitMode = data.kit_pricing_mode || ''; + document.querySelectorAll('input[name="kitPricingMode"]').forEach(r => { + r.checked = r.value === kitMode; + }); + document.getElementById('kitModeBFields').style.display = kitMode === 'separate_line' ? '' : 'none'; + if (el('settKitDiscountCodmat')) el('settKitDiscountCodmat').value = data.kit_discount_codmat || ''; + if (el('settKitDiscountIdPol')) el('settKitDiscountIdPol').value = data.kit_discount_id_pol || ''; + + // Price sync + if (el('settPriceSyncEnabled')) el('settPriceSyncEnabled').checked = data.price_sync_enabled !== "0"; + if (el('settCatalogSyncEnabled')) { + el('settCatalogSyncEnabled').checked = data.catalog_sync_enabled === "1"; + document.getElementById('catalogSyncOptions').style.display = data.catalog_sync_enabled === "1" ? '' : 'none'; + } + if (el('settPriceSyncSchedule')) el('settPriceSyncSchedule').value = data.price_sync_schedule || ''; + + // Load price sync status + try { + const psRes = await fetch('/api/price-sync/status'); + const psData = await psRes.json(); + const psEl = document.getElementById('settPriceSyncStatus'); + if (psEl && psData.last_run) { + psEl.textContent = `Ultima: ${psData.last_run.finished_at || ''} — ${psData.last_run.updated || 0} actualizate din ${psData.last_run.matched || 0}`; + } + } catch {} } catch (err) { console.error('loadSettings error:', err); } @@ -124,6 +174,13 @@ async function saveSettings() { gomag_order_days_back: el('settGomagDaysBack')?.value?.trim() || '7', gomag_limit: el('settGomagLimit')?.value?.trim() || '100', dashboard_poll_seconds: el('settDashPollSeconds')?.value?.trim() || '5', + kit_pricing_mode: document.querySelector('input[name="kitPricingMode"]:checked')?.value || '', + kit_discount_codmat: el('settKitDiscountCodmat')?.value?.trim() || '', + kit_discount_id_pol: el('settKitDiscountIdPol')?.value?.trim() || '', + price_sync_enabled: el('settPriceSyncEnabled')?.checked ? "1" : "0", + catalog_sync_enabled: el('settCatalogSyncEnabled')?.checked ? "1" : "0", + price_sync_schedule: el('settPriceSyncSchedule')?.value || '', + gomag_products_url: '', }; try { const res = await fetch('/api/settings', { @@ -145,6 +202,40 @@ async function saveSettings() { } } +async function startCatalogSync() { + const btn = document.getElementById('btnCatalogSync'); + const status = document.getElementById('settPriceSyncStatus'); + btn.disabled = true; + btn.innerHTML = ' Sincronizare...'; + try { + const res = await fetch('/api/price-sync/start', { method: 'POST' }); + const data = await res.json(); + if (data.error) { + status.innerHTML = `${escHtml(data.error)}`; + btn.disabled = false; + btn.textContent = 'Sincronizează acum'; + return; + } + // Poll status + const pollInterval = setInterval(async () => { + const sr = await fetch('/api/price-sync/status'); + const sd = await sr.json(); + if (sd.status === 'running') { + status.textContent = sd.phase_text || 'Sincronizare în curs...'; + } else { + clearInterval(pollInterval); + btn.disabled = false; + btn.textContent = 'Sincronizează acum'; + if (sd.last_run) status.textContent = `Ultima: ${sd.last_run.finished_at || ''} — ${sd.last_run.updated || 0} actualizate din ${sd.last_run.matched || 0}`; + } + }, 2000); + } catch (err) { + status.innerHTML = `${escHtml(err.message)}`; + btn.disabled = false; + btn.textContent = 'Sincronizează acum'; + } +} + function wireAutocomplete(inputId, dropdownId) { const input = document.getElementById(inputId); const dropdown = document.getElementById(dropdownId); diff --git a/api/app/templates/mappings.html b/api/app/templates/mappings.html index 3d58c69..bc53c93 100644 --- a/api/app/templates/mappings.html +++ b/api/app/templates/mappings.html @@ -47,14 +47,6 @@ - -
- - - -
-
-
@@ -110,7 +102,7 @@ @@ -154,5 +146,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/api/app/templates/settings.html b/api/app/templates/settings.html index b3ff169..05f2620 100644 --- a/api/app/templates/settings.html +++ b/api/app/templates/settings.html @@ -157,6 +157,72 @@ +
+
+
Pricing Kituri / Pachete
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+
+
Sincronizare Prețuri
+
+
+ + +
+
+ + +
+ +
+ +
+
+
@@ -167,5 +233,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/api/database-scripts/04_pack_comenzi.pck b/api/database-scripts/04_pack_comenzi.pck index 7cae75d..46411a4 100644 --- a/api/database-scripts/04_pack_comenzi.pck +++ b/api/database-scripts/04_pack_comenzi.pck @@ -310,6 +310,9 @@ create or replace package body PACK_COMENZI is -- marius.mutu -- adauga_articol_comanda, modifica_articol_comanda + se poate completa ptva (21,11) in loc sa il ia din politica de preturi + -- 19.03.2026 + -- adauga_articol_comanda permite de 2 ori acelasi articol cu cote tva diferite (ex: discount 11% si discount 21%) + ---------------------------------------------------------------------------------- procedure adauga_masina(V_ID_MODEL_MASINA IN NUMBER, V_NRINMAT IN VARCHAR2, @@ -781,6 +784,7 @@ create or replace package body PACK_COMENZI is FROM COMENZI_ELEMENTE WHERE ID_COMANDA = V_ID_COMANDA AND ID_ARTICOL = V_ID_ARTICOL + AND NVL(PTVA,0) = NVL(V_PTVA,0) AND STERS = 0; IF V_NR_INREG > 0 THEN diff --git a/api/database-scripts/06_pack_import_comenzi.pck b/api/database-scripts/06_pack_import_comenzi.pck index bbd8016..41b4148 100644 --- a/api/database-scripts/06_pack_import_comenzi.pck +++ b/api/database-scripts/06_pack_import_comenzi.pck @@ -10,6 +10,8 @@ -- Tabele: ARTICOLE_TERTI (mapari SKU -> CODMAT) -- NOM_ARTICOLE (nomenclator articole ROA) -- COMENZI (verificare duplicat comanda_externa) +-- CRM_POLITICI_PRETURI (flag PRETURI_CU_TVA per politica) +-- CRM_POLITICI_PRET_ART (preturi componente kituri) -- -- Proceduri publice: -- @@ -25,9 +27,21 @@ -- La eroare ridica RAISE_APPLICATION_ERROR(-20001, mesaj). -- Returneaza v_id_comanda (OUT) = ID-ul comenzii create. -- +-- Parametri kit pricing: +-- p_kit_mode — 'distributed' | 'separate_line' | NULL +-- distributed: discountul fata de suma componentelor se distribuie +-- proportional in pretul fiecarei componente +-- separate_line: componentele se insereaza la pret plin + +-- linii discount separate grupate pe cota TVA +-- p_id_pol_productie — politica de pret pentru articole de productie +-- (cont_vanzare in 341/345); NULL = nu se foloseste +-- p_kit_discount_codmat — CODMAT-ul articolului discount (Mode separate_line) +-- p_kit_discount_id_pol — id_pol pentru liniile discount (Mode separate_line) +-- -- Logica cautare articol per SKU: -- 1. Mapari speciale din ARTICOLE_TERTI (reimpachetare, seturi compuse) --- - un SKU poate avea mai multe randuri (set) cu procent_pret +-- - daca SKU are >1 rand si p_kit_mode IS NOT NULL: kit pricing logic +-- - altfel (1 rand sau kit_mode NULL): pret web / cantitate_roa direct -- 2. Fallback: cautare directa in NOM_ARTICOLE dupa CODMAT = SKU -- -- get_last_error / clear_error @@ -57,11 +71,15 @@ CREATE OR REPLACE PACKAGE PACK_IMPORT_COMENZI AS p_data_comanda IN DATE, p_id_partener IN NUMBER, p_json_articole IN CLOB, - p_id_adresa_livrare IN NUMBER DEFAULT NULL, - p_id_adresa_facturare IN NUMBER DEFAULT NULL, - p_id_pol IN NUMBER DEFAULT NULL, - p_id_sectie IN NUMBER DEFAULT NULL, + p_id_adresa_livrare IN NUMBER DEFAULT NULL, + 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 VARCHAR2 DEFAULT NULL, + p_kit_mode IN VARCHAR2 DEFAULT NULL, + p_id_pol_productie IN NUMBER DEFAULT NULL, + p_kit_discount_codmat IN VARCHAR2 DEFAULT NULL, + p_kit_discount_id_pol IN NUMBER DEFAULT NULL, v_id_comanda OUT NUMBER); -- Functii pentru managementul erorilor (pentru orchestrator VFP) @@ -76,6 +94,18 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS c_id_util CONSTANT NUMBER := -3; -- Sistem c_interna CONSTANT NUMBER := 2; -- Comenzi de la client (web) + -- Tipuri pentru kit pricing (accesibile in toate procedurile din body) + TYPE t_kit_component IS RECORD ( + codmat VARCHAR2(50), + id_articol NUMBER, + cantitate_roa NUMBER, + pret_cu_tva NUMBER, + ptva NUMBER, + id_pol_comp NUMBER, + value_total NUMBER + ); + TYPE t_kit_components IS TABLE OF t_kit_component INDEX BY PLS_INTEGER; + -- ================================================================ -- Functii helper pentru managementul erorilor -- ================================================================ @@ -150,11 +180,15 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS p_data_comanda IN DATE, p_id_partener IN NUMBER, p_json_articole IN CLOB, - p_id_adresa_livrare IN NUMBER DEFAULT NULL, - p_id_adresa_facturare IN NUMBER DEFAULT NULL, - p_id_pol IN NUMBER DEFAULT NULL, - p_id_sectie IN NUMBER DEFAULT NULL, + p_id_adresa_livrare IN NUMBER DEFAULT NULL, + 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 VARCHAR2 DEFAULT NULL, + p_kit_mode IN VARCHAR2 DEFAULT NULL, + p_id_pol_productie IN NUMBER DEFAULT NULL, + p_kit_discount_codmat IN VARCHAR2 DEFAULT NULL, + p_kit_discount_id_pol IN NUMBER DEFAULT NULL, v_id_comanda OUT NUMBER) IS v_data_livrare DATE; v_sku VARCHAR2(100); @@ -173,6 +207,15 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS v_pret_unitar NUMBER; v_id_pol_articol NUMBER; -- id_pol per articol (din JSON), prioritar fata de p_id_pol + -- Variabile kit pricing + v_kit_count NUMBER := 0; + v_kit_comps t_kit_components; + v_sum_list_prices NUMBER; + v_discount_total NUMBER; + v_discount_share NUMBER; + v_pret_ajustat NUMBER; + v_discount_allocated NUMBER; + -- pljson l_json_articole CLOB := p_json_articole; v_json_arr pljson_list; @@ -256,65 +299,276 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS END; -- STEP 3: Gaseste articolele ROA pentru acest SKU - -- Cauta mai intai in ARTICOLE_TERTI (mapari speciale / seturi) v_found_mapping := FALSE; - FOR rec IN (SELECT at.codmat, at.cantitate_roa, at.procent_pret - FROM articole_terti at - WHERE at.sku = v_sku - AND at.activ = 1 - AND at.sters = 0 - ORDER BY at.procent_pret DESC) LOOP + -- Numara randurile ARTICOLE_TERTI pentru a detecta kituri (>1 rand = set compus) + SELECT COUNT(*) INTO v_kit_count + FROM articole_terti at + WHERE at.sku = v_sku + AND at.activ = 1 + AND at.sters = 0; + IF v_kit_count > 1 AND p_kit_mode IS NOT NULL THEN + -- ============================================================ + -- KIT PRICING: set compus cu >1 componente, mod activ + -- Prima trecere: colecteaza componente + preturi din politici + -- ============================================================ v_found_mapping := TRUE; - v_id_articol := resolve_id_articol(rec.codmat, p_id_gestiune); - IF v_id_articol IS NULL THEN - v_articole_eroare := v_articole_eroare + 1; - g_last_error := g_last_error || CHR(10) || - 'Articol activ negasit pentru CODMAT: ' || rec.codmat; - CONTINUE; - END IF; - - v_cantitate_roa := rec.cantitate_roa * v_cantitate_web; - v_pret_unitar := CASE WHEN v_pret_web IS NOT NULL - THEN (v_pret_web * rec.procent_pret / 100) / rec.cantitate_roa - ELSE 0 - END; + v_kit_comps.DELETE; + v_sum_list_prices := 0; + DECLARE + v_comp_idx PLS_INTEGER := 0; + v_cont_vanz VARCHAR2(20); + v_preturi_fl NUMBER; + v_pret_val NUMBER; + v_proc_tva NUMBER; BEGIN - PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda, - V_ID_ARTICOL => v_id_articol, - V_ID_POL => NVL(v_id_pol_articol, p_id_pol), - V_CANTITATE => v_cantitate_roa, - V_PRET => v_pret_unitar, - V_ID_UTIL => c_id_util, - V_ID_SECTIE => p_id_sectie, - V_PTVA => v_vat); - v_articole_procesate := v_articole_procesate + 1; - EXCEPTION - WHEN OTHERS THEN + FOR rec IN (SELECT at.codmat, at.cantitate_roa + FROM articole_terti at + WHERE at.sku = v_sku + AND at.activ = 1 + AND at.sters = 0 + ORDER BY at.codmat) LOOP + v_comp_idx := v_comp_idx + 1; + v_kit_comps(v_comp_idx).codmat := rec.codmat; + v_kit_comps(v_comp_idx).cantitate_roa := rec.cantitate_roa; + v_kit_comps(v_comp_idx).id_articol := + resolve_id_articol(rec.codmat, p_id_gestiune); + + IF v_kit_comps(v_comp_idx).id_articol IS NULL THEN + v_articole_eroare := v_articole_eroare + 1; + g_last_error := g_last_error || CHR(10) || + 'Articol activ negasit pentru CODMAT: ' || rec.codmat; + v_kit_comps(v_comp_idx).pret_cu_tva := 0; + v_kit_comps(v_comp_idx).ptva := ROUND(v_vat); + v_kit_comps(v_comp_idx).id_pol_comp := NVL(v_id_pol_articol, p_id_pol); + v_kit_comps(v_comp_idx).value_total := 0; + CONTINUE; + END IF; + + -- Determina id_pol_comp: cont 341/345 → politica productie, altfel vanzare + BEGIN + SELECT NVL(na.cont_vanzare, '') INTO v_cont_vanz + FROM nom_articole na + WHERE na.id_articol = v_kit_comps(v_comp_idx).id_articol + AND ROWNUM = 1; + EXCEPTION WHEN OTHERS THEN v_cont_vanz := ''; + END; + + IF v_cont_vanz IN ('341', '345') AND p_id_pol_productie IS NOT NULL THEN + v_kit_comps(v_comp_idx).id_pol_comp := p_id_pol_productie; + ELSE + v_kit_comps(v_comp_idx).id_pol_comp := NVL(v_id_pol_articol, p_id_pol); + END IF; + + -- Query flag PRETURI_CU_TVA pentru aceasta politica + BEGIN + SELECT NVL(pp.preturi_cu_tva, 0) INTO v_preturi_fl + FROM crm_politici_preturi pp + WHERE pp.id_pol = v_kit_comps(v_comp_idx).id_pol_comp; + EXCEPTION WHEN OTHERS THEN v_preturi_fl := 0; + END; + + -- Citeste PRET si PROC_TVAV din crm_politici_pret_art + BEGIN + SELECT ppa.pret, NVL(ppa.proc_tvav, 1) + INTO v_pret_val, v_proc_tva + FROM crm_politici_pret_art ppa + WHERE ppa.id_pol = v_kit_comps(v_comp_idx).id_pol_comp + AND ppa.id_articol = v_kit_comps(v_comp_idx).id_articol + AND ROWNUM = 1; + + -- V_PRET always WITH TVA + IF v_preturi_fl = 1 THEN + v_kit_comps(v_comp_idx).pret_cu_tva := v_pret_val; + ELSE + v_kit_comps(v_comp_idx).pret_cu_tva := v_pret_val * v_proc_tva; + END IF; + v_kit_comps(v_comp_idx).ptva := ROUND((v_proc_tva - 1) * 100); + EXCEPTION WHEN OTHERS THEN + v_kit_comps(v_comp_idx).pret_cu_tva := 0; + v_kit_comps(v_comp_idx).ptva := ROUND(v_vat); + END; + + v_kit_comps(v_comp_idx).value_total := + v_kit_comps(v_comp_idx).pret_cu_tva * v_kit_comps(v_comp_idx).cantitate_roa; + v_sum_list_prices := v_sum_list_prices + v_kit_comps(v_comp_idx).value_total; + END LOOP; + END; -- end prima trecere + + -- Discount = suma liste - pret web (poate fi negativ = markup) + v_discount_total := v_sum_list_prices - v_pret_web; + + -- ============================================================ + -- A doua trecere: inserare in functie de mod + -- ============================================================ + IF p_kit_mode = 'distributed' THEN + -- Mode A: distribui discountul proportional in pretul fiecarei componente + v_discount_allocated := 0; + FOR i_comp IN 1 .. v_kit_comps.COUNT LOOP + IF v_kit_comps(i_comp).id_articol IS NOT NULL THEN + -- Ultimul articol valid primeste remainder pentru precizie exacta + IF i_comp = v_kit_comps.LAST THEN + v_discount_share := v_discount_total - v_discount_allocated; + ELSE + IF v_sum_list_prices != 0 THEN + v_discount_share := v_discount_total * + (v_kit_comps(i_comp).value_total / v_sum_list_prices); + ELSE + v_discount_share := 0; + END IF; + v_discount_allocated := v_discount_allocated + v_discount_share; + END IF; + + -- pret_ajustat = pret_cu_tva - discount_share / cantitate_roa + v_pret_ajustat := v_kit_comps(i_comp).pret_cu_tva - + (v_discount_share / v_kit_comps(i_comp).cantitate_roa); + + BEGIN + PACK_COMENZI.adauga_articol_comanda( + V_ID_COMANDA => v_id_comanda, + V_ID_ARTICOL => v_kit_comps(i_comp).id_articol, + V_ID_POL => v_kit_comps(i_comp).id_pol_comp, + V_CANTITATE => v_kit_comps(i_comp).cantitate_roa * v_cantitate_web, + V_PRET => v_pret_ajustat, + V_ID_UTIL => c_id_util, + V_ID_SECTIE => p_id_sectie, + V_PTVA => v_kit_comps(i_comp).ptva); + v_articole_procesate := v_articole_procesate + 1; + EXCEPTION + WHEN OTHERS THEN + v_articole_eroare := v_articole_eroare + 1; + g_last_error := g_last_error || CHR(10) || + 'Eroare adaugare kit component (A) ' || + v_kit_comps(i_comp).codmat || ': ' || SQLERRM; + END; + END IF; + END LOOP; + + ELSIF p_kit_mode = 'separate_line' THEN + -- Mode B: componente la pret plin + linii discount separate pe cota TVA + DECLARE + TYPE t_vat_discount IS TABLE OF NUMBER INDEX BY PLS_INTEGER; + v_vat_disc t_vat_discount; + v_vat_key PLS_INTEGER; + v_disc_artid NUMBER; + v_vat_disc_alloc NUMBER; + v_disc_amt NUMBER; + BEGIN + -- Inserare componente la pret plin + acumulare discount pe cota TVA + FOR i_comp IN 1 .. v_kit_comps.COUNT LOOP + IF v_kit_comps(i_comp).id_articol IS NOT NULL THEN + BEGIN + PACK_COMENZI.adauga_articol_comanda( + V_ID_COMANDA => v_id_comanda, + V_ID_ARTICOL => v_kit_comps(i_comp).id_articol, + V_ID_POL => v_kit_comps(i_comp).id_pol_comp, + V_CANTITATE => v_kit_comps(i_comp).cantitate_roa * v_cantitate_web, + V_PRET => v_kit_comps(i_comp).pret_cu_tva, + V_ID_UTIL => c_id_util, + V_ID_SECTIE => p_id_sectie, + V_PTVA => v_kit_comps(i_comp).ptva); + v_articole_procesate := v_articole_procesate + 1; + EXCEPTION + WHEN OTHERS THEN + v_articole_eroare := v_articole_eroare + 1; + g_last_error := g_last_error || CHR(10) || + 'Eroare adaugare kit component (B) ' || + v_kit_comps(i_comp).codmat || ': ' || SQLERRM; + END; + + -- Acumuleaza discountul pe cota TVA (proportional cu valoarea componentei) + v_vat_key := v_kit_comps(i_comp).ptva; + IF v_sum_list_prices != 0 THEN + IF v_vat_disc.EXISTS(v_vat_key) THEN + v_vat_disc(v_vat_key) := v_vat_disc(v_vat_key) + + v_discount_total * (v_kit_comps(i_comp).value_total / v_sum_list_prices); + ELSE + v_vat_disc(v_vat_key) := + v_discount_total * (v_kit_comps(i_comp).value_total / v_sum_list_prices); + END IF; + ELSE + IF NOT v_vat_disc.EXISTS(v_vat_key) THEN + v_vat_disc(v_vat_key) := 0; + END IF; + END IF; + END IF; + END LOOP; + + -- Rezolva articolul discount si insereaza liniile de discount + v_disc_artid := resolve_id_articol(p_kit_discount_codmat, p_id_gestiune); + + IF v_disc_artid IS NOT NULL AND v_vat_disc.COUNT > 0 THEN + v_vat_disc_alloc := 0; + v_vat_key := v_vat_disc.FIRST; + WHILE v_vat_key IS NOT NULL LOOP + -- Ultima cota TVA primeste remainder pentru precizie exacta + IF v_vat_key = v_vat_disc.LAST THEN + v_disc_amt := v_discount_total - v_vat_disc_alloc; + ELSE + v_disc_amt := v_vat_disc(v_vat_key); + v_vat_disc_alloc := v_vat_disc_alloc + v_disc_amt; + END IF; + + IF v_disc_amt != 0 THEN + BEGIN + PACK_COMENZI.adauga_articol_comanda( + V_ID_COMANDA => v_id_comanda, + V_ID_ARTICOL => v_disc_artid, + V_ID_POL => NVL(p_kit_discount_id_pol, p_id_pol), + V_CANTITATE => -1 * v_cantitate_web, + V_PRET => v_disc_amt / v_cantitate_web, + V_ID_UTIL => c_id_util, + V_ID_SECTIE => p_id_sectie, + V_PTVA => v_vat_key); + v_articole_procesate := v_articole_procesate + 1; + EXCEPTION + WHEN OTHERS THEN + v_articole_eroare := v_articole_eroare + 1; + g_last_error := g_last_error || CHR(10) || + 'Eroare linie discount kit TVA=' || v_vat_key || '%: ' || SQLERRM; + END; + END IF; + + v_vat_key := v_vat_disc.NEXT(v_vat_key); + END LOOP; + END IF; + END; -- end mode B block + END IF; -- end kit mode branching + + ELSE + -- ============================================================ + -- MAPARE SIMPLA: 1 CODMAT, sau kit fara kit_mode activ + -- Pret = pret web / cantitate_roa (fara procent_pret) + -- ============================================================ + FOR rec IN (SELECT at.codmat, at.cantitate_roa + FROM articole_terti at + WHERE at.sku = v_sku + AND at.activ = 1 + AND at.sters = 0 + ORDER BY at.codmat) LOOP + + v_found_mapping := TRUE; + v_id_articol := resolve_id_articol(rec.codmat, p_id_gestiune); + IF v_id_articol IS NULL THEN v_articole_eroare := v_articole_eroare + 1; g_last_error := g_last_error || CHR(10) || - 'Eroare adaugare articol ' || rec.codmat || ': ' || SQLERRM; - END; - END LOOP; + 'Articol activ negasit pentru CODMAT: ' || rec.codmat; + CONTINUE; + END IF; - -- Daca nu s-a gasit mapare, cauta direct in NOM_ARTICOLE via resolve_id_articol - IF NOT v_found_mapping THEN - v_id_articol := resolve_id_articol(v_sku, p_id_gestiune); - IF v_id_articol IS NULL THEN - v_articole_eroare := v_articole_eroare + 1; - g_last_error := g_last_error || CHR(10) || - 'SKU negasit in ARTICOLE_TERTI si NOM_ARTICOLE (activ): ' || v_sku; - ELSE - v_codmat := v_sku; - v_pret_unitar := NVL(v_pret_web, 0); + v_cantitate_roa := rec.cantitate_roa * v_cantitate_web; + v_pret_unitar := CASE WHEN v_pret_web IS NOT NULL + THEN v_pret_web / rec.cantitate_roa + ELSE 0 + END; BEGIN PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda, V_ID_ARTICOL => v_id_articol, V_ID_POL => NVL(v_id_pol_articol, p_id_pol), - V_CANTITATE => v_cantitate_web, + V_CANTITATE => v_cantitate_roa, V_PRET => v_pret_unitar, V_ID_UTIL => c_id_util, V_ID_SECTIE => p_id_sectie, @@ -324,10 +578,41 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS WHEN OTHERS THEN v_articole_eroare := v_articole_eroare + 1; g_last_error := g_last_error || CHR(10) || - 'Eroare adaugare articol ' || v_sku || ' (CODMAT: ' || v_codmat || '): ' || SQLERRM; + 'Eroare adaugare articol ' || rec.codmat || ': ' || SQLERRM; END; + END LOOP; + + -- Daca nu s-a gasit mapare in ARTICOLE_TERTI, cauta direct in NOM_ARTICOLE + IF NOT v_found_mapping THEN + v_id_articol := resolve_id_articol(v_sku, p_id_gestiune); + IF v_id_articol IS NULL THEN + v_articole_eroare := v_articole_eroare + 1; + g_last_error := g_last_error || CHR(10) || + 'SKU negasit in ARTICOLE_TERTI si NOM_ARTICOLE (activ): ' || v_sku; + ELSE + v_codmat := v_sku; + v_pret_unitar := NVL(v_pret_web, 0); + + BEGIN + PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda, + V_ID_ARTICOL => v_id_articol, + V_ID_POL => NVL(v_id_pol_articol, p_id_pol), + V_CANTITATE => v_cantitate_web, + V_PRET => v_pret_unitar, + V_ID_UTIL => c_id_util, + V_ID_SECTIE => p_id_sectie, + V_PTVA => v_vat); + v_articole_procesate := v_articole_procesate + 1; + EXCEPTION + WHEN OTHERS THEN + v_articole_eroare := v_articole_eroare + 1; + g_last_error := g_last_error || CHR(10) || + 'Eroare adaugare articol ' || v_sku || + ' (CODMAT: ' || v_codmat || '): ' || SQLERRM; + END; + END IF; END IF; - END IF; + END IF; -- end kit vs simplu END; -- End BEGIN block pentru articol individual diff --git a/api/database-scripts/07_drop_procent_pret.sql b/api/database-scripts/07_drop_procent_pret.sql new file mode 100644 index 0000000..431c0bd --- /dev/null +++ b/api/database-scripts/07_drop_procent_pret.sql @@ -0,0 +1,3 @@ +-- Run AFTER deploying Python code changes and confirming new pricing works +-- Removes the deprecated procent_pret column from ARTICOLE_TERTI +ALTER TABLE ARTICOLE_TERTI DROP COLUMN procent_pret;