feat: Add template editing functionality and improve file loading logic
- Added an "Edit" button for templates in the templates list, redirecting to the template builder. - Enhanced loadPendingFiles function to filter files by vendor ID, displaying a message if no files are found. - Modified openTestModal to load vendor-specific files based on the selected template. - Updated Ollama model configuration for improved JSON extraction. - Refactored Ollama service to support different API formats based on model type. - Implemented lazy loading of templates in TemplateService for better performance. - Added VAT note extraction for invoice line items. - Updated Docker Compose configuration for Ollama model settings.
This commit is contained in:
parent
18b0fe9c05
commit
890bd6245d
53
.env.bak
Normal file
53
.env.bak
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# =====================================================
|
||||||
|
# POSTGRESQL DATABASE - Local Development
|
||||||
|
# =====================================================
|
||||||
|
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
|
||||||
|
|
||||||
|
# Database credentials (bruges af docker-compose)
|
||||||
|
POSTGRES_USER=bmc_hub
|
||||||
|
POSTGRES_PASSWORD=bmc_hub
|
||||||
|
POSTGRES_DB=bmc_hub
|
||||||
|
POSTGRES_PORT=5433
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# API CONFIGURATION
|
||||||
|
# =====================================================
|
||||||
|
API_HOST=0.0.0.0
|
||||||
|
API_PORT=8001
|
||||||
|
API_RELOAD=true
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# SECURITY
|
||||||
|
# =====================================================
|
||||||
|
SECRET_KEY=change-this-in-production-use-random-string
|
||||||
|
CORS_ORIGINS=http://localhost:8000,http://localhost:3000
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# LOGGING
|
||||||
|
# =====================================================
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FILE=logs/app.log
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# GITHUB/GITEA REPOSITORY (Optional - for reference)
|
||||||
|
# =====================================================
|
||||||
|
# Repository: https://g.bmcnetworks.dk/ct/bmc_hub
|
||||||
|
GITHUB_REPO=ct/bmc_hub
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# OLLAMA AI INTEGRATION
|
||||||
|
# =====================================================
|
||||||
|
OLLAMA_ENDPOINT=http://ai_direct.cs.blaahund.dk
|
||||||
|
OLLAMA_MODEL=qwen2.5:3b
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# e-conomic Integration (Optional)
|
||||||
|
# =====================================================
|
||||||
|
# Get credentials from e-conomic Settings -> Integrations -> API
|
||||||
|
ECONOMIC_API_URL=https://restapi.e-conomic.com
|
||||||
|
ECONOMIC_APP_SECRET_TOKEN=your_app_secret_token_here
|
||||||
|
ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
|
||||||
|
|
||||||
|
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
|
||||||
|
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
|
||||||
|
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
|
||||||
@ -281,6 +281,16 @@ async def get_file_extracted_data(file_id: int):
|
|||||||
fetchone=True
|
fetchone=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
llm_json_data = json.loads(extraction['llm_response_json']) if isinstance(extraction['llm_response_json'], str) else extraction['llm_response_json']
|
||||||
|
logger.info(f"📊 Parsed llm_response_json: invoice_number={llm_json_data.get('invoice_number')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Failed to parse llm_response_json: {e}")
|
||||||
|
|
||||||
# Get extraction lines if exist
|
# Get extraction lines if exist
|
||||||
extraction_lines = []
|
extraction_lines = []
|
||||||
if extraction:
|
if extraction:
|
||||||
@ -299,11 +309,65 @@ async def get_file_extracted_data(file_id: int):
|
|||||||
if file_path.exists():
|
if file_path.exists():
|
||||||
pdf_text = await ollama_service._extract_text_from_file(file_path)
|
pdf_text = await ollama_service._extract_text_from_file(file_path)
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
"vat_note": line.get('vat_note')
|
||||||
|
})
|
||||||
|
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,
|
||||||
|
"vat_note": line.get('vat_note')
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build llm_data response
|
||||||
|
llm_data = None
|
||||||
|
if llm_json_data:
|
||||||
|
# Use invoice_number from LLM JSON (works for both AI and template extraction)
|
||||||
|
llm_data = {
|
||||||
|
"invoice_number": llm_json_data.get('invoice_number'),
|
||||||
|
"invoice_date": llm_json_data.get('invoice_date'),
|
||||||
|
"due_date": llm_json_data.get('due_date'),
|
||||||
|
"total_amount": float(llm_json_data.get('total_amount')) if llm_json_data.get('total_amount') else None,
|
||||||
|
"currency": llm_json_data.get('currency') or 'DKK',
|
||||||
|
"document_type": llm_json_data.get('document_type'),
|
||||||
|
"lines": formatted_lines
|
||||||
|
}
|
||||||
|
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'),
|
||||||
|
"lines": formatted_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get vendor from extraction
|
||||||
|
vendor_matched_id = extraction.get('vendor_matched_id') if extraction else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
"filename": file_info['filename'],
|
"filename": file_info['filename'],
|
||||||
"status": file_info['status'],
|
"status": file_info['status'],
|
||||||
"uploaded_at": file_info['uploaded_at'],
|
"uploaded_at": file_info['uploaded_at'],
|
||||||
|
"vendor_matched_id": vendor_matched_id,
|
||||||
|
"llm_data": llm_data,
|
||||||
"extraction": extraction,
|
"extraction": extraction,
|
||||||
"extraction_lines": extraction_lines if extraction_lines else [],
|
"extraction_lines": extraction_lines if extraction_lines else [],
|
||||||
"pdf_text_preview": pdf_text[:5000] if pdf_text else None
|
"pdf_text_preview": pdf_text[:5000] if pdf_text else None
|
||||||
@ -351,6 +415,12 @@ async def download_pending_file(file_id: int):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/supplier-invoices/files/{file_id}/link-vendor")
|
@router.post("/supplier-invoices/files/{file_id}/link-vendor")
|
||||||
async def link_vendor_to_extraction(file_id: int, data: dict):
|
async def link_vendor_to_extraction(file_id: int, data: dict):
|
||||||
"""Link an existing vendor to the extraction"""
|
"""Link an existing vendor to the extraction"""
|
||||||
@ -705,6 +775,29 @@ async def list_templates():
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
"""
|
||||||
|
template = execute_query(query, (template_id,), fetchone=True)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/supplier-invoices/search-vendor")
|
@router.post("/supplier-invoices/search-vendor")
|
||||||
async def search_vendor_by_info(request: Dict):
|
async def search_vendor_by_info(request: Dict):
|
||||||
"""
|
"""
|
||||||
@ -1023,6 +1116,9 @@ async def create_supplier_invoice(data: Dict):
|
|||||||
# Insert lines if provided
|
# Insert lines if provided
|
||||||
if data.get('lines'):
|
if data.get('lines'):
|
||||||
for idx, line in enumerate(data['lines'], start=1):
|
for idx, line in enumerate(data['lines'], start=1):
|
||||||
|
# Map vat_code: I52 for reverse charge, I25 for standard
|
||||||
|
vat_code = line.get('vat_code', 'I25')
|
||||||
|
|
||||||
execute_insert(
|
execute_insert(
|
||||||
"""INSERT INTO supplier_invoice_lines
|
"""INSERT INTO supplier_invoice_lines
|
||||||
(supplier_invoice_id, line_number, description, quantity, unit_price,
|
(supplier_invoice_id, line_number, description, quantity, unit_price,
|
||||||
@ -1035,7 +1131,7 @@ async def create_supplier_invoice(data: Dict):
|
|||||||
line.get('quantity', 1),
|
line.get('quantity', 1),
|
||||||
line.get('unit_price', 0),
|
line.get('unit_price', 0),
|
||||||
line.get('line_total', 0),
|
line.get('line_total', 0),
|
||||||
line.get('vat_code', 'I25'),
|
vat_code,
|
||||||
line.get('vat_rate', 25.00),
|
line.get('vat_rate', 25.00),
|
||||||
line.get('vat_amount', 0),
|
line.get('vat_amount', 0),
|
||||||
line.get('contra_account', '5810'),
|
line.get('contra_account', '5810'),
|
||||||
@ -1582,9 +1678,32 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
|
|||||||
import json
|
import json
|
||||||
extraction_id = execute_insert(
|
extraction_id = execute_insert(
|
||||||
"""INSERT INTO extractions
|
"""INSERT INTO extractions
|
||||||
(file_id, template_id, extraction_method, raw_data, extracted_at)
|
(file_id, vendor_matched_id, document_id, document_date, due_date,
|
||||||
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)""",
|
total_amount, currency, document_type, confidence, llm_response_json, status)
|
||||||
(file_id, template_id, 'template', json.dumps(extracted_fields))
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'extracted')""",
|
||||||
|
(file_id, vendor_id,
|
||||||
|
extracted_fields.get('invoice_number'),
|
||||||
|
extracted_fields.get('invoice_date'),
|
||||||
|
extracted_fields.get('due_date'),
|
||||||
|
extracted_fields.get('total_amount'),
|
||||||
|
extracted_fields.get('currency', 'DKK'),
|
||||||
|
extracted_fields.get('document_type'),
|
||||||
|
confidence,
|
||||||
|
json.dumps(extracted_fields))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert line items if extracted
|
||||||
|
if extracted_fields.get('lines'):
|
||||||
|
for idx, line in enumerate(extracted_fields['lines'], start=1):
|
||||||
|
execute_insert(
|
||||||
|
"""INSERT INTO extraction_lines
|
||||||
|
(extraction_id, line_number, description, quantity, unit_price,
|
||||||
|
line_total, vat_rate, vat_note, confidence)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||||
|
(extraction_id, idx, line.get('description'),
|
||||||
|
line.get('quantity'), line.get('unit_price'),
|
||||||
|
line.get('line_total'), line.get('vat_rate'),
|
||||||
|
line.get('vat_note'), confidence)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log usage
|
# Log usage
|
||||||
@ -1598,87 +1717,23 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
|
|||||||
(template_id, file_id)
|
(template_id, file_id)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# FALLBACK: Use AI to extract data universally
|
# NO AI FALLBACK - Require template
|
||||||
logger.info("🤖 No template matched - using AI universal extraction...")
|
logger.warning(f"⚠️ No template matched (confidence: {confidence:.0%}) - rejecting file")
|
||||||
|
|
||||||
try:
|
|
||||||
# Build AI prompt for universal extraction
|
|
||||||
ai_prompt = f"""OPGAVE: Analyser denne danske faktura og udtræk nøgledata.
|
|
||||||
|
|
||||||
RETURNER KUN VALID JSON - ingen forklaring, ingen markdown, kun ren JSON!
|
|
||||||
|
|
||||||
REQUIRED STRUKTUR:
|
|
||||||
{{
|
|
||||||
"invoice_number": "5082481",
|
|
||||||
"invoice_date": "2025-10-24",
|
|
||||||
"due_date": "2025-11-24",
|
|
||||||
"total_amount": "1471.20",
|
|
||||||
"currency": "DKK",
|
|
||||||
"vendor_name": "DCS ApS",
|
|
||||||
"vendor_cvr": "29522790",
|
|
||||||
"vendor_address": "Høgemosevænget 89, 2820 Gentofte",
|
|
||||||
"line_items": [
|
|
||||||
{{"description": "Ubiquiti Switch", "quantity": 1, "unit_price": "619.00", "total": "619.00"}}
|
|
||||||
]
|
|
||||||
}}
|
|
||||||
|
|
||||||
VIGTIGT:
|
|
||||||
- Dato format: YYYY-MM-DD
|
|
||||||
- Ignorer CVR {settings.OWN_CVR} (det er KØBERS CVR - find LEVERANDØRENS CVR)
|
|
||||||
- currency: Normalt "DKK" for danske fakturaer
|
|
||||||
- line_items: Udtræk så mange linjer som muligt
|
|
||||||
- Hvis et felt ikke kan findes, brug null
|
|
||||||
|
|
||||||
PDF TEKST:
|
|
||||||
{text[:3000]}
|
|
||||||
|
|
||||||
RETURNER KUN JSON!"""
|
|
||||||
|
|
||||||
# Call AI
|
|
||||||
ai_result = await ollama_service.extract_from_text(ai_prompt)
|
|
||||||
|
|
||||||
if ai_result and ai_result.get('vendor_cvr'):
|
|
||||||
# Try to find existing vendor by CVR
|
|
||||||
vendor = execute_query(
|
|
||||||
"SELECT id, name FROM vendors WHERE cvr_number = %s",
|
|
||||||
(ai_result['vendor_cvr'],),
|
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if vendor:
|
|
||||||
vendor_id = vendor['id']
|
|
||||||
logger.info(f"✅ AI matched vendor: {vendor['name']} (CVR: {ai_result['vendor_cvr']})")
|
|
||||||
else:
|
|
||||||
logger.info(f"ℹ️ AI found unknown vendor CVR: {ai_result['vendor_cvr']}")
|
|
||||||
|
|
||||||
extracted_fields = ai_result
|
|
||||||
|
|
||||||
# Save extraction to database
|
|
||||||
import json
|
|
||||||
extraction_id = execute_insert(
|
|
||||||
"""INSERT INTO extractions
|
|
||||||
(file_id, extraction_method, raw_data, extracted_at)
|
|
||||||
VALUES (%s, %s, %s, CURRENT_TIMESTAMP)""",
|
|
||||||
(file_id, 'ai_universal', json.dumps(ai_result))
|
|
||||||
)
|
|
||||||
|
|
||||||
execute_update(
|
execute_update(
|
||||||
"""UPDATE incoming_files
|
"""UPDATE incoming_files
|
||||||
SET status = 'ai_extracted', processed_at = CURRENT_TIMESTAMP
|
SET status = 'failed',
|
||||||
|
error_message = 'Ingen template match - opret template for denne leverandør',
|
||||||
|
processed_at = CURRENT_TIMESTAMP
|
||||||
WHERE file_id = %s""",
|
WHERE file_id = %s""",
|
||||||
(file_id,)
|
(file_id,)
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as ai_error:
|
raise HTTPException(
|
||||||
logger.warning(f"⚠️ AI extraction failed: {ai_error} - manual entry required")
|
status_code=400,
|
||||||
execute_update(
|
detail=f"Ingen template match ({confidence:.0%} confidence) - opret template for denne leverandør"
|
||||||
"""UPDATE incoming_files
|
|
||||||
SET status = 'pending', processed_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE file_id = %s""",
|
|
||||||
(file_id,)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Return data for user to review and confirm
|
# Return data for user to review and confirm
|
||||||
return {
|
return {
|
||||||
"status": "needs_review",
|
"status": "needs_review",
|
||||||
@ -1767,136 +1822,26 @@ async def reprocess_uploaded_file(file_id: int):
|
|||||||
(template_id, file_id)
|
(template_id, file_id)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("🤖 Ingen template match - bruger AI udtrækning med forbedret system prompt")
|
# NO AI FALLBACK - Require template matching
|
||||||
|
logger.warning(f"⚠️ Ingen template match (confidence: {confidence:.0%}) - afviser fil")
|
||||||
|
|
||||||
# Use improved Ollama service with credit note detection
|
|
||||||
ai_result = await ollama_service.extract_from_text(text)
|
|
||||||
|
|
||||||
if not ai_result or 'error' in ai_result:
|
|
||||||
execute_update(
|
execute_update(
|
||||||
"""UPDATE incoming_files
|
"""UPDATE incoming_files
|
||||||
SET status = 'failed', error_message = 'AI udtrækning returnerede ingen data',
|
SET status = 'failed',
|
||||||
|
error_message = 'Ingen template match - opret template for denne leverandør',
|
||||||
processed_at = CURRENT_TIMESTAMP
|
processed_at = CURRENT_TIMESTAMP
|
||||||
WHERE file_id = %s""",
|
WHERE file_id = %s""",
|
||||||
(file_id,)
|
(file_id,)
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
"error": "AI udtrækning fejlede"
|
"error": "Ingen template match - opret template for denne leverandør",
|
||||||
|
"confidence": confidence
|
||||||
}
|
}
|
||||||
|
|
||||||
# Search for vendor by CVR (normalize: remove DK prefix)
|
# Return success with template data
|
||||||
vendor_cvr = ai_result.get('vendor_cvr', '').replace('DK', '').replace('dk', '').strip()
|
|
||||||
vendor_id = None
|
|
||||||
|
|
||||||
# CRITICAL: If AI mistakenly identified our own company as vendor, reject it
|
|
||||||
if vendor_cvr == settings.OWN_CVR:
|
|
||||||
logger.warning(f"⚠️ AI wrongly identified BMC Denmark (CVR {settings.OWN_CVR}) as vendor - this is the customer!")
|
|
||||||
vendor_cvr = None
|
|
||||||
ai_result['vendor_cvr'] = None
|
|
||||||
ai_result['vendor_name'] = None
|
|
||||||
|
|
||||||
if vendor_cvr:
|
|
||||||
vendor = execute_query(
|
|
||||||
"SELECT id, name FROM vendors WHERE cvr_number = %s",
|
|
||||||
(vendor_cvr,),
|
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
if vendor:
|
|
||||||
vendor_id = vendor['id']
|
|
||||||
logger.info(f"✅ Matched vendor: {vendor['name']} (CVR: {vendor_cvr})")
|
|
||||||
else:
|
|
||||||
logger.warning(f"⚠️ Vendor not found for CVR: {vendor_cvr}")
|
|
||||||
|
|
||||||
# Extract dates from raw text if AI didn't provide them
|
|
||||||
invoice_date = ai_result.get('invoice_date')
|
|
||||||
due_date = ai_result.get('due_date')
|
|
||||||
|
|
||||||
# Validate and clean dates
|
|
||||||
if invoice_date == '':
|
|
||||||
invoice_date = None
|
|
||||||
if due_date == '' or not due_date:
|
|
||||||
# If no due date, default to 30 days after invoice date
|
|
||||||
if invoice_date:
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
try:
|
|
||||||
inv_date_obj = datetime.strptime(invoice_date, '%Y-%m-%d')
|
|
||||||
due_date_obj = inv_date_obj + timedelta(days=30)
|
|
||||||
due_date = due_date_obj.strftime('%Y-%m-%d')
|
|
||||||
logger.info(f"📅 Calculated due_date: {due_date} (invoice_date + 30 days)")
|
|
||||||
except:
|
|
||||||
due_date = None
|
|
||||||
else:
|
|
||||||
due_date = None
|
|
||||||
|
|
||||||
if not invoice_date and 'raw_text_snippet' in ai_result:
|
|
||||||
# Try to find date in format "Dato: DD.MM.YYYY"
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
|
||||||
date_match = re.search(r'Dato:\s*(\d{2})\.(\d{2})\.(\d{4})', ai_result['raw_text_snippet'])
|
|
||||||
if date_match:
|
|
||||||
day, month, year = date_match.groups()
|
|
||||||
invoice_date = f"{year}-{month}-{day}"
|
|
||||||
logger.info(f"📅 Extracted invoice_date from text: {invoice_date}")
|
|
||||||
|
|
||||||
# Normalize line items (AI might return 'lines' or 'line_items')
|
|
||||||
line_items = ai_result.get('line_items') or ai_result.get('lines') or []
|
|
||||||
|
|
||||||
# Use matched vendor name if found, otherwise use AI's name
|
|
||||||
vendor_name = ai_result.get('vendor_name')
|
|
||||||
if vendor_id and vendor:
|
|
||||||
vendor_name = vendor['name'] # Override with actual vendor name from database
|
|
||||||
logger.info(f"✅ Using matched vendor name: {vendor_name}")
|
|
||||||
|
|
||||||
# Save extraction to database with document_type_detected
|
|
||||||
document_type = ai_result.get('document_type', 'invoice')
|
|
||||||
extraction_id = execute_insert(
|
|
||||||
"""INSERT INTO extractions (
|
|
||||||
file_id, vendor_matched_id, llm_response_json,
|
|
||||||
vendor_name, vendor_cvr, document_date, due_date,
|
|
||||||
total_amount, currency, confidence, status, document_type_detected
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'extracted', %s)
|
|
||||||
RETURNING extraction_id""",
|
|
||||||
(
|
|
||||||
file_id, vendor_id, json.dumps(ai_result),
|
|
||||||
vendor_name, vendor_cvr, # Use corrected vendor name
|
|
||||||
invoice_date, due_date, # Use extracted dates
|
|
||||||
ai_result.get('total_amount'), ai_result.get('currency', 'DKK'),
|
|
||||||
ai_result.get('confidence', 0.8),
|
|
||||||
document_type # Store detected document type (invoice or credit_note)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save line items (handle both 'lines' and 'line_items')
|
|
||||||
if line_items:
|
|
||||||
for idx, line in enumerate(line_items, 1):
|
|
||||||
execute_update(
|
|
||||||
"""INSERT INTO extraction_lines (
|
|
||||||
extraction_id, line_number, description, quantity,
|
|
||||||
unit_price, line_total, vat_rate, vat_amount, confidence
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
|
||||||
(
|
|
||||||
extraction_id, idx,
|
|
||||||
line.get('description'),
|
|
||||||
line.get('quantity'),
|
|
||||||
line.get('unit_price'),
|
|
||||||
line.get('total_price') or line.get('line_total'),
|
|
||||||
line.get('vat_rate'),
|
|
||||||
line.get('vat_amount'),
|
|
||||||
line.get('confidence', 0.8)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
execute_update(
|
|
||||||
"""UPDATE incoming_files
|
|
||||||
SET status = 'ai_extracted', processed_at = CURRENT_TIMESTAMP, error_message = NULL
|
|
||||||
WHERE file_id = %s""",
|
|
||||||
(file_id,)
|
|
||||||
)
|
|
||||||
|
|
||||||
extracted_fields = ai_result
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
|
|||||||
@ -480,8 +480,8 @@
|
|||||||
<!-- Left: PDF Viewer -->
|
<!-- Left: PDF Viewer -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<h6 class="mb-3">PDF Dokument</h6>
|
<h6 class="mb-3">PDF Dokument</h6>
|
||||||
<div style="border: 1px solid #ddd; border-radius: 4px; height: 600px; overflow: auto;">
|
<div style="border: 1px solid #ddd; border-radius: 4px; height: 600px; overflow: hidden;">
|
||||||
<embed id="manualEntryPdfViewer" type="application/pdf" width="100%" height="100%">
|
<iframe id="manualEntryPdfViewer" type="application/pdf" width="100%" height="100%" style="border: none;"></iframe>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -544,6 +544,11 @@
|
|||||||
<!-- Line Items -->
|
<!-- Line Items -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Fakturalinjer</label>
|
<label class="form-label">Fakturalinjer</label>
|
||||||
|
<div class="alert alert-info py-2 px-3 mb-2" style="font-size: 0.875rem;">
|
||||||
|
<strong>💡 Momskoder:</strong><br>
|
||||||
|
<span class="badge bg-success me-2">I25</span> Standard 25% moms (køb med moms)<br>
|
||||||
|
<span class="badge bg-warning text-dark me-2">I52</span> Omvendt betalingspligt (ingen moms - auto-detekteres fra "⚠️ OMVENDT BETALINGSPLIGT")
|
||||||
|
</div>
|
||||||
<div id="manualLineItems">
|
<div id="manualLineItems">
|
||||||
<!-- Lines will be added here -->
|
<!-- Lines will be added here -->
|
||||||
</div>
|
</div>
|
||||||
@ -1390,16 +1395,29 @@ async function openManualEntryMode() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set file ID
|
||||||
|
document.getElementById('manualEntryFileId').value = fileId;
|
||||||
|
|
||||||
|
// Clear form
|
||||||
|
document.getElementById('manualEntryForm').reset();
|
||||||
|
document.getElementById('manualLineItems').innerHTML = '';
|
||||||
|
manualLineCounter = 0;
|
||||||
|
|
||||||
// Close review modal
|
// Close review modal
|
||||||
const reviewModal = bootstrap.Modal.getInstance(document.getElementById('reviewModal'));
|
const reviewModal = bootstrap.Modal.getInstance(document.getElementById('reviewExtractedDataModal'));
|
||||||
if (reviewModal) {
|
if (reviewModal) {
|
||||||
reviewModal.hide();
|
reviewModal.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set file ID
|
// Open manual entry modal first
|
||||||
document.getElementById('manualEntryFileId').value = fileId;
|
console.log('Opening manual entry modal...');
|
||||||
|
const manualModal = new bootstrap.Modal(document.getElementById('manualEntryModal'));
|
||||||
|
manualModal.show();
|
||||||
|
|
||||||
// Load PDF
|
// Wait a bit for modal to render
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
// Load PDF after modal is open
|
||||||
console.log('Loading PDF...');
|
console.log('Loading PDF...');
|
||||||
document.getElementById('manualEntryPdfViewer').src = `/api/v1/supplier-invoices/files/${fileId}/pdf`;
|
document.getElementById('manualEntryPdfViewer').src = `/api/v1/supplier-invoices/files/${fileId}/pdf`;
|
||||||
|
|
||||||
@ -1407,11 +1425,6 @@ async function openManualEntryMode() {
|
|||||||
console.log('Loading vendors...');
|
console.log('Loading vendors...');
|
||||||
await loadVendorsForManual();
|
await loadVendorsForManual();
|
||||||
|
|
||||||
// Clear form
|
|
||||||
document.getElementById('manualEntryForm').reset();
|
|
||||||
document.getElementById('manualLineItems').innerHTML = '';
|
|
||||||
manualLineCounter = 0;
|
|
||||||
|
|
||||||
// Load extracted data and prefill form
|
// Load extracted data and prefill form
|
||||||
console.log('Loading extracted data...');
|
console.log('Loading extracted data...');
|
||||||
try {
|
try {
|
||||||
@ -1423,10 +1436,14 @@ async function openManualEntryMode() {
|
|||||||
// Prefill form fields
|
// Prefill form fields
|
||||||
if (data.llm_data) {
|
if (data.llm_data) {
|
||||||
const llm = data.llm_data;
|
const llm = data.llm_data;
|
||||||
|
console.log('LLM data invoice_number:', llm.invoice_number);
|
||||||
|
|
||||||
// Invoice number
|
// Invoice number
|
||||||
if (llm.invoice_number) {
|
if (llm.invoice_number) {
|
||||||
|
console.log('Setting invoice number:', llm.invoice_number);
|
||||||
document.getElementById('manualInvoiceNumber').value = llm.invoice_number;
|
document.getElementById('manualInvoiceNumber').value = llm.invoice_number;
|
||||||
|
} else {
|
||||||
|
console.warn('No invoice_number in llm_data');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invoice date
|
// Invoice date
|
||||||
@ -1456,7 +1473,7 @@ async function openManualEntryMode() {
|
|||||||
|
|
||||||
// Vendor - select if matched
|
// Vendor - select if matched
|
||||||
if (data.vendor_matched_id) {
|
if (data.vendor_matched_id) {
|
||||||
document.getElementById('manualVendorSelect').value = data.vendor_matched_id;
|
document.getElementById('manualVendorId').value = data.vendor_matched_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add line items
|
// Add line items
|
||||||
@ -1466,7 +1483,14 @@ async function openManualEntryMode() {
|
|||||||
const lineNum = manualLineCounter;
|
const lineNum = manualLineCounter;
|
||||||
|
|
||||||
if (line.description) {
|
if (line.description) {
|
||||||
document.getElementById(`manualLineDesc${lineNum}`).value = line.description;
|
let desc = line.description;
|
||||||
|
// Add VAT note to description if present
|
||||||
|
if (line.vat_note === 'reverse_charge') {
|
||||||
|
desc += ' ⚠️ OMVENDT BETALINGSPLIGT';
|
||||||
|
} else if (line.vat_note === 'copydan_included') {
|
||||||
|
desc += ' [Copydan incl.]';
|
||||||
|
}
|
||||||
|
document.getElementById(`manualLineDesc${lineNum}`).value = desc;
|
||||||
}
|
}
|
||||||
if (line.quantity) {
|
if (line.quantity) {
|
||||||
document.getElementById(`manualLineQty${lineNum}`).value = line.quantity;
|
document.getElementById(`manualLineQty${lineNum}`).value = line.quantity;
|
||||||
@ -1498,10 +1522,6 @@ async function openManualEntryMode() {
|
|||||||
addManualLine();
|
addManualLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open modal
|
|
||||||
console.log('Opening manual entry modal...');
|
|
||||||
const manualModal = new bootstrap.Modal(document.getElementById('manualEntryModal'));
|
|
||||||
manualModal.show();
|
|
||||||
console.log('Manual entry modal opened successfully');
|
console.log('Manual entry modal opened successfully');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -1618,19 +1638,30 @@ async function saveManualInvoice() {
|
|||||||
const descriptions = document.getElementsByName('line_description[]');
|
const descriptions = document.getElementsByName('line_description[]');
|
||||||
const quantities = document.getElementsByName('line_quantity[]');
|
const quantities = document.getElementsByName('line_quantity[]');
|
||||||
const prices = document.getElementsByName('line_price[]');
|
const prices = document.getElementsByName('line_price[]');
|
||||||
|
const vatRates = document.getElementsByName('line_vat[]');
|
||||||
|
|
||||||
const lines = [];
|
const lines = [];
|
||||||
for (let i = 0; i < descriptions.length; i++) {
|
for (let i = 0; i < descriptions.length; i++) {
|
||||||
if (descriptions[i].value.trim()) {
|
if (descriptions[i].value.trim()) {
|
||||||
|
const desc = descriptions[i].value;
|
||||||
const qty = parseFloat(quantities[i].value) || 1;
|
const qty = parseFloat(quantities[i].value) || 1;
|
||||||
const price = parseFloat(prices[i].value) || 0;
|
const price = parseFloat(prices[i].value) || 0;
|
||||||
|
const vatRate = parseFloat(vatRates[i].value) || 25.00;
|
||||||
|
|
||||||
|
// Detect VAT code from description
|
||||||
|
let vatCode = 'I25'; // Default: 25% input VAT
|
||||||
|
if (desc.includes('OMVENDT BETALINGSPLIGT') || desc.includes('⚠️ OMVENDT BETALINGSPLIGT')) {
|
||||||
|
vatCode = 'I52'; // Reverse charge - no VAT
|
||||||
|
}
|
||||||
|
|
||||||
lines.push({
|
lines.push({
|
||||||
line_number: i + 1,
|
line_number: i + 1,
|
||||||
description: descriptions[i].value,
|
description: desc,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit_price: price,
|
unit_price: price,
|
||||||
line_total: qty * price,
|
line_total: qty * price,
|
||||||
vat_rate: 25.00
|
vat_rate: vatRate,
|
||||||
|
vat_code: vatCode
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -173,6 +173,15 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<h6>PDF Tekst Preview</h6>
|
<h6>PDF Tekst Preview</h6>
|
||||||
|
|
||||||
|
<!-- File selector for editing mode -->
|
||||||
|
<div class="mb-3" id="editFileSelector" style="display:none;">
|
||||||
|
<label class="form-label">Vælg faktura at vise:</label>
|
||||||
|
<select class="form-select form-select-sm" id="editFileSelect" onchange="loadSelectedFile()">
|
||||||
|
<option value="">-- Vælg fil --</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
<strong>Sådan gør du:</strong><br>
|
<strong>Sådan gør du:</strong><br>
|
||||||
@ -440,16 +449,237 @@ let pdfText = '';
|
|||||||
let selectedText = '';
|
let selectedText = '';
|
||||||
let detectionPatterns = [];
|
let detectionPatterns = [];
|
||||||
let fieldPatterns = {};
|
let fieldPatterns = {};
|
||||||
|
let editingTemplateId = null; // Track if we're editing
|
||||||
|
|
||||||
// Load pending files on page load
|
// Load pending files on page load
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
// Check if we're editing an existing template
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
editingTemplateId = urlParams.get('id');
|
||||||
|
|
||||||
|
if (editingTemplateId) {
|
||||||
|
await loadExistingTemplate(editingTemplateId);
|
||||||
|
} else {
|
||||||
await loadPendingFiles();
|
await loadPendingFiles();
|
||||||
await loadVendors();
|
await loadVendors();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function loadExistingTemplate(templateId) {
|
||||||
|
try {
|
||||||
|
console.log('Loading template:', templateId);
|
||||||
|
|
||||||
|
// Load template data
|
||||||
|
const response = await fetch(`/api/v1/supplier-invoices/templates/${templateId}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await response.json();
|
||||||
|
console.log('Template loaded:', template);
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
console.error('Template not found:', templateId);
|
||||||
|
alert('Template ikke fundet');
|
||||||
|
window.location.href = '/billing/templates';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Template found:', template.template_name);
|
||||||
|
|
||||||
|
// Update page title
|
||||||
|
document.querySelector('h1').innerHTML = `<i class="bi bi-pencil me-2"></i>Rediger Template: ${template.template_name}`;
|
||||||
|
|
||||||
|
// Populate template data
|
||||||
|
document.getElementById('templateName').value = template.template_name;
|
||||||
|
|
||||||
|
// Load vendors and select the correct one
|
||||||
|
await loadVendors();
|
||||||
|
document.getElementById('vendorSelect').value = template.vendor_id;
|
||||||
|
|
||||||
|
// Load detection patterns
|
||||||
|
detectionPatterns = template.detection_patterns || [];
|
||||||
|
|
||||||
|
// Load field patterns
|
||||||
|
fieldPatterns = template.field_mappings || {};
|
||||||
|
|
||||||
|
// Skip to step 3 (patterns) - show the step first
|
||||||
|
document.getElementById('stepContent1').classList.add('d-none');
|
||||||
|
document.getElementById('stepContent2').classList.add('d-none');
|
||||||
|
document.getElementById('stepContent3').classList.remove('d-none');
|
||||||
|
|
||||||
|
document.getElementById('step1').classList.remove('active');
|
||||||
|
document.getElementById('step2').classList.remove('active');
|
||||||
|
document.getElementById('step3').classList.add('active');
|
||||||
|
|
||||||
|
// Wait a bit for DOM to be ready, then populate fields
|
||||||
|
setTimeout(() => {
|
||||||
|
renderFieldPatterns();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Load files from this vendor to show PDF preview
|
||||||
|
const filesResponse = await fetch('/api/v1/pending-supplier-invoice-files');
|
||||||
|
const filesData = await filesResponse.json();
|
||||||
|
|
||||||
|
// Filter files by vendor
|
||||||
|
const vendorFiles = filesData.files.filter(f =>
|
||||||
|
f.vendor_matched_id == template.vendor_id ||
|
||||||
|
f.vendor_name == template.vendor_name
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Found ${vendorFiles.length} files for vendor ${template.vendor_name}`);
|
||||||
|
|
||||||
|
// Show file selector in edit mode and populate it
|
||||||
|
const fileSelector = document.getElementById('editFileSelector');
|
||||||
|
const fileSelect = document.getElementById('editFileSelect');
|
||||||
|
if (fileSelector && fileSelect) {
|
||||||
|
fileSelector.style.display = 'block';
|
||||||
|
fileSelect.innerHTML = '<option value="">-- Vælg fil --</option>';
|
||||||
|
|
||||||
|
vendorFiles.forEach(file => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = file.file_id;
|
||||||
|
option.textContent = `${file.filename} (${file.status})`;
|
||||||
|
fileSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no vendor files, show all files
|
||||||
|
if (vendorFiles.length === 0) {
|
||||||
|
filesData.files.forEach(file => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = file.file_id;
|
||||||
|
option.textContent = `${file.filename} - ${file.vendor_name || 'Ukendt'} (${file.status})`;
|
||||||
|
fileSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vendorFiles.length > 0) {
|
||||||
|
const firstFile = vendorFiles[0];
|
||||||
|
if (fileSelect) {
|
||||||
|
fileSelect.value = firstFile.file_id;
|
||||||
|
}
|
||||||
|
const fileResponse = await fetch(`/api/v1/supplier-invoices/reprocess/${firstFile.file_id}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const fileData = await fileResponse.json();
|
||||||
|
selectedText = fileData.pdf_text || '';
|
||||||
|
pdfText = selectedText; // Set pdfText for pattern testing
|
||||||
|
const pdfPreview = document.getElementById('pdfPreview');
|
||||||
|
if (pdfPreview) {
|
||||||
|
pdfPreview.textContent = selectedText;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No files found for this vendor - loading any file');
|
||||||
|
// Fallback to any file if no vendor-specific files
|
||||||
|
if (filesData.files.length > 0) {
|
||||||
|
const firstFile = filesData.files[0];
|
||||||
|
if (fileSelect) {
|
||||||
|
fileSelect.value = firstFile.file_id;
|
||||||
|
}
|
||||||
|
const fileResponse = await fetch(`/api/v1/supplier-invoices/reprocess/${firstFile.file_id}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const fileData = await fileResponse.json();
|
||||||
|
selectedText = fileData.pdf_text || '';
|
||||||
|
pdfText = selectedText; // Set pdfText for pattern testing
|
||||||
|
const pdfPreview = document.getElementById('pdfPreview');
|
||||||
|
if (pdfPreview) {
|
||||||
|
pdfPreview.textContent = selectedText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Template loaded successfully');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load template:', error);
|
||||||
|
console.error('Error details:', error.message, error.stack);
|
||||||
|
alert(`Kunne ikke hente template: ${error.message}`);
|
||||||
|
window.location.href = '/billing/templates';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load selected file when user changes dropdown
|
||||||
|
async function loadSelectedFile() {
|
||||||
|
const fileSelect = document.getElementById('editFileSelect');
|
||||||
|
if (!fileSelect || !fileSelect.value) {
|
||||||
|
console.log('No file selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = fileSelect.value;
|
||||||
|
console.log(`Loading file ${fileId}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileResponse = await fetch(`/api/v1/supplier-invoices/reprocess/${fileId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fileResponse.ok) {
|
||||||
|
throw new Error(`HTTP ${fileResponse.status}: ${await fileResponse.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileData = await fileResponse.json();
|
||||||
|
selectedText = fileData.pdf_text || '';
|
||||||
|
pdfText = selectedText; // Set pdfText for pattern testing
|
||||||
|
|
||||||
|
const pdfPreview = document.getElementById('pdfPreview');
|
||||||
|
if (pdfPreview) {
|
||||||
|
pdfPreview.textContent = selectedText;
|
||||||
|
console.log(`Loaded ${selectedText.length} characters from file`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load file:', error);
|
||||||
|
alert(`Kunne ikke hente fil: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetectionPatterns() {
|
||||||
|
// Detection patterns are stored in array, show them in UI somehow
|
||||||
|
// For now, just log them - you might want to add a display area
|
||||||
|
console.log('Detection patterns:', detectionPatterns);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFieldPatterns() {
|
||||||
|
// Populate field pattern inputs - check if elements exist first
|
||||||
|
const invoiceNumberPattern = document.getElementById('invoiceNumberPattern');
|
||||||
|
const datePattern = document.getElementById('datePattern');
|
||||||
|
const totalPattern = document.getElementById('totalPattern');
|
||||||
|
const cvrPattern = document.getElementById('cvrPattern');
|
||||||
|
const linesStartPattern = document.getElementById('linesStartPattern');
|
||||||
|
const linesEndPattern = document.getElementById('linesEndPattern');
|
||||||
|
const lineItemPattern = document.getElementById('lineItemPattern');
|
||||||
|
|
||||||
|
if (fieldPatterns.invoice_number && invoiceNumberPattern) {
|
||||||
|
invoiceNumberPattern.value = fieldPatterns.invoice_number.pattern || '';
|
||||||
|
}
|
||||||
|
if (fieldPatterns.invoice_date && datePattern) {
|
||||||
|
datePattern.value = fieldPatterns.invoice_date.pattern || '';
|
||||||
|
}
|
||||||
|
if (fieldPatterns.total_amount && totalPattern) {
|
||||||
|
totalPattern.value = fieldPatterns.total_amount.pattern || '';
|
||||||
|
}
|
||||||
|
if (fieldPatterns.vendor_cvr && cvrPattern) {
|
||||||
|
cvrPattern.value = fieldPatterns.vendor_cvr.pattern || '';
|
||||||
|
}
|
||||||
|
if (fieldPatterns.lines_start && linesStartPattern) {
|
||||||
|
linesStartPattern.value = fieldPatterns.lines_start.pattern || '';
|
||||||
|
}
|
||||||
|
if (fieldPatterns.lines_end && linesEndPattern) {
|
||||||
|
linesEndPattern.value = fieldPatterns.lines_end.pattern || '';
|
||||||
|
}
|
||||||
|
if (fieldPatterns.line_item && lineItemPattern) {
|
||||||
|
lineItemPattern.value = fieldPatterns.line_item.pattern || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Field patterns populated');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPendingFiles() {
|
async function loadPendingFiles() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/supplier-invoices/pending-files');
|
const response = await fetch('/api/v1/pending-supplier-invoice-files');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
const filesList = document.getElementById('filesList');
|
const filesList = document.getElementById('filesList');
|
||||||
@ -597,15 +827,21 @@ function setField(fieldName) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('setField called:', { fieldName, selectedText });
|
||||||
|
|
||||||
// Auto-generate regex pattern based on selected text
|
// Auto-generate regex pattern based on selected text
|
||||||
const pattern = generatePattern(selectedText, fieldName);
|
const pattern = generatePattern(selectedText, fieldName);
|
||||||
|
|
||||||
|
console.log('Generated pattern:', pattern);
|
||||||
|
|
||||||
// Store pattern
|
// Store pattern
|
||||||
fieldPatterns[fieldName] = {
|
fieldPatterns[fieldName] = {
|
||||||
value: selectedText,
|
value: selectedText,
|
||||||
pattern: pattern
|
pattern: pattern
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('Stored in fieldPatterns:', fieldPatterns[fieldName]);
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
if (fieldName === 'invoice_number') {
|
if (fieldName === 'invoice_number') {
|
||||||
document.getElementById('invoiceNumberValue').value = selectedText;
|
document.getElementById('invoiceNumberValue').value = selectedText;
|
||||||
@ -682,56 +918,87 @@ function setLineField(lineFieldType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generatePattern(text, fieldName) {
|
function generatePattern(text, fieldName) {
|
||||||
// Find context before the value in PDF
|
console.log('generatePattern called:', { text, fieldName });
|
||||||
const index = pdfText.indexOf(text);
|
console.log('pdfText length:', pdfText.length);
|
||||||
if (index === -1) return escapeRegex(text);
|
|
||||||
|
|
||||||
// Get 30 chars before for better context
|
// Split selected text into words to find label and value
|
||||||
const before = pdfText.substring(Math.max(0, index - 30), index).trim();
|
const words = text.trim().split(/\s+/);
|
||||||
const words = before.split(/\s+/);
|
console.log('Selected text words:', words);
|
||||||
const lastWord = words[words.length - 1] || '';
|
|
||||||
const secondLastWord = words[words.length - 2] || '';
|
|
||||||
|
|
||||||
// Generate pattern based on field type
|
let label = '';
|
||||||
|
let value = '';
|
||||||
|
|
||||||
|
// For invoice_number, date, amount: first word is usually the label
|
||||||
if (fieldName === 'invoice_number') {
|
if (fieldName === 'invoice_number') {
|
||||||
// Number pattern
|
// Try to find number in selected text
|
||||||
if (/^\d+$/.test(text)) {
|
const numberMatch = text.match(/(\d+)/);
|
||||||
return `${escapeRegex(lastWord)}\\s*(\\d+)`;
|
console.log('Number match:', numberMatch);
|
||||||
|
if (numberMatch) {
|
||||||
|
value = numberMatch[1];
|
||||||
|
// Find word before the number
|
||||||
|
const beforeNumber = text.substring(0, text.indexOf(value)).trim();
|
||||||
|
console.log('Before number:', beforeNumber);
|
||||||
|
const labelWords = beforeNumber.split(/\s+/);
|
||||||
|
console.log('Label words:', labelWords);
|
||||||
|
label = labelWords[labelWords.length - 1] || 'Nummer';
|
||||||
|
console.log('Using label:', label);
|
||||||
|
|
||||||
|
const pattern = `${escapeRegex(label)}\\s+(\\d+)`;
|
||||||
|
console.log('Invoice number pattern:', pattern);
|
||||||
|
return pattern;
|
||||||
|
} else {
|
||||||
|
console.log('No number found in selected text!');
|
||||||
}
|
}
|
||||||
} else if (fieldName === 'invoice_date') {
|
} else if (fieldName === 'invoice_date') {
|
||||||
// Date pattern - very flexible
|
// Find date in selected text
|
||||||
// Detect various date formats: DD/MM-YY, DD-MM-YYYY, DD.MM.YYYY, etc.
|
const dateMatch = text.match(/(\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4})/);
|
||||||
const datePatterns = [
|
if (dateMatch) {
|
||||||
/(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{2,4})/, // DD/MM/YY or DD-MM-YYYY
|
value = dateMatch[1];
|
||||||
/(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{2})/, // DD/MM-YY
|
const beforeDate = text.substring(0, text.indexOf(value)).trim();
|
||||||
/(\d{2,4})[\/\-\.](\d{1,2})[\/\-\.](\d{1,2})/ // YYYY-MM-DD
|
const labelWords = beforeDate.split(/\s+/);
|
||||||
];
|
label = labelWords[labelWords.length - 1] || 'Dato';
|
||||||
|
|
||||||
for (let dp of datePatterns) {
|
const pattern = `${escapeRegex(label)}\\s+(\\d{1,2}[\\/.\\-]\\d{1,2}[\\/.\\-]\\d{2,4})`;
|
||||||
if (dp.test(text)) {
|
console.log('Date pattern:', pattern);
|
||||||
// Use flexible pattern that matches any separator
|
return pattern;
|
||||||
return `${escapeRegex(lastWord)}\\s*(\\d{1,2}[\\/.\\-]\\d{1,2}[\\/.\\-]\\d{2,4})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no pattern matches, try with two words context
|
|
||||||
if (secondLastWord) {
|
|
||||||
return `${escapeRegex(secondLastWord)}\\s+${escapeRegex(lastWord)}\\s*(.+)`;
|
|
||||||
}
|
}
|
||||||
} else if (fieldName === 'total_amount') {
|
} else if (fieldName === 'total_amount') {
|
||||||
// Amount pattern - handle Danish format (1.234,56 or 1234,56)
|
// Find amount in selected text
|
||||||
if (/[\d.,]+/.test(text)) {
|
const amountMatch = text.match(/([\d.,]+)\s*$/);
|
||||||
return `${escapeRegex(lastWord)}\\s*([\\d.,]+)`;
|
if (amountMatch) {
|
||||||
|
value = amountMatch[1];
|
||||||
|
const beforeAmount = text.substring(0, text.indexOf(value)).trim();
|
||||||
|
const labelWords = beforeAmount.split(/\s+/);
|
||||||
|
label = labelWords[labelWords.length - 1] || 'beløb';
|
||||||
|
|
||||||
|
const pattern = `${escapeRegex(label)}\\s+([\\d.,]+)`;
|
||||||
|
console.log('Amount pattern:', pattern);
|
||||||
|
return pattern;
|
||||||
}
|
}
|
||||||
} else if (fieldName === 'cvr') {
|
} else if (fieldName === 'cvr') {
|
||||||
// CVR pattern
|
// Find CVR number (8 digits, possibly with DK prefix)
|
||||||
if (/\d{8}/.test(text)) {
|
const cvrMatch = text.match(/DK(\d{8})|(\d{8})/);
|
||||||
return `${escapeRegex(lastWord)}\\s*(\\d{8})`;
|
if (cvrMatch) {
|
||||||
|
const beforeCvr = text.substring(0, text.indexOf(cvrMatch[1] || cvrMatch[2])).trim();
|
||||||
|
const labelWords = beforeCvr.split(/\s+/);
|
||||||
|
label = labelWords[labelWords.length - 1] || 'CVR';
|
||||||
|
|
||||||
|
const pattern = `${escapeRegex(label)}\\s+\\w*(\\d{8})`;
|
||||||
|
console.log('CVR pattern:', pattern);
|
||||||
|
return pattern;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: exact match with context
|
// Fallback: use first word as label
|
||||||
return `${escapeRegex(lastWord)}\\s*(${escapeRegex(text)})`;
|
if (words.length >= 2) {
|
||||||
|
label = words[0];
|
||||||
|
const pattern = `${escapeRegex(label)}\\s+(.+?)`;
|
||||||
|
console.log('Fallback pattern:', pattern);
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ultimate fallback
|
||||||
|
return escapeRegex(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegex(str) {
|
function escapeRegex(str) {
|
||||||
@ -1023,6 +1290,10 @@ async function saveTemplate() {
|
|||||||
const vendorId = document.getElementById('vendorSelect').value;
|
const vendorId = document.getElementById('vendorSelect').value;
|
||||||
const templateName = document.getElementById('templateName').value;
|
const templateName = document.getElementById('templateName').value;
|
||||||
|
|
||||||
|
console.log('Saving template...', { vendorId, templateName, editingTemplateId });
|
||||||
|
console.log('Detection patterns:', detectionPatterns);
|
||||||
|
console.log('Field patterns:', fieldPatterns);
|
||||||
|
|
||||||
if (!vendorId || !templateName) {
|
if (!vendorId || !templateName) {
|
||||||
alert('Vælg leverandør og angiv template navn');
|
alert('Vælg leverandør og angiv template navn');
|
||||||
return;
|
return;
|
||||||
@ -1034,11 +1305,14 @@ async function saveTemplate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build detection patterns from array
|
// Build detection patterns from array
|
||||||
const detectionPatternsData = detectionPatterns.map(text => ({
|
const detectionPatternsData = detectionPatterns.map(item => {
|
||||||
type: 'text',
|
// Handle both string format (new) and object format (loaded from DB)
|
||||||
pattern: text.trim(),
|
if (typeof item === 'string') {
|
||||||
weight: 0.5
|
return { type: 'text', pattern: item.trim(), weight: 0.5 };
|
||||||
}));
|
} else {
|
||||||
|
return { type: item.type || 'text', pattern: item.pattern, weight: item.weight || 0.5 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Build field mappings from stored patterns
|
// Build field mappings from stored patterns
|
||||||
const fieldMappings = {};
|
const fieldMappings = {};
|
||||||
@ -1091,8 +1365,15 @@ async function saveTemplate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/supplier-invoices/templates', {
|
const url = editingTemplateId
|
||||||
method: 'POST',
|
? `/api/v1/supplier-invoices/templates/${editingTemplateId}`
|
||||||
|
: '/api/v1/supplier-invoices/templates';
|
||||||
|
const method = editingTemplateId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
console.log('Sending request:', { url, method, fieldMappings });
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
vendor_id: parseInt(vendorId),
|
vendor_id: parseInt(vendorId),
|
||||||
@ -1102,10 +1383,15 @@ async function saveTemplate() {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('Response status:', response.status);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
alert(`✅ Template gemt! ID: ${data.template_id}\n\nDu kan nu uploade fakturaer og systemet vil automatisk udtrække data.`);
|
const message = editingTemplateId
|
||||||
window.location.href = '/billing/supplier-invoices';
|
? `✅ Template opdateret!\n\nÆndringerne er gemt.`
|
||||||
|
: `✅ Template gemt! ID: ${data.template_id}\n\nDu kan nu uploade fakturaer og systemet vil automatisk udtrække data.`;
|
||||||
|
alert(message);
|
||||||
|
window.location.href = '/billing/templates';
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(`❌ Fejl: ${error.detail}`);
|
alert(`❌ Fejl: ${error.detail}`);
|
||||||
@ -1126,15 +1412,30 @@ async function testTemplate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build detection patterns from array
|
// Build detection patterns from array
|
||||||
const detectionPatternsData = detectionPatterns.map(text => ({
|
const detectionPatternsData = detectionPatterns.map(item => {
|
||||||
type: 'text',
|
// Handle both string format (new) and object format (loaded from DB)
|
||||||
pattern: text.trim(),
|
if (typeof item === 'string') {
|
||||||
weight: 0.5
|
return { type: 'text', pattern: item.trim(), weight: 0.5 };
|
||||||
}));
|
} else {
|
||||||
|
return { type: item.type || 'text', pattern: item.pattern, weight: item.weight || 0.5 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Build field mappings from stored patterns
|
// Build field mappings - use existing fieldPatterns if loaded from DB
|
||||||
const fieldMappings = {};
|
let fieldMappings = {};
|
||||||
|
|
||||||
|
console.log('fieldPatterns:', fieldPatterns);
|
||||||
|
console.log('Has invoice_number?', fieldPatterns.invoice_number);
|
||||||
|
console.log('Has pattern?', fieldPatterns.invoice_number?.pattern);
|
||||||
|
|
||||||
|
// If fieldPatterns already has the right structure (loaded from DB), use it directly
|
||||||
|
if (fieldPatterns.invoice_number && fieldPatterns.invoice_number.pattern) {
|
||||||
|
console.log('Using fieldPatterns directly from DB');
|
||||||
|
fieldMappings = { ...fieldPatterns };
|
||||||
|
console.log('fieldMappings after copy:', fieldMappings);
|
||||||
|
} else {
|
||||||
|
console.log('Building fieldMappings from form');
|
||||||
|
// Build from form fields (new template creation)
|
||||||
if (fieldPatterns.invoice_number) {
|
if (fieldPatterns.invoice_number) {
|
||||||
fieldMappings.invoice_number = {
|
fieldMappings.invoice_number = {
|
||||||
pattern: fieldPatterns.invoice_number.pattern,
|
pattern: fieldPatterns.invoice_number.pattern,
|
||||||
@ -1157,14 +1458,15 @@ async function testTemplate() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fieldPatterns.cvr) {
|
if (fieldPatterns.vendor_cvr || fieldPatterns.cvr) {
|
||||||
fieldMappings.vendor_cvr = {
|
fieldMappings.vendor_cvr = {
|
||||||
pattern: fieldPatterns.cvr.pattern,
|
pattern: (fieldPatterns.vendor_cvr || fieldPatterns.cvr).pattern,
|
||||||
group: 1
|
group: 1
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add line extraction patterns if provided
|
// Add line extraction patterns from form inputs
|
||||||
const linesStartPattern = document.getElementById('linesStartPattern').value;
|
const linesStartPattern = document.getElementById('linesStartPattern').value;
|
||||||
const linesEndPattern = document.getElementById('linesEndPattern').value;
|
const linesEndPattern = document.getElementById('linesEndPattern').value;
|
||||||
const lineItemPattern = document.getElementById('lineItemPattern').value;
|
const lineItemPattern = document.getElementById('lineItemPattern').value;
|
||||||
@ -1213,9 +1515,15 @@ async function testTemplate() {
|
|||||||
for (let [fieldName, config] of Object.entries(fieldMappings)) {
|
for (let [fieldName, config] of Object.entries(fieldMappings)) {
|
||||||
if (['lines_start', 'lines_end', 'line_item'].includes(fieldName)) continue;
|
if (['lines_start', 'lines_end', 'line_item'].includes(fieldName)) continue;
|
||||||
|
|
||||||
|
console.log(`Testing field ${fieldName}:`, config);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const regex = new RegExp(config.pattern, 'i');
|
const regex = new RegExp(config.pattern, 'i');
|
||||||
|
console.log(`Regex for ${fieldName}:`, regex);
|
||||||
|
|
||||||
const match = pdfText.match(regex);
|
const match = pdfText.match(regex);
|
||||||
|
console.log(`Match result for ${fieldName}:`, match);
|
||||||
|
|
||||||
if (match && match[config.group]) {
|
if (match && match[config.group]) {
|
||||||
extractedHtml += `<li>✅ <strong>${fieldName}:</strong> "${match[config.group].trim()}"</li>`;
|
extractedHtml += `<li>✅ <strong>${fieldName}:</strong> "${match[config.group].trim()}"</li>`;
|
||||||
extractedCount++;
|
extractedCount++;
|
||||||
@ -1228,12 +1536,145 @@ async function testTemplate() {
|
|||||||
}
|
}
|
||||||
extractedHtml += '</ul>';
|
extractedHtml += '</ul>';
|
||||||
|
|
||||||
|
// Test line item extraction
|
||||||
|
let lineItemsHtml = '';
|
||||||
|
if (fieldMappings.lines_start && fieldMappings.lines_end && fieldMappings.line_item) {
|
||||||
|
lineItemsHtml = '<h6 class="mt-3">Varelinjer:</h6>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startMatch = pdfText.match(new RegExp(fieldMappings.lines_start.pattern, 'i'));
|
||||||
|
const endMatch = pdfText.match(new RegExp(fieldMappings.lines_end.pattern, 'i'));
|
||||||
|
|
||||||
|
if (startMatch && endMatch) {
|
||||||
|
const startPos = pdfText.indexOf(startMatch[0]) + startMatch[0].length;
|
||||||
|
const endPos = pdfText.indexOf(endMatch[0]);
|
||||||
|
const lineSection = pdfText.substring(startPos, endPos);
|
||||||
|
|
||||||
|
// Check if we have separate item and price patterns (ALSO style)
|
||||||
|
if (fieldMappings.line_price) {
|
||||||
|
// Two-pattern extraction: item info + price info
|
||||||
|
const itemRegex = new RegExp(fieldMappings.line_item.pattern, 'gim');
|
||||||
|
const priceRegex = new RegExp(fieldMappings.line_price.pattern, 'gim');
|
||||||
|
|
||||||
|
const itemMatches = [...lineSection.matchAll(itemRegex)];
|
||||||
|
const priceMatches = [...lineSection.matchAll(priceRegex)];
|
||||||
|
|
||||||
|
console.log('Item matches:', itemMatches.length, 'Price matches:', priceMatches.length);
|
||||||
|
|
||||||
|
if (itemMatches.length > 0 && priceMatches.length > 0) {
|
||||||
|
lineItemsHtml += `<p class="mb-2">✅ Fandt ${Math.min(itemMatches.length, priceMatches.length)} varelinjer:</p>`;
|
||||||
|
lineItemsHtml += '<div class="table-responsive"><table class="table table-sm table-bordered"><thead><tr>';
|
||||||
|
lineItemsHtml += '<th>Position</th><th>Item</th><th>Description</th><th>Qty</th><th>Price</th><th>Total</th><th>VAT</th>';
|
||||||
|
lineItemsHtml += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
// Combine item and price matches
|
||||||
|
const maxLines = Math.min(5, itemMatches.length, priceMatches.length);
|
||||||
|
for (let i = 0; i < maxLines; i++) {
|
||||||
|
const item = itemMatches[i];
|
||||||
|
const price = priceMatches[i];
|
||||||
|
|
||||||
|
// Check for VAT markers between this price and next item
|
||||||
|
const priceEndPos = price.index + price[0].length;
|
||||||
|
let nextItemStartPos = lineSection.length;
|
||||||
|
|
||||||
|
// Find start of next item (if exists)
|
||||||
|
if (i + 1 < itemMatches.length) {
|
||||||
|
nextItemStartPos = itemMatches[i + 1].index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check section between price and next item
|
||||||
|
const betweenSection = lineSection.substring(priceEndPos, nextItemStartPos);
|
||||||
|
|
||||||
|
console.log(`Item ${i} (pos ${item[1]}):`, {
|
||||||
|
priceEndPos,
|
||||||
|
nextItemStartPos,
|
||||||
|
betweenLength: betweenSection.length,
|
||||||
|
betweenPreview: betweenSection.substring(0, 100)
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasReverseCharge = /omvendt.*betalingspligt/i.test(betweenSection);
|
||||||
|
const hasCopydan = /copydan/i.test(betweenSection);
|
||||||
|
|
||||||
|
console.log(` VAT checks: Omvendt=${hasReverseCharge}, Copydan=${hasCopydan}`);
|
||||||
|
|
||||||
|
let vatMarker = '';
|
||||||
|
if (hasReverseCharge && hasCopydan) {
|
||||||
|
vatMarker = '<span class="badge bg-warning text-dark">Omvendt</span> <span class="badge bg-info">Copydan</span>';
|
||||||
|
} else if (hasReverseCharge) {
|
||||||
|
vatMarker = '<span class="badge bg-warning text-dark">Omvendt</span>';
|
||||||
|
} else if (hasCopydan) {
|
||||||
|
vatMarker = '<span class="badge bg-info">Copydan</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
lineItemsHtml += '<tr>';
|
||||||
|
lineItemsHtml += `<td>${item[1]}</td>`; // position
|
||||||
|
lineItemsHtml += `<td>${item[2]}</td>`; // item_number
|
||||||
|
lineItemsHtml += `<td>${item[3] ? item[3].trim().substring(0, 40) : ''}</td>`; // description (truncated)
|
||||||
|
lineItemsHtml += `<td>${price[1]}</td>`; // quantity
|
||||||
|
lineItemsHtml += `<td>${price[2]}</td>`; // unit_price
|
||||||
|
lineItemsHtml += `<td>${price[3]}</td>`; // total_price
|
||||||
|
lineItemsHtml += `<td>${vatMarker}</td>`; // vat marker
|
||||||
|
lineItemsHtml += '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
lineItemsHtml += '</tbody></table></div>';
|
||||||
|
const totalLines = Math.min(itemMatches.length, priceMatches.length);
|
||||||
|
if (totalLines > 5) {
|
||||||
|
lineItemsHtml += `<p class="text-muted"><small>... og ${totalLines - 5} linjer mere</small></p>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lineItemsHtml += `<p class="text-warning">❌ Fandt ${itemMatches.length} item-linjer og ${priceMatches.length} pris-linjer</p>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single-pattern extraction (old style)
|
||||||
|
const lineRegex = new RegExp(fieldMappings.line_item.pattern, 'gim');
|
||||||
|
const lines = [...lineSection.matchAll(lineRegex)];
|
||||||
|
|
||||||
|
if (lines.length > 0) {
|
||||||
|
lineItemsHtml += `<p class="mb-2">✅ Fandt ${lines.length} varelinjer:</p>`;
|
||||||
|
lineItemsHtml += '<div class="table-responsive"><table class="table table-sm table-bordered"><thead><tr>';
|
||||||
|
|
||||||
|
const fields = fieldMappings.line_item.fields || ['position', 'item_number', 'description', 'quantity', 'unit_price', 'total_price'];
|
||||||
|
fields.forEach(f => {
|
||||||
|
lineItemsHtml += `<th>${f}</th>`;
|
||||||
|
});
|
||||||
|
lineItemsHtml += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
// Show first 5 lines
|
||||||
|
lines.slice(0, 5).forEach(match => {
|
||||||
|
lineItemsHtml += '<tr>';
|
||||||
|
for (let i = 1; i <= fields.length; i++) {
|
||||||
|
lineItemsHtml += `<td>${match[i] ? match[i].trim() : ''}</td>`;
|
||||||
|
}
|
||||||
|
lineItemsHtml += '</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
lineItemsHtml += '</tbody></table></div>';
|
||||||
|
if (lines.length > 5) {
|
||||||
|
lineItemsHtml += `<p class="text-muted"><small>... og ${lines.length - 5} linjer mere</small></p>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lineItemsHtml += '<p class="text-warning">❌ Ingen linjer fundet med pattern</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lineItemsHtml += `<p class="text-warning">⚠️ Start eller slut marker ikke fundet</p>`;
|
||||||
|
if (!startMatch) lineItemsHtml += `<small>Start pattern: "${fieldMappings.lines_start.pattern}" ikke fundet</small><br>`;
|
||||||
|
if (!endMatch) lineItemsHtml += `<small>Slut pattern: "${fieldMappings.lines_end.pattern}" ikke fundet</small>`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
lineItemsHtml += `<p class="text-danger">❌ Fejl: ${e.message}</p>`;
|
||||||
|
console.error('Line extraction error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show results
|
// Show results
|
||||||
testResults.innerHTML = `
|
testResults.innerHTML = `
|
||||||
<h5>${matched ? '✅' : '❌'} Template ${matched ? 'MATCHER' : 'MATCHER IKKE'}</h5>
|
<h5>${matched ? '✅' : '❌'} Template ${matched ? 'MATCHER' : 'MATCHER IKKE'}</h5>
|
||||||
<p><strong>Confidence:</strong> ${(confidence * 100).toFixed(0)}% (threshold: 70%)</p>
|
<p><strong>Confidence:</strong> ${(confidence * 100).toFixed(0)}% (threshold: 70%)</p>
|
||||||
${detectionHtml}
|
${detectionHtml}
|
||||||
${extractedHtml}
|
${extractedHtml}
|
||||||
|
${lineItemsHtml}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (matched && extractedCount > 0) {
|
if (matched && extractedCount > 0) {
|
||||||
|
|||||||
@ -163,6 +163,9 @@ async function loadTemplates() {
|
|||||||
</small>
|
</small>
|
||||||
</p>
|
</p>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="editTemplate(${template.template_id})">
|
||||||
|
<i class="bi bi-pencil"></i> Rediger
|
||||||
|
</button>
|
||||||
<button class="btn btn-sm btn-info" onclick="openTestModal(${template.template_id}, '${template.template_name}')">
|
<button class="btn btn-sm btn-info" onclick="openTestModal(${template.template_id}, '${template.template_name}')">
|
||||||
<i class="bi bi-flask"></i> Test
|
<i class="bi bi-flask"></i> Test
|
||||||
</button>
|
</button>
|
||||||
@ -181,28 +184,51 @@ async function loadTemplates() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPendingFiles() {
|
async function loadPendingFiles(vendorId = null) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/supplier-invoices/pending-files');
|
const response = await fetch('/api/v1/pending-supplier-invoice-files');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
const select = document.getElementById('testFileSelect');
|
const select = document.getElementById('testFileSelect');
|
||||||
select.innerHTML = '<option value="">-- Vælg fil --</option>';
|
select.innerHTML = '<option value="">-- Vælg fil --</option>';
|
||||||
|
|
||||||
data.files.forEach(file => {
|
// Filter by vendor if provided
|
||||||
|
let files = data.files;
|
||||||
|
if (vendorId) {
|
||||||
|
files = files.filter(f => f.vendor_matched_id == vendorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
select.innerHTML += `<option value="${file.file_id}">${file.filename}</option>`;
|
select.innerHTML += `<option value="${file.file_id}">${file.filename}</option>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show message if no files for this vendor
|
||||||
|
if (vendorId && files.length === 0) {
|
||||||
|
select.innerHTML += '<option value="" disabled>Ingen filer fra denne leverandør</option>';
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load files:', error);
|
console.error('Failed to load files:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTestModal(templateId, templateName) {
|
async function openTestModal(templateId, templateName) {
|
||||||
currentTemplateId = templateId;
|
currentTemplateId = templateId;
|
||||||
document.getElementById('modalTemplateName').textContent = templateName;
|
document.getElementById('modalTemplateName').textContent = templateName;
|
||||||
document.getElementById('testResultsContainer').classList.add('d-none');
|
document.getElementById('testResultsContainer').classList.add('d-none');
|
||||||
document.getElementById('testFileSelect').value = '';
|
document.getElementById('testFileSelect').value = '';
|
||||||
|
|
||||||
|
// Load template to get vendor_id
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/supplier-invoices/templates/${templateId}`);
|
||||||
|
const template = await response.json();
|
||||||
|
|
||||||
|
// Reload files filtered by this template's vendor
|
||||||
|
await loadPendingFiles(template.vendor_id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load template:', error);
|
||||||
|
await loadPendingFiles(); // Fallback to all files
|
||||||
|
}
|
||||||
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('testModal'));
|
const modal = new bootstrap.Modal(document.getElementById('testModal'));
|
||||||
modal.show();
|
modal.show();
|
||||||
}
|
}
|
||||||
@ -357,6 +383,11 @@ async function deleteTemplate(templateId) {
|
|||||||
alert('❌ Kunne ikke slette template');
|
alert('❌ Kunne ikke slette template');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function editTemplate(templateId) {
|
||||||
|
// Redirect to template builder with template ID
|
||||||
|
window.location.href = `/billing/template-builder?id=${templateId}`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -35,7 +35,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Ollama AI Integration
|
# Ollama AI Integration
|
||||||
OLLAMA_ENDPOINT: str = "http://ai_direct.cs.blaahund.dk"
|
OLLAMA_ENDPOINT: str = "http://ai_direct.cs.blaahund.dk"
|
||||||
OLLAMA_MODEL: str = "qwen2.5:3b" # Hurtigere model til JSON extraction
|
OLLAMA_MODEL: str = "qwen2.5-coder:7b" # qwen2.5-coder fungerer bedre til JSON udtrækning
|
||||||
|
|
||||||
# Company Info
|
# Company Info
|
||||||
OWN_CVR: str = "29522790" # BMC Denmark ApS - ignore when detecting vendors
|
OWN_CVR: str = "29522790" # BMC Denmark ApS - ignore when detecting vendors
|
||||||
|
|||||||
@ -146,7 +146,39 @@ Output: {
|
|||||||
try:
|
try:
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
# Detect if using qwen3 model (requires Chat API)
|
||||||
|
use_chat_api = self.model.startswith('qwen3')
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=1000.0) as client:
|
async with httpx.AsyncClient(timeout=1000.0) as client:
|
||||||
|
if use_chat_api:
|
||||||
|
# qwen3 models use Chat API format
|
||||||
|
logger.info(f"🤖 Using Chat API for {self.model}")
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.endpoint}/api/chat",
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": self.system_prompt
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": f"NU SKAL DU UDTRÆKKE DATA FRA DENNE FAKTURA:\n{text}\n\nVIGTIGT: Dit svar skal STARTE med {{ og SLUTTE med }} - ingen forklaring før eller efter JSON!"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stream": False,
|
||||||
|
"format": "json",
|
||||||
|
"options": {
|
||||||
|
"temperature": 0.1,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"num_predict": 2000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# qwen2.5 and other models use Generate API format
|
||||||
|
logger.info(f"🤖 Using Generate API for {self.model}")
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{self.endpoint}/api/generate",
|
f"{self.endpoint}/api/generate",
|
||||||
json={
|
json={
|
||||||
@ -165,6 +197,33 @@ Output: {
|
|||||||
raise Exception(f"Ollama returned status {response.status_code}: {response.text}")
|
raise Exception(f"Ollama returned status {response.status_code}: {response.text}")
|
||||||
|
|
||||||
result = response.json()
|
result = response.json()
|
||||||
|
|
||||||
|
# Extract response based on API type
|
||||||
|
if use_chat_api:
|
||||||
|
# qwen3 models sometimes put the actual response in "thinking" field
|
||||||
|
raw_response = result.get("message", {}).get("content", "")
|
||||||
|
thinking = result.get("message", {}).get("thinking", "")
|
||||||
|
|
||||||
|
# If content is empty but thinking has data, try to extract JSON from thinking
|
||||||
|
if not raw_response and thinking:
|
||||||
|
logger.info(f"💭 Content empty, attempting to extract JSON from thinking field (length: {len(thinking)})")
|
||||||
|
# Try to find JSON block in thinking text
|
||||||
|
json_start = thinking.find('{')
|
||||||
|
json_end = thinking.rfind('}') + 1
|
||||||
|
if json_start >= 0 and json_end > json_start:
|
||||||
|
potential_json = thinking[json_start:json_end]
|
||||||
|
logger.info(f"📦 Found potential JSON in thinking field (length: {len(potential_json)})")
|
||||||
|
raw_response = potential_json
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ No JSON found in thinking field, using full thinking as fallback")
|
||||||
|
raw_response = thinking
|
||||||
|
elif thinking:
|
||||||
|
logger.info(f"💭 Model thinking (length: {len(thinking)})")
|
||||||
|
|
||||||
|
# DEBUG: Log full result structure
|
||||||
|
logger.info(f"📊 Chat API result keys: {list(result.keys())}")
|
||||||
|
logger.info(f"📊 Message keys: {list(result.get('message', {}).keys())}")
|
||||||
|
else:
|
||||||
raw_response = result.get("response", "")
|
raw_response = result.get("response", "")
|
||||||
|
|
||||||
logger.info(f"✅ Ollama extraction completed (response length: {len(raw_response)})")
|
logger.info(f"✅ Ollama extraction completed (response length: {len(raw_response)})")
|
||||||
@ -243,12 +302,16 @@ Output: {
|
|||||||
def _parse_json_response(self, response: str) -> Dict:
|
def _parse_json_response(self, response: str) -> Dict:
|
||||||
"""Parse JSON from LLM response with improved error handling"""
|
"""Parse JSON from LLM response with improved error handling"""
|
||||||
try:
|
try:
|
||||||
|
# Log preview of response for debugging
|
||||||
|
logger.info(f"🔍 Response preview (first 500 chars): {response[:500]}")
|
||||||
|
|
||||||
# Find JSON in response (between first { and last })
|
# Find JSON in response (between first { and last })
|
||||||
start = response.find('{')
|
start = response.find('{')
|
||||||
end = response.rfind('}') + 1
|
end = response.rfind('}') + 1
|
||||||
|
|
||||||
if start >= 0 and end > start:
|
if start >= 0 and end > start:
|
||||||
json_str = response[start:end]
|
json_str = response[start:end]
|
||||||
|
logger.info(f"🔍 Extracted JSON string length: {len(json_str)}, starts at position {start}")
|
||||||
|
|
||||||
# Try to fix common JSON issues
|
# Try to fix common JSON issues
|
||||||
# Remove trailing commas before } or ]
|
# Remove trailing commas before } or ]
|
||||||
|
|||||||
@ -20,13 +20,20 @@ class TemplateService:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.templates_cache = {}
|
self.templates_cache = {}
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
def _ensure_loaded(self):
|
||||||
|
"""Lazy load templates on first use"""
|
||||||
|
if not self._initialized:
|
||||||
|
logger.info("🔄 Lazy loading templates...")
|
||||||
self._load_templates()
|
self._load_templates()
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
def _load_templates(self):
|
def _load_templates(self):
|
||||||
"""Load all active templates into cache"""
|
"""Load all active templates into cache"""
|
||||||
try:
|
try:
|
||||||
templates = execute_query(
|
templates = execute_query(
|
||||||
"""SELECT t.*, v.name as vendor_name, v.cvr as vendor_cvr
|
"""SELECT t.*, v.name as vendor_name, v.cvr_number as vendor_cvr
|
||||||
FROM supplier_invoice_templates t
|
FROM supplier_invoice_templates t
|
||||||
LEFT JOIN vendors v ON t.vendor_id = v.id
|
LEFT JOIN vendors v ON t.vendor_id = v.id
|
||||||
WHERE t.is_active = TRUE"""
|
WHERE t.is_active = TRUE"""
|
||||||
@ -46,12 +53,17 @@ class TemplateService:
|
|||||||
Find best matching template for PDF text
|
Find best matching template for PDF text
|
||||||
Returns: (template_id, confidence_score)
|
Returns: (template_id, confidence_score)
|
||||||
"""
|
"""
|
||||||
|
self._ensure_loaded() # Lazy load templates
|
||||||
|
|
||||||
|
logger.info(f"🔍 Matching against {len(self.templates_cache)} templates")
|
||||||
|
|
||||||
best_match = None
|
best_match = None
|
||||||
best_score = 0.0
|
best_score = 0.0
|
||||||
pdf_text_lower = pdf_text.lower()
|
pdf_text_lower = pdf_text.lower()
|
||||||
|
|
||||||
for template_id, template in self.templates_cache.items():
|
for template_id, template in self.templates_cache.items():
|
||||||
score = self._calculate_match_score(pdf_text_lower, template)
|
score = self._calculate_match_score(pdf_text_lower, template)
|
||||||
|
logger.debug(f" Template {template_id} ({template['template_name']}): {score:.2f}")
|
||||||
|
|
||||||
if score > best_score:
|
if score > best_score:
|
||||||
best_score = score
|
best_score = score
|
||||||
@ -59,6 +71,8 @@ class TemplateService:
|
|||||||
|
|
||||||
if best_match:
|
if best_match:
|
||||||
logger.info(f"✅ Matched template {best_match} ({self.templates_cache[best_match]['template_name']}) with {best_score:.0%} confidence")
|
logger.info(f"✅ Matched template {best_match} ({self.templates_cache[best_match]['template_name']}) with {best_score:.0%} confidence")
|
||||||
|
else:
|
||||||
|
logger.info(f"⚠️ No template matched (best score: {best_score:.2f})")
|
||||||
|
|
||||||
return best_match, best_score
|
return best_match, best_score
|
||||||
|
|
||||||
@ -96,6 +110,8 @@ class TemplateService:
|
|||||||
|
|
||||||
def extract_fields(self, pdf_text: str, template_id: int) -> Dict:
|
def extract_fields(self, pdf_text: str, template_id: int) -> Dict:
|
||||||
"""Extract invoice fields using template's regex patterns"""
|
"""Extract invoice fields using template's regex patterns"""
|
||||||
|
self._ensure_loaded() # Lazy load templates
|
||||||
|
|
||||||
template = self.templates_cache.get(template_id)
|
template = self.templates_cache.get(template_id)
|
||||||
if not template:
|
if not template:
|
||||||
logger.warning(f"⚠️ Template {template_id} not found in cache")
|
logger.warning(f"⚠️ Template {template_id} not found in cache")
|
||||||
@ -124,6 +140,8 @@ class TemplateService:
|
|||||||
|
|
||||||
def extract_line_items(self, pdf_text: str, template_id: int) -> List[Dict]:
|
def extract_line_items(self, pdf_text: str, template_id: int) -> List[Dict]:
|
||||||
"""Extract invoice line items using template's line patterns"""
|
"""Extract invoice line items using template's line patterns"""
|
||||||
|
self._ensure_loaded() # Lazy load templates
|
||||||
|
|
||||||
template = self.templates_cache.get(template_id)
|
template = self.templates_cache.get(template_id)
|
||||||
if not template:
|
if not template:
|
||||||
logger.warning(f"⚠️ Template {template_id} not found in cache")
|
logger.warning(f"⚠️ Template {template_id} not found in cache")
|
||||||
@ -227,6 +245,7 @@ class TemplateService:
|
|||||||
quantity = None
|
quantity = None
|
||||||
unit_price = None
|
unit_price = None
|
||||||
total_price = None
|
total_price = None
|
||||||
|
vat_note = None # For "Omvendt betalingspligt" etc.
|
||||||
|
|
||||||
for j in range(i+1, min(i+10, len(lines_arr))):
|
for j in range(i+1, min(i+10, len(lines_arr))):
|
||||||
price_line = lines_arr[j].strip()
|
price_line = lines_arr[j].strip()
|
||||||
@ -236,11 +255,20 @@ class TemplateService:
|
|||||||
quantity = price_match.group(1)
|
quantity = price_match.group(1)
|
||||||
unit_price = price_match.group(2).replace(',', '.')
|
unit_price = price_match.group(2).replace(',', '.')
|
||||||
total_price = price_match.group(3).replace(',', '.')
|
total_price = price_match.group(3).replace(',', '.')
|
||||||
|
|
||||||
|
# Check next 3 lines for VAT markers
|
||||||
|
for k in range(j+1, min(j+4, len(lines_arr))):
|
||||||
|
vat_line = lines_arr[k].strip().lower()
|
||||||
|
if 'omvendt' in vat_line and 'betalingspligt' in vat_line:
|
||||||
|
vat_note = "reverse_charge"
|
||||||
|
logger.debug(f"⚠️ Found reverse charge marker for item {item_number}")
|
||||||
|
elif 'copydan' in vat_line:
|
||||||
|
vat_note = "copydan_included"
|
||||||
break
|
break
|
||||||
|
|
||||||
# Kun tilføj hvis vi fandt priser
|
# Kun tilføj hvis vi fandt priser
|
||||||
if quantity and unit_price:
|
if quantity and unit_price:
|
||||||
items.append({
|
item_data = {
|
||||||
'line_number': len(items) + 1,
|
'line_number': len(items) + 1,
|
||||||
'position': position,
|
'position': position,
|
||||||
'item_number': item_number,
|
'item_number': item_number,
|
||||||
@ -249,8 +277,14 @@ class TemplateService:
|
|||||||
'unit_price': unit_price,
|
'unit_price': unit_price,
|
||||||
'total_price': total_price,
|
'total_price': total_price,
|
||||||
'raw_text': f"{line} ... {quantity}ST {unit_price} {total_price}"
|
'raw_text': f"{line} ... {quantity}ST {unit_price} {total_price}"
|
||||||
})
|
}
|
||||||
logger.info(f"✅ Multi-line item: {item_number} - {description[:30]}... ({quantity}ST @ {unit_price})")
|
|
||||||
|
# Add VAT note if found
|
||||||
|
if vat_note:
|
||||||
|
item_data['vat_note'] = vat_note
|
||||||
|
|
||||||
|
items.append(item_data)
|
||||||
|
logger.info(f"✅ Multi-line item: {item_number} - {description[:30]}... ({quantity}ST @ {unit_price}){' [REVERSE CHARGE]' if vat_note == 'reverse_charge' else ''}")
|
||||||
|
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
@ -264,12 +298,13 @@ class TemplateService:
|
|||||||
def log_usage(self, template_id: int, file_id: int, matched: bool,
|
def log_usage(self, template_id: int, file_id: int, matched: bool,
|
||||||
confidence: float, fields: Dict):
|
confidence: float, fields: Dict):
|
||||||
"""Log template usage for statistics"""
|
"""Log template usage for statistics"""
|
||||||
|
import json
|
||||||
try:
|
try:
|
||||||
execute_insert(
|
execute_insert(
|
||||||
"""INSERT INTO template_usage_log
|
"""INSERT INTO template_usage_log
|
||||||
(template_id, file_id, matched, confidence, fields_extracted)
|
(template_id, file_id, matched, confidence, fields_extracted)
|
||||||
VALUES (%s, %s, %s, %s, %s)""",
|
VALUES (%s, %s, %s, %s, %s)""",
|
||||||
(template_id, file_id, matched, confidence, fields)
|
(template_id, file_id, matched, confidence, json.dumps(fields))
|
||||||
)
|
)
|
||||||
|
|
||||||
if matched:
|
if matched:
|
||||||
@ -298,7 +333,8 @@ class TemplateService:
|
|||||||
def reload_templates(self):
|
def reload_templates(self):
|
||||||
"""Reload templates from database"""
|
"""Reload templates from database"""
|
||||||
self.templates_cache = {}
|
self.templates_cache = {}
|
||||||
self._load_templates()
|
self._initialized = False
|
||||||
|
self._ensure_loaded()
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
|
|||||||
@ -51,6 +51,8 @@ services:
|
|||||||
# Override database URL to point to postgres service
|
# Override database URL to point to postgres service
|
||||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
|
||||||
- ENABLE_RELOAD=false
|
- ENABLE_RELOAD=false
|
||||||
|
- OLLAMA_MODEL=qwen3:4b # Bruger Chat API format
|
||||||
|
- OLLAMA_MODEL_FALLBACK=qwen2.5:3b # Backup model
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
|||||||
71
docker-compose.yml.bak2
Normal file
71
docker-compose.yml.bak2
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL Database
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: bmc-hub-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-bmc_hub}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-bmc_hub}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-bmc_hub}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./migrations/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5433}:5432"
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-bmc_hub}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- bmc-hub-network
|
||||||
|
|
||||||
|
# FastAPI Application
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: bmc-hub-api
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-8001}:8000"
|
||||||
|
volumes:
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
- ./static:/app/static
|
||||||
|
- ./data:/app/data
|
||||||
|
# Mount for local development - live code reload
|
||||||
|
- ./app:/app/app:ro
|
||||||
|
- ./main.py:/app/main.py:ro
|
||||||
|
- ./scripts:/app/scripts:ro
|
||||||
|
# Mount OmniSync database for import (read-only)
|
||||||
|
- /Users/christianthomas/pakkemodtagelse/data:/omnisync_data:ro
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
# Override database URL to point to postgres service
|
||||||
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
|
||||||
|
- ENABLE_RELOAD=false
|
||||||
|
- OLLAMA_MODEL=qwen3:4b
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- bmc-hub-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bmc-hub-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
71
docker-compose.yml.bak3
Normal file
71
docker-compose.yml.bak3
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL Database
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: bmc-hub-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-bmc_hub}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-bmc_hub}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-bmc_hub}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./migrations/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5433}:5432"
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-bmc_hub}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- bmc-hub-network
|
||||||
|
|
||||||
|
# FastAPI Application
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: bmc-hub-api
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-8001}:8000"
|
||||||
|
volumes:
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
- ./static:/app/static
|
||||||
|
- ./data:/app/data
|
||||||
|
# Mount for local development - live code reload
|
||||||
|
- ./app:/app/app:ro
|
||||||
|
- ./main.py:/app/main.py:ro
|
||||||
|
- ./scripts:/app/scripts:ro
|
||||||
|
# Mount OmniSync database for import (read-only)
|
||||||
|
- /Users/christianthomas/pakkemodtagelse/data:/omnisync_data:ro
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
# Override database URL to point to postgres service
|
||||||
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
|
||||||
|
- ENABLE_RELOAD=false
|
||||||
|
- OLLAMA_MODEL=qwen2.5:3b
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- bmc-hub-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bmc-hub-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
Loading…
Reference in New Issue
Block a user