Compare commits
15 Commits
feat/multi
...
f68adbb072
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f68adbb072 | ||
|
|
eccd9dd753 | ||
|
|
73fe53394e | ||
|
|
039cbb1438 | ||
|
|
1353d4b8cf | ||
|
|
f1c7625ec7 | ||
|
|
a898666869 | ||
|
|
1cea8cace0 | ||
|
|
327f0e6ea2 | ||
|
|
c806ca2d81 | ||
|
|
952989d34b | ||
|
|
aa6e035c02 | ||
|
|
9e5901a8fb | ||
|
|
bedb93affe | ||
|
|
47e77e7241 |
@@ -34,7 +34,7 @@ python api/test_integration.py # cu Oracle
|
||||
1. Download GoMag API → JSON → parse → validate SKU-uri → import Oracle
|
||||
2. Ordinea: **parteneri** (cauta/creeaza) → **adrese** → **comanda** → **factura cache**
|
||||
3. SKU lookup: ARTICOLE_TERTI (mapped) are prioritate fata de NOM_ARTICOLE (direct)
|
||||
4. Complex sets: un SKU → multiple CODMAT-uri cu `procent_pret` (trebuie sa fie sum=100%)
|
||||
4. Complex sets (kituri/pachete): un SKU → multiple CODMAT-uri cu `cantitate_roa`; preturile se preiau din lista de preturi Oracle
|
||||
5. Comenzi anulate (GoMag statusId=7): verifica daca au factura inainte de stergere din Oracle
|
||||
|
||||
### Statusuri comenzi
|
||||
@@ -51,6 +51,11 @@ python api/test_integration.py # cu Oracle
|
||||
- Dual policy: articolele sunt rutate la `id_pol_vanzare` sau `id_pol_productie` pe baza contului contabil (341/345 = productie)
|
||||
- Daca pretul lipseste, se insereaza automat pret=0
|
||||
|
||||
### Dashboard paginare
|
||||
- Contorul din paginare arata **totalul comenzilor** din perioada selectata (ex: "378 comenzi"), NU doar cele filtrate
|
||||
- Butoanele de filtru (Importat, Omise, Erori, Facturate, Nefacturate, Anulate) arata fiecare cate comenzi are pe langa total
|
||||
- Aceasta este comportamentul dorit: userul vede cate comenzi totale sunt, din care cate importate, cu erori etc.
|
||||
|
||||
### Invoice cache
|
||||
- Coloanele `factura_*` pe `orders` (SQLite), populate lazy din Oracle (`vanzari WHERE sters=0`)
|
||||
- Refresh complet: verifica facturi noi + facturi sterse + comenzi sterse din ROA
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}]
|
||||
@@ -416,6 +446,12 @@ async def order_detail(order_number: str):
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Add settings for receipt display
|
||||
app_settings = await sqlite_service.get_app_settings()
|
||||
order["transport_vat"] = app_settings.get("transport_vat") or "21"
|
||||
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
|
||||
order["discount_codmat"] = app_settings.get("discount_codmat") or ""
|
||||
|
||||
return detail
|
||||
|
||||
|
||||
@@ -657,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", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -679,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}
|
||||
|
||||
|
||||
|
||||
@@ -101,3 +101,82 @@ 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):
|
||||
# GoMag returns products as {"1": {...}, "2": {...}} dict
|
||||
first_val = next(iter(products.values()), None) if products else None
|
||||
if isinstance(first_val, dict):
|
||||
products = list(products.values())
|
||||
else:
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
220
api/app/services/price_sync_service.py
Normal file
220
api/app/services/price_sync_service.py
Normal 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().strftime("%d.%m.%Y %H:%M:%S"))
|
||||
)
|
||||
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().strftime("%d.%m.%Y %H:%M:%S"), status, products_total, matched, updated, errors,
|
||||
"\n".join(log_lines), run_id))
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
_current_price_sync = None
|
||||
@@ -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()
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,6 +35,18 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* ── Checkboxes — accessible size ────────────────── */
|
||||
input[type="checkbox"] {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
accent-color: var(--blue-600);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Top Navbar ──────────────────────────────────── */
|
||||
.top-navbar {
|
||||
position: fixed;
|
||||
@@ -141,6 +153,7 @@ body {
|
||||
padding: 0.625rem 1rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Zebra striping */
|
||||
@@ -212,10 +225,10 @@ body {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
min-width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
@@ -356,11 +369,16 @@ body {
|
||||
.qm-row { display: flex; gap: 6px; align-items: center; }
|
||||
.qm-codmat-wrap { flex: 1; min-width: 0; }
|
||||
.qm-rm-btn { padding: 2px 6px; line-height: 1; }
|
||||
#qmCodmatLines .qm-selected:empty { display: none; }
|
||||
#quickMapModal .modal-body { padding-top: 12px; padding-bottom: 8px; }
|
||||
#quickMapModal .modal-header { padding: 10px 16px; }
|
||||
#quickMapModal .modal-header h5 { font-size: 0.95rem; margin: 0; }
|
||||
#quickMapModal .modal-footer { padding: 8px 16px; }
|
||||
#qmCodmatLines .qm-selected:empty,
|
||||
#codmatLines .qm-selected:empty { display: none; }
|
||||
#quickMapModal .modal-body,
|
||||
#addModal .modal-body { padding-top: 12px; padding-bottom: 8px; }
|
||||
#quickMapModal .modal-header,
|
||||
#addModal .modal-header { padding: 10px 16px; }
|
||||
#quickMapModal .modal-header h5,
|
||||
#addModal .modal-header h5 { font-size: 0.95rem; margin: 0; }
|
||||
#quickMapModal .modal-footer,
|
||||
#addModal .modal-footer { padding: 8px 16px; }
|
||||
|
||||
/* ── Deleted mapping rows ────────────────────────── */
|
||||
tr.mapping-deleted td {
|
||||
@@ -399,7 +417,7 @@ tr.mapping-deleted td {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
@@ -429,10 +447,12 @@ tr.mapping-deleted td {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.9375rem;
|
||||
outline: none;
|
||||
width: 160px;
|
||||
}
|
||||
.search-input:focus { border-color: var(--blue-600); }
|
||||
.search-input:focus {
|
||||
border-color: var(--blue-600);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
/* ── Autocomplete dropdown (keep as-is) ──────────── */
|
||||
.autocomplete-dropdown {
|
||||
|
||||
@@ -4,10 +4,6 @@ let dashPerPage = 50;
|
||||
let dashSortCol = 'order_date';
|
||||
let dashSortDir = 'desc';
|
||||
let dashSearchTimeout = null;
|
||||
let currentQmSku = '';
|
||||
let currentQmOrderNumber = '';
|
||||
let qmAcTimeout = null;
|
||||
|
||||
// Sync polling state
|
||||
let _pollInterval = null;
|
||||
let _lastSyncStatus = null;
|
||||
@@ -484,7 +480,7 @@ function renderCodmatCell(item) {
|
||||
return `<code>${esc(d.codmat)}</code>`;
|
||||
}
|
||||
return item.codmat_details.map(d =>
|
||||
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa} (${d.procent_pret}%)</span></div>`
|
||||
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa}</span></div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
@@ -522,14 +518,12 @@ async function openDashOrderDetail(orderNumber) {
|
||||
document.getElementById('detailIdPartener').textContent = '-';
|
||||
document.getElementById('detailIdAdresaFact').textContent = '-';
|
||||
document.getElementById('detailIdAdresaLivr').textContent = '-';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center">Se incarca...</td></tr>';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="7" class="text-center">Se incarca...</td></tr>';
|
||||
document.getElementById('detailError').style.display = 'none';
|
||||
document.getElementById('detailReceipt').innerHTML = '';
|
||||
document.getElementById('detailReceiptMobile').innerHTML = '';
|
||||
const invInfo = document.getElementById('detailInvoiceInfo');
|
||||
if (invInfo) invInfo.style.display = 'none';
|
||||
const detailItemsTotal = document.getElementById('detailItemsTotal');
|
||||
if (detailItemsTotal) detailItemsTotal.textContent = '-';
|
||||
const detailOrderTotal = document.getElementById('detailOrderTotal');
|
||||
if (detailOrderTotal) detailOrderTotal.textContent = '-';
|
||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||
if (mobileContainer) mobileContainer.innerHTML = '';
|
||||
|
||||
@@ -574,234 +568,208 @@ async function openDashOrderDetail(orderNumber) {
|
||||
document.getElementById('detailError').style.display = '';
|
||||
}
|
||||
|
||||
const dlvEl = document.getElementById('detailDeliveryCost');
|
||||
if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : '–';
|
||||
|
||||
const dscEl = document.getElementById('detailDiscount');
|
||||
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) {
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center text-muted">Niciun articol</td></tr>';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="7" class="text-center text-muted">Niciun articol</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update totals row
|
||||
const itemsTotal = items.reduce((sum, item) => sum + (Number(item.price || 0) * Number(item.quantity || 0)), 0);
|
||||
document.getElementById('detailItemsTotal').textContent = itemsTotal.toFixed(2) + ' lei';
|
||||
document.getElementById('detailOrderTotal').textContent = order.order_total != null ? Number(order.order_total).toFixed(2) + ' lei' : '-';
|
||||
|
||||
// Store items for quick map pre-population
|
||||
window._detailItems = items;
|
||||
|
||||
// Mobile article flat list
|
||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||
if (mobileContainer) {
|
||||
mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => {
|
||||
let mobileHtml = items.map((item, idx) => {
|
||||
const codmatText = item.codmat_details?.length
|
||||
? item.codmat_details.map(d => `<code>${esc(d.codmat)}</code>${d.direct ? ' <span class="badge bg-secondary" style="font-size:0.55rem">direct</span>' : ''}`).join(' ')
|
||||
: `<code>${esc(item.codmat || '–')}</code>`;
|
||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
|
||||
return `<div class="dif-item">
|
||||
<div class="dif-row">
|
||||
<span class="dif-sku dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}', ${idx})">${esc(item.sku)}</span>
|
||||
<span class="dif-sku dif-codmat-link" onclick="openDashQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}', ${idx})">${esc(item.sku)}</span>
|
||||
${codmatText}
|
||||
</div>
|
||||
<div class="dif-row">
|
||||
<span class="dif-name">${esc(item.product_name || '–')}</span>
|
||||
<span class="dif-qty">x${item.quantity || 0}</span>
|
||||
<span class="dif-val">${valoare} lei</span>
|
||||
<span class="dif-val">${fmtNum(valoare)} lei</span>
|
||||
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
}).join('');
|
||||
|
||||
// Transport row (mobile)
|
||||
if (order.delivery_cost > 0) {
|
||||
const tVat = order.transport_vat || '21';
|
||||
mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
||||
<div class="dif-row">
|
||||
<span class="dif-name text-muted">Transport</span>
|
||||
<span class="dif-qty">x1</span>
|
||||
<span class="dif-val">${fmtNum(order.delivery_cost)} lei</span>
|
||||
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${tVat}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Discount rows (mobile)
|
||||
if (order.discount_total > 0) {
|
||||
const discSplit = computeDiscountSplit(items, order);
|
||||
if (discSplit) {
|
||||
Object.entries(discSplit)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.forEach(([rate, amt]) => {
|
||||
if (amt > 0) mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
||||
<div class="dif-row">
|
||||
<span class="dif-name text-muted">Discount</span>
|
||||
<span class="dif-qty">x\u20131</span>
|
||||
<span class="dif-val">${fmtNum(amt)} lei</span>
|
||||
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${Number(rate)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
} else {
|
||||
mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
||||
<div class="dif-row">
|
||||
<span class="dif-name text-muted">Discount</span>
|
||||
<span class="dif-qty">x\u20131</span>
|
||||
<span class="dif-val">${fmtNum(order.discount_total)} lei</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
mobileContainer.innerHTML = '<div class="detail-item-flat">' + mobileHtml + '</div>';
|
||||
}
|
||||
|
||||
document.getElementById('detailItemsBody').innerHTML = items.map((item, idx) => {
|
||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
||||
let tableHtml = items.map((item, idx) => {
|
||||
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
|
||||
return `<tr>
|
||||
<td><code class="codmat-link" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare">${esc(item.sku)}</code></td>
|
||||
<td><code class="codmat-link" onclick="openDashQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare">${esc(item.sku)}</code></td>
|
||||
<td>${esc(item.product_name || '-')}</td>
|
||||
<td>${renderCodmatCell(item)}</td>
|
||||
<td>${item.quantity || 0}</td>
|
||||
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
|
||||
<td class="text-end">${valoare}</td>
|
||||
<td class="text-end">${item.quantity || 0}</td>
|
||||
<td class="text-end">${item.price != null ? fmtNum(item.price) : '-'}</td>
|
||||
<td class="text-end">${item.vat != null ? Number(item.vat) : '-'}</td>
|
||||
<td class="text-end">${fmtNum(valoare)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// Transport row
|
||||
if (order.delivery_cost > 0) {
|
||||
const tVat = order.transport_vat || '21';
|
||||
const tCodmat = order.transport_codmat || '';
|
||||
tableHtml += `<tr class="table-light">
|
||||
<td></td><td class="text-muted">Transport</td>
|
||||
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</td>
|
||||
<td class="text-end">1</td><td class="text-end">${fmtNum(order.delivery_cost)}</td>
|
||||
<td class="text-end">${tVat}</td><td class="text-end">${fmtNum(order.delivery_cost)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// Discount rows (split by VAT rate)
|
||||
if (order.discount_total > 0) {
|
||||
const dCodmat = order.discount_codmat || '';
|
||||
const discSplit = computeDiscountSplit(items, order);
|
||||
if (discSplit) {
|
||||
Object.entries(discSplit)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.forEach(([rate, amt]) => {
|
||||
if (amt > 0) tableHtml += `<tr class="table-light">
|
||||
<td></td><td class="text-muted">Discount</td>
|
||||
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
||||
<td class="text-end">\u20131</td><td class="text-end">${fmtNum(amt)}</td>
|
||||
<td class="text-end">${Number(rate)}</td><td class="text-end">\u2013${fmtNum(amt)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
} else {
|
||||
tableHtml += `<tr class="table-light">
|
||||
<td></td><td class="text-muted">Discount</td>
|
||||
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
||||
<td class="text-end">\u20131</td><td class="text-end">${fmtNum(order.discount_total)}</td>
|
||||
<td class="text-end">-</td><td class="text-end">\u2013${fmtNum(order.discount_total)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('detailItemsBody').innerHTML = tableHtml;
|
||||
|
||||
// Receipt footer (just total)
|
||||
renderReceipt(items, order);
|
||||
} catch (err) {
|
||||
document.getElementById('detailError').textContent = err.message;
|
||||
document.getElementById('detailError').style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Quick Map Modal ───────────────────────────────
|
||||
function fmtNum(v) {
|
||||
return Number(v).toLocaleString('ro-RO', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function openQuickMap(sku, productName, orderNumber, itemIdx) {
|
||||
currentQmSku = sku;
|
||||
currentQmOrderNumber = orderNumber;
|
||||
document.getElementById('qmSku').textContent = sku;
|
||||
document.getElementById('qmProductName').textContent = productName || '-';
|
||||
document.getElementById('qmPctWarning').style.display = 'none';
|
||||
function computeDiscountSplit(items, order) {
|
||||
if (order.discount_split && typeof order.discount_split === 'object')
|
||||
return order.discount_split;
|
||||
|
||||
const container = document.getElementById('qmCodmatLines');
|
||||
container.innerHTML = '';
|
||||
// Compute proportionally from items by VAT rate
|
||||
const byRate = {};
|
||||
items.forEach(item => {
|
||||
const rate = item.vat != null ? Number(item.vat) : null;
|
||||
if (rate === null) return;
|
||||
if (!byRate[rate]) byRate[rate] = 0;
|
||||
byRate[rate] += Number(item.price || 0) * Number(item.quantity || 0);
|
||||
});
|
||||
const rates = Object.keys(byRate).sort((a, b) => Number(a) - Number(b));
|
||||
if (rates.length === 0) return null;
|
||||
|
||||
// Check if this is a direct SKU (SKU=CODMAT in NOM_ARTICOLE)
|
||||
const grandTotal = rates.reduce((s, r) => s + byRate[r], 0);
|
||||
if (grandTotal <= 0) return null;
|
||||
|
||||
const split = {};
|
||||
let remaining = order.discount_total;
|
||||
rates.forEach((rate, i) => {
|
||||
if (i === rates.length - 1) {
|
||||
split[rate] = Math.round(remaining * 100) / 100;
|
||||
} else {
|
||||
const amt = Math.round(order.discount_total * byRate[rate] / grandTotal * 100) / 100;
|
||||
split[rate] = amt;
|
||||
remaining -= amt;
|
||||
}
|
||||
});
|
||||
return split;
|
||||
}
|
||||
|
||||
function renderReceipt(items, order) {
|
||||
const desktop = document.getElementById('detailReceipt');
|
||||
const mobile = document.getElementById('detailReceiptMobile');
|
||||
if (!items.length) {
|
||||
desktop.innerHTML = '';
|
||||
mobile.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const total = order.order_total != null ? fmtNum(order.order_total) : '-';
|
||||
const html = `<span><strong>Total: ${total} lei</strong></span>`;
|
||||
desktop.innerHTML = html;
|
||||
mobile.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Quick Map Modal (uses shared openQuickMap) ───
|
||||
|
||||
function openDashQuickMap(sku, productName, orderNumber, itemIdx) {
|
||||
const item = (window._detailItems || [])[itemIdx];
|
||||
const details = item?.codmat_details;
|
||||
const isDirect = details?.length === 1 && details[0].direct === true;
|
||||
const directInfo = document.getElementById('qmDirectInfo');
|
||||
const saveBtn = document.getElementById('qmSaveBtn');
|
||||
|
||||
if (isDirect) {
|
||||
if (directInfo) {
|
||||
directInfo.innerHTML = `<i class="bi bi-info-circle"></i> SKU = CODMAT direct in nomenclator (<code>${escHtml(details[0].codmat)}</code> — ${escHtml(details[0].denumire || '')}).<br><small class="text-muted">Poti suprascrie cu un alt CODMAT daca e necesar (ex: reambalare).</small>`;
|
||||
directInfo.style.display = '';
|
||||
}
|
||||
if (saveBtn) {
|
||||
saveBtn.textContent = 'Suprascrie mapare';
|
||||
}
|
||||
addQmCodmatLine();
|
||||
} else {
|
||||
if (directInfo) directInfo.style.display = 'none';
|
||||
if (saveBtn) saveBtn.textContent = 'Salveaza';
|
||||
|
||||
// Pre-populate with existing codmat_details if available
|
||||
if (details && details.length > 0) {
|
||||
details.forEach(d => {
|
||||
addQmCodmatLine({ codmat: d.codmat, cantitate: d.cantitate_roa, procent: d.procent_pret, denumire: d.denumire });
|
||||
});
|
||||
} else {
|
||||
addQmCodmatLine();
|
||||
}
|
||||
}
|
||||
|
||||
new bootstrap.Modal(document.getElementById('quickMapModal')).show();
|
||||
}
|
||||
|
||||
function addQmCodmatLine(prefill) {
|
||||
const container = document.getElementById('qmCodmatLines');
|
||||
const idx = container.children.length;
|
||||
const codmatVal = prefill?.codmat || '';
|
||||
const cantVal = prefill?.cantitate || 1;
|
||||
const pctVal = prefill?.procent || 100;
|
||||
const denumireVal = prefill?.denumire || '';
|
||||
const div = document.createElement('div');
|
||||
div.className = 'qm-line';
|
||||
div.innerHTML = `
|
||||
<div class="qm-row">
|
||||
<div class="qm-codmat-wrap position-relative">
|
||||
<input type="text" class="form-control form-control-sm qm-codmat" placeholder="CODMAT..." autocomplete="off" value="${escHtml(codmatVal)}">
|
||||
<div class="autocomplete-dropdown d-none qm-ac-dropdown"></div>
|
||||
</div>
|
||||
<input type="number" class="form-control form-control-sm qm-cantitate" value="${cantVal}" step="0.001" min="0.001" title="Cantitate ROA" style="width:70px">
|
||||
<input type="number" class="form-control form-control-sm qm-procent" value="${pctVal}" step="0.01" min="0" max="100" title="Procent %" style="width:70px">
|
||||
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger qm-rm-btn" onclick="this.closest('.qm-line').remove()"><i class="bi bi-x"></i></button>` : '<span style="width:30px"></span>'}
|
||||
</div>
|
||||
<div class="qm-selected text-muted" style="font-size:0.75rem;padding-left:2px">${escHtml(denumireVal)}</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
|
||||
const input = div.querySelector('.qm-codmat');
|
||||
const dropdown = div.querySelector('.qm-ac-dropdown');
|
||||
const selected = div.querySelector('.qm-selected');
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(qmAcTimeout);
|
||||
qmAcTimeout = setTimeout(() => qmAutocomplete(input, dropdown, selected), 250);
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
||||
});
|
||||
}
|
||||
|
||||
async function qmAutocomplete(input, dropdown, selectedEl) {
|
||||
const q = input.value;
|
||||
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
|
||||
const data = await res.json();
|
||||
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
||||
|
||||
dropdown.innerHTML = data.results.map(r =>
|
||||
`<div class="autocomplete-item" onmousedown="qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
||||
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
dropdown.classList.remove('d-none');
|
||||
} catch { dropdown.classList.add('d-none'); }
|
||||
}
|
||||
|
||||
function qmSelectArticle(el, codmat, label) {
|
||||
const line = el.closest('.qm-line');
|
||||
line.querySelector('.qm-codmat').value = codmat;
|
||||
line.querySelector('.qm-selected').textContent = label;
|
||||
line.querySelector('.qm-ac-dropdown').classList.add('d-none');
|
||||
}
|
||||
|
||||
async function saveQuickMapping() {
|
||||
const lines = document.querySelectorAll('.qm-line');
|
||||
const mappings = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const codmat = line.querySelector('.qm-codmat').value.trim();
|
||||
const cantitate = parseFloat(line.querySelector('.qm-cantitate').value) || 1;
|
||||
const procent = parseFloat(line.querySelector('.qm-procent').value) || 100;
|
||||
if (!codmat) continue;
|
||||
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
|
||||
}
|
||||
|
||||
if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
|
||||
|
||||
if (mappings.length > 1) {
|
||||
const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
|
||||
if (Math.abs(totalPct - 100) > 0.01) {
|
||||
document.getElementById('qmPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
|
||||
document.getElementById('qmPctWarning').style.display = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
document.getElementById('qmPctWarning').style.display = 'none';
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (mappings.length === 1) {
|
||||
res = await fetch('/api/mappings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sku: currentQmSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
|
||||
});
|
||||
} else {
|
||||
res = await fetch('/api/mappings/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sku: currentQmSku, mappings })
|
||||
});
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
|
||||
if (currentQmOrderNumber) openDashOrderDetail(currentQmOrderNumber);
|
||||
openQuickMap({
|
||||
sku,
|
||||
productName,
|
||||
isDirect,
|
||||
directInfo: isDirect ? { codmat: details[0].codmat, denumire: details[0].denumire } : null,
|
||||
prefill: (!isDirect && details?.length) ? details.map(d => ({ codmat: d.codmat, cantitate: d.cantitate_roa, denumire: d.denumire })) : null,
|
||||
onSave: () => {
|
||||
if (orderNumber) openDashOrderDetail(orderNumber);
|
||||
loadDashOrders();
|
||||
} else {
|
||||
const msg = data.detail || data.error || 'Unknown';
|
||||
document.getElementById('qmPctWarning').textContent = msg;
|
||||
document.getElementById('qmPctWarning').style.display = '';
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ let runsPage = 1;
|
||||
let logPollTimer = null;
|
||||
let currentFilter = 'all';
|
||||
let ordersPage = 1;
|
||||
let currentQmSku = '';
|
||||
let currentQmOrderNumber = '';
|
||||
let ordersSortColumn = 'order_date';
|
||||
let ordersSortDirection = 'desc';
|
||||
|
||||
@@ -310,7 +308,7 @@ function renderCodmatCell(item) {
|
||||
}
|
||||
// Multi-CODMAT: compact list
|
||||
return item.codmat_details.map(d =>
|
||||
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa} (${d.procent_pret}%)</span></div>`
|
||||
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa}</span></div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
@@ -384,8 +382,8 @@ async function openOrderDetail(orderNumber) {
|
||||
if (mobileContainer) {
|
||||
mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => {
|
||||
const codmatList = item.codmat_details?.length
|
||||
? item.codmat_details.map(d => `<span class="dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(d.codmat)}</span>`).join(' ')
|
||||
: `<span class="dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(item.codmat || '–')}</span>`;
|
||||
? item.codmat_details.map(d => `<span class="dif-codmat-link" onclick="openLogsQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(d.codmat)}</span>`).join(' ')
|
||||
: `<span class="dif-codmat-link" onclick="openLogsQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(item.codmat || '–')}</span>`;
|
||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
||||
return `<div class="dif-item">
|
||||
<div class="dif-row">
|
||||
@@ -403,7 +401,7 @@ async function openOrderDetail(orderNumber) {
|
||||
|
||||
document.getElementById('detailItemsBody').innerHTML = items.map(item => {
|
||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
||||
const codmatCell = `<span class="codmat-link" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}')" title="Click pentru mapare">${renderCodmatCell(item)}</span>`;
|
||||
const codmatCell = `<span class="codmat-link" onclick="openLogsQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}')" title="Click pentru mapare">${renderCodmatCell(item)}</span>`;
|
||||
return `<tr>
|
||||
<td><code>${esc(item.sku)}</code></td>
|
||||
<td>${esc(item.product_name || '-')}</td>
|
||||
@@ -419,146 +417,17 @@ async function openOrderDetail(orderNumber) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Quick Map Modal (from order detail) ──────────
|
||||
// ── Quick Map Modal (uses shared openQuickMap) ───
|
||||
|
||||
let qmAcTimeout = null;
|
||||
|
||||
function openQuickMap(sku, productName, orderNumber) {
|
||||
currentQmSku = sku;
|
||||
currentQmOrderNumber = orderNumber;
|
||||
document.getElementById('qmSku').textContent = sku;
|
||||
document.getElementById('qmProductName').textContent = productName || '-';
|
||||
document.getElementById('qmPctWarning').style.display = 'none';
|
||||
|
||||
// Reset CODMAT lines
|
||||
const container = document.getElementById('qmCodmatLines');
|
||||
container.innerHTML = '';
|
||||
addQmCodmatLine();
|
||||
|
||||
// Show quick map on top of order detail (modal stacking)
|
||||
new bootstrap.Modal(document.getElementById('quickMapModal')).show();
|
||||
}
|
||||
|
||||
function addQmCodmatLine() {
|
||||
const container = document.getElementById('qmCodmatLines');
|
||||
const idx = container.children.length;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'border rounded p-2 mb-2 qm-line';
|
||||
div.innerHTML = `
|
||||
<div class="mb-2 position-relative">
|
||||
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label>
|
||||
<input type="text" class="form-control form-control-sm qm-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none qm-ac-dropdown"></div>
|
||||
<small class="text-muted qm-selected"></small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
|
||||
<input type="number" class="form-control form-control-sm qm-cantitate" value="1" step="0.001" min="0.001">
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label>
|
||||
<input type="number" class="form-control form-control-sm qm-procent" value="100" step="0.01" min="0" max="100">
|
||||
</div>
|
||||
<div class="col-2 d-flex align-items-end">
|
||||
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.qm-line').remove()"><i class="bi bi-x"></i></button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
|
||||
// Setup autocomplete on the new input
|
||||
const input = div.querySelector('.qm-codmat');
|
||||
const dropdown = div.querySelector('.qm-ac-dropdown');
|
||||
const selected = div.querySelector('.qm-selected');
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(qmAcTimeout);
|
||||
qmAcTimeout = setTimeout(() => qmAutocomplete(input, dropdown, selected), 250);
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
||||
});
|
||||
}
|
||||
|
||||
async function qmAutocomplete(input, dropdown, selectedEl) {
|
||||
const q = input.value;
|
||||
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
|
||||
const data = await res.json();
|
||||
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
||||
|
||||
dropdown.innerHTML = data.results.map(r =>
|
||||
`<div class="autocomplete-item" onmousedown="qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
||||
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
dropdown.classList.remove('d-none');
|
||||
} catch { dropdown.classList.add('d-none'); }
|
||||
}
|
||||
|
||||
function qmSelectArticle(el, codmat, label) {
|
||||
const line = el.closest('.qm-line');
|
||||
line.querySelector('.qm-codmat').value = codmat;
|
||||
line.querySelector('.qm-selected').textContent = label;
|
||||
line.querySelector('.qm-ac-dropdown').classList.add('d-none');
|
||||
}
|
||||
|
||||
async function saveQuickMapping() {
|
||||
const lines = document.querySelectorAll('.qm-line');
|
||||
const mappings = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const codmat = line.querySelector('.qm-codmat').value.trim();
|
||||
const cantitate = parseFloat(line.querySelector('.qm-cantitate').value) || 1;
|
||||
const procent = parseFloat(line.querySelector('.qm-procent').value) || 100;
|
||||
if (!codmat) continue;
|
||||
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
|
||||
}
|
||||
|
||||
if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
|
||||
|
||||
// Validate percentage sum 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('qmPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
|
||||
document.getElementById('qmPctWarning').style.display = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
document.getElementById('qmPctWarning').style.display = 'none';
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (mappings.length === 1) {
|
||||
res = await fetch('/api/mappings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sku: currentQmSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
|
||||
});
|
||||
} else {
|
||||
res = await fetch('/api/mappings/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sku: currentQmSku, mappings })
|
||||
});
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
|
||||
// Refresh order detail items in the still-open modal
|
||||
if (currentQmOrderNumber) openOrderDetail(currentQmOrderNumber);
|
||||
// Refresh orders view
|
||||
function openLogsQuickMap(sku, productName, orderNumber) {
|
||||
openQuickMap({
|
||||
sku,
|
||||
productName,
|
||||
onSave: () => {
|
||||
if (orderNumber) openOrderDetail(orderNumber);
|
||||
loadRunOrders(currentRunId, currentFilter, ordersPage);
|
||||
} else {
|
||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────────────
|
||||
|
||||
@@ -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">✓ 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}">⋮</button>`
|
||||
: `<button class="context-menu-trigger" data-sku="${esc(m.sku)}" data-codmat="${esc(m.codmat)}" data-cantitate="${m.cantitate_roa}">⋮</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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,24 +368,17 @@ function addCodmatLine() {
|
||||
const container = document.getElementById('codmatLines');
|
||||
const idx = container.children.length;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'border rounded p-2 mb-2 codmat-line';
|
||||
div.className = 'qm-line codmat-line';
|
||||
div.innerHTML = `
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col position-relative">
|
||||
<input type="text" class="form-control form-control-sm cl-codmat" placeholder="Cauta CODMAT..." autocomplete="off" data-idx="${idx}">
|
||||
<div class="qm-row">
|
||||
<div class="qm-codmat-wrap position-relative">
|
||||
<input type="text" class="form-control form-control-sm cl-codmat" placeholder="CODMAT..." autocomplete="off" data-idx="${idx}">
|
||||
<div class="autocomplete-dropdown d-none cl-ac-dropdown"></div>
|
||||
<small class="text-muted cl-selected"></small>
|
||||
</div>
|
||||
<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>
|
||||
<input type="number" class="form-control form-control-sm cl-cantitate" value="1" step="0.001" min="0.001" title="Cantitate ROA" style="width:70px">
|
||||
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger qm-rm-btn" onclick="this.closest('.codmat-line').remove()"><i class="bi bi-x"></i></button>` : '<span style="width:30px"></span>'}
|
||||
</div>
|
||||
<div class="qm-selected text-muted cl-selected" style="font-size:0.75rem;padding-left:2px"></div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
|
||||
@@ -412,22 +432,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 +452,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 +480,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 +532,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 +579,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 +587,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 +762,3 @@ function handleMappingConflict(data) {
|
||||
if (warn) { warn.textContent = msg; warn.style.display = ''; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -204,6 +204,154 @@ function renderMobileSegmented(containerId, pills, onSelect) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Shared Quick Map Modal ────────────────────────
|
||||
let _qmOnSave = null;
|
||||
let _qmAcTimeout = null;
|
||||
|
||||
/**
|
||||
* Open the shared quick-map modal.
|
||||
* @param {object} opts
|
||||
* @param {string} opts.sku
|
||||
* @param {string} opts.productName
|
||||
* @param {Array} [opts.prefill] - [{codmat, cantitate, denumire}]
|
||||
* @param {boolean}[opts.isDirect] - true if SKU=CODMAT direct
|
||||
* @param {object} [opts.directInfo] - {codmat, denumire} for direct SKU info
|
||||
* @param {function} opts.onSave - callback(sku, mappings) after successful save
|
||||
*/
|
||||
function openQuickMap(opts) {
|
||||
_qmOnSave = opts.onSave || null;
|
||||
document.getElementById('qmSku').textContent = opts.sku;
|
||||
document.getElementById('qmProductName').textContent = opts.productName || '-';
|
||||
document.getElementById('qmPctWarning').style.display = 'none';
|
||||
|
||||
const container = document.getElementById('qmCodmatLines');
|
||||
container.innerHTML = '';
|
||||
|
||||
const directInfo = document.getElementById('qmDirectInfo');
|
||||
const saveBtn = document.getElementById('qmSaveBtn');
|
||||
|
||||
if (opts.isDirect && opts.directInfo) {
|
||||
if (directInfo) {
|
||||
directInfo.innerHTML = `<i class="bi bi-info-circle"></i> SKU = CODMAT direct in nomenclator (<code>${esc(opts.directInfo.codmat)}</code> — ${esc(opts.directInfo.denumire || '')}).<br><small class="text-muted">Poti suprascrie cu un alt CODMAT daca e necesar (ex: reambalare).</small>`;
|
||||
directInfo.style.display = '';
|
||||
}
|
||||
if (saveBtn) saveBtn.textContent = 'Suprascrie mapare';
|
||||
addQmCodmatLine();
|
||||
} else {
|
||||
if (directInfo) directInfo.style.display = 'none';
|
||||
if (saveBtn) saveBtn.textContent = 'Salveaza';
|
||||
|
||||
if (opts.prefill && opts.prefill.length > 0) {
|
||||
opts.prefill.forEach(d => addQmCodmatLine({ codmat: d.codmat, cantitate: d.cantitate, denumire: d.denumire }));
|
||||
} else {
|
||||
addQmCodmatLine();
|
||||
}
|
||||
}
|
||||
|
||||
new bootstrap.Modal(document.getElementById('quickMapModal')).show();
|
||||
}
|
||||
|
||||
function addQmCodmatLine(prefill) {
|
||||
const container = document.getElementById('qmCodmatLines');
|
||||
const idx = container.children.length;
|
||||
const codmatVal = prefill?.codmat || '';
|
||||
const cantVal = prefill?.cantitate || 1;
|
||||
const denumireVal = prefill?.denumire || '';
|
||||
const div = document.createElement('div');
|
||||
div.className = 'qm-line';
|
||||
div.innerHTML = `
|
||||
<div class="qm-row">
|
||||
<div class="qm-codmat-wrap position-relative">
|
||||
<input type="text" class="form-control form-control-sm qm-codmat" placeholder="CODMAT..." autocomplete="off" value="${esc(codmatVal)}">
|
||||
<div class="autocomplete-dropdown d-none qm-ac-dropdown"></div>
|
||||
</div>
|
||||
<input type="number" class="form-control form-control-sm qm-cantitate" value="${cantVal}" step="0.001" min="0.001" title="Cantitate ROA" style="width:70px">
|
||||
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger qm-rm-btn" onclick="this.closest('.qm-line').remove()"><i class="bi bi-x"></i></button>` : '<span style="width:30px"></span>'}
|
||||
</div>
|
||||
<div class="qm-selected text-muted" style="font-size:0.75rem;padding-left:2px">${esc(denumireVal)}</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
|
||||
const input = div.querySelector('.qm-codmat');
|
||||
const dropdown = div.querySelector('.qm-ac-dropdown');
|
||||
const selected = div.querySelector('.qm-selected');
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(_qmAcTimeout);
|
||||
_qmAcTimeout = setTimeout(() => _qmAutocomplete(input, dropdown, selected), 250);
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
||||
});
|
||||
}
|
||||
|
||||
async function _qmAutocomplete(input, dropdown, selectedEl) {
|
||||
const q = input.value;
|
||||
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
|
||||
const data = await res.json();
|
||||
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
||||
|
||||
dropdown.innerHTML = data.results.map(r =>
|
||||
`<div class="autocomplete-item" onmousedown="_qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
||||
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
dropdown.classList.remove('d-none');
|
||||
} catch { dropdown.classList.add('d-none'); }
|
||||
}
|
||||
|
||||
function _qmSelectArticle(el, codmat, label) {
|
||||
const line = el.closest('.qm-line');
|
||||
line.querySelector('.qm-codmat').value = codmat;
|
||||
line.querySelector('.qm-selected').textContent = label;
|
||||
line.querySelector('.qm-ac-dropdown').classList.add('d-none');
|
||||
}
|
||||
|
||||
async function saveQuickMapping() {
|
||||
const lines = document.querySelectorAll('#qmCodmatLines .qm-line');
|
||||
const mappings = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const codmat = line.querySelector('.qm-codmat').value.trim();
|
||||
const cantitate = parseFloat(line.querySelector('.qm-cantitate').value) || 1;
|
||||
if (!codmat) continue;
|
||||
mappings.push({ codmat, cantitate_roa: cantitate });
|
||||
}
|
||||
|
||||
if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
|
||||
|
||||
const sku = document.getElementById('qmSku').textContent;
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (mappings.length === 1) {
|
||||
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 })
|
||||
});
|
||||
} else {
|
||||
res = await fetch('/api/mappings/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sku, mappings })
|
||||
});
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
|
||||
if (_qmOnSave) _qmOnSave(sku, mappings);
|
||||
} else {
|
||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dot helper ────────────────────────────────────
|
||||
function statusDot(status) {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<html lang="ro" style="color-scheme: light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -7,7 +7,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||
{% set rp = request.scope.get('root_path', '') %}
|
||||
<link href="{{ rp }}/static/css/style.css?v=14" rel="stylesheet">
|
||||
<link href="{{ rp }}/static/css/style.css?v=17" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Top Navbar -->
|
||||
@@ -27,9 +27,41 @@
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Shared Quick Map Modal -->
|
||||
<div class="modal fade" id="quickMapModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Mapeaza SKU: <code id="qmSku"></code></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="margin-bottom:8px; font-size:0.85rem">
|
||||
<small class="text-muted">Produs:</small> <strong id="qmProductName"></strong>
|
||||
</div>
|
||||
<div class="qm-row" style="font-size:0.7rem; color:#9ca3af; padding:0 0 2px">
|
||||
<span style="flex:1">CODMAT</span>
|
||||
<span style="width:70px">Cant.</span>
|
||||
<span style="width:30px"></span>
|
||||
</div>
|
||||
<div id="qmCodmatLines"></div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="addQmCodmatLine()" style="font-size:0.8rem; padding:2px 10px">
|
||||
+ CODMAT
|
||||
</button>
|
||||
<div id="qmDirectInfo" class="alert alert-info mt-2" style="display:none; font-size:0.85rem; padding:8px 12px;"></div>
|
||||
<div id="qmPctWarning" class="text-danger mt-2" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
|
||||
<button type="button" class="btn btn-primary" id="qmSaveBtn" onclick="saveQuickMapping()">Salveaza</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>window.ROOT_PATH = "{{ rp }}";</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ rp }}/static/js/shared.js?v=11"></script>
|
||||
<script src="{{ rp }}/static/js/shared.js?v=12"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -135,12 +135,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="detailTotals" class="d-flex gap-3 mb-2 flex-wrap" style="font-size:0.875rem">
|
||||
<span><small class="text-muted">Valoare:</small> <strong id="detailItemsTotal">-</strong></span>
|
||||
<span id="detailDiscountWrap"><small class="text-muted">Discount:</small> <strong id="detailDiscount">-</strong></span>
|
||||
<span id="detailDeliveryWrap"><small class="text-muted">Transport:</small> <strong id="detailDeliveryCost">-</strong></span>
|
||||
<span><small class="text-muted">Total:</small> <strong id="detailOrderTotal">-</strong></span>
|
||||
</div>
|
||||
<div class="table-responsive d-none d-md-block">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
@@ -148,16 +142,19 @@
|
||||
<th>SKU</th>
|
||||
<th>Produs</th>
|
||||
<th>CODMAT</th>
|
||||
<th>Cant.</th>
|
||||
<th>Pret</th>
|
||||
<th class="text-end">Cant.</th>
|
||||
<th class="text-end">Pret</th>
|
||||
<th class="text-end">TVA%</th>
|
||||
<th class="text-end">Valoare</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="detailItemsBody">
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="detailReceipt" class="d-flex flex-wrap gap-2 mt-1 justify-content-end"></div>
|
||||
</div>
|
||||
<div class="d-md-none" id="detailItemsMobile"></div>
|
||||
<div id="detailReceiptMobile" class="d-flex flex-wrap gap-2 mt-1 d-md-none justify-content-end"></div>
|
||||
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -168,41 +165,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Quick Map Modal (used from order detail) -->
|
||||
<div class="modal fade" id="quickMapModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Mapeaza SKU: <code id="qmSku"></code></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="margin-bottom:8px; font-size:0.85rem">
|
||||
<small class="text-muted">Produs:</small> <strong id="qmProductName"></strong>
|
||||
</div>
|
||||
<div class="qm-row" style="font-size:0.7rem; color:#9ca3af; padding:0 0 2px">
|
||||
<span style="flex:1">CODMAT</span>
|
||||
<span style="width:70px">Cant.</span>
|
||||
<span style="width:70px">%</span>
|
||||
<span style="width:30px"></span>
|
||||
</div>
|
||||
<div id="qmCodmatLines">
|
||||
<!-- Dynamic CODMAT lines -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="addQmCodmatLine()" style="font-size:0.8rem; padding:2px 10px">
|
||||
+ CODMAT
|
||||
</button>
|
||||
<div id="qmDirectInfo" class="alert alert-info mt-2" style="display:none; font-size:0.85rem; padding:8px 12px;"></div>
|
||||
<div id="qmPctWarning" class="text-danger mt-2" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
|
||||
<button type="button" class="btn btn-primary" id="qmSaveBtn" onclick="saveQuickMapping()">Salveaza</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=17"></script>
|
||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=24"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -151,37 +151,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Quick Map Modal (used from order detail) -->
|
||||
<div class="modal fade" id="quickMapModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Mapeaza SKU: <code id="qmSku"></code></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Produs web:</small> <strong id="qmProductName"></strong>
|
||||
</div>
|
||||
<div id="qmCodmatLines">
|
||||
<!-- Dynamic CODMAT lines -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addQmCodmatLine()">
|
||||
<i class="bi bi-plus"></i> Adauga CODMAT
|
||||
</button>
|
||||
<div id="qmPctWarning" class="text-danger mt-2" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveQuickMapping()">Salveaza</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden field for pre-selected run from URL/server -->
|
||||
<input type="hidden" id="preselectedRun" value="{{ selected_run }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=9"></script>
|
||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=11"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -69,27 +61,31 @@
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal with multi-CODMAT support (R11) -->
|
||||
<div class="modal fade" id="addModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal fade" id="addModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="addModalTitle">Adauga Mapare</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">SKU</label>
|
||||
<input type="text" class="form-control" id="inputSku" placeholder="Ex: 8714858124284">
|
||||
<div class="mb-2">
|
||||
<label class="form-label form-label-sm mb-1">SKU</label>
|
||||
<input type="text" class="form-control form-control-sm" id="inputSku" placeholder="Ex: 8714858124284">
|
||||
</div>
|
||||
<div class="mb-2" id="addModalProductName" style="display:none;">
|
||||
<small class="text-muted">Produs web:</small> <strong id="inputProductName"></strong>
|
||||
<div id="addModalProductName" style="display:none; margin-bottom:8px; font-size:0.85rem">
|
||||
<small class="text-muted">Produs:</small> <strong id="inputProductName"></strong>
|
||||
</div>
|
||||
<div class="qm-row" style="font-size:0.7rem; color:#9ca3af; padding:0 0 2px">
|
||||
<span style="flex:1">CODMAT</span>
|
||||
<span style="width:70px">Cant.</span>
|
||||
<span style="width:30px"></span>
|
||||
</div>
|
||||
<hr>
|
||||
<div id="codmatLines">
|
||||
<!-- Dynamic CODMAT lines will be added here -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addCodmatLine()">
|
||||
<i class="bi bi-plus"></i> Adauga CODMAT
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="addCodmatLine()" style="font-size:0.8rem; padding:2px 10px">
|
||||
+ CODMAT
|
||||
</button>
|
||||
<div id="pctWarning" class="text-danger mt-2" style="display:none;"></div>
|
||||
</div>
|
||||
@@ -110,7 +106,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 +150,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=10"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -65,39 +65,10 @@
|
||||
</div>
|
||||
<div id="skusPagBottom" class="pag-strip pag-strip-bottom"></div>
|
||||
|
||||
<!-- Map SKU Modal with multi-CODMAT support (R11) -->
|
||||
<div class="modal fade" id="mapModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Mapeaza SKU: <code id="mapSku"></code></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Produs web:</small> <strong id="mapProductName"></strong>
|
||||
</div>
|
||||
<div id="mapCodmatLines">
|
||||
<!-- Dynamic CODMAT lines -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addMapCodmatLine()">
|
||||
<i class="bi bi-plus"></i> Adauga CODMAT
|
||||
</button>
|
||||
<div id="mapPctWarning" class="text-danger mt-2" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveQuickMap()">Salveaza</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let currentMapSku = '';
|
||||
let mapAcTimeout = null;
|
||||
let currentPage = 1;
|
||||
let skuStatusFilter = 'unresolved';
|
||||
let missingPerPage = 20;
|
||||
@@ -223,7 +194,7 @@ function renderMissingSkusTable(skus, data) {
|
||||
<td class="truncate" style="max-width:300px">${esc(s.product_name || '-')}</td>
|
||||
<td>
|
||||
${!s.resolved
|
||||
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza">
|
||||
? `<a href="#" class="btn-map-icon" onclick="event.stopPropagation(); openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
</a>`
|
||||
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`}
|
||||
@@ -234,7 +205,7 @@ function renderMissingSkusTable(skus, data) {
|
||||
if (mobileList) {
|
||||
mobileList.innerHTML = skus.map(s => {
|
||||
const actionHtml = !s.resolved
|
||||
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;"><i class="bi bi-link-45deg"></i></a>`
|
||||
? `<a href="#" class="btn-map-icon" onclick="event.stopPropagation(); openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;"><i class="bi bi-link-45deg"></i></a>`
|
||||
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`;
|
||||
const flatRowAttrs = !s.resolved
|
||||
? ` onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')" style="cursor:pointer"`
|
||||
@@ -259,132 +230,14 @@ function renderPagination(data) {
|
||||
if (bot) bot.innerHTML = pagHtml;
|
||||
}
|
||||
|
||||
// ── Multi-CODMAT Map Modal ───────────────────────
|
||||
// ── Map Modal (uses shared openQuickMap) ─────────
|
||||
|
||||
function openMapModal(sku, productName) {
|
||||
currentMapSku = sku;
|
||||
document.getElementById('mapSku').textContent = sku;
|
||||
document.getElementById('mapProductName').textContent = productName || '-';
|
||||
document.getElementById('mapPctWarning').style.display = 'none';
|
||||
|
||||
const container = document.getElementById('mapCodmatLines');
|
||||
container.innerHTML = '';
|
||||
addMapCodmatLine();
|
||||
|
||||
new bootstrap.Modal(document.getElementById('mapModal')).show();
|
||||
}
|
||||
|
||||
function addMapCodmatLine() {
|
||||
const container = document.getElementById('mapCodmatLines');
|
||||
const idx = container.children.length;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'border rounded p-2 mb-2 mc-line';
|
||||
div.innerHTML = `
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col position-relative">
|
||||
<input type="text" class="form-control form-control-sm mc-codmat" placeholder="Cauta CODMAT..." autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none mc-ac-dropdown"></div>
|
||||
<small class="text-muted mc-selected"></small>
|
||||
</div>
|
||||
<div class="col-auto" style="width:90px">
|
||||
<input type="number" class="form-control form-control-sm mc-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 mc-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('.mc-line').remove()"><i class="bi bi-x"></i></button>` : '<div style="width:31px"></div>'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
|
||||
const input = div.querySelector('.mc-codmat');
|
||||
const dropdown = div.querySelector('.mc-ac-dropdown');
|
||||
const selected = div.querySelector('.mc-selected');
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(mapAcTimeout);
|
||||
mapAcTimeout = setTimeout(() => mcAutocomplete(input, dropdown, selected), 250);
|
||||
openQuickMap({
|
||||
sku,
|
||||
productName,
|
||||
onSave: () => { loadMissingSkus(currentPage); }
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
||||
});
|
||||
}
|
||||
|
||||
async function mcAutocomplete(input, dropdown, selectedEl) {
|
||||
const q = input.value;
|
||||
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
|
||||
const data = await res.json();
|
||||
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
||||
|
||||
dropdown.innerHTML = data.results.map(r =>
|
||||
`<div class="autocomplete-item" onmousedown="mcSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
||||
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
dropdown.classList.remove('d-none');
|
||||
} catch { dropdown.classList.add('d-none'); }
|
||||
}
|
||||
|
||||
function mcSelectArticle(el, codmat, label) {
|
||||
const line = el.closest('.mc-line');
|
||||
line.querySelector('.mc-codmat').value = codmat;
|
||||
line.querySelector('.mc-selected').textContent = label;
|
||||
line.querySelector('.mc-ac-dropdown').classList.add('d-none');
|
||||
}
|
||||
|
||||
async function saveQuickMap() {
|
||||
const lines = document.querySelectorAll('.mc-line');
|
||||
const mappings = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const codmat = line.querySelector('.mc-codmat').value.trim();
|
||||
const cantitate = parseFloat(line.querySelector('.mc-cantitate').value) || 1;
|
||||
const procent = parseFloat(line.querySelector('.mc-procent').value) || 100;
|
||||
if (!codmat) continue;
|
||||
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
|
||||
}
|
||||
|
||||
if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
|
||||
|
||||
if (mappings.length > 1) {
|
||||
const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
|
||||
if (Math.abs(totalPct - 100) > 0.01) {
|
||||
document.getElementById('mapPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
|
||||
document.getElementById('mapPctWarning').style.display = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
document.getElementById('mapPctWarning').style.display = 'none';
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (mappings.length === 1) {
|
||||
res = await fetch('/api/mappings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sku: currentMapSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
|
||||
});
|
||||
} else {
|
||||
res = await fetch('/api/mappings/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sku: currentMapSku, mappings })
|
||||
});
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
|
||||
loadMissingSkus(currentPage);
|
||||
} else {
|
||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function exportMissingCsv() {
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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, '') 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
|
||||
|
||||
|
||||
3
api/database-scripts/07_drop_procent_pret.sql
Normal file
3
api/database-scripts/07_drop_procent_pret.sql
Normal 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;
|
||||
Reference in New Issue
Block a user