"""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)}