From 25aa9e544c98263fb3454d2dc6841f33af69d3e0 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Mon, 16 Mar 2026 10:15:17 +0000 Subject: [PATCH] feat(sync): add delivery cost, discount tracking and import settings Parse delivery.total and discounts[] from GoMag JSON into new delivery_cost/discount_total fields. Add app_settings table for configuring transport/discount CODMAT codes. When configured, transport and discount are appended as extra articles in the Oracle import JSON. Reorder Total column in dashboard/logs tables and show transport/discount breakdown in order detail modals. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/app/database.py | 11 +++- api/app/routers/sync.py | 26 ++++++++ api/app/services/import_service.py | 34 ++++++++-- api/app/services/order_reader.py | 13 ++++ api/app/services/sqlite_service.py | 51 ++++++++++++--- api/app/services/sync_service.py | 14 +++- api/app/static/js/dashboard.js | 100 ++++++++++++++++++++++++++++- api/app/static/js/logs.js | 22 ++++++- api/app/templates/dashboard.html | 45 ++++++++++++- api/app/templates/logs.html | 8 ++- 10 files changed, 302 insertions(+), 22 deletions(-) diff --git a/api/app/database.py b/api/app/database.py index 93048d3..8f520ae 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -104,7 +104,9 @@ CREATE TABLE IF NOT EXISTS orders ( factura_total_tva REAL, factura_total_cu_tva REAL, invoice_checked_at TEXT, - order_total REAL + order_total REAL, + delivery_cost REAL, + discount_total REAL ); CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date); @@ -140,6 +142,11 @@ CREATE TABLE IF NOT EXISTS web_products ( order_count INTEGER DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT +); + CREATE TABLE IF NOT EXISTS order_items ( order_number TEXT, sku TEXT, @@ -303,6 +310,8 @@ def init_sqlite(): ("factura_total_cu_tva", "REAL"), ("invoice_checked_at", "TEXT"), ("order_total", "REAL"), + ("delivery_cost", "REAL"), + ("discount_total", "REAL"), ]: if col not in order_cols: conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}") diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py index 9b1d316..df6e09d 100644 --- a/api/app/routers/sync.py +++ b/api/app/routers/sync.py @@ -20,6 +20,12 @@ class ScheduleConfig(BaseModel): interval_minutes: int = 5 +class AppSettingsUpdate(BaseModel): + transport_codmat: str = "" + transport_vat: str = "21" + discount_codmat: str = "" + + # API endpoints @router.post("/api/sync/start") async def start_sync(background_tasks: BackgroundTasks): @@ -429,3 +435,23 @@ async def update_schedule(config: ScheduleConfig): async def get_schedule(): """Get current scheduler status.""" return scheduler_service.get_scheduler_status() + + +@router.get("/api/settings") +async def get_app_settings(): + """Get application settings.""" + settings = await sqlite_service.get_app_settings() + return { + "transport_codmat": settings.get("transport_codmat", ""), + "transport_vat": settings.get("transport_vat", "21"), + "discount_codmat": settings.get("discount_codmat", ""), + } + + +@router.put("/api/settings") +async def update_app_settings(config: AppSettingsUpdate): + """Update application settings.""" + await sqlite_service.set_app_setting("transport_codmat", config.transport_codmat) + await sqlite_service.set_app_setting("transport_vat", config.transport_vat) + await sqlite_service.set_app_setting("discount_codmat", config.discount_codmat) + return {"success": True} diff --git a/api/app/services/import_service.py b/api/app/services/import_service.py index f99c64d..2fa83bf 100644 --- a/api/app/services/import_service.py +++ b/api/app/services/import_service.py @@ -60,8 +60,9 @@ def format_address_for_oracle(address: str, city: str, region: str) -> str: return f"JUD:{region_clean};{city_clean};{address_clean}" -def build_articles_json(items) -> str: - """Build JSON string for Oracle PACK_IMPORT_COMENZI.importa_comanda.""" +def build_articles_json(items, order=None, settings=None) -> str: + """Build JSON string for Oracle PACK_IMPORT_COMENZI.importa_comanda. + Includes transport and discount as extra articles if configured.""" articles = [] for item in items: articles.append({ @@ -71,10 +72,35 @@ def build_articles_json(items) -> str: "vat": str(item.vat), "name": clean_web_text(item.name) }) + + if order and settings: + transport_codmat = settings.get("transport_codmat", "") + transport_vat = settings.get("transport_vat", "21") + discount_codmat = settings.get("discount_codmat", "") + + # Transport as article with quantity +1 + if order.delivery_cost > 0 and transport_codmat: + articles.append({ + "sku": transport_codmat, + "quantity": "1", + "price": str(order.delivery_cost), + "vat": transport_vat, + "name": "Transport" + }) + # Discount total with quantity -1 (positive price) + if order.discount_total > 0 and discount_codmat: + articles.append({ + "sku": discount_codmat, + "quantity": "-1", + "price": str(order.discount_total), + "vat": "21", + "name": "Discount" + }) + return json.dumps(articles) -def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dict: +def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_settings: dict = None) -> dict: """Import a single order into Oracle ROA. Returns dict with: @@ -203,7 +229,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic result["id_adresa_livrare"] = int(addr_livr_id) # Step 4: Build articles JSON and import order - articles_json = build_articles_json(order.items) + articles_json = build_articles_json(order.items, order, app_settings) # Use CLOB for the JSON clob_var = cur.var(oracledb.DB_TYPE_CLOB) diff --git a/api/app/services/order_reader.py b/api/app/services/order_reader.py index ce85b9d..8c12787 100644 --- a/api/app/services/order_reader.py +++ b/api/app/services/order_reader.py @@ -55,6 +55,8 @@ class OrderData: billing: OrderBilling = field(default_factory=OrderBilling) shipping: Optional[OrderShipping] = None total: float = 0.0 + delivery_cost: float = 0.0 + discount_total: float = 0.0 payment_name: str = "" delivery_name: str = "" source_file: str = "" @@ -155,6 +157,15 @@ def _parse_order(order_id: str, data: dict, source_file: str) -> OrderData: payment = data.get("payment", {}) or {} delivery = data.get("delivery", {}) or {} + # Parse delivery cost + delivery_cost = float(delivery.get("total", 0) or 0) if isinstance(delivery, dict) else 0.0 + + # Parse discount total (sum of all discount values) + discount_total = 0.0 + for d in data.get("discounts", []): + if isinstance(d, dict): + discount_total += float(d.get("value", 0) or 0) + return OrderData( id=str(data.get("id", order_id)), number=str(data.get("number", "")), @@ -165,6 +176,8 @@ def _parse_order(order_id: str, data: dict, source_file: str) -> OrderData: billing=billing, shipping=shipping, total=float(data.get("total", 0) or 0), + delivery_cost=delivery_cost, + discount_total=discount_total, payment_name=str(payment.get("name", "")) if isinstance(payment, dict) else "", delivery_name=str(delivery.get("name", "")) if isinstance(delivery, dict) else "", source_file=source_file diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index 7d4b573..6093f70 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -51,7 +51,8 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str, missing_skus: list = None, items_count: int = 0, shipping_name: str = None, billing_name: str = None, payment_method: str = None, delivery_method: str = None, - order_total: float = None): + order_total: float = None, + delivery_cost: float = None, discount_total: float = None): """Upsert a single order — one row per order_number, status updated in place.""" db = await get_sqlite() try: @@ -60,8 +61,9 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str, (order_number, order_date, customer_name, status, id_comanda, id_partener, error_message, missing_skus, items_count, last_sync_run_id, shipping_name, billing_name, - payment_method, delivery_method, order_total) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + payment_method, delivery_method, order_total, + delivery_cost, discount_total) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(order_number) DO UPDATE SET status = CASE WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED' @@ -82,12 +84,15 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str, payment_method = COALESCE(excluded.payment_method, orders.payment_method), delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method), order_total = COALESCE(excluded.order_total, orders.order_total), + delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost), + discount_total = COALESCE(excluded.discount_total, orders.discount_total), updated_at = datetime('now') """, (order_number, order_date, customer_name, status, id_comanda, id_partener, error_message, json.dumps(missing_skus) if missing_skus else None, items_count, sync_run_id, shipping_name, billing_name, - payment_method, delivery_method, order_total)) + payment_method, delivery_method, order_total, + delivery_cost, discount_total)) await db.commit() finally: await db.close() @@ -112,7 +117,7 @@ async def save_orders_batch(orders_data: list[dict]): Each dict must have: sync_run_id, order_number, order_date, customer_name, status, id_comanda, id_partener, error_message, missing_skus (list|None), items_count, shipping_name, billing_name, payment_method, delivery_method, status_at_run, - items (list of item dicts). + items (list of item dicts), delivery_cost (optional), discount_total (optional). """ if not orders_data: return @@ -124,8 +129,9 @@ async def save_orders_batch(orders_data: list[dict]): (order_number, order_date, customer_name, status, id_comanda, id_partener, error_message, missing_skus, items_count, last_sync_run_id, shipping_name, billing_name, - payment_method, delivery_method, order_total) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + payment_method, delivery_method, order_total, + delivery_cost, discount_total) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(order_number) DO UPDATE SET status = CASE WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED' @@ -146,6 +152,8 @@ async def save_orders_batch(orders_data: list[dict]): payment_method = COALESCE(excluded.payment_method, orders.payment_method), delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method), order_total = COALESCE(excluded.order_total, orders.order_total), + delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost), + discount_total = COALESCE(excluded.discount_total, orders.discount_total), updated_at = datetime('now') """, [ (d["order_number"], d["order_date"], d["customer_name"], d["status"], @@ -154,7 +162,8 @@ async def save_orders_batch(orders_data: list[dict]): d.get("items_count", 0), d["sync_run_id"], d.get("shipping_name"), d.get("billing_name"), d.get("payment_method"), d.get("delivery_method"), - d.get("order_total")) + d.get("order_total"), + d.get("delivery_cost"), d.get("discount_total")) for d in orders_data ]) @@ -768,3 +777,29 @@ async def update_order_invoice(order_number: str, serie: str = None, await db.commit() finally: await db.close() + + +# ── App Settings ───────────────────────────────── + +async def get_app_settings() -> dict: + """Get all app settings as a dict.""" + db = await get_sqlite() + try: + cursor = await db.execute("SELECT key, value FROM app_settings") + rows = await cursor.fetchall() + return {row["key"]: row["value"] for row in rows} + finally: + await db.close() + + +async def set_app_setting(key: str, value: str): + """Set a single app setting value.""" + db = await get_sqlite() + try: + await db.execute(""" + INSERT OR REPLACE INTO app_settings (key, value) + VALUES (?, ?) + """, (key, value)) + await db.commit() + finally: + await db.close() diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index dc96e6d..05908a4 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -287,6 +287,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None "shipping_name": shipping_name, "billing_name": billing_name, "payment_method": payment_method, "delivery_method": delivery_method, "order_total": order.total or None, + "delivery_cost": order.delivery_cost or None, + "discount_total": order.discount_total or None, "items": order_items_data, }) _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → DEJA IMPORTAT (ID: {id_comanda_roa})") @@ -315,6 +317,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None "shipping_name": shipping_name, "billing_name": billing_name, "payment_method": payment_method, "delivery_method": delivery_method, "order_total": order.total or None, + "delivery_cost": order.delivery_cost or None, + "discount_total": order.discount_total or None, "items": order_items_data, }) _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})") @@ -327,6 +331,9 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None imported_count = 0 error_count = 0 + # Load app settings for transport/discount CODMAT config + app_settings = await sqlite_service.get_app_settings() + for i, order in enumerate(truly_importable): shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order) @@ -338,7 +345,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None result = await asyncio.to_thread( import_service.import_single_order, - order, id_pol=id_pol, id_sectie=id_sectie + order, id_pol=id_pol, id_sectie=id_sectie, + app_settings=app_settings ) # Build order items data for storage (R9) @@ -368,6 +376,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None payment_method=payment_method, delivery_method=delivery_method, order_total=order.total or None, + delivery_cost=order.delivery_cost or None, + discount_total=order.discount_total or None, ) await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED") # Store ROA address IDs (R9) @@ -394,6 +404,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None payment_method=payment_method, delivery_method=delivery_method, order_total=order.total or None, + delivery_cost=order.delivery_cost or None, + discount_total=order.discount_total or None, ) await sqlite_service.add_sync_run_order(run_id, order.number, "ERROR") await sqlite_service.add_order_items(order.number, order_items_data) diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js index 380deb2..7cde8a6 100644 --- a/api/app/static/js/dashboard.js +++ b/api/app/static/js/dashboard.js @@ -313,10 +313,10 @@ async function loadDashOrders() { ${dateStr} ${renderClientCell(o)} ${o.items_count || 0} + ${orderTotal} ${statusDot(o.status)} ${statusLabelText(o.status)} ${o.id_comanda || '-'} ${invoiceBadge} - ${orderTotal} `; }).join(''); } @@ -479,6 +479,10 @@ async function openDashOrderDetail(orderNumber) { if (detailItemsTotal) detailItemsTotal.textContent = '-'; const detailOrderTotal = document.getElementById('detailOrderTotal'); if (detailOrderTotal) detailOrderTotal.textContent = '-'; + const deliveryWrap = document.getElementById('detailDeliveryWrap'); + if (deliveryWrap) deliveryWrap.style.display = 'none'; + const discountWrap = document.getElementById('detailDiscountWrap'); + if (discountWrap) discountWrap.style.display = 'none'; const mobileContainer = document.getElementById('detailItemsMobile'); if (mobileContainer) mobileContainer.innerHTML = ''; @@ -510,6 +514,22 @@ async function openDashOrderDetail(orderNumber) { document.getElementById('detailError').style.display = ''; } + // Show delivery cost + const dlvWrap = document.getElementById('detailDeliveryWrap'); + const dlvEl = document.getElementById('detailDeliveryCost'); + if (order.delivery_cost && Number(order.delivery_cost) > 0) { + if (dlvEl) dlvEl.textContent = Number(order.delivery_cost).toFixed(2) + ' lei'; + if (dlvWrap) dlvWrap.style.display = ''; + } + + // Show discount + const dscWrap = document.getElementById('detailDiscountWrap'); + const dscEl = document.getElementById('detailDiscount'); + if (order.discount_total && Number(order.discount_total) > 0) { + if (dscEl) dscEl.textContent = '-' + Number(order.discount_total).toFixed(2) + ' lei'; + if (dscWrap) dscWrap.style.display = ''; + } + const items = data.items || []; if (items.length === 0) { document.getElementById('detailItemsBody').innerHTML = 'Niciun articol'; @@ -709,3 +729,81 @@ async function saveQuickMapping() { alert('Eroare: ' + err.message); } } + +// ── App Settings ───────────────────────────────── + +let settAcTimeout = null; + +document.addEventListener('DOMContentLoaded', () => { + loadAppSettings(); + wireSettingsAutocomplete('settTransportCodmat', 'settTransportAc'); + wireSettingsAutocomplete('settDiscountCodmat', 'settDiscountAc'); +}); + +async function loadAppSettings() { + try { + const res = await fetch('/api/settings'); + const data = await res.json(); + const el = (id) => document.getElementById(id); + if (el('settTransportCodmat')) el('settTransportCodmat').value = data.transport_codmat || ''; + if (el('settTransportVat')) el('settTransportVat').value = data.transport_vat || '21'; + if (el('settDiscountCodmat')) el('settDiscountCodmat').value = data.discount_codmat || ''; + } catch (err) { + console.error('loadAppSettings error:', err); + } +} + +async function saveAppSettings() { + const transport_codmat = document.getElementById('settTransportCodmat')?.value?.trim() || ''; + const transport_vat = document.getElementById('settTransportVat')?.value || '21'; + const discount_codmat = document.getElementById('settDiscountCodmat')?.value?.trim() || ''; + try { + const res = await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ transport_codmat, transport_vat, discount_codmat }) + }); + const data = await res.json(); + if (data.success) { + alert('Setari salvate!'); + } else { + alert('Eroare: ' + JSON.stringify(data)); + } + } catch (err) { + alert('Eroare salvare setari: ' + err.message); + } +} + +function wireSettingsAutocomplete(inputId, dropdownId) { + const input = document.getElementById(inputId); + const dropdown = document.getElementById(dropdownId); + if (!input || !dropdown) return; + + input.addEventListener('input', () => { + clearTimeout(settAcTimeout); + settAcTimeout = setTimeout(async () => { + const q = input.value.trim(); + 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 => + `
+ ${esc(r.codmat)}${esc(r.denumire)} +
` + ).join(''); + dropdown.classList.remove('d-none'); + } catch { dropdown.classList.add('d-none'); } + }, 250); + }); + + input.addEventListener('blur', () => { + setTimeout(() => dropdown.classList.add('d-none'), 200); + }); +} + +function settSelectArticle(inputId, dropdownId, codmat) { + document.getElementById(inputId).value = codmat; + document.getElementById(dropdownId).classList.add('d-none'); +} diff --git a/api/app/static/js/logs.js b/api/app/static/js/logs.js index e0a889a..72a1e53 100644 --- a/api/app/static/js/logs.js +++ b/api/app/static/js/logs.js @@ -162,8 +162,8 @@ async function loadRunOrders(runId, statusFilter, page) { ${esc(o.order_number)} ${esc(o.customer_name)} ${o.items_count || 0} + ${orderTotal} ${statusDot(o.status)} ${logStatusText(o.status)} - ${orderTotal} `; }).join(''); } @@ -324,6 +324,10 @@ async function openOrderDetail(orderNumber) { if (detailItemsTotal) detailItemsTotal.textContent = '-'; const detailOrderTotal = document.getElementById('detailOrderTotal'); if (detailOrderTotal) detailOrderTotal.textContent = '-'; + const deliveryWrap = document.getElementById('detailDeliveryWrap'); + if (deliveryWrap) deliveryWrap.style.display = 'none'; + const discountWrap = document.getElementById('detailDiscountWrap'); + if (discountWrap) discountWrap.style.display = 'none'; const mobileContainer = document.getElementById('detailItemsMobile'); if (mobileContainer) mobileContainer.innerHTML = ''; @@ -355,6 +359,22 @@ async function openOrderDetail(orderNumber) { document.getElementById('detailError').style.display = ''; } + // Show delivery cost + const dlvWrap = document.getElementById('detailDeliveryWrap'); + const dlvEl = document.getElementById('detailDeliveryCost'); + if (order.delivery_cost && Number(order.delivery_cost) > 0) { + if (dlvEl) dlvEl.textContent = Number(order.delivery_cost).toFixed(2) + ' lei'; + if (dlvWrap) dlvWrap.style.display = ''; + } + + // Show discount + const dscWrap = document.getElementById('detailDiscountWrap'); + const dscEl = document.getElementById('detailDiscount'); + if (order.discount_total && Number(order.discount_total) > 0) { + if (dscEl) dscEl.textContent = '-' + Number(order.discount_total).toFixed(2) + ' lei'; + if (dscWrap) dscWrap.style.display = ''; + } + const items = data.items || []; if (items.length === 0) { document.getElementById('detailItemsBody').innerHTML = 'Niciun articol'; diff --git a/api/app/templates/dashboard.html b/api/app/templates/dashboard.html index 6a879ab..093a939 100644 --- a/api/app/templates/dashboard.html +++ b/api/app/templates/dashboard.html @@ -84,10 +84,10 @@ Data Client Art. + Total Status Import ID ROA Factura - Total @@ -121,8 +121,10 @@ ID Adr. Livrare: - -
+
Valoare articole: - + + Total comanda: -
@@ -153,6 +155,43 @@
+ +
+
+ Setari Import + +
+
+
+
+ +
+ +
+
+ Lasa gol pentru a nu adauga transport la import +
+
+ + +
+
+ +
+ +
+
+ Lasa gol pentru a nu adauga discount la import +
+
+
+
+ -
+
Valoare articole: - + + Total comanda: -
@@ -181,5 +183,5 @@ {% endblock %} {% block scripts %} - + {% endblock %}