feat(pricing): kit/pachet pricing with price list lookup, replace procent_pret

- Oracle PL/SQL: kit pricing logic with Mode A (distributed discount) and
  Mode B (separate discount line), dual policy support, PRETURI_CU_TVA flag
- Eliminate procent_pret from entire stack (Oracle, Python, JS, HTML)
- New settings: kit_pricing_mode, kit_discount_codmat, price_sync_enabled
- Settings UI: cards for Kit Pricing and Price Sync configuration
- Mappings UI: kit badges with lazy-loaded component prices from price list
- Price sync from orders: auto-update ROA prices when web prices differ
- Catalog price sync: new service to sync all GoMag product prices to ROA
- Kit component price validation: pre-check prices before import
- New endpoint GET /api/mappings/{sku}/prices for component price display
- New endpoints POST /api/price-sync/start, GET status, GET history
- DDL script 07_drop_procent_pret.sql (run after deploy confirmation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-03-19 22:29:18 +00:00
parent bedb93affe
commit 9e5901a8fb
17 changed files with 1313 additions and 268 deletions

View File

@@ -152,6 +152,18 @@ CREATE TABLE IF NOT EXISTS app_settings (
value TEXT
);
CREATE TABLE IF NOT EXISTS price_sync_runs (
run_id TEXT PRIMARY KEY,
started_at TEXT,
finished_at TEXT,
status TEXT DEFAULT 'running',
products_total INTEGER DEFAULT 0,
matched INTEGER DEFAULT 0,
updated INTEGER DEFAULT 0,
errors INTEGER DEFAULT 0,
log_text TEXT
);
CREATE TABLE IF NOT EXISTS order_items (
order_number TEXT,
sku TEXT,

View File

@@ -6,6 +6,7 @@ from pydantic import BaseModel, validator
from pathlib import Path
from typing import Optional
import io
import asyncio
from ..services import mapping_service, sqlite_service
@@ -19,7 +20,6 @@ class MappingCreate(BaseModel):
sku: str
codmat: str
cantitate_roa: float = 1
procent_pret: float = 100
@validator('sku', 'codmat')
def not_empty(cls, v):
@@ -29,14 +29,12 @@ class MappingCreate(BaseModel):
class MappingUpdate(BaseModel):
cantitate_roa: Optional[float] = None
procent_pret: Optional[float] = None
activ: Optional[int] = None
class MappingEdit(BaseModel):
new_sku: str
new_codmat: str
cantitate_roa: float = 1
procent_pret: float = 100
@validator('new_sku', 'new_codmat')
def not_empty(cls, v):
@@ -47,7 +45,6 @@ class MappingEdit(BaseModel):
class MappingLine(BaseModel):
codmat: str
cantitate_roa: float = 1
procent_pret: float = 100
class MappingBatchCreate(BaseModel):
sku: str
@@ -63,11 +60,10 @@ async def mappings_page(request: Request):
@router.get("/api/mappings")
async def list_mappings(search: str = "", page: int = 1, per_page: int = 50,
sort_by: str = "sku", sort_dir: str = "asc",
show_deleted: bool = False, pct_filter: str = None):
show_deleted: bool = False):
result = mapping_service.get_mappings(search=search, page=page, per_page=per_page,
sort_by=sort_by, sort_dir=sort_dir,
show_deleted=show_deleted,
pct_filter=pct_filter)
show_deleted=show_deleted)
# Merge product names from web_products (R4)
skus = list({m["sku"] for m in result.get("mappings", [])})
product_names = await sqlite_service.get_web_products_batch(skus)
@@ -75,13 +71,13 @@ async def list_mappings(search: str = "", page: int = 1, per_page: int = 50,
m["product_name"] = product_names.get(m["sku"], "")
# Ensure counts key is always present
if "counts" not in result:
result["counts"] = {"total": 0, "complete": 0, "incomplete": 0}
result["counts"] = {"total": 0}
return result
@router.post("/api/mappings")
async def create_mapping(data: MappingCreate):
try:
result = mapping_service.create_mapping(data.sku, data.codmat, data.cantitate_roa, data.procent_pret)
result = mapping_service.create_mapping(data.sku, data.codmat, data.cantitate_roa)
# Mark SKU as resolved in missing_skus tracking
await sqlite_service.resolve_missing_sku(data.sku)
return {"success": True, **result}
@@ -97,7 +93,7 @@ async def create_mapping(data: MappingCreate):
@router.put("/api/mappings/{sku}/{codmat}")
def update_mapping(sku: str, codmat: str, data: MappingUpdate):
try:
updated = mapping_service.update_mapping(sku, codmat, data.cantitate_roa, data.procent_pret, data.activ)
updated = mapping_service.update_mapping(sku, codmat, data.cantitate_roa, data.activ)
return {"success": updated}
except Exception as e:
return {"success": False, "error": str(e)}
@@ -106,7 +102,7 @@ def update_mapping(sku: str, codmat: str, data: MappingUpdate):
def edit_mapping(sku: str, codmat: str, data: MappingEdit):
try:
result = mapping_service.edit_mapping(sku, codmat, data.new_sku, data.new_codmat,
data.cantitate_roa, data.procent_pret)
data.cantitate_roa)
return {"success": result}
except Exception as e:
return {"success": False, "error": str(e)}
@@ -133,16 +129,10 @@ async def create_batch_mapping(data: MappingBatchCreate):
if not data.mappings:
return {"success": False, "error": "No mappings provided"}
# Validate procent_pret sums to 100 for multi-line sets
if len(data.mappings) > 1:
total_pct = sum(m.procent_pret for m in data.mappings)
if abs(total_pct - 100) > 0.01:
return {"success": False, "error": f"Procent pret trebuie sa fie 100% (actual: {total_pct}%)"}
try:
results = []
for m in data.mappings:
r = mapping_service.create_mapping(data.sku, m.codmat, m.cantitate_roa, m.procent_pret, auto_restore=data.auto_restore)
r = mapping_service.create_mapping(data.sku, m.codmat, m.cantitate_roa, auto_restore=data.auto_restore)
results.append(r)
# Mark SKU as resolved in missing_skus tracking
await sqlite_service.resolve_missing_sku(data.sku)
@@ -151,6 +141,23 @@ async def create_batch_mapping(data: MappingBatchCreate):
return {"success": False, "error": str(e)}
@router.get("/api/mappings/{sku}/prices")
async def get_mapping_prices(sku: str):
"""Get component prices from crm_politici_pret_art for a kit SKU."""
app_settings = await sqlite_service.get_app_settings()
id_pol = int(app_settings.get("id_pol") or 0) or None
id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None
if not id_pol:
return {"error": "Politica de pret nu este configurata", "prices": []}
try:
prices = await asyncio.to_thread(
mapping_service.get_component_prices, sku, id_pol, id_pol_productie
)
return {"prices": prices}
except Exception as e:
return {"error": str(e), "prices": []}
@router.post("/api/mappings/import-csv")
async def import_csv(file: UploadFile = File(...)):
content = await file.read()

View File

@@ -41,6 +41,13 @@ class AppSettingsUpdate(BaseModel):
gomag_order_days_back: str = "7"
gomag_limit: str = "100"
dashboard_poll_seconds: str = "5"
kit_pricing_mode: str = ""
kit_discount_codmat: str = ""
kit_discount_id_pol: str = ""
price_sync_enabled: str = "1"
catalog_sync_enabled: str = "0"
price_sync_schedule: str = ""
gomag_products_url: str = ""
# API endpoints
@@ -139,6 +146,31 @@ async def sync_history(page: int = 1, per_page: int = 20):
return await sqlite_service.get_sync_runs(page, per_page)
@router.post("/api/price-sync/start")
async def start_price_sync(background_tasks: BackgroundTasks):
"""Trigger manual catalog price sync."""
from ..services import price_sync_service
result = await price_sync_service.prepare_price_sync()
if result.get("error"):
return {"error": result["error"]}
run_id = result["run_id"]
background_tasks.add_task(price_sync_service.run_catalog_price_sync, run_id=run_id)
return {"message": "Price sync started", "run_id": run_id}
@router.get("/api/price-sync/status")
async def price_sync_status():
"""Get current price sync status."""
from ..services import price_sync_service
return await price_sync_service.get_price_sync_status()
@router.get("/api/price-sync/history")
async def price_sync_history(page: int = 1, per_page: int = 20):
"""Get price sync run history."""
return await sqlite_service.get_price_sync_runs(page, per_page)
@router.get("/logs", response_class=HTMLResponse)
async def logs_page(request: Request, run: str = None):
return templates.TemplateResponse("logs.html", {"request": request, "selected_run": run or ""})
@@ -285,7 +317,7 @@ async def sync_run_orders(run_id: str, status: str = "all", page: int = 1, per_p
def _get_articole_terti_for_skus(skus: set) -> dict:
"""Query ARTICOLE_TERTI for all active codmat/cantitate/procent per SKU."""
"""Query ARTICOLE_TERTI for all active codmat/cantitate per SKU."""
from .. import database
result = {}
sku_list = list(skus)
@@ -297,7 +329,7 @@ def _get_articole_terti_for_skus(skus: set) -> dict:
placeholders = ",".join([f":s{j}" for j in range(len(batch))])
params = {f"s{j}": sku for j, sku in enumerate(batch)}
cur.execute(f"""
SELECT at.sku, at.codmat, at.cantitate_roa, at.procent_pret,
SELECT at.sku, at.codmat, at.cantitate_roa,
na.denumire
FROM ARTICOLE_TERTI at
LEFT JOIN NOM_ARTICOLE na ON na.codmat = at.codmat AND na.sters = 0 AND na.inactiv = 0
@@ -311,8 +343,7 @@ def _get_articole_terti_for_skus(skus: set) -> dict:
result[sku].append({
"codmat": row[1],
"cantitate_roa": float(row[2]) if row[2] else 1,
"procent_pret": float(row[3]) if row[3] else 100,
"denumire": row[4] or ""
"denumire": row[3] or ""
})
finally:
database.pool.release(conn)
@@ -371,7 +402,6 @@ async def order_detail(order_number: str):
item["codmat_details"] = [{
"codmat": sku,
"cantitate_roa": 1,
"procent_pret": 100,
"denumire": nom_map[sku],
"direct": True
}]
@@ -663,6 +693,13 @@ async def get_app_settings():
"gomag_order_days_back": s.get("gomag_order_days_back", "") or str(config_settings.GOMAG_ORDER_DAYS_BACK),
"gomag_limit": s.get("gomag_limit", "") or str(config_settings.GOMAG_LIMIT),
"dashboard_poll_seconds": s.get("dashboard_poll_seconds", "5"),
"kit_pricing_mode": s.get("kit_pricing_mode", ""),
"kit_discount_codmat": s.get("kit_discount_codmat", ""),
"kit_discount_id_pol": s.get("kit_discount_id_pol", ""),
"price_sync_enabled": s.get("price_sync_enabled", "1"),
"catalog_sync_enabled": s.get("catalog_sync_enabled", "0"),
"price_sync_schedule": s.get("price_sync_schedule", ""),
"gomag_products_url": s.get("gomag_products_url", ""),
}
@@ -685,6 +722,13 @@ async def update_app_settings(config: AppSettingsUpdate):
await sqlite_service.set_app_setting("gomag_order_days_back", config.gomag_order_days_back)
await sqlite_service.set_app_setting("gomag_limit", config.gomag_limit)
await sqlite_service.set_app_setting("dashboard_poll_seconds", config.dashboard_poll_seconds)
await sqlite_service.set_app_setting("kit_pricing_mode", config.kit_pricing_mode)
await sqlite_service.set_app_setting("kit_discount_codmat", config.kit_discount_codmat)
await sqlite_service.set_app_setting("kit_discount_id_pol", config.kit_discount_id_pol)
await sqlite_service.set_app_setting("price_sync_enabled", config.price_sync_enabled)
await sqlite_service.set_app_setting("catalog_sync_enabled", config.catalog_sync_enabled)
await sqlite_service.set_app_setting("price_sync_schedule", config.price_sync_schedule)
await sqlite_service.set_app_setting("gomag_products_url", config.gomag_products_url)
return {"success": True}

View File

@@ -101,3 +101,77 @@ async def download_orders(
await asyncio.sleep(1)
return {"pages": total_pages, "total": total_orders, "files": saved_files}
async def download_products(
api_key: str = None,
api_shop: str = None,
products_url: str = None,
log_fn: Callable[[str], None] = None,
) -> list[dict]:
"""Download all products from GoMag Products API.
Returns list of product dicts with: sku, price, vat, vat_included, bundleItems.
"""
def _log(msg: str):
logger.info(msg)
if log_fn:
log_fn(msg)
effective_key = api_key or settings.GOMAG_API_KEY
effective_shop = api_shop or settings.GOMAG_API_SHOP
default_url = "https://api.gomag.ro/api/v1/product/read/json"
effective_url = products_url or default_url
if not effective_key or not effective_shop:
_log("GoMag API keys neconfigurați, skip product download")
return []
headers = {
"Apikey": effective_key,
"ApiShop": effective_shop,
"User-Agent": "Mozilla/5.0",
"Content-Type": "application/json",
}
all_products = []
total_pages = 1
async with httpx.AsyncClient(timeout=30) as client:
page = 1
while page <= total_pages:
params = {"page": page, "limit": 100}
try:
response = await client.get(effective_url, headers=headers, params=params)
response.raise_for_status()
data = response.json()
except httpx.HTTPError as e:
_log(f"GoMag Products API eroare pagina {page}: {e}")
break
except Exception as e:
_log(f"GoMag Products eroare neașteptată pagina {page}: {e}")
break
if page == 1:
total_pages = int(data.get("pages", 1))
_log(f"GoMag Products: {data.get('total', '?')} produse în {total_pages} pagini")
products = data.get("products", [])
if isinstance(products, dict):
products = [products]
if isinstance(products, list):
for p in products:
if isinstance(p, dict) and p.get("sku"):
all_products.append({
"sku": p["sku"],
"price": p.get("price", "0"),
"vat": p.get("vat", "19"),
"vat_included": p.get("vat_included", "1"),
"bundleItems": p.get("bundleItems", []),
})
page += 1
if page <= total_pages:
await asyncio.sleep(1)
_log(f"GoMag Products: {len(all_products)} produse cu SKU descărcate")
return all_products

View File

@@ -342,6 +342,12 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
# Convert list[int] to CSV string for Oracle VARCHAR2 param
id_gestiune_csv = ",".join(str(g) for g in id_gestiuni) if id_gestiuni else None
# Kit pricing parameters from settings
kit_mode = (app_settings or {}).get("kit_pricing_mode") or None
kit_id_pol_prod = int((app_settings or {}).get("id_pol_productie") or 0) or None
kit_discount_codmat = (app_settings or {}).get("kit_discount_codmat") or None
kit_discount_id_pol = int((app_settings or {}).get("kit_discount_id_pol") or 0) or None
cur.callproc("PACK_IMPORT_COMENZI.importa_comanda", [
order_number, # p_nr_comanda_ext
order_date, # p_data_comanda
@@ -352,7 +358,11 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
id_pol, # p_id_pol
id_sectie, # p_id_sectie
id_gestiune_csv, # p_id_gestiune (CSV string)
id_comanda # v_id_comanda (OUT)
kit_mode, # p_kit_mode
kit_id_pol_prod, # p_id_pol_productie
kit_discount_codmat, # p_kit_discount_codmat
kit_discount_id_pol, # p_kit_discount_id_pol
id_comanda # v_id_comanda (OUT) — MUST STAY LAST
])
comanda_id = id_comanda.getvalue()

View File

@@ -9,14 +9,8 @@ logger = logging.getLogger(__name__)
def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
sort_by: str = "sku", sort_dir: str = "asc",
show_deleted: bool = False, pct_filter: str = None):
"""Get paginated mappings with optional search, sorting, and pct_filter.
pct_filter values:
'complete' only SKU groups where sum(procent_pret for active rows) == 100
'incomplete' only SKU groups where sum < 100
None / 'all' no filter
"""
show_deleted: bool = False):
"""Get paginated mappings with optional search and sorting."""
if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable")
@@ -29,7 +23,6 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
"denumire": "na.denumire",
"um": "na.um",
"cantitate_roa": "at.cantitate_roa",
"procent_pret": "at.procent_pret",
"activ": "at.activ",
}
sort_col = allowed_sort.get(sort_by, "at.sku")
@@ -58,7 +51,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
# Fetch ALL matching rows (no pagination yet — we need to group by SKU first)
data_sql = f"""
SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa,
at.procent_pret, at.activ, at.sters,
at.activ, at.sters,
TO_CHAR(at.data_creare, 'YYYY-MM-DD HH24:MI') as data_creare
FROM ARTICOLE_TERTI at
LEFT JOIN nom_articole na ON na.codmat = at.codmat
@@ -69,7 +62,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
columns = [col[0].lower() for col in cur.description]
all_rows = [dict(zip(columns, row)) for row in cur.fetchall()]
# Group by SKU and compute pct_total for each group
# Group by SKU
from collections import OrderedDict
groups = OrderedDict()
for row in all_rows:
@@ -78,64 +71,13 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
groups[sku] = []
groups[sku].append(row)
# Compute counts across ALL groups (before pct_filter)
total_skus = len(groups)
complete_skus = 0
incomplete_skus = 0
for sku, rows in groups.items():
pct_total = sum(
(r["procent_pret"] or 0)
for r in rows
if r.get("activ") == 1
)
if abs(pct_total - 100) <= 0.01:
complete_skus += 1
else:
incomplete_skus += 1
counts = {
"total": total_skus,
"complete": complete_skus,
"incomplete": incomplete_skus,
}
# Apply pct_filter
if pct_filter in ("complete", "incomplete"):
filtered_groups = {}
for sku, rows in groups.items():
pct_total = sum(
(r["procent_pret"] or 0)
for r in rows
if r.get("activ") == 1
)
is_complete = abs(pct_total - 100) <= 0.01
if pct_filter == "complete" and is_complete:
filtered_groups[sku] = rows
elif pct_filter == "incomplete" and not is_complete:
filtered_groups[sku] = rows
groups = filtered_groups
counts = {"total": len(groups)}
# Flatten back to rows for pagination (paginate by raw row count)
filtered_rows = [row for rows in groups.values() for row in rows]
total = len(filtered_rows)
page_rows = filtered_rows[offset: offset + per_page]
# Attach pct_total and is_complete to each row for the renderer
# Re-compute per visible group
sku_pct = {}
for sku, rows in groups.items():
pct_total = sum(
(r["procent_pret"] or 0)
for r in rows
if r.get("activ") == 1
)
sku_pct[sku] = {"pct_total": pct_total, "is_complete": abs(pct_total - 100) <= 0.01}
for row in page_rows:
meta = sku_pct.get(row["sku"], {"pct_total": 0, "is_complete": False})
row["pct_total"] = meta["pct_total"]
row["is_complete"] = meta["is_complete"]
return {
"mappings": page_rows,
"total": total,
@@ -145,7 +87,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
"counts": counts,
}
def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100, auto_restore: bool = False):
def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, auto_restore: bool = False):
"""Create a new mapping. Returns dict or raises HTTPException on duplicate.
When auto_restore=True, soft-deleted records are restored+updated instead of raising 409.
@@ -194,11 +136,10 @@ def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret
if auto_restore:
cur.execute("""
UPDATE ARTICOLE_TERTI SET sters = 0, activ = 1,
cantitate_roa = :cantitate_roa, procent_pret = :procent_pret,
cantitate_roa = :cantitate_roa,
data_modif = SYSDATE
WHERE sku = :sku AND codmat = :codmat AND sters = 1
""", {"sku": sku, "codmat": codmat,
"cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa})
conn.commit()
return {"sku": sku, "codmat": codmat}
else:
@@ -209,13 +150,13 @@ def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret
)
cur.execute("""
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, 1, 0, SYSDATE, -3)
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa})
conn.commit()
return {"sku": sku, "codmat": codmat}
def update_mapping(sku: str, codmat: str, cantitate_roa: float = None, procent_pret: float = None, activ: int = None):
def update_mapping(sku: str, codmat: str, cantitate_roa: float = None, activ: int = None):
"""Update an existing mapping."""
if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable")
@@ -226,9 +167,6 @@ def update_mapping(sku: str, codmat: str, cantitate_roa: float = None, procent_p
if cantitate_roa is not None:
sets.append("cantitate_roa = :cantitate_roa")
params["cantitate_roa"] = cantitate_roa
if procent_pret is not None:
sets.append("procent_pret = :procent_pret")
params["procent_pret"] = procent_pret
if activ is not None:
sets.append("activ = :activ")
params["activ"] = activ
@@ -263,7 +201,7 @@ def delete_mapping(sku: str, codmat: str):
return cur.rowcount > 0
def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str,
cantitate_roa: float = 1, procent_pret: float = 100):
cantitate_roa: float = 1):
"""Edit a mapping. If PK changed, soft-delete old and insert new."""
if not new_sku or not new_sku.strip():
raise HTTPException(status_code=400, detail="SKU este obligatoriu")
@@ -273,8 +211,8 @@ def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str,
raise HTTPException(status_code=503, detail="Oracle unavailable")
if old_sku == new_sku and old_codmat == new_codmat:
# Simple update - only cantitate/procent changed
return update_mapping(new_sku, new_codmat, cantitate_roa, procent_pret)
# Simple update - only cantitate changed
return update_mapping(new_sku, new_codmat, cantitate_roa)
else:
# PK changed: soft-delete old, upsert new (MERGE handles existing soft-deleted target)
with database.pool.acquire() as conn:
@@ -291,14 +229,12 @@ def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str,
ON (t.sku = s.sku AND t.codmat = s.codmat)
WHEN MATCHED THEN UPDATE SET
cantitate_roa = :cantitate_roa,
procent_pret = :procent_pret,
activ = 1, sters = 0,
data_modif = SYSDATE
WHEN NOT MATCHED THEN INSERT
(sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
""", {"sku": new_sku, "codmat": new_codmat,
"cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
(sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, 1, 0, SYSDATE, -3)
""", {"sku": new_sku, "codmat": new_codmat, "cantitate_roa": cantitate_roa})
conn.commit()
return True
@@ -317,7 +253,9 @@ def restore_mapping(sku: str, codmat: str):
return cur.rowcount > 0
def import_csv(file_content: str):
"""Import mappings from CSV content. Returns summary."""
"""Import mappings from CSV content. Returns summary.
Backward compatible: if procent_pret column exists in CSV, it is silently ignored.
"""
if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable")
@@ -342,7 +280,7 @@ def import_csv(file_content: str):
try:
cantitate = float(row.get("cantitate_roa", "1") or "1")
procent = float(row.get("procent_pret", "100") or "100")
# procent_pret column ignored if present (backward compat)
cur.execute("""
MERGE INTO ARTICOLE_TERTI t
@@ -350,14 +288,13 @@ def import_csv(file_content: str):
ON (t.sku = s.sku AND t.codmat = s.codmat)
WHEN MATCHED THEN UPDATE SET
cantitate_roa = :cantitate_roa,
procent_pret = :procent_pret,
activ = 1,
sters = 0,
data_modif = SYSDATE
WHEN NOT MATCHED THEN INSERT
(sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent})
(sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, 1, 0, SYSDATE, -3)
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate})
created += 1
except Exception as e:
@@ -374,12 +311,12 @@ def export_csv():
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["sku", "codmat", "cantitate_roa", "procent_pret", "activ"])
writer.writerow(["sku", "codmat", "cantitate_roa", "activ"])
with database.pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute("""
SELECT sku, codmat, cantitate_roa, procent_pret, activ
SELECT sku, codmat, cantitate_roa, activ
FROM ARTICOLE_TERTI WHERE sters = 0 ORDER BY sku, codmat
""")
for row in cur:
@@ -391,6 +328,70 @@ def get_csv_template():
"""Return empty CSV template."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["sku", "codmat", "cantitate_roa", "procent_pret"])
writer.writerow(["EXAMPLE_SKU", "EXAMPLE_CODMAT", "1", "100"])
writer.writerow(["sku", "codmat", "cantitate_roa"])
writer.writerow(["EXAMPLE_SKU", "EXAMPLE_CODMAT", "1"])
return output.getvalue()
def get_component_prices(sku: str, id_pol: int, id_pol_productie: int = None) -> list:
"""Get prices from crm_politici_pret_art for kit components.
Returns: [{"codmat", "denumire", "cantitate_roa", "pret", "pret_cu_tva", "proc_tvav", "ptva", "id_pol_used"}]
"""
if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable")
with database.pool.acquire() as conn:
with conn.cursor() as cur:
# Get components from ARTICOLE_TERTI
cur.execute("""
SELECT at.codmat, at.cantitate_roa, na.id_articol, na.cont, na.denumire
FROM ARTICOLE_TERTI at
JOIN NOM_ARTICOLE na ON na.codmat = at.codmat AND na.sters = 0 AND na.inactiv = 0
WHERE at.sku = :sku AND at.activ = 1 AND at.sters = 0
ORDER BY at.codmat
""", {"sku": sku})
components = cur.fetchall()
if len(components) <= 1:
return [] # Not a kit
result = []
for codmat, cant_roa, id_art, cont, denumire in components:
# Determine policy based on account
cont_str = str(cont or "").strip()
pol = id_pol_productie if (cont_str in ("341", "345") and id_pol_productie) else id_pol
# Get PRETURI_CU_TVA flag
cur.execute("SELECT PRETURI_CU_TVA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :pol", {"pol": pol})
pol_row = cur.fetchone()
preturi_cu_tva_flag = pol_row[0] if pol_row else 0
# Get price
cur.execute("""
SELECT PRET, PROC_TVAV FROM crm_politici_pret_art
WHERE id_pol = :pol AND id_articol = :id_art
""", {"pol": pol, "id_art": id_art})
price_row = cur.fetchone()
if price_row:
pret, proc_tvav = price_row
proc_tvav = proc_tvav or 1.19
pret_cu_tva = pret if preturi_cu_tva_flag == 1 else round(pret * proc_tvav, 2)
ptva = round((proc_tvav - 1) * 100)
else:
pret = 0
pret_cu_tva = 0
proc_tvav = 1.19
ptva = 19
result.append({
"codmat": codmat,
"denumire": denumire or "",
"cantitate_roa": float(cant_roa) if cant_roa else 1,
"pret": float(pret) if pret else 0,
"pret_cu_tva": float(pret_cu_tva),
"proc_tvav": float(proc_tvav),
"ptva": int(ptva),
"id_pol_used": pol
})
return result

View File

@@ -0,0 +1,220 @@
"""Catalog price sync service — syncs product prices from GoMag catalog to ROA Oracle."""
import asyncio
import logging
import uuid
from datetime import datetime
from zoneinfo import ZoneInfo
from . import gomag_client, validation_service, sqlite_service
from .. import database
from ..config import settings
logger = logging.getLogger(__name__)
_tz = ZoneInfo("Europe/Bucharest")
_price_sync_lock = asyncio.Lock()
_current_price_sync = None
def _now():
return datetime.now(_tz).replace(tzinfo=None)
async def prepare_price_sync() -> dict:
global _current_price_sync
if _price_sync_lock.locked():
return {"error": "Price sync already running"}
run_id = _now().strftime("%Y%m%d_%H%M%S") + "_ps_" + uuid.uuid4().hex[:6]
_current_price_sync = {
"run_id": run_id, "status": "running",
"started_at": _now().isoformat(), "finished_at": None,
"phase_text": "Starting...",
}
# Create SQLite record
db = await sqlite_service.get_sqlite()
try:
await db.execute(
"INSERT INTO price_sync_runs (run_id, started_at, status) VALUES (?, ?, 'running')",
(run_id, _now().isoformat())
)
await db.commit()
finally:
await db.close()
return {"run_id": run_id}
async def get_price_sync_status() -> dict:
if _current_price_sync and _current_price_sync.get("status") == "running":
return _current_price_sync
# Return last run from SQLite
db = await sqlite_service.get_sqlite()
try:
cursor = await db.execute(
"SELECT * FROM price_sync_runs ORDER BY started_at DESC LIMIT 1"
)
row = await cursor.fetchone()
if row:
return {"status": "idle", "last_run": dict(row)}
return {"status": "idle"}
except Exception:
return {"status": "idle"}
finally:
await db.close()
async def run_catalog_price_sync(run_id: str):
global _current_price_sync
async with _price_sync_lock:
log_lines = []
def _log(msg):
logger.info(msg)
log_lines.append(f"[{_now().strftime('%H:%M:%S')}] {msg}")
if _current_price_sync:
_current_price_sync["phase_text"] = msg
try:
app_settings = await sqlite_service.get_app_settings()
id_pol = int(app_settings.get("id_pol") or 0) or None
id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None
if not id_pol:
_log("Politica de preț nu e configurată — skip sync")
await _finish_run(run_id, "error", log_lines, error="No price policy")
return
# Fetch products from GoMag
_log("Descărcare produse din GoMag API...")
products = await gomag_client.download_products(
api_key=app_settings.get("gomag_api_key"),
api_shop=app_settings.get("gomag_api_shop"),
products_url=app_settings.get("gomag_products_url") or None,
log_fn=_log,
)
if not products:
_log("Niciun produs descărcat")
await _finish_run(run_id, "completed", log_lines, products_total=0)
return
# Connect to Oracle
conn = await asyncio.to_thread(database.get_oracle_connection)
try:
# Get all mappings from ARTICOLE_TERTI
_log("Citire mapări ARTICOLE_TERTI...")
mapped_data = await asyncio.to_thread(
validation_service.resolve_mapped_codmats,
{p["sku"] for p in products}, conn
)
# Get direct articles from NOM_ARTICOLE
_log("Identificare articole directe...")
direct_id_map = {}
with conn.cursor() as cur:
all_skus = list({p["sku"] for p in products})
for i in range(0, len(all_skus), 500):
batch = all_skus[i:i+500]
placeholders = ",".join([f":s{j}" for j in range(len(batch))])
params = {f"s{j}": sku for j, sku in enumerate(batch)}
cur.execute(f"""
SELECT codmat, id_articol, cont FROM nom_articole
WHERE codmat IN ({placeholders}) AND sters = 0 AND inactiv = 0
""", params)
for row in cur:
if row[0] not in mapped_data:
direct_id_map[row[0]] = {"id_articol": row[1], "cont": row[2]}
matched = 0
updated = 0
errors = 0
for product in products:
sku = product["sku"]
try:
price_str = product.get("price", "0")
price = float(price_str) if price_str else 0
if price <= 0:
continue
vat = float(product.get("vat", "19"))
vat_included = product.get("vat_included", "1")
# Calculate price with TVA
if vat_included == "1":
price_cu_tva = price
else:
price_cu_tva = price * (1 + vat / 100)
# Skip kits (>1 CODMAT)
if sku in mapped_data and len(mapped_data[sku]) > 1:
continue
# Determine id_articol and policy
id_articol = None
cantitate_roa = 1
if sku in mapped_data and len(mapped_data[sku]) == 1:
comp = mapped_data[sku][0]
id_articol = comp["id_articol"]
cantitate_roa = comp.get("cantitate_roa") or 1
elif sku in direct_id_map:
id_articol = direct_id_map[sku]["id_articol"]
else:
continue # SKU not in ROA
matched += 1
price_per_unit = price_cu_tva / cantitate_roa if cantitate_roa != 1 else price_cu_tva
# Determine policy
cont = None
if sku in mapped_data and len(mapped_data[sku]) == 1:
cont = mapped_data[sku][0].get("cont")
elif sku in direct_id_map:
cont = direct_id_map[sku].get("cont")
cont_str = str(cont or "").strip()
pol = id_pol_productie if (cont_str in ("341", "345") and id_pol_productie) else id_pol
result = await asyncio.to_thread(
validation_service.compare_and_update_price,
id_articol, pol, price_per_unit, conn
)
if result and result["updated"]:
updated += 1
_log(f" {result['codmat']}: {result['old_price']:.2f}{result['new_price']:.2f}")
except Exception as e:
errors += 1
_log(f"Eroare produs {sku}: {e}")
_log(f"Sync complet: {len(products)} produse, {matched} potrivite, {updated} actualizate, {errors} erori")
finally:
await asyncio.to_thread(database.pool.release, conn)
await _finish_run(run_id, "completed", log_lines,
products_total=len(products), matched=matched,
updated=updated, errors=errors)
except Exception as e:
_log(f"Eroare critică: {e}")
logger.error(f"Catalog price sync error: {e}", exc_info=True)
await _finish_run(run_id, "error", log_lines, error=str(e))
async def _finish_run(run_id, status, log_lines, products_total=0,
matched=0, updated=0, errors=0, error=None):
global _current_price_sync
db = await sqlite_service.get_sqlite()
try:
await db.execute("""
UPDATE price_sync_runs SET
finished_at = ?, status = ?, products_total = ?,
matched = ?, updated = ?, errors = ?,
log_text = ?
WHERE run_id = ?
""", (_now().isoformat(), status, products_total, matched, updated, errors,
"\n".join(log_lines), run_id))
await db.commit()
finally:
await db.close()
_current_price_sync = None

View File

@@ -4,6 +4,9 @@ from datetime import datetime
from zoneinfo import ZoneInfo
from ..database import get_sqlite, get_sqlite_sync
# Re-export so other services can import get_sqlite from sqlite_service
__all__ = ["get_sqlite", "get_sqlite_sync"]
_tz_bucharest = ZoneInfo("Europe/Bucharest")
@@ -927,3 +930,22 @@ async def set_app_setting(key: str, value: str):
await db.commit()
finally:
await db.close()
# ── Price Sync Runs ───────────────────────────────
async def get_price_sync_runs(page: int = 1, per_page: int = 20):
"""Get paginated price sync run history."""
db = await get_sqlite()
try:
offset = (page - 1) * per_page
cursor = await db.execute("SELECT COUNT(*) FROM price_sync_runs")
total = (await cursor.fetchone())[0]
cursor = await db.execute(
"SELECT * FROM price_sync_runs ORDER BY started_at DESC LIMIT ? OFFSET ?",
(per_page, offset)
)
runs = [dict(r) for r in await cursor.fetchall()]
return {"runs": runs, "total": total, "page": page, "pages": (total + per_page - 1) // per_page}
finally:
await db.close()

View File

@@ -465,6 +465,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
if item.sku in validation["mapped"]:
mapped_skus_in_orders.add(item.sku)
mapped_codmat_data = {}
if mapped_skus_in_orders:
mapped_codmat_data = await asyncio.to_thread(
validation_service.resolve_mapped_codmats, mapped_skus_in_orders, conn
@@ -501,6 +502,33 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
# Pass codmat_policy_map to import via app_settings
if codmat_policy_map:
app_settings["_codmat_policy_map"] = codmat_policy_map
# ── Kit component price validation ──
kit_pricing_mode = app_settings.get("kit_pricing_mode")
if kit_pricing_mode and mapped_codmat_data:
id_pol_prod = int(app_settings.get("id_pol_productie") or 0) or None
kit_missing = await asyncio.to_thread(
validation_service.validate_kit_component_prices,
mapped_codmat_data, id_pol, id_pol_prod, conn
)
if kit_missing:
kit_skus_missing = set(kit_missing.keys())
for sku, missing_codmats in kit_missing.items():
_log_line(run_id, f"Kit {sku}: prețuri lipsă pentru {', '.join(missing_codmats)}")
new_truly = []
for order in truly_importable:
order_skus = {item.sku for item in order.items}
if order_skus & kit_skus_missing:
missing_list = list(order_skus & kit_skus_missing)
skipped.append((order, missing_list))
else:
new_truly.append(order)
truly_importable = new_truly
# Mode B config validation
if kit_pricing_mode == "separate_line":
if not app_settings.get("kit_discount_codmat"):
_log_line(run_id, "EROARE: Kit mode 'separate_line' dar kit_discount_codmat nu e configurat!")
finally:
await asyncio.to_thread(database.pool.release, conn)
@@ -565,6 +593,28 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
})
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})")
await sqlite_service.save_orders_batch(skipped_batch)
# ── Price sync from orders ──
if app_settings.get("price_sync_enabled") == "1":
try:
all_sync_orders = truly_importable + already_in_roa
direct_id_map = validation.get("direct_id_map", {})
id_pol_prod = int(app_settings.get("id_pol_productie") or 0) or None
price_updates = await asyncio.to_thread(
validation_service.sync_prices_from_order,
all_sync_orders, mapped_codmat_data,
direct_id_map, codmat_policy_map, id_pol,
id_pol_productie=id_pol_prod,
settings=app_settings
)
if price_updates:
_log_line(run_id, f"Sync prețuri: {len(price_updates)} prețuri actualizate")
for pu in price_updates:
_log_line(run_id, f" {pu['codmat']}: {pu['old_price']:.2f}{pu['new_price']:.2f}")
except Exception as e:
_log_line(run_id, f"Eroare sync prețuri din comenzi: {e}")
logger.error(f"Price sync error: {e}")
_update_progress("skipped", f"Skipped {skipped_count}",
0, len(truly_importable),
{"imported": 0, "skipped": skipped_count, "errors": 0, "already_imported": already_imported_count})

View File

@@ -367,7 +367,7 @@ def validate_and_ensure_prices_dual(codmats: set[str], id_pol_vanzare: int,
def resolve_mapped_codmats(mapped_skus: set[str], conn) -> dict[str, list[dict]]:
"""For mapped SKUs, get their underlying CODMATs from ARTICOLE_TERTI + nom_articole.
Returns: {sku: [{"codmat": str, "id_articol": int, "cont": str|None}]}
Returns: {sku: [{"codmat": str, "id_articol": int, "cont": str|None, "cantitate_roa": float|None}]}
"""
if not mapped_skus:
return {}
@@ -382,7 +382,7 @@ def resolve_mapped_codmats(mapped_skus: set[str], conn) -> dict[str, list[dict]]
params = {f"s{j}": sku for j, sku in enumerate(batch)}
cur.execute(f"""
SELECT at.sku, at.codmat, na.id_articol, na.cont
SELECT at.sku, at.codmat, na.id_articol, na.cont, at.cantitate_roa
FROM ARTICOLE_TERTI at
JOIN NOM_ARTICOLE na ON na.codmat = at.codmat AND na.sters = 0 AND na.inactiv = 0
WHERE at.sku IN ({placeholders}) AND at.activ = 1 AND at.sters = 0
@@ -394,8 +394,162 @@ def resolve_mapped_codmats(mapped_skus: set[str], conn) -> dict[str, list[dict]]
result[sku].append({
"codmat": row[1],
"id_articol": row[2],
"cont": row[3]
"cont": row[3],
"cantitate_roa": row[4]
})
logger.info(f"resolve_mapped_codmats: {len(result)} SKUs → {sum(len(v) for v in result.values())} CODMATs")
return result
def validate_kit_component_prices(mapped_codmat_data: dict, id_pol: int,
id_pol_productie: int = None, conn=None) -> dict:
"""Pre-validate that kit components have non-zero prices in crm_politici_pret_art.
Args:
mapped_codmat_data: {sku: [{"codmat", "id_articol", "cont"}, ...]} from resolve_mapped_codmats
id_pol: default sales price policy
id_pol_productie: production price policy (for cont 341/345)
Returns: {sku: [missing_codmats]} for SKUs with missing prices, {} if all OK
"""
missing = {}
own_conn = conn is None
if own_conn:
conn = database.get_oracle_connection()
try:
with conn.cursor() as cur:
for sku, components in mapped_codmat_data.items():
if len(components) <= 1:
continue # Not a kit
sku_missing = []
for comp in components:
cont = str(comp.get("cont") or "").strip()
if cont in ("341", "345") and id_pol_productie:
pol = id_pol_productie
else:
pol = id_pol
cur.execute("""
SELECT PRET FROM crm_politici_pret_art
WHERE id_pol = :pol AND id_articol = :id_art
""", {"pol": pol, "id_art": comp["id_articol"]})
row = cur.fetchone()
if not row or (row[0] is not None and row[0] == 0):
sku_missing.append(comp["codmat"])
if sku_missing:
missing[sku] = sku_missing
finally:
if own_conn:
database.pool.release(conn)
return missing
def compare_and_update_price(id_articol: int, id_pol: int, web_price_cu_tva: float,
conn, tolerance: float = 0.01) -> dict | None:
"""Compare web price with ROA price and update if different.
Handles PRETURI_CU_TVA flag per policy.
Returns: {"updated": bool, "old_price": float, "new_price": float, "codmat": str} or None if no price entry.
"""
with conn.cursor() as cur:
cur.execute("SELECT PRETURI_CU_TVA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :pol", {"pol": id_pol})
pol_row = cur.fetchone()
if not pol_row:
return None
preturi_cu_tva = pol_row[0] # 1 or 0
cur.execute("""
SELECT PRET, PROC_TVAV, na.codmat
FROM crm_politici_pret_art pa
JOIN nom_articole na ON na.id_articol = pa.id_articol
WHERE pa.id_pol = :pol AND pa.id_articol = :id_art
""", {"pol": id_pol, "id_art": id_articol})
row = cur.fetchone()
if not row:
return None
pret_roa, proc_tvav, codmat = row[0], row[1], row[2]
proc_tvav = proc_tvav or 1.19
if preturi_cu_tva == 1:
pret_roa_cu_tva = pret_roa
else:
pret_roa_cu_tva = pret_roa * proc_tvav
if abs(pret_roa_cu_tva - web_price_cu_tva) <= tolerance:
return {"updated": False, "old_price": pret_roa_cu_tva, "new_price": web_price_cu_tva, "codmat": codmat}
if preturi_cu_tva == 1:
new_pret = web_price_cu_tva
else:
new_pret = round(web_price_cu_tva / proc_tvav, 4)
cur.execute("""
UPDATE crm_politici_pret_art SET PRET = :pret, DATAORA = SYSDATE
WHERE id_pol = :pol AND id_articol = :id_art
""", {"pret": new_pret, "pol": id_pol, "id_art": id_articol})
conn.commit()
return {"updated": True, "old_price": pret_roa_cu_tva, "new_price": web_price_cu_tva, "codmat": codmat}
def sync_prices_from_order(orders, mapped_codmat_data: dict, direct_id_map: dict,
codmat_policy_map: dict, id_pol: int,
id_pol_productie: int = None, conn=None,
settings: dict = None) -> list:
"""Sync prices from order items to ROA for direct/1:1 mappings.
Skips kit components and transport/discount CODMATs.
Returns: list of {"codmat", "old_price", "new_price"} for updated prices.
"""
if settings and settings.get("price_sync_enabled") != "1":
return []
transport_codmat = (settings or {}).get("transport_codmat", "")
discount_codmat = (settings or {}).get("discount_codmat", "")
kit_discount_codmat = (settings or {}).get("kit_discount_codmat", "")
skip_codmats = {transport_codmat, discount_codmat, kit_discount_codmat} - {""}
# Build set of kit SKUs (>1 component)
kit_skus = {sku for sku, comps in mapped_codmat_data.items() if len(comps) > 1}
updated = []
own_conn = conn is None
if own_conn:
conn = database.get_oracle_connection()
try:
for order in orders:
for item in order.items:
sku = item.sku
if not sku or sku in skip_codmats:
continue
if sku in kit_skus:
continue # Don't sync prices from kit orders
web_price = item.price # already with TVA
if not web_price or web_price <= 0:
continue
# Determine id_articol and price policy for this SKU
if sku in mapped_codmat_data and len(mapped_codmat_data[sku]) == 1:
# 1:1 mapping via ARTICOLE_TERTI
comp = mapped_codmat_data[sku][0]
id_articol = comp["id_articol"]
cantitate_roa = comp.get("cantitate_roa") or 1
web_price_per_unit = web_price / cantitate_roa if cantitate_roa != 1 else web_price
elif sku in (direct_id_map or {}):
info = direct_id_map[sku]
id_articol = info["id_articol"] if isinstance(info, dict) else info
web_price_per_unit = web_price
else:
continue
pol = codmat_policy_map.get(sku, id_pol)
result = compare_and_update_price(id_articol, pol, web_price_per_unit, conn)
if result and result["updated"]:
updated.append(result)
finally:
if own_conn:
database.pool.release(conn)
return updated

View File

@@ -5,14 +5,14 @@ let searchTimeout = null;
let sortColumn = 'sku';
let sortDirection = 'asc';
let editingMapping = null; // {sku, codmat} when editing
let pctFilter = 'all';
const kitPriceCache = new Map();
// Load on page ready
document.addEventListener('DOMContentLoaded', () => {
loadMappings();
initAddModal();
initDeleteModal();
initPctFilterPills();
});
function debounceSearch() {
@@ -48,44 +48,6 @@ function updateSortIcons() {
});
}
// ── Pct Filter Pills ─────────────────────────────
function initPctFilterPills() {
document.querySelectorAll('.filter-pill[data-pct]').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.filter-pill[data-pct]').forEach(b => b.classList.remove('active'));
this.classList.add('active');
pctFilter = this.dataset.pct;
currentPage = 1;
loadMappings();
});
});
}
function updatePctCounts(counts) {
if (!counts) return;
const elAll = document.getElementById('mCntAll');
const elComplete = document.getElementById('mCntComplete');
const elIncomplete = document.getElementById('mCntIncomplete');
if (elAll) elAll.textContent = counts.total || 0;
if (elComplete) elComplete.textContent = counts.complete || 0;
if (elIncomplete) elIncomplete.textContent = counts.incomplete || 0;
// Mobile segmented control
renderMobileSegmented('mappingsMobileSeg', [
{ label: 'Toate', count: counts.total || 0, value: 'all', active: pctFilter === 'all', colorClass: 'fc-neutral' },
{ label: 'Complete', count: counts.complete || 0, value: 'complete', active: pctFilter === 'complete', colorClass: 'fc-green' },
{ label: 'Incompl.', count: counts.incomplete || 0, value: 'incomplete', active: pctFilter === 'incomplete', colorClass: 'fc-yellow' }
], (val) => {
document.querySelectorAll('.filter-pill[data-pct]').forEach(b => b.classList.remove('active'));
const pill = document.querySelector(`.filter-pill[data-pct="${val}"]`);
if (pill) pill.classList.add('active');
pctFilter = val;
currentPage = 1;
loadMappings();
});
}
// ── Load & Render ────────────────────────────────
async function loadMappings() {
@@ -99,7 +61,6 @@ async function loadMappings() {
sort_dir: sortDirection
});
if (showDeleted) params.set('show_deleted', 'true');
if (pctFilter && pctFilter !== 'all') params.set('pct_filter', pctFilter);
try {
const res = await fetch(`/api/mappings?${params}`);
@@ -113,7 +74,6 @@ async function loadMappings() {
mappings = mappings.filter(m => m.activ || m.sters);
}
updatePctCounts(data.counts);
renderTable(mappings, showDeleted);
renderPagination(data);
updateSortIcons();
@@ -131,41 +91,52 @@ function renderTable(mappings, showDeleted) {
return;
}
// Count CODMATs per SKU for kit detection
const skuCodmatCount = {};
mappings.forEach(m => {
skuCodmatCount[m.sku] = (skuCodmatCount[m.sku] || 0) + 1;
});
let prevSku = null;
let html = '';
mappings.forEach(m => {
mappings.forEach((m, i) => {
const isNewGroup = m.sku !== prevSku;
if (isNewGroup) {
let pctBadge = '';
if (m.pct_total !== undefined) {
pctBadge = m.is_complete
? ` <span class="badge-pct complete">&#10003; 100%</span>`
: ` <span class="badge-pct incomplete">${typeof m.pct_total === 'number' ? m.pct_total.toFixed(0) : m.pct_total}%</span>`;
}
const isKit = (skuCodmatCount[m.sku] || 0) > 1;
const kitBadge = isKit
? ` <span class="text-muted small">Kit · ${skuCodmatCount[m.sku]}</span><span class="kit-price-loading" data-sku="${esc(m.sku)}" style="display:none"><span class="spinner-border spinner-border-sm ms-1" style="width:0.8rem;height:0.8rem"></span></span>`
: '';
const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : '';
html += `<div class="flat-row" style="background:#f8fafc;font-weight:600;border-top:1px solid #e5e7eb;${inactiveStyle}">
<span class="${m.activ ? 'dot dot-green' : 'dot dot-yellow'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}
title="${m.activ ? 'Activ' : 'Inactiv'}"></span>
<strong class="me-1 text-nowrap">${esc(m.sku)}</strong>${pctBadge}
<strong class="me-1 text-nowrap">${esc(m.sku)}</strong>${kitBadge}
<span class="grow truncate text-muted" style="font-size:0.875rem">${esc(m.product_name || '')}</span>
${m.sters
? `<button class="btn btn-sm btn-outline-success" onclick="event.stopPropagation();restoreMapping('${esc(m.sku)}', '${esc(m.codmat)}')" title="Restaureaza" style="padding:0.1rem 0.4rem"><i class="bi bi-arrow-counterclockwise"></i></button>`
: `<button class="context-menu-trigger" data-sku="${esc(m.sku)}" data-codmat="${esc(m.codmat)}" data-cantitate="${m.cantitate_roa}" data-procent="${m.procent_pret}">&#8942;</button>`
: `<button class="context-menu-trigger" data-sku="${esc(m.sku)}" data-codmat="${esc(m.codmat)}" data-cantitate="${m.cantitate_roa}">&#8942;</button>`
}
</div>`;
}
const deletedStyle = m.sters ? 'text-decoration:line-through;opacity:0.5;' : '';
const isKitRow = (skuCodmatCount[m.sku] || 0) > 1;
const priceSlot = isKitRow ? `<span class="kit-price-slot text-muted small ms-2" data-sku="${esc(m.sku)}" data-codmat="${esc(m.codmat)}"></span>` : '';
html += `<div class="flat-row" style="padding-left:1.5rem;font-size:0.9rem;${deletedStyle}">
<code>${esc(m.codmat)}</code>
<span class="grow truncate text-muted" style="font-size:0.85rem">${esc(m.denumire || '')}</span>
<span class="text-nowrap" style="font-size:0.875rem">
<span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>x${m.cantitate_roa}</span>
· <span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})"`}>${m.procent_pret}%</span>
${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>x${m.cantitate_roa}</span>${priceSlot}
</span>
</div>`;
// After last CODMAT of a kit, add total row
const isLastOfKit = isKitRow && (i === mappings.length - 1 || mappings[i + 1].sku !== m.sku);
if (isLastOfKit) {
html += `<div class="flat-row kit-total-slot text-muted small" data-sku="${esc(m.sku)}" style="padding-left:1.5rem;display:none;border-top:1px dashed #e5e7eb"></div>`;
}
prevSku = m.sku;
});
container.innerHTML = html;
@@ -174,17 +145,76 @@ function renderTable(mappings, showDeleted) {
container.querySelectorAll('.context-menu-trigger').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const { sku, codmat, cantitate, procent } = btn.dataset;
const { sku, codmat, cantitate } = btn.dataset;
const rect = btn.getBoundingClientRect();
showContextMenu(rect.left, rect.bottom + 2, [
{ label: 'Editeaza', action: () => openEditModal(sku, codmat, parseFloat(cantitate), parseFloat(procent)) },
{ label: 'Editeaza', action: () => openEditModal(sku, codmat, parseFloat(cantitate)) },
{ label: 'Sterge', action: () => deleteMappingConfirm(sku, codmat), danger: true }
]);
});
});
// Load prices for visible kits
const loadedKits = new Set();
container.querySelectorAll('.kit-price-loading').forEach(el => {
const sku = el.dataset.sku;
if (!loadedKits.has(sku)) {
loadedKits.add(sku);
loadKitPrices(sku, container);
}
});
}
// Inline edit for flat-row values (cantitate / procent)
async function loadKitPrices(sku, container) {
if (kitPriceCache.has(sku)) {
renderKitPrices(sku, kitPriceCache.get(sku), container);
return;
}
// Show loading spinner
const spinner = container.querySelector(`.kit-price-loading[data-sku="${CSS.escape(sku)}"]`);
if (spinner) spinner.style.display = '';
try {
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/prices`);
const data = await res.json();
if (data.error) {
if (spinner) spinner.innerHTML = `<small class="text-danger">${esc(data.error)}</small>`;
return;
}
kitPriceCache.set(sku, data.prices || []);
renderKitPrices(sku, data.prices || [], container);
} catch (err) {
if (spinner) spinner.innerHTML = `<small class="text-danger">Eroare la încărcarea prețurilor</small>`;
}
}
function renderKitPrices(sku, prices, container) {
if (!prices || prices.length === 0) return;
// Update each codmat row with price info
const rows = container.querySelectorAll(`.kit-price-slot[data-sku="${CSS.escape(sku)}"]`);
let total = 0;
rows.forEach(slot => {
const codmat = slot.dataset.codmat;
const p = prices.find(pr => pr.codmat === codmat);
if (p && p.pret_cu_tva > 0) {
slot.innerHTML = `${p.pret_cu_tva.toFixed(2)} lei (${p.ptva}%)`;
total += p.pret_cu_tva * (p.cantitate_roa || 1);
} else if (p) {
slot.innerHTML = `<span class="text-muted">fără preț</span>`;
}
});
// Show total
const totalSlot = container.querySelector(`.kit-total-slot[data-sku="${CSS.escape(sku)}"]`);
if (totalSlot && total > 0) {
totalSlot.innerHTML = `Total componente: ${total.toFixed(2)} lei`;
totalSlot.style.display = '';
}
// Hide loading spinner
const spinner = container.querySelector(`.kit-price-loading[data-sku="${CSS.escape(sku)}"]`);
if (spinner) spinner.style.display = 'none';
}
// Inline edit for flat-row values (cantitate)
function editFlatValue(span, sku, codmat, field, currentValue) {
if (span.querySelector('input')) return;
@@ -276,7 +306,7 @@ function clearAddForm() {
addCodmatLine();
}
async function openEditModal(sku, codmat, cantitate, procent) {
async function openEditModal(sku, codmat, cantitate) {
editingMapping = { sku, codmat };
document.getElementById('addModalTitle').textContent = 'Editare Mapare';
document.getElementById('inputSku').value = sku;
@@ -308,7 +338,6 @@ async function openEditModal(sku, codmat, cantitate, procent) {
if (line) {
line.querySelector('.cl-codmat').value = codmat;
line.querySelector('.cl-cantitate').value = cantitate;
line.querySelector('.cl-procent').value = procent;
}
} else {
for (const m of allMappings) {
@@ -320,7 +349,6 @@ async function openEditModal(sku, codmat, cantitate, procent) {
line.querySelector('.cl-selected').textContent = m.denumire;
}
line.querySelector('.cl-cantitate').value = m.cantitate_roa;
line.querySelector('.cl-procent').value = m.procent_pret;
}
}
} catch (e) {
@@ -330,7 +358,6 @@ async function openEditModal(sku, codmat, cantitate, procent) {
if (line) {
line.querySelector('.cl-codmat').value = codmat;
line.querySelector('.cl-cantitate').value = cantitate;
line.querySelector('.cl-procent').value = procent;
}
}
@@ -352,9 +379,6 @@ function addCodmatLine() {
<div class="col-auto" style="width:90px">
<input type="number" class="form-control form-control-sm cl-cantitate" value="1" step="0.001" min="0.001" placeholder="Cant." title="Cantitate ROA">
</div>
<div class="col-auto" style="width:90px">
<input type="number" class="form-control form-control-sm cl-procent" value="100" step="0.01" min="0" max="100" placeholder="% Pret" title="Procent Pret">
</div>
<div class="col-auto">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.codmat-line').remove()"><i class="bi bi-x-lg"></i></button>` : '<div style="width:31px"></div>'}
</div>
@@ -412,22 +436,12 @@ async function saveMapping() {
for (const line of lines) {
const codmat = line.querySelector('.cl-codmat').value.trim();
const cantitate = parseFloat(line.querySelector('.cl-cantitate').value) || 1;
const procent = parseFloat(line.querySelector('.cl-procent').value) || 100;
if (!codmat) continue;
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
mappings.push({ codmat, cantitate_roa: cantitate });
}
if (mappings.length === 0) { alert('Adauga cel putin un CODMAT'); return; }
// Validate percentage for multi-line
if (mappings.length > 1) {
const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
if (Math.abs(totalPct - 100) > 0.01) {
document.getElementById('pctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
document.getElementById('pctWarning').style.display = '';
return;
}
}
document.getElementById('pctWarning').style.display = 'none';
try {
@@ -442,8 +456,7 @@ async function saveMapping() {
body: JSON.stringify({
new_sku: sku,
new_codmat: mappings[0].codmat,
cantitate_roa: mappings[0].cantitate_roa,
procent_pret: mappings[0].procent_pret
cantitate_roa: mappings[0].cantitate_roa
})
});
} else {
@@ -471,7 +484,7 @@ async function saveMapping() {
res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
body: JSON.stringify({ sku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa })
});
} else {
res = await fetch('/api/mappings/batch', {
@@ -523,7 +536,6 @@ function showInlineAddRow() {
<small class="text-muted" id="inlineSelected"></small>
</div>
<input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:70px" placeholder="Cant.">
<input type="number" class="form-control form-control-sm" id="inlineProcent" value="100" step="0.01" min="0" max="100" style="width:70px" placeholder="%">
<button class="btn btn-sm btn-success" onclick="saveInlineMapping()" title="Salveaza"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-sm btn-outline-secondary" onclick="cancelInlineAdd()" title="Anuleaza"><i class="bi bi-x-lg"></i></button>
`;
@@ -571,7 +583,6 @@ async function saveInlineMapping() {
const sku = document.getElementById('inlineSku').value.trim();
const codmat = document.getElementById('inlineCodmat').value.trim();
const cantitate = parseFloat(document.getElementById('inlineCantitate').value) || 1;
const procent = parseFloat(document.getElementById('inlineProcent').value) || 100;
if (!sku) { alert('SKU este obligatoriu'); return; }
if (!codmat) { alert('CODMAT este obligatoriu'); return; }
@@ -580,7 +591,7 @@ async function saveInlineMapping() {
const res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate, procent_pret: procent })
body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate })
});
const data = await res.json();
if (data.success) {
@@ -755,4 +766,3 @@ function handleMappingConflict(data) {
if (warn) { warn.textContent = msg; warn.style.display = ''; }
}
}

View File

@@ -5,6 +5,21 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadSettings();
wireAutocomplete('settTransportCodmat', 'settTransportAc');
wireAutocomplete('settDiscountCodmat', 'settDiscountAc');
wireAutocomplete('settKitDiscountCodmat', 'settKitDiscountAc');
// Kit pricing mode radio toggle
document.querySelectorAll('input[name="kitPricingMode"]').forEach(r => {
r.addEventListener('change', () => {
document.getElementById('kitModeBFields').style.display =
document.getElementById('kitModeSeparate').checked ? '' : 'none';
});
});
// Catalog sync toggle
const catChk = document.getElementById('settCatalogSyncEnabled');
if (catChk) catChk.addEventListener('change', () => {
document.getElementById('catalogSyncOptions').style.display = catChk.checked ? '' : 'none';
});
});
async function loadDropdowns() {
@@ -66,6 +81,14 @@ async function loadDropdowns() {
pPolEl.innerHTML += `<option value="${escHtml(p.id)}">${escHtml(p.label)}</option>`;
});
}
const kdPolEl = document.getElementById('settKitDiscountIdPol');
if (kdPolEl) {
kdPolEl.innerHTML = '<option value="">— implicită —</option>';
politici.forEach(p => {
kdPolEl.innerHTML += `<option value="${escHtml(p.id)}">${escHtml(p.label)}</option>`;
});
}
} catch (err) {
console.error('loadDropdowns error:', err);
}
@@ -100,6 +123,33 @@ async function loadSettings() {
if (el('settGomagDaysBack')) el('settGomagDaysBack').value = data.gomag_order_days_back || '7';
if (el('settGomagLimit')) el('settGomagLimit').value = data.gomag_limit || '100';
if (el('settDashPollSeconds')) el('settDashPollSeconds').value = data.dashboard_poll_seconds || '5';
// Kit pricing
const kitMode = data.kit_pricing_mode || '';
document.querySelectorAll('input[name="kitPricingMode"]').forEach(r => {
r.checked = r.value === kitMode;
});
document.getElementById('kitModeBFields').style.display = kitMode === 'separate_line' ? '' : 'none';
if (el('settKitDiscountCodmat')) el('settKitDiscountCodmat').value = data.kit_discount_codmat || '';
if (el('settKitDiscountIdPol')) el('settKitDiscountIdPol').value = data.kit_discount_id_pol || '';
// Price sync
if (el('settPriceSyncEnabled')) el('settPriceSyncEnabled').checked = data.price_sync_enabled !== "0";
if (el('settCatalogSyncEnabled')) {
el('settCatalogSyncEnabled').checked = data.catalog_sync_enabled === "1";
document.getElementById('catalogSyncOptions').style.display = data.catalog_sync_enabled === "1" ? '' : 'none';
}
if (el('settPriceSyncSchedule')) el('settPriceSyncSchedule').value = data.price_sync_schedule || '';
// Load price sync status
try {
const psRes = await fetch('/api/price-sync/status');
const psData = await psRes.json();
const psEl = document.getElementById('settPriceSyncStatus');
if (psEl && psData.last_run) {
psEl.textContent = `Ultima: ${psData.last_run.finished_at || ''}${psData.last_run.updated || 0} actualizate din ${psData.last_run.matched || 0}`;
}
} catch {}
} catch (err) {
console.error('loadSettings error:', err);
}
@@ -124,6 +174,13 @@ async function saveSettings() {
gomag_order_days_back: el('settGomagDaysBack')?.value?.trim() || '7',
gomag_limit: el('settGomagLimit')?.value?.trim() || '100',
dashboard_poll_seconds: el('settDashPollSeconds')?.value?.trim() || '5',
kit_pricing_mode: document.querySelector('input[name="kitPricingMode"]:checked')?.value || '',
kit_discount_codmat: el('settKitDiscountCodmat')?.value?.trim() || '',
kit_discount_id_pol: el('settKitDiscountIdPol')?.value?.trim() || '',
price_sync_enabled: el('settPriceSyncEnabled')?.checked ? "1" : "0",
catalog_sync_enabled: el('settCatalogSyncEnabled')?.checked ? "1" : "0",
price_sync_schedule: el('settPriceSyncSchedule')?.value || '',
gomag_products_url: '',
};
try {
const res = await fetch('/api/settings', {
@@ -145,6 +202,40 @@ async function saveSettings() {
}
}
async function startCatalogSync() {
const btn = document.getElementById('btnCatalogSync');
const status = document.getElementById('settPriceSyncStatus');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Sincronizare...';
try {
const res = await fetch('/api/price-sync/start', { method: 'POST' });
const data = await res.json();
if (data.error) {
status.innerHTML = `<span class="text-danger">${escHtml(data.error)}</span>`;
btn.disabled = false;
btn.textContent = 'Sincronizează acum';
return;
}
// Poll status
const pollInterval = setInterval(async () => {
const sr = await fetch('/api/price-sync/status');
const sd = await sr.json();
if (sd.status === 'running') {
status.textContent = sd.phase_text || 'Sincronizare în curs...';
} else {
clearInterval(pollInterval);
btn.disabled = false;
btn.textContent = 'Sincronizează acum';
if (sd.last_run) status.textContent = `Ultima: ${sd.last_run.finished_at || ''}${sd.last_run.updated || 0} actualizate din ${sd.last_run.matched || 0}`;
}
}, 2000);
} catch (err) {
status.innerHTML = `<span class="text-danger">${escHtml(err.message)}</span>`;
btn.disabled = false;
btn.textContent = 'Sincronizează acum';
}
}
function wireAutocomplete(inputId, dropdownId) {
const input = document.getElementById(inputId);
const dropdown = document.getElementById(dropdownId);

View File

@@ -47,14 +47,6 @@
</div>
</div>
<!-- Percentage filter pills -->
<div class="filter-bar" id="mappingsFilterBar">
<button class="filter-pill active d-none d-md-inline-flex" data-pct="all">Toate <span class="filter-count fc-neutral" id="mCntAll">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-pct="complete">Complete <span class="filter-count fc-green" id="mCntComplete">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-pct="incomplete">Incomplete <span class="filter-count fc-yellow" id="mCntIncomplete">0</span></button>
</div>
<div class="d-md-none mb-2" id="mappingsMobileSeg"></div>
<!-- Top pagination -->
<div id="mappingsPagTop" class="pag-strip"></div>
@@ -110,7 +102,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted small">Format CSV: sku, codmat, cantitate_roa, procent_pret</p>
<p class="text-muted small">Format CSV: sku, codmat, cantitate_roa</p>
<input type="file" class="form-control" id="csvFile" accept=".csv">
<div id="importResult" class="mt-3"></div>
</div>
@@ -154,5 +146,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=7"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=9"></script>
{% endblock %}

View File

@@ -157,6 +157,72 @@
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-2 px-3 fw-semibold">Pricing Kituri / Pachete</div>
<div class="card-body py-2 px-3">
<div class="mb-2">
<div class="form-check">
<input class="form-check-input" type="radio" name="kitPricingMode" id="kitModeOff" value="" checked>
<label class="form-check-label small" for="kitModeOff">Dezactivat</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="kitPricingMode" id="kitModeDistributed" value="distributed">
<label class="form-check-label small" for="kitModeDistributed">Distribuire discount în preț</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="kitPricingMode" id="kitModeSeparate" value="separate_line">
<label class="form-check-label small" for="kitModeSeparate">Linie discount separată</label>
</div>
</div>
<div id="kitModeBFields" style="display:none">
<div class="mb-2">
<label class="form-label mb-0 small">Kit Discount CODMAT</label>
<div class="position-relative">
<input type="text" class="form-control form-control-sm" id="settKitDiscountCodmat" placeholder="ex: DISCOUNT_KIT" autocomplete="off">
<div class="autocomplete-dropdown d-none" id="settKitDiscountAc"></div>
</div>
</div>
<div class="mb-2">
<label class="form-label mb-0 small">Kit Discount Politică</label>
<select class="form-select form-select-sm" id="settKitDiscountIdPol">
<option value="">— implicită —</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-2 px-3 fw-semibold">Sincronizare Prețuri</div>
<div class="card-body py-2 px-3">
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="settPriceSyncEnabled" checked>
<label class="form-check-label small" for="settPriceSyncEnabled">Sync automat prețuri din comenzi</label>
</div>
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="settCatalogSyncEnabled">
<label class="form-check-label small" for="settCatalogSyncEnabled">Sync prețuri din catalog GoMag</label>
</div>
<div id="catalogSyncOptions" style="display:none">
<div class="mb-2">
<label class="form-label mb-0 small">Program</label>
<select class="form-select form-select-sm" id="settPriceSyncSchedule">
<option value="">Doar manual</option>
<option value="daily_03:00">Zilnic la 03:00</option>
<option value="daily_06:00">Zilnic la 06:00</option>
</select>
</div>
</div>
<div id="settPriceSyncStatus" class="text-muted small mt-2"></div>
<button class="btn btn-sm btn-outline-primary mt-2" id="btnCatalogSync" onclick="startCatalogSync()">Sincronizează acum</button>
</div>
</div>
</div>
</div>
<div class="mb-3">
@@ -167,5 +233,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=6"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=7"></script>
{% endblock %}

View File

@@ -310,6 +310,9 @@ create or replace package body PACK_COMENZI is
-- marius.mutu
-- adauga_articol_comanda, modifica_articol_comanda + se poate completa ptva (21,11) in loc sa il ia din politica de preturi
-- 19.03.2026
-- adauga_articol_comanda permite de 2 ori acelasi articol cu cote tva diferite (ex: discount 11% si discount 21%)
----------------------------------------------------------------------------------
procedure adauga_masina(V_ID_MODEL_MASINA IN NUMBER,
V_NRINMAT IN VARCHAR2,
@@ -781,6 +784,7 @@ create or replace package body PACK_COMENZI is
FROM COMENZI_ELEMENTE
WHERE ID_COMANDA = V_ID_COMANDA
AND ID_ARTICOL = V_ID_ARTICOL
AND NVL(PTVA,0) = NVL(V_PTVA,0)
AND STERS = 0;
IF V_NR_INREG > 0 THEN

View File

@@ -10,6 +10,8 @@
-- Tabele: ARTICOLE_TERTI (mapari SKU -> CODMAT)
-- NOM_ARTICOLE (nomenclator articole ROA)
-- COMENZI (verificare duplicat comanda_externa)
-- CRM_POLITICI_PRETURI (flag PRETURI_CU_TVA per politica)
-- CRM_POLITICI_PRET_ART (preturi componente kituri)
--
-- Proceduri publice:
--
@@ -25,9 +27,21 @@
-- La eroare ridica RAISE_APPLICATION_ERROR(-20001, mesaj).
-- Returneaza v_id_comanda (OUT) = ID-ul comenzii create.
--
-- Parametri kit pricing:
-- p_kit_mode — 'distributed' | 'separate_line' | NULL
-- distributed: discountul fata de suma componentelor se distribuie
-- proportional in pretul fiecarei componente
-- separate_line: componentele se insereaza la pret plin +
-- linii discount separate grupate pe cota TVA
-- p_id_pol_productie — politica de pret pentru articole de productie
-- (cont_vanzare in 341/345); NULL = nu se foloseste
-- p_kit_discount_codmat — CODMAT-ul articolului discount (Mode separate_line)
-- p_kit_discount_id_pol — id_pol pentru liniile discount (Mode separate_line)
--
-- Logica cautare articol per SKU:
-- 1. Mapari speciale din ARTICOLE_TERTI (reimpachetare, seturi compuse)
-- - un SKU poate avea mai multe randuri (set) cu procent_pret
-- - daca SKU are >1 rand si p_kit_mode IS NOT NULL: kit pricing logic
-- - altfel (1 rand sau kit_mode NULL): pret web / cantitate_roa direct
-- 2. Fallback: cautare directa in NOM_ARTICOLE dupa CODMAT = SKU
--
-- get_last_error / clear_error
@@ -57,11 +71,15 @@ CREATE OR REPLACE PACKAGE PACK_IMPORT_COMENZI AS
p_data_comanda IN DATE,
p_id_partener IN NUMBER,
p_json_articole IN CLOB,
p_id_adresa_livrare IN NUMBER DEFAULT NULL,
p_id_adresa_facturare IN NUMBER DEFAULT NULL,
p_id_pol IN NUMBER DEFAULT NULL,
p_id_sectie IN NUMBER DEFAULT NULL,
p_id_adresa_livrare IN NUMBER DEFAULT NULL,
p_id_adresa_facturare IN NUMBER DEFAULT NULL,
p_id_pol IN NUMBER DEFAULT NULL,
p_id_sectie IN NUMBER DEFAULT NULL,
p_id_gestiune IN VARCHAR2 DEFAULT NULL,
p_kit_mode IN VARCHAR2 DEFAULT NULL,
p_id_pol_productie IN NUMBER DEFAULT NULL,
p_kit_discount_codmat IN VARCHAR2 DEFAULT NULL,
p_kit_discount_id_pol IN NUMBER DEFAULT NULL,
v_id_comanda OUT NUMBER);
-- Functii pentru managementul erorilor (pentru orchestrator VFP)
@@ -76,6 +94,18 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
c_id_util CONSTANT NUMBER := -3; -- Sistem
c_interna CONSTANT NUMBER := 2; -- Comenzi de la client (web)
-- Tipuri pentru kit pricing (accesibile in toate procedurile din body)
TYPE t_kit_component IS RECORD (
codmat VARCHAR2(50),
id_articol NUMBER,
cantitate_roa NUMBER,
pret_cu_tva NUMBER,
ptva NUMBER,
id_pol_comp NUMBER,
value_total NUMBER
);
TYPE t_kit_components IS TABLE OF t_kit_component INDEX BY PLS_INTEGER;
-- ================================================================
-- Functii helper pentru managementul erorilor
-- ================================================================
@@ -150,11 +180,15 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
p_data_comanda IN DATE,
p_id_partener IN NUMBER,
p_json_articole IN CLOB,
p_id_adresa_livrare IN NUMBER DEFAULT NULL,
p_id_adresa_facturare IN NUMBER DEFAULT NULL,
p_id_pol IN NUMBER DEFAULT NULL,
p_id_sectie IN NUMBER DEFAULT NULL,
p_id_adresa_livrare IN NUMBER DEFAULT NULL,
p_id_adresa_facturare IN NUMBER DEFAULT NULL,
p_id_pol IN NUMBER DEFAULT NULL,
p_id_sectie IN NUMBER DEFAULT NULL,
p_id_gestiune IN VARCHAR2 DEFAULT NULL,
p_kit_mode IN VARCHAR2 DEFAULT NULL,
p_id_pol_productie IN NUMBER DEFAULT NULL,
p_kit_discount_codmat IN VARCHAR2 DEFAULT NULL,
p_kit_discount_id_pol IN NUMBER DEFAULT NULL,
v_id_comanda OUT NUMBER) IS
v_data_livrare DATE;
v_sku VARCHAR2(100);
@@ -173,6 +207,15 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
v_pret_unitar NUMBER;
v_id_pol_articol NUMBER; -- id_pol per articol (din JSON), prioritar fata de p_id_pol
-- Variabile kit pricing
v_kit_count NUMBER := 0;
v_kit_comps t_kit_components;
v_sum_list_prices NUMBER;
v_discount_total NUMBER;
v_discount_share NUMBER;
v_pret_ajustat NUMBER;
v_discount_allocated NUMBER;
-- pljson
l_json_articole CLOB := p_json_articole;
v_json_arr pljson_list;
@@ -256,65 +299,276 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
END;
-- STEP 3: Gaseste articolele ROA pentru acest SKU
-- Cauta mai intai in ARTICOLE_TERTI (mapari speciale / seturi)
v_found_mapping := FALSE;
FOR rec IN (SELECT at.codmat, at.cantitate_roa, at.procent_pret
FROM articole_terti at
WHERE at.sku = v_sku
AND at.activ = 1
AND at.sters = 0
ORDER BY at.procent_pret DESC) LOOP
-- Numara randurile ARTICOLE_TERTI pentru a detecta kituri (>1 rand = set compus)
SELECT COUNT(*) INTO v_kit_count
FROM articole_terti at
WHERE at.sku = v_sku
AND at.activ = 1
AND at.sters = 0;
IF v_kit_count > 1 AND p_kit_mode IS NOT NULL THEN
-- ============================================================
-- KIT PRICING: set compus cu >1 componente, mod activ
-- Prima trecere: colecteaza componente + preturi din politici
-- ============================================================
v_found_mapping := TRUE;
v_id_articol := resolve_id_articol(rec.codmat, p_id_gestiune);
IF v_id_articol IS NULL THEN
v_articole_eroare := v_articole_eroare + 1;
g_last_error := g_last_error || CHR(10) ||
'Articol activ negasit pentru CODMAT: ' || rec.codmat;
CONTINUE;
END IF;
v_cantitate_roa := rec.cantitate_roa * v_cantitate_web;
v_pret_unitar := CASE WHEN v_pret_web IS NOT NULL
THEN (v_pret_web * rec.procent_pret / 100) / rec.cantitate_roa
ELSE 0
END;
v_kit_comps.DELETE;
v_sum_list_prices := 0;
DECLARE
v_comp_idx PLS_INTEGER := 0;
v_cont_vanz VARCHAR2(20);
v_preturi_fl NUMBER;
v_pret_val NUMBER;
v_proc_tva NUMBER;
BEGIN
PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda,
V_ID_ARTICOL => v_id_articol,
V_ID_POL => NVL(v_id_pol_articol, p_id_pol),
V_CANTITATE => v_cantitate_roa,
V_PRET => v_pret_unitar,
V_ID_UTIL => c_id_util,
V_ID_SECTIE => p_id_sectie,
V_PTVA => v_vat);
v_articole_procesate := v_articole_procesate + 1;
EXCEPTION
WHEN OTHERS THEN
FOR rec IN (SELECT at.codmat, at.cantitate_roa
FROM articole_terti at
WHERE at.sku = v_sku
AND at.activ = 1
AND at.sters = 0
ORDER BY at.codmat) LOOP
v_comp_idx := v_comp_idx + 1;
v_kit_comps(v_comp_idx).codmat := rec.codmat;
v_kit_comps(v_comp_idx).cantitate_roa := rec.cantitate_roa;
v_kit_comps(v_comp_idx).id_articol :=
resolve_id_articol(rec.codmat, p_id_gestiune);
IF v_kit_comps(v_comp_idx).id_articol IS NULL THEN
v_articole_eroare := v_articole_eroare + 1;
g_last_error := g_last_error || CHR(10) ||
'Articol activ negasit pentru CODMAT: ' || rec.codmat;
v_kit_comps(v_comp_idx).pret_cu_tva := 0;
v_kit_comps(v_comp_idx).ptva := ROUND(v_vat);
v_kit_comps(v_comp_idx).id_pol_comp := NVL(v_id_pol_articol, p_id_pol);
v_kit_comps(v_comp_idx).value_total := 0;
CONTINUE;
END IF;
-- Determina id_pol_comp: cont 341/345 → politica productie, altfel vanzare
BEGIN
SELECT NVL(na.cont_vanzare, '') INTO v_cont_vanz
FROM nom_articole na
WHERE na.id_articol = v_kit_comps(v_comp_idx).id_articol
AND ROWNUM = 1;
EXCEPTION WHEN OTHERS THEN v_cont_vanz := '';
END;
IF v_cont_vanz IN ('341', '345') AND p_id_pol_productie IS NOT NULL THEN
v_kit_comps(v_comp_idx).id_pol_comp := p_id_pol_productie;
ELSE
v_kit_comps(v_comp_idx).id_pol_comp := NVL(v_id_pol_articol, p_id_pol);
END IF;
-- Query flag PRETURI_CU_TVA pentru aceasta politica
BEGIN
SELECT NVL(pp.preturi_cu_tva, 0) INTO v_preturi_fl
FROM crm_politici_preturi pp
WHERE pp.id_pol = v_kit_comps(v_comp_idx).id_pol_comp;
EXCEPTION WHEN OTHERS THEN v_preturi_fl := 0;
END;
-- Citeste PRET si PROC_TVAV din crm_politici_pret_art
BEGIN
SELECT ppa.pret, NVL(ppa.proc_tvav, 1)
INTO v_pret_val, v_proc_tva
FROM crm_politici_pret_art ppa
WHERE ppa.id_pol = v_kit_comps(v_comp_idx).id_pol_comp
AND ppa.id_articol = v_kit_comps(v_comp_idx).id_articol
AND ROWNUM = 1;
-- V_PRET always WITH TVA
IF v_preturi_fl = 1 THEN
v_kit_comps(v_comp_idx).pret_cu_tva := v_pret_val;
ELSE
v_kit_comps(v_comp_idx).pret_cu_tva := v_pret_val * v_proc_tva;
END IF;
v_kit_comps(v_comp_idx).ptva := ROUND((v_proc_tva - 1) * 100);
EXCEPTION WHEN OTHERS THEN
v_kit_comps(v_comp_idx).pret_cu_tva := 0;
v_kit_comps(v_comp_idx).ptva := ROUND(v_vat);
END;
v_kit_comps(v_comp_idx).value_total :=
v_kit_comps(v_comp_idx).pret_cu_tva * v_kit_comps(v_comp_idx).cantitate_roa;
v_sum_list_prices := v_sum_list_prices + v_kit_comps(v_comp_idx).value_total;
END LOOP;
END; -- end prima trecere
-- Discount = suma liste - pret web (poate fi negativ = markup)
v_discount_total := v_sum_list_prices - v_pret_web;
-- ============================================================
-- A doua trecere: inserare in functie de mod
-- ============================================================
IF p_kit_mode = 'distributed' THEN
-- Mode A: distribui discountul proportional in pretul fiecarei componente
v_discount_allocated := 0;
FOR i_comp IN 1 .. v_kit_comps.COUNT LOOP
IF v_kit_comps(i_comp).id_articol IS NOT NULL THEN
-- Ultimul articol valid primeste remainder pentru precizie exacta
IF i_comp = v_kit_comps.LAST THEN
v_discount_share := v_discount_total - v_discount_allocated;
ELSE
IF v_sum_list_prices != 0 THEN
v_discount_share := v_discount_total *
(v_kit_comps(i_comp).value_total / v_sum_list_prices);
ELSE
v_discount_share := 0;
END IF;
v_discount_allocated := v_discount_allocated + v_discount_share;
END IF;
-- pret_ajustat = pret_cu_tva - discount_share / cantitate_roa
v_pret_ajustat := v_kit_comps(i_comp).pret_cu_tva -
(v_discount_share / v_kit_comps(i_comp).cantitate_roa);
BEGIN
PACK_COMENZI.adauga_articol_comanda(
V_ID_COMANDA => v_id_comanda,
V_ID_ARTICOL => v_kit_comps(i_comp).id_articol,
V_ID_POL => v_kit_comps(i_comp).id_pol_comp,
V_CANTITATE => v_kit_comps(i_comp).cantitate_roa * v_cantitate_web,
V_PRET => v_pret_ajustat,
V_ID_UTIL => c_id_util,
V_ID_SECTIE => p_id_sectie,
V_PTVA => v_kit_comps(i_comp).ptva);
v_articole_procesate := v_articole_procesate + 1;
EXCEPTION
WHEN OTHERS THEN
v_articole_eroare := v_articole_eroare + 1;
g_last_error := g_last_error || CHR(10) ||
'Eroare adaugare kit component (A) ' ||
v_kit_comps(i_comp).codmat || ': ' || SQLERRM;
END;
END IF;
END LOOP;
ELSIF p_kit_mode = 'separate_line' THEN
-- Mode B: componente la pret plin + linii discount separate pe cota TVA
DECLARE
TYPE t_vat_discount IS TABLE OF NUMBER INDEX BY PLS_INTEGER;
v_vat_disc t_vat_discount;
v_vat_key PLS_INTEGER;
v_disc_artid NUMBER;
v_vat_disc_alloc NUMBER;
v_disc_amt NUMBER;
BEGIN
-- Inserare componente la pret plin + acumulare discount pe cota TVA
FOR i_comp IN 1 .. v_kit_comps.COUNT LOOP
IF v_kit_comps(i_comp).id_articol IS NOT NULL THEN
BEGIN
PACK_COMENZI.adauga_articol_comanda(
V_ID_COMANDA => v_id_comanda,
V_ID_ARTICOL => v_kit_comps(i_comp).id_articol,
V_ID_POL => v_kit_comps(i_comp).id_pol_comp,
V_CANTITATE => v_kit_comps(i_comp).cantitate_roa * v_cantitate_web,
V_PRET => v_kit_comps(i_comp).pret_cu_tva,
V_ID_UTIL => c_id_util,
V_ID_SECTIE => p_id_sectie,
V_PTVA => v_kit_comps(i_comp).ptva);
v_articole_procesate := v_articole_procesate + 1;
EXCEPTION
WHEN OTHERS THEN
v_articole_eroare := v_articole_eroare + 1;
g_last_error := g_last_error || CHR(10) ||
'Eroare adaugare kit component (B) ' ||
v_kit_comps(i_comp).codmat || ': ' || SQLERRM;
END;
-- Acumuleaza discountul pe cota TVA (proportional cu valoarea componentei)
v_vat_key := v_kit_comps(i_comp).ptva;
IF v_sum_list_prices != 0 THEN
IF v_vat_disc.EXISTS(v_vat_key) THEN
v_vat_disc(v_vat_key) := v_vat_disc(v_vat_key) +
v_discount_total * (v_kit_comps(i_comp).value_total / v_sum_list_prices);
ELSE
v_vat_disc(v_vat_key) :=
v_discount_total * (v_kit_comps(i_comp).value_total / v_sum_list_prices);
END IF;
ELSE
IF NOT v_vat_disc.EXISTS(v_vat_key) THEN
v_vat_disc(v_vat_key) := 0;
END IF;
END IF;
END IF;
END LOOP;
-- Rezolva articolul discount si insereaza liniile de discount
v_disc_artid := resolve_id_articol(p_kit_discount_codmat, p_id_gestiune);
IF v_disc_artid IS NOT NULL AND v_vat_disc.COUNT > 0 THEN
v_vat_disc_alloc := 0;
v_vat_key := v_vat_disc.FIRST;
WHILE v_vat_key IS NOT NULL LOOP
-- Ultima cota TVA primeste remainder pentru precizie exacta
IF v_vat_key = v_vat_disc.LAST THEN
v_disc_amt := v_discount_total - v_vat_disc_alloc;
ELSE
v_disc_amt := v_vat_disc(v_vat_key);
v_vat_disc_alloc := v_vat_disc_alloc + v_disc_amt;
END IF;
IF v_disc_amt != 0 THEN
BEGIN
PACK_COMENZI.adauga_articol_comanda(
V_ID_COMANDA => v_id_comanda,
V_ID_ARTICOL => v_disc_artid,
V_ID_POL => NVL(p_kit_discount_id_pol, p_id_pol),
V_CANTITATE => -1 * v_cantitate_web,
V_PRET => v_disc_amt / v_cantitate_web,
V_ID_UTIL => c_id_util,
V_ID_SECTIE => p_id_sectie,
V_PTVA => v_vat_key);
v_articole_procesate := v_articole_procesate + 1;
EXCEPTION
WHEN OTHERS THEN
v_articole_eroare := v_articole_eroare + 1;
g_last_error := g_last_error || CHR(10) ||
'Eroare linie discount kit TVA=' || v_vat_key || '%: ' || SQLERRM;
END;
END IF;
v_vat_key := v_vat_disc.NEXT(v_vat_key);
END LOOP;
END IF;
END; -- end mode B block
END IF; -- end kit mode branching
ELSE
-- ============================================================
-- MAPARE SIMPLA: 1 CODMAT, sau kit fara kit_mode activ
-- Pret = pret web / cantitate_roa (fara procent_pret)
-- ============================================================
FOR rec IN (SELECT at.codmat, at.cantitate_roa
FROM articole_terti at
WHERE at.sku = v_sku
AND at.activ = 1
AND at.sters = 0
ORDER BY at.codmat) LOOP
v_found_mapping := TRUE;
v_id_articol := resolve_id_articol(rec.codmat, p_id_gestiune);
IF v_id_articol IS NULL THEN
v_articole_eroare := v_articole_eroare + 1;
g_last_error := g_last_error || CHR(10) ||
'Eroare adaugare articol ' || rec.codmat || ': ' || SQLERRM;
END;
END LOOP;
'Articol activ negasit pentru CODMAT: ' || rec.codmat;
CONTINUE;
END IF;
-- Daca nu s-a gasit mapare, cauta direct in NOM_ARTICOLE via resolve_id_articol
IF NOT v_found_mapping THEN
v_id_articol := resolve_id_articol(v_sku, p_id_gestiune);
IF v_id_articol IS NULL THEN
v_articole_eroare := v_articole_eroare + 1;
g_last_error := g_last_error || CHR(10) ||
'SKU negasit in ARTICOLE_TERTI si NOM_ARTICOLE (activ): ' || v_sku;
ELSE
v_codmat := v_sku;
v_pret_unitar := NVL(v_pret_web, 0);
v_cantitate_roa := rec.cantitate_roa * v_cantitate_web;
v_pret_unitar := CASE WHEN v_pret_web IS NOT NULL
THEN v_pret_web / rec.cantitate_roa
ELSE 0
END;
BEGIN
PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda,
V_ID_ARTICOL => v_id_articol,
V_ID_POL => NVL(v_id_pol_articol, p_id_pol),
V_CANTITATE => v_cantitate_web,
V_CANTITATE => v_cantitate_roa,
V_PRET => v_pret_unitar,
V_ID_UTIL => c_id_util,
V_ID_SECTIE => p_id_sectie,
@@ -324,10 +578,41 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
WHEN OTHERS THEN
v_articole_eroare := v_articole_eroare + 1;
g_last_error := g_last_error || CHR(10) ||
'Eroare adaugare articol ' || v_sku || ' (CODMAT: ' || v_codmat || '): ' || SQLERRM;
'Eroare adaugare articol ' || rec.codmat || ': ' || SQLERRM;
END;
END LOOP;
-- Daca nu s-a gasit mapare in ARTICOLE_TERTI, cauta direct in NOM_ARTICOLE
IF NOT v_found_mapping THEN
v_id_articol := resolve_id_articol(v_sku, p_id_gestiune);
IF v_id_articol IS NULL THEN
v_articole_eroare := v_articole_eroare + 1;
g_last_error := g_last_error || CHR(10) ||
'SKU negasit in ARTICOLE_TERTI si NOM_ARTICOLE (activ): ' || v_sku;
ELSE
v_codmat := v_sku;
v_pret_unitar := NVL(v_pret_web, 0);
BEGIN
PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda,
V_ID_ARTICOL => v_id_articol,
V_ID_POL => NVL(v_id_pol_articol, p_id_pol),
V_CANTITATE => v_cantitate_web,
V_PRET => v_pret_unitar,
V_ID_UTIL => c_id_util,
V_ID_SECTIE => p_id_sectie,
V_PTVA => v_vat);
v_articole_procesate := v_articole_procesate + 1;
EXCEPTION
WHEN OTHERS THEN
v_articole_eroare := v_articole_eroare + 1;
g_last_error := g_last_error || CHR(10) ||
'Eroare adaugare articol ' || v_sku ||
' (CODMAT: ' || v_codmat || '): ' || SQLERRM;
END;
END IF;
END IF;
END IF;
END IF; -- end kit vs simplu
END; -- End BEGIN block pentru articol individual

View File

@@ -0,0 +1,3 @@
-- Run AFTER deploying Python code changes and confirming new pricing works
-- Removes the deprecated procent_pret column from ARTICOLE_TERTI
ALTER TABLE ARTICOLE_TERTI DROP COLUMN procent_pret;