bmc_hub/app/workflows/settlement.py

144 lines
11 KiB
Python
Raw Normal View History

"""Atomic local settlement for approved workflow decisions. No external writes."""
from calendar import monthrange
from collections import defaultdict
from datetime import date
from decimal import Decimal
import json
from fastapi import HTTPException
from psycopg2.extras import RealDictCursor
from app.core.database import atomic_transaction, execute_query, execute_query_single, execute_insert, execute_update
from .integration import active_for_customer
def workflow_customer(time_id):
row = execute_query_single('''SELECT COALESCE(s.customer_id,c.hub_customer_id) AS customer_id
FROM tmodule_times t LEFT JOIN sag_sager s ON s.id=t.sag_id
LEFT JOIN tmodule_customers c ON c.id=t.customer_id WHERE t.id=%s''', (time_id,))
return (row or {}).get('customer_id')
def current_decision(time_id, revision=None):
row = execute_query_single('SELECT * FROM bmc_billing_decisions WHERE time_id=%s AND is_current ORDER BY id DESC LIMIT 1', (time_id,))
if not row or (revision is not None and int(row['revision']) != int(revision)):
raise HTTPException(409, 'Workflowbeslutningen mangler eller er forældet. Genberegn tiden.')
if row['status'] not in {'decided','overridden'} or row.get('method') not in {'invoice','prepaid','subscription','internal','non_billable'}:
raise HTTPException(409, 'Tiden afventer manuel vurdering eller taggodkendelse.')
return row
def settle_workflow_entries(ids, actor_id, line_mode='separate'):
"""Lock and settle a complete selection once, rolling everything back on failure."""
from app.economy.backend.router import _selected_time_entries, _hours_for_prepaid_card, _create_ordre_draft_from_selected
ids = sorted(set(int(value) for value in ids))
if not ids or line_mode not in {'separate','combined'}:
raise HTTPException(422, 'Vælg tider og en gyldig fakturalinjevisning')
with atomic_transaction() as connection:
raw = execute_query('SELECT * FROM tmodule_times WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (ids,))
if len(raw) != len(ids):
raise HTTPException(404, 'En tidsregistrering findes ikke')
old = execute_query('SELECT * FROM bmc_workflow_settlements WHERE time_id=ANY(%s)', (ids,))
old_ids = {row['time_id'] for row in old}
remaining = [entry for entry in raw if entry['id'] not in old_ids]
rows = _selected_time_entries([entry['id'] for entry in remaining]) if remaining else []
by_id = {row['id']: row for row in rows}
decisions = {}
for entry in remaining:
row = by_id.get(entry['id'])
if not row:
raise HTTPException(409, 'Tiden er allerede behandlet gennem en anden afregningsvej')
if not active_for_customer(row.get('billing_customer_id')):
raise HTTPException(409, 'Vælg kun kunder med aktiv workflowafregning')
decision = current_decision(entry['id'], entry.get('workflow_revision', 0))
from .service import load_time_context
from .classification import enrich_time_context
with connection.cursor(cursor_factory=RealDictCursor) as cursor:
live_context = enrich_time_context(cursor, load_time_context(entry['id'], cursor))
if live_context.get('pending_billing_tag_suggestions'):
raise HTTPException(409, 'Afvent godkendelse eller afvisning af relevante AI-tags')
decision = dict(decision, details=dict(decision.get('details') or {}, input=live_context))
decisions[entry['id']] = decision
row['billing_method'] = decision['method']
config = (decision.get('details') or {}).get('billing') or (decision.get('details') or {}).get('decision') or {}
row['prepaid_card_id'] = config.get('card_id') or entry.get('prepaid_card_id')
row['fixed_price_agreement_id'] = config.get('agreement_id') or entry.get('fixed_price_agreement_id')
row['workflow_product_number'] = config.get('product_number')
# Lock agreement/card resources in stable order before balance checks.
card_ids = sorted({int(r['prepaid_card_id']) for r in rows if r['billing_method']=='prepaid' and r.get('prepaid_card_id')})
agreement_ids = sorted({int(r['fixed_price_agreement_id']) for r in rows if r['billing_method']=='subscription' and r.get('fixed_price_agreement_id')})
if card_ids:
execute_query('SELECT id FROM tticket_prepaid_cards WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (card_ids,))
if agreement_ids:
execute_query('SELECT id FROM customer_fixed_price_agreements WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (agreement_ids,))
invoice_groups = defaultdict(list)
drafts = []
for row in rows:
tid, method = row['id'], row['billing_method']
hours = Decimal(str(row.get('approved_hours') or row.get('original_hours') or 0))
if not hours.is_finite() or hours <= 0:
raise HTTPException(409, 'Tidsregistreringen skal have positiv tid')
if method=='invoice':
if not row.get('billing_customer_id'):
raise HTTPException(409, 'Fakturering kræver en kunde')
invoice_groups[int(row['billing_customer_id'])].append(row)
continue
hours = Decimal(str(row.get('original_hours') or 0))
transaction_id = agreement_id = None
if hours <= 0:
raise HTTPException(409, 'Tidsregistreringen skal have positiv tid')
if method=='prepaid':
card = execute_query_single("SELECT * FROM tticket_prepaid_cards WHERE id=%s AND status='active' AND (expires_at IS NULL OR expires_at>=CURRENT_DATE)", (row.get('prepaid_card_id'),))
if not card or int(card['customer_id']) != int(row.get('billing_customer_id') or 0):
raise HTTPException(409, 'Klippekortet er ikke gyldigt for kunden')
_check_coverage(card, decisions[tid])
hours = Decimal(str(_hours_for_prepaid_card(row, int(card.get('rounding_minutes') or 30))))
balance = execute_query_single('UPDATE tticket_prepaid_cards SET used_hours=used_hours+%s,updated_at=now() WHERE id=%s AND remaining_hours>=%s RETURNING remaining_hours', (hours,card['id'],hours))
if not balance:
raise HTTPException(409, 'Klippekortet har ikke timer nok')
transaction_id = execute_insert('''INSERT INTO tticket_prepaid_transactions
(card_id,transaction_type,hours,balance_after,description,created_by_user_id,workflow_time_entry_id)
VALUES(%s,'usage',%s,%s,%s,%s,%s) RETURNING id''',
(card['id'],-hours,balance['remaining_hours'],f'Workflow tidsregistrering #{tid}',actor_id,tid))
elif method=='subscription':
agreement_id = row.get('fixed_price_agreement_id')
worked = row.get('worked_date') or date.today()
agreement = execute_query_single("SELECT * FROM customer_fixed_price_agreements WHERE id=%s AND status IN ('active','pending_cancellation') AND start_date<=%s AND (end_date IS NULL OR end_date>=%s) AND (cancellation_effective_date IS NULL OR cancellation_effective_date>%s)", (agreement_id,worked,worked,worked))
if not agreement or int(agreement['customer_id']) != int(row.get('billing_customer_id') or 0):
raise HTTPException(409, 'Fastprisaftalen dækker ikke kunden eller arbejdsdatoen')
_check_coverage(agreement, decisions[tid])
start = worked.replace(day=1)
end = worked.replace(day=monthrange(worked.year,worked.month)[1])
execute_update('''INSERT INTO fixed_price_billing_periods(agreement_id,period_start,period_end,included_hours,used_hours,base_amount)
VALUES(%s,%s,%s,%s,%s,%s) ON CONFLICT(agreement_id,period_start)
DO UPDATE SET used_hours=fixed_price_billing_periods.used_hours+EXCLUDED.used_hours,updated_at=now()''',
(agreement_id,start,end,agreement['monthly_hours'],hours,
agreement.get('monthly_price') if agreement.get('monthly_price') is not None else Decimal(str(agreement['monthly_hours']))*Decimal(str(agreement['hourly_rate']))))
execute_update('''UPDATE tmodule_times SET billing_method=%s,prepaid_card_id=%s,fixed_price_agreement_id=%s,
status='billed',entry_status='godkendt',billable=%s,approved_hours=%s,approved_by=%s,approved_at=now(),
economy_settled_at=now(),economy_settled_by=%s,aktiv_timer=false WHERE id=%s''',
(method,row.get('prepaid_card_id') if method=='prepaid' else None,agreement_id,method=='prepaid',hours,actor_id,actor_id,tid))
execute_update('''INSERT INTO bmc_workflow_settlements(time_id,method,decision_id,transaction_id,agreement_id,hours,actor_id)
VALUES(%s,%s,%s,%s,%s,%s,%s)''',(tid,method,decisions[tid]['id'],transaction_id,agreement_id,hours,actor_id))
for customer, group in sorted(invoice_groups.items()):
for row in group:
row['billable'] = True
draft_id = _create_ordre_draft_from_selected(customer, group, actor_id, line_mode=line_mode)
drafts.append({'customer_id':customer,'draft_id':draft_id,'entry_ids':[r['id'] for r in group]})
for row in group:
tid=row['id'];hours=Decimal(str(row.get('approved_hours') or row.get('original_hours') or 0))
execute_update('''UPDATE tmodule_times SET billing_method='invoice',billable=true,status='approved',entry_status='godkendt',
economy_order_draft_id=%s,economy_settled_at=now(),economy_settled_by=%s,approved_at=now(),approved_by=%s,
approved_hours=%s,aktiv_timer=false WHERE id=%s''',(draft_id,actor_id,actor_id,hours,tid))
execute_update('INSERT INTO bmc_workflow_settlements(time_id,method,decision_id,draft_id,hours,actor_id) VALUES(%s,%s,%s,%s,%s,%s)',
(tid,'invoice',decisions[tid]['id'],draft_id,hours,actor_id))
execute_update("UPDATE bmc_billing_decisions SET status='settled' WHERE time_id=ANY(%s) AND is_current", (ids,))
return {'success':True,'settled_ids':ids,'created_drafts':drafts,'already_settled_ids':sorted(old_ids),
'orders_url':f"/ordre/{drafts[0]['draft_id']}" if len(drafts)==1 else '/ordre',
'message':'Tiderne er afregnet. Ordrekladder skal fortsat godkendes fra Ordre.'}
def _check_coverage(resource, decision):
from .engine import coverage_allows
context = (decision.get('details') or {}).get('input') or {}
if not coverage_allows(resource, context):
raise HTTPException(409, 'Arbejdet er ikke dækket af den valgte aftale')