feat(migrations): add supplier invoice enhancements for accounting integration

- Added new columns to supplier_invoice_lines for contra_account, line_purpose, resale_customer_id, resale_order_number, is_invoiced_to_customer, and invoiced_date.
- Created indexes for faster filtering by purpose and resale status.
- Introduced economic_accounts table to cache e-conomic chart of accounts with relevant fields and indexes.
- Added comments for documentation on new columns and tables.
- Included success message for migration completion.
This commit is contained in:
Christian 2026-01-07 10:32:41 +01:00
parent 42b766b31e
commit c855f5d027
12 changed files with 4782 additions and 99 deletions

248
MIGRATION_GUIDE_v2.0.0.md Normal file
View File

@ -0,0 +1,248 @@
# Migration Guide - Supplier Invoice Enhancements (v2.0.0)
## 🎯 Hvad migreres:
### Database Changes:
- ✅ `supplier_invoice_lines`: Nye kolonner (contra_account, line_purpose, resale_customer_id, resale_order_number)
- ✅ `economic_accounts`: Ny tabel til e-conomic kontoplan cache
### Backend Changes:
- ✅ e-conomic accounts API integration
- ✅ Line item update endpoint med modkonto support
### Frontend Changes:
- ✅ 3 nye faneblade (Til Betaling, Klar til Bogføring, Varelinjer)
- ✅ Inline redigering af modkonto og formål
- ✅ Backup version på /billing/supplier-invoices2
---
## 📋 Pre-Migration Checklist:
- [ ] Commit alle ændringer til git
- [ ] Test på lokal udvikling fungerer
- [ ] Backup af production database
- [ ] Tag ny version (v2.0.0)
- [ ] Push til Gitea
---
## 🚀 Migration Steps:
### Step 1: Commit og Tag Release
```bash
cd /Users/christianthomas/DEV/bmc_hub_dev
# Commit ændringer
git add .
git commit -m "Supplier invoice enhancements v2.0.0
- Added modkonto (contra_account) support per line
- Added line_purpose tracking (resale, internal, project, stock)
- Added e-conomic accounts API integration
- Redesigned frontend with 3 tabs: Payment, Ready for Booking, Line Items
- Database migration 1000 included
- Backup version available at /billing/supplier-invoices2"
# Opdater VERSION fil
echo "2.0.0" > VERSION
git add VERSION
git commit -m "Bump version to 2.0.0"
# Tag release
git tag v2.0.0
git push origin main
git push origin v2.0.0
```
### Step 2: Backup Production Database
```bash
# SSH til production
ssh bmcadmin@172.16.31.183
# Backup database
cd /srv/podman/bmc_hub_v1.0
podman exec bmc-hub-postgres-prod pg_dump -U bmc_hub bmc_hub > backup_pre_v2.0.0_$(date +%Y%m%d_%H%M%S).sql
# Verificer backup
ls -lh backup_pre_v2.0.0_*.sql
```
### Step 3: Deploy ny Version
Fra lokal Mac:
```bash
cd /Users/christianthomas/DEV/bmc_hub_dev
# Kør deployment script
./deploy_to_prod.sh v2.0.0
```
Dette script:
1. Opdaterer RELEASE_VERSION i .env
2. Stopper containers
3. Bygger nyt image fra Gitea tag v2.0.0
4. Starter containers igen
### Step 4: Kør Migration på Production
```bash
# SSH til production
ssh bmcadmin@172.16.31.183
cd /srv/podman/bmc_hub_v1.0
# Kør migration SQL
podman exec -i bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub < migrations/1000_supplier_invoice_enhancements.sql
# ELLER hvis migrationen ikke er mounted:
# Kopier migration til container først:
podman cp migrations/1000_supplier_invoice_enhancements.sql bmc-hub-postgres-prod:/tmp/migration.sql
podman exec bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub -f /tmp/migration.sql
```
### Step 5: Sync e-conomic Accounts
```bash
# Trigger initial sync af kontoplan
curl -X GET "http://172.16.31.183:8001/api/v1/supplier-invoices/economic/accounts?refresh=true"
# Verificer at konti er cached
curl -s "http://172.16.31.183:8001/api/v1/supplier-invoices/economic/accounts" | jq '.accounts | length'
# Skal returnere antal konti (fx 20)
```
### Step 6: Verificer Migration
```bash
# Tjek database kolonner
podman exec bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub -c "\d supplier_invoice_lines"
# Skal vise: contra_account, line_purpose, resale_customer_id, resale_order_number
# Tjek economic_accounts tabel
podman exec bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub -c "SELECT COUNT(*) FROM economic_accounts;"
# Skal returnere antal accounts (fx 20)
# Test frontend
# Åbn: http://172.16.31.183:8001/billing/supplier-invoices
# Skal vise: Til Betaling, Klar til Bogføring, Varelinjer tabs
# Test backup version
# Åbn: http://172.16.31.183:8001/billing/supplier-invoices2
# Skal vise: Original version med Fakturaer, Mangler Behandling tabs
```
---
## 🔄 Rollback Plan (hvis noget går galt):
### Option 1: Rollback til forrige version
```bash
ssh bmcadmin@172.16.31.183
cd /srv/podman/bmc_hub_v1.0
# Opdater til forrige version (fx v1.3.123)
sed -i 's/^RELEASE_VERSION=.*/RELEASE_VERSION=v1.3.123/' .env
# Rebuild og restart
podman-compose down
podman-compose build --no-cache
podman-compose up -d
```
### Option 2: Restore database backup
```bash
ssh bmcadmin@172.16.31.183
cd /srv/podman/bmc_hub_v1.0
# Stop API for at undgå data ændringer
podman stop bmc-hub-api-prod
# Restore database
podman exec -i bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub < backup_pre_v2.0.0_XXXXXXXX.sql
# Restart API
podman start bmc-hub-api-prod
```
---
## 📊 Post-Migration Validation:
### Test Cases:
1. **Upload Invoice**
- Upload PDF faktura
- Verificer Quick Analysis virker
- Tjek vendor auto-match
2. **Process Invoice**
- Klik "Behandl" på uploaded fil
- Verificer template extraction
- Tjek at linjer oprettes
3. **Assign Modkonto**
- Gå til "Varelinjer" tab
- Vælg modkonto fra dropdown (skal vise 20 konti)
- Vælg formål (Videresalg, Internt, osv.)
- Gem og verificer
4. **Check Ready for Booking**
- Gå til "Klar til Bogføring" tab
- Skal kun vise fakturaer hvor ALLE linjer har modkonto
- Test "Send til e-conomic" knap
5. **Payment View**
- Gå til "Til Betaling" tab
- Verificer sortering efter forfaldsdato
- Test bulk selection
---
## 🎯 Success Criteria:
- ✅ Migration SQL kørt uden fejl
- ✅ 20+ e-conomic accounts cached i database
- ✅ Nye faneblade vises korrekt
- ✅ Modkonto dropdown virker
- ✅ Inline editing af linjer fungerer
- ✅ Backup version tilgængelig på /supplier-invoices2
- ✅ Send til e-conomic virker med nye modkonti
---
## ⚠️ Known Issues & Workarounds:
### Issue 1: Accounts endpoint timeout
**Symptom**: Første kald til accounts endpoint er langsomt (2-3 sek)
**Reason**: Første gang syncer fra e-conomic API
**Workaround**: Pre-trigger sync efter deployment (Step 5)
### Issue 2: Eksisterende fakturaer har ingen modkonto
**Symptom**: Gamle fakturaer vises ikke i "Klar til Bogføring"
**Expected**: Kun nye fakturaer (efter migration) vil have modkonti
**Solution**: Manuel assignment via "Varelinjer" tab for gamle fakturaer hvis nødvendigt
### Issue 3: Browser cache
**Symptom**: Gamle faneblade vises stadig
**Solution**: Ctrl+Shift+R (hard refresh) i browser
---
## 📞 Support:
Ved problemer, tjek:
1. Container logs: `podman logs bmc-hub-api-prod --tail 100`
2. Database logs: `podman logs bmc-hub-postgres-prod --tail 100`
3. Migration status: `podman exec bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub -c "SELECT * FROM economic_accounts LIMIT 5;"`
---
**Version**: 2.0.0
**Date**: 2026-01-07
**Migration File**: 1000_supplier_invoice_enhancements.sql

View File

@ -329,9 +329,11 @@ async def get_file_pdf_text(file_id: int):
if not file_info:
raise HTTPException(status_code=404, detail="Fil ikke fundet")
file_data = file_info[0]
# Read PDF text
from pathlib import Path
file_path = Path(file_info['file_path'])
file_path = Path(file_data['file_path'])
if not file_path.exists():
raise HTTPException(status_code=404, detail=f"Fil ikke fundet på disk: {file_path}")
@ -339,7 +341,7 @@ async def get_file_pdf_text(file_id: int):
return {
"file_id": file_id,
"filename": file_info['filename'],
"filename": file_data['filename'],
"pdf_text": pdf_text
}
@ -372,15 +374,23 @@ async def get_file_extracted_data(file_id: int):
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']
raw_json = extraction['llm_response_json']
# Always parse if it's a string, even if psycopg2 returns it as JSON type
if isinstance(raw_json, str):
llm_json_data = json.loads(raw_json)
elif isinstance(raw_json, dict):
llm_json_data = raw_json
else:
# Fallback: try to parse as string
llm_json_data = json.loads(str(raw_json))
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}")
logger.error(f" Failed to parse llm_response_json: {e}")
# Get extraction lines if exist
# Get extraction lines if exist (use execute_query for multiple rows)
extraction_lines = []
if extraction:
extraction_lines = execute_query_single(
extraction_lines = execute_query(
"""SELECT * FROM extraction_lines
WHERE extraction_id = %s
ORDER BY line_number""",
@ -485,13 +495,14 @@ async def download_pending_file(file_id: int):
try:
# Get file info
file_info = execute_query(
file_result = execute_query(
"SELECT * FROM incoming_files WHERE file_id = %s",
(file_id,))
if not file_info:
if not file_result:
raise HTTPException(status_code=404, detail="Fil ikke fundet")
file_info = file_result[0] # Get first row
file_path = Path(file_info['file_path'])
if not file_path.exists():
raise HTTPException(status_code=404, detail="Fil findes ikke på disk")
@ -1145,16 +1156,18 @@ async def create_template(request: Dict):
async def get_supplier_invoice(invoice_id: int):
"""Get single supplier invoice with lines"""
try:
invoice = execute_query(
invoice_result = execute_query(
"""SELECT si.*, v.name as vendor_full_name, v.economic_supplier_number as vendor_economic_id
FROM supplier_invoices si
LEFT JOIN vendors v ON si.vendor_id = v.id
WHERE si.id = %s""",
(invoice_id,))
if not invoice:
if not invoice_result:
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
invoice = invoice_result[0]
# Get lines
lines = execute_query_single(
"SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number",
@ -1332,6 +1345,76 @@ async def update_supplier_invoice(invoice_id: int, data: Dict):
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/supplier-invoices/{invoice_id}/lines/{line_id}")
async def update_invoice_line(invoice_id: int, line_id: int, data: Dict):
"""
Update supplier invoice line item
Supports updating: contra_account, line_purpose, resale_customer_id, resale_order_number
"""
try:
# Check if invoice exists and is not sent to e-conomic
invoice = execute_query(
"SELECT id, status FROM supplier_invoices WHERE id = %s",
(invoice_id,))
if not invoice:
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
invoice_data = invoice[0]
# Don't allow editing if already sent to e-conomic
if invoice_data['status'] == 'sent_to_economic':
raise HTTPException(
status_code=400,
detail="Cannot edit invoice line that has been sent to e-conomic"
)
# Check if line exists
line = execute_query(
"SELECT id FROM supplier_invoice_lines WHERE id = %s AND supplier_invoice_id = %s",
(line_id, invoice_id))
if not line:
raise HTTPException(status_code=404, detail=f"Line {line_id} not found in invoice {invoice_id}")
# Build update query
update_fields = []
params = []
allowed_fields = ['contra_account', 'line_purpose', 'resale_customer_id',
'resale_order_number', 'description', 'quantity',
'unit_price', 'vat_rate', 'total_amount']
for field in allowed_fields:
if field in data:
update_fields.append(f"{field} = %s")
params.append(data[field])
if not update_fields:
raise HTTPException(status_code=400, detail="No fields to update")
params.append(line_id)
query = f"""
UPDATE supplier_invoice_lines
SET {', '.join(update_fields)}
WHERE id = %s
"""
execute_update(query, tuple(params))
logger.info(f"✅ Updated invoice line {line_id} (Invoice {invoice_id})")
return {"success": True, "line_id": line_id}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed to update invoice line {line_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/supplier-invoices/{invoice_id}")
async def delete_supplier_invoice(invoice_id: int):
"""Delete supplier invoice (soft delete if integrated with e-conomic)"""
@ -1582,6 +1665,65 @@ async def get_economic_journals():
raise HTTPException(status_code=500, detail=str(e))
@router.get("/supplier-invoices/economic/accounts")
async def get_economic_accounts(refresh: bool = False):
"""
Get e-conomic chart of accounts (kontoplan) from cache
Args:
refresh: If True, fetch fresh data from e-conomic API
Returns:
List of accounts with accountNumber, name, accountType, vatCode
"""
try:
# If refresh requested, sync from e-conomic first
if refresh:
economic = get_economic_service()
count = await economic.sync_accounts_to_database()
logger.info(f"✅ Refreshed {count} accounts from e-conomic")
# Fetch from database cache
accounts = execute_query("""
SELECT
account_number as "accountNumber",
name,
account_type as "accountType",
vat_code as "vatCode",
balance,
last_synced as "lastSynced"
FROM economic_accounts
WHERE is_active = TRUE
ORDER BY account_number
""")
# If no accounts in cache and not already refreshed, try syncing
if not accounts and not refresh:
economic = get_economic_service()
count = await economic.sync_accounts_to_database()
logger.info(f"✅ Initial sync: {count} accounts from e-conomic")
# Retry fetch
accounts = execute_query("""
SELECT
account_number as "accountNumber",
name,
account_type as "accountType",
vat_code as "vatCode",
balance,
last_synced as "lastSynced"
FROM economic_accounts
WHERE is_active = TRUE
ORDER BY account_number
""")
return {"accounts": accounts}
except Exception as e:
logger.error(f"❌ Failed to get e-conomic accounts: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ========== STATISTICS & REPORTS ==========
@router.get("/supplier-invoices/stats/overview")
@ -1877,7 +2019,8 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
"""INSERT INTO extractions
(file_id, vendor_matched_id, document_id, document_date, due_date,
total_amount, currency, document_type, confidence, llm_response_json, status)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'extracted')""",
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'extracted')
RETURNING extraction_id""",
(file_id, vendor_id,
extracted_fields.get('invoice_number'),
extracted_fields.get('invoice_date'),

View File

@ -1,6 +1,6 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Kassekladde - BMC Hub{% endblock %}
{% block title %}Leverandør fakturaer - BMC Hub{% endblock %}
{% block extra_css %}
<style>
@ -157,13 +157,24 @@
<!-- Tab Navigation -->
<ul class="nav nav-tabs mb-4" id="mainTabs">
<li class="nav-item">
<a class="nav-link active" id="invoices-tab" data-bs-toggle="tab" href="#invoices-content" onclick="switchToInvoicesTab()">
<i class="bi bi-receipt me-2"></i>Fakturaer
<a class="nav-link active" id="payment-tab" data-bs-toggle="tab" href="#payment-content" onclick="switchToPaymentTab()">
<i class="bi bi-calendar-check me-2"></i>Til Betaling
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="ready-tab" data-bs-toggle="tab" href="#ready-content" onclick="switchToReadyTab()">
<i class="bi bi-check-circle me-2"></i>Klar til Bogføring
<span class="badge bg-success ms-2" id="readyCount" style="display: none;">0</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="lines-tab" data-bs-toggle="tab" href="#lines-content" onclick="switchToLinesTab()">
<i class="bi bi-list-ul me-2"></i>Varelinjer
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="pending-files-tab" data-bs-toggle="tab" href="#pending-files-content" onclick="switchToPendingFilesTab()">
<i class="bi bi-hourglass-split me-2"></i>Mangler Behandling
<i class="bi bi-hourglass-split me-2"></i>Uploadede Filer
<span class="badge bg-warning text-dark ms-2" id="pendingFilesCount" style="display: none;">0</span>
</a>
</li>
@ -172,53 +183,33 @@
<!-- Tab Content -->
<div class="tab-content" id="mainTabContent">
<!-- Invoices Tab -->
<div class="tab-pane fade show active" id="invoices-content">
<!-- Til Betaling Tab -->
<div class="tab-pane fade show active" id="payment-content">
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<div class="filter-pills">
<div class="filter-pill active" data-filter="all" onclick="applyFilter('all', this)">
<i class="bi bi-list-ul me-1"></i>Alle
</div>
<div class="filter-pill" data-filter="pending" onclick="applyFilter('pending', this)">
<i class="bi bi-clock me-1"></i>Afventer
</div>
<div class="filter-pill" data-filter="approved" onclick="applyFilter('approved', this)">
<i class="bi bi-check-circle me-1"></i>Godkendt
</div>
<div class="filter-pill" data-filter="sent_to_economic" onclick="applyFilter('sent_to_economic', this)">
<i class="bi bi-send me-1"></i>Sendt til e-conomic
</div>
<div class="filter-pill" data-filter="overdue" onclick="applyFilter('overdue', this)">
<i class="bi bi-exclamation-triangle me-1"></i>Overskredet
</div>
</div>
</div>
<div class="alert alert-info mb-4">
<i class="bi bi-info-circle me-2"></i>
<strong>Til Betaling:</strong> Fakturaer sorteret efter forfaldsdato. Brug checkboxes til at vælge hvilke der skal betales.
</div>
<!-- Bulk Actions Bar for Invoices -->
<div class="alert alert-light border mb-3" id="invoiceBulkActionsBar" style="display: none;">
<!-- Bulk Actions Bar for Payment -->
<div class="alert alert-light border mb-3" id="paymentBulkActionsBar" style="display: none;">
<div class="d-flex align-items-center justify-content-between">
<div>
<strong><span id="selectedInvoicesCount">0</span> fakturaer valgt</strong>
<strong><span id="selectedPaymentCount">0</span> fakturaer valgt</strong>
<span class="text-muted ms-3">Total: <span id="selectedPaymentTotal">0</span> kr</span>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="bulkSendToEconomic()" title="Send til e-conomic kassekladde">
<i class="bi bi-send me-1"></i>Send til Kassekladde
</button>
<button type="button" class="btn btn-sm btn-outline-warning" onclick="bulkResetInvoices()" title="Nulstil til afventer behandling">
<i class="bi bi-arrow-counterclockwise me-1"></i>Reset
</button>
<button type="button" class="btn btn-sm btn-outline-success" onclick="bulkMarkAsPaid()" title="Marker som betalt">
<i class="bi bi-cash-coin me-1"></i>Marker Betalt
</button>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="exportPaymentFile()" title="Eksporter til betalingsfil">
<i class="bi bi-download me-1"></i>Eksporter
</button>
</div>
</div>
</div>
<!-- Invoices Table -->
<!-- Payment Table -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
@ -226,21 +217,153 @@
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" class="form-check-input" id="selectAllInvoices" onchange="toggleSelectAllInvoices()">
<input type="checkbox" class="form-check-input" id="selectAllPayment" onchange="toggleSelectAllPayment()">
</th>
<th>Forfaldsdato</th>
<th>Fakturanr.</th>
<th>Leverandør</th>
<th>Beløb</th>
<th>Status</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody id="paymentTable">
<tr>
<td colspan="7" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Klar til Bogføring Tab -->
<div class="tab-pane fade" id="ready-content">
<div class="alert alert-success mb-4">
<i class="bi bi-check-circle me-2"></i>
<strong>Klar til Bogføring:</strong> Fakturaer hvor alle varelinjer har modkonto og momskode. Klar til afsendelse til e-conomic.
</div>
<!-- Bulk Actions Bar for Ready -->
<div class="alert alert-light border mb-3" id="readyBulkActionsBar" style="display: none;">
<div class="d-flex align-items-center justify-content-between">
<div>
<strong><span id="selectedReadyCount">0</span> fakturaer valgt</strong>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-primary" onclick="bulkSendToEconomic()" title="Send til e-conomic kassekladde">
<i class="bi bi-send me-1"></i>Send til e-conomic
</button>
</div>
</div>
</div>
<!-- Ready Table --><div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" class="form-check-input" id="selectAllReady" onchange="toggleSelectAllReady()">
</th>
<th>Fakturanr.</th>
<th>Leverandør</th>
<th>Fakturadato</th>
<th>Forfaldsdato</th>
<th>Beløb</th>
<th>Status</th>
<th>e-conomic</th>
<th>Linjer</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody id="invoicesTable">
<tbody id="readyTable">
<tr>
<td colspan="9" class="text-center py-4">
<td colspan="7" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Varelinjer Tab -->
<div class="tab-pane fade" id="lines-content">
<div class="alert alert-warning mb-4">
<i class="bi bi-list-ul me-2"></i>
<strong>Varelinjer Tracking:</strong> Overblik over alle varelinjer. Sæt formål og spor viderefakturering.
</div>
<!-- Filters for Lines -->
<div class="card mb-3">
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label small">Formål</label>
<select class="form-select form-select-sm" id="linePurposeFilter" onchange="loadLineItems()">
<option value="">Alle</option>
<option value="resale">Videresalg</option>
<option value="internal">Internt brug</option>
<option value="project">Projekt</option>
<option value="stock">Lager</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small">Status</label>
<select class="form-select form-select-sm" id="lineStatusFilter" onchange="loadLineItems()">
<option value="">Alle</option>
<option value="not_invoiced">Ikke viderefaktureret</option>
<option value="invoiced">Viderefaktureret</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small">Leverandør</label>
<input type="text" class="form-control form-control-sm" id="lineVendorFilter" placeholder="Søg leverandør..." onkeyup="loadLineItems()">
</div>
<div class="col-md-3">
<label class="form-label small">&nbsp;</label>
<button class="btn btn-sm btn-outline-secondary w-100" onclick="clearLineFilters()">
<i class="bi bi-x-circle me-1"></i>Ryd filtre
</button>
</div>
</div>
</div>
</div>
<!-- Lines Table -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead>
<tr>
<th>Fakturanr.</th>
<th>Leverandør</th>
<th>Beskrivelse</th>
<th>Antal</th>
<th>Beløb</th>
<th>Modkonto</th>
<th>Formål</th>
<th>Kunde/Ordre</th>
<th>Status</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody id="linesTable">
<tr>
<td colspan="10" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
@ -534,7 +657,7 @@
</button>
</h6>
<!-- PDF Text View (default, selectable) -->
<div id="pdfTextView" style="border: 1px solid #ddd; border-radius: 4px; height: 700px; overflow: auto; background: white; padding: 15px; font-family: monospace; font-size: 0.85rem; line-height: 1.4; user-select: text;">
<div id="pdfTextView" class="bg-body border rounded p-3" style="height: 700px; overflow: auto; font-family: monospace; font-size: 0.85rem; line-height: 1.4; user-select: text;">
<div class="text-muted text-center py-5">Indlæser PDF tekst...</div>
</div>
<!-- PDF Original View (hidden by default) -->
@ -730,7 +853,7 @@ let lastFocusedField = null;
// Load data on page load
document.addEventListener('DOMContentLoaded', () => {
loadStats();
loadInvoices();
loadPaymentView(); // Load payment view by default (first tab)
loadVendors();
setupManualEntryTextSelection();
setDefaultDates();
@ -975,6 +1098,196 @@ async function loadVendors() {
}
}
// Helper function for status badge class
function getStatusBadgeClass(status) {
const classes = {
'pending': 'bg-warning text-dark',
'approved': 'bg-info',
'sent_to_economic': 'bg-success',
'paid': 'bg-success',
'overdue': 'bg-danger',
'cancelled': 'bg-secondary',
'unpaid': 'bg-warning text-dark'
};
return classes[status] || 'bg-secondary';
}
// Helper function for status label
function getStatusLabel(status) {
const labels = {
'pending': 'Afventer',
'approved': 'Godkendt',
'sent_to_economic': 'Sendt',
'paid': 'Betalt',
'overdue': 'Overskredet',
'cancelled': 'Annulleret',
'unpaid': 'Ubetalt'
};
return labels[status] || status;
}
// Helper function to view invoice details
async function viewInvoiceDetails(invoiceId) {
// Reuse existing viewInvoice function
await viewInvoice(invoiceId);
}
// Helper function to mark single invoice as paid
async function markSingleAsPaid(invoiceId) {
if (!confirm('Marker denne faktura som betalt?')) return;
try {
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
paid_date: new Date().toISOString().split('T')[0]
})
});
if (response.ok) {
alert('✅ Faktura markeret som betalt');
await loadPaymentView();
} else {
throw new Error('Failed to mark as paid');
}
} catch (error) {
console.error('Failed to mark as paid:', error);
alert('❌ Fejl ved markering som betalt');
}
}
// Helper function to send single invoice to e-conomic
async function sendToEconomic(invoiceId) {
if (!confirm('Send denne faktura til e-conomic?')) return;
try {
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/send-to-economic`, {
method: 'POST'
});
if (response.ok) {
alert('✅ Faktura sendt til e-conomic');
await loadReadyForBookingView();
} else {
const error = await response.json();
throw new Error(error.detail || 'Failed to send');
}
} catch (error) {
console.error('Failed to send to e-conomic:', error);
alert('❌ Fejl ved afsendelse: ' + error.message);
}
}
// Open existing invoice for full editing (like manual entry)
async function editInvoiceFull(invoiceId) {
try {
// Fetch invoice with lines
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`);
const invoice = await response.json();
// Load vendors
await loadVendorsForManual();
// Clear and populate form
document.getElementById('manualEntryForm').reset();
document.getElementById('manualLineItems').innerHTML = '';
document.getElementById('manualEntryFileId').value = '';
// Set invoice data
document.getElementById('manualVendorId').value = invoice.vendor_id || '';
document.getElementById('manualInvoiceNumber').value = invoice.invoice_number || '';
document.getElementById('manualInvoiceType').value = invoice.invoice_type || 'invoice';
document.getElementById('manualInvoiceDate').value = invoice.invoice_date || '';
document.getElementById('manualDueDate').value = invoice.due_date || '';
document.getElementById('manualTotalAmount').value = invoice.total_amount || '';
document.getElementById('manualCurrency').value = invoice.currency || 'DKK';
document.getElementById('manualNotes').value = invoice.notes || '';
// Store invoice ID for update instead of create
document.getElementById('manualEntryForm').dataset.editingInvoiceId = invoiceId;
// Add lines
const lines = invoice.lines || [];
if (lines.length > 0) {
lines.forEach(line => {
addManualLine();
const lineNum = manualLineCounter;
document.getElementById(`manualLineDesc${lineNum}`).value = line.description || '';
document.getElementById(`manualLineQty${lineNum}`).value = line.quantity || 1;
document.getElementById(`manualLinePrice${lineNum}`).value = Math.abs(line.unit_price || 0);
// Set VAT code
const vatCodeSelect = document.getElementById(`manualLineVatCode${lineNum}`);
if (line.vat_code) {
vatCodeSelect.value = line.vat_code;
} else if (line.vat_rate === 25) {
vatCodeSelect.value = 'I25';
} else if (line.vat_rate === 0) {
vatCodeSelect.value = 'I0';
}
document.getElementById(`manualLineContra${lineNum}`).value = line.contra_account || '';
});
} else {
addManualLine();
}
// Try to load PDF if file_id exists in notes
const pdfTextView = document.getElementById('pdfTextView');
const pdfViewer = document.getElementById('manualEntryPdfViewer');
if (invoice.notes && (invoice.notes.includes('file_id') || invoice.notes.includes('fil ID'))) {
// Try multiple patterns: "file_id: 4" or "fil ID 13" or "file ID 13"
const match = invoice.notes.match(/file[_\s]id[:\s]+(\d+)/i);
if (match) {
const fileId = match[1];
console.log('📄 Found file_id:', fileId);
try {
// Load PDF text
const textResponse = await fetch(`/api/v1/supplier-invoices/files/${fileId}/pdf-text`);
if (textResponse.ok) {
const textData = await textResponse.json();
pdfTextView.innerHTML = `<pre style="white-space: pre-wrap; word-wrap: break-word;">${textData.pdf_text || 'Ingen tekst fundet'}</pre>`;
console.log('✅ PDF text loaded');
} else {
console.error('Failed to load PDF text:', textResponse.status);
pdfTextView.innerHTML = '<div class="text-muted text-center py-5">Kunne ikke indlæse PDF tekst</div>';
}
// Set PDF viewer URL (will be loaded when user clicks "Vis Original")
pdfViewer.src = `/api/v1/supplier-invoices/files/${fileId}/pdf`;
console.log('✅ PDF viewer URL set');
} catch (error) {
console.error('Failed to load PDF:', error);
pdfTextView.innerHTML = '<div class="text-muted text-center py-5">Fejl ved indlæsning af PDF</div>';
}
} else {
console.warn('Could not parse file_id from notes');
pdfTextView.innerHTML = '<div class="text-muted text-center py-5">Ingen PDF tilknyttet denne faktura</div>';
}
} else {
console.log('No file_id in notes');
pdfTextView.innerHTML = '<div class="text-muted text-center py-5">Ingen PDF tilknyttet denne faktura</div>';
pdfViewer.style.display = 'none';
}
// Change modal title
document.querySelector('#manualEntryModal .modal-title').innerHTML = '<i class="bi bi-pencil-square me-2"></i>Rediger Faktura';
// Open modal
const modal = new bootstrap.Modal(document.getElementById('manualEntryModal'));
modal.show();
} catch (error) {
console.error('Failed to open invoice for editing:', error);
alert('Fejl ved åbning af faktura: ' + error.message);
}
}
// ========== PENDING FILES FUNCTIONS ==========
// Load pending files count for badge (on page load)
@ -995,10 +1308,316 @@ async function loadPendingFilesCount() {
}
}
// ========== PAYMENT VIEW FUNCTIONS ==========
async function loadPaymentView() {
try {
const tbody = document.getElementById('paymentTable');
tbody.innerHTML = `<tr><td colspan="7" class="text-center py-4"><div class="spinner-border text-primary" role="status"></div></td></tr>`;
const response = await fetch('/api/v1/supplier-invoices?approved_only=true&unpaid_only=true');
const invoices = await response.json();
// Sort by due date (earliest first)
invoices.sort((a, b) => {
if (!a.due_date) return 1;
if (!b.due_date) return -1;
return new Date(a.due_date) - new Date(b.due_date);
});
if (invoices.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">Ingen fakturaer til betaling</td></tr>`;
return;
}
let html = '';
const today = new Date();
invoices.forEach(invoice => {
const dueDate = invoice.due_date ? new Date(invoice.due_date) : null;
const isOverdue = dueDate && dueDate < today;
const isDueSoon = dueDate && !isOverdue && ((dueDate - today) / (1000 * 60 * 60 * 24)) <= 7;
let rowClass = '';
if (isOverdue) rowClass = 'table-danger';
else if (isDueSoon) rowClass = 'table-warning';
html += `
<tr class="${rowClass}">
<td><input type="checkbox" class="form-check-input payment-checkbox" data-invoice-id="${invoice.id}" data-amount="${invoice.total_amount}" onchange="updatePaymentSelection()"></td>
<td>${invoice.due_date || '-'} ${isOverdue ? '<span class="badge bg-danger">OVERSKREDET</span>' : ''} ${isDueSoon ? '<span class="badge bg-warning text-dark">7 DAGE</span>' : ''}</td>
<td>${invoice.invoice_number || '-'}</td>
<td>${invoice.vendor_full_name || invoice.vendor_name || '-'}</td>
<td>${parseFloat(invoice.total_amount || 0).toLocaleString('da-DK')} ${invoice.currency || 'DKK'}</td>
<td><span class="badge ${getStatusBadgeClass(invoice.status)}">${getStatusLabel(invoice.status)}</span></td>
<td>
<button class="btn btn-sm btn-outline-secondary" onclick="editInvoiceFull(${invoice.id})" title="Fuld redigering">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-sm btn-outline-primary" onclick="viewInvoiceDetails(${invoice.id})" title="Se/Rediger detaljer">
<i class="bi bi-pencil-square"></i>
</button>
<button class="btn btn-sm btn-outline-success" onclick="markSingleAsPaid(${invoice.id})" title="Marker betalt">
<i class="bi bi-check-circle"></i>
</button>
</td>
</tr>
`;
});
tbody.innerHTML = html;
} catch (error) {
console.error('Failed to load payment view:', error);
document.getElementById('paymentTable').innerHTML = `<tr><td colspan="7" class="text-center text-danger py-4">Fejl ved indlæsning</td></tr>`;
}
}
function updatePaymentSelection() {
const checkboxes = document.querySelectorAll('.payment-checkbox:checked');
const count = checkboxes.length;
const total = Array.from(checkboxes).reduce((sum, cb) => sum + parseFloat(cb.dataset.amount || 0), 0);
document.getElementById('selectedPaymentCount').textContent = count;
document.getElementById('selectedPaymentTotal').textContent = total.toLocaleString('da-DK', {minimumFractionDigits: 2, maximumFractionDigits: 2});
document.getElementById('paymentBulkActionsBar').style.display = count > 0 ? 'block' : 'none';
}
function toggleSelectAllPayment() {
const mainCheckbox = document.getElementById('selectAllPayment');
const checkboxes = document.querySelectorAll('.payment-checkbox');
checkboxes.forEach(cb => cb.checked = mainCheckbox.checked);
updatePaymentSelection();
}
// ========== READY FOR BOOKING VIEW FUNCTIONS ==========
async function loadReadyForBookingView() {
try {
const tbody = document.getElementById('readyTable');
tbody.innerHTML = `<tr><td colspan="7" class="text-center py-4"><div class="spinner-border text-primary" role="status"></div></td></tr>`;
const response = await fetch('/api/v1/supplier-invoices?approved_only=true');
const invoices = await response.json();
// Filter invoices where all lines have contra_account (ready for booking)
const readyInvoices = [];
for (const invoice of invoices) {
const lines = invoice.lines || [];
// Check if all lines have contra_account
const allLinesHaveAccount = lines.length > 0 && lines.every(line => line.contra_account);
if (allLinesHaveAccount && invoice.status !== 'sent_to_economic') {
readyInvoices.push({...invoice, line_count: lines.length});
}
}
// Update count badge
const countBadge = document.getElementById('readyCount');
if (readyInvoices.length > 0) {
countBadge.textContent = readyInvoices.length;
countBadge.style.display = 'inline-block';
} else {
countBadge.style.display = 'none';
}
if (readyInvoices.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">Ingen fakturaer klar til bogføring</td></tr>`;
return;
}
let html = '';
readyInvoices.forEach(invoice => {
html += `
<tr>
<td><input type="checkbox" class="form-check-input ready-checkbox" data-invoice-id="${invoice.id}" onchange="updateReadySelection()"></td>
<td>${invoice.invoice_number || '-'}</td>
<td>${invoice.vendor_full_name || invoice.vendor_name || '-'}</td>
<td>${invoice.invoice_date || '-'}</td>
<td>${parseFloat(invoice.total_amount || 0).toLocaleString('da-DK')} ${invoice.currency || 'DKK'}</td>
<td><span class="badge bg-success">${invoice.line_count} linjer OK</span></td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="viewInvoiceDetails(${invoice.id})" title="Se/Rediger detaljer">
<i class="bi bi-pencil-square"></i>
</button>
<button class="btn btn-sm btn-primary" onclick="sendToEconomic(${invoice.id})" title="Send til e-conomic">
<i class="bi bi-send"></i>
</button>
</td>
</tr>
`;
});
tbody.innerHTML = html;
} catch (error) {
console.error('Failed to load ready view:', error);
document.getElementById('readyTable').innerHTML = `<tr><td colspan="7" class="text-center text-danger py-4">Fejl ved indlæsning</td></tr>`;
}
}
function updateReadySelection() {
const checkboxes = document.querySelectorAll('.ready-checkbox:checked');
const count = checkboxes.length;
document.getElementById('selectedReadyCount').textContent = count;
document.getElementById('readyBulkActionsBar').style.display = count > 0 ? 'block' : 'none';
}
function toggleSelectAllReady() {
const mainCheckbox = document.getElementById('selectAllReady');
const checkboxes = document.querySelectorAll('.ready-checkbox');
checkboxes.forEach(cb => cb.checked = mainCheckbox.checked);
updateReadySelection();
}
// ========== LINE ITEMS VIEW FUNCTIONS ==========
async function loadLineItems() {
try {
const tbody = document.getElementById('linesTable');
tbody.innerHTML = `<tr><td colspan="10" class="text-center py-4"><div class="spinner-border text-primary" role="status"></div></td></tr>`;
// Get filter values
const purposeFilter = document.getElementById('linePurposeFilter').value;
const statusFilter = document.getElementById('lineStatusFilter').value;
const vendorFilter = document.getElementById('lineVendorFilter').value.toLowerCase();
// Fetch all invoices with lines
const response = await fetch('/api/v1/supplier-invoices');
const invoices = await response.json();
let allLines = [];
for (const invoice of invoices) {
const lines = invoice.lines || [];
lines.forEach(line => {
allLines.push({
...line,
invoice_number: invoice.invoice_number,
vendor_name: invoice.vendor_full_name || invoice.vendor_name,
invoice_id: invoice.id
});
});
}
// Only show lines marked for resale (or no purpose set yet)
allLines = allLines.filter(line => line.line_purpose === 'resale' || !line.line_purpose);
// Apply additional filters from dropdowns
if (purposeFilter) {
allLines = allLines.filter(line => line.line_purpose === purposeFilter);
}
if (statusFilter === 'not_invoiced') {
allLines = allLines.filter(line => !line.is_invoiced_to_customer);
} else if (statusFilter === 'invoiced') {
allLines = allLines.filter(line => line.is_invoiced_to_customer);
}
if (vendorFilter) {
allLines = allLines.filter(line => (line.vendor_name || '').toLowerCase().includes(vendorFilter));
}
if (allLines.length === 0) {
tbody.innerHTML = `<tr><td colspan="10" class="text-center text-muted py-4">Ingen varelinjer fundet</td></tr>`;
return;
}
let html = '';
allLines.forEach(line => {
const purposeOptions = ['resale', 'internal', 'project', 'stock'];
const purposeLabels = {
'resale': 'Videresalg',
'internal': 'Internt',
'project': 'Projekt',
'stock': 'Lager'
};
html += `
<tr>
<td>${line.invoice_number || '-'}</td>
<td>${line.vendor_name || '-'}</td>
<td>${line.description || '-'}</td>
<td>${line.quantity || '-'}</td>
<td>${parseFloat(line.total_amount || 0).toLocaleString('da-DK')}</td>
<td>
<input type="text" class="form-control form-control-sm" style="width: 80px;"
value="${line.contra_account || ''}"
onblur="updateLineField(${line.id}, ${line.invoice_id}, 'contra_account', this.value)"
placeholder="XXXX">
</td>
<td>
<select class="form-select form-select-sm" style="width: 120px;"
onchange="updateLineField(${line.id}, ${line.invoice_id}, 'line_purpose', this.value)">
<option value="">Vælg...</option>
${purposeOptions.map(opt => `<option value="${opt}" ${line.line_purpose === opt ? 'selected' : ''}>${purposeLabels[opt]}</option>`).join('')}
</select>
</td>
<td>
<input type="text" class="form-control form-control-sm" style="width: 120px;"
value="${line.resale_order_number || ''}"
onblur="updateLineField(${line.id}, ${line.invoice_id}, 'resale_order_number', this.value)"
placeholder="Ordre/Kunde">
</td>
<td>
${line.is_invoiced_to_customer ?
`<span class="badge bg-success">Faktureret</span>` :
`<span class="badge bg-secondary">Afventer</span>`}
</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="viewInvoiceDetails(${line.invoice_id})" title="Se/Rediger faktura">
<i class="bi bi-pencil-square"></i>
</button>
</td>
</tr>
`;
});
tbody.innerHTML = html;
} catch (error) {
console.error('Failed to load line items:', error);
document.getElementById('linesTable').innerHTML = `<tr><td colspan="10" class="text-center text-danger py-4">Fejl ved indlæsning</td></tr>`;
}
}
async function updateLineField(lineId, invoiceId, field, value) {
try {
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/lines/${lineId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({[field]: value})
});
if (!response.ok) throw new Error('Update failed');
console.log(`✅ Updated line ${lineId} ${field} = ${value}`);
// Reload line items to refresh view
setTimeout(() => loadLineItems(), 500);
} catch (error) {
console.error('Failed to update line field:', error);
alert('Kunne ikke opdatere felt');
}
}
function clearLineFilters() {
document.getElementById('linePurposeFilter').value = '';
document.getElementById('lineStatusFilter').value = '';
document.getElementById('lineVendorFilter').value = '';
loadLineItems();
}
// Switch to invoices tab
function switchToInvoicesTab() {
// Load invoices when switching to this tab
loadInvoices(currentFilter);
function switchToPaymentTab() {
// Load invoices sorted by due date for payment view
loadPaymentView();
}
function switchToReadyTab() {
// Load invoices ready for booking (all lines have contra_account)
loadReadyForBookingView();
}
function switchToLinesTab() {
// Load all line items with tracking
loadLineItems();
}
// Switch to pending files tab
@ -1300,12 +1919,12 @@ async function reviewExtractedData(fileId) {
</table>
` : '<p class="text-muted">Ingen linjer fundet</p>'}
${data.pdf_text_preview ? `
${data.pdf_text_preview && data.pdf_text_preview.trim() ? `
<h6 class="mt-3">PDF Tekst Preview:</h6>
<div class="border rounded p-3 bg-light" style="max-height: 500px; overflow-y: auto;">
<pre class="mb-0" style="font-size: 0.85rem; white-space: pre-wrap; word-wrap: break-word; font-family: monospace; line-height: 1.3;">${escapeHtml(data.pdf_text_preview)}</pre>
<div class="border rounded p-3 bg-body-secondary" style="max-height: 500px; overflow-y: auto;">
<pre class="mb-0 text-body" style="font-size: 0.85rem; white-space: pre-wrap; word-wrap: break-word; font-family: monospace; line-height: 1.3;">${escapeHtml(data.pdf_text_preview)}</pre>
</div>
` : ''}
` : '<div class="alert alert-warning mt-3"><i class="bi bi-exclamation-triangle me-2"></i>PDF tekst ikke tilgængelig - prøv at genbehandle filen</div>'}
`;
document.getElementById('reviewModalContent').innerHTML = modalContent;
@ -1720,7 +2339,7 @@ async function openManualEntryMode() {
const pdfResponse = await fetch(`/api/v1/supplier-invoices/files/${fileId}/pdf-text`);
if (pdfResponse.ok) {
const pdfData = await pdfResponse.json();
document.getElementById('pdfTextView').innerHTML = `<pre style="margin: 0; white-space: pre-wrap; word-wrap: break-word;">${escapeHtml(pdfData.pdf_text)}</pre>`;
document.getElementById('pdfTextView').innerHTML = `<pre class="mb-0 text-body" style="margin: 0; white-space: pre-wrap; word-wrap: break-word;">${escapeHtml(pdfData.pdf_text)}</pre>`;
} else {
document.getElementById('pdfTextView').innerHTML = '<div class="text-danger">Kunne ikke indlæse PDF tekst</div>';
}
@ -2057,6 +2676,7 @@ function removeManualLine(lineId) {
async function saveManualInvoice() {
try {
const fileId = document.getElementById('manualEntryFileId').value;
const editingInvoiceId = document.getElementById('manualEntryForm').dataset.editingInvoiceId;
const vendorId = document.getElementById('manualVendorId').value;
const invoiceNumber = document.getElementById('manualInvoiceNumber').value;
const invoiceDate = document.getElementById('manualInvoiceDate').value;
@ -2107,8 +2727,27 @@ async function saveManualInvoice() {
}
}
// Create invoice
const response = await fetch('/api/v1/supplier-invoices', {
let response;
if (editingInvoiceId) {
// UPDATE existing invoice
response = await fetch(`/api/v1/supplier-invoices/${editingInvoiceId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
vendor_id: parseInt(vendorId),
invoice_number: invoiceNumber,
invoice_date: invoiceDate,
due_date: dueDate || invoiceDate,
total_amount: totalAmount,
currency: currency,
invoice_type: invoiceType,
notes: notes,
lines: lines
})
});
} else {
// CREATE new invoice
response = await fetch('/api/v1/supplier-invoices', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
@ -2120,31 +2759,45 @@ async function saveManualInvoice() {
currency: currency,
invoice_type: invoiceType,
status: invoiceType === 'credit_note' ? 'credited' : 'unpaid',
notes: `Manuel indtastning fra fil ID ${fileId}. ${notes}`.trim(),
notes: fileId ? `Manuel indtastning fra fil ID ${fileId}. ${notes}`.trim() : notes,
lines: lines
})
});
}
if (response.ok) {
const result = await response.json();
// Mark file as completed
// Mark file as completed if this was from file processing
if (fileId) {
await fetch(`/api/v1/supplier-invoices/files/${fileId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({status: 'completed'})
});
}
// Close modal and refresh
bootstrap.Modal.getInstance(document.getElementById('manualEntryModal')).hide();
alert(`✅ ${invoiceType === 'credit_note' ? 'Kreditnota' : 'Faktura'} oprettet!\n\nFakturanummer: ${invoiceNumber}\nBeløb: ${totalAmount} ${currency}`);
if (editingInvoiceId) {
alert('✅ Faktura opdateret!');
} else {
alert(`✅ ${invoiceType === 'credit_note' ? 'Kreditnota' : 'Faktura'} oprettet!`);
}
// Clear editing flag
delete document.getElementById('manualEntryForm').dataset.editingInvoiceId;
// Refresh views
loadPendingFiles();
loadInvoices();
loadPaymentView();
loadReadyForBookingView();
loadLineItems();
loadStats();
} else {
const error = await response.json();
alert('Kunne ikke oprette faktura: ' + (error.detail || 'Ukendt fejl'));
alert('Kunne ikke gemme faktura: ' + (error.detail || 'Ukendt fejl'));
}
} catch (error) {

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,16 @@ async def supplier_invoices_page(request: Request):
"""Supplier invoices (kassekladde) page"""
return templates.TemplateResponse("billing/frontend/supplier_invoices.html", {
"request": request,
"title": "Kassekladde"
"title": "Leverandør fakturaer"
})
@router.get("/billing/supplier-invoices2", response_class=HTMLResponse)
async def supplier_invoices_v1_backup(request: Request):
"""Supplier invoices V1 backup - original version"""
return templates.TemplateResponse("billing/frontend/supplier_invoices_v1_backup.html", {
"request": request,
"title": "Leverandør fakturaer (V1 Backup)"
})

View File

@ -85,14 +85,17 @@ def execute_query(query: str, params: tuple = None, fetch: bool = True):
def execute_insert(query: str, params: tuple = None):
"""Execute INSERT query and return new ID (requires RETURNING id clause)"""
"""Execute INSERT query and return new ID (requires RETURNING clause)"""
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(query, params)
conn.commit()
result = cursor.fetchone()
return result['id'] if result and 'id' in result else None
if result:
# Return first column value regardless of name (id, extraction_id, file_id, etc.)
return result[list(result.keys())[0]] if result else None
return None
except Exception as e:
conn.rollback()
logger.error(f"Insert error: {e}")

View File

@ -1068,11 +1068,13 @@ async def execute_workflows_for_email(email_id: int):
FROM email_messages
WHERE id = %s AND deleted_at IS NULL
"""
email_data = execute_query(query, (email_id,))
email_result = execute_query(query, (email_id,))
if not email_data:
if not email_result:
raise HTTPException(status_code=404, detail="Email not found")
email_data = email_result[0] # Get first row as dict
# Execute workflows
result = await email_workflow_service.execute_workflows(email_data)

View File

@ -452,6 +452,81 @@ class EconomicService:
logger.error(f"❌ Error fetching journals: {e}")
raise
async def get_accounts(self) -> List[Dict]:
"""
Get chart of accounts (kontoplan) from e-conomic
Returns:
List of account dictionaries with accountNumber, name, accountType, vatCode
"""
try:
url = f"{self.api_url}/accounts"
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=self._get_headers()) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(f"e-conomic API error: {response.status} - {error_text}")
data = await response.json()
# Extract relevant account info
accounts = []
for account in data.get('collection', []):
accounts.append({
'accountNumber': account.get('accountNumber'),
'name': account.get('name'),
'accountType': account.get('accountType'),
'vatCode': account.get('vatCode', {}).get('vatCode') if account.get('vatCode') else None,
'balance': account.get('balance', 0.0)
})
logger.info(f"📥 Fetched {len(accounts)} accounts from e-conomic")
return accounts
except Exception as e:
logger.error(f"❌ Error fetching accounts: {e}")
raise
async def sync_accounts_to_database(self) -> int:
"""
Fetch accounts from e-conomic and cache them in database
Returns:
Number of accounts synced
"""
from app.core.database import execute_query
try:
accounts = await self.get_accounts()
# Upsert accounts into database
for account in accounts:
execute_query("""
INSERT INTO economic_accounts
(account_number, name, account_type, vat_code, balance, last_synced)
VALUES (%s, %s, %s, %s, %s, NOW())
ON CONFLICT (account_number)
DO UPDATE SET
name = EXCLUDED.name,
account_type = EXCLUDED.account_type,
vat_code = EXCLUDED.vat_code,
balance = EXCLUDED.balance,
last_synced = NOW()
""", (
account['accountNumber'],
account['name'],
account['accountType'],
account['vatCode'],
account['balance']
))
logger.info(f"✅ Synced {len(accounts)} accounts to database")
return len(accounts)
except Exception as e:
logger.error(f"❌ Error syncing accounts to database: {e}")
raise
async def create_journal_supplier_invoice(self,
journal_number: int,
supplier_number: int,

View File

@ -104,7 +104,7 @@ class EmailWorkflowService:
ORDER BY priority ASC
"""
workflows = execute_query_single(query, (classification, confidence))
workflows = execute_query(query, (classification, confidence))
# Filter by additional patterns
matching = []
@ -406,9 +406,10 @@ class EmailWorkflowService:
vendor_id = result['id']
# Check if already linked to avoid duplicate updates
current_vendor = execute_query_single(
result_vendor = execute_query(
"SELECT supplier_id FROM email_messages WHERE id = %s",
(email_data['id'],))
current_vendor = result_vendor[0] if result_vendor else None
if current_vendor and current_vendor.get('supplier_id') == vendor_id:
logger.info(f"⏭️ Email already linked to vendor {vendor_id}, skipping duplicate update")
@ -457,7 +458,7 @@ class EmailWorkflowService:
vendor_id = email_data.get('supplier_id')
# Get PDF attachments from email
attachments = execute_query_single(
attachments = execute_query(
"""SELECT filename, file_path, size_bytes, content_type
FROM email_attachments
WHERE email_id = %s AND content_type = 'application/pdf'""",

View File

@ -259,7 +259,7 @@
</a>
<ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="#">Fakturaer</a></li>
<li><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Kassekladde</a></li>
<li><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Leverandør fakturaer</a></li>
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
<li><a class="dropdown-item py-2" href="#">Betalinger</a></li>
<li><hr class="dropdown-divider"></li>

View File

@ -0,0 +1,61 @@
-- Migration 1000: Supplier Invoice Enhancements
-- Adds support for accounting integration (modkonti) and line item tracking
-- Date: 2026-01-06
-- Add columns to supplier_invoice_lines for accounting integration
ALTER TABLE supplier_invoice_lines
ADD COLUMN IF NOT EXISTS contra_account VARCHAR(10),
ADD COLUMN IF NOT EXISTS line_purpose VARCHAR(50),
ADD COLUMN IF NOT EXISTS resale_customer_id INT REFERENCES customers(id),
ADD COLUMN IF NOT EXISTS resale_order_number VARCHAR(50),
ADD COLUMN IF NOT EXISTS is_invoiced_to_customer BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS invoiced_date TIMESTAMP;
-- Create index for faster filtering by purpose
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_purpose
ON supplier_invoice_lines(line_purpose);
-- Create index for faster filtering by resale status
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_resale
ON supplier_invoice_lines(is_invoiced_to_customer);
-- Create table to cache e-conomic chart of accounts (kontoplan)
CREATE TABLE IF NOT EXISTS economic_accounts (
account_number INT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
account_type VARCHAR(50),
vat_code VARCHAR(10),
balance DECIMAL(15, 2),
is_active BOOLEAN DEFAULT TRUE,
last_synced TIMESTAMP DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW()
);
-- Create index for fast account lookups
CREATE INDEX IF NOT EXISTS idx_economic_accounts_active
ON economic_accounts(is_active) WHERE is_active = TRUE;
-- Create index for account type filtering
CREATE INDEX IF NOT EXISTS idx_economic_accounts_type
ON economic_accounts(account_type);
-- Add comments for documentation
COMMENT ON COLUMN supplier_invoice_lines.contra_account IS 'e-conomic account number (modkonto) for this line item';
COMMENT ON COLUMN supplier_invoice_lines.line_purpose IS 'Purpose: resale, internal, project, stock';
COMMENT ON COLUMN supplier_invoice_lines.resale_customer_id IS 'Customer ID if this line is for resale';
COMMENT ON COLUMN supplier_invoice_lines.resale_order_number IS 'Order number if linked to customer order';
COMMENT ON COLUMN supplier_invoice_lines.is_invoiced_to_customer IS 'TRUE when this line has been invoiced to customer';
COMMENT ON COLUMN supplier_invoice_lines.invoiced_date IS 'Date when line was invoiced to customer';
COMMENT ON TABLE economic_accounts IS 'Cached e-conomic chart of accounts (kontoplan) for dropdown menus';
COMMENT ON COLUMN economic_accounts.account_number IS 'e-conomic account number (e.g., 5810, 1970)';
COMMENT ON COLUMN economic_accounts.name IS 'Account name/description';
COMMENT ON COLUMN economic_accounts.account_type IS 'Type: profitAndLoss, status, etc.';
COMMENT ON COLUMN economic_accounts.vat_code IS 'Associated VAT code if applicable';
COMMENT ON COLUMN economic_accounts.last_synced IS 'Last time this account was updated from e-conomic API';
-- Success message
DO $$
BEGIN
RAISE NOTICE '✅ Migration 1000 completed: Supplier invoice enhancements added';
END $$;

View File

@ -13,3 +13,4 @@ apscheduler==3.10.4
pandas==2.2.3
openpyxl==3.1.2
extract-msg==0.55.0
pdfplumber==0.11.4