diff --git a/VERSION b/VERSION index 56f5e1b..01b0f09 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.12 +2.8.13 diff --git a/app/core/economic_write_policy.py b/app/core/economic_write_policy.py index f09b228..8bd57ee 100644 --- a/app/core/economic_write_policy.py +++ b/app/core/economic_write_policy.py @@ -14,7 +14,9 @@ ALLOWED_WRITES = { } _approved_four_eyes_write = ContextVar('approved_four_eyes_write', default=False) +_approved_order_draft_update = ContextVar('approved_order_draft_update', default=False) _FOUR_EYES_UPDATES = re.compile(r'^(?:customers|products)/[^/]+$') +_ORDER_DRAFT_UPDATE = re.compile(r'^orders/drafts/[0-9]+$') @contextmanager @@ -27,6 +29,16 @@ def approved_four_eyes_write(): _approved_four_eyes_write.reset(token) +@contextmanager +def approved_order_draft_update(): + """Permit one user-confirmed update of an existing e-conomic order draft.""" + token = _approved_order_draft_update.set(True) + try: + yield + finally: + _approved_order_draft_update.reset(token) + + def assert_economic_write_allowed(method: str, path: str) -> None: clean_path = path.split('?', 1)[0].strip('/') operation = (method.upper(), clean_path) @@ -35,7 +47,12 @@ def assert_economic_write_allowed(method: str, path: str) -> None: and _FOUR_EYES_UPDATES.fullmatch(clean_path) and _approved_four_eyes_write.get() ) - if operation not in ALLOWED_WRITES and not approved_update: + approved_draft_update = ( + operation[0] == 'PUT' + and _ORDER_DRAFT_UPDATE.fullmatch(clean_path) + and _approved_order_draft_update.get() + ) + if operation not in ALLOWED_WRITES and not approved_update and not approved_draft_update: raise HTTPException( status_code=403, detail='e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder', diff --git a/app/modules/orders/backend/economic_sync.py b/app/modules/orders/backend/economic_sync.py new file mode 100644 index 0000000..2bc6100 --- /dev/null +++ b/app/modules/orders/backend/economic_sync.py @@ -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)} diff --git a/app/modules/orders/backend/router.py b/app/modules/orders/backend/router.py index 07becdf..5f0e7c4 100644 --- a/app/modules/orders/backend/router.py +++ b/app/modules/orders/backend/router.py @@ -88,6 +88,15 @@ class OrdreDraftConsolidateRequest(BaseModel): draft_ids: List[int] = Field(..., min_length=2) +class EconomicDraftUpdatePreviewRequest(BaseModel): + lines: List[Dict[str, Any]] + notes: Optional[str] = None + + +class EconomicDraftUpdateConfirmRequest(BaseModel): + request_id: str + + def _safe_json_field(value: Any) -> Any: if value is None: return None @@ -426,6 +435,36 @@ async def get_ordre_draft(draft_id: int, http_request: Request): raise HTTPException(status_code=500, detail="Failed to fetch ordre draft") +async def _require_economic_order_update(http_request: Request) -> Dict[str, Any]: + from app.core.auth_dependencies import get_current_user, security + from app.core.auth_service import AuthService + actor = await get_current_user(http_request, await security(http_request)) + if not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.orders.update'): + raise HTTPException(403, 'Du mangler rettighed til at opdatere e-conomic-ordrekladder') + return actor + + +@router.post('/ordre/drafts/{draft_id}/economic-update-preview') +async def preview_economic_draft_update(draft_id: int, payload: EconomicDraftUpdatePreviewRequest, http_request: Request): + actor = await _require_economic_order_update(http_request) + from app.modules.orders.backend.economic_sync import create_update_preview + return await create_update_preview(draft_id, payload.lines, payload.notes, actor['id']) + + +@router.post('/ordre/drafts/{draft_id}/economic-update-confirm') +async def confirm_economic_draft_update(draft_id: int, payload: EconomicDraftUpdateConfirmRequest, http_request: Request): + actor = await _require_economic_order_update(http_request) + from app.modules.orders.backend.economic_sync import apply_update + return await apply_update(payload.request_id, draft_id, actor['id']) + + +@router.post('/ordre/drafts/{draft_id}/economic-status-refresh') +async def refresh_economic_draft_status(draft_id: int, http_request: Request): + await _require_economic_order_update(http_request) + from app.modules.orders.backend.economic_sync import sync_one_draft + return await sync_one_draft(draft_id, force=True) + + @router.post("/ordre/drafts") async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request): """Create a new ordre draft.""" @@ -639,6 +678,19 @@ async def list_ordre_draft_sync_events( async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request): """Update existing ordre draft.""" try: + from app.core.database import execute_query_single + existing = execute_query_single('SELECT customer_id,lines_json,notes,layout_number,sync_status FROM ordre_drafts WHERE id=%s', (draft_id,)) + if not existing: + raise HTTPException(status_code=404, detail="Draft not found") + if (existing.get('sync_status') or '').lower() in {'exported','posted','paid'}: + content_changed = ( + existing.get('customer_id') != request.customer_id + or (_safe_json_field(existing.get('lines_json')) or []) != request.lines + or (existing.get('notes') or None) != (request.notes or None) + or existing.get('layout_number') != request.layout_number + ) + if content_changed: + raise HTTPException(409, 'Brug “Opdatér e-conomic-kladde”, så ændringen forhåndsvises og gemmes begge steder.') await _check_draft_prices(http_request, request.customer_id, request.lines, draft_id) from app.core.database import execute_query diff --git a/app/modules/orders/templates/detail.html b/app/modules/orders/templates/detail.html index 3da58b0..12f6d46 100644 --- a/app/modules/orders/templates/detail.html +++ b/app/modules/orders/templates/detail.html @@ -174,6 +174,12 @@
| Vare | Beskrivelse | Antal | Pris | Rabat % |
|---|