Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Agent
c1d8357956 gitignore 2026-03-18 15:11:09 +00:00
Claude Agent
695dafacd5 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) <noreply@anthropic.com>
2026-03-18 15:10:05 +00:00
12 changed files with 429 additions and 59 deletions

1
.gitignore vendored
View File

@@ -46,3 +46,4 @@ api/api/
# Logs directory # Logs directory
logs/ logs/
.gstack/

View File

@@ -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) - **Sync Orchestrator:** Python (`sync_service.py` — download → parse → validate → import)
- **Database:** Oracle PL/SQL packages (IMPORT_PARTENERI, IMPORT_COMENZI) + SQLite (tracking) - **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 ## Development Commands
```bash ```bash

View File

@@ -110,7 +110,8 @@ CREATE TABLE IF NOT EXISTS orders (
order_total REAL, order_total REAL,
delivery_cost REAL, delivery_cost REAL,
discount_total 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_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date); CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date);
@@ -318,6 +319,7 @@ def init_sqlite():
("delivery_cost", "REAL"), ("delivery_cost", "REAL"),
("discount_total", "REAL"), ("discount_total", "REAL"),
("web_status", "TEXT"), ("web_status", "TEXT"),
("discount_split", "TEXT"),
]: ]:
if col not in order_cols: if col not in order_cols:
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}") conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")

View File

@@ -32,8 +32,10 @@ class AppSettingsUpdate(BaseModel):
discount_vat: str = "21" discount_vat: str = "21"
discount_id_pol: str = "" discount_id_pol: str = ""
id_pol: str = "" id_pol: str = ""
id_pol_productie: str = ""
id_sectie: str = "" id_sectie: str = ""
id_gestiune: str = "" id_gestiune: str = ""
split_discount_vat: str = ""
gomag_api_key: str = "" gomag_api_key: str = ""
gomag_api_shop: str = "" gomag_api_shop: str = ""
gomag_order_days_back: str = "7" gomag_order_days_back: str = "7"
@@ -407,6 +409,13 @@ async def order_detail(order_number: str):
except Exception: except Exception:
pass 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 return detail
@@ -636,11 +645,13 @@ async def get_app_settings():
"transport_vat": s.get("transport_vat", "21"), "transport_vat": s.get("transport_vat", "21"),
"discount_codmat": s.get("discount_codmat", ""), "discount_codmat": s.get("discount_codmat", ""),
"transport_id_pol": s.get("transport_id_pol", ""), "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", ""), "discount_id_pol": s.get("discount_id_pol", ""),
"id_pol": s.get("id_pol", ""), "id_pol": s.get("id_pol", ""),
"id_pol_productie": s.get("id_pol_productie", ""),
"id_sectie": s.get("id_sectie", ""), "id_sectie": s.get("id_sectie", ""),
"id_gestiune": s.get("id_gestiune", ""), "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_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_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), "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_vat", config.discount_vat)
await sqlite_service.set_app_setting("discount_id_pol", config.discount_id_pol) 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", 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_sectie", config.id_sectie)
await sqlite_service.set_app_setting("id_gestiune", config.id_gestiune) 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_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_api_shop", config.gomag_api_shop)
await sqlite_service.set_app_setting("gomag_order_days_back", config.gomag_order_days_back) await sqlite_service.set_app_setting("gomag_order_days_back", config.gomag_order_days_back)

View File

@@ -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}" 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: def build_articles_json(items, order=None, settings=None) -> str:
"""Build JSON string for Oracle PACK_IMPORT_COMENZI.importa_comanda. """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 = [] 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: for item in items:
articles.append({ article_dict = {
"sku": item.sku, "sku": item.sku,
"quantity": str(item.quantity), "quantity": str(item.quantity),
"price": str(item.price), "price": str(item.price),
"vat": str(item.vat), "vat": str(item.vat),
"name": clean_web_text(item.name) "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: if order and settings:
transport_codmat = settings.get("transport_codmat", "") transport_codmat = settings.get("transport_codmat", "")
@@ -90,10 +153,40 @@ def build_articles_json(items, order=None, settings=None) -> str:
if settings.get("transport_id_pol"): if settings.get("transport_id_pol"):
article_dict["id_pol"] = settings["transport_id_pol"] article_dict["id_pol"] = settings["transport_id_pol"]
articles.append(article_dict) articles.append(article_dict)
# Discount total with quantity -1 (positive price)
# Discount — smart VAT splitting
if order.discount_total > 0 and discount_codmat: if order.discount_total > 0 and discount_codmat:
# Use GoMag JSON discount VAT if available, fallback to settings discount_split = compute_discount_split(order, settings)
discount_vat = getattr(order, 'discount_vat', None) or settings.get("discount_vat", "19")
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 = { article_dict = {
"sku": discount_codmat, "sku": discount_codmat,
"quantity": "-1", "quantity": "-1",

View File

@@ -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, payment_method: str = None, delivery_method: str = None,
order_total: float = None, order_total: float = None,
delivery_cost: float = None, discount_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.""" """Upsert a single order — one row per order_number, status updated in place."""
db = await get_sqlite() db = await get_sqlite()
try: 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, id_comanda, id_partener, error_message, missing_skus, items_count,
last_sync_run_id, shipping_name, billing_name, last_sync_run_id, shipping_name, billing_name,
payment_method, delivery_method, order_total, payment_method, delivery_method, order_total,
delivery_cost, discount_total, web_status) delivery_cost, discount_total, web_status, discount_split)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_number) DO UPDATE SET ON CONFLICT(order_number) DO UPDATE SET
customer_name = excluded.customer_name, customer_name = excluded.customer_name,
status = CASE 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), delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost),
discount_total = COALESCE(excluded.discount_total, orders.discount_total), discount_total = COALESCE(excluded.discount_total, orders.discount_total),
web_status = COALESCE(excluded.web_status, orders.web_status), web_status = COALESCE(excluded.web_status, orders.web_status),
discount_split = COALESCE(excluded.discount_split, orders.discount_split),
updated_at = datetime('now') updated_at = datetime('now')
""", (order_number, order_date, customer_name, status, """, (order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, id_comanda, id_partener, error_message,
json.dumps(missing_skus) if missing_skus else None, json.dumps(missing_skus) if missing_skus else None,
items_count, sync_run_id, shipping_name, billing_name, items_count, sync_run_id, shipping_name, billing_name,
payment_method, delivery_method, order_total, payment_method, delivery_method, order_total,
delivery_cost, discount_total, web_status)) delivery_cost, discount_total, web_status, discount_split))
await db.commit() await db.commit()
finally: finally:
await db.close() 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, id_comanda, id_partener, error_message, missing_skus, items_count,
last_sync_run_id, shipping_name, billing_name, last_sync_run_id, shipping_name, billing_name,
payment_method, delivery_method, order_total, payment_method, delivery_method, order_total,
delivery_cost, discount_total, web_status) delivery_cost, discount_total, web_status, discount_split)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_number) DO UPDATE SET ON CONFLICT(order_number) DO UPDATE SET
customer_name = excluded.customer_name, customer_name = excluded.customer_name,
status = CASE status = CASE
@@ -168,6 +169,7 @@ async def save_orders_batch(orders_data: list[dict]):
delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost), delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost),
discount_total = COALESCE(excluded.discount_total, orders.discount_total), discount_total = COALESCE(excluded.discount_total, orders.discount_total),
web_status = COALESCE(excluded.web_status, orders.web_status), web_status = COALESCE(excluded.web_status, orders.web_status),
discount_split = COALESCE(excluded.discount_split, orders.discount_split),
updated_at = datetime('now') updated_at = datetime('now')
""", [ """, [
(d["order_number"], d["order_date"], d["customer_name"], d["status"], (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("payment_method"), d.get("delivery_method"),
d.get("order_total"), d.get("order_total"),
d.get("delivery_cost"), d.get("discount_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 for d in orders_data
]) ])

View File

@@ -416,7 +416,27 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
pass pass
elif item.sku in validation["direct"]: elif item.sku in validation["direct"]:
all_codmats.add(item.sku) 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: if all_codmats:
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
)
_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( price_result = await asyncio.to_thread(
validation_service.validate_prices, all_codmats, id_pol, validation_service.validate_prices, all_codmats, id_pol,
conn, validation.get("direct_id_map") conn, validation.get("direct_id_map")
@@ -429,8 +449,53 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
await asyncio.to_thread( await asyncio.to_thread(
validation_service.ensure_prices, validation_service.ensure_prices,
price_result["missing_price"], id_pol, price_result["missing_price"], id_pol,
conn, validation.get("direct_id_map") 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: finally:
await asyncio.to_thread(database.pool.release, conn) 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 "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"]: if result["success"]:
imported_count += 1 imported_count += 1
await sqlite_service.upsert_order( 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, delivery_cost=order.delivery_cost or None,
discount_total=order.discount_total or None, discount_total=order.discount_total or None,
web_status=order.status 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") await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED")
# Store ROA address IDs (R9) # 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, delivery_cost=order.delivery_cost or None,
discount_total=order.discount_total or None, discount_total=order.discount_total or None,
web_status=order.status 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_sync_run_order(run_id, order.number, "ERROR")
await sqlite_service.add_order_items(order.number, order_items_data) await sqlite_service.add_order_items(order.number, order_items_data)

View File

@@ -28,10 +28,10 @@ def check_orders_in_roa(min_date, conn) -> dict:
return existing return existing
def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) -> dict[str, int]: def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) -> dict[str, dict]:
"""Resolve CODMATs to best id_articol: prefers article with stock, then MAX(id_articol). """Resolve CODMATs to best id_articol + cont: prefers article with stock, then MAX(id_articol).
Filters: sters=0 AND inactiv=0. Filters: sters=0 AND inactiv=0.
Returns: {codmat: id_articol} Returns: {codmat: {"id_articol": int, "cont": str|None}}
""" """
if not codmats: if not codmats:
return {} return {}
@@ -58,8 +58,8 @@ def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) ->
params["id_gestiune"] = id_gestiune params["id_gestiune"] = id_gestiune
cur.execute(f""" cur.execute(f"""
SELECT codmat, id_articol FROM ( SELECT codmat, id_articol, cont FROM (
SELECT na.codmat, na.id_articol, SELECT na.codmat, na.id_articol, na.cont,
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
PARTITION BY na.codmat PARTITION BY na.codmat
ORDER BY ORDER BY
@@ -79,7 +79,7 @@ def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) ->
) WHERE rn = 1 ) WHERE rn = 1
""", params) """, params)
for row in cur: for row in cur:
result[row[0]] = row[1] result[row[0]] = {"id_articol": row[1], "cont": row[2]}
finally: finally:
if own_conn: if own_conn:
database.pool.release(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: def validate_skus(skus: set[str], conn=None, id_gestiune: int = None) -> dict:
"""Validate a set of SKUs against Oracle. """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) - mapped: found in ARTICOLE_TERTI (active)
- direct: found in NOM_ARTICOLE by codmat (not in ARTICOLE_TERTI) - direct: found in NOM_ARTICOLE by codmat (not in ARTICOLE_TERTI)
- missing: not found anywhere - 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: if not skus:
return {"mapped": set(), "direct": set(), "missing": set(), "direct_id_map": {}} 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: else:
direct_id_map = {} direct_id_map = {}
direct = set() direct = set()
finally: finally:
if own_conn: if own_conn:
database.pool.release(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 missing = skus - mapped - direct
logger.info(f"SKU validation: {len(mapped)} mapped, {len(direct)} direct, {len(missing)} missing") 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): def classify_orders(orders, validation_result):
"""Classify orders as importable or skipped based on SKU validation. """Classify orders as importable or skipped based on SKU validation.
@@ -158,6 +160,19 @@ def classify_orders(orders, validation_result):
return importable, skipped 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: 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. """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. 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: if not codmats:
return {"has_price": set(), "missing_price": set()} 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() ids_with_price = set()
own_conn = conn is None 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") 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} 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. """Insert price 0 entries for CODMATs missing from the given price policy.
Uses batch executemany instead of individual INSERTs. Uses batch executemany instead of individual INSERTs.
Relies on TRG_CRM_POLITICI_PRET_ART trigger for ID_POL_ART sequence. 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: if not codmats:
return return
proc_tvav = 1 + (cota_tva / 100) if cota_tva else 1.21
own_conn = conn is None own_conn = conn is None
if own_conn: if own_conn:
conn = database.get_oracle_connection() 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) # Build batch params using direct_id_map (already resolved via resolve_codmat_ids)
batch_params = [] 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: for codmat in codmats:
id_articol = codmat_id_map.get(codmat) 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({ batch_params.append({
"id_pol": id_pol, "id_pol": id_pol,
"id_articol": id_articol, "id_articol": id_articol,
"id_valuta": id_valuta "id_valuta": id_valuta,
"proc_tvav": proc_tvav
}) })
if batch_params: 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) ID_UTIL, DATAORA, PROC_TVAV, PRETFTVA, PRETCTVA)
VALUES VALUES
(:id_pol, :id_articol, 0, :id_valuta, (:id_pol, :id_articol, 0, :id_valuta,
-3, SYSDATE, 1.19, 0, 0) -3, SYSDATE, :proc_tvav, 0, 0)
""", batch_params) """, 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() conn.commit()
finally: finally:
@@ -254,3 +274,125 @@ def ensure_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict
database.pool.release(conn) database.pool.release(conn)
logger.info(f"Ensure prices done: {len(codmats)} CODMATs processed for policy {id_pol}") 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

View File

@@ -578,7 +578,19 @@ async function openDashOrderDetail(orderNumber) {
if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : ''; if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : '';
const dscEl = document.getElementById('detailDiscount'); 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('<br>');
} 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 || []; const items = data.items || [];
if (items.length === 0) { if (items.length === 0) {

View File

@@ -57,6 +57,14 @@ async function loadDropdowns() {
dPolEl.innerHTML += `<option value="${escHtml(p.id)}">${escHtml(p.label)}</option>`; dPolEl.innerHTML += `<option value="${escHtml(p.id)}">${escHtml(p.label)}</option>`;
}); });
} }
const pPolEl = document.getElementById('settIdPolProductie');
if (pPolEl) {
pPolEl.innerHTML = '<option value="">— fără politică producție —</option>';
politici.forEach(p => {
pPolEl.innerHTML += `<option value="${escHtml(p.id)}">${escHtml(p.label)}</option>`;
});
}
} catch (err) { } catch (err) {
console.error('loadDropdowns error:', 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('settTransportVat')) el('settTransportVat').value = data.transport_vat || '21';
if (el('settTransportIdPol')) el('settTransportIdPol').value = data.transport_id_pol || ''; if (el('settTransportIdPol')) el('settTransportIdPol').value = data.transport_id_pol || '';
if (el('settDiscountCodmat')) el('settDiscountCodmat').value = data.discount_codmat || ''; 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('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('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('settIdSectie')) el('settIdSectie').value = data.id_sectie || '';
if (el('settIdGestiune')) el('settIdGestiune').value = data.id_gestiune || ''; if (el('settIdGestiune')) el('settIdGestiune').value = data.id_gestiune || '';
if (el('settGomagApiKey')) el('settGomagApiKey').value = data.gomag_api_key || ''; if (el('settGomagApiKey')) el('settGomagApiKey').value = data.gomag_api_key || '';
@@ -93,9 +103,11 @@ async function saveSettings() {
transport_vat: el('settTransportVat')?.value || '21', transport_vat: el('settTransportVat')?.value || '21',
transport_id_pol: el('settTransportIdPol')?.value?.trim() || '', transport_id_pol: el('settTransportIdPol')?.value?.trim() || '',
discount_codmat: el('settDiscountCodmat')?.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() || '', discount_id_pol: el('settDiscountIdPol')?.value?.trim() || '',
split_discount_vat: el('settSplitDiscountVat')?.checked ? "1" : "",
id_pol: el('settIdPol')?.value?.trim() || '', id_pol: el('settIdPol')?.value?.trim() || '',
id_pol_productie: el('settIdPolProductie')?.value?.trim() || '',
id_sectie: el('settIdSectie')?.value?.trim() || '', id_sectie: el('settIdSectie')?.value?.trim() || '',
id_gestiune: el('settIdGestiune')?.value?.trim() || '', id_gestiune: el('settIdGestiune')?.value?.trim() || '',
gomag_api_key: el('settGomagApiKey')?.value?.trim() || '', gomag_api_key: el('settGomagApiKey')?.value?.trim() || '',

View File

@@ -204,5 +204,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=16"></script> <script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=17"></script>
{% endblock %} {% endblock %}

View File

@@ -51,11 +51,18 @@
</select> </select>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label mb-0 small">Politică de Preț (ID_POL)</label> <label class="form-label mb-0 small">Politică Preț Vânzare (ID_POL)</label>
<select class="form-select form-select-sm" id="settIdPol"> <select class="form-select form-select-sm" id="settIdPol">
<option value="">— selectează politică —</option> <option value="">— selectează politică —</option>
</select> </select>
</div> </div>
<div class="mb-2">
<label class="form-label mb-0 small">Politică Preț Producție</label>
<select class="form-select form-select-sm" id="settIdPolProductie">
<option value="">— fără politică producție —</option>
</select>
<div class="form-text" style="font-size:0.75rem">Pentru articole cu cont 341/345 (producție proprie)</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -113,8 +120,9 @@
<select class="form-select form-select-sm" id="settDiscountVat"> <select class="form-select form-select-sm" id="settDiscountVat">
<option value="5">5%</option> <option value="5">5%</option>
<option value="9">9%</option> <option value="9">9%</option>
<option value="19" selected>19%</option> <option value="11">11%</option>
<option value="21">21%</option> <option value="19">19%</option>
<option value="21" selected>21%</option>
</select> </select>
</div> </div>
<div class="col-6"> <div class="col-6">
@@ -124,6 +132,12 @@
</select> </select>
</div> </div>
</div> </div>
<div class="mt-2 form-check">
<input type="checkbox" class="form-check-input" id="settSplitDiscountVat">
<label class="form-check-label small" for="settSplitDiscountVat">
Împarte discount pe cote TVA (proporțional cu valoarea articolelor)
</label>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -152,5 +166,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=4"></script> <script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=5"></script>
{% endblock %} {% endblock %}