feat: Enhance billing frontend with Jinja2 templates and improve invoice handling

- Updated billing frontend views to use Jinja2 templates for rendering HTML pages.
- Added support for displaying supplier invoices, template builder, and templates list with titles.
- Introduced a new configuration setting for company CVR number.
- Enhanced OllamaService to support credit notes in invoice extraction, including detailed JSON output format.
- Improved PDF text extraction using pdfplumber for better layout handling.
- Added a modal for editing vendor details with comprehensive fields and validation.
- Implemented invoice loading and display functionality in vendor detail view.
- Updated vendor management to remove priority handling and improve error messaging.
- Added tests for AI analyze endpoint and CVR filtering to ensure correct behavior.
- Created migration script to support credit notes in the database schema.
This commit is contained in:
Christian 2025-12-08 09:15:52 +01:00
parent dcb4d8a280
commit 18b0fe9c05
14 changed files with 3100 additions and 630 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -3,25 +3,36 @@ Billing Frontend Views
Serves HTML pages for billing features
"""
from fastapi import APIRouter
from fastapi.responses import FileResponse
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/billing/supplier-invoices")
async def supplier_invoices_page():
@router.get("/billing/supplier-invoices", response_class=HTMLResponse)
async def supplier_invoices_page(request: Request):
"""Supplier invoices (kassekladde) page"""
return FileResponse("app/billing/frontend/supplier_invoices.html")
return templates.TemplateResponse("billing/frontend/supplier_invoices.html", {
"request": request,
"title": "Kassekladde"
})
@router.get("/billing/template-builder")
async def template_builder_page():
@router.get("/billing/template-builder", response_class=HTMLResponse)
async def template_builder_page(request: Request):
"""Template builder for supplier invoice extraction"""
return FileResponse("app/billing/frontend/template_builder.html")
return templates.TemplateResponse("billing/frontend/template_builder.html", {
"request": request,
"title": "Template Builder"
})
@router.get("/billing/templates")
async def templates_list_page():
@router.get("/billing/templates", response_class=HTMLResponse)
async def templates_list_page(request: Request):
"""Templates list and management page"""
return FileResponse("app/billing/frontend/templates_list.html")
return templates.TemplateResponse("billing/frontend/templates_list.html", {
"request": request,
"title": "Templates"
})

View File

@ -37,6 +37,9 @@ class Settings(BaseSettings):
OLLAMA_ENDPOINT: str = "http://ai_direct.cs.blaahund.dk"
OLLAMA_MODEL: str = "qwen2.5:3b" # Hurtigere model til JSON extraction
# Company Info
OWN_CVR: str = "29522790" # BMC Denmark ApS - ignore when detecting vendors
# File Upload
UPLOAD_DIR: str = "uploads"
MAX_FILE_SIZE_MB: int = 50

View File

@ -27,37 +27,52 @@ class OllamaService:
def _build_system_prompt(self) -> str:
"""Build Danish system prompt for invoice extraction with CVR"""
return """Du er en ekspert i at læse og udtrække strukturerede data fra danske fakturaer og leverandørdokumenter.
return """Du er en ekspert i at læse og udtrække strukturerede data fra danske fakturaer, kreditnotaer og leverandørdokumenter.
VIGTIGE REGLER:
1. Returner KUN gyldig JSON - ingen forklaring eller ekstra tekst
2. Hvis et felt ikke findes, sæt det til null
3. Beregn confidence baseret hvor sikker du er hvert felt (0.0-1.0)
4. Datoer skal være i format YYYY-MM-DD
5. Tal skal være decimaler (brug . som decimalseparator)
5. DANSKE PRISFORMATER:
- Tusind-separator kan være . (punkt) eller mellemrum: "5.965,18" eller "5 965,18"
- Decimal-separator er , (komma): "1.234,56 kr"
- I JSON output skal du bruge . (punkt) som decimal: 1234.56
- Eksempel: "5.965,18 kr" 5965.18 i JSON
- Eksempel: "1.234,56 DKK" 1234.56 i JSON
6. CVR-nummer skal være 8 cifre uden mellemrum
7. Moms/VAT skal udtrækkes fra hver linje hvis muligt
8. DOKUMENTTYPE DETEKTION:
- "invoice" = Almindelig faktura
- "credit_note" = Kreditnota (refusion, tilbagebetaling, korrektion)
- Kig efter ord som: "Kreditnota", "Credit Note", "Refusion", "Tilbagebetaling", "Godtgørelse"
9. BELØB OG FORTEGN (ABSOLUT KRITISK):
- **ALMINDELIGE FAKTURAER**: Alle beløb skal være POSITIVE tal (total_amount > 0, line_total > 0)
- **KREDITNOTAER**: Alle beløb skal være NEGATIVE tal (total_amount < 0, line_total < 0)
- Hvis dokumentet siger "Faktura" document_type: "invoice" POSITIVE beløb
- Hvis dokumentet siger "Kreditnota" document_type: "credit_note" NEGATIVE beløb
JSON format skal være:
{
"document_type": "invoice",
"invoice_number": "fakturanummer",
"document_type": "invoice" eller "credit_note",
"invoice_number": "fakturanummer eller kreditnota nummer",
"vendor_name": "leverandør firmanavn",
"vendor_cvr": "12345678",
"invoice_date": "YYYY-MM-DD",
"due_date": "YYYY-MM-DD",
"currency": "DKK",
"total_amount": 1234.56,
"vat_amount": 123.45,
"total_amount": 1234.56 (NEGATIVT for kreditnotaer),
"vat_amount": 123.45 (NEGATIVT for kreditnotaer),
"original_invoice_reference": "reference til original faktura (kun for kreditnotaer)",
"lines": [
{
"line_number": 1,
"description": "beskrivelse af varen/ydelsen",
"quantity": antal_som_tal,
"unit_price": pris_per_stk,
"line_total": total_for_linjen,
"unit_price": pris_per_stk (NEGATIVT for kreditnotaer),
"line_total": total_for_linjen (NEGATIVT for kreditnotaer),
"vat_rate": 25.00,
"vat_amount": moms_beløb,
"vat_amount": moms_beløb (NEGATIVT for kreditnotaer),
"confidence": 0.0_til_1.0
}
],
@ -65,24 +80,48 @@ JSON format skal være:
"raw_text_snippet": "første 200 tegn fra dokumentet"
}
EKSEMPEL:
Input: "FAKTURA 2025-001\\nGlobalConnect A/S\\nCVR: 12345678\\n1 stk Fiber 100/100 Mbit @ 299,00 DKK\\nMoms (25%): 74,75 DKK\\nTotal: 373,75 DKK"
EKSEMPEL FAKTURA (POSITIVE BELØB):
Input: "FAKTURA 2025-001\\nGlobalConnect A/S\\nCVR: 12345678\\n1 stk iPhone 16 @ 5.965,18 DKK\\nMoms (25%): 1.491,30 DKK\\nTotal: 7.456,48 DKK"
Output: {
"document_type": "invoice",
"invoice_number": "2025-001",
"vendor_name": "GlobalConnect A/S",
"vendor_cvr": "12345678",
"total_amount": 373.75,
"vat_amount": 74.75,
"total_amount": 7456.48,
"vat_amount": 1491.30,
"lines": [{
"line_number": 1,
"description": "Fiber 100/100 Mbit",
"description": "iPhone 16",
"quantity": 1,
"unit_price": 299.00,
"line_total": 299.00,
"unit_price": 5965.18,
"line_total": 5965.18,
"vat_rate": 25.00,
"vat_amount": 74.75,
"vat_amount": 1491.30,
"confidence": 0.95
}],
"confidence": 0.95
}
EKSEMPEL KREDITNOTA (NEGATIVE BELØB):
Input: "KREDITNOTA CN-2025-042\\nGlobalConnect A/S\\nCVR: 12345678\\nReference: Faktura 2025-001\\nTilbagebetaling:\\n1 stk iPhone 16 returneret @ -5.965,18 DKK\\nMoms (25%): -1.491,30 DKK\\nTotal: -7.456,48 DKK"
Output: {
"document_type": "credit_note",
"invoice_number": "CN-2025-042",
"vendor_name": "GlobalConnect A/S",
"vendor_cvr": "12345678",
"original_invoice_reference": "2025-001",
"total_amount": -7456.48,
"vat_amount": -1491.30,
"lines": [{
"line_number": 1,
"description": "iPhone 16 returneret",
"quantity": 1,
"unit_price": -5965.18,
"line_total": -5965.18,
"vat_rate": 25.00,
"vat_amount": -1491.30,
"confidence": 0.95
}],
"confidence": 0.95
@ -99,11 +138,8 @@ Output: {
Extracted data as dict with CVR, invoice number, amounts, etc.
"""
# Truncate text if too long (keep first 4000 chars)
if len(text) > 4000:
text = text[:4000] + "\\n[... tekst afkortet ...]"
prompt = f"{self.system_prompt}\\n\\nNU SKAL DU UDTRÆKKE DATA FRA DENNE FAKTURA:\\n{text}\\n\\nReturner kun gyldig JSON:"
# No truncation - send full text to AI
prompt = f"{self.system_prompt}\n\nNU SKAL DU UDTRÆKKE DATA FRA DENNE FAKTURA:\n{text}\n\nReturner kun gyldig JSON:"
logger.info(f"🤖 Extracting invoice data from text (length: {len(text)})")
@ -136,6 +172,48 @@ Output: {
# Parse JSON from response
extraction = self._parse_json_response(raw_response)
# CRITICAL: Fix amount signs based on document_type
# LLM sometimes returns negative amounts for invoices - fix this!
document_type = extraction.get('document_type', 'invoice')
if document_type == 'invoice':
# Normal invoices should have POSITIVE amounts
if extraction.get('total_amount') and extraction['total_amount'] < 0:
logger.warning(f"⚠️ Fixing negative total_amount for invoice: {extraction['total_amount']}{abs(extraction['total_amount'])}")
extraction['total_amount'] = abs(extraction['total_amount'])
if extraction.get('vat_amount') and extraction['vat_amount'] < 0:
extraction['vat_amount'] = abs(extraction['vat_amount'])
# Fix line totals
if 'lines' in extraction:
for line in extraction['lines']:
if line.get('unit_price') and line['unit_price'] < 0:
line['unit_price'] = abs(line['unit_price'])
if line.get('line_total') and line['line_total'] < 0:
line['line_total'] = abs(line['line_total'])
if line.get('vat_amount') and line['vat_amount'] < 0:
line['vat_amount'] = abs(line['vat_amount'])
elif document_type == 'credit_note':
# Credit notes should have NEGATIVE amounts
if extraction.get('total_amount') and extraction['total_amount'] > 0:
logger.warning(f"⚠️ Fixing positive total_amount for credit_note: {extraction['total_amount']}{-abs(extraction['total_amount'])}")
extraction['total_amount'] = -abs(extraction['total_amount'])
if extraction.get('vat_amount') and extraction['vat_amount'] > 0:
extraction['vat_amount'] = -abs(extraction['vat_amount'])
# Fix line totals
if 'lines' in extraction:
for line in extraction['lines']:
if line.get('unit_price') and line['unit_price'] > 0:
line['unit_price'] = -abs(line['unit_price'])
if line.get('line_total') and line['line_total'] > 0:
line['line_total'] = -abs(line['line_total'])
if line.get('vat_amount') and line['vat_amount'] > 0:
line['vat_amount'] = -abs(line['vat_amount'])
# Add raw response for debugging
extraction['_raw_llm_response'] = raw_response
@ -237,18 +315,22 @@ Output: {
raise
async def _extract_text_from_pdf(self, file_path: Path) -> str:
"""Extract text from PDF using PyPDF2"""
"""Extract text from PDF using pdfplumber (better table/layout support)"""
try:
from PyPDF2 import PdfReader
import pdfplumber
reader = PdfReader(file_path)
text = ""
all_text = []
with pdfplumber.open(file_path) as pdf:
for page_num, page in enumerate(pdf.pages):
# Strategy: Use regular text extraction (includes tables)
# pdfplumber's extract_text() handles tables better than PyPDF2
page_text = page.extract_text(layout=True, x_tolerance=2, y_tolerance=2)
if page_text:
all_text.append(page_text)
for page_num, page in enumerate(reader.pages):
page_text = page.extract_text()
text += f"\\n--- Side {page_num + 1} ---\\n{page_text}"
logger.info(f"📄 Extracted {len(text)} chars from PDF with {len(reader.pages)} pages")
text = "\\n".join(all_text)
logger.info(f"📄 Extracted {len(text)} chars from PDF with pdfplumber")
return text
except Exception as e:

View File

@ -237,3 +237,29 @@ async def reset_user_password(user_id: int, new_password: str):
logger.info(f"✅ Reset password for user: {user_id}")
return {"message": "Password reset successfully"}
# AI Prompts Endpoint
@router.get("/ai-prompts", tags=["Settings"])
async def get_ai_prompts():
"""Get all AI prompts used in the system"""
from app.services.ollama_service import OllamaService
ollama_service = OllamaService()
prompts = {
"invoice_extraction": {
"name": "Faktura Udtrækning (Invoice Extraction)",
"description": "System prompt brugt til at udtrække data fra fakturaer og kreditnotaer via Ollama LLM",
"model": ollama_service.model,
"endpoint": ollama_service.endpoint,
"prompt": ollama_service._build_system_prompt(),
"parameters": {
"temperature": 0.1,
"top_p": 0.9,
"num_predict": 2000
}
}
}
return prompts

View File

@ -89,6 +89,9 @@
<a class="nav-link" href="#users" data-tab="users">
<i class="bi bi-people me-2"></i>Brugere
</a>
<a class="nav-link" href="#ai-prompts" data-tab="ai-prompts">
<i class="bi bi-robot me-2"></i>AI Prompts
</a>
<a class="nav-link" href="#system" data-tab="system">
<i class="bi bi-gear me-2"></i>System
</a>
@ -177,6 +180,23 @@
</div>
</div>
<!-- AI Prompts -->
<div class="tab-pane fade" id="ai-prompts">
<div class="card p-4">
<h5 class="mb-4 fw-bold">
<i class="bi bi-robot me-2"></i>AI System Prompts
</h5>
<p class="text-muted mb-4">
Her kan du se de prompts der bruges til forskellige AI funktioner i systemet.
</p>
<div id="aiPromptsContent">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
</div>
</div>
</div>
<!-- System Settings -->
<div class="tab-pane fade" id="system">
<div class="card p-4">
@ -459,6 +479,76 @@ function getInitials(name) {
return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase();
}
// Load AI Prompts
async function loadAIPrompts() {
try {
const response = await fetch('/api/v1/ai-prompts');
const prompts = await response.json();
const container = document.getElementById('aiPromptsContent');
container.innerHTML = Object.entries(prompts).map(([key, prompt]) => `
<div class="card mb-4">
<div class="card-header bg-light">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-1 fw-bold">${escapeHtml(prompt.name)}</h6>
<small class="text-muted">${escapeHtml(prompt.description)}</small>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="copyPrompt('${key}')">
<i class="bi bi-clipboard me-1"></i>Kopier
</button>
</div>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<small class="text-muted">Model:</small>
<div><code>${escapeHtml(prompt.model)}</code></div>
</div>
<div class="col-md-4">
<small class="text-muted">Endpoint:</small>
<div><code>${escapeHtml(prompt.endpoint)}</code></div>
</div>
<div class="col-md-4">
<small class="text-muted">Parametre:</small>
<div><code>${JSON.stringify(prompt.parameters)}</code></div>
</div>
</div>
<div>
<small class="text-muted fw-bold d-block mb-2">System Prompt:</small>
<pre id="prompt_${key}" class="border rounded p-3 bg-light" style="max-height: 400px; overflow-y: auto; font-size: 0.85rem; white-space: pre-wrap;">${escapeHtml(prompt.prompt)}</pre>
</div>
</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading AI prompts:', error);
document.getElementById('aiPromptsContent').innerHTML =
'<div class="alert alert-danger">Kunne ikke indlæse AI prompts</div>';
}
}
function copyPrompt(key) {
const promptElement = document.getElementById(`prompt_${key}`);
const text = promptElement.textContent;
navigator.clipboard.writeText(text).then(() => {
// Show success feedback
const btn = event.target.closest('button');
const originalHtml = btn.innerHTML;
btn.innerHTML = '<i class="bi bi-check me-1"></i>Kopieret!';
btn.classList.remove('btn-outline-primary');
btn.classList.add('btn-success');
setTimeout(() => {
btn.innerHTML = originalHtml;
btn.classList.remove('btn-success');
btn.classList.add('btn-outline-primary');
}, 2000);
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
@ -495,6 +585,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
// Load data for tab
if (tab === 'users') {
loadUsers();
} else if (tab === 'ai-prompts') {
loadAIPrompts();
}
});
});

View File

@ -451,26 +451,41 @@
}
});
// Global Search Modal (Cmd+K)
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
const searchInput = document.getElementById('globalSearchInput');
// Keyboard shortcut: Cmd+K or Ctrl+K
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
searchModal.show();
setTimeout(() => {
searchInput.focus();
loadLiveStats();
loadRecentActivity();
}, 300);
}
// Global Search Modal (Cmd+K) - Initialize after DOM is ready
document.addEventListener('DOMContentLoaded', () => {
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
const searchInput = document.getElementById('globalSearchInput');
// ESC to close
if (e.key === 'Escape') {
searchModal.hide();
}
// Keyboard shortcut: Cmd+K or Ctrl+K
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
console.log('Cmd+K pressed - opening search modal'); // Debug
searchModal.show();
setTimeout(() => {
searchInput.focus();
loadLiveStats();
loadRecentActivity();
}, 300);
}
// ESC to close
if (e.key === 'Escape') {
searchModal.hide();
}
});
// Reset search when modal is closed
document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => {
searchInput.value = '';
selectedEntity = null;
document.getElementById('emptyState').style.display = 'block';
document.getElementById('workflowActions').style.display = 'none';
document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
});
});
// Load live statistics for the three boxes
@ -742,18 +757,6 @@
`;
document.head.appendChild(style);
});
// Reset search when modal is closed
document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => {
searchInput.value = '';
selectedEntity = null;
document.getElementById('emptyState').style.display = 'block';
document.getElementById('workflowActions').style.display = 'none';
document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
});
</script>
{% block extra_js %}{% endblock %}
</body>

View File

@ -196,8 +196,31 @@
<!-- Fakturaer Tab -->
<div class="tab-pane fade" id="fakturaer">
<div class="card p-4">
<h5 class="mb-4 fw-bold">Leverandør Fakturaer</h5>
<p class="text-muted">Faktura oversigt kommer snart...</p>
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="mb-0 fw-bold">Leverandør Fakturaer</h5>
<span class="badge bg-primary" id="invoiceCount">0</span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Fakturanr.</th>
<th>Dato</th>
<th>Forfald</th>
<th>Beløb</th>
<th>Status</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody id="invoicesTableBody">
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary" role="status"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@ -213,6 +236,96 @@
</div>
</div>
<!-- Edit Vendor Modal -->
<div class="modal fade" id="editVendorModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-pencil me-2"></i>Rediger Leverandør</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editVendorForm">
<div class="row mb-3">
<div class="col-md-8">
<label class="form-label">Navn *</label>
<input type="text" class="form-control" id="editName" required>
</div>
<div class="col-md-4">
<label class="form-label">CVR-nummer</label>
<input type="text" class="form-control" id="editCvr" maxlength="8" pattern="[0-9]{8}">
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Kategori</label>
<select class="form-select" id="editCategory">
<option value="IT & Software">IT & Software</option>
<option value="Telefoni & Internet">Telefoni & Internet</option>
<option value="Hardware">Hardware</option>
<option value="Cloud Services">Cloud Services</option>
<option value="Hosting">Hosting</option>
<option value="Security">Security</option>
<option value="Consulting">Consulting</option>
<option value="Andet">Andet</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Domæne</label>
<input type="text" class="form-control" id="editDomain" placeholder="example.com">
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Email</label>
<input type="email" class="form-control" id="editEmail">
</div>
<div class="col-md-6">
<label class="form-label">Telefon</label>
<input type="tel" class="form-control" id="editPhone">
</div>
</div>
<div class="row mb-3">
<div class="col-md-8">
<label class="form-label">Adresse</label>
<input type="text" class="form-control" id="editAddress">
</div>
<div class="col-md-4">
<label class="form-label">Postnr. & By</label>
<input type="text" class="form-control" id="editCity">
</div>
</div>
<div class="mb-3">
<label class="form-label">e-conomic Leverandør Nr.</label>
<input type="text" class="form-control" id="editEconomicNumber">
<div class="form-text">Til integration med e-conomic</div>
</div>
<div class="mb-3">
<label class="form-label">Noter</label>
<textarea class="form-control" id="editNotes" rows="3"></textarea>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="editIsActive">
<label class="form-check-label" for="editIsActive">Aktiv leverandør</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveVendor()">
<i class="bi bi-save me-2"></i>Gem Ændringer
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
@ -261,14 +374,6 @@ function displayVendor(vendor) {
</span>
</div>
</div>
<div class="info-row">
<div class="info-label">Prioritet</div>
<div class="info-value">
<div class="priority-indicator ${getPriorityClass(vendor.priority)}">
${vendor.priority}
</div>
</div>
</div>
${vendor.economic_supplier_number ? `
<div class="info-row">
<div class="info-label">e-conomic Leverandør Nr.</div>
@ -351,6 +456,102 @@ function displayVendor(vendor) {
<div class="info-value"><code>#${vendor.id}</code></div>
</div>
`;
// Load invoices
loadVendorInvoices();
}
async function loadVendorInvoices() {
try {
const response = await fetch(`/api/v1/supplier-invoices?vendor_id=${vendorId}`);
if (!response.ok) throw new Error('Failed to load invoices');
const invoices = await response.json();
displayInvoices(invoices);
} catch (error) {
console.error('Error loading invoices:', error);
document.getElementById('invoicesTableBody').innerHTML = `
<tr>
<td colspan="6" class="text-center text-muted py-4">
Kunne ikke indlæse fakturaer
</td>
</tr>
`;
}
}
function displayInvoices(invoices) {
const tbody = document.getElementById('invoicesTableBody');
const count = document.getElementById('invoiceCount');
count.textContent = invoices.length;
if (invoices.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="text-center text-muted py-4">
Ingen fakturaer fundet for denne leverandør
</td>
</tr>
`;
return;
}
tbody.innerHTML = invoices.map(invoice => {
const statusClass = getInvoiceStatusClass(invoice.status);
const statusText = getInvoiceStatusText(invoice.status);
return `
<tr>
<td><strong>${escapeHtml(invoice.invoice_number)}</strong></td>
<td>${formatDateShort(invoice.invoice_date)}</td>
<td>${formatDateShort(invoice.due_date)}</td>
<td><strong>${formatCurrency(invoice.total_amount, invoice.currency)}</strong></td>
<td><span class="badge ${statusClass}">${statusText}</span></td>
<td class="text-end">
<a href="/billing/supplier-invoices?invoice=${invoice.id}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
`;
}).join('');
}
function getInvoiceStatusClass(status) {
const classes = {
'unpaid': 'bg-warning text-dark',
'paid': 'bg-success',
'overdue': 'bg-danger',
'cancelled': 'bg-secondary',
'pending': 'bg-info'
};
return classes[status] || 'bg-secondary';
}
function getInvoiceStatusText(status) {
const texts = {
'unpaid': 'Ubetalt',
'paid': 'Betalt',
'overdue': 'Forfalden',
'cancelled': 'Annulleret',
'pending': 'Afventer'
};
return texts[status] || status;
}
function formatDateShort(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function formatCurrency(amount, currency = 'DKK') {
if (!amount) return '-';
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: currency
}).format(amount);
}
function getCategoryIcon(category) {
@ -376,6 +577,74 @@ function getInitials(name) {
return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase();
}
function editVendor() {
// Get current vendor data and populate form
fetch(`/api/v1/vendors/${vendorId}`)
.then(response => response.json())
.then(vendor => {
document.getElementById('editName').value = vendor.name || '';
document.getElementById('editCvr').value = vendor.cvr_number || '';
document.getElementById('editCategory').value = vendor.category || 'Andet';
document.getElementById('editDomain').value = vendor.domain || '';
document.getElementById('editEmail').value = vendor.email || '';
document.getElementById('editPhone').value = vendor.phone || '';
document.getElementById('editAddress').value = vendor.address || '';
document.getElementById('editCity').value = vendor.city || '';
document.getElementById('editEconomicNumber').value = vendor.economic_supplier_number || '';
document.getElementById('editNotes').value = vendor.notes || '';
document.getElementById('editIsActive').checked = vendor.is_active;
new bootstrap.Modal(document.getElementById('editVendorModal')).show();
})
.catch(error => {
console.error('Error loading vendor for edit:', error);
alert('Kunne ikke hente leverandør data');
});
}
async function saveVendor() {
try {
const data = {
name: document.getElementById('editName').value.trim(),
cvr_number: document.getElementById('editCvr').value.trim() || null,
category: document.getElementById('editCategory').value,
domain: document.getElementById('editDomain').value.trim() || null,
email: document.getElementById('editEmail').value.trim() || null,
phone: document.getElementById('editPhone').value.trim() || null,
address: document.getElementById('editAddress').value.trim() || null,
city: document.getElementById('editCity').value.trim() || null,
economic_supplier_number: document.getElementById('editEconomicNumber').value.trim() || null,
notes: document.getElementById('editNotes').value.trim() || null,
is_active: document.getElementById('editIsActive').checked
};
if (!data.name) {
alert('Navn er påkrævet');
return;
}
const response = await fetch(`/api/v1/vendors/${vendorId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke gemme ændringer');
}
// Close modal and reload vendor
bootstrap.Modal.getInstance(document.getElementById('editVendorModal')).hide();
await loadVendor();
alert('✅ Leverandør opdateret!');
} catch (error) {
console.error('Error saving vendor:', error);
alert('Fejl: ' + error.message);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
@ -393,11 +662,6 @@ function formatDate(dateString) {
});
}
function editVendor() {
// TODO: Implement edit modal
alert('Edit funktion kommer snart!');
}
// Tab navigation
document.querySelectorAll('.vertical-nav .nav-link').forEach(link => {
link.addEventListener('click', (e) => {

View File

@ -95,14 +95,13 @@
<th>Kontakt Info</th>
<th>CVR</th>
<th>Kategori</th>
<th>Prioritet</th>
<th>Status</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="vendorsTableBody">
<tr>
<td colspan="7" class="text-center py-5">
<td colspan="6" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
@ -186,10 +185,6 @@
<option value="hosting">Hosting</option>
</select>
</div>
<div class="col-md-12">
<label class="form-label">Prioritet (1-100)</label>
<input type="number" class="form-control" id="priority" value="50" min="1" max="100">
</div>
<div class="col-12">
<label class="form-label">Noter</label>
<textarea class="form-control" id="notes" rows="3"></textarea>
@ -231,17 +226,25 @@ async function loadVendors() {
params.append('category', currentFilter);
}
console.log('🔄 Loading vendors from:', `/api/v1/vendors?${params}`);
const response = await fetch(`/api/v1/vendors?${params}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const vendors = await response.json();
console.log('✅ Loaded vendors:', vendors.length);
displayVendors(vendors);
updatePagination(vendors.length);
} catch (error) {
console.error('Error loading vendors:', error);
console.error('Error loading vendors:', error);
document.getElementById('vendorsTableBody').innerHTML = `
<tr><td colspan="7" class="text-center text-danger py-5">
<i class="bi bi-exclamation-triangle fs-2 d-block mb-2"></i>
Kunne ikke indlæse leverandører
<strong>Kunne ikke indlæse leverandører</strong><br>
<small class="text-muted">${error.message}</small>
</td></tr>
`;
}
@ -282,11 +285,6 @@ function displayVendors(vendors) {
${getCategoryIcon(vendor.category)} ${escapeHtml(vendor.category)}
</span>
</td>
<td>
<div class="priority-badge ${getPriorityClass(vendor.priority)}">
${vendor.priority}
</div>
</td>
<td>
<span class="badge ${vendor.is_active ? 'bg-success' : 'bg-secondary'}">
${vendor.is_active ? 'Aktiv' : 'Inaktiv'}
@ -313,13 +311,6 @@ function getCategoryIcon(category) {
return icons[category] || '📦';
}
function getPriorityClass(priority) {
if (priority >= 80) return 'bg-danger text-white';
if (priority >= 60) return 'bg-warning';
if (priority >= 40) return 'bg-info';
return 'bg-secondary text-white';
}
function getInitials(name) {
return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase();
}
@ -407,10 +398,10 @@ async function createVendor() {
}
// Search
let searchTimeout;
let vendorSearchTimeout;
document.getElementById('searchInput').addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
clearTimeout(vendorSearchTimeout);
vendorSearchTimeout = setTimeout(() => {
searchTerm = e.target.value;
currentPage = 0;
loadVendors();

View File

@ -0,0 +1,19 @@
-- Migration 008: Add support for credit notes
-- Add invoice_type column to distinguish between invoices and credit notes
ALTER TABLE supplier_invoices
ADD COLUMN IF NOT EXISTS invoice_type VARCHAR(20) DEFAULT 'invoice' CHECK (invoice_type IN ('invoice', 'credit_note'));
-- Update existing records to be 'invoice' type
UPDATE supplier_invoices SET invoice_type = 'invoice' WHERE invoice_type IS NULL;
-- Add index for filtering by type
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_type ON supplier_invoices(invoice_type);
-- Add document_type to extractions table
ALTER TABLE extractions
ADD COLUMN IF NOT EXISTS document_type_detected VARCHAR(20) CHECK (document_type_detected IN ('invoice', 'credit_note', 'receipt', 'other'));
-- Update system prompt context
COMMENT ON COLUMN supplier_invoices.invoice_type IS 'Type of document: invoice or credit_note';
COMMENT ON COLUMN extractions.document_type_detected IS 'AI-detected document type from extraction';

View File

@ -12,5 +12,6 @@ aiohttp==3.10.10
# AI & Document Processing
httpx==0.27.2
PyPDF2==3.0.1
pdfplumber==0.11.4
pytesseract==0.3.13
Pillow==11.0.0

48
test_ai_analyze.py Normal file
View File

@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Test AI analyze endpoint med CVR filter"""
import requests
import pdfplumber
import json
from pathlib import Path
# Extract PDF text
pdf_path = Path("uploads/5082481.pdf")
all_text = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
page_text = page.extract_text(layout=True, x_tolerance=2, y_tolerance=2)
if page_text:
all_text.append(page_text)
full_text = "\n".join(all_text)
# Call AI analyze endpoint
print("🧪 Testing AI analyze endpoint...")
response = requests.post(
'http://localhost:8000/api/v1/supplier-invoices/ai/analyze',
json={'pdf_text': full_text[:2000]}, # First 2000 chars as in the actual code
headers={'Content-Type': 'application/json'}
)
print(f"Status: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"\n✅ AI Analysis Result:")
print(f" CVR: {result.get('cvr')}")
print(f" Invoice Number: {result.get('invoice_number')}")
print(f" Date: {result.get('invoice_date')}")
print(f" Total: {result.get('total_amount')}")
print(f" Detection Patterns: {result.get('detection_patterns')}")
# Check CVR filter
found_cvr = result.get('cvr')
OWN_CVR = "44687369"
if found_cvr == OWN_CVR:
print(f"\n❌ FAIL: AI returned OWN_CVR {OWN_CVR} - filter didn't work!")
elif found_cvr == "29522790":
print(f"\n✅ PASS: AI found correct vendor CVR {found_cvr} (DCS ApS)")
else:
print(f"\n⚠️ WARNING: AI found CVR {found_cvr} - unexpected value")
else:
print(f"❌ Error: {response.text}")

46
test_cvr_filter.py Normal file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Test CVR filter i AI analyze"""
import pdfplumber
import json
import re
from pathlib import Path
# Extract PDF text
pdf_path = Path("/app/uploads/5082481.pdf")
all_text = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
page_text = page.extract_text(layout=True, x_tolerance=2, y_tolerance=2)
if page_text:
all_text.append(page_text)
full_text = "\n".join(all_text)
print("=== PDF TEXT (first 1500 chars) ===")
print(full_text[:1500])
print("\n")
# Find CVR numbers (support both CVR and DK prefix)
cvr_pattern = r'(?:CVR|Momsnr\.?|DK)[:\s-]*(\d{8})'
cvr_matches = re.findall(cvr_pattern, full_text)
print(f"=== Found CVR numbers: {cvr_matches}")
print()
# Check for OWN_CVR
OWN_CVR = "44687369"
if OWN_CVR in cvr_matches:
print(f"⚠️ WARNING: Found OWN_CVR {OWN_CVR} - should be filtered!")
else:
print(f"✅ OWN_CVR {OWN_CVR} not in CVR list (good)")
# Check if we found DCS CVR
DCS_CVR = "29522790"
if DCS_CVR in cvr_matches:
print(f"✅ Found vendor CVR {DCS_CVR} (DCS ApS) - correct!")
else:
print(f"❌ Did NOT find vendor CVR {DCS_CVR}")
print()
print("=== Expected behavior ===")
print("AI should find CVR 29522790 (DCS ApS)")
print(f"AI should IGNORE CVR {OWN_CVR} (BMC Denmark)")