diff --git a/MDfile/RELEASE_NOTES_v2.8.14.md b/MDfile/RELEASE_NOTES_v2.8.14.md new file mode 100644 index 0000000..1b7f709 --- /dev/null +++ b/MDfile/RELEASE_NOTES_v2.8.14.md @@ -0,0 +1,13 @@ +# BMC Hub v2.8.14 + +## ALSO ordrekladder og e-conomic-reference + +- Bruger Hub-ordre-ID som `Eksternt ID` ved eksport til e-conomic. +- Efterlader `Tekst 1` tom for nye ALSO-genererede ordrekladder. +- Tilføjer perioden fra CSV-feltet `Actual Charge Interval` til varelinjens beskrivelse. +- Migration 247 rydder den tidligere systemtekst og opdaterer eksisterende, ikke-eksporterede ALSO-ordrekladder med perioden. +- Bevarer den tidligere eksportreference ved afstemning af allerede påbegyndte eksportforsøg. + +## Opgradering + +Kør `migrations/247_also_order_external_id.sql` efter deployment. Migrationen ændrer kun ALSO-ordrekladder med status `pending`; eksporterede ordre ændres ikke. diff --git a/VERSION b/VERSION index 01b0f09..63cb62f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.13 +2.8.14 diff --git a/app/modules/also/backend/service.py b/app/modules/also/backend/service.py index 3a70504..09f11dc 100644 --- a/app/modules/also/backend/service.py +++ b/app/modules/also/backend/service.py @@ -275,6 +275,21 @@ def _extract_period_start(value: Any) -> Optional[str]: return _parse_date_candidate(match.group(1)) +def _format_charge_period(value: Any) -> Optional[str]: + """Return an explicit ALSO service period without inferring a missing end date.""" + text = _normalized_text(value) + if not text: + return None + match = re.match(r"\s*(\d{2}[./-]\d{2}[./-]\d{4})\s*-\s*(\d{2}[./-]\d{2}[./-]\d{4})\s*$", text) + if not match: + return None + start = _parse_date_candidate(match.group(1)) + end = _parse_date_candidate(match.group(2)) + if not start or not end: + return None + return f"{datetime.strptime(start, '%Y-%m-%d').strftime('%d.%m.%Y')} - {datetime.strptime(end, '%Y-%m-%d').strftime('%d.%m.%Y')}" + + def _is_zero_value_tenant_line(line: Dict[str, Any]) -> bool: product_name = _normalize_match_key(line.get("product_name")) if "microsoftorganizationtenant" not in product_name: @@ -319,7 +334,8 @@ class AlsoService: "total_price": ["total_price", "amount", "line_total", "net_amount", "subtotal", "extended_price", "total", "sales_price_total", "sales_price", "total_amount", "charge"], "currency": ["currency", "valuta"], "billing_start": ["billing_start", "period_start", "start_date", "billing_start_date", "invoice_date", "service_period_start", "billing_month", "period_from", "billing_from", "valid_from", "from_date", "start_date", "startdate"], - "charge_interval": ["charge_interval", "actual_charge_interval", "actualchargeinterval", "term", "commitment", "period_type", "contract_term"], + # The CSV's Actual Charge Interval is the authoritative service period. + "charge_interval": ["actual_charge_interval", "actualchargeinterval", "charge_interval", "term", "commitment", "period_type", "contract_term"], "billing_interval": ["billing_interval", "interval", "billing_cycle", "frequency", "charge_frequency"], "billable_parameters": ["billable_parameters", "billableparameters", "quantity", "qty", "udrc_value", "licenses", "seats", "users", "units", "antal", "license_count", "unit_count", "count"], "source_line_ref": ["source_line_ref", "line_id", "line_ref", "id", "reference"], @@ -2008,6 +2024,10 @@ class AlsoService: quantity = Decimal("1") unit_price = _to_decimal(line.get("unit_price"), _to_decimal(line.get("sales_price"), Decimal("0"))) amount = _to_decimal(line.get("total_price"), default=(quantity * unit_price)) + description = line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement" + charge_period = _format_charge_period(line.get("charge_interval")) + if charge_period: + description = f"{description} · Periode: {charge_period}" draft_lines.append( { @@ -2015,7 +2035,7 @@ class AlsoService: "source_type": "also_cloud", "source_id": int(line["id"]), "reference_id": int(line["import_job_id"]), - "description": line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement", + "description": description, "quantity": float(quantity), "unit": "stk", "unit_price": float(unit_price), @@ -2032,6 +2052,7 @@ class AlsoService: "also_material_number": line.get("material_number"), "also_vendor": line.get("vendor"), "also_import_job_id": int(line.get("import_job_id")), + "billing_period": charge_period, }, } ) @@ -2093,7 +2114,7 @@ class AlsoService: "Abonnementer", customer_id, _json_dumps(draft_lines), - "Genereret fra ALSO Cloud Billing approval", + None, 1, approved_by_user_id, _json_dumps({"source": "also_cloud_billing"}), diff --git a/app/modules/orders/backend/economic_export.py b/app/modules/orders/backend/economic_export.py index 7e2af35..d256c11 100644 --- a/app/modules/orders/backend/economic_export.py +++ b/app/modules/orders/backend/economic_export.py @@ -131,6 +131,7 @@ class OrdreEconomicExportService: currency: str = 'DKK', create_missing_products: Optional[Dict[str, int]] = None, export_reference: Optional[str] = None, + external_id: Optional[int] = None, ) -> Dict[str, Any]: from app.products.backend.economic_documents import active_connection, export_document, preflight, unsaved_key connection = active_connection() @@ -139,7 +140,8 @@ class OrdreEconomicExportService: checked = await preflight(connection, customer_id, lines, layout_number, currency, notes) return {'success': True, 'dry_run': True, 'message': 'Safety mode: valideret uden ekstern skrivning', 'details': checked} return await export_document(connection, 'order', document_key or unsaved_key(customer_id, lines, notes, layout_number), - customer_id, lines, layout_number, currency, notes, user_id) + customer_id, lines, layout_number, currency, notes, user_id, + external_id=external_id) customer = execute_query_single( "SELECT id, name, economic_customer_number FROM customers WHERE id = %s", (customer_id,), @@ -350,7 +352,9 @@ class OrdreEconomicExportService: if notes: payload["notes"] = {"textLine1": str(notes)[:1000]} - if export_reference: + if external_id is not None: + payload["references"] = {"other": str(external_id)[:100]} + elif export_reference: payload["references"] = {"other": "BMC-HUB:" + str(export_reference)[:90]} if not write_allowed: diff --git a/app/modules/orders/backend/router.py b/app/modules/orders/backend/router.py index 5f0e7c4..f3c1941 100644 --- a/app/modules/orders/backend/router.py +++ b/app/modules/orders/backend/router.py @@ -242,6 +242,7 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request): currency=request.currency, create_missing_products=request.create_missing_products, export_reference=export_idempotency_key, + external_id=request.draft_id, ) exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")] diff --git a/app/products/backend/economic_documents.py b/app/products/backend/economic_documents.py index c8fdc36..a851d9d 100644 --- a/app/products/backend/economic_documents.py +++ b/app/products/backend/economic_documents.py @@ -28,6 +28,11 @@ def amount(value): return decimal(value).quantize(Decimal('.01'), rounding=ROUND_HALF_UP) +def external_reference(export_row_id, external_id=None): + """Use the saved Hub order id in e-conomic, with the legacy marker as fallback.""" + return str(external_id)[:100] if external_id is not None else 'BMC-HUB:' + str(export_row_id) + + def line_snapshot(line, product, connection_id, currency): if not product or not product.get('economic_product_number') or product.get('economic_connection_id') != connection_id: raise HTTPException(409, 'Varen mangler en verificeret kobling. Åbn Varer og e-conomic for at knytte eller oprette varen.') @@ -139,7 +144,7 @@ def store_success(row, response, number): async def export_document(connection, kind, document_key, customer_id, lines, layout_number=None, - currency='DKK', notes=None, user_id=None, module='orders'): + currency='DKK', notes=None, user_id=None, module='orders', external_id=None): if kind != 'order': raise HTTPException(403, 'Hub må kun oprette ordrekladder i e-conomic') if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN: @@ -165,7 +170,7 @@ async def export_document(connection, kind, document_key, customer_id, lines, la row = cur.fetchone() if not row: raise HTTPException(409, 'Eksporten behandles allerede') - marker = 'BMC-HUB:' + str(row['id']) + marker = external_reference(row['id'], external_id) payload = snapshot['payload'] payload['references'] = {'other': marker} snapshot['payload'] = payload @@ -199,7 +204,8 @@ async def reconcile(export_id): if row['kind'] != 'order': raise HTTPException(403, 'Hub må kun afstemme ordrekladder') path = 'orders/drafts' - marker = 'BMC-HUB:' + str(row['id']) + expected = row['snapshot']['payload'] + marker = (expected.get('references') or {}).get('other') or external_reference(row['id']) matches = [r for r in await client.collection(path) if (r.get('references') or {}).get('other') == marker] if len(matches) != 1: raise HTTPException(409, f'Fandt {len(matches)} kladder med den præcise Hub-reference. Ingen ny eksport tilladt; kontrollér også bogførte/slettede dokumenter i e-conomic.') @@ -208,7 +214,6 @@ async def reconcile(export_id): if not number: raise HTTPException(409, 'Matchet mangler dokumentnummer') remote = await client.request('GET', path + '/' + str(int(number))) - expected = row['snapshot']['payload'] if (remote.get('references') or {}).get('other') != marker or remote.get('currency') != expected['currency'] or (remote.get('customer') or {}).get('customerNumber') != expected['customer']['customerNumber']: raise HTTPException(409, 'Dokumentets kunde, valuta eller reference afviger') actual_lines = remote.get('lines', []) diff --git a/migrations/247_also_order_external_id.sql b/migrations/247_also_order_external_id.sql new file mode 100644 index 0000000..06ca655 --- /dev/null +++ b/migrations/247_also_order_external_id.sql @@ -0,0 +1,50 @@ +BEGIN; + +-- The old integration message was exposed as "Tekst 1" in e-conomic. +-- Keep user-entered notes intact and only remove the exact ALSO system text. +UPDATE ordre_drafts +SET notes = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE invoice_aggregate_key LIKE 'also-cloud-%' + AND notes = 'Genereret fra ALSO Cloud Billing approval'; + +-- Add the source service period to existing, still-pending ALSO order lines. +WITH rebuilt AS ( + SELECT d.id, + jsonb_agg( + CASE + WHEN line.item->>'source_type' = 'also_cloud' + AND COALESCE(line.item->>'description', '') NOT LIKE '% · Periode: %' + AND source.charge_interval ~ '^\s*\d{2}[./-]\d{2}[./-]\d{4}\s*-\s*\d{2}[./-]\d{2}[./-]\d{4}\s*$' + THEN jsonb_set( + line.item, + '{description}', + to_jsonb( + COALESCE(line.item->>'description', 'Cloud abonnement') + || ' · Periode: ' + || replace(source.charge_interval, '/', '.') + ) + ) + ELSE line.item + END + ORDER BY line.ordinality + ) AS lines_json + FROM ordre_drafts d + CROSS JOIN LATERAL jsonb_array_elements(d.lines_json) WITH ORDINALITY AS line(item, ordinality) + LEFT JOIN also_import_lines source + ON source.id = CASE + WHEN line.item->>'source_id' ~ '^\d+$' THEN (line.item->>'source_id')::bigint + ELSE NULL + END + WHERE d.sync_status = 'pending' + AND d.invoice_aggregate_key LIKE 'also-cloud-%' + GROUP BY d.id +) +UPDATE ordre_drafts d +SET lines_json = rebuilt.lines_json, + updated_at = CURRENT_TIMESTAMP +FROM rebuilt +WHERE d.id = rebuilt.id + AND d.lines_json IS DISTINCT FROM rebuilt.lines_json; + +COMMIT; diff --git a/tests/test_economic_catalog.py b/tests/test_economic_catalog.py index c403e1a..d894c11 100644 --- a/tests/test_economic_catalog.py +++ b/tests/test_economic_catalog.py @@ -86,6 +86,30 @@ def test_snapshot_preserves_saved_price_and_zero(): assert snap['net_total'] == '0.00' +def test_order_external_reference_uses_hub_order_id_and_keeps_legacy_fallback(): + assert d.external_reference('export-uuid', 16) == '16' + assert d.external_reference('export-uuid') == 'BMC-HUB:export-uuid' + + +def test_also_charge_period_is_added_only_for_an_explicit_date_range(): + from app.modules.also.backend.service import AlsoService, _format_charge_period + + assert _format_charge_period('01/09/2026 - 30/09/2026') == '01.09.2026 - 30.09.2026' + assert _format_charge_period('monthly') is None + assert _format_charge_period(None) is None + normalized = AlsoService()._normalize_import_row( + { + 'Company': 'Testkunde', + 'Product Name': 'Microsoft 365', + 'Total Price': '100', + 'Actual Charge Interval': '01/09/2026 - 30/09/2026', + 'Charge Interval': 'monthly', + }, + 'csv:1', + ) + assert normalized['charge_interval'] == '01/09/2026 - 30/09/2026' + + @pytest.mark.parametrize('changes', [{'economic_product_number': None}, {'economic_connection_id': 2}, {'is_active_in_economic': False}, {'deleted_at': 'today'}, {'status': 'inactive'}]) def test_export_invalid_product_blocks(changes):