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 @@