diff --git a/app/billing/backend/supplier_invoices.py b/app/billing/backend/supplier_invoices.py index e4a4b25..d6e3888 100644 --- a/app/billing/backend/supplier_invoices.py +++ b/app/billing/backend/supplier_invoices.py @@ -13,6 +13,7 @@ from app.core.config import settings from app.services.economic_service import get_economic_service from app.services.ollama_service import ollama_service from app.services.template_service import template_service +from app.services.invoice2data_service import get_invoice2data_service import logging import os import re @@ -232,15 +233,25 @@ async def get_pending_files(): f.error_message, f.template_id, f.file_path, + -- Quick analysis results (available immediately on upload) + f.detected_cvr, + f.detected_vendor_id, + f.detected_document_type, + f.detected_document_number, + f.is_own_invoice, + v_detected.name as detected_vendor_name, + v_detected.cvr_number as detected_vendor_cvr, -- Get vendor info from latest extraction ext.vendor_name, ext.vendor_cvr, ext.vendor_matched_id, v.name as matched_vendor_name, + v.cvr_number as matched_vendor_cvr_number, -- Check if already has invoice via latest extraction only si.id as existing_invoice_id, si.invoice_number as existing_invoice_number FROM incoming_files f + LEFT JOIN vendors v_detected ON v_detected.id = f.detected_vendor_id LEFT JOIN LATERAL ( SELECT extraction_id, file_id, vendor_name, vendor_cvr, vendor_matched_id FROM extractions @@ -250,16 +261,82 @@ async def get_pending_files(): ) ext ON true LEFT JOIN vendors v ON v.id = ext.vendor_matched_id LEFT JOIN supplier_invoices si ON si.extraction_id = ext.extraction_id - WHERE f.status IN ('pending', 'processing', 'failed', 'ai_extracted', 'processed') + WHERE f.status IN ('pending', 'processing', 'failed', 'ai_extracted', 'processed', 'duplicate') AND si.id IS NULL -- Only show files without invoice yet ORDER BY f.file_id, f.uploaded_at DESC""" ) + + # Convert to regular dicts so we can add new keys + files = [dict(file) for file in files] if files else [] + + # Check for invoice2data templates for each file + try: + from app.services.invoice2data_service import get_invoice2data_service + invoice2data = get_invoice2data_service() + logger.info(f"📋 Checking invoice2data templates: {len(invoice2data.templates)} loaded") + + for file in files: + # Check if there's an invoice2data template for this vendor's CVR + vendor_cvr = file.get('matched_vendor_cvr_number') or file.get('detected_vendor_cvr') or file.get('vendor_cvr') + file['has_invoice2data_template'] = False + + logger.debug(f" File {file['file_id']}: CVR={vendor_cvr}") + + if vendor_cvr: + # Check all templates for this CVR in keywords + for template_name, template_data in invoice2data.templates.items(): + keywords = template_data.get('keywords', []) + logger.debug(f" Template {template_name}: keywords={keywords}") + if str(vendor_cvr) in [str(k) for k in keywords]: + file['has_invoice2data_template'] = True + file['invoice2data_template_name'] = template_name + logger.info(f" ✅ File {file['file_id']} matched template: {template_name}") + break + except Exception as e: + logger.error(f"❌ Failed to check invoice2data templates: {e}", exc_info=True) + # Continue without invoice2data info + return {"files": files if files else [], "count": len(files) if files else 0} except Exception as e: logger.error(f"❌ Failed to get pending files: {e}") raise HTTPException(status_code=500, detail=str(e)) +@router.get("/supplier-invoices/files/{file_id}/pdf-text") +async def get_file_pdf_text(file_id: int): + """Hent fuld PDF tekst fra en uploaded fil (til template builder)""" + try: + # Get file info + file_info = execute_query( + "SELECT file_path, filename FROM incoming_files WHERE file_id = %s", + (file_id,), + fetchone=True + ) + + if not file_info: + raise HTTPException(status_code=404, detail="Fil ikke fundet") + + # Read PDF text + from pathlib import Path + file_path = Path(file_info['file_path']) + if not file_path.exists(): + raise HTTPException(status_code=404, detail=f"Fil ikke fundet på disk: {file_path}") + + pdf_text = await ollama_service._extract_text_from_file(file_path) + + return { + "file_id": file_id, + "filename": file_info['filename'], + "pdf_text": pdf_text + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Failed to get PDF text: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/supplier-invoices/files/{file_id}/extracted-data") async def get_file_extracted_data(file_id: int): """Hent AI-extracted data fra en uploaded fil""" @@ -758,8 +835,9 @@ async def create_invoice_from_extraction(file_id: int): @router.get("/supplier-invoices/templates") async def list_templates(): - """Hent alle templates""" + """Hent alle templates (både database og invoice2data YAML)""" try: + # Get database templates query = """ SELECT t.*, v.name as vendor_name FROM supplier_invoice_templates t @@ -767,9 +845,55 @@ async def list_templates(): WHERE t.is_active = true ORDER BY t.created_at DESC """ - templates = execute_query(query) + db_templates = execute_query(query) or [] - return templates if templates else [] + # Get invoice2data templates + invoice2data_service = get_invoice2data_service() + invoice2data_templates = [] + + for template_name, template_data in invoice2data_service.templates.items(): + # Extract vendor CVR from keywords + vendor_cvr = None + keywords = template_data.get('keywords', []) + for keyword in keywords: + if isinstance(keyword, str) and keyword.isdigit() and len(keyword) == 8: + vendor_cvr = keyword + break + + # Get vendor info from database if CVR found + vendor_name = template_data.get('issuer', 'Ukendt') + vendor_id = None + if vendor_cvr: + vendor = execute_query( + "SELECT id, name FROM vendors WHERE cvr_number = %s", + (vendor_cvr,), + fetchone=True + ) + if vendor: + vendor_id = vendor['id'] + vendor_name = vendor['name'] + + invoice2data_templates.append({ + 'template_id': -1, # Negative ID to distinguish from DB templates + 'template_name': f"Invoice2Data: {template_name}", + 'template_type': 'invoice2data', + 'yaml_filename': template_name, + 'vendor_id': vendor_id, + 'vendor_name': vendor_name, + 'vendor_cvr': vendor_cvr, + 'default_product_category': template_data.get('default_product_category', 'varesalg'), + 'default_product_group_number': template_data.get('default_product_group_number', 1), + 'usage_count': 0, # Could track this separately + 'is_active': True, + 'detection_patterns': keywords, + 'field_mappings': template_data.get('fields', {}), + 'created_at': None + }) + + # Combine both types + all_templates = db_templates + invoice2data_templates + + return all_templates except Exception as e: logger.error(f"❌ Failed to list templates: {e}") raise HTTPException(status_code=500, detail=str(e)) @@ -978,6 +1102,7 @@ async def create_template(request: Dict): template_name = request.get('template_name') detection_patterns = request.get('detection_patterns', []) field_mappings = request.get('field_mappings', {}) + default_product_category = request.get('default_product_category', 'varesalg') if not vendor_id or not template_name: raise HTTPException(status_code=400, detail="vendor_id og template_name er påkrævet") @@ -996,11 +1121,11 @@ async def create_template(request: Dict): # Insert template and get template_id query = """ INSERT INTO supplier_invoice_templates - (vendor_id, template_name, detection_patterns, field_mappings) - VALUES (%s, %s, %s, %s) + (vendor_id, template_name, detection_patterns, field_mappings, default_product_category) + VALUES (%s, %s, %s, %s, %s) RETURNING template_id """ - result = execute_query(query, (vendor_id, template_name, json.dumps(detection_patterns), json.dumps(field_mappings))) + result = execute_query(query, (vendor_id, template_name, json.dumps(detection_patterns), json.dumps(field_mappings), default_product_category)) template_id = result[0]['template_id'] if result else None if not template_id: @@ -1657,6 +1782,97 @@ async def upload_supplier_invoice(file: UploadFile = File(...)): logger.info(f"📄 Extracting text from {final_path.suffix}...") text = await ollama_service._extract_text_from_file(final_path) + # QUICK ANALYSIS: Extract CVR, document type, invoice number IMMEDIATELY + logger.info(f"⚡ Running quick analysis...") + quick_result = await ollama_service.quick_analysis_on_upload(text) + + # Update file record with quick analysis results + execute_update( + """UPDATE incoming_files + SET detected_cvr = %s, + detected_vendor_id = %s, + detected_document_type = %s, + detected_document_number = %s, + is_own_invoice = %s + WHERE file_id = %s""", + (quick_result.get('cvr'), + quick_result.get('vendor_id'), + quick_result.get('document_type'), + quick_result.get('document_number'), + quick_result.get('is_own_invoice', False), + file_id) + ) + + logger.info(f"📋 Quick analysis saved: CVR={quick_result.get('cvr')}, " + f"Vendor={quick_result.get('vendor_name')}, " + f"Type={quick_result.get('document_type')}, " + f"Number={quick_result.get('document_number')}") + + # DUPLICATE CHECK: Check if invoice number already exists + document_number = quick_result.get('document_number') + if document_number: + logger.info(f"🔍 Checking for duplicate invoice number: {document_number}") + + # Check 1: Search in local database (supplier_invoices table) + existing_invoice = execute_query( + """SELECT si.id, si.invoice_number, si.created_at, v.name as vendor_name + FROM supplier_invoices si + LEFT JOIN vendors v ON v.id = si.vendor_id + WHERE si.invoice_number = %s + ORDER BY si.created_at DESC + LIMIT 1""", + (document_number,), + fetchone=True + ) + + if existing_invoice: + # DUPLICATE FOUND IN DATABASE + logger.error(f"🚫 DUPLICATE: Invoice {document_number} already exists in database (ID: {existing_invoice['id']})") + + # Mark file as duplicate + execute_update( + """UPDATE incoming_files + SET status = 'duplicate', + error_message = %s, + processed_at = CURRENT_TIMESTAMP + WHERE file_id = %s""", + (f"DUBLET: Fakturanummer {document_number} findes allerede i systemet (Faktura #{existing_invoice['id']}, {existing_invoice['vendor_name'] or 'Ukendt leverandør'})", + file_id) + ) + + raise HTTPException( + status_code=409, # 409 Conflict + detail=f"🚫 DUBLET: Fakturanummer {document_number} findes allerede i systemet (Faktura #{existing_invoice['id']}, oprettet {existing_invoice['created_at'].strftime('%d-%m-%Y')})" + ) + + # Check 2: Search in e-conomic (if configured) + from app.services.economic_service import economic_service + if hasattr(economic_service, 'app_secret_token') and economic_service.app_secret_token: + logger.info(f"🔍 Checking e-conomic for invoice number: {document_number}") + economic_duplicate = await economic_service.check_invoice_number_exists(document_number) + + if economic_duplicate: + # DUPLICATE FOUND IN E-CONOMIC + logger.error(f"🚫 DUPLICATE: Invoice {document_number} found in e-conomic (Voucher #{economic_duplicate.get('voucher_number')})") + + # Mark file as duplicate + execute_update( + """UPDATE incoming_files + SET status = 'duplicate', + error_message = %s, + processed_at = CURRENT_TIMESTAMP + WHERE file_id = %s""", + (f"DUBLET: Fakturanummer {document_number} findes i e-conomic (Bilag #{economic_duplicate.get('voucher_number')})", + file_id) + ) + + raise HTTPException( + status_code=409, # 409 Conflict + detail=f"🚫 DUBLET: Fakturanummer {document_number} findes i e-conomic (Bilag #{economic_duplicate.get('voucher_number')}, {economic_duplicate.get('date')})" + ) + + logger.info(f"✅ No duplicate found for invoice {document_number}") + # Try template matching logger.info(f"📋 Matching template...") template_id, confidence = template_service.match_template(text) @@ -1699,7 +1915,8 @@ async def upload_supplier_invoice(file: UploadFile = File(...)): """INSERT INTO extraction_lines (extraction_id, line_number, description, quantity, unit_price, line_total, vat_rate, vat_note, confidence) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""", + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING line_id""", (extraction_id, idx, line.get('description'), line.get('quantity'), line.get('unit_price'), line.get('line_total'), line.get('vat_rate'), @@ -1744,13 +1961,41 @@ async def upload_supplier_invoice(file: UploadFile = File(...)): "confidence": confidence, "extracted_fields": extracted_fields, "pdf_text": text[:500], # First 500 chars for reference + # Quick analysis results (available IMMEDIATELY on upload) + "quick_analysis": { + "cvr": quick_result.get('cvr'), + "vendor_id": quick_result.get('vendor_id'), + "vendor_name": quick_result.get('vendor_name'), + "document_type": quick_result.get('document_type'), + "document_number": quick_result.get('document_number') + }, "message": "Upload gennemført - gennemgå og bekræft data" } - except HTTPException: + except HTTPException as he: + # Mark file as failed if we have file_id + if 'file_id' in locals(): + execute_update( + """UPDATE incoming_files + SET status = 'failed', + error_message = %s, + processed_at = CURRENT_TIMESTAMP + WHERE file_id = %s""", + (str(he.detail), file_id) + ) raise except Exception as e: logger.error(f"❌ Upload failed (inner): {e}", exc_info=True) + # Mark file as failed if we have file_id + if 'file_id' in locals(): + execute_update( + """UPDATE incoming_files + SET status = 'failed', + error_message = %s, + processed_at = CURRENT_TIMESTAMP + WHERE file_id = %s""", + (str(e), file_id) + ) raise HTTPException(status_code=500, detail=f"Upload fejlede: {str(e)}") except HTTPException: @@ -1809,51 +2054,174 @@ async def reprocess_uploaded_file(file_id: int): logger.info(f"✅ Matched template {template_id} ({confidence:.0%})") extracted_fields = template_service.extract_fields(text, template_id) - template = template_service.templates_cache.get(template_id) - if template: - vendor_id = template.get('vendor_id') + # Check if this is an invoice2data template (ID -1) + is_invoice2data = (template_id == -1) - template_service.log_usage(template_id, file_id, True, confidence, extracted_fields) + if is_invoice2data: + # Invoice2data doesn't have vendor in cache + logger.info(f"📋 Using invoice2data template") + # Try to find vendor from extracted CVR + if extracted_fields.get('vendor_vat'): + vendor = execute_query( + "SELECT id FROM vendors WHERE cvr_number = %s", + (extracted_fields['vendor_vat'],), + fetchone=True + ) + if vendor: + vendor_id = vendor['id'] + + # Store invoice2data extraction in database + extraction_id = execute_insert( + """INSERT INTO extractions + (file_id, vendor_matched_id, vendor_name, vendor_cvr, + document_id, document_date, due_date, document_type, document_type_detected, + total_amount, currency, confidence, llm_response_json, status) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING extraction_id""", + (file_id, vendor_id, + extracted_fields.get('issuer'), # vendor_name + extracted_fields.get('vendor_vat'), # vendor_cvr + str(extracted_fields.get('invoice_number')), # document_id + extracted_fields.get('invoice_date'), # document_date + extracted_fields.get('due_date'), + 'invoice', # document_type + 'invoice', # document_type_detected + extracted_fields.get('amount_total'), + extracted_fields.get('currency', 'DKK'), + 1.0, # invoice2data always 100% confidence + json.dumps(extracted_fields), # llm_response_json + 'extracted') # status + ) + + # Insert line items if extracted + if extracted_fields.get('lines'): + for idx, line in enumerate(extracted_fields['lines'], start=1): + execute_insert( + """INSERT INTO extraction_lines + (extraction_id, line_number, description, quantity, unit_price, + line_total, vat_rate, vat_note, confidence, + ip_address, contract_number, location_street, location_zip, location_city) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING line_id""", + (extraction_id, idx, line.get('description'), + line.get('quantity'), line.get('unit_price'), + line.get('line_total'), None, None, 1.0, + line.get('ip_address'), line.get('contract_number'), + line.get('location_street'), line.get('location_zip'), line.get('location_city')) + ) + logger.info(f"✅ Saved {len(extracted_fields['lines'])} line items") + else: + # Custom template from database + template = template_service.templates_cache.get(template_id) + if template: + vendor_id = template.get('vendor_id') + + template_service.log_usage(template_id, file_id, True, confidence, extracted_fields) + # Update file - use NULL for invoice2data templates to avoid FK constraint + db_template_id = None if is_invoice2data else template_id execute_update( """UPDATE incoming_files SET status = 'processed', template_id = %s, processed_at = CURRENT_TIMESTAMP WHERE file_id = %s""", - (template_id, file_id) + (db_template_id, file_id) ) else: - # NO AI FALLBACK - Require template matching - logger.warning(f"⚠️ Ingen template match (confidence: {confidence:.0%}) - afviser fil") + # FALLBACK TO AI EXTRACTION + logger.info(f"⚠️ Ingen template match (confidence: {confidence:.0%}) - bruger AI extraction") + # Use detected vendor from quick analysis if available + vendor_id = file_record.get('detected_vendor_id') + + # Call Ollama for full extraction + logger.info(f"🤖 Calling Ollama for AI extraction...") + llm_result = await ollama_service.extract_from_text(text) + + if not llm_result or 'error' in llm_result: + error_msg = llm_result.get('error') if llm_result else 'AI extraction fejlede' + logger.error(f"❌ AI extraction failed: {error_msg}") + + execute_update( + """UPDATE incoming_files + SET status = 'failed', + error_message = %s, + processed_at = CURRENT_TIMESTAMP + WHERE file_id = %s""", + (f"AI extraction fejlede: {error_msg}", file_id) + ) + + raise HTTPException(status_code=500, detail=f"AI extraction fejlede: {error_msg}") + + extracted_fields = llm_result + confidence = llm_result.get('confidence', 0.75) + + # Store AI extracted data in extractions table + extraction_id = execute_insert( + """INSERT INTO supplier_invoice_extractions + (file_id, vendor_id, invoice_number, invoice_date, due_date, + total_amount, currency, document_type, confidence, llm_data) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING extraction_id""", + (file_id, vendor_id, + llm_result.get('invoice_number'), + llm_result.get('invoice_date'), + llm_result.get('due_date'), + llm_result.get('total_amount'), + llm_result.get('currency', 'DKK'), + llm_result.get('document_type'), + confidence, + json.dumps(llm_result)) + ) + + # Insert line items if extracted + if llm_result.get('lines'): + for idx, line in enumerate(llm_result['lines'], start=1): + execute_insert( + """INSERT INTO extraction_lines + (extraction_id, line_number, description, quantity, unit_price, + line_total, vat_rate, vat_note, confidence) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING line_id""", + (extraction_id, idx, line.get('description'), + line.get('quantity'), line.get('unit_price'), + line.get('line_total'), line.get('vat_rate'), + line.get('vat_note'), confidence) + ) + + # Update file status to ai_extracted execute_update( """UPDATE incoming_files - SET status = 'failed', - error_message = 'Ingen template match - opret template for denne leverandør', - processed_at = CURRENT_TIMESTAMP + SET status = 'ai_extracted', processed_at = CURRENT_TIMESTAMP WHERE file_id = %s""", (file_id,) ) - return { - "status": "failed", - "file_id": file_id, - "error": "Ingen template match - opret template for denne leverandør", - "confidence": confidence - } + logger.info(f"✅ AI extraction completed for file {file_id}") - # Return success with template data - return { + # Return success with template data or AI extraction result + result = { "status": "success", "file_id": file_id, "filename": file_record['filename'], "template_matched": template_id is not None, "template_id": template_id, "vendor_id": vendor_id, - "confidence": confidence if template_id else 0.8, + "confidence": confidence if template_id else llm_result.get('confidence', 0.75), "extracted_fields": extracted_fields, "pdf_text": text[:1000] if not template_id else text } + # Add warning if no template exists + if not template_id and vendor_id: + vendor = execute_query( + "SELECT name FROM vendors WHERE id = %s", + (vendor_id,), + fetchone=True + ) + if vendor: + result["warning"] = f"⚠️ Ingen template fundet for {vendor['name']} - brugte AI extraction (langsommere)" + + return result + except HTTPException: raise except Exception as e: @@ -1866,6 +2234,7 @@ async def update_template( template_name: Optional[str] = None, detection_patterns: Optional[List[Dict]] = None, field_mappings: Optional[Dict] = None, + default_product_category: Optional[str] = None, is_active: Optional[bool] = None ): """Opdater eksisterende template""" @@ -1884,6 +2253,9 @@ async def update_template( if field_mappings is not None: updates.append("field_mappings = %s") params.append(json.dumps(field_mappings)) + if default_product_category is not None: + updates.append("default_product_category = %s") + params.append(default_product_category) if is_active is not None: updates.append("is_active = %s") params.append(is_active) @@ -1911,6 +2283,114 @@ async def update_template( raise HTTPException(status_code=500, detail=str(e)) +@router.post("/supplier-invoices/templates/invoice2data/{template_name}/test") +async def test_invoice2data_template(template_name: str, request: Dict): + """ + Test invoice2data YAML template mod PDF tekst + + Request body: + { + "pdf_text": "Full PDF text content..." + } + + Returns samme format som test_template endpoint + """ + try: + pdf_text = request.get('pdf_text', '') + if not pdf_text: + raise HTTPException(status_code=400, detail="pdf_text er påkrævet") + + # Get invoice2data service + invoice2data_service = get_invoice2data_service() + + # Check if template exists + if template_name not in invoice2data_service.templates: + raise HTTPException(status_code=404, detail=f"Template '{template_name}' ikke fundet") + + template_data = invoice2data_service.templates[template_name] + + # Test extraction + result = invoice2data_service.extract_with_template(pdf_text, template_name) + + if not result: + # Template didn't match + keywords = template_data.get('keywords', []) + detection_results = [] + for keyword in keywords: + found = str(keyword).lower() in pdf_text.lower() + detection_results.append({ + "pattern": str(keyword), + "type": "keyword", + "found": found, + "weight": 0.5 + }) + + return { + "matched": False, + "confidence": 0.0, + "extracted_fields": {}, + "line_items": [], + "detection_results": detection_results, + "template_name": template_name, + "error": "Template matchede ikke PDF'en" + } + + # Extract line items + line_items = [] + if 'lines' in result: + for line in result['lines']: + line_items.append({ + "line_number": line.get('line_number', ''), + "item_number": line.get('item_number', ''), + "description": line.get('description_raw', '') or line.get('description', ''), + "quantity": line.get('quantity', ''), + "unit_price": line.get('unit_price', ''), + "line_total": line.get('line_total', ''), + # Context fields (circuit/location info) + "circuit_id": line.get('circuit_id', ''), + "ip_address": line.get('ip_address', ''), + "contract_number": line.get('contract_number', ''), + "location_street": line.get('location_street', ''), + "location_zip": line.get('location_zip', ''), + "location_city": line.get('location_city', ''), + }) + + # Build detection results + keywords = template_data.get('keywords', []) + detection_results = [] + matched_count = 0 + for keyword in keywords: + found = str(keyword).lower() in pdf_text.lower() + if found: + matched_count += 1 + detection_results.append({ + "pattern": str(keyword), + "type": "keyword", + "found": found, + "weight": 0.5 + }) + + confidence = matched_count / len(keywords) if keywords else 1.0 + + # Remove 'lines' from extracted_fields to avoid duplication + extracted_fields = {k: v for k, v in result.items() if k != 'lines'} + + return { + "matched": True, + "confidence": confidence, + "extracted_fields": extracted_fields, + "line_items": line_items, + "detection_results": detection_results, + "template_name": template_name + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Invoice2data template test failed: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/supplier-invoices/templates/{template_id}/test") async def test_template(template_id: int, request: Dict): """ @@ -2076,6 +2556,102 @@ async def test_template(template_id: int, request: Dict): raise HTTPException(status_code=500, detail=str(e)) +@router.put("/supplier-invoices/templates/invoice2data/{template_name}/category") +async def update_yaml_category(template_name: str, request: Dict): + """ + Opdater default_product_category i YAML template fil + + Request body: + { + "category": "drift" // varesalg, drift, anlæg, abonnement, lager, udlejning + } + """ + try: + import yaml + from pathlib import Path + + new_category = request.get('category') + if not new_category: + raise HTTPException(status_code=400, detail="category er påkrævet") + + # Validate category + valid_categories = ['varesalg', 'drift', 'anlæg', 'abonnement', 'lager', 'udlejning'] + if new_category not in valid_categories: + raise HTTPException(status_code=400, detail=f"Ugyldig kategori. Skal være en af: {', '.join(valid_categories)}") + + # Find YAML file + templates_dir = Path(__file__).parent.parent.parent.parent / 'data' / 'invoice_templates' + yaml_file = templates_dir / f"{template_name}.yml" + + if not yaml_file.exists(): + raise HTTPException(status_code=404, detail=f"YAML fil ikke fundet: {template_name}.yml") + + # Load YAML + with open(yaml_file, 'r', encoding='utf-8') as f: + template_data = yaml.safe_load(f) + + # Update category + template_data['default_product_category'] = new_category + + # Save YAML with preserved formatting + with open(yaml_file, 'w', encoding='utf-8') as f: + yaml.dump(template_data, f, default_flow_style=False, allow_unicode=True, sort_keys=False) + + # Reload invoice2data service to pick up changes + invoice2data_service = get_invoice2data_service() + invoice2data_service.__init__() # Reinitialize to reload templates + + logger.info(f"✅ Updated category for {template_name}.yml to {new_category}") + + return { + "message": "Kategori opdateret", + "template_name": template_name, + "new_category": new_category + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Failed to update YAML category: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/supplier-invoices/templates/invoice2data/{template_name}/content") +async def get_yaml_content(template_name: str): + """ + Hent råt YAML indhold fra template fil + + Returns: + { + "content": "issuer: DCS ApS\nkeywords: ..." + } + """ + try: + from pathlib import Path + + # Find template file + template_dir = Path("data/invoice_templates") + template_file = template_dir / f"{template_name}.yml" + + if not template_file.exists(): + raise HTTPException(status_code=404, detail=f"Template fil ikke fundet: {template_name}.yml") + + # Read file content + content = template_file.read_text(encoding='utf-8') + + return { + "template_name": template_name, + "filename": f"{template_name}.yml", + "content": content + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Failed to read YAML content: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + @router.delete("/supplier-invoices/templates/{template_id}") async def delete_template(template_id: int): """Slet template (soft delete - sæt is_active=false)""" diff --git a/app/billing/frontend/supplier_invoices.html b/app/billing/frontend/supplier_invoices.html index 0bcf4cf..a43bd39 100644 --- a/app/billing/frontend/supplier_invoices.html +++ b/app/billing/frontend/supplier_invoices.html @@ -163,7 +163,7 @@ @@ -197,6 +197,26 @@ + + +
@@ -205,6 +225,9 @@ + @@ -217,7 +240,7 @@ -
+ + Fakturanr. Leverandør Fakturadato
+
Indlæser...
@@ -238,18 +261,43 @@
-
📁 Uploadede filer afventer behandling
+
⏳ Filer der mangler behandling
+ + + +
+ + @@ -257,7 +305,7 @@ - - @@ -759,18 +808,25 @@ function renderInvoices(invoices) { tbody.innerHTML = invoices.map(inv => { const isCreditNote = inv.invoice_type === 'credit_note'; + const isLocked = inv.economic_voucher_number; // Locked if already sent to e-conomic return ` - - + + - - - - - - + + + + + + `}).join(''); + + // Reset bulk selection + updateInvoiceBulkActionsBar(); } // Load vendors for dropdown @@ -849,7 +911,7 @@ async function loadPendingFiles() { const tbody = document.getElementById('pendingFilesTable'); tbody.innerHTML = ` - - @@ -891,23 +953,73 @@ function renderPendingFiles(files) { if (!files || files.length === 0) { tbody.innerHTML = ` - `; return; } - tbody.innerHTML = files.map(file => ` - + tbody.innerHTML = files.map(file => { + // Check if this is BMC's own outgoing invoice + const isOwnInvoice = file.is_own_invoice === true; + const rowClass = isOwnInvoice ? 'table-danger' : (file.status === 'duplicate' ? 'table-danger' : ''); + + // Format Quick Analysis data + let quickAnalysisHtml = '-'; + if (file.detected_cvr || file.detected_document_type || file.detected_document_number) { + quickAnalysisHtml = `
`; + + // OUTGOING INVOICE WARNING + if (isOwnInvoice) { + quickAnalysisHtml += `
UDGÅENDE FAKTURA
`; + quickAnalysisHtml += `
BMCs egen faktura - slet eller ignorer
`; + } + + if (file.detected_document_type) { + const isCredit = file.detected_document_type === 'credit_note'; + quickAnalysisHtml += `
${isCredit ? '💳 Kreditnota' : '📄 Faktura'}
`; + } + + if (file.detected_document_number) { + quickAnalysisHtml += `
${file.detected_document_number}
`; + } + + if (file.detected_cvr) { + quickAnalysisHtml += `
CVR: ${file.detected_cvr}
`; + } + + if (file.detected_vendor_name) { + quickAnalysisHtml += `
${file.detected_vendor_name}
`; + } + + quickAnalysisHtml += `
`; + } + + return ` + + + - + - `).join(''); + `; + }).join(''); } // Get status badge for file status @@ -960,7 +1102,8 @@ function getFileStatusBadge(status) { 'processing': 'Behandler', 'ai_extracted': 'AI Udtrukket', 'processed': 'Behandlet', - 'failed': 'Fejlet' + 'failed': 'Fejlet', + 'duplicate': 'DUBLET' }; return badges[status] || `${status}`; } @@ -1071,8 +1214,14 @@ async function retryExtraction(fileId) { // Reload pending files list to show updated status await loadPendingFiles(); + // Show warning if no template was matched + let message = 'Fil behandlet med succes!'; + if (result.warning) { + message = result.warning + '\n\n' + message + '\n\nOvervej at oprette en template for hurtigere behandling.'; + } + // Show success message and offer to review - if (confirm('Fil behandlet med succes!\n\nVil du gennemse de udtrukne data?')) { + if (confirm(message + '\n\nVil du gennemse de udtrukne data?')) { reviewExtractedData(fileId); } } else { @@ -1146,6 +1295,36 @@ async function deletePendingFile(fileId) { } } +// Create template for file +async function createTemplateForFile(fileId, vendorId, vendorName) { + try { + // Get file data including PDF text + const response = await fetch(`/api/v1/supplier-invoices/files/${fileId}/extracted-data`); + if (!response.ok) { + throw new Error('Kunne ikke hente fil data'); + } + + const data = await response.json(); + const pdfText = data.pdf_text || ''; + + // Store data in sessionStorage for template creation page + sessionStorage.setItem('templateCreateData', JSON.stringify({ + vendorId: vendorId, + vendorName: vendorName, + fileId: fileId, + pdfText: pdfText, + sampleInvoice: data.llm_data || {} + })); + + // Navigate to template builder page + window.location.href = `/billing/template-builder?vendor=${vendorId}&file=${fileId}`; + + } catch (error) { + console.error('Failed to prepare template creation:', error); + alert('Kunne ikke forberede template oprettelse: ' + error.message); + } +} + // Link or create vendor for extraction let selectedVendorId = null; @@ -1207,12 +1386,11 @@ document.addEventListener('DOMContentLoaded', () => { async function searchVendorsForLink(query) { try { const response = await fetch(`/api/v1/vendors?search=${encodeURIComponent(query)}&limit=20`); - const data = await response.json(); - const vendors = data.vendors || []; + const vendors = await response.json(); // API returns array directly const resultsDiv = document.getElementById('vendorSearchResults'); - if (vendors.length === 0) { + if (!vendors || vendors.length === 0) { resultsDiv.innerHTML = '
Ingen leverandører fundet
'; return; } @@ -1238,9 +1416,9 @@ async function searchVendorsForLink(query) { function selectVendorForLink(vendorId, vendorName) { selectedVendorId = vendorId; // Re-render to show selection - const currentQuery = document.getElementById('vendorSearchInput').value; - if (currentQuery) { - searchVendorsForLink(currentQuery); + const searchInputElem = document.getElementById('vendorSearchInput'); + if (searchInputElem && searchInputElem.value) { + searchVendorsForLink(searchInputElem.value); } } @@ -1482,6 +1660,12 @@ async function openManualEntryMode() { addManualLine(); const lineNum = manualLineCounter; + // SKU + if (line.sku) { + document.getElementById(`manualLineSku${lineNum}`).value = line.sku; + } + + // Description if (line.description) { let desc = line.description; // Add VAT note to description if present @@ -1492,14 +1676,30 @@ async function openManualEntryMode() { } document.getElementById(`manualLineDesc${lineNum}`).value = desc; } + + // Quantity if (line.quantity) { document.getElementById(`manualLineQty${lineNum}`).value = line.quantity; } + + // Unit price if (line.unit_price) { document.getElementById(`manualLinePrice${lineNum}`).value = Math.abs(line.unit_price); } - if (line.vat_rate) { - document.getElementById(`manualLineVat${lineNum}`).value = line.vat_rate; + + // VAT code - auto-select based on vat_note + const vatCodeSelect = document.getElementById(`manualLineVatCode${lineNum}`); + if (line.vat_note === 'reverse_charge') { + vatCodeSelect.value = 'I52'; + } else if (line.vat_rate === 0) { + vatCodeSelect.value = 'I0'; + } else { + vatCodeSelect.value = 'I25'; + } + + // Contra account + if (line.contra_account) { + document.getElementById(`manualLineContra${lineNum}`).value = line.contra_account; } }); } else { @@ -1570,25 +1770,43 @@ function addManualLine() { const lineHtml = `
-
-
-
+
+
+
+ + +
+
+
-
+
+
+
- + + +
+
+ +
+ @@ -1635,33 +1853,36 @@ async function saveManualInvoice() { } // Collect line items + const skus = document.getElementsByName('line_sku[]'); const descriptions = document.getElementsByName('line_description[]'); const quantities = document.getElementsByName('line_quantity[]'); const prices = document.getElementsByName('line_price[]'); - const vatRates = document.getElementsByName('line_vat[]'); + const vatCodes = document.getElementsByName('line_vat_code[]'); + const contraAccounts = document.getElementsByName('line_contra[]'); const lines = []; for (let i = 0; i < descriptions.length; i++) { if (descriptions[i].value.trim()) { - const desc = descriptions[i].value; const qty = parseFloat(quantities[i].value) || 1; const price = parseFloat(prices[i].value) || 0; - const vatRate = parseFloat(vatRates[i].value) || 25.00; + const vatCode = vatCodes[i].value; - // Detect VAT code from description - let vatCode = 'I25'; // Default: 25% input VAT - if (desc.includes('OMVENDT BETALINGSPLIGT') || desc.includes('⚠️ OMVENDT BETALINGSPLIGT')) { - vatCode = 'I52'; // Reverse charge - no VAT + // Determine VAT rate from code + let vatRate = 25.00; + if (vatCode === 'I52' || vatCode === 'I0') { + vatRate = 0.00; } lines.push({ line_number: i + 1, - description: desc, + sku: skus[i].value.trim() || null, + description: descriptions[i].value, quantity: qty, unit_price: price, line_total: qty * price, + vat_code: vatCode, vat_rate: vatRate, - vat_code: vatCode + contra_account: contraAccounts[i].value.trim() || '5810' }); } } @@ -1714,6 +1935,403 @@ async function saveManualInvoice() { // ========== END MANUAL ENTRY MODE ========== +// ========== BULK ACTIONS FOR PENDING FILES ========== + +// Toggle select all checkboxes +function toggleSelectAll() { + const selectAll = document.getElementById('selectAllFiles'); + const checkboxes = document.querySelectorAll('.file-checkbox'); + checkboxes.forEach(cb => cb.checked = selectAll.checked); + updateBulkActions(); +} + +// Update bulk actions bar visibility and count +function updateBulkActions() { + const checkboxes = document.querySelectorAll('.file-checkbox:checked'); + const count = checkboxes.length; + const bulkBar = document.getElementById('bulkActionsBar'); + const countSpan = document.getElementById('selectedFilesCount'); + + if (count > 0) { + bulkBar.style.display = 'block'; + countSpan.textContent = count; + } else { + bulkBar.style.display = 'none'; + document.getElementById('selectAllFiles').checked = false; + } +} + +// Get selected file IDs +function getSelectedFileIds() { + const checkboxes = document.querySelectorAll('.file-checkbox:checked'); + return Array.from(checkboxes).map(cb => parseInt(cb.value)); +} + +// Bulk create invoices from selected files +async function bulkCreateInvoices() { + const fileIds = getSelectedFileIds(); + if (fileIds.length === 0) { + alert('Vælg venligst filer først'); + return; + } + + if (!confirm(`Opret fakturaer fra ${fileIds.length} valgte filer?`)) return; + + try { + let successCount = 0; + let failCount = 0; + + for (const fileId of fileIds) { + try { + // Get extracted data + const dataResp = await fetch(`/api/v1/supplier-invoices/files/${fileId}/extracted-data`); + const data = await dataResp.json(); + + if (!data.llm_data || !data.vendor_matched_id) { + failCount++; + continue; + } + + const llm = data.llm_data; + + // Create invoice + const invoiceResp = await fetch('/api/v1/supplier-invoices', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + vendor_id: data.vendor_matched_id, + invoice_number: llm.invoice_number, + invoice_date: llm.invoice_date, + due_date: llm.due_date, + total_amount: llm.total_amount, + currency: llm.currency || 'DKK', + invoice_type: llm.document_type === 'credit_note' ? 'credit_note' : 'invoice', + status: 'unpaid', + notes: `Oprettet fra fil ID ${fileId}`, + lines: (llm.lines || []).map((line, idx) => ({ + line_number: idx + 1, + sku: line.sku || null, + description: line.description, + quantity: line.quantity || 1, + unit_price: line.unit_price || 0, + line_total: line.line_total || 0, + vat_code: line.vat_note === 'reverse_charge' ? 'I52' : 'I25', + vat_rate: line.vat_rate || 25, + contra_account: '5810' + })) + }) + }); + + if (invoiceResp.ok) { + successCount++; + } else { + failCount++; + } + } catch (error) { + console.error(`Failed to create invoice from file ${fileId}:`, error); + failCount++; + } + } + + alert(`✅ ${successCount} fakturaer oprettet\n${failCount > 0 ? `❌ ${failCount} fejlede` : ''}`); + + // Reload data + loadPendingFiles(); + loadInvoices(); + loadStats(); + + } catch (error) { + console.error('Bulk create failed:', error); + alert('❌ Fejl ved bulk oprettelse: ' + error.message); + } +} + +// Bulk reprocess selected files +async function bulkReprocess() { + const fileIds = getSelectedFileIds(); + if (fileIds.length === 0) { + alert('Vælg venligst filer først'); + return; + } + + if (!confirm(`Genbehandle ${fileIds.length} valgte filer?`)) return; + + try { + let successCount = 0; + let failCount = 0; + + for (const fileId of fileIds) { + try { + const response = await fetch(`/api/v1/supplier-invoices/reprocess/${fileId}`, { + method: 'POST' + }); + + if (response.ok) { + successCount++; + } else { + failCount++; + } + } catch (error) { + console.error(`Failed to reprocess file ${fileId}:`, error); + failCount++; + } + } + + alert(`✅ ${successCount} filer genbehandlet\n${failCount > 0 ? `❌ ${failCount} fejlede` : ''}`); + loadPendingFiles(); + + } catch (error) { + console.error('Bulk reprocess failed:', error); + alert('❌ Fejl ved bulk genbehandling: ' + error.message); + } +} + +// Bulk delete selected files +async function bulkDelete() { + const fileIds = getSelectedFileIds(); + if (fileIds.length === 0) { + alert('Vælg venligst filer først'); + return; + } + + if (!confirm(`⚠️ ADVARSEL: Slet ${fileIds.length} valgte filer permanent?\n\nDenne handling kan ikke fortrydes!`)) return; + + try { + let successCount = 0; + let failCount = 0; + + for (const fileId of fileIds) { + try { + const response = await fetch(`/api/v1/supplier-invoices/files/${fileId}`, { + method: 'DELETE' + }); + + if (response.ok) { + successCount++; + } else { + failCount++; + } + } catch (error) { + console.error(`Failed to delete file ${fileId}:`, error); + failCount++; + } + } + + alert(`✅ ${successCount} filer slettet\n${failCount > 0 ? `❌ ${failCount} fejlede` : ''}`); + loadPendingFiles(); + + } catch (error) { + console.error('Bulk delete failed:', error); + alert('❌ Fejl ved bulk sletning: ' + error.message); + } +} + +// ========== END BULK ACTIONS ========== + +// ========== INVOICE BULK ACTIONS ========== + +// Toggle select all invoices +function toggleSelectAllInvoices() { + const selectAll = document.getElementById('selectAllInvoices'); + const checkboxes = document.querySelectorAll('.invoice-checkbox:not(:disabled)'); + checkboxes.forEach(cb => cb.checked = selectAll.checked); + updateInvoiceBulkActionsBar(); +} + +// Update invoice bulk actions bar visibility and count +function updateInvoiceBulkActionsBar() { + const checkboxes = document.querySelectorAll('.invoice-checkbox:checked'); + const count = checkboxes.length; + const bulkBar = document.getElementById('invoiceBulkActionsBar'); + const countSpan = document.getElementById('selectedInvoicesCount'); + + if (count > 0) { + bulkBar.style.display = 'block'; + countSpan.textContent = count; + } else { + bulkBar.style.display = 'none'; + document.getElementById('selectAllInvoices').checked = false; + } +} + +// Get selected invoice IDs +function getSelectedInvoiceIds() { + const checkboxes = document.querySelectorAll('.invoice-checkbox:checked'); + return Array.from(checkboxes).map(cb => parseInt(cb.dataset.invoiceId)); +} + +// Bulk send to e-conomic +async function bulkSendToEconomic() { + const invoiceIds = getSelectedInvoiceIds(); + if (invoiceIds.length === 0) { + alert('Vælg venligst fakturaer først'); + return; + } + + if (!confirm(`Send ${invoiceIds.length} fakturaer til e-conomic kassekladde?`)) return; + + try { + let successCount = 0; + let failCount = 0; + let errors = []; + + for (const invoiceId of invoiceIds) { + try { + const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/send-to-economic`, { + method: 'POST', + headers: {'Content-Type': 'application/json'} + }); + + if (response.ok) { + successCount++; + } else { + const errorData = await response.json(); + errors.push(`Faktura #${invoiceId}: ${errorData.detail || response.statusText}`); + failCount++; + } + } catch (error) { + console.error(`Failed to send invoice ${invoiceId}:`, error); + errors.push(`Faktura #${invoiceId}: ${error.message}`); + failCount++; + } + } + + let message = `✅ ${successCount} fakturaer sendt til e-conomic`; + if (failCount > 0) { + message += `\n\n❌ ${failCount} fejlede:\n${errors.join('\n')}`; + } + alert(message); + + // Reload invoices + loadInvoices(currentFilter); + + } catch (error) { + console.error('Bulk send to e-conomic failed:', error); + alert('❌ Fejl ved bulk sending: ' + error.message); + } +} + +// Bulk reset invoices (move back to pending) +async function bulkResetInvoices() { + const invoiceIds = getSelectedInvoiceIds(); + if (invoiceIds.length === 0) { + alert('Vælg venligst fakturaer først'); + return; + } + + if (!confirm(`Nulstil ${invoiceIds.length} fakturaer til afventer behandling?`)) return; + + try { + let successCount = 0; + let failCount = 0; + + for (const invoiceId of invoiceIds) { + try { + const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ status: 'pending' }) + }); + + if (response.ok) { + successCount++; + } else { + failCount++; + } + } catch (error) { + console.error(`Failed to reset invoice ${invoiceId}:`, error); + failCount++; + } + } + + alert(`✅ ${successCount} fakturaer nulstillet\n${failCount > 0 ? `❌ ${failCount} fejlede` : ''}`); + + // Reload invoices + loadInvoices(currentFilter); + + } catch (error) { + console.error('Bulk reset failed:', error); + alert('❌ Fejl ved bulk nulstilling: ' + error.message); + } +} + +// Bulk mark as paid +async function bulkMarkAsPaid() { + const invoiceIds = getSelectedInvoiceIds(); + if (invoiceIds.length === 0) { + alert('Vælg venligst fakturaer først'); + return; + } + + if (!confirm(`Marker ${invoiceIds.length} fakturaer som betalt?`)) return; + + try { + let successCount = 0; + let failCount = 0; + + for (const invoiceId of invoiceIds) { + try { + const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + status: 'paid', + payment_date: new Date().toISOString().split('T')[0] + }) + }); + + if (response.ok) { + successCount++; + } else { + failCount++; + } + } catch (error) { + console.error(`Failed to mark invoice ${invoiceId} as paid:`, error); + failCount++; + } + } + + alert(`✅ ${successCount} fakturaer markeret som betalt\n${failCount > 0 ? `❌ ${failCount} fejlede` : ''}`); + + // Reload invoices + loadInvoices(currentFilter); + + } catch (error) { + console.error('Bulk mark as paid failed:', error); + alert('❌ Fejl ved bulk betaling: ' + error.message); + } +} + +// Individual mark as paid +async function markInvoiceAsPaid(invoiceId) { + if (!confirm('Marker denne faktura som betalt?')) return; + + try { + const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + status: 'paid', + payment_date: new Date().toISOString().split('T')[0] + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to mark as paid'); + } + + alert('✅ Faktura markeret som betalt'); + loadInvoices(currentFilter); + + } catch (error) { + console.error('Failed to mark invoice as paid:', error); + alert('❌ Fejl: ' + error.message); + } +} + +// ========== END INVOICE BULK ACTIONS ========== + // ========== END PENDING FILES FUNCTIONS ========== // Filter functions @@ -1862,54 +2480,169 @@ async function viewInvoice(invoiceId) { currentInvoiceId = invoiceId; + // Check if invoice can be edited (not yet sent to e-conomic) + const isEditable = !invoice.economic_voucher_number; + const detailsHtml = ` -
-
-

Fakturanummer: ${invoice.invoice_number}

-

Leverandør: ${invoice.vendor_full_name || invoice.vendor_name}

-

Status: ${getStatusBadge(invoice.status)}

-
-
-

Fakturadato: ${formatDate(invoice.invoice_date)}

-

Forfaldsdato: ${formatDate(invoice.due_date)}

-

Total: ${formatCurrency(invoice.total_amount)}

+ +
+
+
+
+
+ + ${isEditable ? + `` : + `
${invoice.invoice_number}
` + } +
+
+ +
${invoice.vendor_full_name || invoice.vendor_name}
+
+
+ +
${getStatusBadge(invoice.status)}
+
+
+
+
+ + ${isEditable ? + `` : + `
${formatDate(invoice.invoice_date)}
` + } +
+
+ + ${isEditable ? + `` : + `
${formatDate(invoice.due_date)}
` + } +
+
+ + ${isEditable ? + `
+ + kr. +
` : + `
${formatCurrency(invoice.total_amount)}
` + } +
+
+
+ + ${invoice.notes ? ` +
+ +
${invoice.notes}
+
+ ` : ''} + + ${invoice.economic_voucher_number ? ` +
+
+ + Sendt til e-conomic - Bilagsnummer: ${invoice.economic_voucher_number} +
Kassekladde ${invoice.economic_journal_number}, Regnskabsår ${invoice.economic_accounting_year} +
+
+ ` : ''}
- ${invoice.description ? `

Beskrivelse: ${invoice.description}

` : ''} - ${invoice.notes ? `

Noter: ${invoice.notes}

` : ''} - - ${invoice.economic_voucher_number ? ` -
- - Sendt til e-conomic - Bilagsnummer: ${invoice.economic_voucher_number} - (Kassekladde ${invoice.economic_journal_number}, År ${invoice.economic_accounting_year}) + +
+
+
Produktlinier (${(invoice.lines || []).length})
- ` : ''} - -
Linjer:
-
+ + Filnavn Upload Dato StatusQuick Analysis Leverandør Template Handlinger
+
Indlæser...
@@ -363,16 +411,16 @@
+ Ingen fakturaer fundet
+
+ + ${isCreditNote ? '' : ''} ${inv.invoice_number} ${inv.vendor_full_name || inv.vendor_name || '-'}${formatDate(inv.invoice_date)}${formatDate(inv.due_date)}${formatCurrency(inv.total_amount)}${getStatusBadge(inv.computed_status || inv.status)}${inv.economic_voucher_number ? `Bilag #${inv.economic_voucher_number}` : '-'}${inv.vendor_full_name || inv.vendor_name || '-'}${formatDate(inv.invoice_date)}${formatDate(inv.due_date)}${formatCurrency(inv.total_amount)}${getStatusBadge(inv.computed_status || inv.status)}${inv.economic_voucher_number ? `Bilag #${inv.economic_voucher_number}` : '-'}
` : ''} ${!inv.economic_voucher_number ? ` + @@ -795,6 +854,9 @@ function renderInvoices(invoices) {
+
Indlæser...
@@ -875,7 +937,7 @@ async function loadPendingFiles() { console.error('Failed to load pending files:', error); document.getElementById('pendingFilesTable').innerHTML = `
+ Fejl ved indlæsning af filer
+ - Ingen uploadede filer afventer behandling + Ingen filer mangler behandling
+ + ${file.filename} + ${isOwnInvoice ? ` +
+ UDGÅENDE FAKTURA (BMC) +
+ ` : ''} + ${file.status === 'duplicate' && file.error_message ? ` +
+ ${file.error_message} +
+ ` : ''}
${formatDate(file.uploaded_at)} ${getFileStatusBadge(file.status)}${quickAnalysisHtml} ${file.vendor_matched_id ? `${file.matched_vendor_name}` : @@ -923,34 +1035,64 @@ function renderPendingFiles(files) { '-') } ${file.template_id ? `Template #${file.template_id}` : 'Ingen'} + ${file.template_id ? + `Template #${file.template_id}` : + (file.has_invoice2data_template ? + `Invoice2Data: ${file.invoice2data_template_name}` : + (file.vendor_matched_id || file.detected_vendor_id ? + `` : + '-')) + } +
- ${file.status === 'ai_extracted' || file.status === 'processed' ? ` + ${isOwnInvoice ? ` + + ` : ''} + ${file.status === 'duplicate' && !isOwnInvoice ? ` + + ` : ''} + ${(file.status === 'ai_extracted' || file.status === 'processed') && !isOwnInvoice ? ` ` : ''} - ${file.status === 'failed' || file.status === 'pending' ? ` + ${(file.status === 'failed' || file.status === 'pending') && !isOwnInvoice ? ` ` : ''} - ${file.status === 'processing' ? ` + ${file.status === 'processing' && !isOwnInvoice ? ` ` : ''} - - Se PDF - + ${file.status !== 'duplicate' ? ` + + Se PDF + + ` : ''}
- - - - - - - - - - - ${(invoice.lines || []).map(line => ` - - - - - - - - `).join('')} - -
BeskrivelseAntalPrisMomsTotal
${line.description}${line.quantity}${formatCurrency(line.unit_price)}${line.vat_code} (${line.vat_rate}%)${formatCurrency(line.line_total)}
+
+ ${isEditable ? ` +
+ 💡 Momskoder: + I25 25% moms (standard) · + I52 Omvendt betalingspligt · + I0 0% (momsfri) +
+ ` : ''} +
+ + + + + + + + + + + + + + ${(invoice.lines || []).map((line, idx) => ` + + + + + + + + + + `).join('')} + + + + + + + + +
VarenrBeskrivelseAntalEnhedsprisTotalMomskodeModkonto
+ ${isEditable ? + `` : + `${line.sku || '-'}` + } + + ${isEditable ? + `` : + (line.description || '') + } + + ${isEditable ? + `` : + line.quantity + } + + ${isEditable ? + `` : + formatCurrency(line.unit_price) + } + ${formatCurrency(line.line_total)} + ${isEditable ? ` + + ` : `${line.vat_code} (${line.vat_rate}%)`} + + ${isEditable ? + `` : + `${line.contra_account || '5810'}` + } +
I alt:${formatCurrency(invoice.total_amount)}
+
+
+ ${isEditable ? ` + + ` : ''} +
`; document.getElementById('invoiceDetails').innerHTML = detailsHtml; @@ -1926,6 +2659,77 @@ async function viewInvoice(invoiceId) { } } +// Save invoice changes +async function saveInvoiceChanges() { + if (!currentInvoiceId) return; + + try { + // Collect header data + const invoiceNumber = document.getElementById('editInvoiceNumber')?.value; + const invoiceDate = document.getElementById('editInvoiceDate')?.value; + const dueDate = document.getElementById('editDueDate')?.value; + const totalAmount = parseFloat(document.getElementById('editTotalAmount')?.value); + + // Collect line items + const lineRows = document.querySelectorAll('#invoiceLinesList tr'); + const lines = []; + + lineRows.forEach((row, idx) => { + const sku = row.querySelector('.line-sku')?.value || ''; + const description = row.querySelector('.line-description')?.value || ''; + const quantity = parseFloat(row.querySelector('.line-quantity')?.value) || 1; + const unitPrice = parseFloat(row.querySelector('.line-price')?.value) || 0; + const vatCode = row.querySelector('.line-vat-code')?.value || 'I25'; + const contraAccount = row.querySelector('.line-contra')?.value || '5810'; + + // Determine VAT rate from code + let vatRate = 25.00; + if (vatCode === 'I52' || vatCode === 'I0') { + vatRate = 0.00; + } + + lines.push({ + line_number: idx + 1, + sku: sku.trim() || null, + description: description, + quantity: quantity, + unit_price: unitPrice, + line_total: quantity * unitPrice, + vat_code: vatCode, + vat_rate: vatRate, + contra_account: contraAccount + }); + }); + + // Update invoice + const response = await fetch(`/api/v1/supplier-invoices/${currentInvoiceId}`, { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + invoice_number: invoiceNumber, + invoice_date: invoiceDate, + due_date: dueDate, + total_amount: totalAmount, + lines: lines + }) + }); + + if (response.ok) { + alert('✅ Ændringer gemt'); + // Reload invoice details + await viewInvoice(currentInvoiceId); + // Refresh list + loadInvoices(currentFilter); + } else { + const error = await response.json(); + alert('❌ Kunne ikke gemme: ' + (error.detail || 'Ukendt fejl')); + } + } catch (error) { + console.error('Failed to save invoice changes:', error); + alert('❌ Fejl ved gem: ' + error.message); + } +} + // Approve invoice async function approveInvoice() { if (!currentInvoiceId) return; @@ -2221,7 +3025,7 @@ function showMultiUploadResult(results) { html += `
`; @@ -2255,7 +3059,7 @@ function showUploadResult(result, success) {
diff --git a/app/billing/frontend/template_builder.html b/app/billing/frontend/template_builder.html index abfaf0c..b285da8 100644 --- a/app/billing/frontend/template_builder.html +++ b/app/billing/frontend/template_builder.html @@ -127,6 +127,11 @@
+
+ +
@@ -156,6 +161,18 @@ Navn på templaten, f.eks. leverandør + "Standard" eller "Email faktura" +
+ + + Standardkategori for varelinjer fra denne leverandør +
@@ -462,6 +479,137 @@ document.addEventListener('DOMContentLoaded', async () => { } else { await loadPendingFiles(); await loadVendors(); + + // Check if we're creating a template for a specific vendor/file + const vendorIdParam = urlParams.get('vendor'); + const fileIdParam = urlParams.get('file'); + + // Check for sessionStorage data (from supplier invoices page) + const storedData = sessionStorage.getItem('templateCreateData'); + let targetFileId = fileIdParam; + let targetVendorId = vendorIdParam; + let targetFileName = null; + let targetPdfText = null; + + if (storedData) { + try { + const data = JSON.parse(storedData); + console.log('🔄 Loaded template creation data from sessionStorage:', data); + + // Override with sessionStorage if available + if (data.fileId) targetFileId = data.fileId; + if (data.vendorId) targetVendorId = data.vendorId; + if (data.pdfText) targetPdfText = data.pdfText; + targetFileName = data.fileName || data.vendorName || targetFileName; + + // Clear sessionStorage after use + sessionStorage.removeItem('templateCreateData'); + } catch (error) { + console.error('Failed to parse template creation data:', error); + } + } + + // If we have PDF text from sessionStorage, skip file selection + if (targetPdfText && targetVendorId && targetFileId) { + console.log('🚀 Fast-track: Using PDF text from sessionStorage'); + + // Set up the file data directly + currentFile = { + file_id: targetFileId, + filename: targetFileName || `File ${targetFileId}`, + text: targetPdfText + }; + pdfText = targetPdfText; + + // Wait for vendors to load + setTimeout(() => { + // Pre-select vendor + const vendorSelect = document.getElementById('vendorSelect'); + if (vendorSelect) { + vendorSelect.value = targetVendorId; + console.log('✅ Vendor pre-selected:', targetVendorId); + } + + // Auto-generate template name + const templateNameInput = document.getElementById('templateName'); + if (templateNameInput && !templateNameInput.value) { + const vendorName = vendorSelect?.options[vendorSelect.selectedIndex]?.text || 'Template'; + templateNameInput.value = `${vendorName} Standard Template`; + console.log('✅ Template name generated:', templateNameInput.value); + } + + // Show PDF preview in step 2 + document.getElementById('pdfPreview2').textContent = pdfText; + + // Go directly to step 2 + console.log('🎯 Jumping to step 2 (vendor & template name)'); + nextStep(2); + + // After a moment, auto-advance to step 3 + setTimeout(() => { + console.log('🚀 Auto-advancing to step 3 (pattern definition)'); + validateAndNextStep(3); + }, 500); + + }, 500); + } + // If we have a target file but no PDF text, try to select from pending list + else if (targetFileId) { + console.log(`🎯 Auto-selecting file ${targetFileId} (${targetFileName || 'unknown'})`); + + // Wait for files to load, then auto-select + setTimeout(async () => { + try { + // First check if file exists in the loaded files + const filesList = document.getElementById('filesList'); + console.log('📋 Files list HTML:', filesList.innerHTML.substring(0, 200)); + + // Try to select the file + console.log('🔄 Calling selectFile...'); + await selectFile(parseInt(targetFileId), targetFileName || `File ${targetFileId}`); + console.log('✅ selectFile completed'); + + // After file is selected, pre-select vendor if available + if (targetVendorId) { + console.log(`🎯 Pre-selecting vendor ${targetVendorId}`); + + // Wait a bit for step 2 to render + setTimeout(() => { + const vendorSelect = document.getElementById('vendorSelect'); + if (!vendorSelect) { + console.error('❌ vendorSelect not found!'); + return; + } + + vendorSelect.value = targetVendorId; + console.log('✅ Vendor selected:', vendorSelect.value); + + // If both file and vendor are set, auto-advance to step 3 + setTimeout(() => { + const templateNameInput = document.getElementById('templateName'); + if (!templateNameInput) { + console.error('❌ templateName input not found!'); + return; + } + + if (!templateNameInput.value) { + // Auto-generate template name if empty + const vendorName = vendorSelect.options[vendorSelect.selectedIndex]?.text || 'Template'; + templateNameInput.value = `${vendorName} Standard Template`; + console.log('✅ Template name set:', templateNameInput.value); + } + + console.log('🚀 Auto-advancing to step 3 (pattern definition)'); + validateAndNextStep(3); + }, 300); + }, 300); + } + } catch (error) { + console.error('❌ Failed to auto-select file:', error); + alert('Kunne ikke auto-vælge fil: ' + error.message); + } + }, 1000); // Increased timeout to 1 second + } } }); @@ -498,6 +646,11 @@ async function loadExistingTemplate(templateId) { await loadVendors(); document.getElementById('vendorSelect').value = template.vendor_id; + // Set product category + if (template.default_product_category) { + document.getElementById('productCategory').value = template.default_product_category; + } + // Load detection patterns detectionPatterns = template.detection_patterns || []; @@ -727,30 +880,63 @@ async function loadVendors() { async function selectFile(fileId, filename) { try { - // Reprocess file to get PDF text - const response = await fetch(`/api/v1/supplier-invoices/reprocess/${fileId}`, { - method: 'POST' - }); + console.log(`🔄 Selecting file: ${fileId} (${filename})`); + + // Get PDF text directly (fast endpoint, no AI processing) + console.log(`📡 Fetching: /api/v1/supplier-invoices/files/${fileId}/pdf-text`); + const response = await fetch(`/api/v1/supplier-invoices/files/${fileId}/pdf-text`); + + console.log(`📥 Response status: ${response.status}`); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`❌ HTTP error: ${response.status} - ${errorText}`); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + const data = await response.json(); + console.log('📦 Response data:', data); + + if (!data.pdf_text) { + console.warn('⚠️ No PDF text in response'); + } currentFile = { file_id: fileId, filename: filename, - text: data.pdf_text + text: data.pdf_text || '' }; - pdfText = data.pdf_text; + pdfText = data.pdf_text || ''; + + console.log(`✅ File loaded, PDF text length: ${pdfText.length} chars`); // Show PDF preview - document.getElementById('pdfPreview').textContent = pdfText; + const pdfPreview = document.getElementById('pdfPreview'); + if (pdfPreview) { + pdfPreview.textContent = pdfText; + } + console.log('🚀 Advancing to step 2'); nextStep(2); + } catch (error) { - console.error('Failed to load file:', error); - alert('Kunne ikke hente fil'); + console.error('❌ Failed to load file:', error); + alert('Kunne ikke hente fil: ' + error.message); } } +function skipFileSelection() { + // Allow user to proceed without selecting a file + // They can upload/paste PDF text later + console.log('⏭️ Skipping file selection'); + + currentFile = null; + pdfText = ''; + + nextStep(2); +} + function validateAndNextStep(targetStep) { // Validate step 2 fields if (targetStep === 3) { @@ -1289,8 +1475,9 @@ async function autoGenerateTemplate() { async function saveTemplate() { const vendorId = document.getElementById('vendorSelect').value; const templateName = document.getElementById('templateName').value; + const productCategory = document.getElementById('productCategory').value; - console.log('Saving template...', { vendorId, templateName, editingTemplateId }); + console.log('Saving template...', { vendorId, templateName, productCategory, editingTemplateId }); console.log('Detection patterns:', detectionPatterns); console.log('Field patterns:', fieldPatterns); @@ -1299,6 +1486,11 @@ async function saveTemplate() { return; } + if (!productCategory) { + alert('Vælg produktkategori'); + return; + } + if (detectionPatterns.length === 0) { alert('Tilføj mindst ét detektionsmønster'); return; @@ -1378,6 +1570,7 @@ async function saveTemplate() { body: JSON.stringify({ vendor_id: parseInt(vendorId), template_name: templateName, + default_product_category: productCategory, detection_patterns: detectionPatternsData, field_mappings: fieldMappings }) diff --git a/app/billing/frontend/templates_list.html b/app/billing/frontend/templates_list.html index 14b0e43..12d7e7b 100644 --- a/app/billing/frontend/templates_list.html +++ b/app/billing/frontend/templates_list.html @@ -56,12 +56,9 @@
-

Faktura Templates

-

Administrer templates til automatisk faktura-udtrækning

+

Invoice2Data Templates (YAML)

+

YAML-baserede templates til automatisk faktura-udtrækning

- - Ny Template -
@@ -69,6 +66,63 @@
+ + + + + +