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 `