2025-12-07 03:29:54 +01:00
"""
Supplier Invoices Router - Leverandørfakturaer ( Kassekladde )
Backend API for managing supplier invoices that integrate with e - conomic
"""
2026-03-02 13:48:14 +01:00
from fastapi import APIRouter , HTTPException , UploadFile , File , BackgroundTasks
2025-12-15 12:28:12 +01:00
from pydantic import BaseModel
2025-12-07 03:29:54 +01:00
from typing import List , Dict , Optional
from datetime import datetime , date , timedelta
from decimal import Decimal
2026-07-09 23:44:30 +02:00
import ipaddress
2025-12-07 03:29:54 +01:00
from pathlib import Path
2026-06-11 09:45:11 +02:00
from app . core . database import execute_query , execute_insert , execute_update , execute_query_single , table_has_column
2025-12-07 03:29:54 +01:00
from app . core . config import settings
from app . services . economic_service import get_economic_service
from app . services . ollama_service import ollama_service
from app . services . template_service import template_service
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
from app . services . invoice2data_service import get_invoice2data_service
2025-12-07 03:29:54 +01:00
import logging
import os
import re
2026-04-15 09:34:26 +02:00
import json
2025-12-07 03:29:54 +01:00
logger = logging . getLogger ( __name__ )
router = APIRouter ( )
2026-04-12 09:26:35 +02:00
_PURCHASE_CASE_TYPE = " indkøb "
2026-04-15 09:34:26 +02:00
SUPPLIER_STATUS_V2 = ( " modtaget " , " godkendt " , " betalt " , " afvist " )
def _v2_status_from_legacy ( legacy_status : Optional [ str ] ) - > str :
value = str ( legacy_status or " " ) . strip ( ) . lower ( )
if value in { " approved " , " sent_to_economic " } :
return " godkendt "
if value == " paid " :
return " betalt "
if value in { " cancelled " , " credited " , " rejected " } :
return " afvist "
return " modtaget "
def _legacy_status_from_v2 ( v2_status : str ) - > str :
mapping = {
" modtaget " : " pending " ,
" godkendt " : " approved " ,
" betalt " : " paid " ,
" afvist " : " cancelled " ,
}
return mapping . get ( v2_status , " pending " )
def _normalize_v2_status ( value : Optional [ str ] ) - > str :
v2_status = str ( value or " " ) . strip ( ) . lower ( )
if not v2_status :
return " modtaget "
if v2_status not in SUPPLIER_STATUS_V2 :
raise HTTPException (
status_code = 400 ,
detail = f " Ugyldig v2 status ' { v2_status } ' . Tilladte: { ' , ' . join ( SUPPLIER_STATUS_V2 ) } " ,
)
return v2_status
def _record_supplier_invoice_event (
invoice_id : int ,
event_type : str ,
from_status : Optional [ str ] ,
to_status : Optional [ str ] ,
payload : Optional [ Dict ] = None ,
) - > None :
try :
execute_update (
"""
INSERT INTO supplier_invoice_events (
supplier_invoice_id ,
event_type ,
from_status ,
to_status ,
payload_json
)
VALUES ( % s , % s , % s , % s , % s : : jsonb )
""" ,
(
invoice_id ,
event_type ,
from_status ,
to_status ,
json . dumps ( payload or { } ) ,
) ,
)
except Exception as event_error :
# Keep core invoice transitions operational even before migration rollout.
logger . warning ( " ⚠️ Could not persist supplier invoice event for %s : %s " , invoice_id , event_error )
def _get_invoice_status_v2 ( invoice_row : Dict ) - > str :
explicit_v2 = invoice_row . get ( " workflow_status_v2 " )
if explicit_v2 :
return _normalize_v2_status ( explicit_v2 )
return _v2_status_from_legacy ( invoice_row . get ( " status " ) )
def _transition_invoice_status_v2 (
invoice_id : int ,
new_status_v2 : str ,
actor : Optional [ str ] = None ,
reason : Optional [ str ] = None ,
) - > Dict :
invoice = execute_query_single (
"""
SELECT id , invoice_number , status , workflow_status_v2
FROM supplier_invoices
WHERE id = % s
""" ,
( invoice_id , ) ,
)
if not invoice :
raise HTTPException ( status_code = 404 , detail = f " Faktura { invoice_id } ikke fundet " )
from_status_v2 = _get_invoice_status_v2 ( invoice )
to_status_v2 = _normalize_v2_status ( new_status_v2 )
if from_status_v2 == to_status_v2 :
return {
" invoice_id " : invoice_id ,
" invoice_number " : invoice . get ( " invoice_number " ) ,
" from_status " : from_status_v2 ,
" to_status " : to_status_v2 ,
" changed " : False ,
}
allowed_transitions = {
" modtaget " : { " godkendt " , " afvist " } ,
" godkendt " : { " betalt " , " afvist " } ,
" betalt " : set ( ) ,
" afvist " : set ( ) ,
}
if to_status_v2 not in allowed_transitions . get ( from_status_v2 , set ( ) ) :
raise HTTPException (
status_code = 400 ,
detail = (
f " Ugyldig statusovergang: { from_status_v2 } -> { to_status_v2 } . "
" Tilladte overgange følger workflow: modtaget -> godkendt -> betalt eller afvist. "
) ,
)
legacy_status = _legacy_status_from_v2 ( to_status_v2 )
approved_by = actor if to_status_v2 == " godkendt " else None
rejected_by = actor if to_status_v2 == " afvist " else None
execute_update (
"""
UPDATE supplier_invoices
SET status = % s ,
workflow_status_v2 = % s ,
approved_by = CASE WHEN % s IS NULL THEN approved_by ELSE % s END ,
approved_at = CASE WHEN % s IS NULL THEN approved_at ELSE CURRENT_TIMESTAMP END ,
rejected_by = CASE WHEN % s IS NULL THEN rejected_by ELSE % s END ,
rejected_at = CASE WHEN % s IS NULL THEN rejected_at ELSE CURRENT_TIMESTAMP END ,
rejection_reason = CASE WHEN % s IS NULL THEN rejection_reason ELSE % s END ,
updated_at = CURRENT_TIMESTAMP
WHERE id = % s
""" ,
(
legacy_status ,
to_status_v2 ,
approved_by ,
approved_by ,
approved_by ,
rejected_by ,
rejected_by ,
rejected_by ,
reason ,
reason ,
invoice_id ,
) ,
)
_record_supplier_invoice_event (
invoice_id = invoice_id ,
event_type = " status_transition " ,
from_status = from_status_v2 ,
to_status = to_status_v2 ,
payload = { " actor " : actor , " reason " : reason } ,
)
return {
" invoice_id " : invoice_id ,
" invoice_number " : invoice . get ( " invoice_number " ) ,
" from_status " : from_status_v2 ,
" to_status " : to_status_v2 ,
" changed " : True ,
}
2026-04-12 09:26:35 +02:00
def _resolve_procurement_customer_id ( ) - > int :
""" Find customer used for internally-owned procurement cases. """
configured_id = getattr ( settings , " PROCUREMENT_CASE_CUSTOMER_ID " , None )
if configured_id :
row = execute_query_single (
" SELECT id FROM customers WHERE id = %s AND is_active = true " ,
( configured_id , )
)
if row :
return int ( row [ " id " ] )
bmc_row = execute_query_single (
"""
SELECT id
FROM customers
WHERE is_active = true
AND LOWER ( name ) LIKE % s
ORDER BY CASE WHEN LOWER ( name ) LIKE % s THEN 0 ELSE 1 END , id
LIMIT 1
""" ,
( " % bmc % " , " % bmc networks % " )
)
if bmc_row :
return int ( bmc_row [ " id " ] )
fallback = execute_query_single (
" SELECT id FROM customers WHERE is_active = true ORDER BY id LIMIT 1 "
)
if fallback :
return int ( fallback [ " id " ] )
raise ValueError ( " No active customer available for procurement case creation " )
2026-07-09 23:44:30 +02:00
def _resolve_group_id_by_name_tokens ( tokens : List [ str ] ) - > Optional [ int ] :
lowered = [ str ( token or " " ) . strip ( ) . lower ( ) for token in tokens if str ( token or " " ) . strip ( ) ]
if not lowered :
return None
clauses = " OR " . join ( [ " LOWER(name) LIKE %s " for _ in lowered ] )
params = tuple ( f " % { token } % " for token in lowered )
row = execute_query_single (
f """
SELECT id
FROM groups
WHERE { clauses }
ORDER BY id
LIMIT 1
""" ,
params ,
)
return int ( row [ " id " ] ) if row and row . get ( " id " ) else None
def _ensure_internet_change_case (
connection_id : int ,
invoice_number : str ,
reference : str ,
connection_name : str ,
owner_customer_id : Optional [ int ] ,
changes : Dict [ str , Dict [ str , object ] ] ,
) - > Optional [ int ] :
if not changes :
return None
title = f " Internet ændring { reference or connection_name } - faktura { invoice_number } "
existing = execute_query_single (
"""
SELECT id
FROM sag_sager
WHERE deleted_at IS NULL
AND titel = % s
ORDER BY id DESC
LIMIT 1
""" ,
( title , ) ,
)
if existing :
return int ( existing [ " id " ] )
assigned_group_id = _resolve_group_id_by_name_tokens ( [ " økonomi " , " okonomi " , " economic " ] )
try :
case_customer_id = owner_customer_id or _resolve_procurement_customer_id ( )
except Exception as exc :
logger . warning (
" Skipping automatic internet change case for connection %s on invoice %s : could not resolve case customer ( %s ) " ,
connection_id ,
invoice_number ,
exc ,
)
return None
change_lines = " \n " . join (
f " - { field } : { change . get ( ' from ' ) } -> { change . get ( ' to ' ) } "
for field , change in changes . items ( )
)
description = (
" Automatisk oprettet ved import af internetfaktura. \n "
f " Forbindelse: { connection_name } \n "
f " Reference: { reference or ' - ' } \n "
f " Faktura: { invoice_number } \n "
" Registrerede ændringer: \n "
f " { change_lines } "
)
try :
try :
row = execute_query_single (
"""
INSERT INTO sag_sager (
titel , beskrivelse , type , status , customer_id , assigned_group_id , created_by_user_id
)
VALUES ( % s , % s , % s , % s , % s , % s , % s )
RETURNING id
""" ,
( title , description , _PURCHASE_CASE_TYPE , " åben " , case_customer_id , assigned_group_id , 1 ) ,
)
except Exception as insert_error :
if ' column " type " ' not in str ( insert_error ) :
raise
row = execute_query_single (
"""
INSERT INTO sag_sager (
titel , beskrivelse , status , customer_id , assigned_group_id , created_by_user_id
)
VALUES ( % s , % s , % s , % s , % s , % s )
RETURNING id
""" ,
( title , description , " åben " , case_customer_id , assigned_group_id , 1 ) ,
)
except Exception as exc :
logger . warning (
" Skipping automatic internet change case for connection %s on invoice %s : %s " ,
connection_id ,
invoice_number ,
exc ,
)
return None
return int ( row [ " id " ] ) if row and row . get ( " id " ) else None
2026-04-12 09:26:35 +02:00
def _ensure_case_for_supplier_invoice (
invoice_id : int ,
invoice_number : str ,
vendor_name : Optional [ str ] ,
total_amount ,
currency : Optional [ str ] ,
file_id : Optional [ int ] = None ,
) - > Optional [ int ] :
""" Create and link a procurement case if missing for a supplier invoice. """
existing = execute_query_single (
" SELECT sag_id FROM supplier_invoices WHERE id = %s " ,
( invoice_id , )
)
if not existing :
return None
current_sag_id = existing . get ( " sag_id " )
if current_sag_id :
return int ( current_sag_id )
customer_id = _resolve_procurement_customer_id ( )
vendor_label = ( vendor_name or " Ukendt leverandør " ) . strip ( )
currency_label = ( currency or " DKK " ) . strip ( )
amount_label = f " { Decimal ( total_amount or 0 ) : .2f } "
case_title = f " Leverandørfaktura { invoice_number } - { vendor_label } "
case_description = (
" Auto-oprettet fra leverandørfaktura \n "
f " Faktura: { invoice_number } \n "
f " Leverandør: { vendor_label } \n "
f " Beløb: { amount_label } { currency_label } \n "
f " Invoice ID: { invoice_id } \n "
f " Fil ID: { file_id or ' - ' } "
)
2026-04-15 09:34:26 +02:00
try :
case_row = execute_query_single (
"""
INSERT INTO sag_sager ( titel , beskrivelse , type , status , customer_id , created_by_user_id )
VALUES ( % s , % s , % s , % s , % s , % s )
RETURNING id
""" ,
( case_title , case_description , _PURCHASE_CASE_TYPE , " åben " , customer_id , 1 )
)
except Exception as insert_error :
# Some environments use sag_sager without a `type` column.
if ' column " type " ' not in str ( insert_error ) :
raise
case_row = execute_query_single (
"""
INSERT INTO sag_sager ( titel , beskrivelse , status , customer_id , created_by_user_id )
VALUES ( % s , % s , % s , % s , % s )
RETURNING id
""" ,
( case_title , case_description , " åben " , customer_id , 1 )
)
2026-04-12 09:26:35 +02:00
if not case_row :
return None
sag_id = int ( case_row [ " id " ] )
execute_update (
" UPDATE supplier_invoices SET sag_id = %s WHERE id = %s " ,
( sag_id , invoice_id )
)
try :
execute_update (
"""
INSERT INTO sag_kommentarer ( sag_id , forfatter , indhold , er_system_besked )
VALUES ( % s , % s , % s , % s )
""" ,
(
sag_id ,
" Invoice Bot " ,
(
" 🔗 Automatisk oprettet fra leverandørfaktura \n "
f " Faktura: { invoice_number } \n "
f " Leverandør: { vendor_label } \n "
f " Beløb: { amount_label } { currency_label } \n "
f " Invoice ID: { invoice_id } "
) ,
True ,
) ,
)
except Exception as comment_error :
logger . warning ( " ⚠️ Could not create case comment for supplier invoice %s : %s " , invoice_id , comment_error )
logger . info ( " ✅ Linked supplier invoice %s to SAG- %s " , invoice_id , sag_id )
return sag_id
2025-12-07 03:29:54 +01:00
2026-07-09 23:44:30 +02:00
def _append_amount_validation_case_note (
sag_id : Optional [ int ] ,
invoice_id : int ,
invoice_number : str ,
validation_details : Optional [ Dict ] ,
validation_warning : Optional [ str ] ,
vat_warning : Optional [ str ] ,
file_id : Optional [ int ] = None ,
) - > None :
if not sag_id :
return
if not validation_warning and not vat_warning :
return
details = validation_details or { }
line_sum = details . get ( " line_sum " )
subtotal = details . get ( " subtotal " )
difference = details . get ( " difference " )
vat_amount = details . get ( " vat_amount " )
vat_expected = details . get ( " vat_expected " )
vat_difference = details . get ( " vat_difference " )
note_lines = [
" Automatisk valideringsadvarsel på leverandørfaktura " ,
f " Faktura: { invoice_number } " ,
f " Invoice ID: { invoice_id } " ,
f " Fil ID: { file_id or ' - ' } " ,
]
if validation_warning :
note_lines . append ( f " Subtotal-advarsel: { validation_warning } " )
if line_sum is not None or subtotal is not None or difference is not None :
note_lines . append (
f " Beregnet linjesum: { line_sum if line_sum is not None else ' - ' } · Subtotal: { subtotal if subtotal is not None else ' - ' } · Afvigelse: { difference if difference is not None else ' - ' } "
)
if vat_warning :
note_lines . append ( f " Moms-advarsel: { vat_warning } " )
if vat_amount is not None or vat_expected is not None or vat_difference is not None :
note_lines . append (
f " Registreret moms: { vat_amount if vat_amount is not None else ' - ' } · Forventet moms: { vat_expected if vat_expected is not None else ' - ' } · Afvigelse: { vat_difference if vat_difference is not None else ' - ' } "
)
note_body = " \n " . join ( note_lines )
try :
existing = execute_query_single (
"""
SELECT id
FROM sag_kommentarer
WHERE sag_id = % s
AND indhold = % s
ORDER BY id DESC
LIMIT 1
""" ,
( sag_id , note_body ) ,
)
if existing :
return
execute_update (
"""
INSERT INTO sag_kommentarer ( sag_id , forfatter , indhold , er_system_besked )
VALUES ( % s , % s , % s , % s )
""" ,
( sag_id , " Invoice Bot " , note_body , True ) ,
)
except Exception as comment_error :
logger . warning (
" ⚠️ Could not persist amount validation case note for supplier invoice %s / SAG- %s : %s " ,
invoice_id ,
sag_id ,
comment_error ,
)
2026-04-15 09:34:26 +02:00
def _to_decimal ( value , default : Decimal = Decimal ( " 0 " ) ) - > Decimal :
if value is None or value == " " :
return default
try :
return Decimal ( str ( value ) )
except Exception :
return default
2026-07-09 23:44:30 +02:00
def _normalize_change_value ( value ) :
if isinstance ( value , Decimal ) :
return str ( value . quantize ( Decimal ( " 0.01 " ) ) )
if isinstance ( value , float ) :
return f " { value : .2f } "
if isinstance ( value , ( datetime , date ) ) :
return value . isoformat ( )
if value is None :
return None
text = str ( value ) . strip ( )
return text or None
def _append_connection_history ( connection_id : int , event_type : str , summary : str , details : Dict ) - > None :
execute_update (
"""
INSERT INTO internet_connections_history ( connection_id , event_type , summary , details )
VALUES ( % s , % s , % s , % s : : jsonb )
""" ,
( connection_id , event_type , summary , json . dumps ( details or { } ) ) ,
)
def _build_change_dict ( existing : Dict , updated : Dict , fields : List [ str ] ) - > Dict [ str , Dict [ str , object ] ] :
changes : Dict [ str , Dict [ str , object ] ] = { }
for field in fields :
before = _normalize_change_value ( existing . get ( field ) )
after = _normalize_change_value ( updated . get ( field ) )
if before != after :
changes [ field ] = { " from " : before , " to " : after }
return changes
2026-04-15 09:34:26 +02:00
def _extract_lines_from_llm_payload ( extraction_row : Optional [ Dict ] ) - > List [ Dict ] :
if not extraction_row :
return [ ]
raw_payload = extraction_row . get ( " llm_response_json " )
if not raw_payload :
return [ ]
try :
payload = raw_payload if isinstance ( raw_payload , dict ) else json . loads ( str ( raw_payload ) )
except Exception :
return [ ]
lines = payload . get ( " lines " )
if not isinstance ( lines , list ) :
return [ ]
normalized = [ ]
for idx , line in enumerate ( lines , start = 1 ) :
if not isinstance ( line , dict ) :
continue
normalized . append (
{
" line_number " : line . get ( " line_number " ) or idx ,
" sku " : line . get ( " sku " ) or line . get ( " item_number " ) or line . get ( " article_number " ) ,
" description " : line . get ( " description " ) or line . get ( " name " ) or " " ,
" quantity " : line . get ( " quantity " ) or 1 ,
" unit_price " : line . get ( " unit_price " ) or 0 ,
" line_total " : line . get ( " line_total " ) or line . get ( " amount " ) or 0 ,
" vat_rate " : line . get ( " vat_rate " ) or 25.0 ,
" vat_amount " : line . get ( " vat_amount " ) or 0 ,
2026-07-09 23:44:30 +02:00
" ip_address " : line . get ( " ip_address " ) ,
" contract_number " : line . get ( " contract_number " ) ,
" provider_reference " : line . get ( " provider_reference " ) ,
" customer_reference " : line . get ( " customer_reference " ) ,
" circuit_id " : line . get ( " circuit_id " ) ,
" end_customer_name " : line . get ( " end_customer_name " ) ,
" period_start " : line . get ( " period_start " ) ,
" period_end " : line . get ( " period_end " ) ,
" service_address " : line . get ( " service_address " ) ,
" location_street " : line . get ( " location_street " ) ,
" location_zip " : line . get ( " location_zip " ) ,
" location_city " : line . get ( " location_city " ) ,
2026-04-15 09:34:26 +02:00
}
)
return normalized
def _load_extraction_lines ( extraction_row : Optional [ Dict ] ) - > List [ Dict ] :
if not extraction_row :
return [ ]
extraction_id = extraction_row . get ( " extraction_id " )
if not extraction_id :
return [ ]
db_lines = execute_query (
"""
2026-07-09 23:44:30 +02:00
SELECT
line_number , sku , description , quantity , unit_price , line_total , vat_rate , vat_amount ,
ip_address , contract_number , provider_reference , customer_reference , circuit_id ,
end_customer_name , period_start , period_end , service_address ,
location_street , location_zip , location_city
2026-04-15 09:34:26 +02:00
FROM extraction_lines
WHERE extraction_id = % s
ORDER BY line_number
""" ,
( extraction_id , ) ,
) or [ ]
if db_lines :
return db_lines
return _extract_lines_from_llm_payload ( extraction_row )
2026-07-09 23:44:30 +02:00
def _compose_supplier_line_description ( line : Dict ) - > str :
description = str ( line . get ( " description " ) or " " ) . strip ( )
extras = [ ]
for key in ( " provider_reference " , " circuit_id " , " contract_number " ) :
value = str ( line . get ( key ) or " " ) . strip ( )
if value and value not in extras :
extras . append ( value )
if line . get ( " service_address " ) :
extras . append ( str ( line . get ( " service_address " ) ) . strip ( ) )
elif line . get ( " location_street " ) and line . get ( " location_zip " ) and line . get ( " location_city " ) :
extras . append ( f " { line . get ( ' location_street ' ) } , { line . get ( ' location_zip ' ) } { line . get ( ' location_city ' ) } " )
if line . get ( " period_start " ) and line . get ( " period_end " ) :
extras . append ( f " { line . get ( ' period_start ' ) } til { line . get ( ' period_end ' ) } " )
if not extras :
return description
return f " { description } ( { ' · ' . join ( extras ) } ) "
def _is_globalconnect_extraction ( extraction_row : Optional [ Dict ] ) - > bool :
if not extraction_row :
return False
vendor_name = str ( extraction_row . get ( " vendor_name " ) or " " ) . lower ( )
if " globalconnect " in vendor_name :
return True
try :
payload = extraction_row . get ( " llm_response_json " )
if isinstance ( payload , str ) :
payload = json . loads ( payload )
issuer = str ( ( payload or { } ) . get ( " issuer " ) or " " ) . lower ( )
template = str ( ( payload or { } ) . get ( " template " ) or " " ) . lower ( )
return " globalconnect " in issuer or template == " dk.globalconnect "
except Exception :
return False
def _normalize_company_name ( value : Optional [ str ] ) - > str :
normalized = str ( value or " " ) . upper ( )
replacements = {
" Æ " : " AE " ,
" Ø " : " OE " ,
" Å " : " AA " ,
" & " : " OG " ,
" / " : " " ,
}
for source , target in replacements . items ( ) :
normalized = normalized . replace ( source , target )
normalized = re . sub ( r " [^A-Z0-9 ]+ " , " " , normalized )
normalized = re . sub ( r " \ s+ " , " " , normalized ) . strip ( )
return normalized
def _build_service_address ( line : Dict ) - > Optional [ str ] :
if line . get ( " service_address " ) :
return str ( line . get ( " service_address " ) ) . strip ( )
if line . get ( " location_street " ) and line . get ( " location_zip " ) and line . get ( " location_city " ) :
return f " { line . get ( ' location_street ' ) } , { line . get ( ' location_zip ' ) } { line . get ( ' location_city ' ) } "
return None
def _normalize_provider_reference ( value : Optional [ str ] ) - > str :
raw = str ( value or " " ) . strip ( ) . upper ( )
if not raw :
return " "
return re . sub ( r " [^A-Z0-9] " , " " , raw )
def _build_mapping_note ( end_customer_name : str , service_address : Optional [ str ] , reference : str ) - > str :
parts = [ f " Afventer mapping for { reference } . " ]
if end_customer_name :
parts . append ( f " Udtrukket kunde: { end_customer_name } . " )
if service_address :
parts . append ( f " Udtrukket adresse: { service_address } . " )
parts . append ( " Kræver manuel kontrol før forbindelsen kan anses som korrekt. " )
return " " . join ( parts )
def _has_confident_globalconnect_mapping ( matched_customer : Optional [ Dict ] , service_address : Optional [ str ] ) - > bool :
return bool ( matched_customer and service_address )
def _resolve_internal_bmc_customer ( ) - > Optional [ Dict ] :
preferred = execute_query_single (
"""
SELECT id , name , address , postal_code , city
FROM customers
WHERE is_active = true
AND lower ( name ) = % s
""" ,
( " bmc networks " , ) ,
)
if preferred :
return dict ( preferred )
try :
customer_id = _resolve_procurement_customer_id ( )
except Exception :
return None
fallback = execute_query_single (
"""
SELECT id , name , address , postal_code , city
FROM customers
WHERE id = % s AND is_active = true
""" ,
( customer_id , ) ,
)
return dict ( fallback ) if fallback else None
def _should_assign_internal_bmc_owner (
lines : List [ Dict ] ,
matched_customer : Optional [ Dict ] ,
service_address : Optional [ str ] ,
) - > bool :
if matched_customer :
return False
if any ( str ( line . get ( " end_customer_name " ) or " " ) . strip ( ) for line in lines ) :
return False
if any ( _looks_like_ip_range_line ( line ) for line in lines ) :
return True
if len ( lines ) > 1 :
return True
return bool ( service_address )
def _shared_connection_value_type ( internal_owner : Optional [ Dict ] , matched_customer : Optional [ Dict ] ) - > str :
if internal_owner and not matched_customer :
return " delefiber "
return " other "
def _load_active_customers_for_matching ( ) - > List [ Dict ] :
rows = execute_query (
"""
SELECT id , name , address , postal_code , city
FROM customers
WHERE is_active = true
ORDER BY id
"""
) or [ ]
return [ dict ( row ) for row in rows ]
def _match_customer_for_globalconnect_line ( line : Dict , customers : List [ Dict ] ) - > Optional [ Dict ] :
end_customer_name = str ( line . get ( " end_customer_name " ) or " " ) . strip ( )
service_address = _build_service_address ( line ) or " "
normalized_target = _normalize_company_name ( end_customer_name )
address_parts = [ part . strip ( ) . upper ( ) for part in re . split ( r " [, ]+ " , service_address ) if part . strip ( ) ]
best_match = None
best_score = 0
for customer in customers :
customer_name = str ( customer . get ( " name " ) or " " ) . strip ( )
normalized_customer = _normalize_company_name ( customer_name )
score = 0
if normalized_target and normalized_customer :
if normalized_target == normalized_customer :
score = 100
elif normalized_target in normalized_customer or normalized_customer in normalized_target :
score = 80
else :
target_tokens = set ( normalized_target . split ( ) )
customer_tokens = set ( normalized_customer . split ( ) )
overlap = target_tokens & customer_tokens
if overlap :
score = max ( score , min ( len ( overlap ) * 15 , 60 ) )
if service_address :
customer_address = str ( customer . get ( " address " ) or " " ) . upper ( )
customer_city = str ( customer . get ( " city " ) or " " ) . upper ( )
customer_postal = str ( customer . get ( " postal_code " ) or " " ) . upper ( )
address_score = 0
if customer_address and customer_address in service_address . upper ( ) :
address_score + = 35
if customer_city and customer_city in service_address . upper ( ) :
address_score + = 20
if customer_postal and customer_postal in service_address . upper ( ) :
address_score + = 20
if address_parts and customer_address :
overlap = sum ( 1 for part in address_parts if len ( part ) > 2 and part in customer_address )
address_score + = min ( overlap * 5 , 15 )
score = max ( score , address_score )
if score > best_score :
best_score = score
best_match = customer
return best_match if best_score > = 50 else None
def _looks_like_ip_range_line ( line : Dict ) - > bool :
description = str ( line . get ( " description " ) or " " ) . lower ( )
return bool ( line . get ( " ip_address " ) ) and (
" ipv4 " in description
or " ipv6 " in description
or " ip-adress " in description
or " ip adress " in description
)
def _looks_like_connection_component ( line : Dict ) - > bool :
if _looks_like_ip_range_line ( line ) :
return False
reference = _normalize_provider_reference ( line . get ( " provider_reference " ) or line . get ( " circuit_id " ) )
if not reference :
return False
description = str ( line . get ( " description " ) or " " ) . lower ( )
if not description :
return False
return any (
token in description
for token in (
" fiber " ,
" adsl " ,
" vdsl " ,
" dsl " ,
" internet " ,
" mpls " ,
" rackskab " ,
" datacenter " ,
" sla " ,
" forbindelse " ,
)
)
def _can_create_connection_from_line ( line : Dict ) - > bool :
if not _looks_like_connection_component ( line ) :
return False
service_address = _build_service_address ( line )
if not service_address :
return False
description = str ( line . get ( " description " ) or " " )
speed_mbps , upload_mbps , download_mbps = _infer_speed_profile ( description )
return bool ( speed_mbps or upload_mbps or download_mbps )
def _infer_connection_type ( description : str ) - > str :
desc = description . lower ( )
if " mpls " in desc :
return " mpls "
if " rackskab " in desc or " datacenter " in desc :
return " datacenter "
if " adsl " in desc or " vdsl " in desc or " dsl " in desc :
return " xdsl "
return " fiber "
def _infer_technology ( description : str ) - > str :
desc = description . lower ( )
if " adsl " in desc :
return " ADSL "
if " vdsl " in desc :
return " VDSL "
if " mpls " in desc :
return " MPLS "
if " fiber " in desc :
return " Fiber "
if " datacenter " in desc :
return " Datacenter "
return " Internet "
def _infer_speed_profile ( description : str ) - > tuple [ Optional [ int ] , Optional [ int ] , Optional [ int ] ] :
slash_match = re . search (
r " ( \ d+(?:[.,] \ d+)?) \ s*/ \ s*( \ d+(?:[.,] \ d+)?) \ s*(K|M|G)bps " ,
description ,
re . IGNORECASE ,
)
if slash_match :
down_value = float ( slash_match . group ( 1 ) . replace ( " , " , " . " ) )
up_value = float ( slash_match . group ( 2 ) . replace ( " , " , " . " ) )
unit = slash_match . group ( 3 ) . upper ( )
multiplier = 1
if unit == " G " :
multiplier = 1000
elif unit == " K " :
multiplier = 1 / 1000
download_mbps = max ( int ( round ( down_value * multiplier ) ) , 1 )
upload_mbps = max ( int ( round ( up_value * multiplier ) ) , 1 )
return download_mbps , upload_mbps , download_mbps
match = re . search ( r " ( \ d+(?:[.,] \ d+)?) \ s*(G|M)bps " , description , re . IGNORECASE )
if not match :
return None , None , None
value = float ( match . group ( 1 ) . replace ( " , " , " . " ) )
unit = match . group ( 2 ) . upper ( )
speed_mbps = int ( value * 1000 ) if unit == " G " else int ( value )
return speed_mbps , speed_mbps , speed_mbps
def _line_monthly_cost ( line : Dict ) - > Decimal :
unit_price = _to_decimal ( line . get ( " unit_price " ) )
if unit_price > 0 :
return unit_price
line_total = _to_decimal ( line . get ( " line_total " ) )
quantity = _to_decimal ( line . get ( " quantity " ) , Decimal ( " 1 " ) )
if quantity < = 0 :
return line_total
return line_total / quantity
def _ensure_connection_pricing_entry ( connection_id : int , effective_from , purchase_price : Decimal , notes : str ) :
if not effective_from :
return
existing = execute_query_single (
"""
SELECT id
FROM internet_connections_pricing
WHERE connection_id = % s AND effective_from = % s
ORDER BY id DESC
LIMIT 1
""" ,
( connection_id , effective_from ) ,
)
if existing :
execute_update (
"""
UPDATE internet_connections_pricing
SET purchase_price = % s ,
notes = % s
WHERE id = % s
""" ,
( purchase_price , notes , existing [ " id " ] ) ,
)
return
execute_update (
"""
INSERT INTO internet_connections_pricing (
connection_id , effective_from , purchase_price , sales_price , notes
)
VALUES ( % s , % s , % s , % s , % s )
""" ,
( connection_id , effective_from , purchase_price , Decimal ( " 0 " ) , notes ) ,
)
def _ensure_ip_addresses_for_range ( range_id : int , cidr : str ) - > int :
existing = execute_query_single (
"""
SELECT COUNT ( * ) AS total
FROM internet_connections_ip_addresses
WHERE range_id = % s AND deleted_at IS NULL
""" ,
( range_id , ) ,
)
if existing and int ( existing . get ( " total " ) or 0 ) > 0 :
return int ( existing . get ( " total " ) or 0 )
try :
network = ipaddress . ip_network ( cidr , strict = False )
except ValueError :
return 0
created = 0
for host in network . hosts ( ) :
existing_ip = execute_query_single (
"""
SELECT id
FROM internet_connections_ip_addresses
WHERE ip_address = % s
AND deleted_at IS NULL
LIMIT 1
""" ,
( str ( host ) , ) ,
)
if existing_ip :
continue
execute_update (
"""
INSERT INTO internet_connections_ip_addresses (
range_id , ip_address , status , assigned_to , assigned_type , comment
)
VALUES ( % s , % s , % s , % s , % s , % s )
""" ,
( range_id , str ( host ) , " available " , None , None , " Auto-generated from synced CIDR " ) ,
)
created + = 1
return created
def _merge_globalconnect_duplicate_connections ( connection_ids : List [ int ] , canonical_id : int ) :
duplicate_ids = [ connection_id for connection_id in connection_ids if connection_id != canonical_id ]
if not duplicate_ids :
return
for duplicate_id in duplicate_ids :
execute_update (
"""
UPDATE internet_connections_ip_ranges
SET connection_id = % s ,
updated_at = CURRENT_TIMESTAMP
WHERE connection_id = % s AND deleted_at IS NULL
""" ,
( canonical_id , duplicate_id ) ,
)
execute_update (
"""
UPDATE internet_connections_ip_addresses
SET assigned_connection_id = % s
WHERE assigned_connection_id = % s
""" ,
( canonical_id , duplicate_id ) ,
)
execute_update (
"""
UPDATE internet_connections_connections
SET deleted_at = CURRENT_TIMESTAMP ,
updated_at = CURRENT_TIMESTAMP ,
notes = CONCAT ( COALESCE ( notes , ' ' ) , CASE WHEN COALESCE ( notes , ' ' ) = ' ' THEN ' ' ELSE E ' \n ' END , % s )
WHERE id = % s
""" ,
( f " Dublet samlet under forbindelse # { canonical_id } via GlobalConnect reference-normalisering. " , duplicate_id ) ,
)
def _merge_globalconnect_duplicate_ip_ranges ( connection_id : int , cidr : str ) - > Optional [ int ] :
matches = execute_query (
"""
SELECT id
FROM internet_connections_ip_ranges
WHERE connection_id = % s
AND cidr = % s
AND deleted_at IS NULL
ORDER BY id
""" ,
( connection_id , cidr ) ,
)
if not matches :
return None
canonical_id = int ( matches [ 0 ] [ " id " ] )
duplicate_ids = [ int ( row [ " id " ] ) for row in matches [ 1 : ] ]
for duplicate_id in duplicate_ids :
execute_update (
"""
UPDATE internet_connections_ip_addresses
SET range_id = % s
WHERE range_id = % s
""" ,
( canonical_id , duplicate_id ) ,
)
execute_update (
"""
UPDATE internet_connections_ip_ranges
SET deleted_at = CURRENT_TIMESTAMP ,
updated_at = CURRENT_TIMESTAMP ,
description = CONCAT ( COALESCE ( description , ' ' ) , CASE WHEN COALESCE ( description , ' ' ) = ' ' THEN ' ' ELSE E ' \n ' END , % s )
WHERE id = % s
""" ,
( f " Dublet samlet under range # { canonical_id } via GlobalConnect reference-normalisering. " , duplicate_id ) ,
)
return canonical_id
def _normalize_service_address_for_match ( value : Optional [ str ] ) - > str :
normalized = str ( value or " " ) . strip ( ) . upper ( )
normalized = normalized . replace ( " Æ " , " AE " ) . replace ( " Ø " , " OE " ) . replace ( " Å " , " AA " )
normalized = re . sub ( r " [^A-Z0-9] " , " " , normalized )
return normalized
def _get_globalconnect_connections_by_reference ( reference : str ) - > List [ Dict ] :
if not reference :
return [ ]
rows = execute_query (
"""
SELECT id , customer_id , address , monthly_cost , technology , connection_type ,
circuit_number , speed_mbps , download_mbps , upload_mbps , status ,
allocation_model , value_type , value_label
FROM internet_connections_connections
WHERE deleted_at IS NULL
AND provider ILIKE ' GlobalConnect %% '
AND regexp_replace ( UPPER ( COALESCE ( circuit_number , ' ' ) ) , ' [^A-Z0-9] ' , ' ' , ' g ' ) = % s
ORDER BY id
""" ,
( reference , ) ,
) or [ ]
return [ dict ( row ) for row in rows ]
def _resolve_existing_globalconnect_connection ( reference : str , service_address : Optional [ str ] ) - > Dict [ str , object ] :
matches = _get_globalconnect_connections_by_reference ( reference )
if not matches :
return { " row " : None , " merge_ids " : [ ] , " conflict_reason " : None , " matched_on " : None }
normalized_target = _normalize_service_address_for_match ( service_address )
exact_matches = [ ]
blank_address_matches = [ ]
other_address_matches = [ ]
for row in matches :
normalized_existing = _normalize_service_address_for_match ( row . get ( " address " ) )
if not normalized_existing :
blank_address_matches . append ( row )
elif normalized_existing == normalized_target :
exact_matches . append ( row )
else :
other_address_matches . append ( row )
if exact_matches :
canonical = exact_matches [ 0 ]
merge_ids = [ int ( row [ " id " ] ) for row in exact_matches [ 1 : ] + blank_address_matches ]
return {
" row " : canonical ,
" merge_ids " : merge_ids ,
" conflict_reason " : None ,
" matched_on " : " address " ,
}
if blank_address_matches and not other_address_matches :
canonical = blank_address_matches [ 0 ]
merge_ids = [ int ( row [ " id " ] ) for row in blank_address_matches [ 1 : ] ]
return {
" row " : canonical ,
" merge_ids " : merge_ids ,
" conflict_reason " : None ,
" matched_on " : " blank_address " ,
}
conflicting_addresses = " , " . join (
sorted ( { str ( row . get ( " address " ) or " " ) . strip ( ) for row in other_address_matches if str ( row . get ( " address " ) or " " ) . strip ( ) } )
)
conflict_reason = " Reference findes allerede på anden adresse "
if conflicting_addresses :
conflict_reason = f " { conflict_reason } : { conflicting_addresses } "
return { " row " : None , " merge_ids " : [ ] , " conflict_reason " : conflict_reason , " matched_on " : None }
def _upsert_globalconnect_connection ( reference : str , lines : List [ Dict ] , invoice_date , invoice_number : str , customers : List [ Dict ] ) - > Optional [ int ] :
if not reference or not lines :
return None
sorted_lines = sorted (
lines ,
key = lambda item : (
" administrationsgebyr " in str ( item . get ( " description " ) or " " ) . lower ( ) ,
" sla " in str ( item . get ( " description " ) or " " ) . lower ( ) ,
) ,
)
primary_line = sorted_lines [ 0 ]
display_reference = str ( primary_line . get ( " provider_reference " ) or primary_line . get ( " circuit_id " ) or reference ) . strip ( )
matched_customer = _match_customer_for_globalconnect_line ( primary_line , customers )
service_address = _build_service_address ( primary_line )
if not service_address :
logger . warning (
" Skipping GlobalConnect connection %s from invoice %s because service address is missing " ,
display_reference or reference ,
invoice_number ,
)
return None
description = str ( primary_line . get ( " description " ) or reference )
end_customer_name = str ( primary_line . get ( " end_customer_name " ) or " " ) . strip ( )
internal_owner = _resolve_internal_bmc_customer ( ) if _should_assign_internal_bmc_owner ( lines , matched_customer , service_address ) else None
owner_customer = matched_customer or internal_owner
is_confident = _has_confident_globalconnect_mapping ( matched_customer , service_address )
connection_name = (
end_customer_name or service_address or f " GlobalConnect { reference } "
if is_confident
else ( f " { internal_owner [ ' name ' ] } · { display_reference } " if internal_owner else f " Afventer mapping · { display_reference } " )
)
monthly_cost = sum ( ( _line_monthly_cost ( line ) for line in lines ) , Decimal ( " 0 " ) )
note_lines = " , " . join ( dict . fromkeys ( str ( line . get ( " description " ) or " " ) . strip ( ) for line in lines if line . get ( " description " ) ) )
base_note = f " Synced fra GlobalConnect faktura { invoice_number } . Komponenter: { note_lines } "
mapping_note = _build_mapping_note ( end_customer_name , service_address , display_reference )
if internal_owner and not matched_customer :
note_text = f " { base_note } Ejer sat til intern BMC-kunde, da forbindelsen bruges som delt hovedforbindelse eller ikke kan bindes sikkert til én slutkunde. "
else :
note_text = base_note if is_confident else f " { base_note } { mapping_note } "
existing_resolution = _resolve_existing_globalconnect_connection ( reference , service_address )
existing = existing_resolution . get ( " row " )
conflict_reason = existing_resolution . get ( " conflict_reason " )
if conflict_reason :
logger . warning (
" Skipping GlobalConnect connection %s from invoice %s because %s " ,
display_reference or reference ,
invoice_number ,
conflict_reason ,
)
return None
merge_ids = [ int ( item ) for item in ( existing_resolution . get ( " merge_ids " ) or [ ] ) ]
if existing and merge_ids :
_merge_globalconnect_duplicate_connections ( [ int ( existing [ " id " ] ) ] + merge_ids , int ( existing [ " id " ] ) )
download_mbps , upload_mbps , speed_mbps = _infer_speed_profile ( description )
target_status = " active " if ( is_confident or internal_owner ) else " pending "
shared_value_type = _shared_connection_value_type ( internal_owner , matched_customer )
payload = (
connection_name ,
" GlobalConnect A/S " ,
owner_customer [ " id " ] if owner_customer else None ,
service_address ,
monthly_cost ,
_infer_technology ( description ) ,
_infer_connection_type ( description ) ,
display_reference ,
speed_mbps ,
upload_mbps ,
download_mbps ,
note_text ,
" shared " if internal_owner and not matched_customer else " dedicated " ,
shared_value_type ,
None ,
)
if existing :
updated_snapshot = {
" customer_id " : owner_customer [ " id " ] if owner_customer else None ,
" address " : service_address ,
" monthly_cost " : monthly_cost ,
" technology " : _infer_technology ( description ) ,
" connection_type " : _infer_connection_type ( description ) ,
" circuit_number " : display_reference ,
" speed_mbps " : speed_mbps ,
" download_mbps " : download_mbps ,
" upload_mbps " : upload_mbps ,
" status " : target_status ,
" allocation_model " : " shared " if internal_owner and not matched_customer else " dedicated " ,
" value_type " : shared_value_type ,
" value_label " : None ,
}
update_payload = (
connection_name ,
" GlobalConnect A/S " ,
owner_customer [ " id " ] if owner_customer else None ,
service_address ,
monthly_cost ,
_infer_technology ( description ) ,
_infer_connection_type ( description ) ,
display_reference ,
speed_mbps ,
download_mbps ,
upload_mbps ,
note_text ,
" shared " if internal_owner and not matched_customer else " dedicated " ,
shared_value_type ,
None ,
existing [ " id " ] ,
)
execute_update (
"""
UPDATE internet_connections_connections
SET name = % s ,
provider = % s ,
customer_id = COALESCE ( % s , customer_id ) ,
address = COALESCE ( % s , address ) ,
status = % s ,
monthly_cost = % s ,
technology = % s ,
connection_type = % s ,
circuit_number = % s ,
speed_mbps = COALESCE ( % s , speed_mbps ) ,
download_mbps = COALESCE ( % s , download_mbps ) ,
upload_mbps = COALESCE ( % s , upload_mbps ) ,
allocation_model = % s ,
value_type = % s ,
value_label = % s ,
notes = % s ,
updated_at = CURRENT_TIMESTAMP
WHERE id = % s
""" ,
(
update_payload [ 0 ] ,
update_payload [ 1 ] ,
update_payload [ 2 ] ,
update_payload [ 3 ] ,
" active " if ( is_confident or internal_owner ) else " pending " ,
update_payload [ 4 ] ,
update_payload [ 5 ] ,
update_payload [ 6 ] ,
update_payload [ 7 ] ,
update_payload [ 8 ] ,
update_payload [ 9 ] ,
update_payload [ 10 ] ,
update_payload [ 12 ] ,
update_payload [ 13 ] ,
update_payload [ 14 ] ,
update_payload [ 11 ] ,
update_payload [ 15 ] ,
) ,
)
connection_id = int ( existing [ " id " ] )
changes = _build_change_dict (
existing ,
updated_snapshot ,
[
" customer_id " ,
" address " ,
" monthly_cost " ,
" technology " ,
" connection_type " ,
" circuit_number " ,
" speed_mbps " ,
" download_mbps " ,
" upload_mbps " ,
" status " ,
" allocation_model " ,
" value_type " ,
] ,
)
if changes :
_append_connection_history (
connection_id ,
" supplier_invoice_sync_changed " ,
f " Opdateret fra GlobalConnect faktura { invoice_number } " ,
{ " reference " : display_reference , " changes " : changes } ,
)
_ensure_internet_change_case (
connection_id = connection_id ,
invoice_number = invoice_number ,
reference = display_reference ,
connection_name = connection_name ,
owner_customer_id = owner_customer [ " id " ] if owner_customer else None ,
changes = changes ,
)
else :
connection_id = execute_insert (
"""
INSERT INTO internet_connections_connections (
name , provider , customer_id , address , status , monthly_cost , sales_price ,
technology , connection_type , circuit_number , speed_mbps , upload_mbps ,
download_mbps , notes , allocation_model , value_type , value_label
)
VALUES ( % s , % s , % s , % s , % s , % s , 0 , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
RETURNING id
""" ,
(
payload [ 0 ] ,
payload [ 1 ] ,
payload [ 2 ] ,
payload [ 3 ] ,
" active " if ( is_confident or internal_owner ) else " pending " ,
payload [ 4 ] ,
payload [ 5 ] ,
payload [ 6 ] ,
payload [ 7 ] ,
payload [ 8 ] ,
payload [ 9 ] ,
payload [ 10 ] ,
payload [ 11 ] ,
payload [ 12 ] ,
payload [ 13 ] ,
payload [ 14 ] ,
) ,
)
_append_connection_history (
connection_id ,
" connection_created_from_supplier_invoice " ,
f " Oprettet fra GlobalConnect faktura { invoice_number } " ,
{ " reference " : display_reference , " normalized_reference " : reference , " invoice_number " : invoice_number } ,
)
_ensure_connection_pricing_entry (
connection_id = connection_id ,
effective_from = invoice_date ,
purchase_price = monthly_cost ,
notes = f " GlobalConnect sync fra faktura { invoice_number } " ,
)
return int ( connection_id )
def _upsert_globalconnect_ip_range ( connection_id : int , line : Dict , invoice_number : str ) :
cidr = str ( line . get ( " ip_address " ) or " " ) . strip ( )
if not connection_id or not cidr :
return None
display_reference = str ( line . get ( " provider_reference " ) or line . get ( " circuit_id " ) or " " ) . strip ( )
service_address = _build_service_address ( line )
matched_customer = _match_customer_for_globalconnect_line ( line , _load_active_customers_for_matching ( ) )
canonical_range_id = _merge_globalconnect_duplicate_ip_ranges ( connection_id , cidr )
existing = execute_query_single (
"""
SELECT id , provider_reference , contract_number , customer_id , service_address , monthly_cost
FROM internet_connections_ip_ranges
WHERE connection_id = % s AND cidr = % s AND deleted_at IS NULL
ORDER BY id
LIMIT 1
""" ,
( connection_id , cidr ) ,
)
if not existing and canonical_range_id :
existing = { " id " : canonical_range_id }
monthly_cost = _line_monthly_cost ( line )
params = (
str ( line . get ( " description " ) or " IP-range " ) . strip ( ) ,
display_reference or None ,
str ( line . get ( " contract_number " ) or " " ) . strip ( ) or None ,
matched_customer [ " id " ] if matched_customer else None ,
service_address ,
monthly_cost ,
Decimal ( " 0 " ) ,
)
if existing :
changes = _build_change_dict (
existing ,
{
" provider_reference " : params [ 1 ] ,
" contract_number " : params [ 2 ] ,
" customer_id " : params [ 3 ] ,
" service_address " : params [ 4 ] ,
" monthly_cost " : params [ 5 ] ,
} ,
[ " provider_reference " , " contract_number " , " customer_id " , " service_address " , " monthly_cost " ] ,
)
execute_update (
"""
UPDATE internet_connections_ip_ranges
SET name = % s ,
provider_reference = % s ,
contract_number = % s ,
customer_id = COALESCE ( % s , customer_id ) ,
service_address = COALESCE ( % s , service_address ) ,
monthly_cost = % s ,
sales_price = % s ,
updated_at = CURRENT_TIMESTAMP
WHERE id = % s
""" ,
params + ( existing [ " id " ] , ) ,
)
range_id = int ( existing [ " id " ] )
if service_address :
execute_update (
"""
UPDATE internet_connections_connections
SET address = COALESCE ( NULLIF ( TRIM ( address ) , ' ' ) , % s ) ,
updated_at = CURRENT_TIMESTAMP
WHERE id = % s
""" ,
( service_address , connection_id ) ,
)
_ensure_ip_addresses_for_range ( range_id , cidr )
if changes :
_append_connection_history (
connection_id ,
" supplier_invoice_ip_range_changed " ,
f " IP-range { cidr } opdateret fra faktura { invoice_number } " ,
{ " cidr " : cidr , " changes " : changes } ,
)
reference = display_reference or cidr
_ensure_internet_change_case (
connection_id = connection_id ,
invoice_number = invoice_number ,
reference = reference ,
connection_name = f " IP-range { cidr } " ,
owner_customer_id = matched_customer [ " id " ] if matched_customer else None ,
changes = changes ,
)
return range_id
range_id = execute_insert (
"""
INSERT INTO internet_connections_ip_ranges (
connection_id , name , cidr , description , provider_reference , contract_number ,
customer_id , service_address , monthly_cost , sales_price
)
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
RETURNING id
""" ,
(
connection_id ,
params [ 0 ] ,
cidr ,
f " Synced fra GlobalConnect faktura { invoice_number } " ,
params [ 1 ] ,
params [ 2 ] ,
params [ 3 ] ,
params [ 4 ] ,
params [ 5 ] ,
params [ 6 ] ,
) ,
)
range_id = int ( range_id )
if service_address :
execute_update (
"""
UPDATE internet_connections_connections
SET address = COALESCE ( NULLIF ( TRIM ( address ) , ' ' ) , % s ) ,
updated_at = CURRENT_TIMESTAMP
WHERE id = % s
""" ,
( service_address , connection_id ) ,
)
_ensure_ip_addresses_for_range ( range_id , cidr )
_append_connection_history (
connection_id ,
" supplier_invoice_ip_range_created " ,
f " IP-range { cidr } oprettet fra faktura { invoice_number } " ,
{ " cidr " : cidr , " provider_reference " : params [ 1 ] , " contract_number " : params [ 2 ] } ,
)
return range_id
def _find_existing_globalconnect_connection_id ( reference : str , service_address : Optional [ str ] ) - > tuple [ Optional [ int ] , Optional [ str ] ] :
resolution = _resolve_existing_globalconnect_connection ( reference , service_address )
existing = resolution . get ( " row " )
if existing and existing . get ( " id " ) :
return int ( existing [ " id " ] ) , None
return None , resolution . get ( " conflict_reason " )
def _connection_can_host_ip_range ( connection_id : Optional [ int ] , service_address : Optional [ str ] ) - > bool :
if not connection_id :
return False
row = execute_query_single (
"""
SELECT address , allocation_model
FROM internet_connections_connections
WHERE id = % s
AND deleted_at IS NULL
""" ,
( connection_id , ) ,
)
if not row :
return False
if str ( row . get ( " allocation_model " ) or " " ) . lower ( ) == " shared " :
return True
if not service_address :
return True
return _normalize_service_address_for_match ( row . get ( " address " ) ) == _normalize_service_address_for_match ( service_address )
def _connection_skip_reason ( line : Dict ) - > str :
reference = _normalize_provider_reference ( line . get ( " provider_reference " ) or line . get ( " circuit_id " ) )
if not reference :
return " Mangler provider-reference/kredsløb "
if not _build_service_address ( line ) :
return " Mangler serviceadresse "
description = str ( line . get ( " description " ) or " " )
speed_mbps , upload_mbps , download_mbps = _infer_speed_profile ( description )
if not any ( ( speed_mbps , upload_mbps , download_mbps ) ) :
return " Mangler hastighed på forbindelseslinjen "
return " Forbindelseslinje kunne ikke valideres "
def _build_sync_audit_entry ( line : Dict , classification : str , status : str , reason : Optional [ str ] = None ) - > Dict :
reference = str ( line . get ( " provider_reference " ) or line . get ( " circuit_id " ) or " " ) . strip ( )
return {
" line_number " : line . get ( " line_number " ) ,
" description " : str ( line . get ( " description " ) or " " ) . strip ( ) or " - " ,
" classification " : classification ,
" status " : status ,
" reason " : reason ,
" provider_reference " : reference or None ,
" ip_address " : str ( line . get ( " ip_address " ) or " " ) . strip ( ) or None ,
" service_address " : _build_service_address ( line ) ,
" end_customer_name " : str ( line . get ( " end_customer_name " ) or " " ) . strip ( ) or None ,
}
def _summarize_sync_audit ( line_audit : List [ Dict ] , connection_groups : int , ip_range_candidates : int ) - > Dict :
actionable_lines = [ entry for entry in line_audit if entry [ " classification " ] in { " connection " , " ip_range " } ]
synced_lines = [ entry for entry in actionable_lines if entry [ " status " ] == " synced " ]
skipped_lines = [ entry for entry in actionable_lines if entry [ " status " ] == " skipped " ]
ignored_lines = [ entry for entry in line_audit if entry [ " status " ] == " ignored " ]
return {
" total_lines " : len ( line_audit ) ,
" actionable_lines " : len ( actionable_lines ) ,
" synced_actionable_lines " : len ( synced_lines ) ,
" skipped_actionable_lines " : len ( skipped_lines ) ,
" ignored_lines " : len ( ignored_lines ) ,
" connection_groups " : connection_groups ,
" ip_range_candidates " : ip_range_candidates ,
" all_actionable_accounted_for " : len ( actionable_lines ) == ( len ( synced_lines ) + len ( skipped_lines ) ) ,
" fully_synced " : len ( skipped_lines ) == 0 and len ( actionable_lines ) > 0 ,
" requires_manual_review " : len ( skipped_lines ) > 0 ,
}
def _sync_globalconnect_extraction_to_internet ( extraction_row : Dict , simulate : bool = False ) - > Dict :
if not _is_globalconnect_extraction ( extraction_row ) :
return { " skipped " : True , " reason " : " not_globalconnect " }
lines = _load_extraction_lines ( extraction_row )
if not lines :
return { " skipped " : True , " reason " : " no_lines " }
invoice_number = str ( extraction_row . get ( " document_id " ) or extraction_row . get ( " invoice_number " ) or extraction_row . get ( " file_id " ) or " ukendt " )
invoice_date = extraction_row . get ( " document_date " )
customers = _load_active_customers_for_matching ( )
grouped_connections : Dict [ str , List [ Dict ] ] = { }
connection_entry_indexes : Dict [ str , List [ int ] ] = { }
ip_range_lines : List [ tuple [ int , Dict ] ] = [ ]
skipped_connection_lines = 0
line_audit : List [ Dict ] = [ ]
for line in lines :
if _looks_like_ip_range_line ( line ) :
line_audit . append ( _build_sync_audit_entry ( line , " ip_range " , " pending " ) )
ip_range_lines . append ( ( len ( line_audit ) - 1 , line ) )
continue
if _looks_like_connection_component ( line ) :
if not _can_create_connection_from_line ( line ) :
skipped_connection_lines + = 1
line_audit . append ( _build_sync_audit_entry ( line , " connection " , " skipped " , _connection_skip_reason ( line ) ) )
continue
reference = _normalize_provider_reference ( line . get ( " provider_reference " ) or line . get ( " circuit_id " ) )
if reference :
grouped_connections . setdefault ( reference , [ ] ) . append ( line )
line_audit . append ( _build_sync_audit_entry ( line , " connection " , " pending " ) )
connection_entry_indexes . setdefault ( reference , [ ] ) . append ( len ( line_audit ) - 1 )
else :
skipped_connection_lines + = 1
line_audit . append ( _build_sync_audit_entry ( line , " connection " , " skipped " , " Mangler provider-reference/kredsløb " ) )
continue
line_audit . append ( _build_sync_audit_entry ( line , " other " , " ignored " , " Ikke en forbindelses- eller IP-range-linje " ) )
connection_map : Dict [ str , int ] = { }
created_or_updated_connections = 0
created_connections = 0
updated_connections = 0
for reference , reference_lines in grouped_connections . items ( ) :
primary_line = reference_lines [ 0 ]
service_address = _build_service_address ( primary_line )
existing_connection_id , connection_conflict_reason = _find_existing_globalconnect_connection_id ( reference , service_address )
if connection_conflict_reason :
for index in connection_entry_indexes . get ( reference , [ ] ) :
line_audit [ index ] [ " status " ] = " skipped "
line_audit [ index ] [ " reason " ] = connection_conflict_reason
skipped_connection_lines + = len ( connection_entry_indexes . get ( reference , [ ] ) )
continue
if simulate :
connection_id = existing_connection_id or - ( len ( connection_map ) + 1 )
else :
connection_id = _upsert_globalconnect_connection (
reference = reference ,
lines = reference_lines ,
invoice_date = invoice_date ,
invoice_number = invoice_number ,
customers = customers ,
)
if connection_id :
connection_map [ reference ] = connection_id
created_or_updated_connections + = 1
if existing_connection_id :
updated_connections + = 1
outcome = " updated "
else :
created_connections + = 1
outcome = " created "
for index in connection_entry_indexes . get ( reference , [ ] ) :
line_audit [ index ] [ " status " ] = " synced "
line_audit [ index ] [ " result " ] = outcome
line_audit [ index ] [ " connection_id " ] = None if simulate else connection_id
else :
for index in connection_entry_indexes . get ( reference , [ ] ) :
line_audit [ index ] [ " status " ] = " skipped "
line_audit [ index ] [ " reason " ] = " Forbindelsen kunne ikke oprettes/opdateres "
created_or_updated_ranges = 0
synced_ip_ranges_existing_connection = 0
skipped_orphan_ip_ranges = 0
for audit_index , line in ip_range_lines :
reference = _normalize_provider_reference ( line . get ( " provider_reference " ) or line . get ( " circuit_id " ) )
service_address = _build_service_address ( line )
connection_id = connection_map . get ( reference )
resolved_from_existing = False
if connection_id and not _connection_can_host_ip_range ( connection_id , service_address ) :
connection_id = None
if not connection_id and reference :
if not service_address :
line_audit [ audit_index ] [ " status " ] = " skipped "
line_audit [ audit_index ] [ " reason " ] = " Mangler serviceadresse til IP-range "
skipped_orphan_ip_ranges + = 1
continue
connection_id , connection_conflict_reason = _find_existing_globalconnect_connection_id ( reference , service_address )
if connection_id :
connection_map [ reference ] = connection_id
resolved_from_existing = True
elif connection_conflict_reason :
skipped_orphan_ip_ranges + = 1
line_audit [ audit_index ] [ " status " ] = " skipped "
line_audit [ audit_index ] [ " reason " ] = connection_conflict_reason
continue
if connection_id and (
simulate
or _upsert_globalconnect_ip_range ( connection_id , line , invoice_number )
) :
created_or_updated_ranges + = 1
if resolved_from_existing :
synced_ip_ranges_existing_connection + = 1
line_audit [ audit_index ] [ " status " ] = " synced "
line_audit [ audit_index ] [ " result " ] = " linked_existing_connection " if resolved_from_existing else " linked_synced_connection "
line_audit [ audit_index ] [ " connection_id " ] = None if simulate else connection_id
elif not connection_id :
skipped_orphan_ip_ranges + = 1
line_audit [ audit_index ] [ " status " ] = " skipped "
line_audit [ audit_index ] [ " reason " ] = " Ingen forbindelse fundet til IP-range "
verification = _summarize_sync_audit (
line_audit = line_audit ,
connection_groups = len ( grouped_connections ) ,
ip_range_candidates = len ( ip_range_lines ) ,
)
return {
" skipped " : False ,
" simulate " : simulate ,
" connections_synced " : created_or_updated_connections ,
" connections_created " : created_connections ,
" connections_updated " : updated_connections ,
" ip_ranges_synced " : created_or_updated_ranges ,
" ip_ranges_linked_existing_connections " : synced_ip_ranges_existing_connection ,
" skipped_connection_lines " : skipped_connection_lines ,
" skipped_orphan_ip_ranges " : skipped_orphan_ip_ranges ,
" line_audit " : line_audit ,
" skipped_items " : [ entry for entry in line_audit if entry [ " status " ] == " skipped " ] ,
" verification " : verification ,
}
2026-04-15 09:34:26 +02:00
def _find_existing_product_id ( vendor_id : Optional [ int ] , description : str , sku : Optional [ str ] ) - > Optional [ int ] :
sku_value = str ( sku or " " ) . strip ( )
desc_value = str ( description or " " ) . strip ( )
if vendor_id and sku_value :
row = execute_query_single (
"""
SELECT id
FROM products
WHERE deleted_at IS NULL
AND supplier_id = % s
AND (
supplier_sku = % s
OR sku_internal = % s
OR manufacturer_sku = % s
)
ORDER BY id
LIMIT 1
""" ,
( vendor_id , sku_value , sku_value , sku_value ) ,
)
if row :
return int ( row [ " id " ] )
if vendor_id and desc_value :
row = execute_query_single (
"""
SELECT id
FROM products
WHERE deleted_at IS NULL
AND supplier_id = % s
AND LOWER ( TRIM ( name ) ) = LOWER ( TRIM ( % s ) )
ORDER BY id
LIMIT 1
""" ,
( vendor_id , desc_value ) ,
)
if row :
return int ( row [ " id " ] )
if desc_value :
row = execute_query_single (
"""
SELECT id
FROM products
WHERE deleted_at IS NULL
AND LOWER ( TRIM ( name ) ) = LOWER ( TRIM ( % s ) )
ORDER BY id
LIMIT 1
""" ,
( desc_value , ) ,
)
if row :
return int ( row [ " id " ] )
return None
def _ensure_product_for_supplier_line (
vendor_id : Optional [ int ] ,
vendor_name : Optional [ str ] ,
description : Optional [ str ] ,
sku : Optional [ str ] ,
unit_price ,
currency : Optional [ str ] ,
vat_rate ,
) - > Optional [ int ] :
desc_value = str ( description or " " ) . strip ( )
sku_value = str ( sku or " " ) . strip ( ) or None
if not desc_value and not sku_value :
return None
try :
existing_id = _find_existing_product_id ( vendor_id , desc_value , sku_value )
if existing_id :
return existing_id
name = desc_value or f " Vare { sku_value } "
created_id = execute_insert (
"""
INSERT INTO products (
name ,
type ,
status ,
sku_internal ,
supplier_id ,
supplier_name ,
supplier_sku ,
supplier_price ,
supplier_currency ,
cost_price ,
vat_rate ,
billable ,
created_by
)
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
RETURNING id
""" ,
(
name ,
" product " ,
" active " ,
sku_value ,
vendor_id ,
vendor_name ,
sku_value ,
_to_decimal ( unit_price ) ,
( currency or " DKK " ) ,
_to_decimal ( unit_price ) ,
_to_decimal ( vat_rate , Decimal ( " 25.00 " ) ) ,
True ,
1 ,
) ,
)
logger . info ( " ✅ Auto-created product %s for vendor %s " , created_id , vendor_id )
return int ( created_id )
except Exception as product_error :
# Keep invoice creation alive even if product auto-create fails.
logger . warning ( " ⚠️ Could not auto-create/find product for supplier line ' %s ' : %s " , desc_value , product_error )
return _find_existing_product_id ( vendor_id , desc_value , sku_value )
2025-12-07 03:29:54 +01:00
def _smart_extract_lines ( text : str ) - > List [ Dict ] :
"""
2025-12-08 09:15:52 +01:00
Universal line extraction using pdfplumber layout mode .
Tries pdfplumber columnar format first , then falls back to vendor - specific patterns .
2025-12-07 03:29:54 +01:00
"""
lines_arr = text . split ( ' \n ' )
items = [ ]
i = 0
while i < len ( lines_arr ) :
line = lines_arr [ i ] . strip ( )
# Skip empty or header lines
if not line or re . search ( r ' (Position|Varenr|Beskrivelse|Antal|Pris|Total|Model) ' , line , re . IGNORECASE ) :
i + = 1
continue
2025-12-08 09:15:52 +01:00
# Pattern 1: pdfplumber layout mode - " 1 95006 Betalingsmetode... 1 41,20 41,20"
# Whitespace-separated columns: position item_number description quantity unit_price total_price
# Most specific pattern - try first!
layout_match = re . match ( r ' ^ \ s*( \ d { 1,2}) \ s+( \ d { 4,10}) \ s+(.+?) \ s( \ d { 1,2}) \ s+([ \ d \ s]+, \ d {2} ) \ s+([ \ d \ s]+, \ d {2} ) \ s*$ ' , line )
if layout_match :
items . append ( {
' line_number ' : len ( items ) + 1 ,
' position ' : layout_match . group ( 1 ) ,
' item_number ' : layout_match . group ( 2 ) ,
' description ' : layout_match . group ( 3 ) . strip ( ) ,
' quantity ' : layout_match . group ( 4 ) ,
' unit_price ' : layout_match . group ( 5 ) . replace ( ' ' , ' ' ) . replace ( ' , ' , ' . ' ) ,
' total_price ' : layout_match . group ( 6 ) . replace ( ' ' , ' ' ) . replace ( ' , ' , ' . ' ) ,
' raw_text ' : line
} )
logger . info ( f " ✅ pdfplumber layout: { layout_match . group ( 2 ) } - { layout_match . group ( 3 ) [ : 30 ] } ... " )
i + = 1
continue
# Pattern 2: ALSO format - "100 48023976 REFURB LENOVO..." (multi-line)
2025-12-07 03:29:54 +01:00
item_match = re . match ( r ' ^( \ d { 1,3}) \ s+( \ d { 6,}) \ s+(.+) ' , line )
if item_match :
position = item_match . group ( 1 )
item_number = item_match . group ( 2 )
description = item_match . group ( 3 ) . strip ( )
# Find næste linje med antal+priser
quantity = None
unit_price = None
total_price = None
for j in range ( i + 1 , min ( i + 10 , len ( lines_arr ) ) ) :
price_line = lines_arr [ j ] . strip ( )
price_match = re . match ( r ' ^( \ d+) \ s*(?:ST|stk|pc|pcs) \ s+([ \ d.,]+) \ s+([ \ d.,]+) ' , price_line , re . IGNORECASE )
if price_match :
quantity = price_match . group ( 1 )
unit_price = price_match . group ( 2 ) . replace ( ' , ' , ' . ' )
total_price = price_match . group ( 3 ) . replace ( ' , ' , ' . ' )
break
if quantity and unit_price :
items . append ( {
' line_number ' : len ( items ) + 1 ,
' position ' : position ,
' item_number ' : item_number ,
' description ' : description ,
' quantity ' : quantity ,
' unit_price ' : unit_price ,
' total_price ' : total_price ,
' raw_text ' : f " { line } ... { quantity } ST { unit_price } { total_price } "
} )
logger . info ( f " ✅ ALSO: { item_number } - { description [ : 30 ] } ... " )
i + = 1
continue
2025-12-08 09:15:52 +01:00
# Pattern 3: DCS single-line - "195006Betalingsmetode... 141,2041,20" (legacy PyPDF2 format)
# Position: 1 digit, Item: 4-10 digits, Description starts with letter
# Prices: Danish format 1-3 digits, comma, 2 decimals (e.g., 41,20 or 619,00)
# Quantity: 1-2 digits (non-greedy) before first price
dcs_match = re . match ( r ' ^( \ d)( \ d { 4,10})([A-Za-z].+?)( \ d { 1,2}?)( \ d { 1,3}, \ d {2} )( \ d { 1,3}, \ d {2} )$ ' , line )
2025-12-07 03:29:54 +01:00
if dcs_match :
items . append ( {
' line_number ' : len ( items ) + 1 ,
' position ' : dcs_match . group ( 1 ) ,
' item_number ' : dcs_match . group ( 2 ) ,
' description ' : dcs_match . group ( 3 ) . strip ( ) ,
' quantity ' : dcs_match . group ( 4 ) ,
' unit_price ' : dcs_match . group ( 5 ) . replace ( ' , ' , ' . ' ) ,
' total_price ' : dcs_match . group ( 6 ) . replace ( ' , ' , ' . ' ) ,
' raw_text ' : line
} )
2025-12-08 09:15:52 +01:00
logger . info ( f " ✅ DCS single-line: { dcs_match . group ( 2 ) } - { dcs_match . group ( 3 ) [ : 30 ] } ... " )
i + = 1
continue
# Pattern 4: DCS multi-line - "2994922511Ubiquiti..." then search for "...USW-FLEX 1619,00619,00" (legacy)
dcs_multi_match = re . match ( r ' ^( \ d)( \ d { 4,10})([A-Za-z].+)$ ' , line )
if dcs_multi_match and not re . search ( r ' KN8|EAN|Model|Position|Varenr|Tekst ' , line ) :
position = dcs_multi_match . group ( 1 )
item_number = dcs_multi_match . group ( 2 )
description = dcs_multi_match . group ( 3 ) . strip ( )
# Search next 5 lines for quantity/prices (Danish format 1-3 digits before comma)
for j in range ( 1 , 6 ) :
if i + j > = len ( lines_arr ) :
break
price_line = lines_arr [ i + j ] . strip ( )
# Match: "S/N: ...USW-FLEX 1619,00619,00" - qty (1-2 digits, non-greedy) + TWO prices
price_match = re . search ( r ' ( \ d { 1,2}?)( \ d { 1,3}, \ d {2} )( \ d { 1,3}, \ d {2} ) \ s*$ ' , price_line )
if price_match :
quantity = price_match . group ( 1 )
unit_price = price_match . group ( 2 ) . replace ( ' , ' , ' . ' )
total_price = price_match . group ( 3 ) . replace ( ' , ' , ' . ' )
items . append ( {
' line_number ' : len ( items ) + 1 ,
' position ' : position ,
' item_number ' : item_number ,
' description ' : description ,
' quantity ' : quantity ,
' unit_price ' : unit_price ,
' total_price ' : total_price ,
' raw_text ' : f " { line } ... { price_line } "
} )
logger . info ( f " ✅ DCS multi-line: { item_number } - { description [ : 30 ] } ... " )
break
2025-12-07 03:29:54 +01:00
i + = 1
continue
i + = 1
if items :
logger . info ( f " 📦 Multi-line extraction found { len ( items ) } items " )
else :
logger . warning ( " ⚠️ Multi-line extraction found no items " )
return items
# ========== CRUD OPERATIONS ==========
@router.get ( " /supplier-invoices " )
async def list_supplier_invoices (
status : Optional [ str ] = None ,
vendor_id : Optional [ int ] = None ,
2026-04-15 09:34:26 +02:00
sag_id : Optional [ int ] = None ,
2025-12-07 03:29:54 +01:00
overdue_only : bool = False
) :
"""
List all supplier invoices with filtering options
Args :
status : Filter by status ( pending , approved , sent_to_economic , paid , overdue , cancelled )
vendor_id : Filter by vendor
overdue_only : Only show overdue unpaid invoices
"""
try :
query = """
SELECT
si . * ,
v . name as vendor_full_name ,
v . economic_supplier_number as vendor_economic_id ,
CASE
WHEN si . paid_date IS NOT NULL THEN ' paid '
WHEN si . due_date < CURRENT_DATE AND si . paid_date IS NULL THEN ' overdue '
ELSE si . status
2026-04-15 09:34:26 +02:00
END as computed_status ,
COALESCE (
si . workflow_status_v2 ,
CASE
WHEN si . status IN ( ' approved ' , ' sent_to_economic ' ) THEN ' godkendt '
WHEN si . status = ' paid ' THEN ' betalt '
WHEN si . status IN ( ' cancelled ' , ' credited ' , ' rejected ' ) THEN ' afvist '
ELSE ' modtaget '
END
) as status_v2
2025-12-07 03:29:54 +01:00
FROM supplier_invoices si
LEFT JOIN vendors v ON si . vendor_id = v . id
WHERE 1 = 1
"""
params = [ ]
if status :
2026-04-15 09:34:26 +02:00
normalized_filter = str ( status ) . strip ( ) . lower ( )
if normalized_filter in SUPPLIER_STATUS_V2 :
query + = """
AND COALESCE (
si . workflow_status_v2 ,
CASE
WHEN si . status IN ( ' approved ' , ' sent_to_economic ' ) THEN ' godkendt '
WHEN si . status = ' paid ' THEN ' betalt '
WHEN si . status IN ( ' cancelled ' , ' credited ' , ' rejected ' ) THEN ' afvist '
ELSE ' modtaget '
END
) = % s
"""
params . append ( normalized_filter )
else :
query + = " AND si.status = %s "
params . append ( status )
2025-12-07 03:29:54 +01:00
if vendor_id :
query + = " AND si.vendor_id = %s "
params . append ( vendor_id )
2026-04-15 09:34:26 +02:00
if sag_id :
2026-06-11 09:45:11 +02:00
if table_has_column ( " supplier_invoices " , " sag_id " ) :
query + = " AND si.sag_id = %s "
params . append ( sag_id )
elif (
table_has_column ( " supplier_invoice_relations " , " supplier_invoice_id " )
and table_has_column ( " supplier_invoice_relations " , " relation_type " )
and table_has_column ( " supplier_invoice_relations " , " relation_id " )
) :
query + = """
AND EXISTS (
SELECT 1
FROM supplier_invoice_relations sir
WHERE sir . supplier_invoice_id = si . id
AND sir . relation_type = ' sag '
AND sir . relation_id = % s
)
"""
params . append ( sag_id )
else :
logger . warning (
" ⚠️ supplier invoice sag filter requested, but no schema link available (sag_id column/relation table missing) "
)
query + = " AND 1 = 0 "
2025-12-07 03:29:54 +01:00
if overdue_only :
query + = " AND si.due_date < CURRENT_DATE AND si.paid_date IS NULL "
query + = " ORDER BY si.due_date ASC, si.invoice_date DESC "
2025-12-16 22:07:20 +01:00
invoices = execute_query ( query , tuple ( params ) if params else ( ) )
2025-12-07 03:29:54 +01:00
2025-12-08 09:15:52 +01:00
# Add lines to each invoice
for invoice in invoices :
lines = execute_query (
" SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number " ,
( invoice [ ' id ' ] , )
)
invoice [ ' lines ' ] = lines
return invoices
except Exception as e :
logger . error ( f " ❌ Failed to list supplier invoices: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /pending-supplier-invoice-files " )
async def get_pending_files ( ) :
""" Hent alle filer der venter på behandling, inkl. AI-extracted """
try :
# Hent både pending files OG ai_extracted files
files = execute_query (
""" SELECT DISTINCT ON (f.file_id)
f . file_id ,
f . filename ,
f . status ,
f . uploaded_at ,
f . error_message ,
f . template_id ,
f . file_path ,
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
- - Quick analysis results ( available immediately on upload )
f . detected_cvr ,
f . detected_vendor_id ,
f . detected_document_type ,
f . detected_document_number ,
f . is_own_invoice ,
v_detected . name as detected_vendor_name ,
v_detected . cvr_number as detected_vendor_cvr ,
2025-12-08 09:15:52 +01:00
- - Get vendor info from latest extraction
ext . vendor_name ,
ext . vendor_cvr ,
ext . vendor_matched_id ,
v . name as matched_vendor_name ,
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
v . cvr_number as matched_vendor_cvr_number ,
2025-12-08 09:15:52 +01:00
- - Check if already has invoice via latest extraction only
si . id as existing_invoice_id ,
si . invoice_number as existing_invoice_number
FROM incoming_files f
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
LEFT JOIN vendors v_detected ON v_detected . id = f . detected_vendor_id
2025-12-08 09:15:52 +01:00
LEFT JOIN LATERAL (
SELECT extraction_id , file_id , vendor_name , vendor_cvr , vendor_matched_id
FROM extractions
WHERE file_id = f . file_id
ORDER BY created_at DESC
LIMIT 1
) ext ON true
LEFT JOIN vendors v ON v . id = ext . vendor_matched_id
LEFT JOIN supplier_invoices si ON si . extraction_id = ext . extraction_id
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
WHERE f . status IN ( ' pending ' , ' processing ' , ' failed ' , ' ai_extracted ' , ' processed ' , ' duplicate ' )
2025-12-08 09:15:52 +01:00
AND si . id IS NULL - - Only show files without invoice yet
ORDER BY f . file_id , f . uploaded_at DESC """
)
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
# Convert to regular dicts so we can add new keys
files = [ dict ( file ) for file in files ] if files else [ ]
# Check for invoice2data templates for each file
try :
from app . services . invoice2data_service import get_invoice2data_service
invoice2data = get_invoice2data_service ( )
logger . info ( f " 📋 Checking invoice2data templates: { len ( invoice2data . templates ) } loaded " )
for file in files :
2025-12-15 12:28:12 +01:00
# Check if there's an invoice2data template for this vendor's CVR or name
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
vendor_cvr = file . get ( ' matched_vendor_cvr_number ' ) or file . get ( ' detected_vendor_cvr ' ) or file . get ( ' vendor_cvr ' )
2025-12-15 12:28:12 +01:00
vendor_name = file . get ( ' vendor_name ' ) or file . get ( ' detected_vendor_name ' ) or file . get ( ' matched_vendor_name ' )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
file [ ' has_invoice2data_template ' ] = False
2025-12-15 12:28:12 +01:00
logger . debug ( f " File { file [ ' file_id ' ] } : CVR= { vendor_cvr } , name= { vendor_name } " )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
2025-12-15 12:28:12 +01:00
# Check all templates
for template_name , template_data in invoice2data . templates . items ( ) :
keywords = template_data . get ( ' keywords ' , [ ] )
logger . debug ( f " Template { template_name } : keywords= { keywords } " )
# Match by CVR
if vendor_cvr and str ( vendor_cvr ) in [ str ( k ) for k in keywords ] :
file [ ' has_invoice2data_template ' ] = True
file [ ' invoice2data_template_name ' ] = template_name
logger . info ( f " ✅ File { file [ ' file_id ' ] } matched template { template_name } by CVR " )
break
# Match by vendor name
if vendor_name :
for keyword in keywords :
if str ( keyword ) . upper ( ) in str ( vendor_name ) . upper ( ) :
file [ ' has_invoice2data_template ' ] = True
file [ ' invoice2data_template_name ' ] = template_name
logger . info ( f " ✅ File { file [ ' file_id ' ] } matched template { template_name } by name " )
break
if file [ ' has_invoice2data_template ' ] :
break
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
except Exception as e :
logger . error ( f " ❌ Failed to check invoice2data templates: { e } " , exc_info = True )
# Continue without invoice2data info
2025-12-08 09:15:52 +01:00
return { " files " : files if files else [ ] , " count " : len ( files ) if files else 0 }
except Exception as e :
logger . error ( f " ❌ Failed to get pending files: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-01-25 03:29:28 +01:00
@router.get ( " /supplier-invoices/files " )
async def get_files_by_status ( status : Optional [ str ] = None , limit : int = 100 ) :
"""
Get files filtered by status ( es )
Query params :
- status : Comma - separated list of statuses ( e . g . , " pending,extraction_failed " )
- limit : Maximum number of results
"""
try :
# Parse status filter
status_list = [ ]
if status :
status_list = [ s . strip ( ) for s in status . split ( ' , ' ) ]
# Build query
if status_list :
placeholders = ' , ' . join ( [ ' %s ' ] * len ( status_list ) )
query = f """
SELECT f . file_id , f . filename , f . file_path , f . file_size , f . mime_type ,
f . status , f . uploaded_at , f . processed_at , f . detected_cvr ,
f . detected_vendor_id , v . name as detected_vendor_name ,
2026-03-02 06:22:33 +01:00
ext . vendor_name ,
ext . vendor_cvr ,
ext . vendor_matched_id ,
COALESCE ( v_ext . name , ext . vendor_name , v . name ) as best_vendor_name ,
ext . total_amount ,
ext . confidence as vendor_match_confidence
2026-01-25 03:29:28 +01:00
FROM incoming_files f
LEFT JOIN vendors v ON f . detected_vendor_id = v . id
2026-03-02 06:22:33 +01:00
LEFT JOIN LATERAL (
SELECT vendor_name , vendor_cvr , vendor_matched_id , total_amount , confidence
FROM extractions
WHERE file_id = f . file_id
ORDER BY created_at DESC
LIMIT 1
) ext ON true
LEFT JOIN vendors v_ext ON v_ext . id = ext . vendor_matched_id
2026-01-25 03:29:28 +01:00
WHERE f . status IN ( { placeholders } )
ORDER BY f . uploaded_at DESC
LIMIT % s
"""
params = tuple ( status_list ) + ( limit , )
else :
query = """
SELECT f . file_id , f . filename , f . file_path , f . file_size , f . mime_type ,
f . status , f . uploaded_at , f . processed_at , f . detected_cvr ,
f . detected_vendor_id , v . name as detected_vendor_name ,
2026-03-02 06:22:33 +01:00
ext . vendor_name ,
ext . vendor_cvr ,
ext . vendor_matched_id ,
COALESCE ( v_ext . name , ext . vendor_name , v . name ) as best_vendor_name ,
ext . total_amount ,
ext . confidence as vendor_match_confidence
2026-01-25 03:29:28 +01:00
FROM incoming_files f
LEFT JOIN vendors v ON f . detected_vendor_id = v . id
2026-03-02 06:22:33 +01:00
LEFT JOIN LATERAL (
SELECT vendor_name , vendor_cvr , vendor_matched_id , total_amount , confidence
FROM extractions
WHERE file_id = f . file_id
ORDER BY created_at DESC
LIMIT 1
) ext ON true
LEFT JOIN vendors v_ext ON v_ext . id = ext . vendor_matched_id
2026-01-25 03:29:28 +01:00
ORDER BY f . uploaded_at DESC
LIMIT % s
"""
params = ( limit , )
files = execute_query ( query , params )
if not files :
files = [ ]
return {
" count " : len ( files ) ,
" files " : files
}
except Exception as e :
logger . error ( f " ❌ Failed to get files: { e } " , exc_info = True )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
@router.get ( " /supplier-invoices/files/ {file_id} /pdf-text " )
async def get_file_pdf_text ( file_id : int ) :
""" Hent fuld PDF tekst fra en uploaded fil (til template builder) """
try :
# Get file info
file_info = execute_query (
" SELECT file_path, filename FROM incoming_files WHERE file_id = %s " ,
2025-12-16 15:36:11 +01:00
( file_id , ) )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
if not file_info :
raise HTTPException ( status_code = 404 , detail = " Fil ikke fundet " )
2026-01-07 10:32:41 +01:00
file_data = file_info [ 0 ]
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
# Read PDF text
from pathlib import Path
2026-01-07 10:32:41 +01:00
file_path = Path ( file_data [ ' file_path ' ] )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
if not file_path . exists ( ) :
raise HTTPException ( status_code = 404 , detail = f " Fil ikke fundet på disk: { file_path } " )
pdf_text = await ollama_service . _extract_text_from_file ( file_path )
return {
" file_id " : file_id ,
2026-01-07 10:32:41 +01:00
" filename " : file_data [ ' filename ' ] ,
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
" pdf_text " : pdf_text
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to get PDF text: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-08 09:15:52 +01:00
@router.get ( " /supplier-invoices/files/ {file_id} /extracted-data " )
async def get_file_extracted_data ( file_id : int ) :
""" Hent AI-extracted data fra en uploaded fil """
try :
# Get file info
2025-12-16 15:36:11 +01:00
file_info = execute_query_single (
2025-12-08 09:15:52 +01:00
" SELECT * FROM incoming_files WHERE file_id = %s " ,
2025-12-16 15:36:11 +01:00
( file_id , ) )
2025-12-08 09:15:52 +01:00
if not file_info :
raise HTTPException ( status_code = 404 , detail = " Fil ikke fundet " )
# Get extraction results if exists
2025-12-16 15:36:11 +01:00
extraction = execute_query_single (
2025-12-08 09:15:52 +01:00
" SELECT * FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1 " ,
2025-12-16 15:36:11 +01:00
( file_id , ) )
2025-12-08 09:15:52 +01:00
2025-12-08 23:46:18 +01:00
# Parse llm_response_json if it exists (from AI or template extraction)
llm_json_data = None
if extraction and extraction . get ( ' llm_response_json ' ) :
import json
try :
2026-01-07 10:32:41 +01:00
raw_json = extraction [ ' llm_response_json ' ]
# Always parse if it's a string, even if psycopg2 returns it as JSON type
if isinstance ( raw_json , str ) :
llm_json_data = json . loads ( raw_json )
elif isinstance ( raw_json , dict ) :
llm_json_data = raw_json
else :
# Fallback: try to parse as string
llm_json_data = json . loads ( str ( raw_json ) )
2025-12-08 23:46:18 +01:00
logger . info ( f " 📊 Parsed llm_response_json: invoice_number= { llm_json_data . get ( ' invoice_number ' ) } " )
except Exception as e :
2026-01-07 10:32:41 +01:00
logger . error ( f " ❌ Failed to parse llm_response_json: { e } " )
2025-12-08 23:46:18 +01:00
2026-01-07 10:32:41 +01:00
# Get extraction lines if exist (use execute_query for multiple rows)
2025-12-08 09:15:52 +01:00
extraction_lines = [ ]
if extraction :
2026-01-07 10:32:41 +01:00
extraction_lines = execute_query (
2025-12-08 09:15:52 +01:00
""" SELECT * FROM extraction_lines
WHERE extraction_id = % s
ORDER BY line_number """ ,
( extraction [ ' extraction_id ' ] , )
)
# Read PDF text if needed
pdf_text = None
2026-01-25 03:29:28 +01:00
if file_info and file_info . get ( ' file_path ' ) :
2025-12-08 09:15:52 +01:00
from pathlib import Path
file_path = Path ( file_info [ ' file_path ' ] )
if file_path . exists ( ) :
pdf_text = await ollama_service . _extract_text_from_file ( file_path )
2025-12-08 23:46:18 +01:00
# Format line items for frontend
formatted_lines = [ ]
if extraction_lines :
for line in extraction_lines :
formatted_lines . append ( {
" description " : line . get ( ' description ' ) ,
" quantity " : float ( line . get ( ' quantity ' ) ) if line . get ( ' quantity ' ) else None ,
" unit_price " : float ( line . get ( ' unit_price ' ) ) if line . get ( ' unit_price ' ) else None ,
" vat_rate " : float ( line . get ( ' vat_rate ' ) ) if line . get ( ' vat_rate ' ) else None ,
" line_total " : float ( line . get ( ' line_total ' ) ) if line . get ( ' line_total ' ) else None ,
2026-07-09 23:44:30 +02:00
" vat_note " : line . get ( ' vat_note ' ) ,
" provider_reference " : line . get ( ' provider_reference ' ) ,
" contract_number " : line . get ( ' contract_number ' ) ,
" circuit_id " : line . get ( ' circuit_id ' ) ,
" ip_address " : line . get ( ' ip_address ' ) ,
" end_customer_name " : line . get ( ' end_customer_name ' ) ,
" service_address " : line . get ( ' service_address ' ) ,
2025-12-08 23:46:18 +01:00
} )
elif llm_json_data and llm_json_data . get ( ' lines ' ) :
# Use lines from LLM JSON response
for line in llm_json_data [ ' lines ' ] :
formatted_lines . append ( {
" description " : line . get ( ' description ' ) ,
" quantity " : float ( line . get ( ' quantity ' ) ) if line . get ( ' quantity ' ) else None ,
" unit_price " : float ( line . get ( ' unit_price ' ) ) if line . get ( ' unit_price ' ) else None ,
" vat_rate " : float ( line . get ( ' vat_rate ' ) ) if line . get ( ' vat_rate ' ) else None ,
" line_total " : float ( line . get ( ' line_total ' ) ) if line . get ( ' line_total ' ) else None ,
2026-07-09 23:44:30 +02:00
" vat_note " : line . get ( ' vat_note ' ) ,
" provider_reference " : line . get ( ' provider_reference ' ) ,
" contract_number " : line . get ( ' contract_number ' ) ,
" circuit_id " : line . get ( ' circuit_id ' ) ,
" ip_address " : line . get ( ' ip_address ' ) ,
" end_customer_name " : line . get ( ' end_customer_name ' ) ,
" service_address " : line . get ( ' service_address ' ) ,
2025-12-08 23:46:18 +01:00
} )
# Build llm_data response
llm_data = None
if llm_json_data :
2025-12-15 12:28:12 +01:00
# Normalize common invoice2data field names to our API schema
total_amount_value = llm_json_data . get ( ' total_amount ' )
if total_amount_value is None :
total_amount_value = llm_json_data . get ( ' amount_total ' )
invoice_date_value = llm_json_data . get ( ' invoice_date ' )
if invoice_date_value is None :
invoice_date_value = llm_json_data . get ( ' document_date ' )
due_date_value = llm_json_data . get ( ' due_date ' )
2026-03-02 09:33:50 +01:00
# Vendor name: AI uses 'vendor_name', invoice2data uses 'issuer'
vendor_name_val = (
llm_json_data . get ( ' vendor_name ' ) or
llm_json_data . get ( ' issuer ' ) or
( extraction . get ( ' vendor_name ' ) if extraction else None )
)
# Vendor CVR: AI uses 'vendor_cvr', invoice2data uses 'vendor_vat'
vendor_cvr_val = (
llm_json_data . get ( ' vendor_cvr ' ) or
llm_json_data . get ( ' vendor_vat ' ) or
( extraction . get ( ' vendor_cvr ' ) if extraction else None )
)
# Vendor address: AI uses 'vendor_address', invoice2data may have separate fields
vendor_address_val = (
llm_json_data . get ( ' vendor_address ' ) or
llm_json_data . get ( ' supplier_address ' ) or
llm_json_data . get ( ' vendor_street ' )
)
vendor_city_val = llm_json_data . get ( ' vendor_city ' ) or llm_json_data . get ( ' city ' )
vendor_postal_val = llm_json_data . get ( ' vendor_postal_code ' ) or llm_json_data . get ( ' postal_code ' )
vendor_email_val = llm_json_data . get ( ' vendor_email ' ) or llm_json_data . get ( ' supplier_email ' )
2025-12-08 23:46:18 +01:00
# Use invoice_number from LLM JSON (works for both AI and template extraction)
llm_data = {
" invoice_number " : llm_json_data . get ( ' invoice_number ' ) ,
2025-12-15 12:28:12 +01:00
" invoice_date " : invoice_date_value ,
" due_date " : due_date_value ,
" total_amount " : float ( total_amount_value ) if total_amount_value else None ,
2025-12-08 23:46:18 +01:00
" currency " : llm_json_data . get ( ' currency ' ) or ' DKK ' ,
" document_type " : llm_json_data . get ( ' document_type ' ) ,
2026-03-02 09:33:50 +01:00
" vendor_name " : vendor_name_val ,
" vendor_cvr " : vendor_cvr_val ,
" vendor_address " : vendor_address_val ,
" vendor_city " : vendor_city_val ,
" vendor_postal_code " : vendor_postal_val ,
" vendor_email " : vendor_email_val ,
2026-07-09 23:44:30 +02:00
" lines " : formatted_lines ,
" _validation_warning " : llm_json_data . get ( ' _validation_warning ' ) ,
" _vat_warning " : llm_json_data . get ( ' _vat_warning ' ) ,
" _validation_details " : llm_json_data . get ( ' _validation_details ' ) ,
2025-12-08 23:46:18 +01:00
}
elif extraction :
# Fallback to extraction table columns if no LLM JSON
llm_data = {
" invoice_number " : extraction . get ( ' document_id ' ) ,
" invoice_date " : extraction . get ( ' document_date ' ) . isoformat ( ) if extraction . get ( ' document_date ' ) else None ,
" due_date " : extraction . get ( ' due_date ' ) . isoformat ( ) if extraction . get ( ' due_date ' ) else None ,
" total_amount " : float ( extraction . get ( ' total_amount ' ) ) if extraction . get ( ' total_amount ' ) else None ,
" currency " : extraction . get ( ' currency ' ) or ' DKK ' ,
" document_type " : extraction . get ( ' document_type ' ) ,
2026-03-02 09:33:50 +01:00
" vendor_name " : extraction . get ( ' vendor_name ' ) ,
" vendor_cvr " : extraction . get ( ' vendor_cvr ' ) ,
" vendor_address " : None ,
" vendor_city " : None ,
" vendor_postal_code " : None ,
" vendor_email " : None ,
2025-12-08 23:46:18 +01:00
" lines " : formatted_lines
}
# Get vendor from extraction
vendor_matched_id = extraction . get ( ' vendor_matched_id ' ) if extraction else None
2026-07-09 23:44:30 +02:00
internet_sync_preview = None
if extraction :
try :
internet_sync_preview = _sync_globalconnect_extraction_to_internet ( extraction , simulate = True )
except Exception as preview_error :
logger . warning ( " ⚠️ Could not build internet sync preview for file %s : %s " , file_id , preview_error )
2025-12-08 23:46:18 +01:00
2025-12-08 09:15:52 +01:00
return {
" file_id " : file_id ,
" filename " : file_info [ ' filename ' ] ,
" status " : file_info [ ' status ' ] ,
" uploaded_at " : file_info [ ' uploaded_at ' ] ,
2025-12-08 23:46:18 +01:00
" vendor_matched_id " : vendor_matched_id ,
" llm_data " : llm_data ,
2025-12-08 09:15:52 +01:00
" extraction " : extraction ,
" extraction_lines " : extraction_lines if extraction_lines else [ ] ,
2026-07-09 23:44:30 +02:00
" internet_sync_preview " : internet_sync_preview ,
2025-12-08 09:15:52 +01:00
" pdf_text_preview " : pdf_text [ : 5000 ] if pdf_text else None
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to get extracted data: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-01-25 03:29:28 +01:00
@router.patch ( " /incoming-files/ {file_id} " )
async def update_incoming_file ( file_id : int , data : Dict ) :
"""
Update incoming file metadata ( e . g . , detected_vendor_id )
"""
try :
# Check if file exists
file_info = execute_query (
" SELECT file_id FROM incoming_files WHERE file_id = %s " ,
( file_id , )
)
if not file_info :
raise HTTPException ( status_code = 404 , detail = f " File { file_id } not found " )
# Build update query dynamically based on provided fields
allowed_fields = [ ' detected_vendor_id ' , ' status ' , ' notes ' ]
update_fields = [ ]
update_values = [ ]
for field in allowed_fields :
if field in data :
update_fields . append ( f " { field } = %s " )
update_values . append ( data [ field ] )
if not update_fields :
raise HTTPException ( status_code = 400 , detail = " No valid fields to update " )
# Execute update
update_values . append ( file_id )
query = f " UPDATE incoming_files SET { ' , ' . join ( update_fields ) } WHERE file_id = %s "
execute_update ( query , tuple ( update_values ) )
logger . info ( f " ✅ Updated file { file_id } : { update_fields } " )
return {
" file_id " : file_id ,
" message " : " File updated successfully " ,
" updated_fields " : list ( data . keys ( ) )
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to update file: { e } " , exc_info = True )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.delete ( " /incoming-files/ {file_id} " )
async def delete_incoming_file ( file_id : int ) :
"""
Delete incoming file from database and disk
"""
try :
# Get file info
file_info = execute_query (
" SELECT file_path FROM incoming_files WHERE file_id = %s " ,
( file_id , )
)
if not file_info :
raise HTTPException ( status_code = 404 , detail = f " File { file_id } not found " )
file_path = Path ( file_info [ 0 ] [ ' file_path ' ] )
# Delete from database
execute_update (
" DELETE FROM incoming_files WHERE file_id = %s " ,
( file_id , )
)
# Delete from disk if exists
if file_path . exists ( ) :
file_path . unlink ( )
logger . info ( f " 🗑️ Deleted file from disk: { file_path } " )
logger . info ( f " ✅ Deleted file { file_id } " )
return {
" file_id " : file_id ,
" message " : " File deleted successfully "
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to delete file: { e } " , exc_info = True )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-08 09:15:52 +01:00
@router.get ( " /supplier-invoices/files/ {file_id} /download " )
async def download_pending_file ( file_id : int ) :
""" View PDF in browser """
from fastapi . responses import FileResponse
from pathlib import Path
try :
# Get file info
2026-01-07 10:32:41 +01:00
file_result = execute_query (
2025-12-08 09:15:52 +01:00
" SELECT * FROM incoming_files WHERE file_id = %s " ,
2025-12-16 15:36:11 +01:00
( file_id , ) )
2025-12-08 09:15:52 +01:00
2026-01-07 10:32:41 +01:00
if not file_result :
2025-12-08 09:15:52 +01:00
raise HTTPException ( status_code = 404 , detail = " Fil ikke fundet " )
2026-01-07 10:32:41 +01:00
file_info = file_result [ 0 ] # Get first row
2025-12-08 09:15:52 +01:00
file_path = Path ( file_info [ ' file_path ' ] )
if not file_path . exists ( ) :
raise HTTPException ( status_code = 404 , detail = " Fil findes ikke på disk " )
# Return with inline disposition so browser displays it instead of downloading
return FileResponse (
path = str ( file_path ) ,
media_type = ' application/pdf ' ,
headers = { " Content-Disposition " : f " inline; filename= { file_info [ ' filename ' ] } " }
)
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to view file: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-08 23:46:18 +01:00
@router.get ( " /supplier-invoices/files/ {file_id} /pdf " )
async def get_file_pdf ( file_id : int ) :
""" Get PDF file for viewing (alias for download endpoint) """
return await download_pending_file ( file_id )
2025-12-08 09:15:52 +01:00
@router.post ( " /supplier-invoices/files/ {file_id} /link-vendor " )
async def link_vendor_to_extraction ( file_id : int , data : dict ) :
""" Link an existing vendor to the extraction """
try :
vendor_id = data . get ( ' vendor_id ' )
if not vendor_id :
raise HTTPException ( status_code = 400 , detail = " vendor_id is required " )
# Verify vendor exists
2025-12-16 15:36:11 +01:00
vendor = execute_query_single (
2025-12-08 09:15:52 +01:00
" SELECT id, name FROM vendors WHERE id = %s " ,
2025-12-16 15:36:11 +01:00
( vendor_id , ) )
2025-12-08 09:15:52 +01:00
if not vendor :
raise HTTPException ( status_code = 404 , detail = " Leverandør ikke fundet " )
# Get latest extraction for this file
2025-12-16 15:36:11 +01:00
extraction = execute_query_single (
2025-12-08 09:15:52 +01:00
" SELECT extraction_id FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1 " ,
2025-12-16 15:36:11 +01:00
( file_id , ) )
2025-12-08 09:15:52 +01:00
if not extraction :
2026-03-02 13:12:01 +01:00
# No extraction exists (e.g. custom template match or not yet processed)
# Create a minimal placeholder extraction so vendor can be linked
logger . info ( f " ⚠️ No extraction for file { file_id } — creating minimal extraction for vendor link " )
extraction_id = execute_insert (
""" INSERT INTO extractions
( file_id , vendor_matched_id , vendor_name , vendor_cvr ,
document_id , document_type , document_type_detected ,
currency , confidence , status )
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
RETURNING extraction_id """ ,
( file_id , vendor_id ,
vendor [ ' name ' ] , None ,
None , ' invoice ' , ' invoice ' ,
' DKK ' , 1.0 , ' manual ' )
)
else :
extraction_id = extraction [ ' extraction_id ' ]
# Update extraction with vendor match
execute_update (
" UPDATE extractions SET vendor_matched_id = %s WHERE extraction_id = %s " ,
( vendor_id , extraction_id )
)
# Also update incoming_files so table shows vendor immediately
2025-12-08 09:15:52 +01:00
execute_update (
2026-03-02 13:12:01 +01:00
" UPDATE incoming_files SET detected_vendor_id = %s , status = ' processed ' WHERE file_id = %s " ,
( vendor_id , file_id )
2025-12-08 09:15:52 +01:00
)
2026-07-09 23:44:30 +02:00
sync_result = None
extraction_row = execute_query_single (
" SELECT * FROM extractions WHERE extraction_id = %s " ,
( extraction_id , ) ,
)
if extraction_row :
sync_result = _sync_globalconnect_extraction_to_internet ( extraction_row )
2025-12-08 09:15:52 +01:00
2026-03-02 13:12:01 +01:00
logger . info ( f " ✅ Linked vendor { vendor [ ' name ' ] } (ID: { vendor_id } ) to file { file_id } " )
2025-12-08 09:15:52 +01:00
return {
" status " : " success " ,
" vendor_id " : vendor_id ,
2026-07-09 23:44:30 +02:00
" vendor_name " : vendor [ ' name ' ] ,
" internet_sync " : sync_result ,
2025-12-08 09:15:52 +01:00
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to link vendor: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.delete ( " /supplier-invoices/files/ {file_id} " )
async def delete_pending_file_endpoint ( file_id : int ) :
""" Slet uploaded fil og relateret data """
import os
from pathlib import Path
try :
# Get file info
2025-12-16 15:36:11 +01:00
file_info = execute_query_single (
2025-12-08 09:15:52 +01:00
" SELECT * FROM incoming_files WHERE file_id = %s " ,
2025-12-16 15:36:11 +01:00
( file_id , ) )
2025-12-08 09:15:52 +01:00
if not file_info :
raise HTTPException ( status_code = 404 , detail = " Fil ikke fundet " )
# Check if already converted to invoice
2025-12-16 15:36:11 +01:00
invoice_exists = execute_query_single (
2025-12-08 09:15:52 +01:00
""" SELECT si.id FROM supplier_invoices si
JOIN extractions e ON si . extraction_id = e . extraction_id
WHERE e . file_id = % s """ ,
2025-12-16 15:36:11 +01:00
( file_id , ) )
2025-12-08 09:15:52 +01:00
if invoice_exists :
raise HTTPException (
status_code = 400 ,
detail = " Kan ikke slette fil - der er allerede oprettet en faktura fra denne fil "
)
# Delete from database (cascade will handle extractions)
execute_update (
" DELETE FROM incoming_files WHERE file_id = %s " ,
( file_id , )
)
# Delete physical file
2026-01-25 03:29:28 +01:00
if file_info and file_info . get ( ' file_path ' ) :
2025-12-08 09:15:52 +01:00
file_path = Path ( file_info [ ' file_path ' ] )
if file_path . exists ( ) :
os . remove ( file_path )
logger . info ( f " 🗑️ Deleted file: { file_path } " )
return { " message " : " Fil slettet " , " file_id " : file_id }
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to delete file: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.patch ( " /supplier-invoices/files/ {file_id} " )
async def update_file_status ( file_id : int , data : dict ) :
""" Opdater status på uploadet fil """
try :
allowed_statuses = [ ' pending ' , ' processing ' , ' processed ' , ' ai_extracted ' , ' completed ' , ' failed ' ]
new_status = data . get ( ' status ' )
if not new_status or new_status not in allowed_statuses :
raise HTTPException ( status_code = 400 , detail = f " Ugyldig status. Tilladte: { ' , ' . join ( allowed_statuses ) } " )
execute_update (
" UPDATE incoming_files SET status = %s , processed_at = CURRENT_TIMESTAMP WHERE file_id = %s " ,
( new_status , file_id )
)
logger . info ( f " ✅ Updated file { file_id } status to { new_status } " )
return { " status " : " success " , " file_id " : file_id , " new_status " : new_status }
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to update file status: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /supplier-invoices/files/ {file_id} /link-vendor " )
async def link_vendor_to_extraction ( file_id : int , data : dict ) :
""" Link en eksisterende leverandør til en extraction """
try :
vendor_id = data . get ( ' vendor_id ' )
if not vendor_id :
raise HTTPException ( status_code = 400 , detail = " vendor_id er påkrævet " )
# Verify vendor exists
2025-12-16 15:36:11 +01:00
vendor = execute_query_single (
2025-12-08 09:15:52 +01:00
" SELECT id, name FROM vendors WHERE id = %s " ,
2025-12-16 15:36:11 +01:00
( vendor_id , ) )
2025-12-08 09:15:52 +01:00
if not vendor :
raise HTTPException ( status_code = 404 , detail = f " Leverandør { vendor_id } ikke fundet " )
# Get latest extraction for this file
2025-12-16 15:36:11 +01:00
extraction = execute_query_single (
2025-12-08 09:15:52 +01:00
" SELECT extraction_id FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1 " ,
2025-12-16 15:36:11 +01:00
( file_id , ) )
2025-12-08 09:15:52 +01:00
if not extraction :
2026-03-02 13:12:41 +01:00
# Create minimal extraction if none exists
logger . info ( f " ⚠️ No extraction for file { file_id } — creating minimal extraction for vendor link " )
extraction_id = execute_insert (
""" INSERT INTO extractions
( file_id , vendor_matched_id , vendor_name , vendor_cvr ,
document_id , document_type , document_type_detected ,
currency , confidence , status )
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
RETURNING extraction_id """ ,
( file_id , vendor_id , vendor [ ' name ' ] , None ,
None , ' invoice ' , ' invoice ' , ' DKK ' , 1.0 , ' manual ' )
)
else :
extraction_id = extraction [ ' extraction_id ' ]
execute_update (
" UPDATE extractions SET vendor_matched_id = %s WHERE extraction_id = %s " ,
( vendor_id , extraction_id )
)
2025-12-08 09:15:52 +01:00
execute_update (
2026-03-02 13:12:41 +01:00
" UPDATE incoming_files SET detected_vendor_id = %s , status = ' processed ' WHERE file_id = %s " ,
( vendor_id , file_id )
2025-12-08 09:15:52 +01:00
)
2026-07-09 23:44:30 +02:00
sync_result = None
extraction_row = execute_query_single (
" SELECT * FROM extractions WHERE extraction_id = %s " ,
( extraction_id , ) ,
)
if extraction_row :
sync_result = _sync_globalconnect_extraction_to_internet ( extraction_row )
2025-12-08 09:15:52 +01:00
2026-03-02 13:12:41 +01:00
logger . info ( f " ✅ Linked vendor { vendor [ ' name ' ] } (ID: { vendor_id } ) to extraction { extraction_id } " )
2025-12-08 09:15:52 +01:00
return {
" status " : " success " ,
" vendor_id " : vendor_id ,
" vendor_name " : vendor [ ' name ' ] ,
2026-07-09 23:44:30 +02:00
" extraction_id " : extraction_id ,
" internet_sync " : sync_result ,
2025-12-08 09:15:52 +01:00
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to link vendor: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /supplier-invoices/from-extraction/ {file_id} " )
async def create_invoice_from_extraction ( file_id : int ) :
""" Opret leverandørfaktura fra extraction data """
try :
# Get latest extraction for this file
2025-12-16 15:36:11 +01:00
extraction = execute_query_single (
2025-12-08 09:15:52 +01:00
""" SELECT e.*, v.name as vendor_name
FROM extractions e
LEFT JOIN vendors v ON v . id = e . vendor_matched_id
WHERE e . file_id = % s
ORDER BY e . created_at DESC
LIMIT 1 """ ,
2025-12-16 15:36:11 +01:00
( file_id , ) )
2025-12-08 09:15:52 +01:00
if not extraction :
raise HTTPException ( status_code = 404 , detail = " Ingen extraction fundet for denne fil " )
2026-04-12 09:26:35 +02:00
extraction_data = extraction
2026-01-25 03:29:28 +01:00
2025-12-08 09:15:52 +01:00
# Check if vendor is matched
2026-01-25 03:29:28 +01:00
if not extraction_data [ ' vendor_matched_id ' ] :
2025-12-08 09:15:52 +01:00
raise HTTPException (
status_code = 400 ,
detail = " Leverandør skal linkes før faktura kan oprettes. Brug ' Link eller Opret Leverandør ' først. "
)
# Check if invoice already exists
2025-12-16 15:36:11 +01:00
existing = execute_query_single (
2025-12-08 09:15:52 +01:00
" SELECT id FROM supplier_invoices WHERE extraction_id = %s " ,
2026-01-25 03:29:28 +01:00
( extraction_data [ ' extraction_id ' ] , ) )
2025-12-08 09:15:52 +01:00
if existing :
raise HTTPException ( status_code = 400 , detail = " Faktura er allerede oprettet fra denne extraction " )
2026-04-15 09:34:26 +02:00
# Get extracted lines from DB, fallback to LLM payload lines.
lines = _load_extraction_lines ( extraction_data )
2025-12-08 09:15:52 +01:00
# Parse LLM response JSON if it's a string
import json
2026-01-25 03:29:28 +01:00
llm_data = extraction_data . get ( ' llm_response_json ' )
2025-12-08 09:15:52 +01:00
if isinstance ( llm_data , str ) :
try :
llm_data = json . loads ( llm_data )
except :
llm_data = { }
elif not llm_data :
llm_data = { }
2026-07-09 23:44:30 +02:00
validation_warning = llm_data . get ( ' _validation_warning ' ) if isinstance ( llm_data , dict ) else None
vat_warning = llm_data . get ( ' _vat_warning ' ) if isinstance ( llm_data , dict ) else None
validation_details = llm_data . get ( ' _validation_details ' ) if isinstance ( llm_data , dict ) else None
2025-12-08 09:15:52 +01:00
# Get invoice number and type from LLM data or generate one
invoice_number = llm_data . get ( ' invoice_number ' ) if llm_data else None
if not invoice_number :
invoice_number = f " INV- { file_id } "
# Detect document type (invoice or credit_note)
document_type = llm_data . get ( ' document_type ' , ' invoice ' ) if llm_data else ' invoice '
invoice_type = ' credit_note ' if document_type == ' credit_note ' else ' invoice '
# Get dates - use today as fallback if missing
from datetime import datetime , timedelta
2026-01-25 03:29:28 +01:00
invoice_date = extraction_data . get ( ' document_date ' )
2025-12-08 09:15:52 +01:00
if not invoice_date :
invoice_date = datetime . now ( ) . strftime ( ' % Y- % m- %d ' )
logger . warning ( f " ⚠️ No invoice_date found, using today: { invoice_date } " )
2026-01-25 03:29:28 +01:00
due_date = extraction_data . get ( ' due_date ' )
2025-12-08 09:15:52 +01:00
if not due_date :
# Default to 30 days from invoice date
inv_date_obj = datetime . strptime ( invoice_date , ' % Y- % m- %d ' )
due_date = ( inv_date_obj + timedelta ( days = 30 ) ) . strftime ( ' % Y- % m- %d ' )
logger . warning ( f " ⚠️ No due_date found, using invoice_date + 30 days: { due_date } " )
# Create supplier invoice
invoice_id = execute_insert (
""" INSERT INTO supplier_invoices (
vendor_id , invoice_number , invoice_date , due_date ,
2026-04-15 09:34:26 +02:00
total_amount , currency , status , workflow_status_v2 , extraction_id , notes , invoice_type
) VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
2025-12-08 09:15:52 +01:00
RETURNING id """ ,
(
2026-01-25 03:29:28 +01:00
extraction_data [ ' vendor_matched_id ' ] ,
2025-12-08 09:15:52 +01:00
invoice_number ,
invoice_date ,
due_date ,
2026-01-25 03:29:28 +01:00
extraction_data [ ' total_amount ' ] ,
extraction_data [ ' currency ' ] ,
2026-04-15 09:34:26 +02:00
' cancelled ' if invoice_type == ' credit_note ' else ' pending ' ,
' afvist ' if invoice_type == ' credit_note ' else ' modtaget ' ,
2026-01-25 03:29:28 +01:00
extraction_data [ ' extraction_id ' ] ,
2025-12-08 09:15:52 +01:00
f " Oprettet fra AI extraction (file_id: { file_id } ) " ,
invoice_type
)
)
2026-04-12 09:26:35 +02:00
2026-04-15 09:34:26 +02:00
_record_supplier_invoice_event (
invoice_id = invoice_id ,
event_type = " invoice_created " ,
from_status = None ,
to_status = ' afvist ' if invoice_type == ' credit_note ' else ' modtaget ' ,
payload = { " source " : " from_extraction " , " file_id " : file_id , " invoice_type " : invoice_type } ,
)
2026-04-12 09:26:35 +02:00
sag_id = _ensure_case_for_supplier_invoice (
invoice_id = invoice_id ,
invoice_number = invoice_number ,
vendor_name = extraction . get ( " vendor_name " ) ,
total_amount = extraction_data . get ( " total_amount " ) ,
currency = extraction_data . get ( " currency " ) ,
file_id = file_id ,
)
2026-07-09 23:44:30 +02:00
_append_amount_validation_case_note (
sag_id = sag_id ,
invoice_id = invoice_id ,
invoice_number = invoice_number ,
validation_details = validation_details ,
validation_warning = validation_warning ,
vat_warning = vat_warning ,
file_id = file_id ,
)
2025-12-08 09:15:52 +01:00
# Create invoice lines
if lines :
for line in lines :
2026-04-15 09:34:26 +02:00
quantity = _to_decimal ( line . get ( ' quantity ' ) , Decimal ( ' 1 ' ) )
unit_price = _to_decimal ( line . get ( ' unit_price ' ) )
line_total = _to_decimal ( line . get ( ' line_total ' ) )
if line_total < = 0 :
line_total = quantity * unit_price
vat_rate = _to_decimal ( line . get ( ' vat_rate ' ) , Decimal ( ' 25.00 ' ) )
vat_amount = _to_decimal ( line . get ( ' vat_amount ' ) )
sku = line . get ( ' sku ' ) or line . get ( ' item_number ' )
product_id = _ensure_product_for_supplier_line (
vendor_id = extraction_data . get ( ' vendor_matched_id ' ) ,
vendor_name = extraction . get ( ' vendor_name ' ) ,
2026-07-09 23:44:30 +02:00
description = _compose_supplier_line_description ( line ) ,
2026-04-15 09:34:26 +02:00
sku = sku ,
unit_price = unit_price ,
currency = extraction_data . get ( ' currency ' ) ,
vat_rate = vat_rate ,
)
2025-12-08 09:15:52 +01:00
execute_update (
""" INSERT INTO supplier_invoice_lines (
2026-04-15 09:34:26 +02:00
supplier_invoice_id , line_number , description , quantity , unit_price ,
line_total , vat_rate , vat_amount , product_id , sku
) VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s ) """ ,
2025-12-08 09:15:52 +01:00
(
invoice_id ,
2026-04-15 09:34:26 +02:00
line . get ( ' line_number ' ) ,
2026-07-09 23:44:30 +02:00
_compose_supplier_line_description ( line ) ,
2026-04-15 09:34:26 +02:00
quantity ,
unit_price ,
line_total ,
vat_rate ,
vat_amount ,
product_id ,
sku ,
2025-12-08 09:15:52 +01:00
)
)
# Update file status
execute_update (
" UPDATE incoming_files SET status = ' completed ' WHERE file_id = %s " ,
( file_id , )
)
2026-07-09 23:44:30 +02:00
internet_sync_result = _sync_globalconnect_extraction_to_internet ( extraction_data )
2025-12-08 09:15:52 +01:00
logger . info ( f " ✅ Created supplier invoice { invoice_id } from extraction { extraction [ ' extraction_id ' ] } " )
return {
" status " : " success " ,
" invoice_id " : invoice_id ,
2026-04-12 09:26:35 +02:00
" sag_id " : sag_id ,
2025-12-08 09:15:52 +01:00
" invoice_number " : invoice_number ,
" vendor_name " : extraction [ ' vendor_name ' ] ,
" total_amount " : extraction [ ' total_amount ' ] ,
2026-07-09 23:44:30 +02:00
" currency " : extraction [ ' currency ' ] ,
" internet_sync " : internet_sync_result ,
" amount_validation " : {
" warning " : validation_warning ,
" vat_warning " : vat_warning ,
" details " : validation_details ,
} ,
2025-12-08 09:15:52 +01:00
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to create invoice from extraction: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-07-09 23:44:30 +02:00
@router.post ( " /supplier-invoices/files/ {file_id} /sync-internet " )
async def sync_extraction_to_internet ( file_id : int ) :
""" Create or update internet connections/IP ranges from a GlobalConnect extraction. """
extraction = execute_query_single (
"""
SELECT *
FROM extractions
WHERE file_id = % s
ORDER BY created_at DESC
LIMIT 1
""" ,
( file_id , ) ,
)
if not extraction :
raise HTTPException ( status_code = 404 , detail = " Ingen extraction fundet for denne fil " )
result = _sync_globalconnect_extraction_to_internet ( extraction )
return {
" status " : " success " ,
" file_id " : file_id ,
" internet_sync " : result ,
}
2025-12-08 09:15:52 +01:00
# Keep existing endpoints below...
# ========== TEMPLATE MANAGEMENT (must be before {invoice_id} route) ==========
@router.get ( " /supplier-invoices/templates " )
async def list_templates ( ) :
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
""" Hent alle templates (både database og invoice2data YAML) """
2025-12-08 09:15:52 +01:00
try :
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
# Get database templates
2025-12-08 09:15:52 +01:00
query = """
SELECT t . * , v . name as vendor_name
FROM supplier_invoice_templates t
LEFT JOIN vendors v ON t . vendor_id = v . id
WHERE t . is_active = true
ORDER BY t . created_at DESC
"""
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
db_templates = execute_query ( query ) or [ ]
# Get invoice2data templates
invoice2data_service = get_invoice2data_service ( )
invoice2data_templates = [ ]
for template_name , template_data in invoice2data_service . templates . items ( ) :
# Extract vendor CVR from keywords
vendor_cvr = None
keywords = template_data . get ( ' keywords ' , [ ] )
for keyword in keywords :
if isinstance ( keyword , str ) and keyword . isdigit ( ) and len ( keyword ) == 8 :
vendor_cvr = keyword
break
# Get vendor info from database if CVR found
vendor_name = template_data . get ( ' issuer ' , ' Ukendt ' )
vendor_id = None
if vendor_cvr :
vendor = execute_query (
" SELECT id, name FROM vendors WHERE cvr_number = %s " ,
2025-12-16 15:36:11 +01:00
( vendor_cvr , ) )
2026-01-25 03:29:28 +01:00
if vendor and len ( vendor ) > 0 :
vendor_data = vendor [ 0 ]
vendor_id = vendor_data [ ' id ' ]
vendor_name = vendor_data [ ' name ' ]
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
invoice2data_templates . append ( {
' template_id ' : - 1 , # Negative ID to distinguish from DB templates
' template_name ' : f " Invoice2Data: { template_name } " ,
' template_type ' : ' invoice2data ' ,
' yaml_filename ' : template_name ,
' vendor_id ' : vendor_id ,
' vendor_name ' : vendor_name ,
' vendor_cvr ' : vendor_cvr ,
' default_product_category ' : template_data . get ( ' default_product_category ' , ' varesalg ' ) ,
' default_product_group_number ' : template_data . get ( ' default_product_group_number ' , 1 ) ,
' usage_count ' : 0 , # Could track this separately
' is_active ' : True ,
' detection_patterns ' : keywords ,
' field_mappings ' : template_data . get ( ' fields ' , { } ) ,
' created_at ' : None
} )
# Combine both types
all_templates = db_templates + invoice2data_templates
2025-12-08 09:15:52 +01:00
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
return all_templates
2025-12-08 09:15:52 +01:00
except Exception as e :
logger . error ( f " ❌ Failed to list templates: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-08 23:46:18 +01:00
@router.get ( " /supplier-invoices/templates/ {template_id} " )
async def get_template ( template_id : int ) :
""" Hent et specifikt template med vendor info """
try :
query = """
SELECT t . * , v . name as vendor_name , v . cvr_number as vendor_cvr
FROM supplier_invoice_templates t
LEFT JOIN vendors v ON t . vendor_id = v . id
WHERE t . template_id = % s AND t . is_active = true
"""
2025-12-16 15:36:11 +01:00
template = execute_query_single ( query , ( template_id , ) )
2025-12-08 23:46:18 +01:00
if not template :
raise HTTPException ( status_code = 404 , detail = " Template not found " )
return template
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to get template { template_id } : { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-08 09:15:52 +01:00
@router.post ( " /supplier-invoices/search-vendor " )
async def search_vendor_by_info ( request : Dict ) :
"""
Søg efter vendor baseret på navn , CVR , eller opret ny
Request body :
{
" vendor_name " : " DCS ApS " ,
" vendor_cvr " : " 12345678 " ,
" vendor_address " : " Vej 1, 2000 By " ,
" create_if_missing " : true
}
"""
try :
vendor_name = request . get ( ' vendor_name ' )
vendor_cvr = request . get ( ' vendor_cvr ' )
vendor_address = request . get ( ' vendor_address ' )
create_if_missing = request . get ( ' create_if_missing ' , False )
# Search by CVR first (most accurate)
if vendor_cvr :
2025-12-16 15:36:11 +01:00
vendor = execute_query_single (
2025-12-08 09:15:52 +01:00
" SELECT id, name, cvr_number FROM vendors WHERE cvr_number = %s " ,
2025-12-16 15:36:11 +01:00
( vendor_cvr , ) )
2025-12-08 09:15:52 +01:00
if vendor :
return {
" found " : True ,
" vendor_id " : vendor [ ' id ' ] ,
" vendor_name " : vendor [ ' name ' ] ,
" source " : " cvr_match "
}
# Search by name (fuzzy)
if vendor_name :
2025-12-16 15:36:11 +01:00
vendors = execute_query_single (
2025-12-08 09:15:52 +01:00
" SELECT id, name, cvr_number FROM vendors WHERE LOWER(name) LIKE LOWER( %s ) LIMIT 5 " ,
( f " % { vendor_name } % " , )
)
if vendors :
return {
" found " : True ,
" matches " : vendors ,
" source " : " name_search " ,
" message " : " Flere mulige matches - vælg en eller opret ny "
}
# Create new vendor if requested
if create_if_missing and vendor_name :
from app . core . config import settings
# Validate not creating vendor with own CVR
if vendor_cvr and settings . OWN_CVR in vendor_cvr :
raise HTTPException (
status_code = 400 ,
detail = f " Kan ikke oprette vendor med eget CVR ( { settings . OWN_CVR } ) "
)
new_vendor_id = execute_insert (
""" INSERT INTO vendors (name, cvr_number, address, created_at)
VALUES ( % s , % s , % s , CURRENT_TIMESTAMP ) """ ,
( vendor_name , vendor_cvr , vendor_address )
)
logger . info ( f " ✅ Created new vendor: { vendor_name } (ID: { new_vendor_id } ) " )
return {
" found " : False ,
" created " : True ,
" vendor_id " : new_vendor_id ,
" vendor_name " : vendor_name ,
" source " : " newly_created "
}
return {
" found " : False ,
" message " : " Ingen vendor fundet - angiv create_if_missing=true for at oprette "
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Vendor search failed: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /supplier-invoices/ai/analyze " )
async def ai_analyze_invoice ( request : Dict ) :
""" Brug AI til at analysere faktura og foreslå template felter """
try :
pdf_text = request . get ( ' pdf_text ' , ' ' )
vendor_id = request . get ( ' vendor_id ' )
if not pdf_text :
raise HTTPException ( status_code = 400 , detail = " Ingen PDF tekst angivet " )
# Build enhanced PDF text with instruction
from app . core . config import settings
enhanced_text = f """ OPGAVE: Analyser denne danske faktura og udtræk information til template-generering.
RETURNER KUN VALID JSON - ingen forklaring , ingen markdown , kun ren JSON !
REQUIRED STRUKTUR ( alle felter skal med ) :
{ {
" invoice_number " : " 5082481 " ,
" invoice_date " : " 24/10-25 " ,
" total_amount " : " 1471.20 " ,
" cvr " : " 29522790 " ,
" detection_patterns " : [ " DCS ApS " , " WWW.DCS.DK " , " Høgemosevænget " ] ,
" lines_start " : " Nr.VarenrTekst " ,
" lines_end " : " Subtotal "
} }
FIND FØLGENDE :
1. invoice_number : Fakturanummer ( efter " Nummer " , " Faktura nr " , " Invoice " )
2. invoice_date : Dato ( format DD / MM - YY eller DD - MM - YYYY )
3. total_amount : Total beløb
- Søg efter " Total " , " I alt " , " Totalbeløb "
- Hvis beløbet er på næste linje , match sidste tal
- Format : [ \d . , ] + ( f . eks . 1.471 , 20 eller 1471.20 )
4. cvr : CVR nummer ( 8 cifre efter " CVR " , " Momsnr " , " DK " )
- IGNORER CVR { settings . OWN_CVR } - dette er KØBERS CVR , ikke leverandør !
- Find LEVERANDØRENS CVR ( normalt i toppen / header )
5. detection_patterns : 3 - 5 UNIKKE tekststrenge der identificerer leverandøren
- Leverandørens navn ( f . eks . " DCS ApS " , " ALSO A/S " )
- Website eller email ( f . eks . " WWW.DCS.DK " )
- Adresse element ( f . eks . " Høgemosevænget " , " Mårkærvej " )
- UNDGÅ generiske ord som " Faktura " , " Danmark " , " Side "
6. lines_start : Tekst LIGE FØR varelinjer ( f . eks . " Nr.VarenrTekst " , " Position Varenr " )
7. lines_end : Tekst EFTER varelinjer ( f . eks . " Subtotal " , " I alt " , " Side 1 af " )
VIGTIGT :
- detection_patterns SKAL være mindst 3 specifikke tekststrenge
- Vælg tekststrenge der er UNIKKE for denne leverandør
- CVR SKAL være leverandørens - IKKE { settings . OWN_CVR } ( det er køber )
- LAD VÆRE med at lave patterns eller line_item regex - kun udtræk rå data
PDF TEKST :
{ pdf_text [ : 2000 ] }
RETURNER KUN JSON - intet andet ! """
# Call Ollama
logger . info ( f " 🤖 Starter AI analyse af { len ( pdf_text ) } tegn PDF tekst " )
result = await ollama_service . extract_from_text ( enhanced_text )
if not result :
raise HTTPException ( status_code = 500 , detail = " AI kunne ikke analysere fakturaen " )
2025-12-07 03:29:54 +01:00
2025-12-08 09:15:52 +01:00
logger . info ( f " ✅ AI analyse gennemført: { result } " )
return result
2025-12-07 03:29:54 +01:00
except Exception as e :
2025-12-08 09:15:52 +01:00
logger . error ( f " ❌ AI analyse fejlede: { e } " )
raise HTTPException ( status_code = 500 , detail = f " AI analyse fejlede: { str ( e ) } " )
2025-12-07 03:29:54 +01:00
2025-12-08 09:15:52 +01:00
@router.post ( " /supplier-invoices/templates " )
async def create_template ( request : Dict ) :
"""
Opret ny template
Request body :
{
" vendor_id " : 1 ,
" template_name " : " Test Template " ,
" detection_patterns " : [ { " type " : " text " , " pattern " : " BMC Denmark " , " weight " : 0.5 } ] ,
" field_mappings " : { " invoice_number " : { " pattern " : r " Nummer \ s*( \ d+) " , " group " : 1 } }
}
"""
2025-12-07 03:29:54 +01:00
try :
2025-12-08 09:15:52 +01:00
import json
from app . core . config import settings
vendor_id = request . get ( ' vendor_id ' )
template_name = request . get ( ' template_name ' )
detection_patterns = request . get ( ' detection_patterns ' , [ ] )
field_mappings = request . get ( ' field_mappings ' , { } )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
default_product_category = request . get ( ' default_product_category ' , ' varesalg ' )
2025-12-08 09:15:52 +01:00
if not vendor_id or not template_name :
raise HTTPException ( status_code = 400 , detail = " vendor_id og template_name er påkrævet " )
# Validate that vendor CVR is not own company
vendor_cvr_mapping = field_mappings . get ( ' vendor_cvr ' , { } )
if vendor_cvr_mapping :
# Extract CVR value from pattern or value field
cvr_value = vendor_cvr_mapping . get ( ' value ' ) or vendor_cvr_mapping . get ( ' pattern ' , ' ' )
if settings . OWN_CVR in str ( cvr_value ) :
raise HTTPException (
status_code = 400 ,
detail = f " CVR { cvr_value } matcher egen virksomhed ( { settings . OWN_CVR } ). Brug leverandørens CVR, ikke købers! "
)
# Insert template and get template_id
2025-12-07 03:29:54 +01:00
query = """
2025-12-08 09:15:52 +01:00
INSERT INTO supplier_invoice_templates
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
( vendor_id , template_name , detection_patterns , field_mappings , default_product_category )
VALUES ( % s , % s , % s , % s , % s )
2025-12-08 09:15:52 +01:00
RETURNING template_id
2025-12-07 03:29:54 +01:00
"""
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
result = execute_query ( query , ( vendor_id , template_name , json . dumps ( detection_patterns ) , json . dumps ( field_mappings ) , default_product_category ) )
2025-12-08 09:15:52 +01:00
template_id = result [ 0 ] [ ' template_id ' ] if result else None
2025-12-07 03:29:54 +01:00
2025-12-08 09:15:52 +01:00
if not template_id :
raise HTTPException ( status_code = 500 , detail = " Kunne ikke oprette template " )
# Reload templates in cache
template_service . reload_templates ( )
logger . info ( f " ✅ Template created: { template_name } (ID: { template_id } ) for vendor { vendor_id } " )
return { " template_id " : template_id , " message " : " Template oprettet " }
except HTTPException :
raise
2025-12-07 03:29:54 +01:00
except Exception as e :
2025-12-08 09:15:52 +01:00
logger . error ( f " ❌ Failed to create template: { e } " , exc_info = True )
2025-12-07 03:29:54 +01:00
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /supplier-invoices/ {invoice_id} " )
async def get_supplier_invoice ( invoice_id : int ) :
""" Get single supplier invoice with lines """
try :
2026-01-07 10:32:41 +01:00
invoice_result = execute_query (
2025-12-07 03:29:54 +01:00
""" SELECT si.*, v.name as vendor_full_name, v.economic_supplier_number as vendor_economic_id
FROM supplier_invoices si
LEFT JOIN vendors v ON si . vendor_id = v . id
WHERE si . id = % s """ ,
2025-12-16 15:36:11 +01:00
( invoice_id , ) )
2025-12-07 03:29:54 +01:00
2026-01-07 10:32:41 +01:00
if not invoice_result :
2025-12-07 03:29:54 +01:00
raise HTTPException ( status_code = 404 , detail = f " Invoice { invoice_id } not found " )
2026-01-07 10:32:41 +01:00
invoice = invoice_result [ 0 ]
2025-12-07 03:29:54 +01:00
# Get lines
2025-12-16 15:36:11 +01:00
lines = execute_query_single (
2025-12-07 03:29:54 +01:00
" SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number " ,
( invoice_id , )
)
invoice [ ' lines ' ] = lines
return invoice
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to get supplier invoice { invoice_id } : { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /supplier-invoices " )
async def create_supplier_invoice ( data : Dict ) :
"""
Create new supplier invoice
Required fields :
- invoice_number : str
- vendor_id : int
- invoice_date : str ( YYYY - MM - DD )
- total_amount : float
Optional fields :
- due_date : str ( YYYY - MM - DD ) - defaults to invoice_date + 30 days
- vat_amount : float
- net_amount : float
- currency : str ( default ' DKK ' )
- description : str
- notes : str
- lines : List [ Dict ] with line items
"""
try :
# Validate required fields
required = [ ' invoice_number ' , ' vendor_id ' , ' invoice_date ' , ' total_amount ' ]
missing = [ f for f in required if f not in data ]
if missing :
raise HTTPException ( status_code = 400 , detail = f " Missing required fields: { ' , ' . join ( missing ) } " )
# Calculate due_date if not provided (30 days default)
invoice_date = datetime . fromisoformat ( data [ ' invoice_date ' ] )
due_date = data . get ( ' due_date ' )
if not due_date :
due_date = ( invoice_date + timedelta ( days = 30 ) ) . strftime ( ' % Y- % m- %d ' )
2025-12-08 09:15:52 +01:00
# Determine invoice type (default to invoice)
invoice_type = data . get ( ' invoice_type ' , ' invoice ' )
if invoice_type not in [ ' invoice ' , ' credit_note ' ] :
invoice_type = ' invoice '
2025-12-07 03:29:54 +01:00
# Insert supplier invoice
invoice_id = execute_insert (
""" INSERT INTO supplier_invoices
( invoice_number , vendor_id , vendor_name , invoice_date , due_date ,
total_amount , vat_amount , net_amount , currency , description , notes ,
2026-04-15 09:34:26 +02:00
status , workflow_status_v2 , created_by , invoice_type )
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
RETURNING id """ ,
2025-12-07 03:29:54 +01:00
(
data [ ' invoice_number ' ] ,
data [ ' vendor_id ' ] ,
data . get ( ' vendor_name ' ) ,
data [ ' invoice_date ' ] ,
due_date ,
data [ ' total_amount ' ] ,
data . get ( ' vat_amount ' , 0 ) ,
data . get ( ' net_amount ' , data [ ' total_amount ' ] ) ,
data . get ( ' currency ' , ' DKK ' ) ,
data . get ( ' description ' ) ,
data . get ( ' notes ' ) ,
2026-04-15 09:34:26 +02:00
' cancelled ' if invoice_type == ' credit_note ' else ' pending ' ,
' afvist ' if invoice_type == ' credit_note ' else ' modtaget ' ,
2025-12-08 09:15:52 +01:00
data . get ( ' created_by ' ) ,
invoice_type
2025-12-07 03:29:54 +01:00
)
)
2026-04-15 09:34:26 +02:00
_record_supplier_invoice_event (
invoice_id = invoice_id ,
event_type = " invoice_created " ,
from_status = None ,
to_status = ' afvist ' if invoice_type == ' credit_note ' else ' modtaget ' ,
payload = { " source " : " manual_create " , " invoice_type " : invoice_type } ,
)
2025-12-07 03:29:54 +01:00
# Insert lines if provided
if data . get ( ' lines ' ) :
for idx , line in enumerate ( data [ ' lines ' ] , start = 1 ) :
2025-12-08 23:46:18 +01:00
# Map vat_code: I52 for reverse charge, I25 for standard
vat_code = line . get ( ' vat_code ' , ' I25 ' )
2026-04-15 09:34:26 +02:00
quantity = _to_decimal ( line . get ( ' quantity ' ) , Decimal ( ' 1 ' ) )
unit_price = _to_decimal ( line . get ( ' unit_price ' ) )
line_total = _to_decimal ( line . get ( ' line_total ' ) )
if line_total < = 0 :
line_total = quantity * unit_price
vat_rate = _to_decimal ( line . get ( ' vat_rate ' ) , Decimal ( ' 25.00 ' ) )
vat_amount = _to_decimal ( line . get ( ' vat_amount ' ) )
sku = line . get ( ' sku ' )
product_id = line . get ( ' product_id ' )
if not product_id :
product_id = _ensure_product_for_supplier_line (
vendor_id = data . get ( ' vendor_id ' ) ,
vendor_name = data . get ( ' vendor_name ' ) ,
description = line . get ( ' description ' ) ,
sku = sku ,
unit_price = unit_price ,
currency = data . get ( ' currency ' , ' DKK ' ) ,
vat_rate = vat_rate ,
)
2025-12-08 23:46:18 +01:00
2026-04-15 09:34:26 +02:00
execute_update (
2025-12-07 03:29:54 +01:00
""" INSERT INTO supplier_invoice_lines
( supplier_invoice_id , line_number , description , quantity , unit_price ,
2026-04-15 09:34:26 +02:00
line_total , vat_code , vat_rate , vat_amount , contra_account , product_id , sku )
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s ) """ ,
2025-12-07 03:29:54 +01:00
(
invoice_id ,
line . get ( ' line_number ' , idx ) ,
line . get ( ' description ' ) ,
2026-04-15 09:34:26 +02:00
quantity ,
unit_price ,
line_total ,
2025-12-08 23:46:18 +01:00
vat_code ,
2026-04-15 09:34:26 +02:00
vat_rate ,
vat_amount ,
2025-12-07 03:29:54 +01:00
line . get ( ' contra_account ' , ' 5810 ' ) ,
2026-04-15 09:34:26 +02:00
product_id ,
sku ,
2025-12-07 03:29:54 +01:00
)
)
logger . info ( f " ✅ Created supplier invoice: { data [ ' invoice_number ' ] } (ID: { invoice_id } ) " )
return {
" success " : True ,
" invoice_id " : invoice_id ,
" invoice_number " : data [ ' invoice_number ' ] ,
" due_date " : due_date
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to create supplier invoice: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.put ( " /supplier-invoices/ {invoice_id} " )
async def update_supplier_invoice ( invoice_id : int , data : Dict ) :
""" Update supplier invoice details """
try :
# Check if invoice exists
existing = execute_query (
" SELECT id, status FROM supplier_invoices WHERE id = %s " ,
2025-12-16 15:36:11 +01:00
( invoice_id , ) )
2025-12-07 03:29:54 +01:00
if not existing :
raise HTTPException ( status_code = 404 , detail = f " Invoice { invoice_id } not found " )
2026-01-25 03:29:28 +01:00
existing_invoice = existing [ 0 ]
2025-12-07 03:29:54 +01:00
# Don't allow editing if already sent to e-conomic
2026-01-25 03:29:28 +01:00
if existing_invoice [ ' status ' ] == ' sent_to_economic ' :
2025-12-07 03:29:54 +01:00
raise HTTPException (
status_code = 400 ,
detail = " Cannot edit invoice that has been sent to e-conomic "
)
# Build update query dynamically based on provided fields
update_fields = [ ]
params = [ ]
allowed_fields = [ ' invoice_number ' , ' vendor_id ' , ' vendor_name ' , ' invoice_date ' ,
' due_date ' , ' total_amount ' , ' vat_amount ' , ' net_amount ' ,
' currency ' , ' description ' , ' notes ' , ' status ' ]
for field in allowed_fields :
if field in data :
update_fields . append ( f " { field } = %s " )
params . append ( data [ field ] )
if not update_fields :
raise HTTPException ( status_code = 400 , detail = " No fields to update " )
params . append ( invoice_id )
query = f """
UPDATE supplier_invoices
SET { ' , ' . join ( update_fields ) } , updated_at = CURRENT_TIMESTAMP
WHERE id = % s
"""
execute_update ( query , tuple ( params ) )
logger . info ( f " ✅ Updated supplier invoice { invoice_id } " )
return { " success " : True , " invoice_id " : invoice_id }
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to update supplier invoice { invoice_id } : { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-01-07 10:32:41 +01:00
@router.patch ( " /supplier-invoices/ {invoice_id} /lines/ {line_id} " )
async def update_invoice_line ( invoice_id : int , line_id : int , data : Dict ) :
"""
Update supplier invoice line item
Supports updating : contra_account , line_purpose , resale_customer_id , resale_order_number
"""
try :
# Check if invoice exists and is not sent to e-conomic
invoice = execute_query (
" SELECT id, status FROM supplier_invoices WHERE id = %s " ,
( invoice_id , ) )
if not invoice :
raise HTTPException ( status_code = 404 , detail = f " Invoice { invoice_id } not found " )
invoice_data = invoice [ 0 ]
# Don't allow editing if already sent to e-conomic
if invoice_data [ ' status ' ] == ' sent_to_economic ' :
raise HTTPException (
status_code = 400 ,
detail = " Cannot edit invoice line that has been sent to e-conomic "
)
# Check if line exists
line = execute_query (
" SELECT id FROM supplier_invoice_lines WHERE id = %s AND supplier_invoice_id = %s " ,
( line_id , invoice_id ) )
2026-01-25 03:29:28 +01:00
if not line or len ( line ) == 0 :
2026-01-07 10:32:41 +01:00
raise HTTPException ( status_code = 404 , detail = f " Line { line_id } not found in invoice { invoice_id } " )
# Build update query
update_fields = [ ]
params = [ ]
allowed_fields = [ ' contra_account ' , ' line_purpose ' , ' resale_customer_id ' ,
' resale_order_number ' , ' description ' , ' quantity ' ,
' unit_price ' , ' vat_rate ' , ' total_amount ' ]
for field in allowed_fields :
if field in data :
update_fields . append ( f " { field } = %s " )
params . append ( data [ field ] )
if not update_fields :
raise HTTPException ( status_code = 400 , detail = " No fields to update " )
params . append ( line_id )
query = f """
UPDATE supplier_invoice_lines
SET { ' , ' . join ( update_fields ) }
WHERE id = % s
"""
execute_update ( query , tuple ( params ) )
logger . info ( f " ✅ Updated invoice line { line_id } (Invoice { invoice_id } ) " )
return { " success " : True , " line_id " : line_id }
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to update invoice line { line_id } : { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-07 03:29:54 +01:00
@router.delete ( " /supplier-invoices/ {invoice_id} " )
async def delete_supplier_invoice ( invoice_id : int ) :
""" Delete supplier invoice (soft delete if integrated with e-conomic) """
try :
2025-12-16 15:36:11 +01:00
invoice = execute_query_single (
2025-12-07 03:29:54 +01:00
" SELECT id, invoice_number, economic_voucher_number FROM supplier_invoices WHERE id = %s " ,
2025-12-16 15:36:11 +01:00
( invoice_id , ) )
2025-12-07 03:29:54 +01:00
if not invoice :
raise HTTPException ( status_code = 404 , detail = f " Invoice { invoice_id } not found " )
# If sent to e-conomic, only mark as cancelled (don't delete)
if invoice . get ( ' economic_voucher_number ' ) :
execute_update (
" UPDATE supplier_invoices SET status = ' cancelled ' , updated_at = CURRENT_TIMESTAMP WHERE id = %s " ,
( invoice_id , )
)
logger . info ( f " ⚠️ Marked supplier invoice { invoice [ ' invoice_number ' ] } as cancelled (sent to e-conomic) " )
return { " success " : True , " message " : " Invoice marked as cancelled " , " invoice_id " : invoice_id }
# Otherwise, delete invoice and lines
execute_update ( " DELETE FROM supplier_invoice_lines WHERE supplier_invoice_id = %s " , ( invoice_id , ) )
execute_update ( " DELETE FROM supplier_invoices WHERE id = %s " , ( invoice_id , ) )
logger . info ( f " 🗑️ Deleted supplier invoice { invoice [ ' invoice_number ' ] } (ID: { invoice_id } ) " )
return { " success " : True , " message " : " Invoice deleted " , " invoice_id " : invoice_id }
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to delete supplier invoice { invoice_id } : { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
# ========== E-CONOMIC INTEGRATION ==========
2025-12-15 12:28:12 +01:00
class ApproveRequest ( BaseModel ) :
approved_by : str
2026-03-18 07:14:28 +01:00
2026-04-15 09:34:26 +02:00
class RejectRequest ( BaseModel ) :
rejected_by : str
reason : Optional [ str ] = None
2026-03-18 07:14:28 +01:00
class MarkPaidRequest ( BaseModel ) :
paid_date : Optional [ date ] = None
2026-04-15 09:34:26 +02:00
amount : Optional [ Decimal ] = None
payment_method : Optional [ str ] = None
payment_reference : Optional [ str ] = None
notes : Optional [ str ] = None
paid_by : Optional [ str ] = None
2026-03-18 07:14:28 +01:00
2025-12-07 03:29:54 +01:00
@router.post ( " /supplier-invoices/ {invoice_id} /approve " )
2025-12-15 12:28:12 +01:00
async def approve_supplier_invoice ( invoice_id : int , request : ApproveRequest ) :
2026-04-15 09:34:26 +02:00
""" Approve supplier invoice for payment (v2 status flow). """
2025-12-07 03:29:54 +01:00
try :
2026-04-15 09:34:26 +02:00
transition = _transition_invoice_status_v2 (
invoice_id = invoice_id ,
new_status_v2 = " godkendt " ,
actor = request . approved_by ,
2025-12-07 03:29:54 +01:00
)
2026-04-15 09:34:26 +02:00
logger . info (
" ✅ Approved supplier invoice %s by %s ( %s -> %s ) " ,
transition . get ( " invoice_number " ) ,
request . approved_by ,
transition . get ( " from_status " ) ,
transition . get ( " to_status " ) ,
)
return {
" success " : True ,
" invoice_id " : invoice_id ,
" approved_by " : request . approved_by ,
" status_v2 " : transition . get ( " to_status " ) ,
" changed " : transition . get ( " changed " , False ) ,
}
2025-12-07 03:29:54 +01:00
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to approve invoice { invoice_id } : { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-04-15 09:34:26 +02:00
@router.post ( " /supplier-invoices/ {invoice_id} /reject " )
async def reject_supplier_invoice ( invoice_id : int , request : RejectRequest ) :
""" Reject supplier invoice in v2 workflow. """
try :
transition = _transition_invoice_status_v2 (
invoice_id = invoice_id ,
new_status_v2 = " afvist " ,
actor = request . rejected_by ,
reason = request . reason ,
)
logger . info (
" ❌ Rejected supplier invoice %s by %s ( %s -> %s ) " ,
transition . get ( " invoice_number " ) ,
request . rejected_by ,
transition . get ( " from_status " ) ,
transition . get ( " to_status " ) ,
)
return {
" success " : True ,
" invoice_id " : invoice_id ,
" rejected_by " : request . rejected_by ,
" status_v2 " : transition . get ( " to_status " ) ,
" changed " : transition . get ( " changed " , False ) ,
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to reject invoice { invoice_id } : { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /supplier-invoices/ {invoice_id} /payments " )
async def get_supplier_invoice_payments ( invoice_id : int ) :
""" Return all registered payments and remaining balance for a supplier invoice. """
try :
invoice = execute_query_single (
" SELECT id, total_amount, currency FROM supplier_invoices WHERE id = %s " ,
( invoice_id , ) ,
)
if not invoice :
raise HTTPException ( status_code = 404 , detail = f " Faktura { invoice_id } ikke fundet " )
payments = execute_query (
"""
SELECT id , payment_date , amount , currency , payment_method , payment_reference , notes , paid_by , created_at
FROM supplier_invoice_payments
WHERE supplier_invoice_id = % s
ORDER BY payment_date ASC , id ASC
""" ,
( invoice_id , ) ,
) or [ ]
paid_total = sum ( Decimal ( str ( p . get ( " amount " ) or 0 ) ) for p in payments )
total_amount = Decimal ( str ( invoice . get ( " total_amount " ) or 0 ) )
remaining = total_amount - paid_total
return {
" invoice_id " : invoice_id ,
" currency " : invoice . get ( " currency " ) or " DKK " ,
" total_amount " : float ( total_amount ) ,
" paid_total " : float ( paid_total ) ,
" remaining " : float ( max ( remaining , Decimal ( " 0 " ) ) ) ,
" payments " : payments ,
}
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Failed to fetch supplier payments for invoice %s : %s " , invoice_id , e )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /supplier-invoices/ {invoice_id} /events " )
async def get_supplier_invoice_events ( invoice_id : int , limit : int = 100 ) :
""" Return lifecycle events for supplier invoice (outbox/event-log view). """
try :
exists = execute_query_single ( " SELECT id FROM supplier_invoices WHERE id = %s " , ( invoice_id , ) )
if not exists :
raise HTTPException ( status_code = 404 , detail = f " Faktura { invoice_id } ikke fundet " )
rows = execute_query (
"""
SELECT id , event_type , from_status , to_status , payload_json , webhook_status , created_at , processed_at
FROM supplier_invoice_events
WHERE supplier_invoice_id = % s
ORDER BY created_at DESC , id DESC
LIMIT % s
""" ,
( invoice_id , limit ) ,
) or [ ]
return rows
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Failed to fetch supplier events for invoice %s : %s " , invoice_id , e )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-03-18 07:14:28 +01:00
@router.post ( " /supplier-invoices/ {invoice_id} /mark-paid " )
async def mark_supplier_invoice_paid ( invoice_id : int , request : MarkPaidRequest ) :
2026-04-15 09:34:26 +02:00
""" Register payment (supports split payments) and mark as paid when fully covered. """
2026-03-18 07:14:28 +01:00
try :
invoice = execute_query_single (
2026-04-15 09:34:26 +02:00
"""
SELECT id , invoice_number , status , workflow_status_v2 , total_amount , currency
FROM supplier_invoices
WHERE id = % s
""" ,
2026-03-18 07:14:28 +01:00
( invoice_id , )
)
if not invoice :
raise HTTPException ( status_code = 404 , detail = f " Faktura { invoice_id } ikke fundet " )
2026-04-15 09:34:26 +02:00
status_v2 = _get_invoice_status_v2 ( invoice )
if status_v2 != ' godkendt ' :
2026-03-18 07:14:28 +01:00
raise HTTPException (
status_code = 400 ,
detail = (
2026-04-15 09:34:26 +02:00
f " Faktura har status ' { status_v2 } ' - "
" kun godkendte fakturaer kan registrere betaling "
2026-03-18 07:14:28 +01:00
)
)
2026-04-15 09:34:26 +02:00
total_amount = Decimal ( str ( invoice . get ( " total_amount " ) or 0 ) )
payment_date = request . paid_date or date . today ( )
paid_sum_row = execute_query_single (
" SELECT COALESCE(SUM(amount), 0) AS paid_sum FROM supplier_invoice_payments WHERE supplier_invoice_id = %s " ,
( invoice_id , ) ,
)
already_paid = Decimal ( str ( ( paid_sum_row or { } ) . get ( " paid_sum " ) or 0 ) )
remaining = total_amount - already_paid
if remaining < = 0 :
transition = _transition_invoice_status_v2 (
invoice_id = invoice_id ,
new_status_v2 = " betalt " ,
actor = request . paid_by ,
)
return {
" success " : True ,
" invoice_id " : invoice_id ,
" status_v2 " : transition . get ( " to_status " ) ,
" paid_total " : float ( already_paid ) ,
" remaining " : 0.0 ,
" message " : " Faktura er allerede fuldt betalt " ,
}
amount = request . amount if request . amount is not None else remaining
amount = Decimal ( str ( amount ) )
if amount < = 0 :
raise HTTPException ( status_code = 400 , detail = " amount skal være større end 0 " )
if amount > remaining :
raise HTTPException (
status_code = 400 ,
detail = f " amount ( { amount } ) overstiger restbeløb ( { remaining } ) " ,
)
2026-03-18 07:14:28 +01:00
execute_update (
2026-04-15 09:34:26 +02:00
"""
INSERT INTO supplier_invoice_payments (
supplier_invoice_id ,
payment_date ,
amount ,
currency ,
payment_method ,
payment_reference ,
notes ,
paid_by
) VALUES ( % s , % s , % s , % s , % s , % s , % s , % s )
""" ,
(
invoice_id ,
payment_date ,
amount ,
invoice . get ( " currency " ) or " DKK " ,
request . payment_method ,
request . payment_reference ,
request . notes ,
request . paid_by ,
) ,
)
_record_supplier_invoice_event (
invoice_id = invoice_id ,
event_type = " payment_registered " ,
from_status = status_v2 ,
to_status = status_v2 ,
payload = {
" amount " : str ( amount ) ,
" payment_date " : str ( payment_date ) ,
" payment_method " : request . payment_method ,
" payment_reference " : request . payment_reference ,
" paid_by " : request . paid_by ,
} ,
2026-03-18 07:14:28 +01:00
)
2026-04-15 09:34:26 +02:00
new_paid_sum = already_paid + amount
new_remaining = total_amount - new_paid_sum
if new_remaining < = 0 :
transition = _transition_invoice_status_v2 (
invoice_id = invoice_id ,
new_status_v2 = " betalt " ,
actor = request . paid_by ,
)
execute_update (
"""
UPDATE supplier_invoices
SET paid_date = % s ,
payment_reference = COALESCE ( % s , payment_reference ) ,
payment_method = COALESCE ( % s , payment_method ) ,
updated_at = CURRENT_TIMESTAMP
WHERE id = % s
""" ,
( payment_date , request . payment_reference , request . payment_method , invoice_id ) ,
)
status_v2 = transition . get ( " to_status " ) or " betalt "
2026-03-18 07:14:28 +01:00
logger . info (
2026-04-15 09:34:26 +02:00
" ✅ Registered supplier payment for invoice %s (ID: %s ): amount= %s , remaining= %s " ,
2026-03-18 07:14:28 +01:00
invoice [ ' invoice_number ' ] ,
invoice_id ,
2026-04-15 09:34:26 +02:00
amount ,
max ( new_remaining , Decimal ( ' 0 ' ) ) ,
2026-03-18 07:14:28 +01:00
)
return {
" success " : True ,
" invoice_id " : invoice_id ,
2026-04-15 09:34:26 +02:00
" status_v2 " : status_v2 ,
" payment_date " : payment_date ,
" payment_amount " : float ( amount ) ,
" paid_total " : float ( new_paid_sum ) ,
" remaining " : float ( max ( new_remaining , Decimal ( ' 0 ' ) ) ) ,
2026-03-18 07:14:28 +01:00
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to mark invoice { invoice_id } as paid: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-07 03:29:54 +01:00
@router.post ( " /supplier-invoices/ {invoice_id} /send-to-economic " )
async def send_to_economic ( invoice_id : int ) :
"""
Send approved supplier invoice to e - conomic kassekladde
Creates voucher entry in e - conomic journals
"""
try :
# Get invoice with lines
2025-12-16 15:36:11 +01:00
invoice = execute_query_single (
2025-12-07 03:29:54 +01:00
""" SELECT si.*, v.economic_supplier_number as vendor_economic_id, v.name as vendor_full_name
FROM supplier_invoices si
LEFT JOIN vendors v ON si . vendor_id = v . id
WHERE si . id = % s """ ,
2025-12-16 15:36:11 +01:00
( invoice_id , ) )
2025-12-07 03:29:54 +01:00
if not invoice :
raise HTTPException ( status_code = 404 , detail = f " Invoice { invoice_id } not found " )
if invoice [ ' status ' ] != ' approved ' :
raise HTTPException ( status_code = 400 , detail = " Invoice must be approved before sending to e-conomic " )
if invoice . get ( ' economic_voucher_number ' ) :
raise HTTPException ( status_code = 400 , detail = " Invoice already sent to e-conomic " )
# Get lines
2025-12-16 15:36:11 +01:00
lines = execute_query_single (
2025-12-07 03:29:54 +01:00
" SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number " ,
( invoice_id , )
)
if not lines :
raise HTTPException ( status_code = 400 , detail = " Invoice must have at least one line item " )
# Check if vendor exists in e-conomic
economic = get_economic_service ( )
vendor_economic_id = invoice . get ( ' vendor_economic_id ' )
# If vendor not in e-conomic, create it
if not vendor_economic_id :
vendor_result = await economic . search_supplier_by_name ( invoice . get ( ' vendor_full_name ' ) or invoice . get ( ' vendor_name ' ) )
if vendor_result :
vendor_economic_id = vendor_result [ ' supplierNumber ' ]
# Update local vendor record
execute_update (
" UPDATE vendors SET economic_supplier_number = %s WHERE id = %s " ,
( vendor_economic_id , invoice [ ' vendor_id ' ] )
)
else :
# Create new supplier in e-conomic
new_supplier = await economic . create_supplier ( {
' name ' : invoice . get ( ' vendor_full_name ' ) or invoice . get ( ' vendor_name ' ) ,
' currency ' : invoice . get ( ' currency ' , ' DKK ' )
} )
if new_supplier and new_supplier . get ( ' supplierNumber ' ) :
vendor_economic_id = new_supplier [ ' supplierNumber ' ]
else :
raise HTTPException ( status_code = 500 , detail = " Failed to create supplier in e-conomic " )
# Get default journal number from settings
journal_setting = execute_query (
2025-12-16 15:36:11 +01:00
" SELECT setting_value FROM supplier_invoice_settings WHERE setting_key = ' economic_default_journal ' " )
2026-01-25 03:29:28 +01:00
journal_number = int ( journal_setting [ 0 ] [ ' setting_value ' ] ) if journal_setting and len ( journal_setting ) > 0 else 1
2025-12-07 03:29:54 +01:00
# Build VAT breakdown from lines
vat_breakdown = { }
line_items = [ ]
for line in lines :
vat_code = line . get ( ' vat_code ' , ' I25 ' )
if vat_code not in vat_breakdown :
vat_breakdown [ vat_code ] = {
' net ' : 0 ,
' vat ' : 0 ,
' gross ' : 0 ,
' rate ' : line . get ( ' vat_rate ' , 25.00 )
}
line_total = float ( line . get ( ' line_total ' , 0 ) )
vat_amount = float ( line . get ( ' vat_amount ' , 0 ) )
net_amount = line_total - vat_amount
vat_breakdown [ vat_code ] [ ' net ' ] + = net_amount
vat_breakdown [ vat_code ] [ ' vat ' ] + = vat_amount
vat_breakdown [ vat_code ] [ ' gross ' ] + = line_total
line_items . append ( {
' description ' : line . get ( ' description ' ) ,
' quantity ' : float ( line . get ( ' quantity ' , 1 ) ) ,
' unit_price ' : float ( line . get ( ' unit_price ' , 0 ) ) ,
' line_total ' : line_total ,
' vat_code ' : vat_code ,
' vat_amount ' : vat_amount ,
' contra_account ' : line . get ( ' contra_account ' , ' 5810 ' ) ,
' sku ' : line . get ( ' sku ' )
} )
# Send to e-conomic
result = await economic . create_journal_supplier_invoice (
journal_number = journal_number ,
supplier_number = vendor_economic_id ,
invoice_number = invoice [ ' invoice_number ' ] ,
invoice_date = invoice [ ' invoice_date ' ] . isoformat ( ) if isinstance ( invoice [ ' invoice_date ' ] , date ) else invoice [ ' invoice_date ' ] ,
total_amount = float ( invoice [ ' total_amount ' ] ) ,
vat_breakdown = vat_breakdown ,
line_items = line_items ,
due_date = invoice [ ' due_date ' ] . isoformat ( ) if invoice . get ( ' due_date ' ) and isinstance ( invoice [ ' due_date ' ] , date ) else invoice . get ( ' due_date ' ) ,
text = invoice . get ( ' description ' ) or f " Supplier invoice { invoice [ ' invoice_number ' ] } "
)
if result . get ( ' error ' ) :
raise HTTPException ( status_code = 500 , detail = result . get ( ' message ' , ' Failed to create voucher in e-conomic ' ) )
# Update invoice with e-conomic details
execute_update (
""" UPDATE supplier_invoices
SET status = ' sent_to_economic ' ,
economic_supplier_number = % s ,
economic_journal_number = % s ,
economic_voucher_number = % s ,
economic_accounting_year = % s ,
sent_to_economic_at = CURRENT_TIMESTAMP
WHERE id = % s """ ,
(
vendor_economic_id ,
result [ ' journal_number ' ] ,
result [ ' voucher_number ' ] ,
result [ ' accounting_year ' ] ,
invoice_id
)
)
# Upload attachment if file_path exists
if invoice . get ( ' file_path ' ) and os . path . exists ( invoice [ ' file_path ' ] ) :
attachment_result = await economic . upload_voucher_attachment (
journal_number = result [ ' journal_number ' ] ,
accounting_year = result [ ' accounting_year ' ] ,
voucher_number = result [ ' voucher_number ' ] ,
pdf_path = invoice [ ' file_path ' ] ,
filename = f " { invoice [ ' invoice_number ' ] } .pdf "
)
if attachment_result . get ( ' success ' ) :
logger . info ( f " 📎 Uploaded attachment for voucher { result [ ' voucher_number ' ] } " )
logger . info ( f " ✅ Sent supplier invoice { invoice [ ' invoice_number ' ] } to e-conomic (voucher # { result [ ' voucher_number ' ] } ) " )
return {
" success " : True ,
" invoice_id " : invoice_id ,
" voucher_number " : result [ ' voucher_number ' ] ,
" journal_number " : result [ ' journal_number ' ] ,
" accounting_year " : result [ ' accounting_year ' ]
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to send invoice { invoice_id } to e-conomic: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /supplier-invoices/economic/journals " )
async def get_economic_journals ( ) :
""" Get available e-conomic journals (kassekladder) """
try :
economic = get_economic_service ( )
journals = await economic . get_supplier_invoice_journals ( )
return { " journals " : journals }
except Exception as e :
logger . error ( f " ❌ Failed to get e-conomic journals: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-01-07 10:32:41 +01:00
@router.get ( " /supplier-invoices/economic/accounts " )
async def get_economic_accounts ( refresh : bool = False ) :
"""
Get e - conomic chart of accounts ( kontoplan ) from cache
Args :
refresh : If True , fetch fresh data from e - conomic API
Returns :
List of accounts with accountNumber , name , accountType , vatCode
"""
try :
# If refresh requested, sync from e-conomic first
if refresh :
economic = get_economic_service ( )
count = await economic . sync_accounts_to_database ( )
logger . info ( f " ✅ Refreshed { count } accounts from e-conomic " )
# Fetch from database cache
accounts = execute_query ( """
SELECT
account_number as " accountNumber " ,
name ,
account_type as " accountType " ,
vat_code as " vatCode " ,
balance ,
last_synced as " lastSynced "
FROM economic_accounts
WHERE is_active = TRUE
ORDER BY account_number
""" )
# If no accounts in cache and not already refreshed, try syncing
if not accounts and not refresh :
economic = get_economic_service ( )
count = await economic . sync_accounts_to_database ( )
logger . info ( f " ✅ Initial sync: { count } accounts from e-conomic " )
# Retry fetch
accounts = execute_query ( """
SELECT
account_number as " accountNumber " ,
name ,
account_type as " accountType " ,
vat_code as " vatCode " ,
balance ,
last_synced as " lastSynced "
FROM economic_accounts
WHERE is_active = TRUE
ORDER BY account_number
""" )
return { " accounts " : accounts }
except Exception as e :
logger . error ( f " ❌ Failed to get e-conomic accounts: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-07 03:29:54 +01:00
# ========== STATISTICS & REPORTS ==========
@router.get ( " /supplier-invoices/stats/overview " )
async def get_payment_overview ( ) :
"""
Get overview of supplier invoices payment status
Returns stats for total , paid , overdue , due soon , and pending invoices
"""
try :
today = date . today ( ) . isoformat ( )
2025-12-16 15:36:11 +01:00
stats = execute_query_single ( """
2025-12-07 03:29:54 +01:00
SELECT
COUNT ( * ) as total_count ,
SUM ( CASE WHEN paid_date IS NOT NULL THEN 1 ELSE 0 END ) as paid_count ,
SUM ( CASE WHEN paid_date IS NULL AND due_date < % s THEN 1 ELSE 0 END ) as overdue_count ,
SUM ( CASE WHEN paid_date IS NULL AND due_date > = % s AND due_date < = ( % s : : date + INTERVAL ' 7 days ' ) THEN 1 ELSE 0 END ) as due_soon_count ,
SUM ( CASE WHEN paid_date IS NULL AND ( due_date IS NULL OR due_date > ( % s : : date + INTERVAL ' 7 days ' ) ) THEN 1 ELSE 0 END ) as pending_count ,
SUM ( total_amount ) as total_amount ,
SUM ( CASE WHEN paid_date IS NOT NULL THEN total_amount ELSE 0 END ) as paid_amount ,
SUM ( CASE WHEN paid_date IS NULL THEN total_amount ELSE 0 END ) as unpaid_amount ,
SUM ( CASE WHEN paid_date IS NULL AND due_date < % s THEN total_amount ELSE 0 END ) as overdue_amount
FROM supplier_invoices
WHERE status != ' cancelled '
2025-12-16 15:36:11 +01:00
""" , (today, today, today, today, today))
2025-12-07 03:29:54 +01:00
return {
" total_invoices " : stats . get ( ' total_count ' , 0 ) if stats else 0 ,
" paid_count " : stats . get ( ' paid_count ' , 0 ) if stats else 0 ,
" overdue_count " : stats . get ( ' overdue_count ' , 0 ) if stats else 0 ,
" due_soon_count " : stats . get ( ' due_soon_count ' , 0 ) if stats else 0 ,
" pending_count " : stats . get ( ' pending_count ' , 0 ) if stats else 0 ,
" total_amount " : float ( stats . get ( ' total_amount ' , 0 ) or 0 ) if stats else 0 ,
" paid_amount " : float ( stats . get ( ' paid_amount ' , 0 ) or 0 ) if stats else 0 ,
" unpaid_amount " : float ( stats . get ( ' unpaid_amount ' , 0 ) or 0 ) if stats else 0 ,
" overdue_amount " : float ( stats . get ( ' overdue_amount ' , 0 ) or 0 ) if stats else 0
}
except Exception as e :
logger . error ( f " ❌ Failed to get payment overview: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /supplier-invoices/stats/by-vendor " )
async def get_stats_by_vendor ( ) :
""" Get supplier invoice statistics grouped by vendor """
try :
2025-12-16 15:36:11 +01:00
stats = execute_query_single ( """
2025-12-07 03:29:54 +01:00
SELECT
v . id as vendor_id ,
v . name as vendor_name ,
COUNT ( si . id ) as invoice_count ,
SUM ( si . total_amount ) as total_amount ,
SUM ( CASE WHEN si . paid_date IS NULL THEN si . total_amount ELSE 0 END ) as unpaid_amount ,
MAX ( si . due_date ) as latest_due_date
FROM vendors v
LEFT JOIN supplier_invoices si ON v . id = si . vendor_id
WHERE si . status != ' cancelled ' OR si . status IS NULL
GROUP BY v . id , v . name
HAVING COUNT ( si . id ) > 0
ORDER BY unpaid_amount DESC
""" )
return { " vendor_stats " : stats }
except Exception as e :
logger . error ( f " ❌ Failed to get vendor stats: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
# ========== UPLOAD & AI EXTRACTION ==========
@router.post ( " /supplier-invoices/upload " )
async def upload_supplier_invoice ( file : UploadFile = File ( . . . ) ) :
"""
Upload supplier invoice ( PDF / image ) and extract data using templates
Process :
1. Validate file type and size
2. Calculate SHA256 checksum for duplicate detection
3. Save file to uploads directory
4. Extract text ( PDF / OCR )
5. Match template based on PDF content
6. Extract fields using template regex patterns
7. Show form with pre - filled data for user review
Returns :
{
" status " : " success|duplicate|needs_review " ,
" file_id " : int ,
" template_matched " : bool ,
" template_id " : int ,
" extracted_fields " : dict ,
" confidence " : float ,
" pdf_text " : str # For manual review
}
"""
2025-12-08 09:15:52 +01:00
from app . core . config import settings
2025-12-07 03:29:54 +01:00
try :
# Validate file extension
suffix = Path ( file . filename ) . suffix . lower ( )
2026-03-01 20:01:11 +01:00
suffix_clean = suffix . lstrip ( ' . ' )
# Build allowed set — guard against pydantic parsing CSV as a single element
raw = settings . ALLOWED_EXTENSIONS
if len ( raw ) == 1 and ' , ' in raw [ 0 ] :
raw = [ e . strip ( ) for e in raw [ 0 ] . split ( ' , ' ) ]
allowed_clean = { ext . lower ( ) . lstrip ( ' . ' ) for ext in raw }
if suffix_clean not in allowed_clean :
2025-12-07 03:29:54 +01:00
raise HTTPException (
status_code = 400 ,
2026-03-01 20:01:11 +01:00
detail = f " Filtype { suffix } ikke tilladt. Tilladte: { ' , ' . join ( sorted ( allowed_clean ) ) } "
2025-12-07 03:29:54 +01:00
)
# Create upload directory
upload_dir = Path ( settings . UPLOAD_DIR )
upload_dir . mkdir ( parents = True , exist_ok = True )
# Save file temporarily to calculate checksum
temp_path = upload_dir / f " temp_ { datetime . now ( ) . timestamp ( ) } _ { file . filename } "
try :
# Validate file size while saving
2026-03-01 20:15:40 +01:00
max_size = settings . EMAIL_MAX_UPLOAD_SIZE_MB * 1024 * 1024
2025-12-07 03:29:54 +01:00
total_size = 0
with open ( temp_path , " wb " ) as buffer :
while chunk := await file . read ( 8192 ) :
total_size + = len ( chunk )
if total_size > max_size :
temp_path . unlink ( missing_ok = True )
raise HTTPException (
status_code = 413 ,
2026-03-01 20:15:40 +01:00
detail = f " Fil for stor (max { settings . EMAIL_MAX_UPLOAD_SIZE_MB } MB) "
2025-12-07 03:29:54 +01:00
)
buffer . write ( chunk )
logger . info ( f " 📥 Uploaded file: { file . filename } ( { total_size } bytes) " )
# Calculate SHA256 checksum
checksum = ollama_service . calculate_file_checksum ( temp_path )
# Check for duplicate file
2026-03-02 00:05:24 +01:00
existing_file = execute_query_single (
2025-12-07 03:29:54 +01:00
" SELECT file_id, status FROM incoming_files WHERE checksum = %s " ,
2025-12-16 15:36:11 +01:00
( checksum , ) )
2025-12-07 03:29:54 +01:00
if existing_file :
temp_path . unlink ( missing_ok = True )
logger . warning ( f " ⚠️ Duplicate file detected: { checksum [ : 16 ] } ... " )
# Get existing invoice if linked
2025-12-16 15:36:11 +01:00
existing_invoice = execute_query_single (
2025-12-07 03:29:54 +01:00
""" SELECT si.* FROM supplier_invoices si
JOIN extractions e ON si . extraction_id = e . extraction_id
WHERE e . file_id = % s """ ,
2025-12-16 15:36:11 +01:00
( existing_file [ ' file_id ' ] , ) )
2025-12-07 03:29:54 +01:00
return {
" status " : " duplicate " ,
" message " : " Denne fil er allerede uploadet " ,
" file_id " : existing_file [ ' file_id ' ] ,
" invoice_id " : existing_invoice [ ' id ' ] if existing_invoice else None
}
# Rename to permanent name
final_path = upload_dir / file . filename
counter = 1
while final_path . exists ( ) :
final_path = upload_dir / f " { final_path . stem } _ { counter } { final_path . suffix } "
counter + = 1
temp_path . rename ( final_path )
logger . info ( f " 💾 Saved file as: { final_path . name } " )
# Insert file record
2025-12-16 15:36:11 +01:00
file_record = execute_query_single (
2025-12-07 03:29:54 +01:00
""" INSERT INTO incoming_files
( filename , original_filename , file_path , file_size , mime_type , checksum , status )
2026-01-25 03:29:28 +01:00
VALUES ( % s , % s , % s , % s , % s , % s , ' pending ' ) RETURNING file_id """ ,
2025-12-07 03:29:54 +01:00
( final_path . name , file . filename , str ( final_path ) , total_size ,
2025-12-16 15:36:11 +01:00
ollama_service . _get_mime_type ( final_path ) , checksum ) )
2025-12-07 03:29:54 +01:00
file_id = file_record [ ' file_id ' ]
2026-01-25 03:29:28 +01:00
logger . info ( f " ✅ File uploaded successfully - ready for batch analysis " )
2025-12-07 03:29:54 +01:00
2026-01-25 03:29:28 +01:00
# Return simple response - all extraction happens in batch analyze
2025-12-07 03:29:54 +01:00
return {
2026-01-25 03:29:28 +01:00
" status " : " uploaded " ,
2025-12-07 03:29:54 +01:00
" file_id " : file_id ,
2026-01-25 03:29:28 +01:00
" filename " : file . filename ,
" message " : " Fil uploadet - klik ' Analyser alle ' for at behandle "
2025-12-07 03:29:54 +01:00
}
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
except HTTPException as he :
# Mark file as failed if we have file_id
if ' file_id ' in locals ( ) :
execute_update (
""" UPDATE incoming_files
SET status = ' failed ' ,
error_message = % s ,
processed_at = CURRENT_TIMESTAMP
WHERE file_id = % s """ ,
( str ( he . detail ) , file_id )
)
2025-12-07 03:29:54 +01:00
raise
except Exception as e :
logger . error ( f " ❌ Upload failed (inner): { e } " , exc_info = True )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
# Mark file as failed if we have file_id
if ' file_id ' in locals ( ) :
execute_update (
""" UPDATE incoming_files
SET status = ' failed ' ,
error_message = % s ,
processed_at = CURRENT_TIMESTAMP
WHERE file_id = % s """ ,
( str ( e ) , file_id )
)
2025-12-07 03:29:54 +01:00
raise HTTPException ( status_code = 500 , detail = f " Upload fejlede: { str ( e ) } " )
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Upload failed (outer): { e } " , exc_info = True )
raise HTTPException ( status_code = 500 , detail = f " Upload fejlede: { str ( e ) } " )
# ========== ECONOMIC SYNC ==========
2026-03-18 07:14:28 +01:00
@router.post ( " /supplier-invoices/ {invoice_id} /send-to-economic-legacy-unimplemented " )
2025-12-07 03:29:54 +01:00
async def send_invoice_to_economic ( invoice_id : int ) :
""" Send supplier invoice to e-conomic - requires separate implementation """
raise HTTPException ( status_code = 501 , detail = " e-conomic integration kommer senere " )
@router.post ( " /supplier-invoices/reprocess/ {file_id} " )
async def reprocess_uploaded_file ( file_id : int ) :
"""
Genbehandl en uploadet fil med template matching
Bruges til at behandle filer der fejlede eller ikke blev færdigbehandlet
"""
2025-12-08 09:15:52 +01:00
import json
from datetime import datetime , timedelta
2025-12-07 03:29:54 +01:00
try :
# Get file record
2025-12-16 15:36:11 +01:00
file_record = execute_query_single (
2025-12-07 03:29:54 +01:00
" SELECT * FROM incoming_files WHERE file_id = %s " ,
2025-12-16 15:36:11 +01:00
( file_id , ) )
2025-12-07 03:29:54 +01:00
if not file_record :
raise HTTPException ( status_code = 404 , detail = f " Fil { file_id } ikke fundet " )
file_path = Path ( file_record [ ' file_path ' ] )
if not file_path . exists ( ) :
raise HTTPException ( status_code = 404 , detail = f " Fil ikke fundet på disk: { file_path } " )
logger . info ( f " <EFBFBD> <EFBFBD> Genbehandler fil { file_id } : { file_record [ ' filename ' ] } " )
# Extract text from file
text = await ollama_service . _extract_text_from_file ( file_path )
# Try template matching
template_id , confidence = template_service . match_template ( text )
extracted_fields = { }
vendor_id = None
if template_id and confidence > = 0.5 :
logger . info ( f " ✅ Matched template { template_id } ( { confidence : .0% } ) " )
extracted_fields = template_service . extract_fields ( text , template_id )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
# Check if this is an invoice2data template (ID -1)
is_invoice2data = ( template_id == - 1 )
2025-12-07 03:29:54 +01:00
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
if is_invoice2data :
2025-12-15 12:28:12 +01:00
def _to_numeric ( value ) :
if value is None :
return None
if isinstance ( value , ( int , float , Decimal ) ) :
return float ( value )
if not isinstance ( value , str ) :
return None
cleaned = value . strip ( ) . replace ( ' ' , ' ' )
if not cleaned :
return None
# Common Danish formatting: 25.000,00 or 1.530,00
if ' , ' in cleaned :
cleaned = cleaned . replace ( ' . ' , ' ' ) . replace ( ' , ' , ' . ' )
try :
return float ( cleaned )
except ValueError :
return None
def _clean_document_id ( value ) :
if value is None :
return None
if isinstance ( value , str ) :
cleaned = value . strip ( )
return cleaned if cleaned and cleaned . lower ( ) != ' none ' else None
return str ( value )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
# Invoice2data doesn't have vendor in cache
logger . info ( f " 📋 Using invoice2data template " )
# Try to find vendor from extracted CVR
if extracted_fields . get ( ' vendor_vat ' ) :
2025-12-16 15:36:11 +01:00
vendor = execute_query_single (
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
" SELECT id FROM vendors WHERE cvr_number = %s " ,
2025-12-16 15:36:11 +01:00
( extracted_fields [ ' vendor_vat ' ] , ) )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
if vendor :
vendor_id = vendor [ ' id ' ]
2025-12-15 12:28:12 +01:00
# Fallback: use vendor detected during quick analysis (incoming_files.detected_vendor_id)
if vendor_id is None :
vendor_id = file_record . get ( ' detected_vendor_id ' )
# Fallback: match by issuer name
if vendor_id is None and extracted_fields . get ( ' issuer ' ) :
2025-12-16 15:36:11 +01:00
vendor = execute_query_single (
2025-12-15 12:28:12 +01:00
" SELECT id FROM vendors WHERE name ILIKE %s ORDER BY id LIMIT 1 " ,
2025-12-16 15:36:11 +01:00
( extracted_fields [ ' issuer ' ] , ) )
2025-12-15 12:28:12 +01:00
if vendor :
vendor_id = vendor [ ' id ' ]
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
# Store invoice2data extraction in database
extraction_id = execute_insert (
""" INSERT INTO extractions
( file_id , vendor_matched_id , vendor_name , vendor_cvr ,
document_id , document_date , due_date , document_type , document_type_detected ,
total_amount , currency , confidence , llm_response_json , status )
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
RETURNING extraction_id """ ,
( file_id , vendor_id ,
extracted_fields . get ( ' issuer ' ) , # vendor_name
extracted_fields . get ( ' vendor_vat ' ) , # vendor_cvr
2025-12-15 12:28:12 +01:00
_clean_document_id ( extracted_fields . get ( ' invoice_number ' ) ) , # document_id
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
extracted_fields . get ( ' invoice_date ' ) , # document_date
extracted_fields . get ( ' due_date ' ) ,
' invoice ' , # document_type
' invoice ' , # document_type_detected
2025-12-15 12:28:12 +01:00
_to_numeric ( extracted_fields . get ( ' amount_total ' ) if extracted_fields . get ( ' amount_total ' ) is not None else extracted_fields . get ( ' total_amount ' ) ) ,
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
extracted_fields . get ( ' currency ' , ' DKK ' ) ,
1.0 , # invoice2data always 100% confidence
json . dumps ( extracted_fields ) , # llm_response_json
' extracted ' ) # status
)
# Insert line items if extracted
if extracted_fields . get ( ' lines ' ) :
for idx , line in enumerate ( extracted_fields [ ' lines ' ] , start = 1 ) :
2025-12-15 12:28:12 +01:00
line_total = _to_numeric ( line . get ( ' line_total ' ) )
unit_price = _to_numeric ( line . get ( ' unit_price ' ) )
quantity = _to_numeric ( line . get ( ' quantity ' ) )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
execute_insert (
""" INSERT INTO extraction_lines
( extraction_id , line_number , description , quantity , unit_price ,
line_total , vat_rate , vat_note , confidence ,
2026-07-09 23:44:30 +02:00
ip_address , contract_number , provider_reference , customer_reference , circuit_id ,
end_customer_name , period_start , period_end , service_address ,
location_street , location_zip , location_city )
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
RETURNING line_id """ ,
( extraction_id , idx , line . get ( ' description ' ) ,
2025-12-15 12:28:12 +01:00
quantity , unit_price ,
line_total , None , None , 1.0 ,
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
line . get ( ' ip_address ' ) , line . get ( ' contract_number ' ) ,
2026-07-09 23:44:30 +02:00
line . get ( ' provider_reference ' ) , line . get ( ' customer_reference ' ) , line . get ( ' circuit_id ' ) ,
line . get ( ' end_customer_name ' ) , line . get ( ' period_start ' ) , line . get ( ' period_end ' ) , line . get ( ' service_address ' ) ,
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
line . get ( ' location_street ' ) , line . get ( ' location_zip ' ) , line . get ( ' location_city ' ) )
)
logger . info ( f " ✅ Saved { len ( extracted_fields [ ' lines ' ] ) } line items " )
else :
# Custom template from database
template = template_service . templates_cache . get ( template_id )
if template :
vendor_id = template . get ( ' vendor_id ' )
template_service . log_usage ( template_id , file_id , True , confidence , extracted_fields )
2025-12-07 03:29:54 +01:00
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
# Update file - use NULL for invoice2data templates to avoid FK constraint
db_template_id = None if is_invoice2data else template_id
2025-12-07 03:29:54 +01:00
execute_update (
""" UPDATE incoming_files
SET status = ' processed ' , template_id = % s , processed_at = CURRENT_TIMESTAMP
WHERE file_id = % s """ ,
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
( db_template_id , file_id )
2025-12-07 03:29:54 +01:00
)
else :
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
# FALLBACK TO AI EXTRACTION
logger . info ( f " ⚠️ Ingen template match (confidence: { confidence : .0% } ) - bruger AI extraction " )
# Use detected vendor from quick analysis if available
vendor_id = file_record . get ( ' detected_vendor_id ' )
2025-12-08 09:15:52 +01:00
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
# Call Ollama for full extraction
logger . info ( f " 🤖 Calling Ollama for AI extraction... " )
llm_result = await ollama_service . extract_from_text ( text )
2025-12-15 12:28:12 +01:00
# Handle both dict and string error responses
if not llm_result or isinstance ( llm_result , str ) or ( isinstance ( llm_result , dict ) and ' error ' in llm_result ) :
if isinstance ( llm_result , dict ) :
error_msg = llm_result . get ( ' error ' , ' AI extraction fejlede ' )
elif isinstance ( llm_result , str ) :
error_msg = llm_result # Error message returned as string
else :
error_msg = ' AI extraction fejlede '
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
logger . error ( f " ❌ AI extraction failed: { error_msg } " )
execute_update (
""" UPDATE incoming_files
SET status = ' failed ' ,
error_message = % s ,
processed_at = CURRENT_TIMESTAMP
WHERE file_id = % s """ ,
( f " AI extraction fejlede: { error_msg } " , file_id )
)
raise HTTPException ( status_code = 500 , detail = f " AI extraction fejlede: { error_msg } " )
extracted_fields = llm_result
confidence = llm_result . get ( ' confidence ' , 0.75 )
2026-03-02 09:01:43 +01:00
2026-03-02 12:59:17 +01:00
# Post-process: clear own CVR(s) if AI mistakenly returned them
2026-03-02 09:01:43 +01:00
extracted_cvr = llm_result . get ( ' vendor_cvr ' )
2026-03-02 12:59:17 +01:00
own_cvr = getattr ( settings , ' OWN_CVR ' , ' 29522790 ' )
OWN_CVRS = { str ( own_cvr ) . strip ( ) , ' 29522790 ' , ' 14416285 ' } # alle BMC CVR numre
extracted_cvr_clean = str ( extracted_cvr ) . replace ( ' DK ' , ' ' ) . strip ( ) if extracted_cvr else ' '
if extracted_cvr_clean and extracted_cvr_clean in OWN_CVRS :
logger . warning ( f " ⚠️ AI returned own CVR ( { extracted_cvr_clean } ) as vendor_cvr - clearing it " )
2026-03-02 09:01:43 +01:00
llm_result [ ' vendor_cvr ' ] = None
extracted_cvr = None
2026-03-02 12:59:17 +01:00
# Also clear vendor_name if it looks like BMC
vendor_name = llm_result . get ( ' vendor_name ' , ' ' ) or ' '
if ' BMC ' in vendor_name . upper ( ) and ' DENMARK ' in vendor_name . upper ( ) :
logger . warning ( f " ⚠️ AI returned own company name ' { vendor_name } ' as vendor_name - clearing it " )
llm_result [ ' vendor_name ' ] = None
2026-03-02 09:01:43 +01:00
# Try to find vendor in DB by extracted CVR or name (overrides detected_vendor_id)
if extracted_cvr :
cvr_clean = str ( extracted_cvr ) . replace ( ' DK ' , ' ' ) . strip ( )
vendor_row = execute_query_single (
" SELECT id FROM vendors WHERE cvr_number = %s AND is_active = true " ,
( cvr_clean , ) )
if vendor_row :
vendor_id = vendor_row [ ' id ' ]
logger . info ( f " ✅ Matched vendor by CVR { cvr_clean } : vendor_id= { vendor_id } " )
execute_update (
" UPDATE incoming_files SET detected_vendor_id = %s WHERE file_id = %s " ,
( vendor_id , file_id ) )
if not vendor_id and llm_result . get ( ' vendor_name ' ) :
vendor_row = execute_query_single (
" SELECT id FROM vendors WHERE name ILIKE %s AND is_active = true ORDER BY id LIMIT 1 " ,
( f " % { llm_result [ ' vendor_name ' ] } % " , ) )
if vendor_row :
vendor_id = vendor_row [ ' id ' ]
logger . info ( f " ✅ Matched vendor by name ' { llm_result [ ' vendor_name ' ] } ' : vendor_id= { vendor_id } " )
execute_update (
" UPDATE incoming_files SET detected_vendor_id = %s WHERE file_id = %s " ,
( vendor_id , file_id ) )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
# Store AI extracted data in extractions table
extraction_id = execute_insert (
2026-03-02 08:48:03 +01:00
""" INSERT INTO extractions
( file_id , vendor_matched_id , vendor_name , vendor_cvr ,
document_id , document_date , due_date ,
total_amount , currency , document_type , document_type_detected ,
confidence , llm_response_json , status )
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s ) RETURNING extraction_id """ ,
( file_id , vendor_id ,
llm_result . get ( ' vendor_name ' ) ,
llm_result . get ( ' vendor_cvr ' ) ,
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
llm_result . get ( ' invoice_number ' ) ,
llm_result . get ( ' invoice_date ' ) ,
llm_result . get ( ' due_date ' ) ,
llm_result . get ( ' total_amount ' ) ,
llm_result . get ( ' currency ' , ' DKK ' ) ,
2026-03-02 08:48:03 +01:00
llm_result . get ( ' document_type ' , ' invoice ' ) ,
llm_result . get ( ' document_type ' , ' invoice ' ) ,
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
confidence ,
2026-03-02 08:48:03 +01:00
json . dumps ( llm_result ) ,
' extracted ' )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
)
# Insert line items if extracted
if llm_result . get ( ' lines ' ) :
for idx , line in enumerate ( llm_result [ ' lines ' ] , start = 1 ) :
execute_insert (
""" INSERT INTO extraction_lines
( extraction_id , line_number , description , quantity , unit_price ,
2026-07-09 23:44:30 +02:00
line_total , vat_rate , confidence ,
ip_address , contract_number , provider_reference , customer_reference , circuit_id ,
end_customer_name , period_start , period_end , service_address ,
location_street , location_zip , location_city )
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
RETURNING line_id """ ,
( extraction_id , idx , line . get ( ' description ' ) ,
line . get ( ' quantity ' ) , line . get ( ' unit_price ' ) ,
2026-03-02 08:54:14 +01:00
line . get ( ' line_total ' ) , line . get ( ' vat_rate ' ) ,
2026-07-09 23:44:30 +02:00
confidence ,
line . get ( ' ip_address ' ) , line . get ( ' contract_number ' ) ,
line . get ( ' provider_reference ' ) , line . get ( ' customer_reference ' ) , line . get ( ' circuit_id ' ) ,
line . get ( ' end_customer_name ' ) , line . get ( ' period_start ' ) , line . get ( ' period_end ' ) , line . get ( ' service_address ' ) ,
line . get ( ' location_street ' ) , line . get ( ' location_zip ' ) , line . get ( ' location_city ' ) )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
)
# Update file status to ai_extracted
2025-12-07 03:29:54 +01:00
execute_update (
""" UPDATE incoming_files
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
SET status = ' ai_extracted ' , processed_at = CURRENT_TIMESTAMP
2025-12-07 03:29:54 +01:00
WHERE file_id = % s """ ,
( file_id , )
)
2025-12-08 09:15:52 +01:00
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
logger . info ( f " ✅ AI extraction completed for file { file_id } " )
2025-12-07 03:29:54 +01:00
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
# Return success with template data or AI extraction result
2025-12-15 12:28:12 +01:00
# Determine confidence value safely
if template_id :
final_confidence = confidence
elif ' llm_result ' in locals ( ) and isinstance ( llm_result , dict ) :
final_confidence = llm_result . get ( ' confidence ' , 0.75 )
else :
final_confidence = 0.0
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
result = {
2025-12-07 03:29:54 +01:00
" status " : " success " ,
" file_id " : file_id ,
" filename " : file_record [ ' filename ' ] ,
" template_matched " : template_id is not None ,
" template_id " : template_id ,
" vendor_id " : vendor_id ,
2025-12-15 12:28:12 +01:00
" confidence " : final_confidence ,
2025-12-07 03:29:54 +01:00
" extracted_fields " : extracted_fields ,
2025-12-08 09:15:52 +01:00
" pdf_text " : text [ : 1000 ] if not template_id else text
2025-12-07 03:29:54 +01:00
}
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
# Add warning if no template exists
if not template_id and vendor_id :
2025-12-16 15:36:11 +01:00
vendor = execute_query_single (
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
" SELECT name FROM vendors WHERE id = %s " ,
2025-12-16 15:36:11 +01:00
( vendor_id , ) )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
if vendor :
result [ " warning " ] = f " ⚠️ Ingen template fundet for { vendor [ ' name ' ] } - brugte AI extraction (langsommere) "
return result
2025-12-07 03:29:54 +01:00
except HTTPException :
raise
except Exception as e :
raise HTTPException ( status_code = 500 , detail = f " Genbehandling fejlede: { str ( e ) } " )
2026-03-02 13:48:14 +01:00
@router.post ( " /supplier-invoices/files/batch-analyze " )
async def batch_analyze_files ( background_tasks : BackgroundTasks ) :
"""
Kør AI - analyse på alle ubehandlede filer i baggrunden .
Returnerer øjeblikkeligt – filer behandles async .
"""
pending = execute_query (
""" SELECT file_id, filename FROM incoming_files
WHERE status IN ( ' pending ' , ' requires_vendor_selection ' , ' uploaded ' , ' failed ' )
ORDER BY uploaded_at DESC
LIMIT 100 """ ,
( )
)
if not pending :
return { " started " : 0 , " message " : " Ingen filer at behandle " }
file_ids = [ r [ ' file_id ' ] for r in pending ]
logger . info ( f " 🚀 Batch-analyse startet for { len ( file_ids ) } filer " )
async def _run_batch ( ids ) :
ok = err = 0
for fid in ids :
try :
await reprocess_uploaded_file ( fid )
ok + = 1
except Exception as ex :
logger . error ( f " ❌ Batch fejl file { fid } : { ex } " )
err + = 1
logger . info ( f " ✅ Batch færdig: { ok } ok, { err } fejlet " )
background_tasks . add_task ( _run_batch , file_ids )
return {
" started " : len ( file_ids ) ,
" message " : f " { len ( file_ids ) } filer sendt til analyse i baggrunden. Opdater siden om lidt. " ,
" analyzed " : 0 ,
" requires_vendor_selection " : 0 ,
" failed " : 0
}
2025-12-07 03:29:54 +01:00
@router.put ( " /supplier-invoices/templates/ {template_id} " )
async def update_template (
template_id : int ,
template_name : Optional [ str ] = None ,
detection_patterns : Optional [ List [ Dict ] ] = None ,
field_mappings : Optional [ Dict ] = None ,
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
default_product_category : Optional [ str ] = None ,
2025-12-07 03:29:54 +01:00
is_active : Optional [ bool ] = None
) :
""" Opdater eksisterende template """
try :
import json
updates = [ ]
params = [ ]
if template_name :
updates . append ( " template_name = %s " )
params . append ( template_name )
if detection_patterns is not None :
updates . append ( " detection_patterns = %s " )
params . append ( json . dumps ( detection_patterns ) )
if field_mappings is not None :
updates . append ( " field_mappings = %s " )
params . append ( json . dumps ( field_mappings ) )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
if default_product_category is not None :
updates . append ( " default_product_category = %s " )
params . append ( default_product_category )
2025-12-07 03:29:54 +01:00
if is_active is not None :
updates . append ( " is_active = %s " )
params . append ( is_active )
if not updates :
raise HTTPException ( status_code = 400 , detail = " Ingen opdateringer angivet " )
updates . append ( " updated_at = CURRENT_TIMESTAMP " )
params . append ( template_id )
execute_update (
f " UPDATE supplier_invoice_templates SET { ' , ' . join ( updates ) } WHERE template_id = %s " ,
tuple ( params )
)
# Reload templates
template_service . reload_templates ( )
logger . info ( f " ✅ Template { template_id } opdateret " )
return { " message " : " Template opdateret " }
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to update template: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
@router.post ( " /supplier-invoices/templates/invoice2data/ {template_name} /test " )
async def test_invoice2data_template ( template_name : str , request : Dict ) :
"""
Test invoice2data YAML template mod PDF tekst
Request body :
{
" pdf_text " : " Full PDF text content... "
}
Returns samme format som test_template endpoint
"""
try :
pdf_text = request . get ( ' pdf_text ' , ' ' )
if not pdf_text :
raise HTTPException ( status_code = 400 , detail = " pdf_text er påkrævet " )
# Get invoice2data service
invoice2data_service = get_invoice2data_service ( )
# Check if template exists
if template_name not in invoice2data_service . templates :
raise HTTPException ( status_code = 404 , detail = f " Template ' { template_name } ' ikke fundet " )
template_data = invoice2data_service . templates [ template_name ]
# Test extraction
result = invoice2data_service . extract_with_template ( pdf_text , template_name )
if not result :
# Template didn't match
keywords = template_data . get ( ' keywords ' , [ ] )
detection_results = [ ]
for keyword in keywords :
found = str ( keyword ) . lower ( ) in pdf_text . lower ( )
detection_results . append ( {
" pattern " : str ( keyword ) ,
" type " : " keyword " ,
" found " : found ,
" weight " : 0.5
} )
return {
" matched " : False ,
" confidence " : 0.0 ,
" extracted_fields " : { } ,
" line_items " : [ ] ,
" detection_results " : detection_results ,
" template_name " : template_name ,
" error " : " Template matchede ikke PDF ' en "
}
# Extract line items
line_items = [ ]
if ' lines ' in result :
for line in result [ ' lines ' ] :
line_items . append ( {
" line_number " : line . get ( ' line_number ' , ' ' ) ,
" item_number " : line . get ( ' item_number ' , ' ' ) ,
" description " : line . get ( ' description_raw ' , ' ' ) or line . get ( ' description ' , ' ' ) ,
" quantity " : line . get ( ' quantity ' , ' ' ) ,
" unit_price " : line . get ( ' unit_price ' , ' ' ) ,
" line_total " : line . get ( ' line_total ' , ' ' ) ,
# Context fields (circuit/location info)
" circuit_id " : line . get ( ' circuit_id ' , ' ' ) ,
" ip_address " : line . get ( ' ip_address ' , ' ' ) ,
" contract_number " : line . get ( ' contract_number ' , ' ' ) ,
" location_street " : line . get ( ' location_street ' , ' ' ) ,
" location_zip " : line . get ( ' location_zip ' , ' ' ) ,
" location_city " : line . get ( ' location_city ' , ' ' ) ,
} )
# Build detection results
keywords = template_data . get ( ' keywords ' , [ ] )
detection_results = [ ]
matched_count = 0
for keyword in keywords :
found = str ( keyword ) . lower ( ) in pdf_text . lower ( )
if found :
matched_count + = 1
detection_results . append ( {
" pattern " : str ( keyword ) ,
" type " : " keyword " ,
" found " : found ,
" weight " : 0.5
} )
confidence = matched_count / len ( keywords ) if keywords else 1.0
# Remove 'lines' from extracted_fields to avoid duplication
extracted_fields = { k : v for k , v in result . items ( ) if k != ' lines ' }
return {
" matched " : True ,
" confidence " : confidence ,
" extracted_fields " : extracted_fields ,
" line_items " : line_items ,
" detection_results " : detection_results ,
" template_name " : template_name
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Invoice2data template test failed: { e } " , exc_info = True )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-07 03:29:54 +01:00
@router.post ( " /supplier-invoices/templates/ {template_id} /test " )
async def test_template ( template_id : int , request : Dict ) :
"""
Test template mod PDF tekst
Request body :
{
" pdf_text " : " Full PDF text content... "
}
Returns :
{
" matched " : true / false ,
" confidence " : 0.85 ,
" extracted_fields " : {
" invoice_number " : " 12345 " ,
" invoice_date " : " 01/12-25 " ,
" total_amount " : " 1234.56 " ,
" vendor_cvr " : " 12345678 "
} ,
" detection_results " : [
{ " pattern " : " BMC Denmark ApS " , " found " : true , " weight " : 0.5 }
]
}
"""
try :
import re
import json
pdf_text = request . get ( ' pdf_text ' , ' ' )
if not pdf_text :
raise HTTPException ( status_code = 400 , detail = " pdf_text er påkrævet " )
# Fetch template
query = " SELECT * FROM supplier_invoice_templates WHERE template_id = %s "
template = execute_query ( query , ( template_id , ) )
if not template :
raise HTTPException ( status_code = 404 , detail = " Template ikke fundet " )
template = template [ 0 ]
detection_patterns = template . get ( ' detection_patterns ' , [ ] )
field_mappings = template . get ( ' field_mappings ' , { } )
# Test detection patterns
2025-12-08 09:15:52 +01:00
total_score = 0.0
2025-12-07 03:29:54 +01:00
max_score = 0.0
detection_results = [ ]
for pattern in detection_patterns :
pattern_type = pattern . get ( ' type ' , ' text ' )
pattern_value = pattern . get ( ' pattern ' , ' ' )
weight = float ( pattern . get ( ' weight ' , 0.5 ) )
max_score + = weight
found = False
if pattern_type == ' text ' and pattern_value in pdf_text :
found = True
total_score + = weight
detection_results . append ( {
" pattern " : pattern_value ,
" type " : pattern_type ,
" found " : found ,
" weight " : weight
} )
confidence = ( total_score / max_score ) if max_score > 0 else 0.0
matched = confidence > = 0.7 # Match threshold
# Extract fields if matched
extracted_fields = { }
if matched :
for field_name , field_config in field_mappings . items ( ) :
pattern = field_config . get ( ' pattern ' , ' ' )
group = field_config . get ( ' group ' , 1 )
# Skip non-field patterns (lines_start, lines_end, line_item)
if field_name in [ ' lines_start ' , ' lines_end ' , ' line_item ' ] :
continue
try :
match = re . search ( pattern , pdf_text , re . IGNORECASE | re . MULTILINE )
if match and len ( match . groups ( ) ) > = group :
extracted_fields [ field_name ] = match . group ( group ) . strip ( )
except Exception as e :
logger . warning ( f " Pattern match failed for { field_name } : { e } " )
# Extract line items if matched
line_items = [ ]
if matched :
# Extract line items using smart extraction
lines_start = field_mappings . get ( ' lines_start ' , { } ) . get ( ' pattern ' )
lines_end = field_mappings . get ( ' lines_end ' , { } ) . get ( ' pattern ' )
line_pattern = field_mappings . get ( ' line_item ' , { } ) . get ( ' pattern ' )
line_fields = field_mappings . get ( ' line_item ' , { } ) . get ( ' fields ' , [ ] )
if line_pattern or lines_start :
# Extract section between start and end markers
text_section = pdf_text
if lines_start :
try :
start_match = re . search ( lines_start , pdf_text , re . IGNORECASE )
if start_match :
text_section = pdf_text [ start_match . end ( ) : ]
logger . debug ( f " Found lines_start at position { start_match . end ( ) } " )
except Exception as e :
logger . warning ( f " Failed to find lines_start: { e } " )
if lines_end :
try :
end_match = re . search ( lines_end , text_section , re . IGNORECASE )
if end_match :
text_section = text_section [ : end_match . start ( ) ]
logger . debug ( f " Found lines_end at position { end_match . start ( ) } " )
except Exception as e :
logger . warning ( f " Failed to find lines_end: { e } " )
# Try pattern first, then smart extraction
if line_pattern :
try :
for match in re . finditer ( line_pattern , text_section , re . MULTILINE ) :
line_data = {
' line_number ' : len ( line_items ) + 1 ,
' raw_text ' : match . group ( 0 )
}
for idx , field_name in enumerate ( line_fields , start = 1 ) :
if idx < = len ( match . groups ( ) ) :
line_data [ field_name ] = match . group ( idx ) . strip ( )
line_items . append ( line_data )
except Exception as e :
logger . error ( f " ❌ Pattern extraction failed: { e } " )
# Fallback to smart extraction if no lines found
if not line_items :
logger . info ( " 🧠 Trying smart extraction... " )
logger . debug ( f " Text section length: { len ( text_section ) } , first 500 chars: { text_section [ : 500 ] } " )
line_items = _smart_extract_lines ( text_section )
logger . info ( f " 🧠 Smart extraction returned { len ( line_items ) } items " )
if line_items :
logger . info ( f " 📦 Extracted { len ( line_items ) } line items from test " )
else :
logger . warning ( f " ⚠️ No line items matched. Section length: { len ( text_section ) } chars " )
logger . debug ( f " Section preview: { text_section [ : 300 ] } " )
logger . info ( f " 🧪 Template { template_id } test: matched= { matched } , confidence= { confidence : .2f } , lines= { len ( line_items ) } " )
return {
" matched " : matched ,
" confidence " : round ( confidence , 2 ) ,
" extracted_fields " : extracted_fields ,
" line_items " : line_items ,
" detection_results " : detection_results ,
" template_name " : template . get ( ' template_name ' , ' ' )
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Template test failed: { e } " , exc_info = True )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
feat: Implement quick analysis on PDF upload for CVR, document type, and number extraction
- Added `check_invoice_number_exists` method in `EconomicService` to verify invoice numbers in e-conomic journals.
- Introduced `quick_analysis_on_upload` method in `OllamaService` for extracting critical fields from uploaded PDFs, including CVR, document type, and document number.
- Created migration script to add new fields for storing detected CVR, vendor ID, document type, and document number in the `incoming_files` table.
- Developed comprehensive tests for the quick analysis functionality, validating CVR detection, document type identification, and invoice number extraction.
2025-12-09 14:54:33 +01:00
@router.put ( " /supplier-invoices/templates/invoice2data/ {template_name} /category " )
async def update_yaml_category ( template_name : str , request : Dict ) :
"""
Opdater default_product_category i YAML template fil
Request body :
{
" category " : " drift " / / varesalg , drift , anlæg , abonnement , lager , udlejning
}
"""
try :
import yaml
from pathlib import Path
new_category = request . get ( ' category ' )
if not new_category :
raise HTTPException ( status_code = 400 , detail = " category er påkrævet " )
# Validate category
valid_categories = [ ' varesalg ' , ' drift ' , ' anlæg ' , ' abonnement ' , ' lager ' , ' udlejning ' ]
if new_category not in valid_categories :
raise HTTPException ( status_code = 400 , detail = f " Ugyldig kategori. Skal være en af: { ' , ' . join ( valid_categories ) } " )
# Find YAML file
templates_dir = Path ( __file__ ) . parent . parent . parent . parent / ' data ' / ' invoice_templates '
yaml_file = templates_dir / f " { template_name } .yml "
if not yaml_file . exists ( ) :
raise HTTPException ( status_code = 404 , detail = f " YAML fil ikke fundet: { template_name } .yml " )
# Load YAML
with open ( yaml_file , ' r ' , encoding = ' utf-8 ' ) as f :
template_data = yaml . safe_load ( f )
# Update category
template_data [ ' default_product_category ' ] = new_category
# Save YAML with preserved formatting
with open ( yaml_file , ' w ' , encoding = ' utf-8 ' ) as f :
yaml . dump ( template_data , f , default_flow_style = False , allow_unicode = True , sort_keys = False )
# Reload invoice2data service to pick up changes
invoice2data_service = get_invoice2data_service ( )
invoice2data_service . __init__ ( ) # Reinitialize to reload templates
logger . info ( f " ✅ Updated category for { template_name } .yml to { new_category } " )
return {
" message " : " Kategori opdateret " ,
" template_name " : template_name ,
" new_category " : new_category
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to update YAML category: { e } " , exc_info = True )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /supplier-invoices/templates/invoice2data/ {template_name} /content " )
async def get_yaml_content ( template_name : str ) :
"""
Hent råt YAML indhold fra template fil
Returns :
{
" content " : " issuer: DCS ApS \n keywords: ... "
}
"""
try :
from pathlib import Path
# Find template file
template_dir = Path ( " data/invoice_templates " )
template_file = template_dir / f " { template_name } .yml "
if not template_file . exists ( ) :
raise HTTPException ( status_code = 404 , detail = f " Template fil ikke fundet: { template_name } .yml " )
# Read file content
content = template_file . read_text ( encoding = ' utf-8 ' )
return {
" template_name " : template_name ,
" filename " : f " { template_name } .yml " ,
" content " : content
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to read YAML content: { e } " , exc_info = True )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-07 03:29:54 +01:00
@router.delete ( " /supplier-invoices/templates/ {template_id} " )
async def delete_template ( template_id : int ) :
""" Slet template (soft delete - sæt is_active=false) """
try :
execute_update (
" UPDATE supplier_invoice_templates SET is_active = false WHERE template_id = %s " ,
( template_id , )
)
template_service . reload_templates ( )
logger . info ( f " ✅ Template { template_id } deaktiveret " )
return { " message " : " Template slettet " }
except Exception as e :
logger . error ( f " ❌ Failed to delete template: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-01-25 03:29:28 +01:00
# Helper function for creating invoice from file
async def create_invoice_from_file ( file_id : int , vendor_id : int ) - > int :
""" Create a minimal supplier invoice from file without full extraction """
try :
file_info = execute_query_single (
" SELECT filename, file_path FROM incoming_files WHERE file_id = %s " ,
( file_id , )
)
if not file_info :
raise ValueError ( f " File { file_id } not found " )
# Create minimal invoice record
invoice_id = execute_insert (
""" INSERT INTO supplier_invoices (
vendor_id , invoice_number , invoice_date , due_date ,
2026-04-15 09:34:26 +02:00
total_amount , currency , status , workflow_status_v2 , notes
) VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s )
2026-01-25 03:29:28 +01:00
RETURNING id """ ,
(
vendor_id ,
f " PENDING- { file_id } " , # Temporary invoice number
datetime . now ( ) . date ( ) , # Use today as placeholder
( datetime . now ( ) + timedelta ( days = 30 ) ) . date ( ) , # Due in 30 days
0.00 , # Amount to be filled manually
' DKK ' ,
2026-04-15 09:34:26 +02:00
' pending ' ,
' modtaget ' ,
2026-01-25 03:29:28 +01:00
f " Oprettet fra fil: { file_info [ ' filename ' ] } (file_id: { file_id } ) "
)
)
2026-04-12 09:26:35 +02:00
2026-04-15 09:34:26 +02:00
_record_supplier_invoice_event (
invoice_id = invoice_id ,
event_type = " invoice_created " ,
from_status = None ,
to_status = " modtaget " ,
payload = { " source " : " from_file " , " file_id " : file_id } ,
)
2026-04-12 09:26:35 +02:00
_ensure_case_for_supplier_invoice (
invoice_id = invoice_id ,
invoice_number = f " PENDING- { file_id } " ,
vendor_name = None ,
total_amount = 0 ,
currency = " DKK " ,
file_id = file_id ,
)
2026-01-25 03:29:28 +01:00
logger . info ( f " ✅ Created minimal invoice { invoice_id } for file { file_id } , vendor { vendor_id } " )
return invoice_id
except Exception as e :
logger . error ( f " ❌ Failed to create invoice from file: { e } " )
raise
@router.post ( " /supplier-invoices/files/ {file_id} /match-vendor " )
async def match_vendor_for_file ( file_id : int ) :
"""
Match vendor for uploaded file with confidence scoring
Returns list of vendors with confidence scores :
- 100 % = Exact CVR match
- 90 % = Email domain match
- 70 % = Fuzzy name match
"""
try :
# Get file info with detected CVR and vendor
file_info = execute_query (
""" SELECT file_id, detected_cvr, detected_vendor_id, filename
FROM incoming_files WHERE file_id = % s """ ,
( file_id , )
)
if not file_info :
raise HTTPException ( status_code = 404 , detail = f " File { file_id } not found " )
file_data = file_info [ 0 ]
detected_cvr = file_data . get ( ' detected_cvr ' )
detected_vendor_id = file_data . get ( ' detected_vendor_id ' )
vendor_matches = [ ]
# Get all active vendors
vendors = execute_query ( " SELECT id, name, cvr_number, email, domain FROM vendors WHERE is_active = true ORDER BY name " )
if not vendors :
vendors = [ ]
# If file already has detected_vendor_id, use it as 100% match
if detected_vendor_id :
matched_vendor = next ( ( v for v in vendors if v [ ' id ' ] == detected_vendor_id ) , None )
if matched_vendor :
vendor_matches . append ( {
" vendor_id " : matched_vendor [ ' id ' ] ,
" vendor_name " : matched_vendor [ ' name ' ] ,
" cvr_number " : matched_vendor . get ( ' cvr_number ' ) ,
" confidence " : 100 ,
" match_reason " : " Automatically detected from email " ,
" is_exact_match " : True
} )
# Auto-select this vendor and create invoice
logger . info ( f " ✅ Auto-selected vendor { matched_vendor [ ' name ' ] } (ID: { detected_vendor_id } ) from detected_vendor_id " )
# Create supplier invoice directly
invoice_id = await create_invoice_from_file ( file_id , detected_vendor_id )
# Update file status
execute_update (
" UPDATE incoming_files SET status = ' analyzed ' WHERE file_id = %s " ,
( file_id , )
)
return {
" file_id " : file_id ,
" filename " : file_data [ ' filename ' ] ,
" detected_cvr " : detected_cvr ,
" matches " : vendor_matches ,
" auto_selected " : vendor_matches [ 0 ] ,
" requires_manual_selection " : False ,
" invoice_id " : invoice_id ,
" message " : f " Leverandør auto-valgt: { matched_vendor [ ' name ' ] } "
}
for vendor in vendors :
# Skip if already matched by detected_vendor_id
if detected_vendor_id and vendor [ ' id ' ] == detected_vendor_id :
continue
confidence = 0
match_reason = [ ]
# 100% = Exact CVR match
if detected_cvr and vendor . get ( ' cvr_number ' ) :
if detected_cvr . strip ( ) == str ( vendor [ ' cvr_number ' ] ) . strip ( ) :
confidence = 100
match_reason . append ( " Exact CVR match " )
# 90% = Email domain match (if we have extracted sender email from file metadata)
# Note: This requires additional extraction logic - placeholder for now
# 70% = Fuzzy name match (simple contains check for now)
if confidence == 0 :
filename_lower = file_data [ ' filename ' ] . lower ( )
vendor_name_lower = vendor [ ' name ' ] . lower ( )
# Check if vendor name appears in filename
if vendor_name_lower in filename_lower or filename_lower in vendor_name_lower :
confidence = 70
match_reason . append ( " Name appears in filename " )
if confidence > 0 :
vendor_matches . append ( {
" vendor_id " : vendor [ ' id ' ] ,
" vendor_name " : vendor [ ' name ' ] ,
" cvr_number " : vendor . get ( ' cvr_number ' ) ,
" confidence " : confidence ,
" match_reason " : " , " . join ( match_reason ) ,
" is_exact_match " : confidence == 100
} )
# Sort by confidence descending
vendor_matches . sort ( key = lambda x : x [ ' confidence ' ] , reverse = True )
# If we have a 100% match, auto-select it
auto_selected = None
if vendor_matches and vendor_matches [ 0 ] [ ' confidence ' ] == 100 :
auto_selected = vendor_matches [ 0 ]
# Update file with detected vendor
execute_update (
" UPDATE incoming_files SET detected_vendor_id = %s WHERE file_id = %s " ,
( auto_selected [ ' vendor_id ' ] , file_id )
)
logger . info ( f " ✅ Auto-matched vendor { auto_selected [ ' vendor_name ' ] } (100% CVR match) for file { file_id } " )
return {
" file_id " : file_id ,
" filename " : file_data [ ' filename ' ] ,
" detected_cvr " : detected_cvr ,
" matches " : vendor_matches ,
" auto_selected " : auto_selected ,
" requires_manual_selection " : auto_selected is None
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Vendor matching failed for file { file_id } : { e } " , exc_info = True )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /supplier-invoices/suggest-line-codes " )
async def suggest_line_codes ( vendor_id : int , description : str ) :
"""
Suggest VAT code , contra account , and line purpose based on historical data
Uses weighted scoring : score = match_count × ( 1.0 + 1.0 / ( days_old + 1 ) )
Newer matches are weighted higher . Requires minimum 3 matches to return suggestion .
"""
try :
from datetime import datetime
from difflib import SequenceMatcher
# Get all lines from this vendor with vat_code, contra_account, line_purpose set
history_lines = execute_query (
""" SELECT sil.description, sil.vat_code, sil.contra_account, sil.line_purpose,
si . invoice_date , si . created_at
FROM supplier_invoice_lines sil
JOIN supplier_invoices si ON sil . supplier_invoice_id = si . id
WHERE si . vendor_id = % s
AND sil . vat_code IS NOT NULL
ORDER BY si . created_at DESC
LIMIT 500 """ ,
( vendor_id , )
)
if not history_lines :
return {
" vendor_id " : vendor_id ,
" description " : description ,
" suggestions " : [ ] ,
" has_suggestions " : False ,
" note " : " No historical data found for this vendor "
}
# Score each unique combination
combination_scores = { }
description_lower = description . lower ( ) . strip ( )
for line in history_lines :
hist_desc = ( line . get ( ' description ' ) or ' ' ) . lower ( ) . strip ( )
# Fuzzy match descriptions
similarity = SequenceMatcher ( None , description_lower , hist_desc ) . ratio ( )
# Only consider matches with >60% similarity
if similarity < 0.6 :
continue
# Calculate recency weight
created_at = line . get ( ' created_at ' ) or line . get ( ' invoice_date ' )
if isinstance ( created_at , str ) :
created_at = datetime . fromisoformat ( created_at . replace ( ' Z ' , ' +00:00 ' ) )
days_old = ( datetime . now ( ) - created_at ) . days if created_at else 365
recency_weight = 1.0 + ( 1.0 / ( days_old + 1 ) )
# Create combination key
combo_key = (
line . get ( ' vat_code ' ) ,
line . get ( ' contra_account ' ) ,
line . get ( ' line_purpose ' )
)
if combo_key not in combination_scores :
combination_scores [ combo_key ] = {
' vat_code ' : line . get ( ' vat_code ' ) ,
' contra_account ' : line . get ( ' contra_account ' ) ,
' line_purpose ' : line . get ( ' line_purpose ' ) ,
' match_count ' : 0 ,
' total_similarity ' : 0 ,
' weighted_score ' : 0 ,
' last_used ' : created_at ,
' example_descriptions ' : [ ]
}
combination_scores [ combo_key ] [ ' match_count ' ] + = 1
combination_scores [ combo_key ] [ ' total_similarity ' ] + = similarity
combination_scores [ combo_key ] [ ' weighted_score ' ] + = similarity * recency_weight
if len ( combination_scores [ combo_key ] [ ' example_descriptions ' ] ) < 3 :
combination_scores [ combo_key ] [ ' example_descriptions ' ] . append ( line . get ( ' description ' ) )
# Update last_used if this is newer
if created_at and ( not combination_scores [ combo_key ] [ ' last_used ' ] or created_at > combination_scores [ combo_key ] [ ' last_used ' ] ) :
combination_scores [ combo_key ] [ ' last_used ' ] = created_at
# Filter to combinations with ≥3 matches
valid_suggestions = [
combo for combo in combination_scores . values ( )
if combo [ ' match_count ' ] > = 3
]
# Sort by weighted score
valid_suggestions . sort ( key = lambda x : x [ ' weighted_score ' ] , reverse = True )
# Format suggestions
formatted_suggestions = [ ]
for suggestion in valid_suggestions [ : 5 ] : # Top 5 suggestions
formatted_suggestions . append ( {
' vat_code ' : suggestion [ ' vat_code ' ] ,
' contra_account ' : suggestion [ ' contra_account ' ] ,
' line_purpose ' : suggestion [ ' line_purpose ' ] ,
' match_count ' : suggestion [ ' match_count ' ] ,
' confidence_score ' : round ( suggestion [ ' weighted_score ' ] , 2 ) ,
' last_used ' : suggestion [ ' last_used ' ] . strftime ( ' % Y- % m- %d ' ) if suggestion [ ' last_used ' ] else None ,
' example_descriptions ' : suggestion [ ' example_descriptions ' ]
} )
return {
" vendor_id " : vendor_id ,
" description " : description ,
" suggestions " : formatted_suggestions ,
" has_suggestions " : len ( formatted_suggestions ) > 0 ,
" top_suggestion " : formatted_suggestions [ 0 ] if formatted_suggestions else None
}
except Exception as e :
logger . error ( f " ❌ Line code suggestion failed: { e } " , exc_info = True )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /supplier-invoices/files/batch-analyze " )
async def batch_analyze_files ( ) :
"""
Batch analyze all pending files using cascade extraction :
1. invoice2data ( YAML templates ) - fastest
2. template_service ( regex patterns ) - if invoice2data fails
3. ollama AI - as last backup
Auto - creates invoices for files with 100 % vendor match .
Files with < 100 % match remain pending for manual vendor selection .
"""
try :
# Get all pending files
pending_files = execute_query (
""" SELECT file_id, filename, file_path, detected_vendor_id, detected_cvr
FROM incoming_files
WHERE status IN ( ' pending ' , ' extraction_failed ' )
ORDER BY uploaded_at DESC """
)
if not pending_files :
return {
" message " : " No pending files to analyze " ,
" analyzed " : 0 ,
" invoices_created " : 0 ,
" failed " : 0 ,
" requires_vendor_selection " : 0
}
results = {
" analyzed " : 0 ,
" invoices_created " : 0 ,
" failed " : 0 ,
" requires_vendor_selection " : 0 ,
" details " : [ ]
}
for file_data in pending_files :
file_id = file_data [ ' file_id ' ]
filename = file_data [ ' filename ' ]
try :
# Run vendor matching first
vendor_match_result = await match_vendor_for_file ( file_id )
# If no 100% match, skip extraction and mark for manual selection
if vendor_match_result . get ( ' requires_manual_selection ' ) :
execute_update (
" UPDATE incoming_files SET status = ' requires_vendor_selection ' WHERE file_id = %s " ,
( file_id , )
)
results [ ' requires_vendor_selection ' ] + = 1
results [ ' details ' ] . append ( {
" file_id " : file_id ,
" filename " : filename ,
" status " : " requires_vendor_selection " ,
" vendor_matches " : len ( vendor_match_result . get ( ' matches ' , [ ] ) )
} )
continue
# We have 100% vendor match - proceed with extraction cascade
vendor_id = vendor_match_result [ ' auto_selected ' ] [ ' vendor_id ' ]
# Try extraction cascade (this logic should be moved to a helper function)
# For now, mark as analyzed
execute_update (
" UPDATE incoming_files SET status = ' analyzed ' , detected_vendor_id = %s WHERE file_id = %s " ,
( vendor_id , file_id )
)
results [ ' analyzed ' ] + = 1
results [ ' details ' ] . append ( {
" file_id " : file_id ,
" filename " : filename ,
" status " : " analyzed " ,
" vendor_id " : vendor_id ,
" vendor_name " : vendor_match_result [ ' auto_selected ' ] [ ' vendor_name ' ]
} )
logger . info ( f " ✅ Analyzed file { file_id } : { filename } " )
except Exception as e :
logger . error ( f " ❌ Batch analysis failed for file { file_id } : { e } " )
execute_update (
" UPDATE incoming_files SET status = ' extraction_failed ' WHERE file_id = %s " ,
( file_id , )
)
results [ ' failed ' ] + = 1
results [ ' details ' ] . append ( {
" file_id " : file_id ,
" filename " : filename ,
" status " : " extraction_failed " ,
" error " : str ( e )
} )
logger . info ( f " ✅ Batch analysis complete: { results [ ' analyzed ' ] } analyzed, { results [ ' invoices_created ' ] } invoices created, { results [ ' failed ' ] } failed " )
return results
except Exception as e :
logger . error ( f " ❌ Batch analysis failed: { e } " , exc_info = True )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /supplier-invoices/files/ {file_id} /retry " )
async def retry_extraction ( file_id : int ) :
"""
Retry extraction for a failed file
Re - runs the cascade : invoice2data → template_service → ollama AI
"""
try :
# Check if file exists
file_info = execute_query (
" SELECT file_id, filename, file_path, status FROM incoming_files WHERE file_id = %s " ,
( file_id , )
)
if not file_info :
raise HTTPException ( status_code = 404 , detail = f " File { file_id } not found " )
file_data = file_info [ 0 ]
# Reset status to pending
execute_update (
" UPDATE incoming_files SET status = ' pending ' WHERE file_id = %s " ,
( file_id , )
)
logger . info ( f " 🔄 Retrying extraction for file { file_id } : { file_data [ ' filename ' ] } " )
2026-03-02 06:22:33 +01:00
# Run full extraction cascade immediately
result = await reprocess_uploaded_file ( file_id )
return result
2026-01-25 03:29:28 +01:00
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Retry extraction failed for file { file_id } : { e } " , exc_info = True )
raise HTTPException ( status_code = 500 , detail = str ( e ) )