From b3059dced10c311cef2201cbcdbbdd0cf4e92ceb Mon Sep 17 00:00:00 2001 From: Christian Date: Sun, 13 Sep 2026 09:56:24 +0200 Subject: [PATCH] release: v2.8.9 preview missing products during export --- MDfile/RELEASE_NOTES_v2.8.9.md | 14 ++++ VERSION | 2 +- app/modules/orders/backend/economic_export.py | 78 ++++++++++++++++++- app/modules/orders/backend/router.py | 7 +- app/modules/orders/templates/detail.html | 67 +++++++++++++++- tests/test_order_economic_safety_config.py | 22 ++++++ 6 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 MDfile/RELEASE_NOTES_v2.8.9.md diff --git a/MDfile/RELEASE_NOTES_v2.8.9.md b/MDfile/RELEASE_NOTES_v2.8.9.md new file mode 100644 index 0000000..8e694c5 --- /dev/null +++ b/MDfile/RELEASE_NOTES_v2.8.9.md @@ -0,0 +1,14 @@ +# BMC Hub v2.8.9 + +## Produktoprettelse ved ordreeksport + +- Hvis en ordrelinjes vare ikke findes i e-conomic, åbnes et forhåndsvindue før eksport. +- Vinduet viser varenummer, navn, salgspris, EAN/stregkode, beskrivelse og varegruppe for hver vare, der skal oprettes. +- Brugeren vælger eller kontrollerer varegruppen og godkender samlet med **Opret varer og eksportér**. +- Før godkendelsen udføres kun opslag. Efter godkendelsen oprettes de viste varer og derefter ordrekladden. +- Vareoprettelse kræver fortsat rettigheden `economic.catalog.manage`; ordreeksport kræver `economic.documents.export`. + +## Verifikation + +- Regressionstest dækker den præcise e-conomic-payload med pris, EAN, beskrivelse og varegruppe. +- Python- og JavaScript-syntakskontrol samt diff-kontrol består. diff --git a/VERSION b/VERSION index 80803fa..d578041 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.8 +2.8.9 diff --git a/app/modules/orders/backend/economic_export.py b/app/modules/orders/backend/economic_export.py index 187dfe2..ea0c150 100644 --- a/app/modules/orders/backend/economic_export.py +++ b/app/modules/orders/backend/economic_export.py @@ -59,6 +59,20 @@ def _require_valid_product_numbers(product_numbers: set[str], valid_product_numb return missing +def _product_creation_payload(proposal: Dict[str, Any], group_number: int) -> Dict[str, Any]: + payload = { + "productNumber": proposal["product_number"], + "name": str(proposal["name"])[:300], + "description": str(proposal.get("description") or proposal["name"])[:2500], + "salesPrice": proposal["sales_price"], + "barred": False, + "productGroup": {"productGroupNumber": group_number}, + } + if proposal.get("ean"): + payload["barCode"] = str(proposal["ean"])[:50] + return payload + + class OrdreEconomicExportService: """e-conomic export service for global ordre page.""" @@ -115,6 +129,7 @@ class OrdreEconomicExportService: user_id: Optional[int] = None, document_key: Optional[str] = None, currency: str = 'DKK', + create_missing_products: Optional[Dict[str, int]] = None, ) -> Dict[str, Any]: from app.products.backend.economic_documents import active_connection, export_document, preflight, unsaved_key connection = active_connection() @@ -147,7 +162,9 @@ class OrdreEconomicExportService: product_map: Dict[int, str] = {} if product_ids: product_rows = execute_query( - "SELECT id, sku_internal FROM products WHERE id = ANY(%s)", + """SELECT id, name, sku_internal, ean, short_description, long_description, + sales_price, economic_product_group_number + FROM products WHERE id = ANY(%s)""", (product_ids,), ) or [] product_map = { @@ -155,8 +172,12 @@ class OrdreEconomicExportService: for row in product_rows if row.get("sku_internal") } + product_details = {int(row["id"]): row for row in product_rows} + else: + product_details = {} economic_lines: List[Dict[str, Any]] = [] + creation_candidates: Dict[str, Dict[str, Any]] = {} for line in selected_lines: try: quantity = float(line.get("quantity") or 0) @@ -189,6 +210,16 @@ class OrdreEconomicExportService: if product_number: line_payload["product"] = {"productNumber": str(product_number)} + local_product = product_details.get(int(product_id)) if product_id is not None and str(product_id).isdigit() else None + creation_candidates.setdefault(str(product_number), { + "product_number": str(product_number), + "product_id": int(product_id) if product_id is not None and str(product_id).isdigit() else None, + "name": (local_product or {}).get("name") or line_payload["description"], + "description": (local_product or {}).get("long_description") or (local_product or {}).get("short_description") or line_payload["description"], + "ean": (local_product or {}).get("ean") or line.get("ean"), + "sales_price": float((local_product or {}).get("sales_price") or unit_price), + "suggested_group_number": (local_product or {}).get("economic_product_group_number"), + }) if discount > 0: line_payload["discountPercentage"] = discount @@ -247,7 +278,50 @@ class OrdreEconomicExportService: detail=_economic_error_message(product_response.status, product_text), ) - missing_product_numbers = _require_valid_product_numbers(product_numbers, valid_product_numbers) + missing_product_numbers = sorted(product_numbers - valid_product_numbers) + if missing_product_numbers: + async with session.get( + f"{self.api_url}/product-groups?pagesize=1000", + headers=self._headers(), timeout=aiohttp.ClientTimeout(total=30), + ) as groups_response: + groups_text = await groups_response.text() + if groups_response.status != 200: + raise HTTPException(502, _economic_error_message(groups_response.status, groups_text)) + groups_data = await groups_response.json(content_type=None) + groups = [ + {"number": row.get("productGroupNumber"), "name": row.get("name") or ""} + for row in (groups_data.get("collection") or []) if row.get("productGroupNumber") is not None + ] + approved_groups = create_missing_products or {} + if any(number not in approved_groups for number in missing_product_numbers): + raise HTTPException(status_code=409, detail={ + "code": "economic_products_missing", + "message": "Godkend oprettelse af de manglende varer før ordren eksporteres.", + "products": [creation_candidates[number] for number in missing_product_numbers], + "product_groups": groups, + }) + if self.read_only or self.dry_run: + raise HTTPException(409, "Safety mode blokerer oprettelse af varer i e-conomic") + available_groups = {int(group["number"]) for group in groups} + for number in missing_product_numbers: + group_number = int(approved_groups[number]) + if group_number not in available_groups: + raise HTTPException(409, f"Varegruppe {group_number} findes ikke i e-conomic") + proposal = creation_candidates[number] + product_payload = _product_creation_payload(proposal, group_number) + assert_economic_write_allowed("POST", "/products") + async with session.post( + f"{self.api_url}/products", headers=self._headers(), json=product_payload, + timeout=aiohttp.ClientTimeout(total=30), + ) as create_response: + create_text = await create_response.text() + if create_response.status not in (200, 201): + raise HTTPException(502, _economic_error_message(create_response.status, create_text)) + created = await create_response.json(content_type=None) + if str(created.get("productNumber") or "") != number: + raise HTTPException(502, f"e-conomic oprettede ikke det forventede varenummer {number}") + valid_product_numbers.add(number) + _require_valid_product_numbers(product_numbers, valid_product_numbers) customer_layout = economic_customer.get("layout") or {} resolved_layout_number = customer_layout.get("layoutNumber") or self.default_layout diff --git a/app/modules/orders/backend/router.py b/app/modules/orders/backend/router.py index 26e0b6e..8e6585f 100644 --- a/app/modules/orders/backend/router.py +++ b/app/modules/orders/backend/router.py @@ -44,6 +44,7 @@ class OrdreLineInput(BaseModel): discount_percentage: float = Field(default=0, ge=0, le=100) unit: Optional[str] = None product_id: Optional[int] = None + ean: Optional[str] = None selected: bool = True currency: str = Field(default='DKK', pattern='^[A-Z]{3}$') @@ -56,6 +57,7 @@ class OrdreExportRequest(BaseModel): draft_id: Optional[int] = None force_export: bool = False currency: str = Field(default='DKK', pattern='^[A-Z]{3}$') + create_missing_products: Dict[str, int] = Field(default_factory=dict) class OrdreDraftUpsertRequest(BaseModel): @@ -160,12 +162,14 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request): try: user_id = _get_user_id_from_request(http_request) from app.products.backend.economic_documents import active_connection - if active_connection(): + if active_connection() or request.create_missing_products: from app.core.auth_dependencies import get_current_user, security from app.core.auth_service import AuthService actor = await get_current_user(http_request, await security(http_request)) if not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.documents.export'): raise HTTPException(403, 'Du mangler rettighed til e-conomic-eksport') + if request.create_missing_products and not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.catalog.manage'): + raise HTTPException(403, 'Du mangler rettighed til at oprette varer i e-conomic') user_id = actor['id'] previous_status = None export_idempotency_key = None @@ -211,6 +215,7 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request): user_id=user_id, document_key=f'order-draft:{request.draft_id}' if request.draft_id else None, currency=request.currency, + create_missing_products=request.create_missing_products, ) exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")] diff --git a/app/modules/orders/templates/detail.html b/app/modules/orders/templates/detail.html index c43de1b..925242e 100644 --- a/app/modules/orders/templates/detail.html +++ b/app/modules/orders/templates/detail.html @@ -294,6 +294,30 @@ + + {% endblock %} @@ -306,6 +330,7 @@ let syncEventsOffset = 0; let syncEventsTotal = 0; let detailToast = null; + let pendingEconomicProductPreview = null; function showToast(message, variant = 'dark') { const toastEl = document.getElementById('detailToast'); @@ -780,7 +805,41 @@ } catch (err) { showToast(err.message, 'danger'); } } - async function exportOrder() { + function showEconomicProductPreview(detail) { + pendingEconomicProductPreview = detail; + const groups = detail.product_groups || []; + document.getElementById('economicProductPreviewRows').innerHTML = (detail.products || []).map((product, index) => { + const options = groups.map(group => { + const selected = Number(product.suggested_group_number) === Number(group.number) ? ' selected' : ''; + return ``; + }).join(''); + return `
+
+ Opret produkt ${escapeHtml(product.product_number)} + ${formatCurrency(product.sales_price)} +
+
+
Navn
${escapeHtml(product.name || '-')}
+
EAN / stregkode
${escapeHtml(product.ean || '-')}
+
+
Beskrivelse
${escapeHtml(product.description || '-')}
+
+
`; + }).join(''); + bootstrap.Modal.getOrCreateInstance(document.getElementById('economicProductPreviewModal')).show(); + } + + async function confirmEconomicProductCreation() { + if (!pendingEconomicProductPreview) return; + const approvals = {}; + document.querySelectorAll('[data-economic-product]').forEach(card => { + approvals[card.dataset.economicProduct] = Number(card.querySelector('[data-product-group]').value); + }); + bootstrap.Modal.getInstance(document.getElementById('economicProductPreviewModal'))?.hide(); + await exportOrder(approvals); + } + + async function exportOrder(createMissingProducts = {}) { const customerId = Number(document.getElementById('customerId').value || 0); if (!customerId) { showToast('Angiv kunde ID før eksport', 'warning'); @@ -805,12 +864,14 @@ discount_percentage: Number(line.discount_percentage || 0), unit: line.unit || 'stk', product_id: line.product_id || null, + ean: line.ean || null, selected: true, })), notes: document.getElementById('orderNotes').value || null, layout_number: Number(document.getElementById('layoutNumber').value || 0) || null, draft_id: draftId, force_export: document.getElementById('forceExportToggle').checked, + create_missing_products: createMissingProducts, }; try { @@ -821,6 +882,10 @@ }); const data = await res.json(); if (!res.ok) { + if (data.detail && data.detail.code === 'economic_products_missing') { + showEconomicProductPreview(data.detail); + return; + } throw new Error(data.detail || 'Eksport fejlede'); } diff --git a/tests/test_order_economic_safety_config.py b/tests/test_order_economic_safety_config.py index df78617..3120104 100644 --- a/tests/test_order_economic_safety_config.py +++ b/tests/test_order_economic_safety_config.py @@ -66,3 +66,25 @@ def test_order_export_accepts_only_verified_products(): from app.modules.orders.backend.economic_export import _require_valid_product_numbers assert _require_valid_product_numbers({"1000"}, {"1000"}) == [] + + +def test_product_creation_preview_values_become_economic_payload(): + from app.modules.orders.backend.economic_export import _product_creation_payload + + payload = _product_creation_payload({ + "product_number": "W2211X", + "name": "HP 207X Cyan", + "description": "Original toner", + "ean": "0194850012345", + "sales_price": 899.95, + }, 2) + + assert payload == { + "productNumber": "W2211X", + "name": "HP 207X Cyan", + "description": "Original toner", + "salesPrice": 899.95, + "barCode": "0194850012345", + "barred": False, + "productGroup": {"productGroupNumber": 2}, + }