diff --git a/MDfile/RELEASE_NOTES_v2.8.5.md b/MDfile/RELEASE_NOTES_v2.8.5.md new file mode 100644 index 0000000..b9798ac --- /dev/null +++ b/MDfile/RELEASE_NOTES_v2.8.5.md @@ -0,0 +1,13 @@ +# BMC Hub v2.8.5 + +## e-conomic ordreeksport + +- Henter kundens standardopsætning fra e-conomic før oprettelse af en ordrekladde. +- Medsender obligatoriske betalingsbetingelser, modtageroplysninger og momszone. +- Bruger kundens valuta og adresseoplysninger fra e-conomic. +- Viser e-conomics konkrete valideringsfejl i stedet for kun HTTP-status 400. + +## Verifikation + +- 9 relevante regressionstests består. +- Python-syntaks og diff-kontrol består. diff --git a/VERSION b/VERSION index 2701a22..766d708 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.4 +2.8.5 diff --git a/app/modules/orders/backend/economic_export.py b/app/modules/orders/backend/economic_export.py index 75fc438..9eaf0c4 100644 --- a/app/modules/orders/backend/economic_export.py +++ b/app/modules/orders/backend/economic_export.py @@ -12,6 +12,36 @@ from app.core.database import execute_query, execute_query_single logger = logging.getLogger(__name__) +def _economic_error_message(status: int, response_text: str) -> str: + """Return a useful, bounded e-conomic validation error without headers or credentials.""" + raw = str(response_text or "").strip() + messages: List[str] = [] + try: + payload = json.loads(raw) + except (TypeError, ValueError): + payload = None + + def collect(value: Any, prefix: str = "") -> None: + if isinstance(value, dict): + for key, child in value.items(): + if str(key).lower() in {"developerhint", "logid", "httpstatuscode"}: + continue + collect(child, f"{prefix}.{key}".strip(".")) + elif isinstance(value, list): + for child in value: + collect(child, prefix) + elif value not in (None, "") and str(value) not in messages: + label = f"{prefix}: " if prefix else "" + messages.append(f"{label}{value}") + + if payload is not None: + collect(payload) + elif raw: + messages.append(raw) + detail = " · ".join(messages)[:1200] + return f"e-conomic afviste ordren ({status})" + (f": {detail}" if detail else "") + + class OrdreEconomicExportService: """e-conomic export service for global ordre page.""" @@ -138,41 +168,73 @@ class OrdreEconomicExportService: economic_lines.append(line_payload) - payload: Dict[str, Any] = { - "date": date.today().isoformat(), - "currency": "DKK", - "customer": { - "customerNumber": int(customer["economic_customer_number"]), - }, - "layout": { - "layoutNumber": int(layout_number or self.default_layout), - }, - "lines": economic_lines, - } - - if notes: - payload["notes"] = {"textLine1": str(notes)[:250]} - operation = f"Export ordre for customer {customer_id} to e-conomic" - if not self._check_write_permission(operation): - return { - "success": True, - "dry_run": True, - "message": "DRY-RUN: Export blocked by safety flags", - "details": { - "customer_id": customer_id, - "customer_name": customer.get("name"), - "selected_line_count": len(selected_lines), - "read_only": self.read_only, - "dry_run": self.dry_run, - "user_id": user_id, - "payload": payload, - }, - } - - logger.info("📤 Sending ordre payload to e-conomic: %s", json.dumps(payload, default=str)) + write_allowed = self._check_write_permission(operation) async with aiohttp.ClientSession() as session: + customer_number = int(customer["economic_customer_number"]) + async with session.get( + f"{self.api_url}/customers/{customer_number}", + headers=self._headers(), + timeout=aiohttp.ClientTimeout(total=30), + ) as customer_response: + customer_text = await customer_response.text() + if customer_response.status != 200: + logger.error("❌ e-conomic customer lookup failed (%s): %s", customer_response.status, customer_text) + raise HTTPException( + status_code=502, + detail=_economic_error_message(customer_response.status, customer_text), + ) + economic_customer = await customer_response.json(content_type=None) + + payment_terms = economic_customer.get("paymentTerms") + vat_zone = economic_customer.get("vatZone") + if not payment_terms or not vat_zone: + raise HTTPException( + status_code=502, + detail="e-conomic-kunden mangler betalingsbetingelser eller momszone", + ) + + payload: Dict[str, Any] = { + "date": date.today().isoformat(), + "currency": str(economic_customer.get("currency") or "DKK"), + "customer": {"customerNumber": customer_number}, + "paymentTerms": payment_terms, + "recipient": { + "name": str(economic_customer.get("name") or customer.get("name") or "Kunde"), + "address": str(economic_customer.get("address") or ""), + "zip": str(economic_customer.get("zip") or ""), + "city": str(economic_customer.get("city") or ""), + "country": str(economic_customer.get("country") or ""), + "vatZone": vat_zone, + }, + "layout": { + "layoutNumber": int(layout_number or self.default_layout), + }, + "lines": economic_lines, + } + + if notes: + payload["notes"] = {"textLine1": str(notes)[:1000]} + + if not write_allowed: + return { + "success": True, + "dry_run": True, + "message": "DRY-RUN: Export blocked by safety flags", + "details": { + "customer_id": customer_id, + "customer_name": customer.get("name"), + "selected_line_count": len(selected_lines), + "read_only": self.read_only, + "dry_run": self.dry_run, + "user_id": user_id, + "payload": payload, + }, + } + + logger.info("📤 Sending ordre payload to e-conomic: %s", json.dumps(payload, default=str)) + async with session.post( f"{self.api_url}/orders/drafts", headers=self._headers(), @@ -184,7 +246,7 @@ class OrdreEconomicExportService: logger.error("❌ e-conomic export failed (%s): %s", response.status, response_text) raise HTTPException( status_code=502, - detail=f"e-conomic export fejlede ({response.status})", + detail=_economic_error_message(response.status, response_text), ) export_result = await response.json(content_type=None) diff --git a/tests/test_order_economic_safety_config.py b/tests/test_order_economic_safety_config.py index 36fdf73..b166086 100644 --- a/tests/test_order_economic_safety_config.py +++ b/tests/test_order_economic_safety_config.py @@ -33,3 +33,17 @@ def test_order_export_respects_explicit_order_safety_flags(monkeypatch): assert service.read_only is True assert service.dry_run is True + + +def test_economic_validation_error_is_exposed_without_developer_metadata(): + from app.modules.orders.backend.economic_export import _economic_error_message + + message = _economic_error_message( + 400, + '{"message":"Validation failed","errors":{"paymentTerms":"Required"},"developerHint":"internal"}', + ) + + assert "Validation failed" in message + assert "paymentTerms" in message + assert "Required" in message + assert "internal" not in message