144 lines
11 KiB
Python
144 lines
11 KiB
Python
|
|
"""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')
|