Compare commits

...

4 Commits

Author SHA1 Message Date
Christian
ee274a939a release: v2.8.16 show multiline order descriptions 2026-09-14 15:24:43 +02:00
Christian
b95815de0d release: v2.8.15 format ALSO periods on separate line 2026-09-14 14:18:12 +02:00
Christian
dbdb1c554a release: v2.8.14 add ALSO billing periods 2026-09-14 13:35:38 +02:00
Christian
85fd99c5d8 release: v2.8.13 sync economic order lifecycle 2026-09-13 14:05:06 +02:00
17 changed files with 582 additions and 20 deletions

View File

@ -0,0 +1,13 @@
# BMC Hub v2.8.14
## ALSO ordrekladder og e-conomic-reference
- Bruger Hub-ordre-ID som `Eksternt ID` ved eksport til e-conomic.
- Efterlader `Tekst 1` tom for nye ALSO-genererede ordrekladder.
- Tilføjer perioden fra CSV-feltet `Actual Charge Interval` til varelinjens beskrivelse.
- Migration 247 rydder den tidligere systemtekst og opdaterer eksisterende, ikke-eksporterede ALSO-ordrekladder med perioden.
- Bevarer den tidligere eksportreference ved afstemning af allerede påbegyndte eksportforsøg.
## Opgradering
Kør `migrations/247_also_order_external_id.sql` efter deployment. Migrationen ændrer kun ALSO-ordrekladder med status `pending`; eksporterede ordre ændres ikke.

View File

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

View File

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

View File

@ -1 +1 @@
2.8.12 2.8.16

View File

@ -14,7 +14,9 @@ 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
@ -27,6 +29,16 @@ 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)
@ -35,7 +47,12 @@ 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()
) )
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( 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,6 +275,21 @@ 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:
@ -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"], "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"],
"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"], "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"],
@ -2008,6 +2024,10 @@ 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(
{ {
@ -2015,7 +2035,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": line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement", "description": description,
"quantity": float(quantity), "quantity": float(quantity),
"unit": "stk", "unit": "stk",
"unit_price": float(unit_price), "unit_price": float(unit_price),
@ -2032,6 +2052,7 @@ 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,
}, },
} }
) )
@ -2093,7 +2114,7 @@ class AlsoService:
"Abonnementer", "Abonnementer",
customer_id, customer_id,
_json_dumps(draft_lines), _json_dumps(draft_lines),
"Genereret fra ALSO Cloud Billing approval", None,
1, 1,
approved_by_user_id, approved_by_user_id,
_json_dumps({"source": "also_cloud_billing"}), _json_dumps({"source": "also_cloud_billing"}),

View File

@ -131,6 +131,7 @@ class OrdreEconomicExportService:
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, 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()
@ -139,7 +140,8 @@ 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,),
@ -350,7 +352,9 @@ class OrdreEconomicExportService:
if notes: if notes:
payload["notes"] = {"textLine1": str(notes)[:1000]} payload["notes"] = {"textLine1": str(notes)[:1000]}
if export_reference: if external_id is not None:
payload["references"] = {"other": str(external_id)[:100]}
elif export_reference:
payload["references"] = {"other": "BMC-HUB:" + str(export_reference)[:90]} payload["references"] = {"other": "BMC-HUB:" + str(export_reference)[:90]}
if not write_allowed: if not write_allowed:

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

@ -88,6 +88,15 @@ 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
@ -233,6 +242,7 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
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, 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")]
@ -426,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") 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."""
@ -639,6 +679,19 @@ 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
? `<input type="text" class="form-control form-control-sm" 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;">` onchange="ordreLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>`
: escapeHtml(line.description || '-'); : `<span style="white-space:pre-line">${escapeHtml(line.description || '-')}</span>`;
const manualActions = isManual const manualActions = isManual
? ` ? `

View File

@ -174,6 +174,12 @@
<div class="text-muted small">Status, e-conomic-numre og historik for ordren</div> <div class="text-muted small">Status, e-conomic-numre og historik for ordren</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>
@ -227,6 +233,10 @@
<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">
@ -339,6 +349,13 @@
</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 %}
@ -352,6 +369,7 @@
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');
@ -453,9 +471,11 @@
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 descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}" const mayEditExportedDraft = isExportedLine && orderData && orderData.sync_status === 'exported' && !orderData.economic_invoice_number;
${isExportedLine ? 'disabled' : ''} const lockLine = isExportedLine && !mayEditExportedDraft;
onchange="orderLines[${index}].description = this.value;">`; 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 exportStatus = line.export_status || '-';
const statusBadge = exportStatus === 'exported' const statusBadge = exportStatus === 'exported'
@ -481,17 +501,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)}"
${isExportedLine ? 'disabled' : ''} ${lockLine ? '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)}"
${isExportedLine ? 'disabled' : ''} ${lockLine ? '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)}"
${isExportedLine ? 'disabled' : ''} ${lockLine ? '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>
@ -660,6 +680,10 @@
const errorBanner = document.getElementById('exportErrorBanner'); const errorBanner = document.getElementById('exportErrorBanner');
errorBanner.textContent = orderData.last_export_error ? `Seneste eksportfejl: ${orderData.last_export_error}` : ''; errorBanner.textContent = orderData.last_export_error ? `Seneste eksportfejl: ${orderData.last_export_error}` : '';
errorBanner.classList.toggle('d-none', !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);
@ -854,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() { 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,6 +28,11 @@ 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.')
@ -139,7 +144,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'): currency='DKK', notes=None, user_id=None, module='orders', external_id=None):
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:
@ -165,7 +170,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 = 'BMC-HUB:' + str(row['id']) marker = external_reference(row['id'], external_id)
payload = snapshot['payload'] payload = snapshot['payload']
payload['references'] = {'other': marker} payload['references'] = {'other': marker}
snapshot['payload'] = payload snapshot['payload'] = payload
@ -199,7 +204,8 @@ 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'
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] 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.')
@ -208,7 +214,6 @@ 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,6 +227,17 @@ 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

@ -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' 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):
@ -140,6 +164,28 @@ 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'}