Compare commits
2 Commits
69a3088579
...
c1d8357956
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1d8357956 | ||
|
|
695dafacd5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,3 +46,4 @@ api/api/
|
||||
|
||||
# Logs directory
|
||||
logs/
|
||||
.gstack/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('<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 || [];
|
||||
if (items.length === 0) {
|
||||
|
||||
@@ -57,6 +57,14 @@ async function loadDropdowns() {
|
||||
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) {
|
||||
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() || '',
|
||||
|
||||
@@ -204,5 +204,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
@@ -51,11 +51,18 @@
|
||||
</select>
|
||||
</div>
|
||||
<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">
|
||||
<option value="">— selectează politică —</option>
|
||||
</select>
|
||||
</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>
|
||||
@@ -113,8 +120,9 @@
|
||||
<select class="form-select form-select-sm" id="settDiscountVat">
|
||||
<option value="5">5%</option>
|
||||
<option value="9">9%</option>
|
||||
<option value="19" selected>19%</option>
|
||||
<option value="21">21%</option>
|
||||
<option value="11">11%</option>
|
||||
<option value="19">19%</option>
|
||||
<option value="21" selected>21%</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
@@ -124,6 +132,12 @@
|
||||
</select>
|
||||
</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>
|
||||
@@ -152,5 +166,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
Reference in New Issue
Block a user