diff --git a/MDfile/RELEASE_NOTES_v2.8.12.md b/MDfile/RELEASE_NOTES_v2.8.12.md new file mode 100644 index 0000000..08b4d61 --- /dev/null +++ b/MDfile/RELEASE_NOTES_v2.8.12.md @@ -0,0 +1,21 @@ +# BMC Hub v2.8.12 + +## Eksportkvittering og afstemning + +- Gemmer e-conomic-kunde, valuta, layout, nettobeløb, request, response, ekstern reference, oprettede varer og brugeren, der eksporterede. +- Sender den interne idempotency-reference med ordrekladden til e-conomic. +- En ordre kan ikke længere markeres som eksporteret, bogført eller betalt uden et e-conomic-ordrenummer. +- Eksisterende poster i den ugyldige kombination flyttes til `uncertain`, så de skal afstemmes før en eventuel ny eksport. +- Gemmer og viser den seneste eksportfejl. + +## Ordrevisning + +- Viser kundenavn sammen med Hub-ID og e-conomic-kundenummer. +- Viser en kompakt eksportkvittering med ordrenummer, periode, valuta, layout, nettobeløb, bruger og reference. +- Viser eksporthistorik som læsbare beskrivelser i stedet for rå JSON. +- Skjuler fakturanummer indtil bogføring og skjuler tvungen geneksport efter gennemført eksport. +- Retter eksisterende ALSO-abonnementskladder til titlen `Abonnementer`. + +## Installation + +- Kør migration `245_ordre_export_receipt.sql` før applikationen opdateres. diff --git a/VERSION b/VERSION index 38bc521..56f5e1b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.11 +2.8.12 diff --git a/app/modules/orders/backend/economic_export.py b/app/modules/orders/backend/economic_export.py index ea0c150..7e2af35 100644 --- a/app/modules/orders/backend/economic_export.py +++ b/app/modules/orders/backend/economic_export.py @@ -130,6 +130,7 @@ class OrdreEconomicExportService: document_key: Optional[str] = None, currency: str = 'DKK', create_missing_products: Optional[Dict[str, int]] = None, + export_reference: Optional[str] = None, ) -> Dict[str, Any]: from app.products.backend.economic_documents import active_connection, export_document, preflight, unsaved_key connection = active_connection() @@ -178,6 +179,7 @@ class OrdreEconomicExportService: economic_lines: List[Dict[str, Any]] = [] creation_candidates: Dict[str, Dict[str, Any]] = {} + created_product_numbers: List[str] = [] for line in selected_lines: try: quantity = float(line.get("quantity") or 0) @@ -321,6 +323,7 @@ class OrdreEconomicExportService: if str(created.get("productNumber") or "") != number: raise HTTPException(502, f"e-conomic oprettede ikke det forventede varenummer {number}") valid_product_numbers.add(number) + created_product_numbers.append(number) _require_valid_product_numbers(product_numbers, valid_product_numbers) customer_layout = economic_customer.get("layout") or {} @@ -347,6 +350,8 @@ class OrdreEconomicExportService: if notes: payload["notes"] = {"textLine1": str(notes)[:1000]} + if export_reference: + payload["references"] = {"other": "BMC-HUB:" + str(export_reference)[:90]} if not write_allowed: return { @@ -400,6 +405,12 @@ class OrdreEconomicExportService: "skipped_product_numbers": missing_product_numbers, "missing_product_numbers": missing_product_numbers, "economic_response": export_result, + "request_payload": payload, + "economic_customer_number": customer_number, + "currency": payload["currency"], + "layout_number": int(resolved_layout_number), + "economic_order_url": export_result.get("self"), + "created_product_numbers": created_product_numbers, }, } diff --git a/app/modules/orders/backend/router.py b/app/modules/orders/backend/router.py index b40cdb9..07becdf 100644 --- a/app/modules/orders/backend/router.py +++ b/app/modules/orders/backend/router.py @@ -12,7 +12,7 @@ from app.modules.orders.backend.service import aggregate_order_lines logger = logging.getLogger(__name__) router = APIRouter() -ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"} +ALLOWED_SYNC_STATUSES = {"pending", "uncertain", "exported", "failed", "posted", "paid"} def _economic_order_number(export_result: Dict[str, Any]) -> Any: @@ -26,6 +26,11 @@ def _economic_order_number(export_result: Dict[str, Any]) -> Any: ) +def _validate_sync_status_number(sync_status: str, incoming: Any, existing: Any) -> None: + if sync_status in {"exported", "posted", "paid"} and not (incoming if incoming is not None else existing): + raise HTTPException(409, "Status kan ikke sættes til eksporteret uden et e-conomic-ordrenummer") + + def _snapshot_lines(lines): from app.products.backend.economic_documents import active_connection, snapshot_draft_lines return snapshot_draft_lines(lines) if active_connection() else lines @@ -227,6 +232,7 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request): document_key=f'order-draft:{request.draft_id}' if request.draft_id else None, currency=request.currency, create_missing_products=request.create_missing_products, + export_reference=export_idempotency_key, ) exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")] @@ -253,6 +259,16 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request): or export_result.get("invoice_number") or export_result.get("invoiceNumber") ) + if not export_result.get("dry_run") and not economic_order_number: + raise HTTPException(502, "e-conomic returnerede intet ordrenummer. Kladden er ikke markeret som eksporteret.") + details = export_result.get("details") or {} + request_payload = details.get("request_payload") or {} + response_payload = details.get("economic_response") or {} + net_amount = sum( + float(line.get("quantity") or 0) * float(line.get("unitNetPrice") or 0) + * (1 - float(line.get("discountPercentage") or 0) / 100) + for line in (request_payload.get("lines") or []) + ) target_sync_status = "pending" if export_result.get("dry_run") else "exported" execute_query( @@ -263,6 +279,17 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request): export_idempotency_key = %s, economic_order_number = COALESCE(%s, economic_order_number), economic_invoice_number = COALESCE(%s, economic_invoice_number), + economic_export_id = %s, + economic_order_url = %s, + exported_economic_customer_number = %s, + exported_currency = %s, + exported_layout_number = %s, + exported_net_amount = %s, + export_request_json = %s::jsonb, + export_response_json = %s::jsonb, + exported_created_products = %s::jsonb, + last_export_error = NULL, + exported_by_user_id = %s, last_sync_at = CURRENT_TIMESTAMP, last_exported_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP @@ -274,6 +301,16 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request): export_idempotency_key, str(economic_order_number) if economic_order_number is not None else None, str(economic_invoice_number) if economic_invoice_number is not None else None, + export_result.get("export_id") or export_idempotency_key, + details.get("economic_order_url"), + str(details.get("economic_customer_number") or "") or None, + details.get("currency"), + details.get("layout_number"), + net_amount, + json.dumps(request_payload, ensure_ascii=False), + json.dumps(response_payload, ensure_ascii=False), + json.dumps(details.get("created_product_numbers") or [], ensure_ascii=False), + user_id, request.draft_id, ), ) @@ -288,12 +325,33 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request): "idempotency_key": export_idempotency_key, "economic_order_number": economic_order_number, "economic_invoice_number": economic_invoice_number, + "economic_export_id": export_result.get("export_id") or export_idempotency_key, + "economic_customer_number": details.get("economic_customer_number"), + "currency": details.get("currency"), + "layout_number": details.get("layout_number"), + "net_amount": net_amount, + "created_product_numbers": details.get("created_product_numbers") or [], }, user_id, ) return export_result - except HTTPException: + except HTTPException as exc: + is_preview = isinstance(exc.detail, dict) and exc.detail.get("code") == "economic_products_missing" + if request.draft_id and not is_preview: + try: + from app.core.database import execute_query + execute_query( + "UPDATE ordre_drafts SET last_export_error=%s,last_sync_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=%s", + (str(exc.detail)[:2000], request.draft_id), + ) + _log_sync_event( + request.draft_id, "export_failed", previous_status, previous_status, + {"status_code": exc.status_code, "message": str(exc.detail)[:1000], + "idempotency_key": export_idempotency_key}, user_id, + ) + except Exception as log_exc: + logger.warning("Kunne ikke gemme eksportfejl for kladde %s: %s", request.draft_id, log_exc) raise except Exception as e: logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True) @@ -344,7 +402,13 @@ async def list_ordre_drafts( async def get_ordre_draft(draft_id: int, http_request: Request): """Get single ordre draft with lines payload (no user filtering).""" try: - query = "SELECT * FROM ordre_drafts WHERE id = %s LIMIT 1" + query = """SELECT d.*, c.name AS customer_name, + c.economic_customer_number AS current_economic_customer_number, + u.full_name AS exported_by_name + FROM ordre_drafts d + LEFT JOIN customers c ON c.id = d.customer_id + LEFT JOIN users u ON u.user_id = d.exported_by_user_id + WHERE d.id = %s LIMIT 1""" params = (draft_id,) from app.core.database import execute_query_single @@ -411,6 +475,7 @@ async def get_ordre_draft_sync_summary(http_request: Request): query = """ SELECT COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count, + COUNT(*) FILTER (WHERE sync_status = 'uncertain') AS uncertain_count, COUNT(*) FILTER (WHERE sync_status = 'exported') AS exported_count, COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count, COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count, @@ -420,6 +485,7 @@ async def get_ordre_draft_sync_summary(http_request: Request): """ return execute_query_single(query, ()) or { "pending_count": 0, + "uncertain_count": 0, "exported_count": 0, "failed_count": 0, "posted_count": 0, @@ -464,12 +530,13 @@ async def update_ordre_draft_sync_status(draft_id: int, payload: Dict[str, Any], from app.core.database import execute_query_single previous = execute_query_single( - "SELECT sync_status FROM ordre_drafts WHERE id = %s", + "SELECT sync_status, economic_order_number FROM ordre_drafts WHERE id = %s", (draft_id,) ) if not previous: raise HTTPException(status_code=404, detail="Draft not found") from_status = (previous.get("sync_status") or "pending").strip().lower() + _validate_sync_status_number(sync_status, economic_order_number, previous.get("economic_order_number")) values.append(draft_id) from app.core.database import execute_query diff --git a/app/modules/orders/templates/detail.html b/app/modules/orders/templates/detail.html index 925242e..3da58b0 100644 --- a/app/modules/orders/templates/detail.html +++ b/app/modules/orders/templates/detail.html @@ -97,8 +97,9 @@ Safety mode aktiv: e-conomic eksport er read-only eller dry-run. +
-
+
@@ -117,6 +118,7 @@
Kunde ID
+
-
@@ -147,11 +149,29 @@
Sidst opdateret
-
+
+
+ Eksportkvittering + Åbn i e-conomic +
+
+
Ordrenummer
-
+
e-conomic-kunde
-
+
Periode
-
+
Valuta / layout
-
+
Eksporteret af
-
+
Eksporteret
-
+
Nettobeløb
-
+
Intern eksportreference
-
+
Oprettede varer
-
+
+
+
-
Sync Lifecycle
-
Manuel statusstyring og audit events for denne ordre
+
Eksport og status
+
Status, e-conomic-numre og historik for ordren
-
+
e-conomic faktura nr.
@@ -202,7 +223,7 @@
Ordrenummer
-
-
+
Fakturanummer
-
@@ -210,7 +231,7 @@
- +
@@ -349,8 +370,8 @@ detailToast.show(); } - function formatCurrency(value) { - return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0)); + function formatCurrency(value, currency = 'DKK') { + return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(Number(value || 0)); } function formatDate(dateStr) { @@ -381,6 +402,7 @@ if (normalized === 'posted') return 'posted'; if (normalized === 'exported') return 'exported'; if (normalized === 'failed') return 'failed'; + if (normalized === 'uncertain') return 'uafklaret'; return 'pending'; } @@ -393,6 +415,32 @@ document.getElementById('lastSyncAt').textContent = formatDate(orderData.last_sync_at); document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-'; document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_number || '-'; + const showInvoice = ['posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()) || Boolean(orderData.economic_invoice_number); + document.getElementById('invoiceNumberEditWrap').classList.toggle('d-none', !showInvoice); + document.getElementById('invoiceNumberViewWrap').classList.toggle('d-none', !showInvoice); + document.getElementById('forceExportWrap').classList.toggle('d-none', ['uncertain', 'exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase())); + + const hasReceipt = Boolean(orderData.economic_order_number || orderData.economic_export_id); + document.getElementById('exportReceiptCard').classList.toggle('d-none', !hasReceipt); + if (hasReceipt) { + document.getElementById('receiptOrderNumber').textContent = orderData.economic_order_number || '-'; + document.getElementById('receiptCustomer').textContent = orderData.exported_economic_customer_number || orderData.current_economic_customer_number || '-'; + const period = orderData.coverage_start && orderData.coverage_end + ? `${orderData.coverage_start} – ${orderData.coverage_end}` + : (String(orderData.invoice_aggregate_key || '').match(/\d{4}-\d{2}$/) || [])[0] || '-'; + document.getElementById('receiptPeriod').textContent = period; + document.getElementById('receiptCurrencyLayout').textContent = `${orderData.exported_currency || 'DKK'} / ${orderData.exported_layout_number || orderData.layout_number || '-'}`; + document.getElementById('receiptUser').textContent = orderData.exported_by_name || '-'; + document.getElementById('receiptExportedAt').textContent = formatDate(orderData.last_exported_at); + document.getElementById('receiptNetAmount').textContent = formatCurrency(orderData.exported_net_amount, orderData.exported_currency || 'DKK'); + document.getElementById('receiptExportId').textContent = orderData.economic_export_id || orderData.export_idempotency_key || '-'; + const createdProducts = Array.isArray(orderData.exported_created_products) ? orderData.exported_created_products : []; + document.getElementById('receiptProductsWrap').classList.toggle('d-none', !createdProducts.length); + document.getElementById('receiptProducts').textContent = createdProducts.join(', '); + const link = document.getElementById('economicOrderLink'); + link.classList.toggle('d-none', !orderData.economic_order_url); + if (orderData.economic_order_url) link.href = orderData.economic_order_url; + } } function renderLines() { @@ -597,13 +645,21 @@ document.getElementById('orderTitle').value = orderData.title || ''; document.getElementById('customerId').value = orderData.customer_id || ''; + document.getElementById('customerIdentity').textContent = orderData.customer_name + ? `${orderData.customer_name} · e-conomic ${orderData.current_economic_customer_number || 'ikke koblet'}` + : '-'; document.getElementById('layoutNumber').value = orderData.layout_number || ''; document.getElementById('orderNotes').value = orderData.notes || ''; - const hasExported = orderData.last_exported_at ? true : false; - document.getElementById('orderStatus').innerHTML = hasExported + const hasExported = ['exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()); + const isUncertain = String(orderData.sync_status || '').toLowerCase() === 'uncertain'; + document.getElementById('orderStatus').innerHTML = hasExported ? 'Eksporteret' - : 'Ikke eksporteret'; + : isUncertain ? 'Uafklaret' + : 'Ikke eksporteret'; + const errorBanner = document.getElementById('exportErrorBanner'); + errorBanner.textContent = orderData.last_export_error ? `Seneste eksportfejl: ${orderData.last_export_error}` : ''; + errorBanner.classList.toggle('d-none', !orderData.last_export_error); document.getElementById('createdAt').textContent = formatDate(orderData.created_at); document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at); @@ -694,9 +750,18 @@ } body.innerHTML = items.map((event) => { - const payload = typeof event.event_payload === 'object' - ? JSON.stringify(event.event_payload, null, 2) - : String(event.event_payload || ''); + const data = typeof event.event_payload === 'object' ? event.event_payload : {}; + let payload = String(event.event_payload || ''); + if (event.event_type === 'export_success') { + payload = `Ordre ${data.economic_order_number || '-'} · Kunde ${data.economic_customer_number || '-'} · ${data.currency || '-'} · Layout ${data.layout_number || '-'} · ${formatCurrency(data.net_amount, data.currency)}`; + if ((data.created_product_numbers || []).length) payload += ` · Oprettede varer: ${data.created_product_numbers.join(', ')}`; + } else if (event.event_type === 'export_attempt') { + payload = data.force_export ? 'Tvungen geneksport startet' : 'Eksport startet'; + } else if (event.event_type === 'sync_status_manual_update') { + payload = `Manuel statusændring${data.economic_order_number ? ` · Ordre ${data.economic_order_number}` : ''}`; + } else if (typeof event.event_payload === 'object') { + payload = Object.entries(data).map(([key, value]) => `${key}: ${value ?? '-'}`).join(' · '); + } return ` @@ -704,7 +769,7 @@ ${escapeHtml(event.event_type || '-')} ${escapeHtml(event.from_status || '-')} ${escapeHtml(event.to_status || '-')} -
${escapeHtml(payload)}
+
${escapeHtml(payload)}
`; }).join(''); diff --git a/app/products/backend/economic_documents.py b/app/products/backend/economic_documents.py index 4fd7283..c8fdc36 100644 --- a/app/products/backend/economic_documents.py +++ b/app/products/backend/economic_documents.py @@ -115,8 +115,16 @@ async def preflight(connection, customer_id, lines, layout_number=None, currency def result_for(row): key = 'economic_order_number' if row['kind'] == 'order' else 'economic_invoice_number' + snapshot = row.get('snapshot') or {} + payload = snapshot.get('payload') or {} + response = row.get('response') or {} return {'success': True, 'dry_run': False, key: row['economic_number'], 'economic_draft_id': row['economic_number'], 'export_id': str(row['id']), + 'details': {'request_payload': payload, 'economic_response': response, + 'economic_customer_number': (payload.get('customer') or {}).get('customerNumber'), + 'currency': payload.get('currency'), + 'layout_number': (payload.get('layout') or {}).get('layoutNumber'), + 'economic_order_url': response.get('self'), 'created_product_numbers': []}, 'message': 'Eksporteret til e-conomic-kladde'} diff --git a/migrations/245_ordre_export_receipt.sql b/migrations/245_ordre_export_receipt.sql new file mode 100644 index 0000000..3101fa9 --- /dev/null +++ b/migrations/245_ordre_export_receipt.sql @@ -0,0 +1,32 @@ +-- Persist the evidence needed to reconcile an order draft with e-conomic. +BEGIN; + +ALTER TABLE ordre_drafts +ADD COLUMN IF NOT EXISTS economic_export_id VARCHAR(120), +ADD COLUMN IF NOT EXISTS economic_order_url TEXT, +ADD COLUMN IF NOT EXISTS exported_economic_customer_number VARCHAR(80), +ADD COLUMN IF NOT EXISTS exported_currency VARCHAR(3), +ADD COLUMN IF NOT EXISTS exported_layout_number INTEGER, +ADD COLUMN IF NOT EXISTS exported_net_amount NUMERIC(18,2), +ADD COLUMN IF NOT EXISTS export_request_json JSONB, +ADD COLUMN IF NOT EXISTS export_response_json JSONB, +ADD COLUMN IF NOT EXISTS exported_created_products JSONB NOT NULL DEFAULT '[]'::jsonb, +ADD COLUMN IF NOT EXISTS last_export_error TEXT, +ADD COLUMN IF NOT EXISTS exported_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL; + +ALTER TABLE ordre_drafts DROP CONSTRAINT IF EXISTS ordre_drafts_sync_status_check; +ALTER TABLE ordre_drafts ADD CONSTRAINT ordre_drafts_sync_status_check +CHECK (sync_status IN ('pending', 'uncertain', 'exported', 'failed', 'posted', 'paid')); + +UPDATE ordre_drafts +SET sync_status = 'uncertain', + last_export_error = COALESCE(last_export_error, 'Tidligere eksport: eksport markeret gennemført uden gemt e-conomic-ordrenummer; afstem manuelt før ny eksport') +WHERE sync_status IN ('exported', 'posted', 'paid') + AND economic_order_number IS NULL; + +UPDATE ordre_drafts +SET title = 'Abonnementer' +WHERE invoice_aggregate_key LIKE 'also-cloud-%' + AND title LIKE 'ALSO Cloud %'; + +COMMIT; diff --git a/tests/test_order_economic_safety_config.py b/tests/test_order_economic_safety_config.py index 127f1a4..22d1a66 100644 --- a/tests/test_order_economic_safety_config.py +++ b/tests/test_order_economic_safety_config.py @@ -94,3 +94,15 @@ def test_legacy_draft_id_is_saved_as_order_number(): from app.modules.orders.backend.router import _economic_order_number assert _economic_order_number({"economic_draft_id": 4711}) == 4711 + + +def test_exported_status_requires_economic_order_number(): + from fastapi import HTTPException + from app.modules.orders.backend.router import _validate_sync_status_number + + try: + _validate_sync_status_number("exported", None, None) + assert False, "exported status without an external order number must be rejected" + except HTTPException as exc: + assert exc.status_code == 409 + _validate_sync_status_number("exported", None, "4711")