bmc_hub/app/modules/orders/backend/economic_sync.py

183 lines
11 KiB
Python

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