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:
|
if not file_info:
|
||||||
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
||||||
|
|
||||||
|
file_data = file_info[0]
|
||||||
|
|
||||||
# Read PDF text
|
# Read PDF text
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
file_path = Path(file_info['file_path'])
|
file_path = Path(file_data['file_path'])
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
raise HTTPException(status_code=404, detail=f"Fil ikke fundet på disk: {file_path}")
|
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 {
|
return {
|
||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
"filename": file_info['filename'],
|
"filename": file_data['filename'],
|
||||||
"pdf_text": pdf_text
|
"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'):
|
if extraction and extraction.get('llm_response_json'):
|
||||||
import json
|
import json
|
||||||
try:
|
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')}")
|
logger.info(f"📊 Parsed llm_response_json: invoice_number={llm_json_data.get('invoice_number')}")
|
||||||
except Exception as e:
|
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 = []
|
extraction_lines = []
|
||||||
if extraction:
|
if extraction:
|
||||||
extraction_lines = execute_query_single(
|
extraction_lines = execute_query(
|
||||||
"""SELECT * FROM extraction_lines
|
"""SELECT * FROM extraction_lines
|
||||||
WHERE extraction_id = %s
|
WHERE extraction_id = %s
|
||||||
ORDER BY line_number""",
|
ORDER BY line_number""",
|
||||||
@ -485,13 +495,14 @@ async def download_pending_file(file_id: int):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Get file info
|
# Get file info
|
||||||
file_info = execute_query(
|
file_result = execute_query(
|
||||||
"SELECT * FROM incoming_files WHERE file_id = %s",
|
"SELECT * FROM incoming_files WHERE file_id = %s",
|
||||||
(file_id,))
|
(file_id,))
|
||||||
|
|
||||||
if not file_info:
|
if not file_result:
|
||||||
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
||||||
|
|
||||||
|
file_info = file_result[0] # Get first row
|
||||||
file_path = Path(file_info['file_path'])
|
file_path = Path(file_info['file_path'])
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="Fil findes ikke på disk")
|
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):
|
async def get_supplier_invoice(invoice_id: int):
|
||||||
"""Get single supplier invoice with lines"""
|
"""Get single supplier invoice with lines"""
|
||||||
try:
|
try:
|
||||||
invoice = execute_query(
|
invoice_result = execute_query(
|
||||||
"""SELECT si.*, v.name as vendor_full_name, v.economic_supplier_number as vendor_economic_id
|
"""SELECT si.*, v.name as vendor_full_name, v.economic_supplier_number as vendor_economic_id
|
||||||
FROM supplier_invoices si
|
FROM supplier_invoices si
|
||||||
LEFT JOIN vendors v ON si.vendor_id = v.id
|
LEFT JOIN vendors v ON si.vendor_id = v.id
|
||||||
WHERE si.id = %s""",
|
WHERE si.id = %s""",
|
||||||
(invoice_id,))
|
(invoice_id,))
|
||||||
|
|
||||||
if not invoice:
|
if not invoice_result:
|
||||||
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
|
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
|
||||||
|
|
||||||
|
invoice = invoice_result[0]
|
||||||
|
|
||||||
# Get lines
|
# Get lines
|
||||||
lines = execute_query_single(
|
lines = execute_query_single(
|
||||||
"SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number",
|
"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))
|
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}")
|
@router.delete("/supplier-invoices/{invoice_id}")
|
||||||
async def delete_supplier_invoice(invoice_id: int):
|
async def delete_supplier_invoice(invoice_id: int):
|
||||||
"""Delete supplier invoice (soft delete if integrated with e-conomic)"""
|
"""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))
|
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 ==========
|
# ========== STATISTICS & REPORTS ==========
|
||||||
|
|
||||||
@router.get("/supplier-invoices/stats/overview")
|
@router.get("/supplier-invoices/stats/overview")
|
||||||
@ -1877,7 +2019,8 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
|
|||||||
"""INSERT INTO extractions
|
"""INSERT INTO extractions
|
||||||
(file_id, vendor_matched_id, document_id, document_date, due_date,
|
(file_id, vendor_matched_id, document_id, document_date, due_date,
|
||||||
total_amount, currency, document_type, confidence, llm_response_json, status)
|
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,
|
(file_id, vendor_id,
|
||||||
extracted_fields.get('invoice_number'),
|
extracted_fields.get('invoice_number'),
|
||||||
extracted_fields.get('invoice_date'),
|
extracted_fields.get('invoice_date'),
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{% extends "shared/frontend/base.html" %}
|
{% extends "shared/frontend/base.html" %}
|
||||||
|
|
||||||
{% block title %}Kassekladde - BMC Hub{% endblock %}
|
{% block title %}Leverandør fakturaer - BMC Hub{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@ -157,13 +157,24 @@
|
|||||||
<!-- Tab Navigation -->
|
<!-- Tab Navigation -->
|
||||||
<ul class="nav nav-tabs mb-4" id="mainTabs">
|
<ul class="nav nav-tabs mb-4" id="mainTabs">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" id="invoices-tab" data-bs-toggle="tab" href="#invoices-content" onclick="switchToInvoicesTab()">
|
<a class="nav-link active" id="payment-tab" data-bs-toggle="tab" href="#payment-content" onclick="switchToPaymentTab()">
|
||||||
<i class="bi bi-receipt me-2"></i>Fakturaer
|
<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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" id="pending-files-tab" data-bs-toggle="tab" href="#pending-files-content" onclick="switchToPendingFilesTab()">
|
<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>
|
<span class="badge bg-warning text-dark ms-2" id="pendingFilesCount" style="display: none;">0</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@ -172,53 +183,33 @@
|
|||||||
<!-- Tab Content -->
|
<!-- Tab Content -->
|
||||||
<div class="tab-content" id="mainTabContent">
|
<div class="tab-content" id="mainTabContent">
|
||||||
|
|
||||||
<!-- Invoices Tab -->
|
<!-- Til Betaling Tab -->
|
||||||
<div class="tab-pane fade show active" id="invoices-content">
|
<div class="tab-pane fade show active" id="payment-content">
|
||||||
|
|
||||||
<!-- Filters -->
|
<div class="alert alert-info mb-4">
|
||||||
<div class="card mb-4">
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
<div class="card-body">
|
<strong>Til Betaling:</strong> Fakturaer sorteret efter forfaldsdato. Brug checkboxes til at vælge hvilke der skal betales.
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Bulk Actions Bar for Invoices -->
|
<!-- Bulk Actions Bar for Payment -->
|
||||||
<div class="alert alert-light border mb-3" id="invoiceBulkActionsBar" style="display: none;">
|
<div class="alert alert-light border mb-3" id="paymentBulkActionsBar" style="display: none;">
|
||||||
<div class="d-flex align-items-center justify-content-between">
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
<div>
|
<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>
|
||||||
<div class="btn-group" role="group">
|
<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">
|
<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
|
<i class="bi bi-cash-coin me-1"></i>Marker Betalt
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Invoices Table -->
|
<!-- Payment Table -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@ -226,21 +217,153 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 40px;">
|
<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>
|
||||||
<th>Fakturanr.</th>
|
<th>Fakturanr.</th>
|
||||||
<th>Leverandør</th>
|
<th>Leverandør</th>
|
||||||
<th>Fakturadato</th>
|
<th>Fakturadato</th>
|
||||||
<th>Forfaldsdato</th>
|
|
||||||
<th>Beløb</th>
|
<th>Beløb</th>
|
||||||
<th>Status</th>
|
<th>Linjer</th>
|
||||||
<th>e-conomic</th>
|
|
||||||
<th>Handlinger</th>
|
<th>Handlinger</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="invoicesTable">
|
<tbody id="readyTable">
|
||||||
<tr>
|
<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">
|
<div class="spinner-border text-primary" role="status">
|
||||||
<span class="visually-hidden">Indlæser...</span>
|
<span class="visually-hidden">Indlæser...</span>
|
||||||
</div>
|
</div>
|
||||||
@ -534,7 +657,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</h6>
|
</h6>
|
||||||
<!-- PDF Text View (default, selectable) -->
|
<!-- 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 class="text-muted text-center py-5">Indlæser PDF tekst...</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- PDF Original View (hidden by default) -->
|
<!-- PDF Original View (hidden by default) -->
|
||||||
@ -730,7 +853,7 @@ let lastFocusedField = null;
|
|||||||
// Load data on page load
|
// Load data on page load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadStats();
|
loadStats();
|
||||||
loadInvoices();
|
loadPaymentView(); // Load payment view by default (first tab)
|
||||||
loadVendors();
|
loadVendors();
|
||||||
setupManualEntryTextSelection();
|
setupManualEntryTextSelection();
|
||||||
setDefaultDates();
|
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 ==========
|
// ========== PENDING FILES FUNCTIONS ==========
|
||||||
|
|
||||||
// Load pending files count for badge (on page load)
|
// 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
|
// Switch to invoices tab
|
||||||
function switchToInvoicesTab() {
|
function switchToPaymentTab() {
|
||||||
// Load invoices when switching to this tab
|
// Load invoices sorted by due date for payment view
|
||||||
loadInvoices(currentFilter);
|
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
|
// Switch to pending files tab
|
||||||
@ -1300,12 +1919,12 @@ async function reviewExtractedData(fileId) {
|
|||||||
</table>
|
</table>
|
||||||
` : '<p class="text-muted">Ingen linjer fundet</p>'}
|
` : '<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>
|
<h6 class="mt-3">PDF Tekst Preview:</h6>
|
||||||
<div class="border rounded p-3 bg-light" style="max-height: 500px; overflow-y: auto;">
|
<div class="border rounded p-3 bg-body-secondary" 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>
|
<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>
|
||||||
` : ''}
|
` : '<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;
|
document.getElementById('reviewModalContent').innerHTML = modalContent;
|
||||||
@ -1720,7 +2339,7 @@ async function openManualEntryMode() {
|
|||||||
const pdfResponse = await fetch(`/api/v1/supplier-invoices/files/${fileId}/pdf-text`);
|
const pdfResponse = await fetch(`/api/v1/supplier-invoices/files/${fileId}/pdf-text`);
|
||||||
if (pdfResponse.ok) {
|
if (pdfResponse.ok) {
|
||||||
const pdfData = await pdfResponse.json();
|
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 {
|
} else {
|
||||||
document.getElementById('pdfTextView').innerHTML = '<div class="text-danger">Kunne ikke indlæse PDF tekst</div>';
|
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() {
|
async function saveManualInvoice() {
|
||||||
try {
|
try {
|
||||||
const fileId = document.getElementById('manualEntryFileId').value;
|
const fileId = document.getElementById('manualEntryFileId').value;
|
||||||
|
const editingInvoiceId = document.getElementById('manualEntryForm').dataset.editingInvoiceId;
|
||||||
const vendorId = document.getElementById('manualVendorId').value;
|
const vendorId = document.getElementById('manualVendorId').value;
|
||||||
const invoiceNumber = document.getElementById('manualInvoiceNumber').value;
|
const invoiceNumber = document.getElementById('manualInvoiceNumber').value;
|
||||||
const invoiceDate = document.getElementById('manualInvoiceDate').value;
|
const invoiceDate = document.getElementById('manualInvoiceDate').value;
|
||||||
@ -2107,8 +2727,27 @@ async function saveManualInvoice() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create invoice
|
let response;
|
||||||
const response = await fetch('/api/v1/supplier-invoices', {
|
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',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -2120,31 +2759,45 @@ async function saveManualInvoice() {
|
|||||||
currency: currency,
|
currency: currency,
|
||||||
invoice_type: invoiceType,
|
invoice_type: invoiceType,
|
||||||
status: invoiceType === 'credit_note' ? 'credited' : 'unpaid',
|
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
|
lines: lines
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
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}`, {
|
await fetch(`/api/v1/supplier-invoices/files/${fileId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({status: 'completed'})
|
body: JSON.stringify({status: 'completed'})
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Close modal and refresh
|
// Close modal and refresh
|
||||||
bootstrap.Modal.getInstance(document.getElementById('manualEntryModal')).hide();
|
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();
|
loadPendingFiles();
|
||||||
loadInvoices();
|
loadPaymentView();
|
||||||
|
loadReadyForBookingView();
|
||||||
|
loadLineItems();
|
||||||
loadStats();
|
loadStats();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
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) {
|
} 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"""
|
"""Supplier invoices (kassekladde) page"""
|
||||||
return templates.TemplateResponse("billing/frontend/supplier_invoices.html", {
|
return templates.TemplateResponse("billing/frontend/supplier_invoices.html", {
|
||||||
"request": request,
|
"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):
|
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()
|
conn = get_db_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
result = cursor.fetchone()
|
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:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
logger.error(f"Insert error: {e}")
|
logger.error(f"Insert error: {e}")
|
||||||
|
|||||||
@ -1068,11 +1068,13 @@ async def execute_workflows_for_email(email_id: int):
|
|||||||
FROM email_messages
|
FROM email_messages
|
||||||
WHERE id = %s AND deleted_at IS NULL
|
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")
|
raise HTTPException(status_code=404, detail="Email not found")
|
||||||
|
|
||||||
|
email_data = email_result[0] # Get first row as dict
|
||||||
|
|
||||||
# Execute workflows
|
# Execute workflows
|
||||||
result = await email_workflow_service.execute_workflows(email_data)
|
result = await email_workflow_service.execute_workflows(email_data)
|
||||||
|
|
||||||
|
|||||||
@ -452,6 +452,81 @@ class EconomicService:
|
|||||||
logger.error(f"❌ Error fetching journals: {e}")
|
logger.error(f"❌ Error fetching journals: {e}")
|
||||||
raise
|
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,
|
async def create_journal_supplier_invoice(self,
|
||||||
journal_number: int,
|
journal_number: int,
|
||||||
supplier_number: int,
|
supplier_number: int,
|
||||||
|
|||||||
@ -104,7 +104,7 @@ class EmailWorkflowService:
|
|||||||
ORDER BY priority ASC
|
ORDER BY priority ASC
|
||||||
"""
|
"""
|
||||||
|
|
||||||
workflows = execute_query_single(query, (classification, confidence))
|
workflows = execute_query(query, (classification, confidence))
|
||||||
|
|
||||||
# Filter by additional patterns
|
# Filter by additional patterns
|
||||||
matching = []
|
matching = []
|
||||||
@ -406,9 +406,10 @@ class EmailWorkflowService:
|
|||||||
vendor_id = result['id']
|
vendor_id = result['id']
|
||||||
|
|
||||||
# Check if already linked to avoid duplicate updates
|
# 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",
|
"SELECT supplier_id FROM email_messages WHERE id = %s",
|
||||||
(email_data['id'],))
|
(email_data['id'],))
|
||||||
|
current_vendor = result_vendor[0] if result_vendor else None
|
||||||
|
|
||||||
if current_vendor and current_vendor.get('supplier_id') == vendor_id:
|
if current_vendor and current_vendor.get('supplier_id') == vendor_id:
|
||||||
logger.info(f"⏭️ Email already linked to vendor {vendor_id}, skipping duplicate update")
|
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')
|
vendor_id = email_data.get('supplier_id')
|
||||||
|
|
||||||
# Get PDF attachments from email
|
# Get PDF attachments from email
|
||||||
attachments = execute_query_single(
|
attachments = execute_query(
|
||||||
"""SELECT filename, file_path, size_bytes, content_type
|
"""SELECT filename, file_path, size_bytes, content_type
|
||||||
FROM email_attachments
|
FROM email_attachments
|
||||||
WHERE email_id = %s AND content_type = 'application/pdf'""",
|
WHERE email_id = %s AND content_type = 'application/pdf'""",
|
||||||
|
|||||||
@ -259,7 +259,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu mt-2">
|
<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="#">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="#">Abonnementer</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="#">Betalinger</a></li>
|
<li><a class="dropdown-item py-2" href="#">Betalinger</a></li>
|
||||||
<li><hr class="dropdown-divider"></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
|
pandas==2.2.3
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
extract-msg==0.55.0
|
extract-msg==0.55.0
|
||||||
|
pdfplumber==0.11.4
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user