Compare commits

...

3 Commits

Author SHA1 Message Date
Christian
ee274a939a release: v2.8.16 show multiline order descriptions 2026-09-14 15:24:43 +02:00
Christian
b95815de0d release: v2.8.15 format ALSO periods on separate line 2026-09-14 14:18:12 +02:00
Christian
dbdb1c554a release: v2.8.14 add ALSO billing periods 2026-09-14 13:35:38 +02:00
13 changed files with 180 additions and 16 deletions

View File

@ -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.

View File

@ -0,0 +1,11 @@
# BMC Hub v2.8.15
## ALSO-perioder på ordrelinjer
- Viser perioden fra `Actual Charge Interval` på en ny linje under produktnavnet.
- Migration 248 retter eksisterende, ikke-eksporterede ALSO-ordrekladder.
- Allerede eksporterede ordre ændres ikke.
## Opgradering
Kør `migrations/248_also_order_period_line_break.sql` efter deployment.

View File

@ -0,0 +1,9 @@
# BMC Hub v2.8.16
## Flerlinjede ordrebeskrivelser
- Viser ordrelinjens produkt og ALSO-periode på separate linjer i både opret- og detaljevisningen.
- Bruger et kompakt tekstområde ved redigering, så linjeskiftet bevares ved gem og eksport til e-conomic.
- Viser linjeskift i forhåndsvisningen af en e-conomic-opdatering.
Der følger ingen ny databasemigration med denne release.

View File

@ -1 +1 @@
2.8.13
2.8.16

View File

@ -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}\nPeriode: {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"}),

View File

@ -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:

View File

@ -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")]

View File

@ -344,9 +344,9 @@
const index = line.originalIndex;
const isManual = line.source_type === 'manual';
const descriptionField = isManual
? `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
onchange="ordreLines[${index}].description = this.value;">`
: escapeHtml(line.description || '-');
? `<textarea class="form-control form-control-sm" rows="2" style="min-width:260px; resize:vertical;"
onchange="ordreLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>`
: `<span style="white-space:pre-line">${escapeHtml(line.description || '-')}</span>`;
const manualActions = isManual
? `

View File

@ -473,9 +473,9 @@
const isExportedLine = line.export_status === 'exported';
const mayEditExportedDraft = isExportedLine && orderData && orderData.sync_status === 'exported' && !orderData.economic_invoice_number;
const lockLine = isExportedLine && !mayEditExportedDraft;
const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
const descriptionField = `<textarea class="form-control form-control-sm" rows="2" style="min-width:260px; resize:vertical;"
${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].description = this.value;">`;
onchange="orderLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>`;
const exportStatus = line.export_status || '-';
const statusBadge = exportStatus === 'exported'
@ -890,7 +890,7 @@
const before = data.before || {}, after = data.after || {};
const rows = (after.lines || []).map((line, i) => {
const old = (before.lines || [])[i] || {};
return `<tr><td>${escapeHtml(line.product_number || '-')}</td><td>${escapeHtml(old.description)} → ${escapeHtml(line.description)}</td><td>${escapeHtml(old.quantity)} → ${escapeHtml(line.quantity)}</td><td>${escapeHtml(old.unit_price)} → ${escapeHtml(line.unit_price)}</td><td>${escapeHtml(old.discount_percentage)} → ${escapeHtml(line.discount_percentage)}</td></tr>`;
return `<tr><td>${escapeHtml(line.product_number || '-')}</td><td style="white-space:pre-line">${escapeHtml(old.description)} → ${escapeHtml(line.description)}</td><td>${escapeHtml(old.quantity)} → ${escapeHtml(line.quantity)}</td><td>${escapeHtml(old.unit_price)} → ${escapeHtml(line.unit_price)}</td><td>${escapeHtml(old.discount_percentage)} → ${escapeHtml(line.discount_percentage)}</td></tr>`;
}).join('');
document.getElementById('economicUpdateComparison').innerHTML = `<div class="mb-3"><strong>Noter:</strong> ${escapeHtml(before.notes || '-')} → ${escapeHtml(after.notes || '-')}</div><div class="table-responsive"><table class="table table-sm"><thead><tr><th>Vare</th><th>Beskrivelse</th><th>Antal</th><th>Pris</th><th>Rabat %</th></tr></thead><tbody>${rows}</tbody></table></div>`;
bootstrap.Modal.getOrCreateInstance(document.getElementById('economicUpdatePreviewModal')).show();

View File

@ -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', [])

View File

@ -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;

View File

@ -0,0 +1,26 @@
BEGIN;
-- Put the ALSO billing period on its own line for pending order drafts.
UPDATE ordre_drafts
SET lines_json = (
SELECT jsonb_agg(
CASE
WHEN item->>'source_type' = 'also_cloud'
AND COALESCE(item->>'description', '') LIKE '% · Periode: %'
THEN jsonb_set(
item,
'{description}',
to_jsonb(replace(item->>'description', ' · Periode: ', E'\nPeriode: '))
)
ELSE item
END
ORDER BY ordinality
)
FROM jsonb_array_elements(lines_json) WITH ORDINALITY AS source(item, ordinality)
),
updated_at = CURRENT_TIMESTAMP
WHERE sync_status = 'pending'
AND invoice_aggregate_key LIKE 'also-cloud-%'
AND lines_json::text LIKE '% · Periode: %';
COMMIT;

View File

@ -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):