183 lines
11 KiB
Python
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)}
|