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. +
-${escapeHtml(payload)}