Compare commits

..

No commits in common. "main" and "v2.8.9" have entirely different histories.
main ... v2.8.9

22 changed files with 45 additions and 853 deletions

View File

@ -1,11 +0,0 @@
# 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

@ -1,6 +0,0 @@
# 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

@ -1,21 +0,0 @@
# 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

@ -1,13 +0,0 @@
# 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

@ -1,11 +0,0 @@
# 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

@ -1,9 +0,0 @@
# 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.16 2.8.9

View File

@ -14,9 +14,7 @@ ALLOWED_WRITES = {
} }
_approved_four_eyes_write = ContextVar('approved_four_eyes_write', default=False) _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)/[^/]+$') _FOUR_EYES_UPDATES = re.compile(r'^(?:customers|products)/[^/]+$')
_ORDER_DRAFT_UPDATE = re.compile(r'^orders/drafts/[0-9]+$')
@contextmanager @contextmanager
@ -29,16 +27,6 @@ def approved_four_eyes_write():
_approved_four_eyes_write.reset(token) _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: def assert_economic_write_allowed(method: str, path: str) -> None:
clean_path = path.split('?', 1)[0].strip('/') clean_path = path.split('?', 1)[0].strip('/')
operation = (method.upper(), clean_path) operation = (method.upper(), clean_path)
@ -47,12 +35,7 @@ def assert_economic_write_allowed(method: str, path: str) -> None:
and _FOUR_EYES_UPDATES.fullmatch(clean_path) and _FOUR_EYES_UPDATES.fullmatch(clean_path)
and _approved_four_eyes_write.get() and _approved_four_eyes_write.get()
) )
approved_draft_update = ( if operation not in ALLOWED_WRITES and not approved_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( raise HTTPException(
status_code=403, status_code=403,
detail='e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder', detail='e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder',

View File

@ -275,21 +275,6 @@ def _extract_period_start(value: Any) -> Optional[str]:
return _parse_date_candidate(match.group(1)) 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: def _is_zero_value_tenant_line(line: Dict[str, Any]) -> bool:
product_name = _normalize_match_key(line.get("product_name")) product_name = _normalize_match_key(line.get("product_name"))
if "microsoftorganizationtenant" not in product_name: if "microsoftorganizationtenant" not in product_name:
@ -334,8 +319,7 @@ class AlsoService:
"total_price": ["total_price", "amount", "line_total", "net_amount", "subtotal", "extended_price", "total", "sales_price_total", "sales_price", "total_amount", "charge"], "total_price": ["total_price", "amount", "line_total", "net_amount", "subtotal", "extended_price", "total", "sales_price_total", "sales_price", "total_amount", "charge"],
"currency": ["currency", "valuta"], "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"], "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"],
# The CSV's Actual Charge Interval is the authoritative service period. "charge_interval": ["charge_interval", "actual_charge_interval", "actualchargeinterval", "term", "commitment", "period_type", "contract_term"],
"charge_interval": ["actual_charge_interval", "actualchargeinterval", "charge_interval", "term", "commitment", "period_type", "contract_term"],
"billing_interval": ["billing_interval", "interval", "billing_cycle", "frequency", "charge_frequency"], "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"], "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"], "source_line_ref": ["source_line_ref", "line_id", "line_ref", "id", "reference"],
@ -2024,10 +2008,6 @@ class AlsoService:
quantity = Decimal("1") quantity = Decimal("1")
unit_price = _to_decimal(line.get("unit_price"), _to_decimal(line.get("sales_price"), Decimal("0"))) 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)) 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( draft_lines.append(
{ {
@ -2035,7 +2015,7 @@ class AlsoService:
"source_type": "also_cloud", "source_type": "also_cloud",
"source_id": int(line["id"]), "source_id": int(line["id"]),
"reference_id": int(line["import_job_id"]), "reference_id": int(line["import_job_id"]),
"description": description, "description": line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement",
"quantity": float(quantity), "quantity": float(quantity),
"unit": "stk", "unit": "stk",
"unit_price": float(unit_price), "unit_price": float(unit_price),
@ -2052,7 +2032,6 @@ class AlsoService:
"also_material_number": line.get("material_number"), "also_material_number": line.get("material_number"),
"also_vendor": line.get("vendor"), "also_vendor": line.get("vendor"),
"also_import_job_id": int(line.get("import_job_id")), "also_import_job_id": int(line.get("import_job_id")),
"billing_period": charge_period,
}, },
} }
) )
@ -2088,7 +2067,7 @@ class AlsoService:
] ]
execute_query( execute_query(
"""UPDATE ordre_drafts """UPDATE ordre_drafts
SET title = 'Abonnementer', lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP SET lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""", WHERE id = %s""",
(_json_dumps(merged_lines), int(draft["id"])), (_json_dumps(merged_lines), int(draft["id"])),
) )
@ -2111,10 +2090,10 @@ class AlsoService:
RETURNING id RETURNING id
""", """,
( (
"Abonnementer", f"ALSO Cloud {customer_name} - {period_key}",
customer_id, customer_id,
_json_dumps(draft_lines), _json_dumps(draft_lines),
None, "Genereret fra ALSO Cloud Billing approval",
1, 1,
approved_by_user_id, approved_by_user_id,
_json_dumps({"source": "also_cloud_billing"}), _json_dumps({"source": "also_cloud_billing"}),

View File

@ -130,8 +130,6 @@ class OrdreEconomicExportService:
document_key: Optional[str] = None, document_key: Optional[str] = None,
currency: str = 'DKK', currency: str = 'DKK',
create_missing_products: Optional[Dict[str, int]] = None, create_missing_products: Optional[Dict[str, int]] = None,
export_reference: Optional[str] = None,
external_id: Optional[int] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
from app.products.backend.economic_documents import active_connection, export_document, preflight, unsaved_key from app.products.backend.economic_documents import active_connection, export_document, preflight, unsaved_key
connection = active_connection() connection = active_connection()
@ -140,8 +138,7 @@ class OrdreEconomicExportService:
checked = await preflight(connection, customer_id, lines, layout_number, currency, notes) 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 {'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), 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( customer = execute_query_single(
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s", "SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
(customer_id,), (customer_id,),
@ -181,7 +178,6 @@ class OrdreEconomicExportService:
economic_lines: List[Dict[str, Any]] = [] economic_lines: List[Dict[str, Any]] = []
creation_candidates: Dict[str, Dict[str, Any]] = {} creation_candidates: Dict[str, Dict[str, Any]] = {}
created_product_numbers: List[str] = []
for line in selected_lines: for line in selected_lines:
try: try:
quantity = float(line.get("quantity") or 0) quantity = float(line.get("quantity") or 0)
@ -325,7 +321,6 @@ class OrdreEconomicExportService:
if str(created.get("productNumber") or "") != number: if str(created.get("productNumber") or "") != number:
raise HTTPException(502, f"e-conomic oprettede ikke det forventede varenummer {number}") raise HTTPException(502, f"e-conomic oprettede ikke det forventede varenummer {number}")
valid_product_numbers.add(number) valid_product_numbers.add(number)
created_product_numbers.append(number)
_require_valid_product_numbers(product_numbers, valid_product_numbers) _require_valid_product_numbers(product_numbers, valid_product_numbers)
customer_layout = economic_customer.get("layout") or {} customer_layout = economic_customer.get("layout") or {}
@ -352,10 +347,6 @@ class OrdreEconomicExportService:
if notes: if notes:
payload["notes"] = {"textLine1": str(notes)[:1000]} 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: if not write_allowed:
return { return {
@ -409,12 +400,6 @@ class OrdreEconomicExportService:
"skipped_product_numbers": missing_product_numbers, "skipped_product_numbers": missing_product_numbers,
"missing_product_numbers": missing_product_numbers, "missing_product_numbers": missing_product_numbers,
"economic_response": export_result, "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

@ -1,182 +0,0 @@
"""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,23 +12,7 @@ from app.modules.orders.backend.service import aggregate_order_lines
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
ALLOWED_SYNC_STATUSES = {"pending", "uncertain", "exported", "failed", "posted", "paid"} ALLOWED_SYNC_STATUSES = {"pending", "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): def _snapshot_lines(lines):
@ -88,15 +72,6 @@ class OrdreDraftConsolidateRequest(BaseModel):
draft_ids: List[int] = Field(..., min_length=2) 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: def _safe_json_field(value: Any) -> Any:
if value is None: if value is None:
return None return None
@ -241,8 +216,6 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
document_key=f'order-draft:{request.draft_id}' if request.draft_id else None, document_key=f'order-draft:{request.draft_id}' if request.draft_id else None,
currency=request.currency, currency=request.currency,
create_missing_products=request.create_missing_products, 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")] exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")]
@ -263,22 +236,16 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
"timestamp": datetime.utcnow().isoformat(), "timestamp": datetime.utcnow().isoformat(),
} }
economic_order_number = _economic_order_number(export_result) economic_order_number = (
export_result.get("economic_order_number")
or export_result.get("order_number")
or export_result.get("orderNumber")
)
economic_invoice_number = ( economic_invoice_number = (
export_result.get("economic_invoice_number") export_result.get("economic_invoice_number")
or export_result.get("invoice_number") or export_result.get("invoice_number")
or export_result.get("invoiceNumber") 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" target_sync_status = "pending" if export_result.get("dry_run") else "exported"
execute_query( execute_query(
@ -289,17 +256,6 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
export_idempotency_key = %s, export_idempotency_key = %s,
economic_order_number = COALESCE(%s, economic_order_number), economic_order_number = COALESCE(%s, economic_order_number),
economic_invoice_number = COALESCE(%s, economic_invoice_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_sync_at = CURRENT_TIMESTAMP,
last_exported_at = CURRENT_TIMESTAMP, last_exported_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
@ -311,16 +267,6 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
export_idempotency_key, export_idempotency_key,
str(economic_order_number) if economic_order_number is not None else None, 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, 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, request.draft_id,
), ),
) )
@ -335,33 +281,12 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
"idempotency_key": export_idempotency_key, "idempotency_key": export_idempotency_key,
"economic_order_number": economic_order_number, "economic_order_number": economic_order_number,
"economic_invoice_number": economic_invoice_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, user_id,
) )
return export_result return export_result
except HTTPException as exc: except HTTPException:
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 raise
except Exception as e: except Exception as e:
logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True) logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True)
@ -412,13 +337,7 @@ async def list_ordre_drafts(
async def get_ordre_draft(draft_id: int, http_request: Request): async def get_ordre_draft(draft_id: int, http_request: Request):
"""Get single ordre draft with lines payload (no user filtering).""" """Get single ordre draft with lines payload (no user filtering)."""
try: try:
query = """SELECT d.*, c.name AS customer_name, query = "SELECT * FROM ordre_drafts WHERE id = %s LIMIT 1"
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,) params = (draft_id,)
from app.core.database import execute_query_single from app.core.database import execute_query_single
@ -436,36 +355,6 @@ async def get_ordre_draft(draft_id: int, http_request: Request):
raise HTTPException(status_code=500, detail="Failed to fetch ordre draft") 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") @router.post("/ordre/drafts")
async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request): async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request):
"""Create a new ordre draft.""" """Create a new ordre draft."""
@ -515,7 +404,6 @@ async def get_ordre_draft_sync_summary(http_request: Request):
query = """ query = """
SELECT SELECT
COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count, 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 = 'exported') AS exported_count,
COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count, COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count,
COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count, COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count,
@ -525,7 +413,6 @@ async def get_ordre_draft_sync_summary(http_request: Request):
""" """
return execute_query_single(query, ()) or { return execute_query_single(query, ()) or {
"pending_count": 0, "pending_count": 0,
"uncertain_count": 0,
"exported_count": 0, "exported_count": 0,
"failed_count": 0, "failed_count": 0,
"posted_count": 0, "posted_count": 0,
@ -570,13 +457,12 @@ async def update_ordre_draft_sync_status(draft_id: int, payload: Dict[str, Any],
from app.core.database import execute_query_single from app.core.database import execute_query_single
previous = execute_query_single( previous = execute_query_single(
"SELECT sync_status, economic_order_number FROM ordre_drafts WHERE id = %s", "SELECT sync_status FROM ordre_drafts WHERE id = %s",
(draft_id,) (draft_id,)
) )
if not previous: if not previous:
raise HTTPException(status_code=404, detail="Draft not found") raise HTTPException(status_code=404, detail="Draft not found")
from_status = (previous.get("sync_status") or "pending").strip().lower() 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) values.append(draft_id)
from app.core.database import execute_query from app.core.database import execute_query
@ -679,19 +565,6 @@ async def list_ordre_draft_sync_events(
async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request): async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request):
"""Update existing ordre draft.""" """Update existing ordre draft."""
try: 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) await _check_draft_prices(http_request, request.customer_id, request.lines, draft_id)
from app.core.database import execute_query from app.core.database import execute_query

View File

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

View File

@ -97,9 +97,8 @@
<i class="bi bi-shield-exclamation me-1"></i> <i class="bi bi-shield-exclamation me-1"></i>
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run. <strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
</div> </div>
<div id="exportErrorBanner" class="alert alert-danger d-none"></div>
<div id="forceExportWrap" class="d-flex justify-content-end mb-3"> <div class="d-flex justify-content-end mb-3">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="forceExportToggle"> <input class="form-check-input" type="checkbox" id="forceExportToggle">
<label class="form-check-label" for="forceExportToggle">Force export (brug kun ved retry)</label> <label class="form-check-label" for="forceExportToggle">Force export (brug kun ved retry)</label>
@ -118,7 +117,6 @@
<div class="info-item"> <div class="info-item">
<div class="info-label">Kunde ID</div> <div class="info-label">Kunde ID</div>
<input type="number" id="customerId" class="form-control" placeholder="Kunde ID"> <input type="number" id="customerId" class="form-control" placeholder="Kunde ID">
<div id="customerIdentity" class="small text-muted mt-1">-</div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
@ -149,37 +147,13 @@
<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 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>
<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="sync-card">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3"> <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div> <div>
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Eksport og status</h5> <h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Sync Lifecycle</h5>
<div class="text-muted small">Status, e-conomic-numre og historik for ordren</div> <div class="text-muted small">Manuel statusstyring og audit events for denne ordre</div>
</div> </div>
<div class="d-flex gap-2"> <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)"> <button class="btn btn-outline-secondary btn-sm" onclick="loadSyncEvents(0)">
<i class="bi bi-arrow-clockwise me-1"></i>Opdater events <i class="bi bi-arrow-clockwise me-1"></i>Opdater events
</button> </button>
@ -194,7 +168,6 @@
<div class="sync-label">Sync status</div> <div class="sync-label">Sync status</div>
<select id="syncStatusSelect" class="form-select form-select-sm"> <select id="syncStatusSelect" class="form-select form-select-sm">
<option value="pending">pending</option> <option value="pending">pending</option>
<option value="uncertain">uncertain</option>
<option value="exported">exported</option> <option value="exported">exported</option>
<option value="failed">failed</option> <option value="failed">failed</option>
<option value="posted">posted</option> <option value="posted">posted</option>
@ -205,7 +178,7 @@
<div class="sync-label">e-conomic ordre nr.</div> <div class="sync-label">e-conomic ordre nr.</div>
<input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345"> <input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345">
</div> </div>
<div class="col-md-3" id="invoiceNumberEditWrap"> <div class="col-md-3">
<div class="sync-label">e-conomic faktura nr.</div> <div class="sync-label">e-conomic faktura nr.</div>
<input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877"> <input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877">
</div> </div>
@ -229,19 +202,15 @@
<div class="sync-label">Ordrenummer</div> <div class="sync-label">Ordrenummer</div>
<div id="economicOrderNumberView" class="sync-value">-</div> <div id="economicOrderNumberView" class="sync-value">-</div>
</div> </div>
<div class="col-md-3" id="invoiceNumberViewWrap"> <div class="col-md-3">
<div class="sync-label">Fakturanummer</div> <div class="sync-label">Fakturanummer</div>
<div id="economicInvoiceNumberView" class="sync-value">-</div> <div id="economicInvoiceNumberView" class="sync-value">-</div>
</div> </div>
<div class="col-md-3">
<div class="sync-label">Betalingsstatus</div>
<div id="economicPaymentStatusView" class="sync-value">-</div>
</div>
</div> </div>
<div class="row g-2 align-items-end mb-3"> <div class="row g-2 align-items-end mb-3">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label mb-1">Hændelse</label> <label class="form-label mb-1">Event type</label>
<input id="eventTypeFilter" type="text" class="form-control form-control-sm" placeholder="fx export_success"> <input id="eventTypeFilter" type="text" class="form-control form-control-sm" placeholder="fx export_success">
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
@ -349,13 +318,6 @@
</div> </div>
</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> </div>
{% endblock %} {% endblock %}
@ -369,7 +331,6 @@
let syncEventsTotal = 0; let syncEventsTotal = 0;
let detailToast = null; let detailToast = null;
let pendingEconomicProductPreview = null; let pendingEconomicProductPreview = null;
let pendingEconomicUpdateId = null;
function showToast(message, variant = 'dark') { function showToast(message, variant = 'dark') {
const toastEl = document.getElementById('detailToast'); const toastEl = document.getElementById('detailToast');
@ -388,8 +349,8 @@
detailToast.show(); detailToast.show();
} }
function formatCurrency(value, currency = 'DKK') { function formatCurrency(value) {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(Number(value || 0)); return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
} }
function formatDate(dateStr) { function formatDate(dateStr) {
@ -420,7 +381,6 @@
if (normalized === 'posted') return '<span class="badge bg-info text-dark">posted</span>'; 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 === 'exported') return '<span class="badge bg-primary">exported</span>';
if (normalized === 'failed') return '<span class="badge bg-danger">failed</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>'; return '<span class="badge bg-warning text-dark">pending</span>';
} }
@ -433,32 +393,6 @@
document.getElementById('lastSyncAt').textContent = formatDate(orderData.last_sync_at); document.getElementById('lastSyncAt').textContent = formatDate(orderData.last_sync_at);
document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-'; document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-';
document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_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() { function renderLines() {
@ -471,11 +405,9 @@
tbody.innerHTML = orderLines.map((line, index) => { tbody.innerHTML = orderLines.map((line, index) => {
const isExportedLine = line.export_status === 'exported'; const isExportedLine = line.export_status === 'exported';
const mayEditExportedDraft = isExportedLine && orderData && orderData.sync_status === 'exported' && !orderData.economic_invoice_number; const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
const lockLine = isExportedLine && !mayEditExportedDraft; ${isExportedLine ? 'disabled' : ''}
const descriptionField = `<textarea class="form-control form-control-sm" rows="2" style="min-width:260px; resize:vertical;" onchange="orderLines[${index}].description = this.value;">`;
${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>`;
const exportStatus = line.export_status || '-'; const exportStatus = line.export_status || '-';
const statusBadge = exportStatus === 'exported' const statusBadge = exportStatus === 'exported'
@ -501,17 +433,17 @@
<td>${descriptionField}</td> <td>${descriptionField}</td>
<td style="min-width:100px;"> <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)}" <input type="number" min="0.01" step="0.01" class="form-control form-control-sm" value="${Number(line.quantity || 1)}"
${lockLine ? 'disabled' : ''} ${isExportedLine ? 'disabled' : ''}
onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});"> onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
</td> </td>
<td style="min-width:120px;"> <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)}" <input type="number" min="0" step="0.01" class="form-control form-control-sm" value="${Number(line.unit_price || 0)}"
${lockLine ? 'disabled' : ''} ${isExportedLine ? 'disabled' : ''}
onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});"> onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
</td> </td>
<td style="min-width:110px;"> <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)}" <input type="number" min="0" max="100" step="0.01" class="form-control form-control-sm" value="${Number(line.discount_percentage || 0)}"
${lockLine ? 'disabled' : ''} ${isExportedLine ? 'disabled' : ''}
onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});"> onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
</td> </td>
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td> <td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
@ -665,25 +597,13 @@
document.getElementById('orderTitle').value = orderData.title || ''; document.getElementById('orderTitle').value = orderData.title || '';
document.getElementById('customerId').value = orderData.customer_id || ''; 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('layoutNumber').value = orderData.layout_number || '';
document.getElementById('orderNotes').value = orderData.notes || ''; document.getElementById('orderNotes').value = orderData.notes || '';
const hasExported = ['exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()); const hasExported = orderData.last_exported_at ? true : false;
const isUncertain = String(orderData.sync_status || '').toLowerCase() === 'uncertain'; document.getElementById('orderStatus').innerHTML = hasExported
document.getElementById('orderStatus').innerHTML = hasExported
? '<span class="badge bg-success">Eksporteret</span>' ? '<span class="badge bg-success">Eksporteret</span>'
: isUncertain ? '<span class="badge bg-warning text-dark">Uafklaret</span>' : '<span class="badge bg-warning text-dark">Ikke eksporteret</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('createdAt').textContent = formatDate(orderData.created_at);
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at); document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
@ -774,18 +694,9 @@
} }
body.innerHTML = items.map((event) => { body.innerHTML = items.map((event) => {
const data = typeof event.event_payload === 'object' ? event.event_payload : {}; const payload = typeof event.event_payload === 'object'
let payload = String(event.event_payload || ''); ? JSON.stringify(event.event_payload, null, 2)
if (event.event_type === 'export_success') { : String(event.event_payload || '');
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 ` return `
<tr> <tr>
@ -793,7 +704,7 @@
<td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td> <td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td>
<td>${escapeHtml(event.from_status || '-')}</td> <td>${escapeHtml(event.from_status || '-')}</td>
<td>${escapeHtml(event.to_status || '-')}</td> <td>${escapeHtml(event.to_status || '-')}</td>
<td><div class="event-payload">${escapeHtml(payload)}</div></td> <td><pre class="event-payload mb-0">${escapeHtml(payload)}</pre></td>
</tr> </tr>
`; `;
}).join(''); }).join('');
@ -878,48 +789,6 @@
} }
} }
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() { async function previewPrices() {
try { try {
const url = `/api/v1/economic/ordre-drafts/${draftId}/reprice`; const url = `/api/v1/economic/ordre-drafts/${draftId}/reprice`;

View File

@ -28,11 +28,6 @@ def amount(value):
return decimal(value).quantize(Decimal('.01'), rounding=ROUND_HALF_UP) 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): 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: 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.') raise HTTPException(409, 'Varen mangler en verificeret kobling. Åbn Varer og e-conomic for at knytte eller oprette varen.')
@ -120,16 +115,8 @@ async def preflight(connection, customer_id, lines, layout_number=None, currency
def result_for(row): def result_for(row):
key = 'economic_order_number' if row['kind'] == 'order' else 'economic_invoice_number' 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'], return {'success': True, 'dry_run': False, key: row['economic_number'],
'economic_draft_id': row['economic_number'], 'export_id': str(row['id']), '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'} 'message': 'Eksporteret til e-conomic-kladde'}
@ -144,7 +131,7 @@ def store_success(row, response, number):
async def export_document(connection, kind, document_key, customer_id, lines, layout_number=None, async def export_document(connection, kind, document_key, customer_id, lines, layout_number=None,
currency='DKK', notes=None, user_id=None, module='orders', external_id=None): currency='DKK', notes=None, user_id=None, module='orders'):
if kind != 'order': if kind != 'order':
raise HTTPException(403, 'Hub må kun oprette ordrekladder i e-conomic') raise HTTPException(403, 'Hub må kun oprette ordrekladder i e-conomic')
if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN: if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
@ -170,7 +157,7 @@ async def export_document(connection, kind, document_key, customer_id, lines, la
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(409, 'Eksporten behandles allerede') raise HTTPException(409, 'Eksporten behandles allerede')
marker = external_reference(row['id'], external_id) marker = 'BMC-HUB:' + str(row['id'])
payload = snapshot['payload'] payload = snapshot['payload']
payload['references'] = {'other': marker} payload['references'] = {'other': marker}
snapshot['payload'] = payload snapshot['payload'] = payload
@ -204,8 +191,7 @@ async def reconcile(export_id):
if row['kind'] != 'order': if row['kind'] != 'order':
raise HTTPException(403, 'Hub må kun afstemme ordrekladder') raise HTTPException(403, 'Hub må kun afstemme ordrekladder')
path = 'orders/drafts' path = 'orders/drafts'
expected = row['snapshot']['payload'] marker = 'BMC-HUB:' + str(row['id'])
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] matches = [r for r in await client.collection(path) if (r.get('references') or {}).get('other') == marker]
if len(matches) != 1: 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.') 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.')
@ -214,6 +200,7 @@ async def reconcile(export_id):
if not number: if not number:
raise HTTPException(409, 'Matchet mangler dokumentnummer') raise HTTPException(409, 'Matchet mangler dokumentnummer')
remote = await client.request('GET', path + '/' + str(int(number))) 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']: 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') raise HTTPException(409, 'Dokumentets kunde, valuta eller reference afviger')
actual_lines = remote.get('lines', []) actual_lines = remote.get('lines', [])

11
main.py
View File

@ -227,17 +227,6 @@ async def lifespan(app: FastAPI):
) )
logger.info("✅ Ordre draft reconcile job scheduled (daily at 04:30)") 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: if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED:
from app.jobs.eset_sync import run_eset_sync from app.jobs.eset_sync import run_eset_sync

View File

@ -1,32 +0,0 @@
-- 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

@ -1,48 +0,0 @@
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

@ -1,50 +0,0 @@
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

@ -1,26 +0,0 @@
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,30 +86,6 @@ def test_snapshot_preserves_saved_price_and_zero():
assert snap['net_total'] == '0.00' 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}, @pytest.mark.parametrize('changes', [{'economic_product_number': None}, {'economic_connection_id': 2},
{'is_active_in_economic': False}, {'deleted_at': 'today'}, {'status': 'inactive'}]) {'is_active_in_economic': False}, {'deleted_at': 'today'}, {'status': 'inactive'}])
def test_export_invalid_product_blocks(changes): def test_export_invalid_product_blocks(changes):
@ -164,28 +140,6 @@ def test_four_eyes_context_allows_only_customer_and_product_put():
assert_economic_write_allowed('PUT', 'products/ABC-1') 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(): def test_four_eyes_change_field_allowlist():
from app.products.backend.economic_routes import _validate_change_fields from app.products.backend.economic_routes import _validate_change_fields
assert _validate_change_fields('product', {'name': 'Nyt navn'}) == {'name': 'Nyt navn'} assert _validate_change_fields('product', {'name': 'Nyt navn'}) == {'name': 'Nyt navn'}

View File

@ -88,21 +88,3 @@ def test_product_creation_preview_values_become_economic_payload():
"barred": False, "barred": False,
"productGroup": {"productGroupNumber": 2}, "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")