From 695dafacd5c21a1b95b7cbc07504e9ce929029e7 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 18 Mar 2026 15:10:05 +0000 Subject: [PATCH] feat: dual pricing policies + discount VAT splitting Add production pricing policy (id_pol_productie) for articles with cont 341/345, smart discount VAT splitting across multiple rates, per-article id_pol support, and mapped SKU price validation. Settings UI updated with new controls. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 8 ++ api/app/database.py | 4 +- api/app/routers/sync.py | 15 ++- api/app/services/import_service.py | 125 +++++++++++++++--- api/app/services/sqlite_service.py | 16 ++- api/app/services/sync_service.py | 93 +++++++++++-- api/app/services/validation_service.py | 172 ++++++++++++++++++++++--- api/app/static/js/dashboard.js | 14 +- api/app/static/js/settings.js | 16 ++- api/app/templates/dashboard.html | 2 +- api/app/templates/settings.html | 22 +++- 11 files changed, 428 insertions(+), 59 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2e6e753..39892c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,14 @@ Importa automat comenzi din GoMag in sistemul ERP ROA Oracle. Stack complet Pyth - **Sync Orchestrator:** Python (`sync_service.py` — download → parse → validate → import) - **Database:** Oracle PL/SQL packages (IMPORT_PARTENERI, IMPORT_COMENZI) + SQLite (tracking) +## GStack Workflow (pentru features noi) + +1. `/plan-ceo-review` — planning interactiv (alegi modul: expansion / hold scope / reducere) +2. Implementare cu TeamCreate (ca de obicei) +3. `/review` — code review pe diff înainte de ship +4. `/ship` — push + unit tests + crează PR automat +5. `/qa` — testează aplicația live în browser real (după ce rulează) + ## Development Commands ```bash diff --git a/api/app/database.py b/api/app/database.py index 89b42ea..f711c3b 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -110,7 +110,8 @@ CREATE TABLE IF NOT EXISTS orders ( order_total REAL, delivery_cost REAL, discount_total REAL, - web_status TEXT + web_status TEXT, + discount_split TEXT ); CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date); @@ -318,6 +319,7 @@ def init_sqlite(): ("delivery_cost", "REAL"), ("discount_total", "REAL"), ("web_status", "TEXT"), + ("discount_split", "TEXT"), ]: if col not in order_cols: conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}") diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py index dd5aeec..45338a8 100644 --- a/api/app/routers/sync.py +++ b/api/app/routers/sync.py @@ -32,8 +32,10 @@ class AppSettingsUpdate(BaseModel): discount_vat: str = "21" discount_id_pol: str = "" id_pol: str = "" + id_pol_productie: str = "" id_sectie: str = "" id_gestiune: str = "" + split_discount_vat: str = "" gomag_api_key: str = "" gomag_api_shop: str = "" gomag_order_days_back: str = "7" @@ -407,6 +409,13 @@ async def order_detail(order_number: str): except Exception: pass + # Parse discount_split JSON string + if order.get("discount_split"): + try: + order["discount_split"] = json.loads(order["discount_split"]) + except (json.JSONDecodeError, TypeError): + pass + return detail @@ -636,11 +645,13 @@ async def get_app_settings(): "transport_vat": s.get("transport_vat", "21"), "discount_codmat": s.get("discount_codmat", ""), "transport_id_pol": s.get("transport_id_pol", ""), - "discount_vat": s.get("discount_vat", "19"), + "discount_vat": s.get("discount_vat", "21"), "discount_id_pol": s.get("discount_id_pol", ""), "id_pol": s.get("id_pol", ""), + "id_pol_productie": s.get("id_pol_productie", ""), "id_sectie": s.get("id_sectie", ""), "id_gestiune": s.get("id_gestiune", ""), + "split_discount_vat": s.get("split_discount_vat", ""), "gomag_api_key": s.get("gomag_api_key", "") or config_settings.GOMAG_API_KEY, "gomag_api_shop": s.get("gomag_api_shop", "") or config_settings.GOMAG_API_SHOP, "gomag_order_days_back": s.get("gomag_order_days_back", "") or str(config_settings.GOMAG_ORDER_DAYS_BACK), @@ -659,8 +670,10 @@ async def update_app_settings(config: AppSettingsUpdate): await sqlite_service.set_app_setting("discount_vat", config.discount_vat) await sqlite_service.set_app_setting("discount_id_pol", config.discount_id_pol) await sqlite_service.set_app_setting("id_pol", config.id_pol) + await sqlite_service.set_app_setting("id_pol_productie", config.id_pol_productie) await sqlite_service.set_app_setting("id_sectie", config.id_sectie) await sqlite_service.set_app_setting("id_gestiune", config.id_gestiune) + await sqlite_service.set_app_setting("split_discount_vat", config.split_discount_vat) await sqlite_service.set_app_setting("gomag_api_key", config.gomag_api_key) await sqlite_service.set_app_setting("gomag_api_shop", config.gomag_api_shop) await sqlite_service.set_app_setting("gomag_order_days_back", config.gomag_order_days_back) diff --git a/api/app/services/import_service.py b/api/app/services/import_service.py index 272897b..2000f48 100644 --- a/api/app/services/import_service.py +++ b/api/app/services/import_service.py @@ -60,18 +60,81 @@ def format_address_for_oracle(address: str, city: str, region: str) -> str: return f"JUD:{region_clean};{city_clean};{address_clean}" +def compute_discount_split(order, settings: dict) -> dict | None: + """Compute proportional discount split by VAT rate from order items. + + Returns: {"11": 3.98, "21": 1.43} or None if split not applicable. + Only splits when split_discount_vat is enabled AND multiple VAT rates exist. + When single VAT rate: returns {actual_rate: total} (smarter than GoMag's fixed 21%). + """ + if not order or order.discount_total <= 0: + return None + + split_enabled = settings.get("split_discount_vat") == "1" + + # Calculate VAT distribution from order items (exclude zero-value) + vat_totals = {} + for item in order.items: + item_value = abs(item.price * item.quantity) + if item_value > 0: + vat_key = str(int(item.vat)) if item.vat == int(item.vat) else str(item.vat) + vat_totals[vat_key] = vat_totals.get(vat_key, 0) + item_value + + if not vat_totals: + return None + + grand_total = sum(vat_totals.values()) + if grand_total <= 0: + return None + + if len(vat_totals) == 1: + # Single VAT rate — use that rate (smarter than GoMag's fixed 21%) + actual_vat = list(vat_totals.keys())[0] + return {actual_vat: round(order.discount_total, 2)} + + if not split_enabled: + return None + + # Multiple VAT rates — split proportionally + result = {} + discount_remaining = order.discount_total + sorted_rates = sorted(vat_totals.keys(), key=lambda x: float(x)) + + for i, vat_rate in enumerate(sorted_rates): + if i == len(sorted_rates) - 1: + split_amount = round(discount_remaining, 2) # last gets remainder + else: + proportion = vat_totals[vat_rate] / grand_total + split_amount = round(order.discount_total * proportion, 2) + discount_remaining -= split_amount + + if split_amount > 0: + result[vat_rate] = split_amount + + return result if result else None + + def build_articles_json(items, order=None, settings=None) -> str: """Build JSON string for Oracle PACK_IMPORT_COMENZI.importa_comanda. - Includes transport and discount as extra articles if configured.""" + Includes transport and discount as extra articles if configured. + Supports per-article id_pol from codmat_policy_map and discount VAT splitting.""" articles = [] + codmat_policy_map = settings.get("_codmat_policy_map", {}) if settings else {} + default_id_pol = settings.get("id_pol", "") if settings else "" + for item in items: - articles.append({ + article_dict = { "sku": item.sku, "quantity": str(item.quantity), "price": str(item.price), "vat": str(item.vat), "name": clean_web_text(item.name) - }) + } + # Per-article id_pol from dual-policy validation + item_pol = codmat_policy_map.get(item.sku) + if item_pol and str(item_pol) != str(default_id_pol): + article_dict["id_pol"] = str(item_pol) + articles.append(article_dict) if order and settings: transport_codmat = settings.get("transport_codmat", "") @@ -90,20 +153,50 @@ def build_articles_json(items, order=None, settings=None) -> str: if settings.get("transport_id_pol"): article_dict["id_pol"] = settings["transport_id_pol"] articles.append(article_dict) - # Discount total with quantity -1 (positive price) + + # Discount — smart VAT splitting if order.discount_total > 0 and discount_codmat: - # Use GoMag JSON discount VAT if available, fallback to settings - discount_vat = getattr(order, 'discount_vat', None) or settings.get("discount_vat", "19") - article_dict = { - "sku": discount_codmat, - "quantity": "-1", - "price": str(order.discount_total), - "vat": discount_vat, - "name": "Discount" - } - if settings.get("discount_id_pol"): - article_dict["id_pol"] = settings["discount_id_pol"] - articles.append(article_dict) + discount_split = compute_discount_split(order, settings) + + if discount_split and len(discount_split) > 1: + # Multiple VAT rates — multiple discount lines + for vat_rate, split_amount in sorted(discount_split.items(), key=lambda x: float(x[0])): + article_dict = { + "sku": discount_codmat, + "quantity": "-1", + "price": str(split_amount), + "vat": vat_rate, + "name": f"Discount (TVA {vat_rate}%)" + } + if settings.get("discount_id_pol"): + article_dict["id_pol"] = settings["discount_id_pol"] + articles.append(article_dict) + elif discount_split and len(discount_split) == 1: + # Single VAT rate — use detected rate + actual_vat = list(discount_split.keys())[0] + article_dict = { + "sku": discount_codmat, + "quantity": "-1", + "price": str(order.discount_total), + "vat": actual_vat, + "name": "Discount" + } + if settings.get("discount_id_pol"): + article_dict["id_pol"] = settings["discount_id_pol"] + articles.append(article_dict) + else: + # Fallback — original behavior with GoMag VAT or settings default + discount_vat = getattr(order, 'discount_vat', None) or settings.get("discount_vat", "21") + article_dict = { + "sku": discount_codmat, + "quantity": "-1", + "price": str(order.discount_total), + "vat": discount_vat, + "name": "Discount" + } + if settings.get("discount_id_pol"): + article_dict["id_pol"] = settings["discount_id_pol"] + articles.append(article_dict) return json.dumps(articles) diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index 727d5c8..a071c18 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -61,7 +61,7 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str, payment_method: str = None, delivery_method: str = None, order_total: float = None, delivery_cost: float = None, discount_total: float = None, - web_status: str = None): + web_status: str = None, discount_split: str = None): """Upsert a single order — one row per order_number, status updated in place.""" db = await get_sqlite() try: @@ -71,8 +71,8 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str, id_comanda, id_partener, error_message, missing_skus, items_count, last_sync_run_id, shipping_name, billing_name, payment_method, delivery_method, order_total, - delivery_cost, discount_total, web_status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + delivery_cost, discount_total, web_status, discount_split) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(order_number) DO UPDATE SET customer_name = excluded.customer_name, status = CASE @@ -97,13 +97,14 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str, delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost), discount_total = COALESCE(excluded.discount_total, orders.discount_total), web_status = COALESCE(excluded.web_status, orders.web_status), + discount_split = COALESCE(excluded.discount_split, orders.discount_split), updated_at = datetime('now') """, (order_number, order_date, customer_name, status, id_comanda, id_partener, error_message, json.dumps(missing_skus) if missing_skus else None, items_count, sync_run_id, shipping_name, billing_name, payment_method, delivery_method, order_total, - delivery_cost, discount_total, web_status)) + delivery_cost, discount_total, web_status, discount_split)) await db.commit() finally: await db.close() @@ -142,8 +143,8 @@ async def save_orders_batch(orders_data: list[dict]): id_comanda, id_partener, error_message, missing_skus, items_count, last_sync_run_id, shipping_name, billing_name, payment_method, delivery_method, order_total, - delivery_cost, discount_total, web_status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + delivery_cost, discount_total, web_status, discount_split) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(order_number) DO UPDATE SET customer_name = excluded.customer_name, status = CASE @@ -168,6 +169,7 @@ async def save_orders_batch(orders_data: list[dict]): delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost), discount_total = COALESCE(excluded.discount_total, orders.discount_total), web_status = COALESCE(excluded.web_status, orders.web_status), + discount_split = COALESCE(excluded.discount_split, orders.discount_split), updated_at = datetime('now') """, [ (d["order_number"], d["order_date"], d["customer_name"], d["status"], @@ -178,7 +180,7 @@ async def save_orders_batch(orders_data: list[dict]): d.get("payment_method"), d.get("delivery_method"), d.get("order_total"), d.get("delivery_cost"), d.get("discount_total"), - d.get("web_status")) + d.get("web_status"), d.get("discount_split")) for d in orders_data ]) diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index e111bf6..2e13a31 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -416,21 +416,86 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None pass elif item.sku in validation["direct"]: all_codmats.add(item.sku) + # Get standard VAT rate from settings for PROC_TVAV metadata + cota_tva = float(app_settings.get("discount_vat") or 21) + + # Dual pricing policy support + id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None + codmat_policy_map = {} + if all_codmats: - price_result = await asyncio.to_thread( - validation_service.validate_prices, all_codmats, id_pol, - conn, validation.get("direct_id_map") - ) - if price_result["missing_price"]: - logger.info( - f"Auto-adding price 0 for {len(price_result['missing_price'])} " - f"direct articles in policy {id_pol}" + if id_pol_productie: + # Dual-policy: classify articles by cont (sales vs production) + codmat_policy_map = await asyncio.to_thread( + validation_service.validate_and_ensure_prices_dual, + all_codmats, id_pol, id_pol_productie, + conn, validation.get("direct_id_map"), + cota_tva=cota_tva ) - await asyncio.to_thread( - validation_service.ensure_prices, - price_result["missing_price"], id_pol, + _log_line(run_id, + f"Politici duale: {sum(1 for v in codmat_policy_map.values() if v == id_pol)} vanzare, " + f"{sum(1 for v in codmat_policy_map.values() if v == id_pol_productie)} productie") + else: + # Single-policy (backward compatible) + price_result = await asyncio.to_thread( + validation_service.validate_prices, all_codmats, id_pol, conn, validation.get("direct_id_map") ) + if price_result["missing_price"]: + logger.info( + f"Auto-adding price 0 for {len(price_result['missing_price'])} " + f"direct articles in policy {id_pol}" + ) + await asyncio.to_thread( + validation_service.ensure_prices, + price_result["missing_price"], id_pol, + conn, validation.get("direct_id_map"), + cota_tva=cota_tva + ) + + # Also validate mapped SKU prices (cherry-pick 1) + mapped_skus_in_orders = set() + for order in (truly_importable + already_in_roa): + for item in order.items: + if item.sku in validation["mapped"]: + mapped_skus_in_orders.add(item.sku) + + if mapped_skus_in_orders: + mapped_codmat_data = await asyncio.to_thread( + validation_service.resolve_mapped_codmats, mapped_skus_in_orders, conn + ) + # Build id_map for mapped codmats and validate/ensure their prices + mapped_id_map = {} + for sku, entries in mapped_codmat_data.items(): + for entry in entries: + mapped_id_map[entry["codmat"]] = { + "id_articol": entry["id_articol"], + "cont": entry.get("cont") + } + mapped_codmats = set(mapped_id_map.keys()) + if mapped_codmats: + if id_pol_productie: + mapped_policy_map = await asyncio.to_thread( + validation_service.validate_and_ensure_prices_dual, + mapped_codmats, id_pol, id_pol_productie, + conn, mapped_id_map, cota_tva=cota_tva + ) + codmat_policy_map.update(mapped_policy_map) + else: + mp_result = await asyncio.to_thread( + validation_service.validate_prices, + mapped_codmats, id_pol, conn, mapped_id_map + ) + if mp_result["missing_price"]: + await asyncio.to_thread( + validation_service.ensure_prices, + mp_result["missing_price"], id_pol, + conn, mapped_id_map, cota_tva=cota_tva + ) + + # Pass codmat_policy_map to import via app_settings + if codmat_policy_map: + app_settings["_codmat_policy_map"] = codmat_policy_map finally: await asyncio.to_thread(database.pool.release, conn) @@ -529,6 +594,10 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None "cantitate_roa": None }) + # Compute discount split for SQLite storage + ds = import_service.compute_discount_split(order, app_settings) + discount_split_json = json.dumps(ds) if ds else None + if result["success"]: imported_count += 1 await sqlite_service.upsert_order( @@ -548,6 +617,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None delivery_cost=order.delivery_cost or None, discount_total=order.discount_total or None, web_status=order.status or None, + discount_split=discount_split_json, ) await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED") # Store ROA address IDs (R9) @@ -577,6 +647,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None delivery_cost=order.delivery_cost or None, discount_total=order.discount_total or None, web_status=order.status or None, + discount_split=discount_split_json, ) await sqlite_service.add_sync_run_order(run_id, order.number, "ERROR") await sqlite_service.add_order_items(order.number, order_items_data) diff --git a/api/app/services/validation_service.py b/api/app/services/validation_service.py index 15a6016..8361484 100644 --- a/api/app/services/validation_service.py +++ b/api/app/services/validation_service.py @@ -28,10 +28,10 @@ def check_orders_in_roa(min_date, conn) -> dict: return existing -def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) -> dict[str, int]: - """Resolve CODMATs to best id_articol: prefers article with stock, then MAX(id_articol). +def resolve_codmat_ids(codmats: set[str], id_gestiune: 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. - Returns: {codmat: id_articol} + Returns: {codmat: {"id_articol": int, "cont": str|None}} """ if not codmats: return {} @@ -58,8 +58,8 @@ def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) -> params["id_gestiune"] = id_gestiune cur.execute(f""" - SELECT codmat, id_articol FROM ( - SELECT na.codmat, na.id_articol, + SELECT codmat, id_articol, cont FROM ( + SELECT na.codmat, na.id_articol, na.cont, ROW_NUMBER() OVER ( PARTITION BY na.codmat ORDER BY @@ -79,7 +79,7 @@ def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) -> ) WHERE rn = 1 """, params) for row in cur: - result[row[0]] = row[1] + result[row[0]] = {"id_articol": row[1], "cont": row[2]} finally: if own_conn: database.pool.release(conn) @@ -90,11 +90,11 @@ def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) -> def validate_skus(skus: set[str], conn=None, id_gestiune: int = None) -> dict: """Validate a set of SKUs against Oracle. - Returns: {mapped: set, direct: set, missing: set, direct_id_map: {codmat: id_articol}} + 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} for direct SKUs (saves a round-trip in validate_prices) + - 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": {}} @@ -129,6 +129,7 @@ def validate_skus(skus: set[str], conn=None, id_gestiune: int = None) -> dict: else: direct_id_map = {} direct = set() + finally: if own_conn: database.pool.release(conn) @@ -136,7 +137,8 @@ def validate_skus(skus: set[str], conn=None, id_gestiune: int = None) -> dict: 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} + 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. @@ -158,6 +160,19 @@ def classify_orders(orders, validation_result): 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. @@ -166,7 +181,7 @@ def validate_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: di if not codmats: return {"has_price": set(), "missing_price": set()} - codmat_to_id = dict(direct_id_map) if direct_id_map else {} + codmat_to_id = _extract_id_map(direct_id_map) ids_with_price = set() own_conn = conn is None @@ -199,14 +214,18 @@ def validate_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: di 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): +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() @@ -224,7 +243,7 @@ def ensure_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict # Build batch params using direct_id_map (already resolved via resolve_codmat_ids) batch_params = [] - codmat_id_map = dict(direct_id_map) if direct_id_map else {} + codmat_id_map = _extract_id_map(direct_id_map) for codmat in codmats: id_articol = codmat_id_map.get(codmat) @@ -234,7 +253,8 @@ def ensure_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict batch_params.append({ "id_pol": id_pol, "id_articol": id_articol, - "id_valuta": id_valuta + "id_valuta": id_valuta, + "proc_tvav": proc_tvav }) if batch_params: @@ -244,9 +264,9 @@ def ensure_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict ID_UTIL, DATAORA, PROC_TVAV, PRETFTVA, PRETCTVA) VALUES (:id_pol, :id_articol, 0, :id_valuta, - -3, SYSDATE, 1.19, 0, 0) + -3, SYSDATE, :proc_tvav, 0, 0) """, batch_params) - logger.info(f"Batch inserted {len(batch_params)} price entries for policy {id_pol}") + logger.info(f"Batch inserted {len(batch_params)} price entries for policy {id_pol} (PROC_TVAV={proc_tvav})") conn.commit() finally: @@ -254,3 +274,125 @@ def ensure_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict 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) -> 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}]} + """ + if not mapped_skus: + return {} + + 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)} + + cur.execute(f""" + SELECT at.sku, at.codmat, na.id_articol, na.cont + 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 + """, 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] + }) + + logger.info(f"resolve_mapped_codmats: {len(result)} SKUs → {sum(len(v) for v in result.values())} CODMATs") + return result diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js index 8748a29..4079085 100644 --- a/api/app/static/js/dashboard.js +++ b/api/app/static/js/dashboard.js @@ -578,7 +578,19 @@ async function openDashOrderDetail(orderNumber) { if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : '–'; const dscEl = document.getElementById('detailDiscount'); - if (dscEl) dscEl.textContent = order.discount_total > 0 ? '–' + Number(order.discount_total).toFixed(2) + ' lei' : '–'; + if (dscEl) { + if (order.discount_total > 0 && order.discount_split && typeof order.discount_split === 'object') { + const entries = Object.entries(order.discount_split); + if (entries.length > 1) { + const parts = entries.map(([vat, amt]) => `–${Number(amt).toFixed(2)} (TVA ${vat}%)`); + dscEl.innerHTML = parts.join('
'); + } else { + dscEl.textContent = '–' + Number(order.discount_total).toFixed(2) + ' lei'; + } + } else { + dscEl.textContent = order.discount_total > 0 ? '–' + Number(order.discount_total).toFixed(2) + ' lei' : '–'; + } + } const items = data.items || []; if (items.length === 0) { diff --git a/api/app/static/js/settings.js b/api/app/static/js/settings.js index c57c190..6531fae 100644 --- a/api/app/static/js/settings.js +++ b/api/app/static/js/settings.js @@ -57,6 +57,14 @@ async function loadDropdowns() { dPolEl.innerHTML += ``; }); } + + const pPolEl = document.getElementById('settIdPolProductie'); + if (pPolEl) { + pPolEl.innerHTML = ''; + politici.forEach(p => { + pPolEl.innerHTML += ``; + }); + } } catch (err) { console.error('loadDropdowns error:', err); } @@ -71,9 +79,11 @@ async function loadSettings() { if (el('settTransportVat')) el('settTransportVat').value = data.transport_vat || '21'; if (el('settTransportIdPol')) el('settTransportIdPol').value = data.transport_id_pol || ''; if (el('settDiscountCodmat')) el('settDiscountCodmat').value = data.discount_codmat || ''; - if (el('settDiscountVat')) el('settDiscountVat').value = data.discount_vat || '19'; + if (el('settDiscountVat')) el('settDiscountVat').value = data.discount_vat || '21'; if (el('settDiscountIdPol')) el('settDiscountIdPol').value = data.discount_id_pol || ''; + if (el('settSplitDiscountVat')) el('settSplitDiscountVat').checked = data.split_discount_vat === "1"; if (el('settIdPol')) el('settIdPol').value = data.id_pol || ''; + if (el('settIdPolProductie')) el('settIdPolProductie').value = data.id_pol_productie || ''; if (el('settIdSectie')) el('settIdSectie').value = data.id_sectie || ''; if (el('settIdGestiune')) el('settIdGestiune').value = data.id_gestiune || ''; if (el('settGomagApiKey')) el('settGomagApiKey').value = data.gomag_api_key || ''; @@ -93,9 +103,11 @@ async function saveSettings() { transport_vat: el('settTransportVat')?.value || '21', transport_id_pol: el('settTransportIdPol')?.value?.trim() || '', discount_codmat: el('settDiscountCodmat')?.value?.trim() || '', - discount_vat: el('settDiscountVat')?.value || '19', + discount_vat: el('settDiscountVat')?.value || '21', discount_id_pol: el('settDiscountIdPol')?.value?.trim() || '', + split_discount_vat: el('settSplitDiscountVat')?.checked ? "1" : "", id_pol: el('settIdPol')?.value?.trim() || '', + id_pol_productie: el('settIdPolProductie')?.value?.trim() || '', id_sectie: el('settIdSectie')?.value?.trim() || '', id_gestiune: el('settIdGestiune')?.value?.trim() || '', gomag_api_key: el('settGomagApiKey')?.value?.trim() || '', diff --git a/api/app/templates/dashboard.html b/api/app/templates/dashboard.html index 476cfc8..ec1ac01 100644 --- a/api/app/templates/dashboard.html +++ b/api/app/templates/dashboard.html @@ -204,5 +204,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/api/app/templates/settings.html b/api/app/templates/settings.html index ee03890..dacbeef 100644 --- a/api/app/templates/settings.html +++ b/api/app/templates/settings.html @@ -51,11 +51,18 @@
- +
+
+ + +
Pentru articole cu cont 341/345 (producție proprie)
+
@@ -113,8 +120,9 @@
@@ -124,6 +132,12 @@
+
+ + +
@@ -152,5 +166,5 @@ {% endblock %} {% block scripts %} - + {% endblock %}