Compare commits

..

8 Commits
v2.8.8 ... main

23 changed files with 1037 additions and 49 deletions

View File

@ -0,0 +1,11 @@
# BMC Hub v2.8.10
## Gem e-conomic-ordrenummer
- Gemmer `economic_draft_id` fra det eksisterende eksportflow som kladdens e-conomic-ordrenummer.
- Audit-eventet `export_success` indeholder dermed det faktiske nummer i stedet for `null`.
- Bevarer understøttelse af de øvrige eksisterende feltnavne i eksportsvaret.
## Verifikation
- Regressionstest dækker mapping fra `economic_draft_id` til e-conomic-ordrenummer.

View File

@ -0,0 +1,6 @@
# BMC Hub v2.8.11
## Ordreoverskrift
- Ordrekladder oprettet fra ALSO-abonnementer får overskriften `Abonnementer`.
- Eksisterende åbne abonnementskladder får samme overskrift, når der tilføjes flere godkendte linjer.

View File

@ -0,0 +1,21 @@
# BMC Hub v2.8.12
## Eksportkvittering og afstemning
- Gemmer e-conomic-kunde, valuta, layout, nettobeløb, request, response, ekstern reference, oprettede varer og brugeren, der eksporterede.
- Sender den interne idempotency-reference med ordrekladden til e-conomic.
- En ordre kan ikke længere markeres som eksporteret, bogført eller betalt uden et e-conomic-ordrenummer.
- Eksisterende poster i den ugyldige kombination flyttes til `uncertain`, så de skal afstemmes før en eventuel ny eksport.
- Gemmer og viser den seneste eksportfejl.
## Ordrevisning
- Viser kundenavn sammen med Hub-ID og e-conomic-kundenummer.
- Viser en kompakt eksportkvittering med ordrenummer, periode, valuta, layout, nettobeløb, bruger og reference.
- Viser eksporthistorik som læsbare beskrivelser i stedet for rå JSON.
- Skjuler fakturanummer indtil bogføring og skjuler tvungen geneksport efter gennemført eksport.
- Retter eksisterende ALSO-abonnementskladder til titlen `Abonnementer`.
## Installation
- Kør migration `245_ordre_export_receipt.sql` før applikationen opdateres.

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

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

View File

@ -1 +1 @@
2.8.8
2.8.16

View File

@ -14,7 +14,9 @@ ALLOWED_WRITES = {
}
_approved_four_eyes_write = ContextVar('approved_four_eyes_write', default=False)
_approved_order_draft_update = ContextVar('approved_order_draft_update', default=False)
_FOUR_EYES_UPDATES = re.compile(r'^(?:customers|products)/[^/]+$')
_ORDER_DRAFT_UPDATE = re.compile(r'^orders/drafts/[0-9]+$')
@contextmanager
@ -27,6 +29,16 @@ def approved_four_eyes_write():
_approved_four_eyes_write.reset(token)
@contextmanager
def approved_order_draft_update():
"""Permit one user-confirmed update of an existing e-conomic order draft."""
token = _approved_order_draft_update.set(True)
try:
yield
finally:
_approved_order_draft_update.reset(token)
def assert_economic_write_allowed(method: str, path: str) -> None:
clean_path = path.split('?', 1)[0].strip('/')
operation = (method.upper(), clean_path)
@ -35,7 +47,12 @@ def assert_economic_write_allowed(method: str, path: str) -> None:
and _FOUR_EYES_UPDATES.fullmatch(clean_path)
and _approved_four_eyes_write.get()
)
if operation not in ALLOWED_WRITES and not approved_update:
approved_draft_update = (
operation[0] == 'PUT'
and _ORDER_DRAFT_UPDATE.fullmatch(clean_path)
and _approved_order_draft_update.get()
)
if operation not in ALLOWED_WRITES and not approved_update and not approved_draft_update:
raise HTTPException(
status_code=403,
detail='e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder',

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,
},
}
)
@ -2067,7 +2088,7 @@ class AlsoService:
]
execute_query(
"""UPDATE ordre_drafts
SET lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP
SET title = 'Abonnementer', lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(_json_dumps(merged_lines), int(draft["id"])),
)
@ -2090,10 +2111,10 @@ class AlsoService:
RETURNING id
""",
(
f"ALSO Cloud {customer_name} - {period_key}",
"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

@ -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,9 @@ class OrdreEconomicExportService:
user_id: Optional[int] = None,
document_key: Optional[str] = None,
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()
@ -123,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,),
@ -147,7 +165,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 +175,13 @@ 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]] = {}
created_product_numbers: List[str] = []
for line in selected_lines:
try:
quantity = float(line.get("quantity") or 0)
@ -189,6 +214,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 +282,51 @@ 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)
created_product_numbers.append(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
@ -273,6 +352,10 @@ class OrdreEconomicExportService:
if notes:
payload["notes"] = {"textLine1": str(notes)[:1000]}
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:
return {
@ -326,6 +409,12 @@ class OrdreEconomicExportService:
"skipped_product_numbers": missing_product_numbers,
"missing_product_numbers": missing_product_numbers,
"economic_response": export_result,
"request_payload": payload,
"economic_customer_number": customer_number,
"currency": payload["currency"],
"layout_number": int(resolved_layout_number),
"economic_order_url": export_result.get("self"),
"created_product_numbers": created_product_numbers,
},
}

View File

@ -0,0 +1,182 @@
"""Safe updates and daily read-only lifecycle checks for e-conomic order drafts."""
import json
import logging
from copy import deepcopy
from datetime import datetime, timezone, timedelta
from decimal import Decimal
from uuid import UUID
from fastapi import HTTPException
from psycopg2.extras import Json
from app.core.economic_write_policy import approved_order_draft_update
from app.products.backend.economic_catalog import EconomicClient, RemoteError, query, transaction
from app.products.backend.economic_documents import active_connection
logger = logging.getLogger(__name__)
def _number(value):
try:
return str(Decimal(str(value or 0)).quantize(Decimal('.01')))
except Exception:
return '0.00'
def _editable_snapshot(payload):
return {
'notes': ((payload.get('notes') or {}).get('textLine1') or ''),
'lines': [{
'product_number': ((line.get('product') or {}).get('productNumber')),
'description': line.get('description') or '',
'quantity': _number(line.get('quantity')),
'unit_price': _number(line.get('unitNetPrice')),
'discount_percentage': _number(line.get('discountPercentage')),
} for line in payload.get('lines') or []],
}
def build_update_payload(remote, local_lines, notes):
"""Keep external identity fields and only replace user-editable order content."""
allowed = ('date', 'currency', 'customer', 'paymentTerms', 'recipient', 'layout', 'references')
payload = {key: deepcopy(remote[key]) for key in allowed if key in remote}
remote_lines = remote.get('lines') or []
if len(remote_lines) != len(local_lines):
raise HTTPException(409, 'Antallet af linjer er ændret. Opret en ny ordre for at tilføje eller fjerne varer.')
changed, payload_lines = [], []
for remote_line, local in zip(remote_lines, local_lines):
if not (remote_line.get('product') or {}).get('productNumber'):
raise HTTPException(409, 'En eksisterende e-conomic-linje mangler varenummer og kan ikke opdateres sikkert.')
output = {'product': deepcopy(remote_line['product']),
'description': str(local.get('description') or 'Ordrelinje'),
'quantity': float(local.get('quantity') or 0),
'unitNetPrice': float(local.get('unit_price') or 0),
'discountPercentage': float(local.get('discount_percentage') or 0)}
if remote_line.get('unit'):
output['unit'] = deepcopy(remote_line['unit'])
if output['quantity'] <= 0 or output['unitNetPrice'] < 0 or not 0 <= output['discountPercentage'] <= 100:
raise HTTPException(422, 'Ugyldig mængde, pris eller rabat')
payload_lines.append(output)
changed.append(dict(local))
payload['lines'] = payload_lines
if notes:
payload['notes'] = {'textLine1': str(notes)[:1000]}
else:
payload.pop('notes', None)
return payload, changed
async def create_update_preview(draft_id, local_lines, notes, actor_id):
row = query('SELECT * FROM ordre_drafts WHERE id=%s', (draft_id,), one=True)
if not row:
raise HTTPException(404, 'Ordren findes ikke')
if not row.get('economic_order_number') or row.get('economic_invoice_number') or row.get('sync_status') in ('posted', 'paid'):
raise HTTPException(409, 'Kun en åben e-conomic-ordrekladde kan opdateres')
connection = active_connection()
client = EconomicClient()
if connection:
await client.verify(connection['id'])
try:
remote = await client.request('GET', f"orders/drafts/{int(row['economic_order_number'])}")
except RemoteError as exc:
if exc.status == 404:
raise HTTPException(409, 'Ordren er ikke længere en åben kladde. Hent status for at finde fakturaen.') from exc
raise HTTPException(502, str(exc)) from exc
proposed, saved_lines = build_update_payload(remote, local_lines, notes)
before, after = _editable_snapshot(remote), _editable_snapshot(proposed)
with transaction() as cur:
cur.execute("UPDATE economic_order_update_requests SET status='expired' WHERE draft_id=%s AND status='pending'", (draft_id,))
cur.execute('''INSERT INTO economic_order_update_requests
(draft_id,economic_order_number,before_snapshot,proposed_payload,proposed_local_lines,requested_by_user_id)
VALUES(%s,%s,%s,%s,%s,%s) RETURNING id,expires_at''',
(draft_id, str(row['economic_order_number']), Json(before), Json(proposed), Json(saved_lines), actor_id))
request_row = cur.fetchone()
return {'request_id': str(request_row['id']), 'expires_at': request_row['expires_at'], 'before': before, 'after': after}
async def apply_update(request_id, draft_id, actor_id):
try:
UUID(str(request_id))
except ValueError as exc:
raise HTTPException(422, 'Ugyldig godkendelsesreference') from exc
row = query('''SELECT r.*,d.sync_status,d.economic_invoice_number FROM economic_order_update_requests r
JOIN ordre_drafts d ON d.id=r.draft_id WHERE r.id=%s AND r.draft_id=%s''', (request_id, draft_id), one=True)
if not row or row['status'] != 'pending' or row['expires_at'] <= datetime.now(timezone.utc):
raise HTTPException(409, 'Godkendelsen er udløbet eller allerede brugt')
if row.get('economic_invoice_number') or row.get('sync_status') in ('posted', 'paid'):
raise HTTPException(409, 'Ordren er faktureret og kan ikke længere opdateres')
client = EconomicClient()
remote = await client.request('GET', f"orders/drafts/{int(row['economic_order_number'])}")
if _editable_snapshot(remote) != row['before_snapshot']:
raise HTTPException(409, 'Ordren er ændret i e-conomic siden forhåndsvisningen. Lav en ny forhåndsvisning.')
try:
with approved_order_draft_update():
response = await client.request('PUT', f"orders/drafts/{int(row['economic_order_number'])}", row['proposed_payload'])
except RemoteError as exc:
query("UPDATE economic_order_update_requests SET status='failed',error=%s WHERE id=%s", (str(exc), request_id))
raise HTTPException(502, str(exc)) from exc
with transaction() as cur:
cur.execute("SELECT set_config('bmc.economic_order_update',%s,true)", (str(draft_id),))
cur.execute('''UPDATE ordre_drafts SET lines_json=%s,notes=%s,export_request_json=%s,
export_response_json=%s,last_sync_at=now(),updated_at=now() WHERE id=%s''',
(Json(row['proposed_local_lines']), (row['proposed_payload'].get('notes') or {}).get('textLine1'),
Json(row['proposed_payload']), Json(response), draft_id))
cur.execute("UPDATE economic_order_update_requests SET status='applied',applied_at=now(),response=%s WHERE id=%s", (Json(response), request_id))
cur.execute('''INSERT INTO ordre_draft_sync_events(draft_id,event_type,from_status,to_status,event_payload,created_by_user_id)
VALUES(%s,'economic_draft_updated',%s,%s,%s,%s)''',
(draft_id, row['sync_status'], row['sync_status'], Json({'request_id': str(request_id)}), actor_id))
return {'status': 'updated', 'economic_order_number': row['economic_order_number']}
async def sync_one_draft(draft_id, force=False):
row = query('SELECT * FROM ordre_drafts WHERE id=%s', (draft_id,), one=True)
if not row or not row.get('economic_order_number'):
raise HTTPException(404, 'Ordren har intet e-conomic-ordrenummer')
if not force and row.get('economic_last_checked_at') and row['economic_last_checked_at'] > datetime.now(timezone.utc) - timedelta(days=1):
return {'status': row.get('economic_document_state') or 'unchanged', 'skipped': True}
client = EconomicClient()
marker = 'BMC-HUB:' + str(row.get('economic_export_id') or row.get('export_idempotency_key') or '')
state, invoice, remote = 'draft', None, None
try:
remote = await client.request('GET', f"orders/drafts/{int(row['economic_order_number'])}")
except RemoteError as exc:
if exc.status != 404:
query('UPDATE ordre_drafts SET economic_last_checked_at=now(),economic_status_error=%s WHERE id=%s', (str(exc), draft_id))
raise
booked = await client.collection('invoices/booked')
order_number = str(row['economic_order_number'])
matches = [x for x in booked if (
(x.get('references') or {}).get('other') == marker
or str(x.get('orderNumber') or (x.get('references') or {}).get('orderNumber') or '') == order_number
)]
if len(matches) == 1:
remote, invoice, state = matches[0], str(matches[0].get('bookedInvoiceNumber') or matches[0].get('invoiceNumber')), 'posted'
paid = await client.collection('invoices/paid')
if any(str(x.get('bookedInvoiceNumber') or x.get('invoiceNumber')) == invoice for x in paid):
state = 'paid'
else:
state = 'not_found'
payment = 'paid' if state == 'paid' else ('unpaid' if state == 'posted' else None)
with transaction() as cur:
cur.execute('''UPDATE ordre_drafts SET economic_invoice_number=COALESCE(%s,economic_invoice_number),
sync_status=CASE WHEN %s='paid' THEN 'paid' WHEN %s='posted' THEN 'posted' ELSE sync_status END,
economic_document_state=%s,economic_payment_status=%s,economic_status_response=%s,
economic_status_error=NULL,economic_last_checked_at=now(),last_sync_at=now(),updated_at=now() WHERE id=%s''',
(invoice,state,state,state,payment,Json(remote or {}),draft_id))
if state in ('posted','paid') and state != row.get('sync_status'):
cur.execute('''INSERT INTO ordre_draft_sync_events(draft_id,event_type,from_status,to_status,event_payload)
VALUES(%s,'economic_status_sync',%s,%s,%s)''', (draft_id,row.get('sync_status'),state,Json({'invoice_number':invoice,'payment_status':payment})))
return {'status': state, 'invoice_number': invoice, 'payment_status': payment, 'skipped': False}
async def sync_due_drafts():
rows = query("""SELECT id FROM ordre_drafts WHERE economic_order_number IS NOT NULL
AND sync_status IN ('exported','posted')
AND (economic_last_checked_at IS NULL OR economic_last_checked_at < now()-interval '1 day')
ORDER BY economic_last_checked_at NULLS FIRST LIMIT 100""") or []
for row in rows:
try:
await sync_one_draft(row['id'])
except Exception:
logger.exception('Daglig e-conomic-statuskontrol fejlede for ordre %s', row['id'])
return {'checked': len(rows)}

View File

@ -12,7 +12,23 @@ from app.modules.orders.backend.service import aggregate_order_lines
logger = logging.getLogger(__name__)
router = APIRouter()
ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"}
ALLOWED_SYNC_STATUSES = {"pending", "uncertain", "exported", "failed", "posted", "paid"}
def _economic_order_number(export_result: Dict[str, Any]) -> Any:
"""Normalize legacy and catalog-aware e-conomic export response fields."""
return (
export_result.get("economic_order_number")
or export_result.get("economic_draft_id")
or export_result.get("draftOrderNumber")
or export_result.get("order_number")
or export_result.get("orderNumber")
)
def _validate_sync_status_number(sync_status: str, incoming: Any, existing: Any) -> None:
if sync_status in {"exported", "posted", "paid"} and not (incoming if incoming is not None else existing):
raise HTTPException(409, "Status kan ikke sættes til eksporteret uden et e-conomic-ordrenummer")
def _snapshot_lines(lines):
@ -44,6 +60,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 +73,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):
@ -70,6 +88,15 @@ class OrdreDraftConsolidateRequest(BaseModel):
draft_ids: List[int] = Field(..., min_length=2)
class EconomicDraftUpdatePreviewRequest(BaseModel):
lines: List[Dict[str, Any]]
notes: Optional[str] = None
class EconomicDraftUpdateConfirmRequest(BaseModel):
request_id: str
def _safe_json_field(value: Any) -> Any:
if value is None:
return None
@ -160,12 +187,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 +240,9 @@ 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,
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")]
@ -231,16 +263,22 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
"timestamp": datetime.utcnow().isoformat(),
}
economic_order_number = (
export_result.get("economic_order_number")
or export_result.get("order_number")
or export_result.get("orderNumber")
)
economic_order_number = _economic_order_number(export_result)
economic_invoice_number = (
export_result.get("economic_invoice_number")
or export_result.get("invoice_number")
or export_result.get("invoiceNumber")
)
if not export_result.get("dry_run") and not economic_order_number:
raise HTTPException(502, "e-conomic returnerede intet ordrenummer. Kladden er ikke markeret som eksporteret.")
details = export_result.get("details") or {}
request_payload = details.get("request_payload") or {}
response_payload = details.get("economic_response") or {}
net_amount = sum(
float(line.get("quantity") or 0) * float(line.get("unitNetPrice") or 0)
* (1 - float(line.get("discountPercentage") or 0) / 100)
for line in (request_payload.get("lines") or [])
)
target_sync_status = "pending" if export_result.get("dry_run") else "exported"
execute_query(
@ -251,6 +289,17 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
export_idempotency_key = %s,
economic_order_number = COALESCE(%s, economic_order_number),
economic_invoice_number = COALESCE(%s, economic_invoice_number),
economic_export_id = %s,
economic_order_url = %s,
exported_economic_customer_number = %s,
exported_currency = %s,
exported_layout_number = %s,
exported_net_amount = %s,
export_request_json = %s::jsonb,
export_response_json = %s::jsonb,
exported_created_products = %s::jsonb,
last_export_error = NULL,
exported_by_user_id = %s,
last_sync_at = CURRENT_TIMESTAMP,
last_exported_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
@ -262,6 +311,16 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
export_idempotency_key,
str(economic_order_number) if economic_order_number is not None else None,
str(economic_invoice_number) if economic_invoice_number is not None else None,
export_result.get("export_id") or export_idempotency_key,
details.get("economic_order_url"),
str(details.get("economic_customer_number") or "") or None,
details.get("currency"),
details.get("layout_number"),
net_amount,
json.dumps(request_payload, ensure_ascii=False),
json.dumps(response_payload, ensure_ascii=False),
json.dumps(details.get("created_product_numbers") or [], ensure_ascii=False),
user_id,
request.draft_id,
),
)
@ -276,12 +335,33 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
"idempotency_key": export_idempotency_key,
"economic_order_number": economic_order_number,
"economic_invoice_number": economic_invoice_number,
"economic_export_id": export_result.get("export_id") or export_idempotency_key,
"economic_customer_number": details.get("economic_customer_number"),
"currency": details.get("currency"),
"layout_number": details.get("layout_number"),
"net_amount": net_amount,
"created_product_numbers": details.get("created_product_numbers") or [],
},
user_id,
)
return export_result
except HTTPException:
except HTTPException as exc:
is_preview = isinstance(exc.detail, dict) and exc.detail.get("code") == "economic_products_missing"
if request.draft_id and not is_preview:
try:
from app.core.database import execute_query
execute_query(
"UPDATE ordre_drafts SET last_export_error=%s,last_sync_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=%s",
(str(exc.detail)[:2000], request.draft_id),
)
_log_sync_event(
request.draft_id, "export_failed", previous_status, previous_status,
{"status_code": exc.status_code, "message": str(exc.detail)[:1000],
"idempotency_key": export_idempotency_key}, user_id,
)
except Exception as log_exc:
logger.warning("Kunne ikke gemme eksportfejl for kladde %s: %s", request.draft_id, log_exc)
raise
except Exception as e:
logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True)
@ -332,7 +412,13 @@ async def list_ordre_drafts(
async def get_ordre_draft(draft_id: int, http_request: Request):
"""Get single ordre draft with lines payload (no user filtering)."""
try:
query = "SELECT * FROM ordre_drafts WHERE id = %s LIMIT 1"
query = """SELECT d.*, c.name AS customer_name,
c.economic_customer_number AS current_economic_customer_number,
u.full_name AS exported_by_name
FROM ordre_drafts d
LEFT JOIN customers c ON c.id = d.customer_id
LEFT JOIN users u ON u.user_id = d.exported_by_user_id
WHERE d.id = %s LIMIT 1"""
params = (draft_id,)
from app.core.database import execute_query_single
@ -350,6 +436,36 @@ async def get_ordre_draft(draft_id: int, http_request: Request):
raise HTTPException(status_code=500, detail="Failed to fetch ordre draft")
async def _require_economic_order_update(http_request: Request) -> Dict[str, Any]:
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.orders.update'):
raise HTTPException(403, 'Du mangler rettighed til at opdatere e-conomic-ordrekladder')
return actor
@router.post('/ordre/drafts/{draft_id}/economic-update-preview')
async def preview_economic_draft_update(draft_id: int, payload: EconomicDraftUpdatePreviewRequest, http_request: Request):
actor = await _require_economic_order_update(http_request)
from app.modules.orders.backend.economic_sync import create_update_preview
return await create_update_preview(draft_id, payload.lines, payload.notes, actor['id'])
@router.post('/ordre/drafts/{draft_id}/economic-update-confirm')
async def confirm_economic_draft_update(draft_id: int, payload: EconomicDraftUpdateConfirmRequest, http_request: Request):
actor = await _require_economic_order_update(http_request)
from app.modules.orders.backend.economic_sync import apply_update
return await apply_update(payload.request_id, draft_id, actor['id'])
@router.post('/ordre/drafts/{draft_id}/economic-status-refresh')
async def refresh_economic_draft_status(draft_id: int, http_request: Request):
await _require_economic_order_update(http_request)
from app.modules.orders.backend.economic_sync import sync_one_draft
return await sync_one_draft(draft_id, force=True)
@router.post("/ordre/drafts")
async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request):
"""Create a new ordre draft."""
@ -399,6 +515,7 @@ async def get_ordre_draft_sync_summary(http_request: Request):
query = """
SELECT
COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count,
COUNT(*) FILTER (WHERE sync_status = 'uncertain') AS uncertain_count,
COUNT(*) FILTER (WHERE sync_status = 'exported') AS exported_count,
COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count,
COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count,
@ -408,6 +525,7 @@ async def get_ordre_draft_sync_summary(http_request: Request):
"""
return execute_query_single(query, ()) or {
"pending_count": 0,
"uncertain_count": 0,
"exported_count": 0,
"failed_count": 0,
"posted_count": 0,
@ -452,12 +570,13 @@ async def update_ordre_draft_sync_status(draft_id: int, payload: Dict[str, Any],
from app.core.database import execute_query_single
previous = execute_query_single(
"SELECT sync_status FROM ordre_drafts WHERE id = %s",
"SELECT sync_status, economic_order_number FROM ordre_drafts WHERE id = %s",
(draft_id,)
)
if not previous:
raise HTTPException(status_code=404, detail="Draft not found")
from_status = (previous.get("sync_status") or "pending").strip().lower()
_validate_sync_status_number(sync_status, economic_order_number, previous.get("economic_order_number"))
values.append(draft_id)
from app.core.database import execute_query
@ -560,6 +679,19 @@ async def list_ordre_draft_sync_events(
async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request):
"""Update existing ordre draft."""
try:
from app.core.database import execute_query_single
existing = execute_query_single('SELECT customer_id,lines_json,notes,layout_number,sync_status FROM ordre_drafts WHERE id=%s', (draft_id,))
if not existing:
raise HTTPException(status_code=404, detail="Draft not found")
if (existing.get('sync_status') or '').lower() in {'exported','posted','paid'}:
content_changed = (
existing.get('customer_id') != request.customer_id
or (_safe_json_field(existing.get('lines_json')) or []) != request.lines
or (existing.get('notes') or None) != (request.notes or None)
or existing.get('layout_number') != request.layout_number
)
if content_changed:
raise HTTPException(409, 'Brug “Opdatér e-conomic-kladde”, så ændringen forhåndsvises og gemmes begge steder.')
await _check_draft_prices(http_request, request.customer_id, request.lines, draft_id)
from app.core.database import execute_query

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

@ -97,8 +97,9 @@
<i class="bi bi-shield-exclamation me-1"></i>
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
</div>
<div id="exportErrorBanner" class="alert alert-danger d-none"></div>
<div class="d-flex justify-content-end mb-3">
<div id="forceExportWrap" class="d-flex justify-content-end mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="forceExportToggle">
<label class="form-check-label" for="forceExportToggle">Force export (brug kun ved retry)</label>
@ -117,6 +118,7 @@
<div class="info-item">
<div class="info-label">Kunde ID</div>
<input type="number" id="customerId" class="form-control" placeholder="Kunde ID">
<div id="customerIdentity" class="small text-muted mt-1">-</div>
</div>
</div>
<div class="col-md-3">
@ -147,13 +149,37 @@
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Sidst opdateret</div><div id="updatedAt" class="summary-value">-</div></div></div>
</div>
<div id="exportReceiptCard" class="card mb-3 d-none">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-receipt-cutoff me-1"></i>Eksportkvittering</strong>
<a id="economicOrderLink" class="btn btn-sm btn-outline-primary d-none" target="_blank" rel="noopener">Åbn i e-conomic</a>
</div>
<div class="card-body"><div class="row g-3">
<div class="col-md-3"><div class="small text-muted">Ordrenummer</div><div id="receiptOrderNumber" class="fw-semibold">-</div></div>
<div class="col-md-3"><div class="small text-muted">e-conomic-kunde</div><div id="receiptCustomer">-</div></div>
<div class="col-md-2"><div class="small text-muted">Periode</div><div id="receiptPeriod">-</div></div>
<div class="col-md-2"><div class="small text-muted">Valuta / layout</div><div id="receiptCurrencyLayout">-</div></div>
<div class="col-md-2"><div class="small text-muted">Eksporteret af</div><div id="receiptUser">-</div></div>
<div class="col-md-3"><div class="small text-muted">Eksporteret</div><div id="receiptExportedAt">-</div></div>
<div class="col-md-3"><div class="small text-muted">Nettobeløb</div><div id="receiptNetAmount">-</div></div>
<div class="col-md-6"><div class="small text-muted">Intern eksportreference</div><div id="receiptExportId" class="font-monospace small text-break">-</div></div>
<div id="receiptProductsWrap" class="col-12 d-none"><div class="small text-muted">Oprettede varer</div><div id="receiptProducts">-</div></div>
</div></div>
</div>
<div class="sync-card">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Sync Lifecycle</h5>
<div class="text-muted small">Manuel statusstyring og audit events for denne ordre</div>
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Eksport og status</h5>
<div class="text-muted small">Status, e-conomic-numre og historik for ordren</div>
</div>
<div class="d-flex gap-2">
<button id="economicUpdateBtn" class="btn btn-outline-primary btn-sm d-none" onclick="previewEconomicUpdate()">
<i class="bi bi-pencil-square me-1"></i>Opdatér e-conomic-kladde
</button>
<button id="economicStatusBtn" class="btn btn-outline-secondary btn-sm d-none" onclick="refreshEconomicStatus()">
<i class="bi bi-cloud-arrow-down me-1"></i>Hent status nu
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="loadSyncEvents(0)">
<i class="bi bi-arrow-clockwise me-1"></i>Opdater events
</button>
@ -168,6 +194,7 @@
<div class="sync-label">Sync status</div>
<select id="syncStatusSelect" class="form-select form-select-sm">
<option value="pending">pending</option>
<option value="uncertain">uncertain</option>
<option value="exported">exported</option>
<option value="failed">failed</option>
<option value="posted">posted</option>
@ -178,7 +205,7 @@
<div class="sync-label">e-conomic ordre nr.</div>
<input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345">
</div>
<div class="col-md-3">
<div class="col-md-3" id="invoiceNumberEditWrap">
<div class="sync-label">e-conomic faktura nr.</div>
<input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877">
</div>
@ -202,15 +229,19 @@
<div class="sync-label">Ordrenummer</div>
<div id="economicOrderNumberView" class="sync-value">-</div>
</div>
<div class="col-md-3">
<div class="col-md-3" id="invoiceNumberViewWrap">
<div class="sync-label">Fakturanummer</div>
<div id="economicInvoiceNumberView" class="sync-value">-</div>
</div>
<div class="col-md-3">
<div class="sync-label">Betalingsstatus</div>
<div id="economicPaymentStatusView" class="sync-value">-</div>
</div>
</div>
<div class="row g-2 align-items-end mb-3">
<div class="col-md-3">
<label class="form-label mb-1">Event type</label>
<label class="form-label mb-1">Hændelse</label>
<input id="eventTypeFilter" type="text" class="form-control form-control-sm" placeholder="fx export_success">
</div>
<div class="col-md-2">
@ -294,6 +325,37 @@
</div>
</div>
</div>
<div class="modal fade" id="economicProductPreviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title">Varer oprettes før eksport</h5>
<div class="text-muted small">Kontrollér værdierne. Først efter godkendelse oprettes varerne og ordrekladden.</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div class="alert alert-info"><i class="bi bi-info-circle me-1"></i>Der skrives ikke noget til e-conomic, før du vælger <strong>Opret varer og eksportér</strong>.</div>
<div id="economicProductPreviewRows"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="confirmEconomicProductsBtn" onclick="confirmEconomicProductCreation()">
<i class="bi bi-box-arrow-up-right me-1"></i>Opret varer og eksportér
</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="economicUpdatePreviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content">
<div class="modal-header"><div><h5 class="modal-title">Opdatér e-conomic-ordrekladde</h5><div class="text-muted small">Kontrollér før og efter. Kun den åbne ordrekladde ændres.</div></div><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"><div class="alert alert-info">Der skrives først til e-conomic, når du vælger <strong>Godkend og opdatér</strong>.</div><div id="economicUpdateComparison"></div></div>
<div class="modal-footer"><button class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" onclick="confirmEconomicUpdate()">Godkend og opdatér</button></div>
</div></div>
</div>
</div>
{% endblock %}
@ -306,6 +368,8 @@
let syncEventsOffset = 0;
let syncEventsTotal = 0;
let detailToast = null;
let pendingEconomicProductPreview = null;
let pendingEconomicUpdateId = null;
function showToast(message, variant = 'dark') {
const toastEl = document.getElementById('detailToast');
@ -324,8 +388,8 @@
detailToast.show();
}
function formatCurrency(value) {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
function formatCurrency(value, currency = 'DKK') {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(Number(value || 0));
}
function formatDate(dateStr) {
@ -356,6 +420,7 @@
if (normalized === 'posted') return '<span class="badge bg-info text-dark">posted</span>';
if (normalized === 'exported') return '<span class="badge bg-primary">exported</span>';
if (normalized === 'failed') return '<span class="badge bg-danger">failed</span>';
if (normalized === 'uncertain') return '<span class="badge bg-warning text-dark">uafklaret</span>';
return '<span class="badge bg-warning text-dark">pending</span>';
}
@ -368,6 +433,32 @@
document.getElementById('lastSyncAt').textContent = formatDate(orderData.last_sync_at);
document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-';
document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_number || '-';
const showInvoice = ['posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()) || Boolean(orderData.economic_invoice_number);
document.getElementById('invoiceNumberEditWrap').classList.toggle('d-none', !showInvoice);
document.getElementById('invoiceNumberViewWrap').classList.toggle('d-none', !showInvoice);
document.getElementById('forceExportWrap').classList.toggle('d-none', ['uncertain', 'exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()));
const hasReceipt = Boolean(orderData.economic_order_number || orderData.economic_export_id);
document.getElementById('exportReceiptCard').classList.toggle('d-none', !hasReceipt);
if (hasReceipt) {
document.getElementById('receiptOrderNumber').textContent = orderData.economic_order_number || '-';
document.getElementById('receiptCustomer').textContent = orderData.exported_economic_customer_number || orderData.current_economic_customer_number || '-';
const period = orderData.coverage_start && orderData.coverage_end
? `${orderData.coverage_start} ${orderData.coverage_end}`
: (String(orderData.invoice_aggregate_key || '').match(/\d{4}-\d{2}$/) || [])[0] || '-';
document.getElementById('receiptPeriod').textContent = period;
document.getElementById('receiptCurrencyLayout').textContent = `${orderData.exported_currency || 'DKK'} / ${orderData.exported_layout_number || orderData.layout_number || '-'}`;
document.getElementById('receiptUser').textContent = orderData.exported_by_name || '-';
document.getElementById('receiptExportedAt').textContent = formatDate(orderData.last_exported_at);
document.getElementById('receiptNetAmount').textContent = formatCurrency(orderData.exported_net_amount, orderData.exported_currency || 'DKK');
document.getElementById('receiptExportId').textContent = orderData.economic_export_id || orderData.export_idempotency_key || '-';
const createdProducts = Array.isArray(orderData.exported_created_products) ? orderData.exported_created_products : [];
document.getElementById('receiptProductsWrap').classList.toggle('d-none', !createdProducts.length);
document.getElementById('receiptProducts').textContent = createdProducts.join(', ');
const link = document.getElementById('economicOrderLink');
link.classList.toggle('d-none', !orderData.economic_order_url);
if (orderData.economic_order_url) link.href = orderData.economic_order_url;
}
}
function renderLines() {
@ -380,9 +471,11 @@
tbody.innerHTML = orderLines.map((line, index) => {
const isExportedLine = line.export_status === 'exported';
const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
${isExportedLine ? 'disabled' : ''}
onchange="orderLines[${index}].description = this.value;">`;
const mayEditExportedDraft = isExportedLine && orderData && orderData.sync_status === 'exported' && !orderData.economic_invoice_number;
const lockLine = isExportedLine && !mayEditExportedDraft;
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;">${escapeHtml(line.description || '')}</textarea>`;
const exportStatus = line.export_status || '-';
const statusBadge = exportStatus === 'exported'
@ -408,17 +501,17 @@
<td>${descriptionField}</td>
<td style="min-width:100px;">
<input type="number" min="0.01" step="0.01" class="form-control form-control-sm" value="${Number(line.quantity || 1)}"
${isExportedLine ? 'disabled' : ''}
${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:120px;">
<input type="number" min="0" step="0.01" class="form-control form-control-sm" value="${Number(line.unit_price || 0)}"
${isExportedLine ? 'disabled' : ''}
${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:110px;">
<input type="number" min="0" max="100" step="0.01" class="form-control form-control-sm" value="${Number(line.discount_percentage || 0)}"
${isExportedLine ? 'disabled' : ''}
${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
@ -572,13 +665,25 @@
document.getElementById('orderTitle').value = orderData.title || '';
document.getElementById('customerId').value = orderData.customer_id || '';
document.getElementById('customerIdentity').textContent = orderData.customer_name
? `${orderData.customer_name} · e-conomic ${orderData.current_economic_customer_number || 'ikke koblet'}`
: '-';
document.getElementById('layoutNumber').value = orderData.layout_number || '';
document.getElementById('orderNotes').value = orderData.notes || '';
const hasExported = orderData.last_exported_at ? true : false;
const hasExported = ['exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase());
const isUncertain = String(orderData.sync_status || '').toLowerCase() === 'uncertain';
document.getElementById('orderStatus').innerHTML = hasExported
? '<span class="badge bg-success">Eksporteret</span>'
: '<span class="badge bg-warning text-dark">Ikke eksporteret</span>';
: isUncertain ? '<span class="badge bg-warning text-dark">Uafklaret</span>'
: '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
const errorBanner = document.getElementById('exportErrorBanner');
errorBanner.textContent = orderData.last_export_error ? `Seneste eksportfejl: ${orderData.last_export_error}` : '';
errorBanner.classList.toggle('d-none', !orderData.last_export_error);
const openEconomicDraft = orderData.sync_status === 'exported' && orderData.economic_order_number && !orderData.economic_invoice_number;
document.getElementById('economicUpdateBtn').classList.toggle('d-none', !openEconomicDraft);
document.getElementById('economicStatusBtn').classList.toggle('d-none', !orderData.economic_order_number);
document.getElementById('economicPaymentStatusView').textContent = orderData.economic_payment_status || (orderData.economic_document_state === 'draft' ? 'Ikke faktureret' : '-');
document.getElementById('createdAt').textContent = formatDate(orderData.created_at);
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
@ -669,9 +774,18 @@
}
body.innerHTML = items.map((event) => {
const payload = typeof event.event_payload === 'object'
? JSON.stringify(event.event_payload, null, 2)
: String(event.event_payload || '');
const data = typeof event.event_payload === 'object' ? event.event_payload : {};
let payload = String(event.event_payload || '');
if (event.event_type === 'export_success') {
payload = `Ordre ${data.economic_order_number || '-'} · Kunde ${data.economic_customer_number || '-'} · ${data.currency || '-'} · Layout ${data.layout_number || '-'} · ${formatCurrency(data.net_amount, data.currency)}`;
if ((data.created_product_numbers || []).length) payload += ` · Oprettede varer: ${data.created_product_numbers.join(', ')}`;
} else if (event.event_type === 'export_attempt') {
payload = data.force_export ? 'Tvungen geneksport startet' : 'Eksport startet';
} else if (event.event_type === 'sync_status_manual_update') {
payload = `Manuel statusændring${data.economic_order_number ? ` · Ordre ${data.economic_order_number}` : ''}`;
} else if (typeof event.event_payload === 'object') {
payload = Object.entries(data).map(([key, value]) => `${key}: ${value ?? '-'}`).join(' · ');
}
return `
<tr>
@ -679,7 +793,7 @@
<td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td>
<td>${escapeHtml(event.from_status || '-')}</td>
<td>${escapeHtml(event.to_status || '-')}</td>
<td><pre class="event-payload mb-0">${escapeHtml(payload)}</pre></td>
<td><div class="event-payload">${escapeHtml(payload)}</div></td>
</tr>
`;
}).join('');
@ -764,6 +878,48 @@
}
}
async function previewEconomicUpdate() {
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-update-preview`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({lines: orderLines, notes: document.getElementById('orderNotes').value || null}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Forhåndsvisning fejlede');
pendingEconomicUpdateId = data.request_id;
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 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();
} catch (err) { showToast(err.message, 'danger'); }
}
async function confirmEconomicUpdate() {
if (!pendingEconomicUpdateId) return;
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-update-confirm`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request_id:pendingEconomicUpdateId})});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Opdateringen fejlede');
bootstrap.Modal.getInstance(document.getElementById('economicUpdatePreviewModal'))?.hide();
pendingEconomicUpdateId = null;
showToast('e-conomic-ordrekladden er opdateret', 'success');
await loadOrder();
} catch (err) { showToast(err.message, 'danger'); }
}
async function refreshEconomicStatus() {
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-status-refresh`, {method:'POST'});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Statuskontrol fejlede');
showToast(data.invoice_number ? `Faktura ${data.invoice_number}: ${data.payment_status || data.status}` : 'Ordren er stadig en åben kladde', 'success');
await loadOrder();
} catch (err) { showToast(err.message, 'danger'); }
}
async function previewPrices() {
try {
const url = `/api/v1/economic/ordre-drafts/${draftId}/reprice`;
@ -780,7 +936,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 `<option value="${Number(group.number)}"${selected}>${escapeHtml(group.number)} · ${escapeHtml(group.name)}</option>`;
}).join('');
return `<div class="card mb-3" data-economic-product="${escapeHtml(product.product_number)}">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Opret produkt ${escapeHtml(product.product_number)}</strong>
<span class="badge bg-success">${formatCurrency(product.sales_price)}</span>
</div>
<div class="card-body"><div class="row g-3">
<div class="col-md-4"><div class="small text-muted">Navn</div><div>${escapeHtml(product.name || '-')}</div></div>
<div class="col-md-4"><div class="small text-muted">EAN / stregkode</div><div>${escapeHtml(product.ean || '-')}</div></div>
<div class="col-md-4"><label class="small text-muted" for="economicGroup${index}">Varegruppe</label><select id="economicGroup${index}" class="form-select form-select-sm" data-product-group>${options}</select></div>
<div class="col-12"><div class="small text-muted">Beskrivelse</div><div>${escapeHtml(product.description || '-')}</div></div>
</div></div>
</div>`;
}).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 +995,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 +1013,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');
}

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.')
@ -115,8 +120,16 @@ async def preflight(connection, customer_id, lines, layout_number=None, currency
def result_for(row):
key = 'economic_order_number' if row['kind'] == 'order' else 'economic_invoice_number'
snapshot = row.get('snapshot') or {}
payload = snapshot.get('payload') or {}
response = row.get('response') or {}
return {'success': True, 'dry_run': False, key: row['economic_number'],
'economic_draft_id': row['economic_number'], 'export_id': str(row['id']),
'details': {'request_payload': payload, 'economic_response': response,
'economic_customer_number': (payload.get('customer') or {}).get('customerNumber'),
'currency': payload.get('currency'),
'layout_number': (payload.get('layout') or {}).get('layoutNumber'),
'economic_order_url': response.get('self'), 'created_product_numbers': []},
'message': 'Eksporteret til e-conomic-kladde'}
@ -131,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:
@ -157,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
@ -191,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.')
@ -200,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', [])

11
main.py
View File

@ -227,6 +227,17 @@ async def lifespan(app: FastAPI):
)
logger.info("✅ Ordre draft reconcile job scheduled (daily at 04:30)")
from app.modules.orders.backend.economic_sync import sync_due_drafts
backup_scheduler.scheduler.add_job(
func=sync_due_drafts,
trigger=CronTrigger(hour=4, minute=45),
id='economic_order_daily_status',
name='Daily e-conomic order and payment status',
max_instances=1,
replace_existing=True,
)
logger.info("✅ e-conomic ordre- og betalingsstatus planlagt dagligt kl. 04:45")
if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED:
from app.jobs.eset_sync import run_eset_sync

View File

@ -0,0 +1,32 @@
-- Persist the evidence needed to reconcile an order draft with e-conomic.
BEGIN;
ALTER TABLE ordre_drafts
ADD COLUMN IF NOT EXISTS economic_export_id VARCHAR(120),
ADD COLUMN IF NOT EXISTS economic_order_url TEXT,
ADD COLUMN IF NOT EXISTS exported_economic_customer_number VARCHAR(80),
ADD COLUMN IF NOT EXISTS exported_currency VARCHAR(3),
ADD COLUMN IF NOT EXISTS exported_layout_number INTEGER,
ADD COLUMN IF NOT EXISTS exported_net_amount NUMERIC(18,2),
ADD COLUMN IF NOT EXISTS export_request_json JSONB,
ADD COLUMN IF NOT EXISTS export_response_json JSONB,
ADD COLUMN IF NOT EXISTS exported_created_products JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS last_export_error TEXT,
ADD COLUMN IF NOT EXISTS exported_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL;
ALTER TABLE ordre_drafts DROP CONSTRAINT IF EXISTS ordre_drafts_sync_status_check;
ALTER TABLE ordre_drafts ADD CONSTRAINT ordre_drafts_sync_status_check
CHECK (sync_status IN ('pending', 'uncertain', 'exported', 'failed', 'posted', 'paid'));
UPDATE ordre_drafts
SET sync_status = 'uncertain',
last_export_error = COALESCE(last_export_error, 'Tidligere eksport: eksport markeret gennemført uden gemt e-conomic-ordrenummer; afstem manuelt før ny eksport')
WHERE sync_status IN ('exported', 'posted', 'paid')
AND economic_order_number IS NULL;
UPDATE ordre_drafts
SET title = 'Abonnementer'
WHERE invoice_aggregate_key LIKE 'also-cloud-%'
AND title LIKE 'ALSO Cloud %';
COMMIT;

View File

@ -0,0 +1,48 @@
BEGIN;
ALTER TABLE ordre_drafts
ADD COLUMN IF NOT EXISTS economic_last_checked_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS economic_document_state VARCHAR(30),
ADD COLUMN IF NOT EXISTS economic_payment_status VARCHAR(30),
ADD COLUMN IF NOT EXISTS economic_status_response JSONB,
ADD COLUMN IF NOT EXISTS economic_status_error TEXT;
CREATE TABLE IF NOT EXISTS economic_order_update_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
draft_id INTEGER NOT NULL REFERENCES ordre_drafts(id) ON DELETE CASCADE,
economic_order_number VARCHAR(80) NOT NULL,
before_snapshot JSONB NOT NULL,
proposed_payload JSONB NOT NULL,
proposed_local_lines JSONB NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','applied','expired','failed')),
requested_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '15 minutes',
applied_at TIMESTAMPTZ,
response JSONB,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_economic_order_update_pending ON economic_order_update_requests(draft_id,status);
CREATE INDEX IF NOT EXISTS idx_ordre_economic_daily_check ON ordre_drafts(economic_last_checked_at) WHERE economic_order_number IS NOT NULL;
CREATE OR REPLACE FUNCTION protect_exported_ordre_lines() RETURNS trigger AS $$
BEGIN
IF (NEW.lines_json IS DISTINCT FROM OLD.lines_json OR NEW.customer_id IS DISTINCT FROM OLD.customer_id)
AND current_setting('bmc.economic_order_update', true) IS DISTINCT FROM OLD.id::text
AND EXISTS (SELECT 1 FROM economic_document_exports WHERE document_key='order-draft:'||OLD.id::text AND status NOT IN ('rejected','archived')) THEN
RAISE EXCEPTION 'Exported or uncertain document lines are immutable; use the approved e-conomic draft update flow';
END IF;
RETURN NEW;
END; $$ LANGUAGE plpgsql;
INSERT INTO permissions(code,description,category)
VALUES ('economic.orders.update','Forhåndsvis og godkend opdatering af en åben e-conomic-ordrekladde','economic')
ON CONFLICT (code) DO NOTHING;
INSERT INTO group_permissions(group_id,permission_id)
SELECT g.id,p.id FROM groups g CROSS JOIN permissions p
WHERE g.name='Administrators' AND p.code='economic.orders.update'
ON CONFLICT DO NOTHING;
COMMIT;

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):
@ -140,6 +164,28 @@ def test_four_eyes_context_allows_only_customer_and_product_put():
assert_economic_write_allowed('PUT', 'products/ABC-1')
def test_confirmed_order_context_allows_only_numeric_draft_put():
from app.core.economic_write_policy import approved_order_draft_update, assert_economic_write_allowed
with pytest.raises(HTTPException):
assert_economic_write_allowed('PUT', 'orders/drafts/12')
with approved_order_draft_update():
assert_economic_write_allowed('PUT', 'orders/drafts/12')
with pytest.raises(HTTPException):
assert_economic_write_allowed('PUT', 'orders/12')
with pytest.raises(HTTPException):
assert_economic_write_allowed('DELETE', 'orders/drafts/12')
def test_order_update_payload_strips_read_only_fields_and_keeps_product():
from app.modules.orders.backend.economic_sync import build_update_payload
payload, _ = build_update_payload({
'date':'2026-09-13','currency':'DKK','customer':{'customerNumber':1},
'orderNumber':42,'netAmount':100,'lines':[{'lineNumber':1,'product':{'productNumber':'P1'},'quantity':1,'unitNetPrice':10}],
}, [{'description':'Ny','quantity':2,'unit_price':25,'discount_percentage':10}], 'Note')
assert 'orderNumber' not in payload and 'netAmount' not in payload
assert payload['lines'] == [{'product':{'productNumber':'P1'},'description':'Ny','quantity':2.0,'unitNetPrice':25.0,'discountPercentage':10.0}]
def test_four_eyes_change_field_allowlist():
from app.products.backend.economic_routes import _validate_change_fields
assert _validate_change_fields('product', {'name': 'Nyt navn'}) == {'name': 'Nyt navn'}

View File

@ -66,3 +66,43 @@ 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},
}
def test_legacy_draft_id_is_saved_as_order_number():
from app.modules.orders.backend.router import _economic_order_number
assert _economic_order_number({"economic_draft_id": 4711}) == 4711
def test_exported_status_requires_economic_order_number():
from fastapi import HTTPException
from app.modules.orders.backend.router import _validate_sync_status_number
try:
_validate_sync_status_number("exported", None, None)
assert False, "exported status without an external order number must be rejected"
except HTTPException as exc:
assert exc.status_code == 409
_validate_sync_status_number("exported", None, "4711")