Compare commits

...

21 Commits

Author SHA1 Message Date
Claude Agent
6c72be5f86 fix: add ROOT_PATH prefix to missing SKUs CSV export URL for IIS proxy
The export CSV button used a hardcoded /api/validate/missing-skus-csv path,
bypassing the IIS /gomag reverse proxy prefix. Also add changelog comments
to PACK_COMENZI and PACK_FACTURARE for the duplicate CODMAT discrimination.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:37:11 +00:00
Claude Agent
9a545617c2 chore: add version comments (20.03.2026) to pack_comenzi and pack_facturare
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:37:09 +00:00
Claude Agent
95565af4cd fix: discriminare pe PRET+SIGN(CANTITATE) pentru duplicate CODMAT pe comanda
Permite articole duplicate cu preturi diferite pe aceeasi comanda (kit + direct
cu acelasi CODMAT) si articol + retur la acelasi pret. Cheia de unicitate devine
(ID_COMANDA, ID_ARTICOL, PTVA, PRET, SIGN(CANTITATE)).

Modificari in 8 locuri: duplicate check (pack_comenzi), cursor_comanda factura/aviz,
cursor_lucrare ambele ramuri, adauga_articol_lucrare_pret, adauga_articol_factura,
inchide_comanda. Zero signatura schimbata, zero schema change, zero VFP impact.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:32:55 +00:00
Claude Agent
93314e7a6a fix: bridge SKU→policy mapping for ARTICOLE_TERTI mapped articles
codmat_policy_map had CODMAT keys only, but build_articles_json looks
up by GoMag SKU — mapped articles like FRSETP250 never got per-article
id_pol, causing Oracle to use default sales policy and fail when price
exists only in production policy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 00:16:37 +00:00
Claude Agent
d802a08512 mapari sql 2026-03-19 23:57:52 +00:00
Claude Agent
c7ac3e5c00 mapari sql 2026-03-19 23:57:41 +00:00
Claude Agent
f68adbb072 chore: bump CSS cache version to v=17 2026-03-19 23:29:25 +00:00
Claude Agent
eccd9dd753 style(design): FINDING-008 — add color-scheme: light declaration 2026-03-19 23:29:17 +00:00
Claude Agent
73fe53394e style(design): FINDING-007 — add text-wrap: balance to headings 2026-03-19 23:29:09 +00:00
Claude Agent
039cbb1438 style(design): FINDING-005 — increase filter pill padding for 44px touch target 2026-03-19 23:28:48 +00:00
Claude Agent
1353d4b8cf style(design): FINDING-004 — add tabular-nums to table cells for aligned numbers 2026-03-19 23:28:39 +00:00
Claude Agent
f1c7625ec7 style(design): FINDING-003 — add focus ring to search input, remove outline:none 2026-03-19 23:28:30 +00:00
Claude Agent
a898666869 style(design): FINDING-002 — increase checkbox size from 13px to 18px 2026-03-19 23:28:08 +00:00
Claude Agent
1cea8cace0 style(design): FINDING-001 — increase pagination button size to 44px touch target 2026-03-19 23:27:56 +00:00
Claude Agent
327f0e6ea2 refactor(ui): unify mapping form into single shared component
Extract the SKU mapping modal (HTML + JS) from dashboard, logs, and
missing_skus into a shared component in base.html + shared.js. All pages
now use the same compact layout with CODMAT/Cant. column headers.

- Fix missing_skus backdrop bug: event.stopPropagation() on icon click
  prevents double modal open from <a> + <tr> event bubbling
- Shrink mappings addModal from modal-lg to regular size with compact layout
- Remove ~500 lines of duplicated modal HTML and JS across 4 pages
- Each page keeps a thin wrapper (openDashQuickMap, openLogsQuickMap,
  openMapModal) that calls shared openQuickMap() with an onSave callback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:21:43 +00:00
Claude Agent
c806ca2d81 fix(ui): format price sync timestamps as dd.mm.yyyy hh24:mi:ss Bucharest time
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:55:38 +00:00
Claude Agent
952989d34b fix: remove procent_pret from quick-map modals, fix catalog price sync
Remove leftover procent_pret input fields and validation from dashboard,
logs and missing_skus quick-map modals (missed in 9e5901a). Fix GoMag
Products API returning dict-keyed products instead of array, which caused
catalog price sync to find 0 products with SKU.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:53:36 +00:00
Claude Agent
aa6e035c02 fix(oracle): use na.cont instead of na.cont_vanzare in kit pricing
The column cont_vanzare does not exist in nom_articole. The correct
column name is cont, consistent with all Python code references.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:32:20 +00:00
Claude Agent
9e5901a8fb feat(pricing): kit/pachet pricing with price list lookup, replace procent_pret
- Oracle PL/SQL: kit pricing logic with Mode A (distributed discount) and
  Mode B (separate discount line), dual policy support, PRETURI_CU_TVA flag
- Eliminate procent_pret from entire stack (Oracle, Python, JS, HTML)
- New settings: kit_pricing_mode, kit_discount_codmat, price_sync_enabled
- Settings UI: cards for Kit Pricing and Price Sync configuration
- Mappings UI: kit badges with lazy-loaded component prices from price list
- Price sync from orders: auto-update ROA prices when web prices differ
- Catalog price sync: new service to sync all GoMag product prices to ROA
- Kit component price validation: pre-check prices before import
- New endpoint GET /api/mappings/{sku}/prices for component price display
- New endpoints POST /api/price-sync/start, GET status, GET history
- DDL script 07_drop_procent_pret.sql (run after deploy confirmation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:29:18 +00:00
Claude Agent
bedb93affe feat(dashboard): receipt-style order detail with inline transport and discount rows
Replace totals bar + VAT subtotals table with transport/discount as table
rows (with CODMAT from settings, proper VAT rate) and a single Total footer.
Right-align qty/price/TVA columns, thousands separator (ro-RO), discount
shown as qty=-1 price=positive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:44:56 +00:00
Claude Agent
47e77e7241 Merge branch 'feat/multi-gestiune-stock' into main 2026-03-18 16:24:03 +00:00
28 changed files with 1870 additions and 876 deletions

View File

@@ -34,7 +34,7 @@ python api/test_integration.py # cu Oracle
1. Download GoMag API → JSON → parse → validate SKU-uri → import Oracle 1. Download GoMag API → JSON → parse → validate SKU-uri → import Oracle
2. Ordinea: **parteneri** (cauta/creeaza) → **adrese****comanda****factura cache** 2. Ordinea: **parteneri** (cauta/creeaza) → **adrese****comanda****factura cache**
3. SKU lookup: ARTICOLE_TERTI (mapped) are prioritate fata de NOM_ARTICOLE (direct) 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 5. Comenzi anulate (GoMag statusId=7): verifica daca au factura inainte de stergere din Oracle
### Statusuri comenzi ### 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) - 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 - 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 ### Invoice cache
- Coloanele `factura_*` pe `orders` (SQLite), populate lazy din Oracle (`vanzari WHERE sters=0`) - Coloanele `factura_*` pe `orders` (SQLite), populate lazy din Oracle (`vanzari WHERE sters=0`)
- Refresh complet: verifica facturi noi + facturi sterse + comenzi sterse din ROA - Refresh complet: verifica facturi noi + facturi sterse + comenzi sterse din ROA

View File

@@ -152,6 +152,18 @@ CREATE TABLE IF NOT EXISTS app_settings (
value TEXT 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 ( CREATE TABLE IF NOT EXISTS order_items (
order_number TEXT, order_number TEXT,
sku TEXT, sku TEXT,

View File

@@ -6,6 +6,7 @@ from pydantic import BaseModel, validator
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import io import io
import asyncio
from ..services import mapping_service, sqlite_service from ..services import mapping_service, sqlite_service
@@ -19,7 +20,6 @@ class MappingCreate(BaseModel):
sku: str sku: str
codmat: str codmat: str
cantitate_roa: float = 1 cantitate_roa: float = 1
procent_pret: float = 100
@validator('sku', 'codmat') @validator('sku', 'codmat')
def not_empty(cls, v): def not_empty(cls, v):
@@ -29,14 +29,12 @@ class MappingCreate(BaseModel):
class MappingUpdate(BaseModel): class MappingUpdate(BaseModel):
cantitate_roa: Optional[float] = None cantitate_roa: Optional[float] = None
procent_pret: Optional[float] = None
activ: Optional[int] = None activ: Optional[int] = None
class MappingEdit(BaseModel): class MappingEdit(BaseModel):
new_sku: str new_sku: str
new_codmat: str new_codmat: str
cantitate_roa: float = 1 cantitate_roa: float = 1
procent_pret: float = 100
@validator('new_sku', 'new_codmat') @validator('new_sku', 'new_codmat')
def not_empty(cls, v): def not_empty(cls, v):
@@ -47,7 +45,6 @@ class MappingEdit(BaseModel):
class MappingLine(BaseModel): class MappingLine(BaseModel):
codmat: str codmat: str
cantitate_roa: float = 1 cantitate_roa: float = 1
procent_pret: float = 100
class MappingBatchCreate(BaseModel): class MappingBatchCreate(BaseModel):
sku: str sku: str
@@ -63,11 +60,10 @@ async def mappings_page(request: Request):
@router.get("/api/mappings") @router.get("/api/mappings")
async def list_mappings(search: str = "", page: int = 1, per_page: int = 50, async def list_mappings(search: str = "", page: int = 1, per_page: int = 50,
sort_by: str = "sku", sort_dir: str = "asc", 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, result = mapping_service.get_mappings(search=search, page=page, per_page=per_page,
sort_by=sort_by, sort_dir=sort_dir, sort_by=sort_by, sort_dir=sort_dir,
show_deleted=show_deleted, show_deleted=show_deleted)
pct_filter=pct_filter)
# Merge product names from web_products (R4) # Merge product names from web_products (R4)
skus = list({m["sku"] for m in result.get("mappings", [])}) skus = list({m["sku"] for m in result.get("mappings", [])})
product_names = await sqlite_service.get_web_products_batch(skus) 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"], "") m["product_name"] = product_names.get(m["sku"], "")
# Ensure counts key is always present # Ensure counts key is always present
if "counts" not in result: if "counts" not in result:
result["counts"] = {"total": 0, "complete": 0, "incomplete": 0} result["counts"] = {"total": 0}
return result return result
@router.post("/api/mappings") @router.post("/api/mappings")
async def create_mapping(data: MappingCreate): async def create_mapping(data: MappingCreate):
try: 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 # Mark SKU as resolved in missing_skus tracking
await sqlite_service.resolve_missing_sku(data.sku) await sqlite_service.resolve_missing_sku(data.sku)
return {"success": True, **result} return {"success": True, **result}
@@ -97,7 +93,7 @@ async def create_mapping(data: MappingCreate):
@router.put("/api/mappings/{sku}/{codmat}") @router.put("/api/mappings/{sku}/{codmat}")
def update_mapping(sku: str, codmat: str, data: MappingUpdate): def update_mapping(sku: str, codmat: str, data: MappingUpdate):
try: 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} return {"success": updated}
except Exception as e: except Exception as e:
return {"success": False, "error": str(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): def edit_mapping(sku: str, codmat: str, data: MappingEdit):
try: try:
result = mapping_service.edit_mapping(sku, codmat, data.new_sku, data.new_codmat, 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} return {"success": result}
except Exception as e: except Exception as e:
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
@@ -133,16 +129,10 @@ async def create_batch_mapping(data: MappingBatchCreate):
if not data.mappings: if not data.mappings:
return {"success": False, "error": "No mappings provided"} 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: try:
results = [] results = []
for m in data.mappings: 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) results.append(r)
# Mark SKU as resolved in missing_skus tracking # Mark SKU as resolved in missing_skus tracking
await sqlite_service.resolve_missing_sku(data.sku) 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)} 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") @router.post("/api/mappings/import-csv")
async def import_csv(file: UploadFile = File(...)): async def import_csv(file: UploadFile = File(...)):
content = await file.read() content = await file.read()

View File

@@ -41,6 +41,13 @@ class AppSettingsUpdate(BaseModel):
gomag_order_days_back: str = "7" gomag_order_days_back: str = "7"
gomag_limit: str = "100" gomag_limit: str = "100"
dashboard_poll_seconds: str = "5" 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 # 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) 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) @router.get("/logs", response_class=HTMLResponse)
async def logs_page(request: Request, run: str = None): async def logs_page(request: Request, run: str = None):
return templates.TemplateResponse("logs.html", {"request": request, "selected_run": run or ""}) 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: 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 from .. import database
result = {} result = {}
sku_list = list(skus) 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))]) placeholders = ",".join([f":s{j}" for j in range(len(batch))])
params = {f"s{j}": sku for j, sku in enumerate(batch)} params = {f"s{j}": sku for j, sku in enumerate(batch)}
cur.execute(f""" cur.execute(f"""
SELECT at.sku, at.codmat, at.cantitate_roa, at.procent_pret, SELECT at.sku, at.codmat, at.cantitate_roa,
na.denumire na.denumire
FROM ARTICOLE_TERTI at FROM ARTICOLE_TERTI at
LEFT JOIN NOM_ARTICOLE na ON na.codmat = at.codmat AND na.sters = 0 AND na.inactiv = 0 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({ result[sku].append({
"codmat": row[1], "codmat": row[1],
"cantitate_roa": float(row[2]) if row[2] else 1, "cantitate_roa": float(row[2]) if row[2] else 1,
"procent_pret": float(row[3]) if row[3] else 100, "denumire": row[3] or ""
"denumire": row[4] or ""
}) })
finally: finally:
database.pool.release(conn) database.pool.release(conn)
@@ -371,7 +402,6 @@ async def order_detail(order_number: str):
item["codmat_details"] = [{ item["codmat_details"] = [{
"codmat": sku, "codmat": sku,
"cantitate_roa": 1, "cantitate_roa": 1,
"procent_pret": 100,
"denumire": nom_map[sku], "denumire": nom_map[sku],
"direct": True "direct": True
}] }]
@@ -416,6 +446,12 @@ async def order_detail(order_number: str):
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
pass 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 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_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), "gomag_limit": s.get("gomag_limit", "") or str(config_settings.GOMAG_LIMIT),
"dashboard_poll_seconds": s.get("dashboard_poll_seconds", "5"), "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_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("gomag_limit", config.gomag_limit)
await sqlite_service.set_app_setting("dashboard_poll_seconds", config.dashboard_poll_seconds) 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} return {"success": True}

View File

@@ -101,3 +101,82 @@ async def download_orders(
await asyncio.sleep(1) await asyncio.sleep(1)
return {"pages": total_pages, "total": total_orders, "files": saved_files} 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

View File

@@ -342,6 +342,12 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
# Convert list[int] to CSV string for Oracle VARCHAR2 param # 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 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", [ cur.callproc("PACK_IMPORT_COMENZI.importa_comanda", [
order_number, # p_nr_comanda_ext order_number, # p_nr_comanda_ext
order_date, # p_data_comanda 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_pol, # p_id_pol
id_sectie, # p_id_sectie id_sectie, # p_id_sectie
id_gestiune_csv, # p_id_gestiune (CSV string) 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() comanda_id = id_comanda.getvalue()

View File

@@ -9,14 +9,8 @@ logger = logging.getLogger(__name__)
def get_mappings(search: str = "", page: int = 1, per_page: int = 50, def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
sort_by: str = "sku", sort_dir: str = "asc", sort_by: str = "sku", sort_dir: str = "asc",
show_deleted: bool = False, pct_filter: str = None): show_deleted: bool = False):
"""Get paginated mappings with optional search, sorting, and pct_filter. """Get paginated mappings with optional search and sorting."""
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
"""
if database.pool is None: if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable") 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", "denumire": "na.denumire",
"um": "na.um", "um": "na.um",
"cantitate_roa": "at.cantitate_roa", "cantitate_roa": "at.cantitate_roa",
"procent_pret": "at.procent_pret",
"activ": "at.activ", "activ": "at.activ",
} }
sort_col = allowed_sort.get(sort_by, "at.sku") 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) # Fetch ALL matching rows (no pagination yet — we need to group by SKU first)
data_sql = f""" data_sql = f"""
SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa, 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 TO_CHAR(at.data_creare, 'YYYY-MM-DD HH24:MI') as data_creare
FROM ARTICOLE_TERTI at FROM ARTICOLE_TERTI at
LEFT JOIN nom_articole na ON na.codmat = at.codmat 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] columns = [col[0].lower() for col in cur.description]
all_rows = [dict(zip(columns, row)) for row in cur.fetchall()] 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 from collections import OrderedDict
groups = OrderedDict() groups = OrderedDict()
for row in all_rows: 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] = []
groups[sku].append(row) groups[sku].append(row)
# Compute counts across ALL groups (before pct_filter) counts = {"total": len(groups)}
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
# Flatten back to rows for pagination (paginate by raw row count) # Flatten back to rows for pagination (paginate by raw row count)
filtered_rows = [row for rows in groups.values() for row in rows] filtered_rows = [row for rows in groups.values() for row in rows]
total = len(filtered_rows) total = len(filtered_rows)
page_rows = filtered_rows[offset: offset + per_page] 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 { return {
"mappings": page_rows, "mappings": page_rows,
"total": total, "total": total,
@@ -145,7 +87,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
"counts": counts, "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. """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. 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: if auto_restore:
cur.execute(""" cur.execute("""
UPDATE ARTICOLE_TERTI SET sters = 0, activ = 1, UPDATE ARTICOLE_TERTI SET sters = 0, activ = 1,
cantitate_roa = :cantitate_roa, procent_pret = :procent_pret, cantitate_roa = :cantitate_roa,
data_modif = SYSDATE data_modif = SYSDATE
WHERE sku = :sku AND codmat = :codmat AND sters = 1 WHERE sku = :sku AND codmat = :codmat AND sters = 1
""", {"sku": sku, "codmat": codmat, """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa})
"cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
conn.commit() conn.commit()
return {"sku": sku, "codmat": codmat} return {"sku": sku, "codmat": codmat}
else: else:
@@ -209,13 +150,13 @@ def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret
) )
cur.execute(""" cur.execute("""
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare) INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3) VALUES (:sku, :codmat, :cantitate_roa, 1, 0, SYSDATE, -3)
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa, "procent_pret": procent_pret}) """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa})
conn.commit() conn.commit()
return {"sku": sku, "codmat": codmat} 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.""" """Update an existing mapping."""
if database.pool is None: if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable") 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: if cantitate_roa is not None:
sets.append("cantitate_roa = :cantitate_roa") sets.append("cantitate_roa = :cantitate_roa")
params["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: if activ is not None:
sets.append("activ = :activ") sets.append("activ = :activ")
params["activ"] = activ params["activ"] = activ
@@ -263,7 +201,7 @@ def delete_mapping(sku: str, codmat: str):
return cur.rowcount > 0 return cur.rowcount > 0
def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str, 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.""" """Edit a mapping. If PK changed, soft-delete old and insert new."""
if not new_sku or not new_sku.strip(): if not new_sku or not new_sku.strip():
raise HTTPException(status_code=400, detail="SKU este obligatoriu") 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") raise HTTPException(status_code=503, detail="Oracle unavailable")
if old_sku == new_sku and old_codmat == new_codmat: if old_sku == new_sku and old_codmat == new_codmat:
# Simple update - only cantitate/procent changed # Simple update - only cantitate changed
return update_mapping(new_sku, new_codmat, cantitate_roa, procent_pret) return update_mapping(new_sku, new_codmat, cantitate_roa)
else: else:
# PK changed: soft-delete old, upsert new (MERGE handles existing soft-deleted target) # PK changed: soft-delete old, upsert new (MERGE handles existing soft-deleted target)
with database.pool.acquire() as conn: 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) ON (t.sku = s.sku AND t.codmat = s.codmat)
WHEN MATCHED THEN UPDATE SET WHEN MATCHED THEN UPDATE SET
cantitate_roa = :cantitate_roa, cantitate_roa = :cantitate_roa,
procent_pret = :procent_pret,
activ = 1, sters = 0, activ = 1, sters = 0,
data_modif = SYSDATE data_modif = SYSDATE
WHEN NOT MATCHED THEN INSERT WHEN NOT MATCHED THEN INSERT
(sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare) (sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3) VALUES (:sku, :codmat, :cantitate_roa, 1, 0, SYSDATE, -3)
""", {"sku": new_sku, "codmat": new_codmat, """, {"sku": new_sku, "codmat": new_codmat, "cantitate_roa": cantitate_roa})
"cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
conn.commit() conn.commit()
return True return True
@@ -317,7 +253,9 @@ def restore_mapping(sku: str, codmat: str):
return cur.rowcount > 0 return cur.rowcount > 0
def import_csv(file_content: str): 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: if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable") raise HTTPException(status_code=503, detail="Oracle unavailable")
@@ -342,7 +280,7 @@ def import_csv(file_content: str):
try: try:
cantitate = float(row.get("cantitate_roa", "1") or "1") 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(""" cur.execute("""
MERGE INTO ARTICOLE_TERTI t 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) ON (t.sku = s.sku AND t.codmat = s.codmat)
WHEN MATCHED THEN UPDATE SET WHEN MATCHED THEN UPDATE SET
cantitate_roa = :cantitate_roa, cantitate_roa = :cantitate_roa,
procent_pret = :procent_pret,
activ = 1, activ = 1,
sters = 0, sters = 0,
data_modif = SYSDATE data_modif = SYSDATE
WHEN NOT MATCHED THEN INSERT WHEN NOT MATCHED THEN INSERT
(sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare) (sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3) VALUES (:sku, :codmat, :cantitate_roa, 1, 0, SYSDATE, -3)
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent}) """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate})
created += 1 created += 1
except Exception as e: except Exception as e:
@@ -374,12 +311,12 @@ def export_csv():
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output) 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 database.pool.acquire() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(""" 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 FROM ARTICOLE_TERTI WHERE sters = 0 ORDER BY sku, codmat
""") """)
for row in cur: for row in cur:
@@ -391,6 +328,70 @@ def get_csv_template():
"""Return empty CSV template.""" """Return empty CSV template."""
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output) writer = csv.writer(output)
writer.writerow(["sku", "codmat", "cantitate_roa", "procent_pret"]) writer.writerow(["sku", "codmat", "cantitate_roa"])
writer.writerow(["EXAMPLE_SKU", "EXAMPLE_CODMAT", "1", "100"]) writer.writerow(["EXAMPLE_SKU", "EXAMPLE_CODMAT", "1"])
return output.getvalue() return output.getvalue()
def get_component_prices(sku: str, id_pol: int, id_pol_productie: int = None) -> list:
"""Get prices from crm_politici_pret_art for kit components.
Returns: [{"codmat", "denumire", "cantitate_roa", "pret", "pret_cu_tva", "proc_tvav", "ptva", "id_pol_used"}]
"""
if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable")
with database.pool.acquire() as conn:
with conn.cursor() as cur:
# Get components from ARTICOLE_TERTI
cur.execute("""
SELECT at.codmat, at.cantitate_roa, na.id_articol, na.cont, na.denumire
FROM ARTICOLE_TERTI at
JOIN NOM_ARTICOLE na ON na.codmat = at.codmat AND na.sters = 0 AND na.inactiv = 0
WHERE at.sku = :sku AND at.activ = 1 AND at.sters = 0
ORDER BY at.codmat
""", {"sku": sku})
components = cur.fetchall()
if len(components) <= 1:
return [] # Not a kit
result = []
for codmat, cant_roa, id_art, cont, denumire in components:
# Determine policy based on account
cont_str = str(cont or "").strip()
pol = id_pol_productie if (cont_str in ("341", "345") and id_pol_productie) else id_pol
# Get PRETURI_CU_TVA flag
cur.execute("SELECT PRETURI_CU_TVA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :pol", {"pol": pol})
pol_row = cur.fetchone()
preturi_cu_tva_flag = pol_row[0] if pol_row else 0
# Get price
cur.execute("""
SELECT PRET, PROC_TVAV FROM crm_politici_pret_art
WHERE id_pol = :pol AND id_articol = :id_art
""", {"pol": pol, "id_art": id_art})
price_row = cur.fetchone()
if price_row:
pret, proc_tvav = price_row
proc_tvav = proc_tvav or 1.19
pret_cu_tva = pret if preturi_cu_tva_flag == 1 else round(pret * proc_tvav, 2)
ptva = round((proc_tvav - 1) * 100)
else:
pret = 0
pret_cu_tva = 0
proc_tvav = 1.19
ptva = 19
result.append({
"codmat": codmat,
"denumire": denumire or "",
"cantitate_roa": float(cant_roa) if cant_roa else 1,
"pret": float(pret) if pret else 0,
"pret_cu_tva": float(pret_cu_tva),
"proc_tvav": float(proc_tvav),
"ptva": int(ptva),
"id_pol_used": pol
})
return result

View File

@@ -0,0 +1,220 @@
"""Catalog price sync service — syncs product prices from GoMag catalog to ROA Oracle."""
import asyncio
import logging
import uuid
from datetime import datetime
from zoneinfo import ZoneInfo
from . import gomag_client, validation_service, sqlite_service
from .. import database
from ..config import settings
logger = logging.getLogger(__name__)
_tz = ZoneInfo("Europe/Bucharest")
_price_sync_lock = asyncio.Lock()
_current_price_sync = None
def _now():
return datetime.now(_tz).replace(tzinfo=None)
async def prepare_price_sync() -> dict:
global _current_price_sync
if _price_sync_lock.locked():
return {"error": "Price sync already running"}
run_id = _now().strftime("%Y%m%d_%H%M%S") + "_ps_" + uuid.uuid4().hex[:6]
_current_price_sync = {
"run_id": run_id, "status": "running",
"started_at": _now().isoformat(), "finished_at": None,
"phase_text": "Starting...",
}
# Create SQLite record
db = await sqlite_service.get_sqlite()
try:
await db.execute(
"INSERT INTO price_sync_runs (run_id, started_at, status) VALUES (?, ?, 'running')",
(run_id, _now().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

View File

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

View File

@@ -465,6 +465,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
if item.sku in validation["mapped"]: if item.sku in validation["mapped"]:
mapped_skus_in_orders.add(item.sku) mapped_skus_in_orders.add(item.sku)
mapped_codmat_data = {}
if mapped_skus_in_orders: if mapped_skus_in_orders:
mapped_codmat_data = await asyncio.to_thread( mapped_codmat_data = await asyncio.to_thread(
validation_service.resolve_mapped_codmats, mapped_skus_in_orders, conn validation_service.resolve_mapped_codmats, mapped_skus_in_orders, conn
@@ -498,9 +499,47 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
conn, mapped_id_map, cota_tva=cota_tva conn, mapped_id_map, cota_tva=cota_tva
) )
# Add SKU → policy entries for mapped articles (1:1 and kits)
# codmat_policy_map has CODMAT keys, but build_articles_json
# looks up by GoMag SKU — bridge the gap here
if codmat_policy_map and mapped_codmat_data:
for sku, entries in mapped_codmat_data.items():
if len(entries) == 1:
# 1:1 mapping: SKU inherits the CODMAT's policy
codmat = entries[0]["codmat"]
if codmat in codmat_policy_map:
codmat_policy_map[sku] = codmat_policy_map[codmat]
# Pass codmat_policy_map to import via app_settings # Pass codmat_policy_map to import via app_settings
if codmat_policy_map: if codmat_policy_map:
app_settings["_codmat_policy_map"] = 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: finally:
await asyncio.to_thread(database.pool.release, conn) await asyncio.to_thread(database.pool.release, conn)
@@ -565,6 +604,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)})") _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})")
await sqlite_service.save_orders_batch(skipped_batch) 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}", _update_progress("skipped", f"Skipped {skipped_count}",
0, len(truly_importable), 0, len(truly_importable),
{"imported": 0, "skipped": skipped_count, "errors": 0, "already_imported": already_imported_count}) {"imported": 0, "skipped": skipped_count, "errors": 0, "already_imported": already_imported_count})

View File

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

View File

@@ -35,6 +35,18 @@ body {
padding: 0; 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 ──────────────────────────────────── */
.top-navbar { .top-navbar {
position: fixed; position: fixed;
@@ -141,6 +153,7 @@ body {
padding: 0.625rem 1rem; padding: 0.625rem 1rem;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 1rem; font-size: 1rem;
font-variant-numeric: tabular-nums;
} }
/* Zebra striping */ /* Zebra striping */
@@ -212,10 +225,10 @@ body {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 2rem; min-width: 2.75rem;
height: 2rem; height: 2.75rem;
padding: 0 0.5rem; padding: 0 0.5rem;
font-size: 0.8125rem; font-size: 0.875rem;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 0.375rem; border-radius: 0.375rem;
background: #fff; background: #fff;
@@ -356,11 +369,16 @@ body {
.qm-row { display: flex; gap: 6px; align-items: center; } .qm-row { display: flex; gap: 6px; align-items: center; }
.qm-codmat-wrap { flex: 1; min-width: 0; } .qm-codmat-wrap { flex: 1; min-width: 0; }
.qm-rm-btn { padding: 2px 6px; line-height: 1; } .qm-rm-btn { padding: 2px 6px; line-height: 1; }
#qmCodmatLines .qm-selected:empty { display: none; } #qmCodmatLines .qm-selected:empty,
#quickMapModal .modal-body { padding-top: 12px; padding-bottom: 8px; } #codmatLines .qm-selected:empty { display: none; }
#quickMapModal .modal-header { padding: 10px 16px; } #quickMapModal .modal-body,
#quickMapModal .modal-header h5 { font-size: 0.95rem; margin: 0; } #addModal .modal-body { padding-top: 12px; padding-bottom: 8px; }
#quickMapModal .modal-footer { padding: 8px 16px; } #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 ────────────────────────── */ /* ── Deleted mapping rows ────────────────────────── */
tr.mapping-deleted td { tr.mapping-deleted td {
@@ -399,7 +417,7 @@ tr.mapping-deleted td {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.3rem; gap: 0.3rem;
padding: 0.375rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 0.375rem; border-radius: 0.375rem;
background: #fff; background: #fff;
@@ -429,10 +447,12 @@ tr.mapping-deleted td {
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 0.375rem; border-radius: 0.375rem;
font-size: 0.9375rem; font-size: 0.9375rem;
outline: none;
width: 160px; 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 (keep as-is) ──────────── */
.autocomplete-dropdown { .autocomplete-dropdown {

View File

@@ -4,10 +4,6 @@ let dashPerPage = 50;
let dashSortCol = 'order_date'; let dashSortCol = 'order_date';
let dashSortDir = 'desc'; let dashSortDir = 'desc';
let dashSearchTimeout = null; let dashSearchTimeout = null;
let currentQmSku = '';
let currentQmOrderNumber = '';
let qmAcTimeout = null;
// Sync polling state // Sync polling state
let _pollInterval = null; let _pollInterval = null;
let _lastSyncStatus = null; let _lastSyncStatus = null;
@@ -484,7 +480,7 @@ function renderCodmatCell(item) {
return `<code>${esc(d.codmat)}</code>`; return `<code>${esc(d.codmat)}</code>`;
} }
return item.codmat_details.map(d => 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(''); ).join('');
} }
@@ -522,14 +518,12 @@ async function openDashOrderDetail(orderNumber) {
document.getElementById('detailIdPartener').textContent = '-'; document.getElementById('detailIdPartener').textContent = '-';
document.getElementById('detailIdAdresaFact').textContent = '-'; document.getElementById('detailIdAdresaFact').textContent = '-';
document.getElementById('detailIdAdresaLivr').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('detailError').style.display = 'none';
document.getElementById('detailReceipt').innerHTML = '';
document.getElementById('detailReceiptMobile').innerHTML = '';
const invInfo = document.getElementById('detailInvoiceInfo'); const invInfo = document.getElementById('detailInvoiceInfo');
if (invInfo) invInfo.style.display = 'none'; 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'); const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) mobileContainer.innerHTML = ''; if (mobileContainer) mobileContainer.innerHTML = '';
@@ -574,234 +568,208 @@ async function openDashOrderDetail(orderNumber) {
document.getElementById('detailError').style.display = ''; 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 || []; const items = data.items || [];
if (items.length === 0) { 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; 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 // Store items for quick map pre-population
window._detailItems = items; window._detailItems = items;
// Mobile article flat list // Mobile article flat list
const mobileContainer = document.getElementById('detailItemsMobile'); const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) { 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 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(' ') ? 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>`; : `<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"> return `<div class="dif-item">
<div class="dif-row"> <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} ${codmatText}
</div> </div>
<div class="dif-row"> <div class="dif-row">
<span class="dif-name">${esc(item.product_name || '')}</span> <span class="dif-name">${esc(item.product_name || '')}</span>
<span class="dif-qty">x${item.quantity || 0}</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('');
// 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>
</div>`; </div>`;
}).join('') + '</div>';
} }
document.getElementById('detailItemsBody').innerHTML = items.map((item, idx) => { // Discount rows (mobile)
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2); 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>';
}
let tableHtml = items.map((item, idx) => {
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
return `<tr> 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>${esc(item.product_name || '-')}</td>
<td>${renderCodmatCell(item)}</td> <td>${renderCodmatCell(item)}</td>
<td>${item.quantity || 0}</td> <td class="text-end">${item.quantity || 0}</td>
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td> <td class="text-end">${item.price != null ? fmtNum(item.price) : '-'}</td>
<td class="text-end">${valoare}</td> <td class="text-end">${item.vat != null ? Number(item.vat) : '-'}</td>
<td class="text-end">${fmtNum(valoare)}</td>
</tr>`; </tr>`;
}).join(''); }).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) { } catch (err) {
document.getElementById('detailError').textContent = err.message; document.getElementById('detailError').textContent = err.message;
document.getElementById('detailError').style.display = ''; 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) { function computeDiscountSplit(items, order) {
currentQmSku = sku; if (order.discount_split && typeof order.discount_split === 'object')
currentQmOrderNumber = orderNumber; return order.discount_split;
document.getElementById('qmSku').textContent = sku;
document.getElementById('qmProductName').textContent = productName || '-';
document.getElementById('qmPctWarning').style.display = 'none';
const container = document.getElementById('qmCodmatLines'); // Compute proportionally from items by VAT rate
container.innerHTML = ''; 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 item = (window._detailItems || [])[itemIdx];
const details = item?.codmat_details; const details = item?.codmat_details;
const isDirect = details?.length === 1 && details[0].direct === true; const isDirect = details?.length === 1 && details[0].direct === true;
const directInfo = document.getElementById('qmDirectInfo');
const saveBtn = document.getElementById('qmSaveBtn');
if (isDirect) { openQuickMap({
if (directInfo) { sku,
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>`; productName,
directInfo.style.display = ''; isDirect,
} directInfo: isDirect ? { codmat: details[0].codmat, denumire: details[0].denumire } : null,
if (saveBtn) { prefill: (!isDirect && details?.length) ? details.map(d => ({ codmat: d.codmat, cantitate: d.cantitate_roa, denumire: d.denumire })) : null,
saveBtn.textContent = 'Suprascrie mapare'; onSave: () => {
} if (orderNumber) openDashOrderDetail(orderNumber);
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> &mdash; <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);
loadDashOrders(); 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);
} }
});
} }

View File

@@ -5,8 +5,6 @@ let runsPage = 1;
let logPollTimer = null; let logPollTimer = null;
let currentFilter = 'all'; let currentFilter = 'all';
let ordersPage = 1; let ordersPage = 1;
let currentQmSku = '';
let currentQmOrderNumber = '';
let ordersSortColumn = 'order_date'; let ordersSortColumn = 'order_date';
let ordersSortDirection = 'desc'; let ordersSortDirection = 'desc';
@@ -310,7 +308,7 @@ function renderCodmatCell(item) {
} }
// Multi-CODMAT: compact list // Multi-CODMAT: compact list
return item.codmat_details.map(d => 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(''); ).join('');
} }
@@ -384,8 +382,8 @@ async function openOrderDetail(orderNumber) {
if (mobileContainer) { if (mobileContainer) {
mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => { mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => {
const codmatList = item.codmat_details?.length 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(' ') ? 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="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(item.codmat || '')}</span>`; : `<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); const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
return `<div class="dif-item"> return `<div class="dif-item">
<div class="dif-row"> <div class="dif-row">
@@ -403,7 +401,7 @@ async function openOrderDetail(orderNumber) {
document.getElementById('detailItemsBody').innerHTML = items.map(item => { document.getElementById('detailItemsBody').innerHTML = items.map(item => {
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2); 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> return `<tr>
<td><code>${esc(item.sku)}</code></td> <td><code>${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</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 openLogsQuickMap(sku, productName, orderNumber) {
openQuickMap({
function openQuickMap(sku, productName, orderNumber) { sku,
currentQmSku = sku; productName,
currentQmOrderNumber = orderNumber; onSave: () => {
document.getElementById('qmSku').textContent = sku; if (orderNumber) openOrderDetail(orderNumber);
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
loadRunOrders(currentRunId, currentFilter, ordersPage); loadRunOrders(currentRunId, currentFilter, ordersPage);
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
} catch (err) {
alert('Eroare: ' + err.message);
} }
});
} }
// ── Init ──────────────────────────────────────── // ── Init ────────────────────────────────────────

View File

@@ -5,14 +5,14 @@ let searchTimeout = null;
let sortColumn = 'sku'; let sortColumn = 'sku';
let sortDirection = 'asc'; let sortDirection = 'asc';
let editingMapping = null; // {sku, codmat} when editing let editingMapping = null; // {sku, codmat} when editing
let pctFilter = 'all';
const kitPriceCache = new Map();
// Load on page ready // Load on page ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadMappings(); loadMappings();
initAddModal(); initAddModal();
initDeleteModal(); initDeleteModal();
initPctFilterPills();
}); });
function debounceSearch() { 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 ──────────────────────────────── // ── Load & Render ────────────────────────────────
async function loadMappings() { async function loadMappings() {
@@ -99,7 +61,6 @@ async function loadMappings() {
sort_dir: sortDirection sort_dir: sortDirection
}); });
if (showDeleted) params.set('show_deleted', 'true'); if (showDeleted) params.set('show_deleted', 'true');
if (pctFilter && pctFilter !== 'all') params.set('pct_filter', pctFilter);
try { try {
const res = await fetch(`/api/mappings?${params}`); const res = await fetch(`/api/mappings?${params}`);
@@ -113,7 +74,6 @@ async function loadMappings() {
mappings = mappings.filter(m => m.activ || m.sters); mappings = mappings.filter(m => m.activ || m.sters);
} }
updatePctCounts(data.counts);
renderTable(mappings, showDeleted); renderTable(mappings, showDeleted);
renderPagination(data); renderPagination(data);
updateSortIcons(); updateSortIcons();
@@ -131,41 +91,52 @@ function renderTable(mappings, showDeleted) {
return; 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 prevSku = null;
let html = ''; let html = '';
mappings.forEach(m => { mappings.forEach((m, i) => {
const isNewGroup = m.sku !== prevSku; const isNewGroup = m.sku !== prevSku;
if (isNewGroup) { if (isNewGroup) {
let pctBadge = ''; const isKit = (skuCodmatCount[m.sku] || 0) > 1;
if (m.pct_total !== undefined) { const kitBadge = isKit
pctBadge = m.is_complete ? ` <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>`
? ` <span class="badge-pct complete">&#10003; 100%</span>` : '';
: ` <span class="badge-pct incomplete">${typeof m.pct_total === 'number' ? m.pct_total.toFixed(0) : m.pct_total}%</span>`;
}
const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : ''; 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}"> 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'}" <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})"`} ${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}
title="${m.activ ? 'Activ' : 'Inactiv'}"></span> 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> <span class="grow truncate text-muted" style="font-size:0.875rem">${esc(m.product_name || '')}</span>
${m.sters ${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="btn btn-sm btn-outline-success" onclick="event.stopPropagation();restoreMapping('${esc(m.sku)}', '${esc(m.codmat)}')" title="Restaureaza" style="padding:0.1rem 0.4rem"><i class="bi bi-arrow-counterclockwise"></i></button>`
: `<button class="context-menu-trigger" data-sku="${esc(m.sku)}" data-codmat="${esc(m.codmat)}" data-cantitate="${m.cantitate_roa}" data-procent="${m.procent_pret}">&#8942;</button>` : `<button class="context-menu-trigger" data-sku="${esc(m.sku)}" data-codmat="${esc(m.codmat)}" data-cantitate="${m.cantitate_roa}">&#8942;</button>`
} }
</div>`; </div>`;
} }
const deletedStyle = m.sters ? 'text-decoration:line-through;opacity:0.5;' : ''; 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}"> html += `<div class="flat-row" style="padding-left:1.5rem;font-size:0.9rem;${deletedStyle}">
<code>${esc(m.codmat)}</code> <code>${esc(m.codmat)}</code>
<span class="grow truncate text-muted" style="font-size:0.85rem">${esc(m.denumire || '')}</span> <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="text-nowrap" style="font-size:0.875rem">
<span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}" <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> ${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>x${m.cantitate_roa}</span>${priceSlot}
· <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>
</span> </span>
</div>`; </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; prevSku = m.sku;
}); });
container.innerHTML = html; container.innerHTML = html;
@@ -174,17 +145,76 @@ function renderTable(mappings, showDeleted) {
container.querySelectorAll('.context-menu-trigger').forEach(btn => { container.querySelectorAll('.context-menu-trigger').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
const { sku, codmat, cantitate, procent } = btn.dataset; const { sku, codmat, cantitate } = btn.dataset;
const rect = btn.getBoundingClientRect(); const rect = btn.getBoundingClientRect();
showContextMenu(rect.left, rect.bottom + 2, [ 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 } { 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) { function editFlatValue(span, sku, codmat, field, currentValue) {
if (span.querySelector('input')) return; if (span.querySelector('input')) return;
@@ -276,7 +306,7 @@ function clearAddForm() {
addCodmatLine(); addCodmatLine();
} }
async function openEditModal(sku, codmat, cantitate, procent) { async function openEditModal(sku, codmat, cantitate) {
editingMapping = { sku, codmat }; editingMapping = { sku, codmat };
document.getElementById('addModalTitle').textContent = 'Editare Mapare'; document.getElementById('addModalTitle').textContent = 'Editare Mapare';
document.getElementById('inputSku').value = sku; document.getElementById('inputSku').value = sku;
@@ -308,7 +338,6 @@ async function openEditModal(sku, codmat, cantitate, procent) {
if (line) { if (line) {
line.querySelector('.cl-codmat').value = codmat; line.querySelector('.cl-codmat').value = codmat;
line.querySelector('.cl-cantitate').value = cantitate; line.querySelector('.cl-cantitate').value = cantitate;
line.querySelector('.cl-procent').value = procent;
} }
} else { } else {
for (const m of allMappings) { 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-selected').textContent = m.denumire;
} }
line.querySelector('.cl-cantitate').value = m.cantitate_roa; line.querySelector('.cl-cantitate').value = m.cantitate_roa;
line.querySelector('.cl-procent').value = m.procent_pret;
} }
} }
} catch (e) { } catch (e) {
@@ -330,7 +358,6 @@ async function openEditModal(sku, codmat, cantitate, procent) {
if (line) { if (line) {
line.querySelector('.cl-codmat').value = codmat; line.querySelector('.cl-codmat').value = codmat;
line.querySelector('.cl-cantitate').value = cantitate; line.querySelector('.cl-cantitate').value = cantitate;
line.querySelector('.cl-procent').value = procent;
} }
} }
@@ -341,24 +368,17 @@ function addCodmatLine() {
const container = document.getElementById('codmatLines'); const container = document.getElementById('codmatLines');
const idx = container.children.length; const idx = container.children.length;
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 codmat-line'; div.className = 'qm-line codmat-line';
div.innerHTML = ` div.innerHTML = `
<div class="row g-2 align-items-center"> <div class="qm-row">
<div class="col position-relative"> <div class="qm-codmat-wrap position-relative">
<input type="text" class="form-control form-control-sm cl-codmat" placeholder="Cauta CODMAT..." autocomplete="off" data-idx="${idx}"> <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> <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> </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>
<div class="qm-selected text-muted cl-selected" style="font-size:0.75rem;padding-left:2px"></div>
`; `;
container.appendChild(div); container.appendChild(div);
@@ -412,22 +432,12 @@ async function saveMapping() {
for (const line of lines) { for (const line of lines) {
const codmat = line.querySelector('.cl-codmat').value.trim(); const codmat = line.querySelector('.cl-codmat').value.trim();
const cantitate = parseFloat(line.querySelector('.cl-cantitate').value) || 1; const cantitate = parseFloat(line.querySelector('.cl-cantitate').value) || 1;
const procent = parseFloat(line.querySelector('.cl-procent').value) || 100;
if (!codmat) continue; 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; } 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'; document.getElementById('pctWarning').style.display = 'none';
try { try {
@@ -442,8 +452,7 @@ async function saveMapping() {
body: JSON.stringify({ body: JSON.stringify({
new_sku: sku, new_sku: sku,
new_codmat: mappings[0].codmat, new_codmat: mappings[0].codmat,
cantitate_roa: mappings[0].cantitate_roa, cantitate_roa: mappings[0].cantitate_roa
procent_pret: mappings[0].procent_pret
}) })
}); });
} else { } else {
@@ -471,7 +480,7 @@ async function saveMapping() {
res = await fetch('/api/mappings', { res = await fetch('/api/mappings', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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 { } else {
res = await fetch('/api/mappings/batch', { res = await fetch('/api/mappings/batch', {
@@ -523,7 +532,6 @@ function showInlineAddRow() {
<small class="text-muted" id="inlineSelected"></small> <small class="text-muted" id="inlineSelected"></small>
</div> </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="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-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> <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 sku = document.getElementById('inlineSku').value.trim();
const codmat = document.getElementById('inlineCodmat').value.trim(); const codmat = document.getElementById('inlineCodmat').value.trim();
const cantitate = parseFloat(document.getElementById('inlineCantitate').value) || 1; const cantitate = parseFloat(document.getElementById('inlineCantitate').value) || 1;
const procent = parseFloat(document.getElementById('inlineProcent').value) || 100;
if (!sku) { alert('SKU este obligatoriu'); return; } if (!sku) { alert('SKU este obligatoriu'); return; }
if (!codmat) { alert('CODMAT este obligatoriu'); return; } if (!codmat) { alert('CODMAT este obligatoriu'); return; }
@@ -580,7 +587,7 @@ async function saveInlineMapping() {
const res = await fetch('/api/mappings', { const res = await fetch('/api/mappings', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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(); const data = await res.json();
if (data.success) { if (data.success) {
@@ -755,4 +762,3 @@ function handleMappingConflict(data) {
if (warn) { warn.textContent = msg; warn.style.display = ''; } if (warn) { warn.textContent = msg; warn.style.display = ''; }
} }
} }

View File

@@ -5,6 +5,21 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadSettings(); await loadSettings();
wireAutocomplete('settTransportCodmat', 'settTransportAc'); wireAutocomplete('settTransportCodmat', 'settTransportAc');
wireAutocomplete('settDiscountCodmat', 'settDiscountAc'); 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() { async function loadDropdowns() {
@@ -66,6 +81,14 @@ async function loadDropdowns() {
pPolEl.innerHTML += `<option value="${escHtml(p.id)}">${escHtml(p.label)}</option>`; 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) { } catch (err) {
console.error('loadDropdowns error:', 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('settGomagDaysBack')) el('settGomagDaysBack').value = data.gomag_order_days_back || '7';
if (el('settGomagLimit')) el('settGomagLimit').value = data.gomag_limit || '100'; if (el('settGomagLimit')) el('settGomagLimit').value = data.gomag_limit || '100';
if (el('settDashPollSeconds')) el('settDashPollSeconds').value = data.dashboard_poll_seconds || '5'; 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) { } catch (err) {
console.error('loadSettings error:', err); console.error('loadSettings error:', err);
} }
@@ -124,6 +174,13 @@ async function saveSettings() {
gomag_order_days_back: el('settGomagDaysBack')?.value?.trim() || '7', gomag_order_days_back: el('settGomagDaysBack')?.value?.trim() || '7',
gomag_limit: el('settGomagLimit')?.value?.trim() || '100', gomag_limit: el('settGomagLimit')?.value?.trim() || '100',
dashboard_poll_seconds: el('settDashPollSeconds')?.value?.trim() || '5', 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 { try {
const res = await fetch('/api/settings', { 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) { function wireAutocomplete(inputId, dropdownId) {
const input = document.getElementById(inputId); const input = document.getElementById(inputId);
const dropdown = document.getElementById(dropdownId); const dropdown = document.getElementById(dropdownId);

View File

@@ -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> &mdash; <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 ──────────────────────────────────── // ── Dot helper ────────────────────────────────────
function statusDot(status) { function statusDot(status) {
switch ((status || '').toUpperCase()) { switch ((status || '').toUpperCase()) {

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ro"> <html lang="ro" style="color-scheme: light">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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@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"> <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', '') %} {% 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> </head>
<body> <body>
<!-- Top Navbar --> <!-- Top Navbar -->
@@ -27,9 +27,41 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </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>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="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 %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -135,12 +135,6 @@
</div> </div>
</div> </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"> <div class="table-responsive d-none d-md-block">
<table class="table table-sm table-bordered mb-0"> <table class="table table-sm table-bordered mb-0">
<thead class="table-light"> <thead class="table-light">
@@ -148,16 +142,19 @@
<th>SKU</th> <th>SKU</th>
<th>Produs</th> <th>Produs</th>
<th>CODMAT</th> <th>CODMAT</th>
<th>Cant.</th> <th class="text-end">Cant.</th>
<th>Pret</th> <th class="text-end">Pret</th>
<th class="text-end">TVA%</th>
<th class="text-end">Valoare</th> <th class="text-end">Valoare</th>
</tr> </tr>
</thead> </thead>
<tbody id="detailItemsBody"> <tbody id="detailItemsBody">
</tbody> </tbody>
</table> </table>
<div id="detailReceipt" class="d-flex flex-wrap gap-2 mt-1 justify-content-end"></div>
</div> </div>
<div class="d-md-none" id="detailItemsMobile"></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 id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -168,41 +165,8 @@
</div> </div>
<!-- Quick Map Modal (used from order detail) --> <!-- 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 %} {% endblock %}
{% block scripts %} {% 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 %} {% endblock %}

View File

@@ -151,37 +151,10 @@
</div> </div>
<!-- Quick Map Modal (used from order detail) --> <!-- 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 --> <!-- Hidden field for pre-selected run from URL/server -->
<input type="hidden" id="preselectedRun" value="{{ selected_run }}"> <input type="hidden" id="preselectedRun" value="{{ selected_run }}">
{% endblock %} {% endblock %}
{% block scripts %} {% 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 %} {% endblock %}

View File

@@ -47,14 +47,6 @@
</div> </div>
</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 --> <!-- Top pagination -->
<div id="mappingsPagTop" class="pag-strip"></div> <div id="mappingsPagTop" class="pag-strip"></div>
@@ -69,27 +61,31 @@
</div> </div>
<!-- Add/Edit Modal with multi-CODMAT support (R11) --> <!-- Add/Edit Modal with multi-CODMAT support (R11) -->
<div class="modal fade" id="addModal" tabindex="-1"> <div class="modal fade" id="addModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-lg"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="addModalTitle">Adauga Mapare</h5> <h5 class="modal-title" id="addModalTitle">Adauga Mapare</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-2">
<label class="form-label">SKU</label> <label class="form-label form-label-sm mb-1">SKU</label>
<input type="text" class="form-control" id="inputSku" placeholder="Ex: 8714858124284"> <input type="text" class="form-control form-control-sm" id="inputSku" placeholder="Ex: 8714858124284">
</div> </div>
<div class="mb-2" id="addModalProductName" style="display:none;"> <div id="addModalProductName" style="display:none; margin-bottom:8px; font-size:0.85rem">
<small class="text-muted">Produs web:</small> <strong id="inputProductName"></strong> <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> </div>
<hr>
<div id="codmatLines"> <div id="codmatLines">
<!-- Dynamic CODMAT lines will be added here --> <!-- Dynamic CODMAT lines will be added here -->
</div> </div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addCodmatLine()"> <button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="addCodmatLine()" style="font-size:0.8rem; padding:2px 10px">
<i class="bi bi-plus"></i> Adauga CODMAT + CODMAT
</button> </button>
<div id="pctWarning" class="text-danger mt-2" style="display:none;"></div> <div id="pctWarning" class="text-danger mt-2" style="display:none;"></div>
</div> </div>
@@ -110,7 +106,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <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"> <input type="file" class="form-control" id="csvFile" accept=".csv">
<div id="importResult" class="mt-3"></div> <div id="importResult" class="mt-3"></div>
</div> </div>
@@ -154,5 +150,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% 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 %} {% endblock %}

View File

@@ -65,39 +65,10 @@
</div> </div>
<div id="skusPagBottom" class="pag-strip pag-strip-bottom"></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 %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
let currentMapSku = '';
let mapAcTimeout = null;
let currentPage = 1; let currentPage = 1;
let skuStatusFilter = 'unresolved'; let skuStatusFilter = 'unresolved';
let missingPerPage = 20; let missingPerPage = 20;
@@ -223,7 +194,7 @@ function renderMissingSkusTable(skus, data) {
<td class="truncate" style="max-width:300px">${esc(s.product_name || '-')}</td> <td class="truncate" style="max-width:300px">${esc(s.product_name || '-')}</td>
<td> <td>
${!s.resolved ${!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> <i class="bi bi-link-45deg"></i>
</a>` </a>`
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`} : `<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) { if (mobileList) {
mobileList.innerHTML = skus.map(s => { mobileList.innerHTML = skus.map(s => {
const actionHtml = !s.resolved 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>`; : `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`;
const flatRowAttrs = !s.resolved const flatRowAttrs = !s.resolved
? ` onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')" style="cursor:pointer"` ? ` onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')" style="cursor:pointer"`
@@ -259,136 +230,18 @@ function renderPagination(data) {
if (bot) bot.innerHTML = pagHtml; if (bot) bot.innerHTML = pagHtml;
} }
// ── Multi-CODMAT Map Modal ─────────────────────── // ── Map Modal (uses shared openQuickMap) ─────────
function openMapModal(sku, productName) { function openMapModal(sku, productName) {
currentMapSku = sku; openQuickMap({
document.getElementById('mapSku').textContent = sku; sku,
document.getElementById('mapProductName').textContent = productName || '-'; productName,
document.getElementById('mapPctWarning').style.display = 'none'; onSave: () => { loadMissingSkus(currentPage); }
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);
}); });
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() { function exportMissingCsv() {
window.location.href = '/api/validate/missing-skus-csv'; window.location.href = (window.ROOT_PATH || '') + '/api/validate/missing-skus-csv';
} }
</script> </script>

View File

@@ -157,6 +157,72 @@
</div> </div>
</div> </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>
<div class="mb-3"> <div class="mb-3">
@@ -167,5 +233,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% 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 %} {% endblock %}

View File

@@ -4,6 +4,8 @@ create or replace package PACK_COMENZI is
-- Created : 18/08/2006 -- Created : 18/08/2006
-- Purpose : -- Purpose :
-- 20.03.2026 - duplicate CODMAT pe comanda: discriminare pe PRET + SIGN(CANTITATE)
id_comanda COMENZI.ID_COMANDA%TYPE; id_comanda COMENZI.ID_COMANDA%TYPE;
procedure adauga_masina(V_ID_MODEL_MASINA IN NUMBER, procedure adauga_masina(V_ID_MODEL_MASINA IN NUMBER,
@@ -310,6 +312,9 @@ create or replace package body PACK_COMENZI is
-- marius.mutu -- marius.mutu
-- adauga_articol_comanda, modifica_articol_comanda + se poate completa ptva (21,11) in loc sa il ia din politica de preturi -- 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, procedure adauga_masina(V_ID_MODEL_MASINA IN NUMBER,
V_NRINMAT IN VARCHAR2, V_NRINMAT IN VARCHAR2,
@@ -781,6 +786,9 @@ create or replace package body PACK_COMENZI is
FROM COMENZI_ELEMENTE FROM COMENZI_ELEMENTE
WHERE ID_COMANDA = V_ID_COMANDA WHERE ID_COMANDA = V_ID_COMANDA
AND ID_ARTICOL = V_ID_ARTICOL AND ID_ARTICOL = V_ID_ARTICOL
AND NVL(PTVA,0) = NVL(V_PTVA,0)
AND PRET = V_PRET2
AND SIGN(CANTITATE) = SIGN(V_CANTITATE)
AND STERS = 0; AND STERS = 0;
IF V_NR_INREG > 0 THEN IF V_NR_INREG > 0 THEN

View File

@@ -10,6 +10,8 @@
-- Tabele: ARTICOLE_TERTI (mapari SKU -> CODMAT) -- Tabele: ARTICOLE_TERTI (mapari SKU -> CODMAT)
-- NOM_ARTICOLE (nomenclator articole ROA) -- NOM_ARTICOLE (nomenclator articole ROA)
-- COMENZI (verificare duplicat comanda_externa) -- COMENZI (verificare duplicat comanda_externa)
-- CRM_POLITICI_PRETURI (flag PRETURI_CU_TVA per politica)
-- CRM_POLITICI_PRET_ART (preturi componente kituri)
-- --
-- Proceduri publice: -- Proceduri publice:
-- --
@@ -25,9 +27,21 @@
-- La eroare ridica RAISE_APPLICATION_ERROR(-20001, mesaj). -- La eroare ridica RAISE_APPLICATION_ERROR(-20001, mesaj).
-- Returneaza v_id_comanda (OUT) = ID-ul comenzii create. -- 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: -- Logica cautare articol per SKU:
-- 1. Mapari speciale din ARTICOLE_TERTI (reimpachetare, seturi compuse) -- 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 -- 2. Fallback: cautare directa in NOM_ARTICOLE dupa CODMAT = SKU
-- --
-- get_last_error / clear_error -- get_last_error / clear_error
@@ -62,6 +76,10 @@ CREATE OR REPLACE PACKAGE PACK_IMPORT_COMENZI AS
p_id_pol IN NUMBER DEFAULT NULL, p_id_pol IN NUMBER DEFAULT NULL,
p_id_sectie IN NUMBER DEFAULT NULL, p_id_sectie IN NUMBER DEFAULT NULL,
p_id_gestiune IN VARCHAR2 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); v_id_comanda OUT NUMBER);
-- Functii pentru managementul erorilor (pentru orchestrator VFP) -- 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_id_util CONSTANT NUMBER := -3; -- Sistem
c_interna CONSTANT NUMBER := 2; -- Comenzi de la client (web) 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 -- Functii helper pentru managementul erorilor
-- ================================================================ -- ================================================================
@@ -155,6 +185,10 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
p_id_pol IN NUMBER DEFAULT NULL, p_id_pol IN NUMBER DEFAULT NULL,
p_id_sectie IN NUMBER DEFAULT NULL, p_id_sectie IN NUMBER DEFAULT NULL,
p_id_gestiune IN VARCHAR2 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_id_comanda OUT NUMBER) IS
v_data_livrare DATE; v_data_livrare DATE;
v_sku VARCHAR2(100); v_sku VARCHAR2(100);
@@ -173,6 +207,15 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
v_pret_unitar NUMBER; v_pret_unitar NUMBER;
v_id_pol_articol NUMBER; -- id_pol per articol (din JSON), prioritar fata de p_id_pol 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 -- pljson
l_json_articole CLOB := p_json_articole; l_json_articole CLOB := p_json_articole;
v_json_arr pljson_list; v_json_arr pljson_list;
@@ -256,15 +299,255 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
END; END;
-- STEP 3: Gaseste articolele ROA pentru acest SKU -- STEP 3: Gaseste articolele ROA pentru acest SKU
-- Cauta mai intai in ARTICOLE_TERTI (mapari speciale / seturi)
v_found_mapping := FALSE; v_found_mapping := FALSE;
FOR rec IN (SELECT at.codmat, at.cantitate_roa, at.procent_pret -- 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_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
FOR rec IN (SELECT at.codmat, at.cantitate_roa
FROM articole_terti at FROM articole_terti at
WHERE at.sku = v_sku WHERE at.sku = v_sku
AND at.activ = 1 AND at.activ = 1
AND at.sters = 0 AND at.sters = 0
ORDER BY at.procent_pret DESC) LOOP 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_found_mapping := TRUE;
v_id_articol := resolve_id_articol(rec.codmat, p_id_gestiune); v_id_articol := resolve_id_articol(rec.codmat, p_id_gestiune);
@@ -277,7 +560,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
v_cantitate_roa := rec.cantitate_roa * v_cantitate_web; v_cantitate_roa := rec.cantitate_roa * v_cantitate_web;
v_pret_unitar := CASE WHEN v_pret_web IS NOT NULL v_pret_unitar := CASE WHEN v_pret_web IS NOT NULL
THEN (v_pret_web * rec.procent_pret / 100) / rec.cantitate_roa THEN v_pret_web / rec.cantitate_roa
ELSE 0 ELSE 0
END; END;
@@ -299,7 +582,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
END; END;
END LOOP; END LOOP;
-- Daca nu s-a gasit mapare, cauta direct in NOM_ARTICOLE via resolve_id_articol -- Daca nu s-a gasit mapare in ARTICOLE_TERTI, cauta direct in NOM_ARTICOLE
IF NOT v_found_mapping THEN IF NOT v_found_mapping THEN
v_id_articol := resolve_id_articol(v_sku, p_id_gestiune); v_id_articol := resolve_id_articol(v_sku, p_id_gestiune);
IF v_id_articol IS NULL THEN IF v_id_articol IS NULL THEN
@@ -324,10 +607,12 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
WHEN OTHERS THEN WHEN OTHERS THEN
v_articole_eroare := v_articole_eroare + 1; v_articole_eroare := v_articole_eroare + 1;
g_last_error := g_last_error || CHR(10) || g_last_error := g_last_error || CHR(10) ||
'Eroare adaugare articol ' || v_sku || ' (CODMAT: ' || v_codmat || '): ' || SQLERRM; 'Eroare adaugare articol ' || v_sku ||
' (CODMAT: ' || v_codmat || '): ' || SQLERRM;
END; END;
END IF; END IF;
END IF; END IF;
END IF; -- end kit vs simplu
END; -- End BEGIN block pentru articol individual END; -- End BEGIN block pentru articol individual

View File

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

View File

@@ -10,6 +10,8 @@ CREATE OR REPLACE PACKAGE "PACK_FACTURARE" is
-- nTipIncasare: scrie_incsare2 -- nTipIncasare: scrie_incsare2
-- descarca_gestiune - tva adaos -- descarca_gestiune - tva adaos
-- 20.03.2026 - duplicate CODMAT pe comanda: PRET in GROUP BY/JOIN (cursor_comanda, cursor_lucrare, inchide_comanda, adauga_articol_*)
cnume_program VARCHAR(30) := 'ROAFACTURARE'; cnume_program VARCHAR(30) := 'ROAFACTURARE';
TYPE cursor_facturare IS REF CURSOR; TYPE cursor_facturare IS REF CURSOR;
@@ -3034,15 +3036,15 @@ CREATE OR REPLACE PACKAGE BODY "PACK_FACTURARE" is
ON B.ID_POL = G.ID_POL ON B.ID_POL = G.ID_POL
LEFT JOIN NOM_ARTICOLE C LEFT JOIN NOM_ARTICOLE C
ON A.ID_ARTICOL = C.ID_ARTICOL ON A.ID_ARTICOL = C.ID_ARTICOL
LEFT JOIN (SELECT B1.ID_ARTICOL, SUM(B1.CANTITATE) AS CANTITATE LEFT JOIN (SELECT B1.ID_ARTICOL, B1.PRET, SUM(B1.CANTITATE) AS CANTITATE
FROM VANZARI A1 FROM VANZARI A1
LEFT JOIN VANZARI_DETALII B1 LEFT JOIN VANZARI_DETALII B1
ON A1.ID_VANZARE = B1.ID_VANZARE ON A1.ID_VANZARE = B1.ID_VANZARE
AND B1.STERS = 0 AND B1.STERS = 0
WHERE A1.STERS = 0 WHERE A1.STERS = 0
AND A1.ID_COMANDA = V_ID_COMANDA AND A1.ID_COMANDA = V_ID_COMANDA
GROUP BY B1.ID_ARTICOL) D GROUP BY B1.ID_ARTICOL, B1.PRET) D
ON A.ID_ARTICOL = D.ID_ARTICOL ON A.ID_ARTICOL = D.ID_ARTICOL AND A.PRET = D.PRET
LEFT JOIN (SELECT ID_VALUTA, CURS, MULTIPLICATOR LEFT JOIN (SELECT ID_VALUTA, CURS, MULTIPLICATOR
FROM CURS FROM CURS
WHERE DATA <= V_DATA_CURS WHERE DATA <= V_DATA_CURS
@@ -3121,15 +3123,15 @@ CREATE OR REPLACE PACKAGE BODY "PACK_FACTURARE" is
ON B.ID_POL = G.ID_POL ON B.ID_POL = G.ID_POL
LEFT JOIN NOM_ARTICOLE C LEFT JOIN NOM_ARTICOLE C
ON A.ID_ARTICOL = C.ID_ARTICOL ON A.ID_ARTICOL = C.ID_ARTICOL
LEFT JOIN (SELECT B1.ID_ARTICOL, SUM(B1.CANTITATE) AS CANTITATE LEFT JOIN (SELECT B1.ID_ARTICOL, B1.PRET, SUM(B1.CANTITATE) AS CANTITATE
FROM VANZARI A1 FROM VANZARI A1
LEFT JOIN VANZARI_DETALII B1 LEFT JOIN VANZARI_DETALII B1
ON A1.ID_VANZARE = B1.ID_VANZARE ON A1.ID_VANZARE = B1.ID_VANZARE
AND B1.STERS = 0 AND B1.STERS = 0
WHERE A1.STERS = 0 WHERE A1.STERS = 0
AND A1.ID_COMANDA = V_ID_COMANDA AND A1.ID_COMANDA = V_ID_COMANDA
GROUP BY B1.ID_ARTICOL) D GROUP BY B1.ID_ARTICOL, B1.PRET) D
ON A.ID_ARTICOL = D.ID_ARTICOL ON A.ID_ARTICOL = D.ID_ARTICOL AND A.PRET = D.PRET
LEFT JOIN (SELECT ID_VALUTA, CURS, MULTIPLICATOR LEFT JOIN (SELECT ID_VALUTA, CURS, MULTIPLICATOR
FROM CURS FROM CURS
WHERE DATA <= V_DATA_CURS WHERE DATA <= V_DATA_CURS
@@ -3362,15 +3364,17 @@ CREATE OR REPLACE PACKAGE BODY "PACK_FACTURARE" is
ON A.ID_ARTICOL = C.ID_ARTICOL ON A.ID_ARTICOL = C.ID_ARTICOL
LEFT JOIN (SELECT B1.ID_ARTICOL, LEFT JOIN (SELECT B1.ID_ARTICOL,
A1.ID_COMANDA, A1.ID_COMANDA,
B1.PRET,
SUM(B1.CANTITATE) AS CANTITATE SUM(B1.CANTITATE) AS CANTITATE
FROM VANZARI A1 FROM VANZARI A1
LEFT JOIN VANZARI_DETALII B1 LEFT JOIN VANZARI_DETALII B1
ON A1.ID_VANZARE = B1.ID_VANZARE ON A1.ID_VANZARE = B1.ID_VANZARE
AND B1.STERS = 0 AND B1.STERS = 0
WHERE A1.STERS = 0 WHERE A1.STERS = 0
GROUP BY B1.ID_ARTICOL, A1.ID_COMANDA) D GROUP BY B1.ID_ARTICOL, A1.ID_COMANDA, B1.PRET) D
ON A.ID_ARTICOL = D.ID_ARTICOL ON A.ID_ARTICOL = D.ID_ARTICOL
AND A.ID_COMANDA = D.ID_COMANDA AND A.ID_COMANDA = D.ID_COMANDA
AND A.PRET = D.PRET
LEFT JOIN (SELECT ID_ARTICOL, LEFT JOIN (SELECT ID_ARTICOL,
SUM(CANTS + CANT - CANTE) AS CANT_STOC, SUM(CANTS + CANT - CANTE) AS CANT_STOC,
CONT CONT
@@ -3510,15 +3514,17 @@ CREATE OR REPLACE PACKAGE BODY "PACK_FACTURARE" is
ON A.ID_ARTICOL = C.ID_ARTICOL ON A.ID_ARTICOL = C.ID_ARTICOL
LEFT JOIN (SELECT B1.ID_ARTICOL, LEFT JOIN (SELECT B1.ID_ARTICOL,
A1.ID_COMANDA, A1.ID_COMANDA,
B1.PRET,
SUM(B1.CANTITATE) AS CANTITATE SUM(B1.CANTITATE) AS CANTITATE
FROM VANZARI A1 FROM VANZARI A1
LEFT JOIN VANZARI_DETALII B1 LEFT JOIN VANZARI_DETALII B1
ON A1.ID_VANZARE = B1.ID_VANZARE ON A1.ID_VANZARE = B1.ID_VANZARE
AND B1.STERS = 0 AND B1.STERS = 0
WHERE A1.STERS = 0 WHERE A1.STERS = 0
GROUP BY B1.ID_ARTICOL, A1.ID_COMANDA) D GROUP BY B1.ID_ARTICOL, A1.ID_COMANDA, B1.PRET) D
ON A.ID_ARTICOL = D.ID_ARTICOL ON A.ID_ARTICOL = D.ID_ARTICOL
AND A.ID_COMANDA = D.ID_COMANDA AND A.ID_COMANDA = D.ID_COMANDA
AND A.PRET = D.PRET
LEFT JOIN (SELECT ID_ARTICOL, LEFT JOIN (SELECT ID_ARTICOL,
SUM(CANTS + CANT - CANTE) AS CANT_STOC, SUM(CANTS + CANT - CANTE) AS CANT_STOC,
CONT CONT
@@ -4867,6 +4873,7 @@ CREATE OR REPLACE PACKAGE BODY "PACK_FACTURARE" is
WHERE A.ID_COMANDA = V_ID_COMANDA WHERE A.ID_COMANDA = V_ID_COMANDA
AND A.ID_ARTICOL = V_ID_ARTICOL AND A.ID_ARTICOL = V_ID_ARTICOL
AND A.ID_POL = V_ID_POL AND A.ID_POL = V_ID_POL
AND A.PRET = V_PRETIN
AND A.STERS = 0; AND A.STERS = 0;
EXCEPTION EXCEPTION
WHEN TOO_MANY_ROWS THEN WHEN TOO_MANY_ROWS THEN
@@ -5044,6 +5051,7 @@ CREATE OR REPLACE PACKAGE BODY "PACK_FACTURARE" is
ON A.ID_ARTICOL = D.ID_ARTICOL ON A.ID_ARTICOL = D.ID_ARTICOL
WHERE A.ID_COMANDA = V_ID_COMANDA WHERE A.ID_COMANDA = V_ID_COMANDA
AND A.ID_ARTICOL = V_ID_ARTICOL AND A.ID_ARTICOL = V_ID_ARTICOL
AND A.PRET = V_PRET_TEMP
AND A.STERS = 0; AND A.STERS = 0;
WHEN pack_facturare.ntip = 4 THEN WHEN pack_facturare.ntip = 4 THEN
@@ -5758,15 +5766,18 @@ CREATE OR REPLACE PACKAGE BODY "PACK_FACTURARE" is
LEFT JOIN (SELECT ID_ARTICOL, LEFT JOIN (SELECT ID_ARTICOL,
ID_POL, ID_POL,
ID_VALUTA, ID_VALUTA,
PRET,
SUM(CANTITATE) AS CANTITATE SUM(CANTITATE) AS CANTITATE
FROM VANZARI_DETALII_TEMP FROM VANZARI_DETALII_TEMP
GROUP BY ID_ARTICOL, ID_POL, ID_VALUTA) B GROUP BY ID_ARTICOL, ID_POL, ID_VALUTA, PRET) B
ON A.ID_ARTICOL = B.ID_ARTICOL ON A.ID_ARTICOL = B.ID_ARTICOL
AND A.ID_POL = B.ID_POL AND A.ID_POL = B.ID_POL
AND A.ID_VALUTA = B.ID_VALUTA AND A.ID_VALUTA = B.ID_VALUTA
AND A.PRET = B.PRET
LEFT JOIN (SELECT B.ID_ARTICOL, LEFT JOIN (SELECT B.ID_ARTICOL,
B.ID_POL, B.ID_POL,
B.ID_VALUTA, B.ID_VALUTA,
B.PRET,
SUM(B.CANTITATE) AS CANTITATE SUM(B.CANTITATE) AS CANTITATE
FROM VANZARI A FROM VANZARI A
LEFT JOIN VANZARI_DETALII B LEFT JOIN VANZARI_DETALII B
@@ -5774,10 +5785,11 @@ CREATE OR REPLACE PACKAGE BODY "PACK_FACTURARE" is
AND B.STERS = 0 AND B.STERS = 0
WHERE A.ID_COMANDA = to_number(pack_facturare.clistaid) WHERE A.ID_COMANDA = to_number(pack_facturare.clistaid)
AND A.STERS = 0 AND A.STERS = 0
GROUP BY B.ID_ARTICOL, B.ID_POL, B.ID_VALUTA) C GROUP BY B.ID_ARTICOL, B.ID_POL, B.ID_VALUTA, B.PRET) C
ON A.ID_ARTICOL = C.ID_ARTICOL ON A.ID_ARTICOL = C.ID_ARTICOL
AND A.ID_POL = C.ID_POL AND A.ID_POL = C.ID_POL
AND A.ID_VALUTA = C.ID_VALUTA AND A.ID_VALUTA = C.ID_VALUTA
AND A.PRET = C.PRET
WHERE A.STERS = 0 WHERE A.STERS = 0
AND A.ID_COMANDA = to_number(pack_facturare.clistaid) AND A.ID_COMANDA = to_number(pack_facturare.clistaid)
AND A.CANTITATE > NVL(C.CANTITATE, 0) + NVL(B.CANTITATE, 0); AND A.CANTITATE > NVL(C.CANTITATE, 0) + NVL(B.CANTITATE, 0);

View File

@@ -0,0 +1,79 @@
-- =============================================================================
-- Script mapari articole GoMag → ROA
-- Generat: 2026-03-19
-- Baza: vending | Server: vending
-- =============================================================================
-- =============================================
-- PARTEA 1: Update CODMAT in NOM_ARTICOLE
-- =============================================
-- id=2020 LAVAZZA BBE EXPERT GUSTO FORTE — CODMAT lipseste (NULL)
UPDATE nom_articole SET codmat = '8000070028685' WHERE id_articol = 2020 AND codmat IS NULL;
-- id=4345 MY POS SIGMA — lowercase ca sa fie identic cu SKU GoMag
UPDATE nom_articole SET codmat = 'mypossigma' WHERE id_articol = 4345 AND codmat = 'MYPOSSIGMA';
-- =============================================
-- PARTEA 2: Mapari ARTICOLE_TERTI (sku != codmat)
-- =============================================
-- Fresso — EAN-uri diferite de codmat intern
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031026295', 'FRSBRZ1000', 1, 1, 0); -- Fresso Brazilia 1kg
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031062538', 'FRSEVK1000', 1, 1, 0); -- Fresso Evoke blend 1kg
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031026325', 'FRSCLB1000', 1, 1, 0); -- Fresso Columbia Caldas 1kg
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031026356', 'FRSCRA1000', 1, 1, 0); -- Fresso Costa Rica Tarrazu 1kg
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031026462', 'FRSETP250', 1, 1, 0); -- Fresso Etiopia 250g
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031026479', 'FRSETP500', 1, 1, 0); -- Fresso Etiopia 500g
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031026486', 'FRSETP1000', 1, 1, 0); -- Fresso Etiopia 1kg
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031044138', 'FRSEVK250', 1, 1, 0); -- Fresso Evoke blend 250g
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('59400310625381000MI', 'FRSEVK1000', 1, 1, 0); -- Fresso Evoke macinata 1kg
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('FBS500PE', 'FRSBRZ500', 1, 1, 0); -- Fresso Brazilia 500g macinata
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('FEY250PI', 'FRSETP250', 1, 1, 0); -- Fresso Etiopia 250g macinata
-- Tchibo / Lavazza / alte branduri — EAN-uri diferite
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('4006067176463', 'SUISSE500', 1, 1, 0); -- Tchibo Cafe Creme Suisse 500g
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('69891863', '8000070038493', 1, 1, 0); -- Lavazza Crema e Gusto Forte 1Kg
-- Piese / accesorii — coduri diferite
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('65221', '33.7006.5221', 1, 1, 0); -- Pastile curatare Schaerer
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('C7774', 'COL100', 1, 1, 0); -- Eticheta colant cu pret
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('MEICF7900', 'MEICF560', 1, 1, 0); -- Restiera MEI Cashflow CF 7900
-- =============================================
-- PARTEA 3: Mapari ARTICOLE_TERTI — impachetari diferite (cantitate != 1)
-- =============================================
-- Prolait/Regilait/Ristora 500g — ROA tine in KG sau BUC, 500g = 0.5
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('8004990125530', '8004990125530', 0.5, 1, 0); -- Prolait Topping Blue 500g (UM=KG)
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('3043937103250', '3043937103250', 0.5, 1, 0); -- Regilait Topping 2 Green 500g (UM=KG)
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('8004990123680', '8004990123680', 0.5, 1, 0); -- Ristora Top Lapte Granulat 500g
-- Pahare — baxuri mari (1 bax web = N seturi ROA de 100buc)
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('10008ozparis', '10573080', 10, 1, 0); -- Pahar 8oz Paris bax 1000 = 10 seturi
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('100012ozlvzJND', '58912326634', 10, 1, 0); -- Pahar 12oz Lavazza JND bax 1000 = 10 seturi
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('589123214745675', '8OZLRLP', 10, 1, 0); -- Pahar 8oz Lavazza RLP bax 1000 = 10 seturi
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('10008ozTchibo', '58', 10, 1, 0); -- Pahar 8oz Tchibo bax 1000 = 10 seturi
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('10008ozBlueJND', '105712338826', 10, 1, 0); -- Pahar 8oz Albastru JND bax 1000 = 10 seturi
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('30006ozLavazza', '169', 30, 1, 0); -- Pahar 6oz Lavazza RLP bax 3000 = 30 seturi
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('30007ozLavazza', '1655455', 30, 1, 0); -- Pahar 7oz Lavazza RLP bax 3000 = 30 seturi
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('22507ozLavazza', '51', 22.5, 1, 0); -- Pahar 7oz Lavazza SIBA bax 2250 = 22.5 seturi
-- Pahare — ambalaje mici (50buc = 0.5 set de 100)
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5891232122239', '8OZLRLP', 0.5, 1, 0); -- Pahar 8oz Albastru RLP 50buc = 0.5 set
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('87872376', '87872376', 0.5, 1, 0); -- Pahar 7oz Lavazza JND 50buc = 0.5 set
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('6ozFloMAZ', '6OZFLOMAZ', 0.5, 1, 0); -- Pahar 6oz Floral MAZ 50buc = 0.5 set
-- Pachet cafea
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('6ktcs', 'SUISSE500', 10, 1, 0); -- Pachet 5kg Tchibo Suisse = 10x500g
COMMIT;
-- =============================================
-- VERIFICARE
-- =============================================
-- SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa, at.activ
-- FROM ARTICOLE_TERTI at
-- LEFT JOIN nom_articole na ON na.codmat = at.codmat AND na.sters = 0
-- WHERE at.sters = 0
-- ORDER BY at.sku;