"""Additive resource configuration APIs; existing agreement/card APIs stay unchanged.""" from decimal import Decimal from typing import Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from psycopg2.extras import Json from app.core.auth_dependencies import require_permission from .service import transaction, json_safe, emit_event router=APIRouter(prefix='/workflows/resources') access=require_permission('workflows.manage') class Coverage(BaseModel): tag_ids:list[int]=Field(default_factory=list) excluded_tag_ids:list[int]=Field(default_factory=list) include_descendants:bool=False class ResourceUpdate(BaseModel): workflow_coverage:Coverage monthly_price:Optional[Decimal]=Field(default=None,ge=0,max_digits=12,decimal_places=2) @router.get('') def list_resources(customer_id:Optional[int]=None,user=Depends(access)): with transaction() as cursor: where=' WHERE customer_id=%s' if customer_id is not None else '' params=(customer_id,) if customer_id is not None else None cursor.execute('SELECT id,agreement_number,customer_id,status,workflow_coverage,monthly_price FROM customer_fixed_price_agreements'+where+' ORDER BY customer_id,id',params) agreements=cursor.fetchall() cursor.execute('SELECT id,card_number,customer_id,status,remaining_hours,workflow_coverage FROM tticket_prepaid_cards'+where+' ORDER BY customer_id,id',params) cards=cursor.fetchall() cursor.execute('SELECT customer_id,workflow_coverage FROM bmc_workflow_customer_card_coverage') profiles={int(p['customer_id']):p['workflow_coverage'] for p in cursor.fetchall()} for card in cards: if int(card['customer_id']) in profiles: card['workflow_coverage']=profiles[int(card['customer_id'])] card['coverage_scope']='customer' return json_safe({'agreements':agreements,'cards':cards}) def update_resource(kind,resource_id,payload,user): table='customer_fixed_price_agreements' if kind=='agreement' else 'tticket_prepaid_cards' if kind=='card' and 'monthly_price' in payload.model_fields_set: raise HTTPException(422,'Månedspris gælder kun fastprisaftaler') coverage=payload.workflow_coverage.model_dump() # Explicitly saving empty coverage means all tags; untouched legacy {} stays inert. coverage['auto_billing']=True tag_ids=sorted(set(coverage['tag_ids']+coverage['excluded_tag_ids'])) if any(tag_id<=0 for tag_id in tag_ids): raise HTTPException(422,'Tag-ID skal være positivt') with transaction() as cursor: cursor.execute(f'SELECT * FROM {table} WHERE id=%s',(resource_id,)) resource=cursor.fetchone() if not resource: raise HTTPException(404,'Aftale eller kort findes ikke') if tag_ids: cursor.execute('SELECT id FROM tags WHERE id=ANY(%s) AND is_active=TRUE',(tag_ids,)) if {r['id'] for r in cursor.fetchall()}!=set(tag_ids): raise HTTPException(422,'Et tag findes ikke eller er inaktivt') # Lock pending time rows before the resource, matching settlement lock order. cursor.execute("SELECT t.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 COALESCE(s.customer_id,c.hub_customer_id)=%s AND t.status NOT IN ('approved','billed') AND t.economy_order_draft_id IS NULL ORDER BY t.id FOR UPDATE OF t",(resource['customer_id'],)) time_ids=[r['id'] for r in cursor.fetchall()] cursor.execute(f'SELECT * FROM {table} WHERE id=%s FOR UPDATE',(resource_id,)) resource=cursor.fetchone() price_sql=',monthly_price=%s' if kind=='agreement' and 'monthly_price' in payload.model_fields_set else '' values=[Json(coverage)] if price_sql: values.append(payload.monthly_price) values.append(resource_id) cursor.execute(f'UPDATE {table} SET workflow_coverage=%s{price_sql},updated_at=now() WHERE id=%s RETURNING *',tuple(values)) updated=cursor.fetchone() cursor.execute('INSERT INTO bmc_workflow_resource_history(resource_type,resource_id,created_by,before_value,after_value) VALUES(%s,%s,%s,%s,%s)',(kind,resource_id,str(user['id']),Json(json_safe(dict(resource))),Json(json_safe(dict(updated))))) if time_ids: cursor.execute('UPDATE tmodule_times SET workflow_revision=workflow_revision+1 WHERE id=ANY(%s) RETURNING id,workflow_revision',(time_ids,)) for time in cursor.fetchall(): emit_event(cursor,'time.updated','time_entry',time['id'],time['workflow_revision']) return json_safe(dict(updated)) @router.put('/agreements/{resource_id}/coverage') def update_agreement(resource_id:int,payload:ResourceUpdate,user=Depends(access)): return update_resource('agreement',resource_id,payload,user) @router.put('/cards/{resource_id}/coverage') def update_card(resource_id:int,payload:ResourceUpdate,user=Depends(access)): return update_resource('card',resource_id,payload,user) @router.put('/customers/{customer_id}/card-coverage') def update_customer_card_coverage(customer_id:int,payload:Coverage,user=Depends(access)): coverage=payload.model_dump() coverage['auto_billing']=True tag_ids=sorted(set(coverage['tag_ids']+coverage['excluded_tag_ids'])) if any(tag_id<=0 for tag_id in tag_ids): raise HTTPException(422,'Tag-ID skal være positivt') with transaction() as cursor: cursor.execute('SELECT id FROM customers WHERE id=%s',(customer_id,)) if not cursor.fetchone(): raise HTTPException(404,'Kunden findes ikke') if tag_ids: cursor.execute('SELECT id FROM tags WHERE id=ANY(%s) AND is_active=TRUE',(tag_ids,)) if {r['id'] for r in cursor.fetchall()}!=set(tag_ids): raise HTTPException(422,'Et tag findes ikke eller er inaktivt') # Match settlement lock order. Approved decisions are immutable and revalidated at settlement. cursor.execute("SELECT t.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 COALESCE(s.customer_id,c.hub_customer_id)=%s AND t.status NOT IN ('approved','billed') AND t.economy_order_draft_id IS NULL ORDER BY t.id FOR UPDATE OF t",(customer_id,)) time_ids=[r['id'] for r in cursor.fetchall()] cursor.execute('SELECT id FROM customers WHERE id=%s FOR UPDATE',(customer_id,)) cursor.execute('SELECT workflow_coverage FROM bmc_workflow_customer_card_coverage WHERE customer_id=%s',(customer_id,)) previous=cursor.fetchone() cursor.execute('INSERT INTO bmc_workflow_customer_card_coverage(customer_id,workflow_coverage) VALUES(%s,%s) ON CONFLICT(customer_id) DO UPDATE SET workflow_coverage=EXCLUDED.workflow_coverage,updated_at=now()', (customer_id,Json(coverage))) cursor.execute('INSERT INTO bmc_workflow_resource_history(resource_type,resource_id,created_by,before_value,after_value) VALUES(%s,%s,%s,%s,%s)',('customer_card',customer_id,str(user['id']),Json(json_safe(dict(previous))) if previous else Json({}),Json({'workflow_coverage':coverage}))) if time_ids: cursor.execute('UPDATE tmodule_times SET workflow_revision=workflow_revision+1 WHERE id=ANY(%s) RETURNING id,workflow_revision',(time_ids,)) for time in cursor.fetchall(): emit_event(cursor,'time.updated','time_entry',time['id'],time['workflow_revision']) return {'customer_id':customer_id,'workflow_coverage':coverage}