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