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:
parent
42b766b31e
commit
c855f5d027
248
MIGRATION_GUIDE_v2.0.0.md
Normal file
248
MIGRATION_GUIDE_v2.0.0.md
Normal 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
|
||||
@ -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'),
|
||||
|
||||
@ -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"> </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,44 +2727,77 @@ async function saveManualInvoice() {
|
||||
}
|
||||
}
|
||||
|
||||
// Create invoice
|
||||
const response = await fetch('/api/v1/supplier-invoices', {
|
||||
method: 'POST',
|
||||
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,
|
||||
status: invoiceType === 'credit_note' ? 'credited' : 'unpaid',
|
||||
notes: `Manuel indtastning fra fil ID ${fileId}. ${notes}`.trim(),
|
||||
lines: lines
|
||||
})
|
||||
});
|
||||
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({
|
||||
vendor_id: parseInt(vendorId),
|
||||
invoice_number: invoiceNumber,
|
||||
invoice_date: invoiceDate,
|
||||
due_date: dueDate || invoiceDate,
|
||||
total_amount: totalAmount,
|
||||
currency: currency,
|
||||
invoice_type: invoiceType,
|
||||
status: invoiceType === 'credit_note' ? 'credited' : 'unpaid',
|
||||
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
|
||||
await fetch(`/api/v1/supplier-invoices/files/${fileId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({status: '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) {
|
||||
|
||||
3487
app/billing/frontend/supplier_invoices_v1_backup.html
Normal file
3487
app/billing/frontend/supplier_invoices_v1_backup.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)"
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'""",
|
||||
|
||||
@ -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>
|
||||
|
||||
61
migrations/1000_supplier_invoice_enhancements.sql
Normal file
61
migrations/1000_supplier_invoice_enhancements.sql
Normal 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 $$;
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user