From 13dc1736b434bbc4b732e1b22d1a97b98f84e698 Mon Sep 17 00:00:00 2001 From: Christian Date: Sun, 12 Apr 2026 09:26:35 +0200 Subject: [PATCH] feat: Implement supplier invoice case traceability and purchase line classification --- app/billing/backend/supplier_invoices.py | 138 +++++++++++- app/modules/sag/backend/router.py | 206 ++++++++++++++++-- app/modules/sag/templates/detail.html | 87 +++++++- ...169_supplier_invoice_case_traceability.sql | 47 ++++ 4 files changed, 448 insertions(+), 30 deletions(-) create mode 100644 migrations/169_supplier_invoice_case_traceability.sql diff --git a/app/billing/backend/supplier_invoices.py b/app/billing/backend/supplier_invoices.py index f92ef92..4f19e91 100644 --- a/app/billing/backend/supplier_invoices.py +++ b/app/billing/backend/supplier_invoices.py @@ -22,6 +22,121 @@ import re logger = logging.getLogger(__name__) router = APIRouter() +_PURCHASE_CASE_TYPE = "indkøb" + + +def _resolve_procurement_customer_id() -> int: + """Find customer used for internally-owned procurement cases.""" + configured_id = getattr(settings, "PROCUREMENT_CASE_CUSTOMER_ID", None) + if configured_id: + row = execute_query_single( + "SELECT id FROM customers WHERE id = %s AND is_active = true", + (configured_id,) + ) + if row: + return int(row["id"]) + + bmc_row = execute_query_single( + """ + SELECT id + FROM customers + WHERE is_active = true + AND LOWER(name) LIKE %s + ORDER BY CASE WHEN LOWER(name) LIKE %s THEN 0 ELSE 1 END, id + LIMIT 1 + """, + ("%bmc%", "%bmc networks%") + ) + if bmc_row: + return int(bmc_row["id"]) + + fallback = execute_query_single( + "SELECT id FROM customers WHERE is_active = true ORDER BY id LIMIT 1" + ) + if fallback: + return int(fallback["id"]) + + raise ValueError("No active customer available for procurement case creation") + + +def _ensure_case_for_supplier_invoice( + invoice_id: int, + invoice_number: str, + vendor_name: Optional[str], + total_amount, + currency: Optional[str], + file_id: Optional[int] = None, +) -> Optional[int]: + """Create and link a procurement case if missing for a supplier invoice.""" + existing = execute_query_single( + "SELECT sag_id FROM supplier_invoices WHERE id = %s", + (invoice_id,) + ) + if not existing: + return None + + current_sag_id = existing.get("sag_id") + if current_sag_id: + return int(current_sag_id) + + customer_id = _resolve_procurement_customer_id() + vendor_label = (vendor_name or "Ukendt leverandør").strip() + currency_label = (currency or "DKK").strip() + amount_label = f"{Decimal(total_amount or 0):.2f}" + + case_title = f"Leverandørfaktura {invoice_number} - {vendor_label}" + case_description = ( + "Auto-oprettet fra leverandørfaktura\n" + f"Faktura: {invoice_number}\n" + f"Leverandør: {vendor_label}\n" + f"Beløb: {amount_label} {currency_label}\n" + f"Invoice ID: {invoice_id}\n" + f"Fil ID: {file_id or '-'}" + ) + + case_row = execute_query_single( + """ + INSERT INTO sag_sager (titel, beskrivelse, type, status, customer_id) + VALUES (%s, %s, %s, %s, %s) + RETURNING id + """, + (case_title, case_description, _PURCHASE_CASE_TYPE, "åben", customer_id) + ) + if not case_row: + return None + + sag_id = int(case_row["id"]) + + execute_update( + "UPDATE supplier_invoices SET sag_id = %s WHERE id = %s", + (sag_id, invoice_id) + ) + + try: + execute_update( + """ + INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) + VALUES (%s, %s, %s, %s) + """, + ( + sag_id, + "Invoice Bot", + ( + "🔗 Automatisk oprettet fra leverandørfaktura\n" + f"Faktura: {invoice_number}\n" + f"Leverandør: {vendor_label}\n" + f"Beløb: {amount_label} {currency_label}\n" + f"Invoice ID: {invoice_id}" + ), + True, + ), + ) + except Exception as comment_error: + logger.warning("⚠️ Could not create case comment for supplier invoice %s: %s", invoice_id, comment_error) + + logger.info("✅ Linked supplier invoice %s to SAG-%s", invoice_id, sag_id) + return sag_id + def _smart_extract_lines(text: str) -> List[Dict]: """ @@ -957,7 +1072,7 @@ async def create_invoice_from_extraction(file_id: int): if not extraction: raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil") - extraction_data = extraction[0] + extraction_data = extraction # Check if vendor is matched if not extraction_data['vendor_matched_id']: @@ -975,7 +1090,7 @@ async def create_invoice_from_extraction(file_id: int): raise HTTPException(status_code=400, detail="Faktura er allerede oprettet fra denne extraction") # Get extraction lines - lines = execute_query_single( + lines = execute_query( """SELECT * FROM extraction_lines WHERE extraction_id = %s ORDER BY line_number""", @@ -1036,6 +1151,15 @@ async def create_invoice_from_extraction(file_id: int): invoice_type ) ) + + sag_id = _ensure_case_for_supplier_invoice( + invoice_id=invoice_id, + invoice_number=invoice_number, + vendor_name=extraction.get("vendor_name"), + total_amount=extraction_data.get("total_amount"), + currency=extraction_data.get("currency"), + file_id=file_id, + ) # Create invoice lines if lines: @@ -1067,6 +1191,7 @@ async def create_invoice_from_extraction(file_id: int): return { "status": "success", "invoice_id": invoice_id, + "sag_id": sag_id, "invoice_number": invoice_number, "vendor_name": extraction['vendor_name'], "total_amount": extraction['total_amount'], @@ -3088,6 +3213,15 @@ async def create_invoice_from_file(file_id: int, vendor_id: int) -> int: f"Oprettet fra fil: {file_info['filename']} (file_id: {file_id})" ) ) + + _ensure_case_for_supplier_invoice( + invoice_id=invoice_id, + invoice_number=f"PENDING-{file_id}", + vendor_name=None, + total_amount=0, + currency="DKK", + file_id=file_id, + ) logger.info(f"✅ Created minimal invoice {invoice_id} for file {file_id}, vendor {vendor_id}") return invoice_id diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 9a8379a..8c548d9 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -2240,6 +2240,73 @@ async def get_case_calendar_events(sag_id: int, include_children: bool = True): # VAREKØB & SALG - CRUD (Case-linked sale items) # ============================================================================ +_PURCHASE_PURPOSE_VALUES = { + "salg", + "lager", + "asset", + "intern_brug", + "retur_reklamation", + "projekt_omkostning", +} + + +def _normalize_purchase_purpose(value: Optional[object], item_type: str) -> Optional[str]: + if item_type != "purchase": + return None + + if value is None: + return None + + normalized = str(value).strip().lower() + if not normalized: + return None + + if normalized not in _PURCHASE_PURPOSE_VALUES: + allowed = ", ".join(sorted(_PURCHASE_PURPOSE_VALUES)) + raise HTTPException(status_code=400, detail=f"purchase_purpose must be one of: {allowed}") + + return normalized + + +def _resolve_purchase_traceability( + supplier_invoice_id_value: Optional[object], + supplier_invoice_line_id_value: Optional[object], +) -> tuple[Optional[int], Optional[int]]: + supplier_invoice_id = _coerce_optional_int(supplier_invoice_id_value, "supplier_invoice_id") + supplier_invoice_line_id = _coerce_optional_int(supplier_invoice_line_id_value, "supplier_invoice_line_id") + + if supplier_invoice_line_id is not None and supplier_invoice_id is None: + line_row = execute_query_single( + "SELECT supplier_invoice_id FROM supplier_invoice_lines WHERE id = %s", + (supplier_invoice_line_id,) + ) + if not line_row: + raise HTTPException(status_code=400, detail="Invalid supplier_invoice_line_id") + supplier_invoice_id = int(line_row["supplier_invoice_id"]) + + if supplier_invoice_id is not None: + invoice_exists = execute_query_single( + "SELECT id FROM supplier_invoices WHERE id = %s", + (supplier_invoice_id,) + ) + if not invoice_exists: + raise HTTPException(status_code=400, detail="Invalid supplier_invoice_id") + + if supplier_invoice_line_id is not None: + line_exists = execute_query_single( + "SELECT id, supplier_invoice_id FROM supplier_invoice_lines WHERE id = %s", + (supplier_invoice_line_id,) + ) + if not line_exists: + raise HTTPException(status_code=400, detail="Invalid supplier_invoice_line_id") + if supplier_invoice_id is not None and int(line_exists["supplier_invoice_id"]) != supplier_invoice_id: + raise HTTPException( + status_code=400, + detail="supplier_invoice_line_id does not belong to supplier_invoice_id", + ) + + return supplier_invoice_id, supplier_invoice_line_id + @router.get("/sag/{sag_id}/sale-items") async def list_sale_items(sag_id: int): """List sale items for a case.""" @@ -2290,27 +2357,68 @@ async def create_sale_item(sag_id: int, data: dict): if status not in ("draft", "confirmed", "cancelled"): raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled") - query = """ - INSERT INTO sag_salgsvarer - (sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id) - VALUES - (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING * - """ - params = ( - sag_id, - item_type, - description, - data.get("quantity"), - data.get("unit"), - data.get("unit_price"), - amount, - data.get("currency", "DKK"), - status, - data.get("line_date"), - data.get("external_ref"), - data.get("product_id"), - ) + has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose") + purchase_purpose = None + supplier_invoice_id = None + supplier_invoice_line_id = None + if has_purchase_columns: + purchase_purpose = _normalize_purchase_purpose(data.get("purchase_purpose"), item_type) + supplier_invoice_id, supplier_invoice_line_id = _resolve_purchase_traceability( + data.get("supplier_invoice_id"), + data.get("supplier_invoice_line_id"), + ) + if item_type != "purchase": + supplier_invoice_id = None + supplier_invoice_line_id = None + + if has_purchase_columns: + query = """ + INSERT INTO sag_salgsvarer + (sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id, + purchase_purpose, supplier_invoice_id, supplier_invoice_line_id) + VALUES + (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING * + """ + params = ( + sag_id, + item_type, + description, + data.get("quantity"), + data.get("unit"), + data.get("unit_price"), + amount, + data.get("currency", "DKK"), + status, + data.get("line_date"), + data.get("external_ref"), + data.get("product_id"), + purchase_purpose, + supplier_invoice_id, + supplier_invoice_line_id, + ) + else: + query = """ + INSERT INTO sag_salgsvarer + (sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id) + VALUES + (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING * + """ + params = ( + sag_id, + item_type, + description, + data.get("quantity"), + data.get("unit"), + data.get("unit_price"), + amount, + data.get("currency", "DKK"), + status, + data.get("line_date"), + data.get("external_ref"), + data.get("product_id"), + ) result = execute_query(query, params) if result: logger.info("✅ Sale item created for case %s", sag_id) @@ -2349,7 +2457,7 @@ async def update_sale_item(sag_id: int, item_id: int, updates: dict): """Update a sale item for a case.""" try: check = execute_query( - "SELECT id FROM sag_salgsvarer WHERE id = %s AND sag_id = %s", + "SELECT id, type FROM sag_salgsvarer WHERE id = %s AND sag_id = %s", (item_id, sag_id) ) if not check: @@ -2367,10 +2475,18 @@ async def update_sale_item(sag_id: int, item_id: int, updates: dict): "line_date", "external_ref", "product_id", + "purchase_purpose", + "supplier_invoice_id", + "supplier_invoice_line_id", ] set_clauses = [] params = [] + has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose") + current_type = (check[0].get("type") or "sale").lower() + next_type = current_type + if "type" in updates: + next_type = (updates.get("type") or "").lower() for field in allowed_fields: if field in updates: @@ -2386,10 +2502,56 @@ async def update_sale_item(sag_id: int, item_id: int, updates: dict): raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled") set_clauses.append("status = %s") params.append(value) + elif field == "purchase_purpose": + if not has_purchase_columns: + continue + value = _normalize_purchase_purpose(updates.get(field), next_type) + set_clauses.append("purchase_purpose = %s") + params.append(value) + elif field in ("supplier_invoice_id", "supplier_invoice_line_id"): + # handled together below to keep consistency between IDs + continue else: set_clauses.append(f"{field} = %s") params.append(updates[field]) + if has_purchase_columns and ( + "supplier_invoice_id" in updates + or "supplier_invoice_line_id" in updates + or next_type != current_type + ): + raw_supplier_invoice_id = updates.get("supplier_invoice_id") if "supplier_invoice_id" in updates else None + raw_supplier_invoice_line_id = updates.get("supplier_invoice_line_id") if "supplier_invoice_line_id" in updates else None + + if "supplier_invoice_id" not in updates or "supplier_invoice_line_id" not in updates: + current_refs = execute_query_single( + "SELECT supplier_invoice_id, supplier_invoice_line_id FROM sag_salgsvarer WHERE id = %s AND sag_id = %s", + (item_id, sag_id) + ) or {} + if "supplier_invoice_id" not in updates: + raw_supplier_invoice_id = current_refs.get("supplier_invoice_id") + if "supplier_invoice_line_id" not in updates: + raw_supplier_invoice_line_id = current_refs.get("supplier_invoice_line_id") + + supplier_invoice_id, supplier_invoice_line_id = _resolve_purchase_traceability( + raw_supplier_invoice_id, + raw_supplier_invoice_line_id, + ) + + if next_type != "purchase": + supplier_invoice_id = None + supplier_invoice_line_id = None + + set_clauses.append("supplier_invoice_id = %s") + params.append(supplier_invoice_id) + set_clauses.append("supplier_invoice_line_id = %s") + params.append(supplier_invoice_line_id) + + if has_purchase_columns and next_type != "purchase": + if "purchase_purpose" not in updates: + set_clauses.append("purchase_purpose = %s") + params.append(None) + if not set_clauses: raise HTTPException(status_code=400, detail="No valid fields to update") diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index f9fd1f9..1ece365 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -6024,6 +6024,7 @@ Enhed Enhedspris Linjesum + Formål Kilde-sag Status Handlinger @@ -6031,7 +6032,7 @@ - Indlæser indkøbslinjer... + Indlæser indkøbslinjer... @@ -7282,6 +7283,20 @@ let saleItemsCache = []; + const purchasePurposeLabels = { + salg: 'Salg', + lager: 'Lager', + asset: 'Asset', + intern_brug: 'Intern brug', + retur_reklamation: 'Retur/reklamation', + projekt_omkostning: 'Projektomkostning' + }; + + function purchasePurposeLabel(value) { + if (!value) return '-'; + return purchasePurposeLabels[value] || value; + } + async function loadVarekobSalg() { try { const res = await fetch(`/api/v1/sag/${salesCaseId}/varekob-salg?include_subcases=true`); @@ -7323,9 +7338,10 @@ const salesItems = items.filter(item => (item.type || '').toLowerCase() !== 'purchase'); const purchaseItems = items.filter(item => (item.type || '').toLowerCase() === 'purchase'); - const renderRows = (list) => { + const renderRows = (list, isPurchase) => { if (!list.length) { - return 'Ingen linjer'; + const columns = isPurchase ? 10 : 9; + return `Ingen linjer`; } return list.map(item => { @@ -7334,6 +7350,9 @@ const sourceBadge = isSubcase ? `Under-sag` : `Denne sag`; + const purposeCell = isPurchase + ? `${purchasePurposeLabel(item.purchase_purpose)}` + : ''; return ` ${item.line_date || '-'} @@ -7342,6 +7361,7 @@ ${item.unit || '-'} ${item.unit_price != null ? formatCurrency(item.unit_price) : '-'} ${formatCurrency(item.amount)} + ${purposeCell} ${item.source_sag_titel || '-'}${sourceBadge} ${statusLabel} @@ -7355,8 +7375,8 @@ }).join(''); }; - salesBody.innerHTML = renderRows(salesItems); - purchaseBody.innerHTML = renderRows(purchaseItems); + salesBody.innerHTML = renderRows(salesItems, false); + purchaseBody.innerHTML = renderRows(purchaseItems, true); const salesSum = salesItems.reduce((sum, item) => sum + Number(item.amount || 0), 0); const purchaseSum = purchaseItems.reduce((sum, item) => sum + Number(item.amount || 0), 0); @@ -7400,10 +7420,30 @@ document.getElementById('sale_amount').value = item?.amount ?? ''; document.getElementById('sale_currency').value = item?.currency || 'DKK'; document.getElementById('sale_external_ref').value = item?.external_ref || ''; + document.getElementById('sale_purchase_purpose').value = item?.purchase_purpose || ''; + document.getElementById('sale_supplier_invoice_id').value = item?.supplier_invoice_id || ''; + document.getElementById('sale_supplier_invoice_line_id').value = item?.supplier_invoice_line_id || ''; + + togglePurchaseFields(); new bootstrap.Modal(document.getElementById('saleItemModal')).show(); } + function togglePurchaseFields() { + const type = document.getElementById('sale_type').value; + const purchaseFields = document.getElementById('sale_purchase_fields'); + if (!purchaseFields) return; + + if (type === 'purchase') { + purchaseFields.classList.remove('d-none'); + } else { + purchaseFields.classList.add('d-none'); + document.getElementById('sale_purchase_purpose').value = ''; + document.getElementById('sale_supplier_invoice_id').value = ''; + document.getElementById('sale_supplier_invoice_line_id').value = ''; + } + } + function openSaleItemModalById(itemId) { const item = saleItemsCache.find((entry) => entry.id === itemId); openSaleItemModal(item || null); @@ -7429,9 +7469,18 @@ unit_price: document.getElementById('sale_unit_price').value || null, amount: document.getElementById('sale_amount').value, currency: document.getElementById('sale_currency').value || 'DKK', - external_ref: document.getElementById('sale_external_ref').value || null + external_ref: document.getElementById('sale_external_ref').value || null, + purchase_purpose: document.getElementById('sale_purchase_purpose').value || null, + supplier_invoice_id: document.getElementById('sale_supplier_invoice_id').value || null, + supplier_invoice_line_id: document.getElementById('sale_supplier_invoice_line_id').value || null }; + if (payload.type !== 'purchase') { + payload.purchase_purpose = null; + payload.supplier_invoice_id = null; + payload.supplier_invoice_line_id = null; + } + if (!payload.description || !payload.amount) { alert('Beskrivelse og linjesum er påkrævet.'); return; @@ -7470,8 +7519,10 @@ document.addEventListener('DOMContentLoaded', function() { const qtyInput = document.getElementById('sale_quantity'); const priceInput = document.getElementById('sale_unit_price'); + const typeInput = document.getElementById('sale_type'); if (qtyInput) qtyInput.addEventListener('input', updateSaleAmount); if (priceInput) priceInput.addEventListener('input', updateSaleAmount); + if (typeInput) typeInput.addEventListener('change', togglePurchaseFields); loadVarekobSalg(); }); @@ -8769,6 +8820,30 @@ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/migrations/169_supplier_invoice_case_traceability.sql b/migrations/169_supplier_invoice_case_traceability.sql new file mode 100644 index 0000000..1ec88bc --- /dev/null +++ b/migrations/169_supplier_invoice_case_traceability.sql @@ -0,0 +1,47 @@ +-- Migration 169: Supplier invoice -> case traceability and purchase line classification +-- Created: 2026-04-12 + +-- Link supplier invoices to cases for procurement workflow +ALTER TABLE supplier_invoices +ADD COLUMN IF NOT EXISTS sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_supplier_invoices_sag_id +ON supplier_invoices(sag_id); + +-- Add explicit purchase line classification + invoice traceability on case purchase lines +ALTER TABLE sag_salgsvarer +ADD COLUMN IF NOT EXISTS purchase_purpose VARCHAR(50), +ADD COLUMN IF NOT EXISTS supplier_invoice_id INTEGER REFERENCES supplier_invoices(id) ON DELETE SET NULL, +ADD COLUMN IF NOT EXISTS supplier_invoice_line_id INTEGER REFERENCES supplier_invoice_lines(id) ON DELETE SET NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_sag_salgsvarer_purchase_purpose' + ) THEN + ALTER TABLE sag_salgsvarer + ADD CONSTRAINT chk_sag_salgsvarer_purchase_purpose + CHECK ( + purchase_purpose IS NULL + OR purchase_purpose IN ( + 'salg', + 'lager', + 'asset', + 'intern_brug', + 'retur_reklamation', + 'projekt_omkostning' + ) + ); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_purchase_purpose +ON sag_salgsvarer(purchase_purpose); + +CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_supplier_invoice_id +ON sag_salgsvarer(supplier_invoice_id); + +CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_supplier_invoice_line_id +ON sag_salgsvarer(supplier_invoice_line_id);