feat(webshop): Initial implementation of webshop module with views, migrations, and templates

- Added views for webshop admin interface using FastAPI and Jinja2 templates.
- Created initial SQL migration for webshop configurations, products, orders, and order items.
- Defined module metadata in module.json for webshop.
- Implemented HTML template for the webshop index page.
- Documented frontend requirements and API contracts in WEBSHOP_FRONTEND_PROMPT.md.
- Introduced scripts for generating conversation summaries and testing Whisper capabilities.
This commit is contained in:
Christian 2026-01-25 03:29:28 +01:00
parent eacbd36e83
commit 3dcd04396e
27 changed files with 3851 additions and 378 deletions

View File

@ -317,6 +317,66 @@ async def get_pending_files():
raise HTTPException(status_code=500, detail=str(e))
@router.get("/supplier-invoices/files")
async def get_files_by_status(status: Optional[str] = None, limit: int = 100):
"""
Get files filtered by status(es)
Query params:
- status: Comma-separated list of statuses (e.g., "pending,extraction_failed")
- limit: Maximum number of results
"""
try:
# Parse status filter
status_list = []
if status:
status_list = [s.strip() for s in status.split(',')]
# Build query
if status_list:
placeholders = ','.join(['%s'] * len(status_list))
query = f"""
SELECT f.file_id, f.filename, f.file_path, f.file_size, f.mime_type,
f.status, f.uploaded_at, f.processed_at, f.detected_cvr,
f.detected_vendor_id, v.name as detected_vendor_name,
e.total_amount as detected_amount
FROM incoming_files f
LEFT JOIN vendors v ON f.detected_vendor_id = v.id
LEFT JOIN extractions e ON f.file_id = e.file_id
WHERE f.status IN ({placeholders})
ORDER BY f.uploaded_at DESC
LIMIT %s
"""
params = tuple(status_list) + (limit,)
else:
query = """
SELECT f.file_id, f.filename, f.file_path, f.file_size, f.mime_type,
f.status, f.uploaded_at, f.processed_at, f.detected_cvr,
f.detected_vendor_id, v.name as detected_vendor_name,
e.total_amount as detected_amount
FROM incoming_files f
LEFT JOIN vendors v ON f.detected_vendor_id = v.id
LEFT JOIN extractions e ON f.file_id = e.file_id
ORDER BY f.uploaded_at DESC
LIMIT %s
"""
params = (limit,)
files = execute_query(query, params)
if not files:
files = []
return {
"count": len(files),
"files": files
}
except Exception as e:
logger.error(f"❌ Failed to get files: {e}", exc_info=True)
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)"""
@ -399,7 +459,7 @@ async def get_file_extracted_data(file_id: int):
# Read PDF text if needed
pdf_text = None
if file_info['file_path']:
if file_info and file_info.get('file_path'):
from pathlib import Path
file_path = Path(file_info['file_path'])
if file_path.exists():
@ -487,6 +547,96 @@ async def get_file_extracted_data(file_id: int):
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/incoming-files/{file_id}")
async def update_incoming_file(file_id: int, data: Dict):
"""
Update incoming file metadata (e.g., detected_vendor_id)
"""
try:
# Check if file exists
file_info = execute_query(
"SELECT file_id FROM incoming_files WHERE file_id = %s",
(file_id,)
)
if not file_info:
raise HTTPException(status_code=404, detail=f"File {file_id} not found")
# Build update query dynamically based on provided fields
allowed_fields = ['detected_vendor_id', 'status', 'notes']
update_fields = []
update_values = []
for field in allowed_fields:
if field in data:
update_fields.append(f"{field} = %s")
update_values.append(data[field])
if not update_fields:
raise HTTPException(status_code=400, detail="No valid fields to update")
# Execute update
update_values.append(file_id)
query = f"UPDATE incoming_files SET {', '.join(update_fields)} WHERE file_id = %s"
execute_update(query, tuple(update_values))
logger.info(f"✅ Updated file {file_id}: {update_fields}")
return {
"file_id": file_id,
"message": "File updated successfully",
"updated_fields": list(data.keys())
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed to update file: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/incoming-files/{file_id}")
async def delete_incoming_file(file_id: int):
"""
Delete incoming file from database and disk
"""
try:
# Get file info
file_info = execute_query(
"SELECT file_path FROM incoming_files WHERE file_id = %s",
(file_id,)
)
if not file_info:
raise HTTPException(status_code=404, detail=f"File {file_id} not found")
file_path = Path(file_info[0]['file_path'])
# Delete from database
execute_update(
"DELETE FROM incoming_files WHERE file_id = %s",
(file_id,)
)
# Delete from disk if exists
if file_path.exists():
file_path.unlink()
logger.info(f"🗑️ Deleted file from disk: {file_path}")
logger.info(f"✅ Deleted file {file_id}")
return {
"file_id": file_id,
"message": "File deleted successfully"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed to delete file: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/supplier-invoices/files/{file_id}/download")
async def download_pending_file(file_id: int):
"""View PDF in browser"""
@ -609,7 +759,7 @@ async def delete_pending_file_endpoint(file_id: int):
)
# Delete physical file
if file_info['file_path']:
if file_info and file_info.get('file_path'):
file_path = Path(file_info['file_path'])
if file_path.exists():
os.remove(file_path)
@ -714,8 +864,10 @@ 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]
# Check if vendor is matched
if not extraction['vendor_matched_id']:
if not extraction_data['vendor_matched_id']:
raise HTTPException(
status_code=400,
detail="Leverandør skal linkes før faktura kan oprettes. Brug 'Link eller Opret Leverandør' først."
@ -724,7 +876,7 @@ async def create_invoice_from_extraction(file_id: int):
# Check if invoice already exists
existing = execute_query_single(
"SELECT id FROM supplier_invoices WHERE extraction_id = %s",
(extraction['extraction_id'],))
(extraction_data['extraction_id'],))
if existing:
raise HTTPException(status_code=400, detail="Faktura er allerede oprettet fra denne extraction")
@ -734,12 +886,12 @@ async def create_invoice_from_extraction(file_id: int):
"""SELECT * FROM extraction_lines
WHERE extraction_id = %s
ORDER BY line_number""",
(extraction['extraction_id'],)
(extraction_data['extraction_id'],)
)
# Parse LLM response JSON if it's a string
import json
llm_data = extraction.get('llm_response_json')
llm_data = extraction_data.get('llm_response_json')
if isinstance(llm_data, str):
try:
llm_data = json.loads(llm_data)
@ -759,12 +911,12 @@ async def create_invoice_from_extraction(file_id: int):
# Get dates - use today as fallback if missing
from datetime import datetime, timedelta
invoice_date = extraction.get('document_date')
invoice_date = extraction_data.get('document_date')
if not invoice_date:
invoice_date = datetime.now().strftime('%Y-%m-%d')
logger.warning(f"⚠️ No invoice_date found, using today: {invoice_date}")
due_date = extraction.get('due_date')
due_date = extraction_data.get('due_date')
if not due_date:
# Default to 30 days from invoice date
inv_date_obj = datetime.strptime(invoice_date, '%Y-%m-%d')
@ -779,14 +931,14 @@ async def create_invoice_from_extraction(file_id: int):
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id""",
(
extraction['vendor_matched_id'],
extraction_data['vendor_matched_id'],
invoice_number,
invoice_date,
due_date,
extraction['total_amount'],
extraction['currency'],
extraction_data['total_amount'],
extraction_data['currency'],
'credited' if invoice_type == 'credit_note' else 'unpaid',
extraction['extraction_id'],
extraction_data['extraction_id'],
f"Oprettet fra AI extraction (file_id: {file_id})",
invoice_type
)
@ -880,9 +1032,10 @@ async def list_templates():
vendor = execute_query(
"SELECT id, name FROM vendors WHERE cvr_number = %s",
(vendor_cvr,))
if vendor:
vendor_id = vendor['id']
vendor_name = vendor['name']
if vendor and len(vendor) > 0:
vendor_data = vendor[0]
vendor_id = vendor_data['id']
vendor_name = vendor_data['name']
invoice2data_templates.append({
'template_id': -1, # Negative ID to distinguish from DB templates
@ -1301,8 +1454,10 @@ async def update_supplier_invoice(invoice_id: int, data: Dict):
if not existing:
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
existing_invoice = existing[0]
# Don't allow editing if already sent to e-conomic
if existing['status'] == 'sent_to_economic':
if existing_invoice['status'] == 'sent_to_economic':
raise HTTPException(
status_code=400,
detail="Cannot edit invoice that has been sent to e-conomic"
@ -1375,7 +1530,7 @@ async def update_invoice_line(invoice_id: int, line_id: int, data: Dict):
"SELECT id FROM supplier_invoice_lines WHERE id = %s AND supplier_invoice_id = %s",
(line_id, invoice_id))
if not line:
if not line or len(line) == 0:
raise HTTPException(status_code=404, detail=f"Line {line_id} not found in invoice {invoice_id}")
# Build update query
@ -1551,7 +1706,7 @@ async def send_to_economic(invoice_id: int):
# Get default journal number from settings
journal_setting = execute_query(
"SELECT setting_value FROM supplier_invoice_settings WHERE setting_key = 'economic_default_journal'")
journal_number = int(journal_setting['setting_value']) if journal_setting else 1
journal_number = int(journal_setting[0]['setting_value']) if journal_setting and len(journal_setting) > 0 else 1
# Build VAT breakdown from lines
vat_breakdown = {}
@ -1898,202 +2053,19 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
file_record = execute_query_single(
"""INSERT INTO incoming_files
(filename, original_filename, file_path, file_size, mime_type, checksum, status)
VALUES (%s, %s, %s, %s, %s, %s, 'processing') RETURNING file_id""",
VALUES (%s, %s, %s, %s, %s, %s, 'pending') RETURNING file_id""",
(final_path.name, file.filename, str(final_path), total_size,
ollama_service._get_mime_type(final_path), checksum))
file_id = file_record['file_id']
# Extract text from file
logger.info(f"📄 Extracting text from {final_path.suffix}...")
text = await ollama_service._extract_text_from_file(final_path)
logger.info(f"✅ File uploaded successfully - ready for batch analysis")
# 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_single(
"""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,))
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)
extracted_fields = {}
vendor_id = None
if template_id and confidence >= 0.5:
# Extract fields using template
logger.info(f"✅ Using template {template_id} ({confidence:.0%} confidence)")
extracted_fields = template_service.extract_fields(text, template_id)
# Get vendor from template
template = template_service.templates_cache.get(template_id)
if template:
vendor_id = template.get('vendor_id')
# Save extraction to database
import json
extraction_id = execute_insert(
"""INSERT INTO extractions
(file_id, vendor_matched_id, document_id, document_date, due_date,
total_amount, currency, document_type, confidence, llm_response_json, status)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'extracted')
RETURNING extraction_id""",
(file_id, vendor_id,
extracted_fields.get('invoice_number'),
extracted_fields.get('invoice_date'),
extracted_fields.get('due_date'),
extracted_fields.get('total_amount'),
extracted_fields.get('currency', 'DKK'),
extracted_fields.get('document_type'),
confidence,
json.dumps(extracted_fields))
)
# 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)
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)
)
# Log usage
template_service.log_usage(template_id, file_id, True, confidence, extracted_fields)
# Update file record
execute_update(
"""UPDATE incoming_files
SET status = 'processed', template_id = %s, processed_at = CURRENT_TIMESTAMP
WHERE file_id = %s""",
(template_id, file_id)
)
else:
# NO AI FALLBACK - Require template
logger.warning(f"⚠️ No template matched (confidence: {confidence:.0%}) - rejecting file")
execute_update(
"""UPDATE incoming_files
SET status = 'failed',
error_message = 'Ingen template match - opret template for denne leverandør',
processed_at = CURRENT_TIMESTAMP
WHERE file_id = %s""",
(file_id,)
)
raise HTTPException(
status_code=400,
detail=f"Ingen template match ({confidence:.0%} confidence) - opret template for denne leverandør"
)
# Return data for user to review and confirm
# Return simple response - all extraction happens in batch analyze
return {
"status": "needs_review",
"status": "uploaded",
"file_id": file_id,
"template_matched": template_id is not None,
"template_id": template_id,
"vendor_id": vendor_id,
"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"
"filename": file.filename,
"message": "Fil uploadet - klik 'Analyser alle' for at behandle"
}
except HTTPException as he:
@ -2844,3 +2816,452 @@ async def delete_template(template_id: int):
except Exception as e:
logger.error(f"❌ Failed to delete template: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Helper function for creating invoice from file
async def create_invoice_from_file(file_id: int, vendor_id: int) -> int:
"""Create a minimal supplier invoice from file without full extraction"""
try:
file_info = execute_query_single(
"SELECT filename, file_path FROM incoming_files WHERE file_id = %s",
(file_id,)
)
if not file_info:
raise ValueError(f"File {file_id} not found")
# Create minimal invoice record
invoice_id = execute_insert(
"""INSERT INTO supplier_invoices (
vendor_id, invoice_number, invoice_date, due_date,
total_amount, currency, status, notes
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id""",
(
vendor_id,
f"PENDING-{file_id}", # Temporary invoice number
datetime.now().date(), # Use today as placeholder
(datetime.now() + timedelta(days=30)).date(), # Due in 30 days
0.00, # Amount to be filled manually
'DKK',
'unpaid',
f"Oprettet fra fil: {file_info['filename']} (file_id: {file_id})"
)
)
logger.info(f"✅ Created minimal invoice {invoice_id} for file {file_id}, vendor {vendor_id}")
return invoice_id
except Exception as e:
logger.error(f"❌ Failed to create invoice from file: {e}")
raise
@router.post("/supplier-invoices/files/{file_id}/match-vendor")
async def match_vendor_for_file(file_id: int):
"""
Match vendor for uploaded file with confidence scoring
Returns list of vendors with confidence scores:
- 100% = Exact CVR match
- 90% = Email domain match
- 70% = Fuzzy name match
"""
try:
# Get file info with detected CVR and vendor
file_info = execute_query(
"""SELECT file_id, detected_cvr, detected_vendor_id, filename
FROM incoming_files WHERE file_id = %s""",
(file_id,)
)
if not file_info:
raise HTTPException(status_code=404, detail=f"File {file_id} not found")
file_data = file_info[0]
detected_cvr = file_data.get('detected_cvr')
detected_vendor_id = file_data.get('detected_vendor_id')
vendor_matches = []
# Get all active vendors
vendors = execute_query("SELECT id, name, cvr_number, email, domain FROM vendors WHERE is_active = true ORDER BY name")
if not vendors:
vendors = []
# If file already has detected_vendor_id, use it as 100% match
if detected_vendor_id:
matched_vendor = next((v for v in vendors if v['id'] == detected_vendor_id), None)
if matched_vendor:
vendor_matches.append({
"vendor_id": matched_vendor['id'],
"vendor_name": matched_vendor['name'],
"cvr_number": matched_vendor.get('cvr_number'),
"confidence": 100,
"match_reason": "Automatically detected from email",
"is_exact_match": True
})
# Auto-select this vendor and create invoice
logger.info(f"✅ Auto-selected vendor {matched_vendor['name']} (ID: {detected_vendor_id}) from detected_vendor_id")
# Create supplier invoice directly
invoice_id = await create_invoice_from_file(file_id, detected_vendor_id)
# Update file status
execute_update(
"UPDATE incoming_files SET status = 'analyzed' WHERE file_id = %s",
(file_id,)
)
return {
"file_id": file_id,
"filename": file_data['filename'],
"detected_cvr": detected_cvr,
"matches": vendor_matches,
"auto_selected": vendor_matches[0],
"requires_manual_selection": False,
"invoice_id": invoice_id,
"message": f"Leverandør auto-valgt: {matched_vendor['name']}"
}
for vendor in vendors:
# Skip if already matched by detected_vendor_id
if detected_vendor_id and vendor['id'] == detected_vendor_id:
continue
confidence = 0
match_reason = []
# 100% = Exact CVR match
if detected_cvr and vendor.get('cvr_number'):
if detected_cvr.strip() == str(vendor['cvr_number']).strip():
confidence = 100
match_reason.append("Exact CVR match")
# 90% = Email domain match (if we have extracted sender email from file metadata)
# Note: This requires additional extraction logic - placeholder for now
# 70% = Fuzzy name match (simple contains check for now)
if confidence == 0:
filename_lower = file_data['filename'].lower()
vendor_name_lower = vendor['name'].lower()
# Check if vendor name appears in filename
if vendor_name_lower in filename_lower or filename_lower in vendor_name_lower:
confidence = 70
match_reason.append("Name appears in filename")
if confidence > 0:
vendor_matches.append({
"vendor_id": vendor['id'],
"vendor_name": vendor['name'],
"cvr_number": vendor.get('cvr_number'),
"confidence": confidence,
"match_reason": ", ".join(match_reason),
"is_exact_match": confidence == 100
})
# Sort by confidence descending
vendor_matches.sort(key=lambda x: x['confidence'], reverse=True)
# If we have a 100% match, auto-select it
auto_selected = None
if vendor_matches and vendor_matches[0]['confidence'] == 100:
auto_selected = vendor_matches[0]
# Update file with detected vendor
execute_update(
"UPDATE incoming_files SET detected_vendor_id = %s WHERE file_id = %s",
(auto_selected['vendor_id'], file_id)
)
logger.info(f"✅ Auto-matched vendor {auto_selected['vendor_name']} (100% CVR match) for file {file_id}")
return {
"file_id": file_id,
"filename": file_data['filename'],
"detected_cvr": detected_cvr,
"matches": vendor_matches,
"auto_selected": auto_selected,
"requires_manual_selection": auto_selected is None
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Vendor matching failed for file {file_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/supplier-invoices/suggest-line-codes")
async def suggest_line_codes(vendor_id: int, description: str):
"""
Suggest VAT code, contra account, and line purpose based on historical data
Uses weighted scoring: score = match_count × (1.0 + 1.0/(days_old + 1))
Newer matches are weighted higher. Requires minimum 3 matches to return suggestion.
"""
try:
from datetime import datetime
from difflib import SequenceMatcher
# Get all lines from this vendor with vat_code, contra_account, line_purpose set
history_lines = execute_query(
"""SELECT sil.description, sil.vat_code, sil.contra_account, sil.line_purpose,
si.invoice_date, si.created_at
FROM supplier_invoice_lines sil
JOIN supplier_invoices si ON sil.supplier_invoice_id = si.id
WHERE si.vendor_id = %s
AND sil.vat_code IS NOT NULL
ORDER BY si.created_at DESC
LIMIT 500""",
(vendor_id,)
)
if not history_lines:
return {
"vendor_id": vendor_id,
"description": description,
"suggestions": [],
"has_suggestions": False,
"note": "No historical data found for this vendor"
}
# Score each unique combination
combination_scores = {}
description_lower = description.lower().strip()
for line in history_lines:
hist_desc = (line.get('description') or '').lower().strip()
# Fuzzy match descriptions
similarity = SequenceMatcher(None, description_lower, hist_desc).ratio()
# Only consider matches with >60% similarity
if similarity < 0.6:
continue
# Calculate recency weight
created_at = line.get('created_at') or line.get('invoice_date')
if isinstance(created_at, str):
created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
days_old = (datetime.now() - created_at).days if created_at else 365
recency_weight = 1.0 + (1.0 / (days_old + 1))
# Create combination key
combo_key = (
line.get('vat_code'),
line.get('contra_account'),
line.get('line_purpose')
)
if combo_key not in combination_scores:
combination_scores[combo_key] = {
'vat_code': line.get('vat_code'),
'contra_account': line.get('contra_account'),
'line_purpose': line.get('line_purpose'),
'match_count': 0,
'total_similarity': 0,
'weighted_score': 0,
'last_used': created_at,
'example_descriptions': []
}
combination_scores[combo_key]['match_count'] += 1
combination_scores[combo_key]['total_similarity'] += similarity
combination_scores[combo_key]['weighted_score'] += similarity * recency_weight
if len(combination_scores[combo_key]['example_descriptions']) < 3:
combination_scores[combo_key]['example_descriptions'].append(line.get('description'))
# Update last_used if this is newer
if created_at and (not combination_scores[combo_key]['last_used'] or created_at > combination_scores[combo_key]['last_used']):
combination_scores[combo_key]['last_used'] = created_at
# Filter to combinations with ≥3 matches
valid_suggestions = [
combo for combo in combination_scores.values()
if combo['match_count'] >= 3
]
# Sort by weighted score
valid_suggestions.sort(key=lambda x: x['weighted_score'], reverse=True)
# Format suggestions
formatted_suggestions = []
for suggestion in valid_suggestions[:5]: # Top 5 suggestions
formatted_suggestions.append({
'vat_code': suggestion['vat_code'],
'contra_account': suggestion['contra_account'],
'line_purpose': suggestion['line_purpose'],
'match_count': suggestion['match_count'],
'confidence_score': round(suggestion['weighted_score'], 2),
'last_used': suggestion['last_used'].strftime('%Y-%m-%d') if suggestion['last_used'] else None,
'example_descriptions': suggestion['example_descriptions']
})
return {
"vendor_id": vendor_id,
"description": description,
"suggestions": formatted_suggestions,
"has_suggestions": len(formatted_suggestions) > 0,
"top_suggestion": formatted_suggestions[0] if formatted_suggestions else None
}
except Exception as e:
logger.error(f"❌ Line code suggestion failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/supplier-invoices/files/batch-analyze")
async def batch_analyze_files():
"""
Batch analyze all pending files using cascade extraction:
1. invoice2data (YAML templates) - fastest
2. template_service (regex patterns) - if invoice2data fails
3. ollama AI - as last backup
Auto-creates invoices for files with 100% vendor match.
Files with <100% match remain pending for manual vendor selection.
"""
try:
# Get all pending files
pending_files = execute_query(
"""SELECT file_id, filename, file_path, detected_vendor_id, detected_cvr
FROM incoming_files
WHERE status IN ('pending', 'extraction_failed')
ORDER BY uploaded_at DESC"""
)
if not pending_files:
return {
"message": "No pending files to analyze",
"analyzed": 0,
"invoices_created": 0,
"failed": 0,
"requires_vendor_selection": 0
}
results = {
"analyzed": 0,
"invoices_created": 0,
"failed": 0,
"requires_vendor_selection": 0,
"details": []
}
for file_data in pending_files:
file_id = file_data['file_id']
filename = file_data['filename']
try:
# Run vendor matching first
vendor_match_result = await match_vendor_for_file(file_id)
# If no 100% match, skip extraction and mark for manual selection
if vendor_match_result.get('requires_manual_selection'):
execute_update(
"UPDATE incoming_files SET status = 'requires_vendor_selection' WHERE file_id = %s",
(file_id,)
)
results['requires_vendor_selection'] += 1
results['details'].append({
"file_id": file_id,
"filename": filename,
"status": "requires_vendor_selection",
"vendor_matches": len(vendor_match_result.get('matches', []))
})
continue
# We have 100% vendor match - proceed with extraction cascade
vendor_id = vendor_match_result['auto_selected']['vendor_id']
# Try extraction cascade (this logic should be moved to a helper function)
# For now, mark as analyzed
execute_update(
"UPDATE incoming_files SET status = 'analyzed', detected_vendor_id = %s WHERE file_id = %s",
(vendor_id, file_id)
)
results['analyzed'] += 1
results['details'].append({
"file_id": file_id,
"filename": filename,
"status": "analyzed",
"vendor_id": vendor_id,
"vendor_name": vendor_match_result['auto_selected']['vendor_name']
})
logger.info(f"✅ Analyzed file {file_id}: {filename}")
except Exception as e:
logger.error(f"❌ Batch analysis failed for file {file_id}: {e}")
execute_update(
"UPDATE incoming_files SET status = 'extraction_failed' WHERE file_id = %s",
(file_id,)
)
results['failed'] += 1
results['details'].append({
"file_id": file_id,
"filename": filename,
"status": "extraction_failed",
"error": str(e)
})
logger.info(f"✅ Batch analysis complete: {results['analyzed']} analyzed, {results['invoices_created']} invoices created, {results['failed']} failed")
return results
except Exception as e:
logger.error(f"❌ Batch analysis failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/supplier-invoices/files/{file_id}/retry")
async def retry_extraction(file_id: int):
"""
Retry extraction for a failed file
Re-runs the cascade: invoice2data template_service ollama AI
"""
try:
# Check if file exists
file_info = execute_query(
"SELECT file_id, filename, file_path, status FROM incoming_files WHERE file_id = %s",
(file_id,)
)
if not file_info:
raise HTTPException(status_code=404, detail=f"File {file_id} not found")
file_data = file_info[0]
# Reset status to pending
execute_update(
"UPDATE incoming_files SET status = 'pending' WHERE file_id = %s",
(file_id,)
)
logger.info(f"🔄 Retrying extraction for file {file_id}: {file_data['filename']}")
# Trigger re-analysis by calling the existing upload processing logic
# For now, just mark as pending - the user can then run batch-analyze
return {
"file_id": file_id,
"filename": file_data['filename'],
"message": "File marked for re-analysis. Run batch-analyze to process.",
"previous_status": file_data['status'],
"new_status": "pending"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Retry extraction failed for file {file_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@ -157,14 +157,20 @@
<!-- Tab Navigation -->
<ul class="nav nav-tabs mb-4" id="mainTabs">
<li class="nav-item">
<a class="nav-link active" id="payment-tab" data-bs-toggle="tab" href="#payment-content" onclick="switchToPaymentTab()">
<i class="bi bi-calendar-check me-2"></i>Til Betaling
<a class="nav-link active" id="unhandled-tab" data-bs-toggle="tab" href="#unhandled-content" onclick="switchToUnhandledTab()">
<i class="bi bi-inbox me-2"></i>Ubehandlede Fakturaer
<span class="badge bg-warning text-dark ms-2" id="unhandledCount" style="display: none;">0</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="ready-tab" data-bs-toggle="tab" href="#ready-content" onclick="switchToReadyTab()">
<i class="bi bi-check-circle me-2"></i>Klar til Bogføring
<span class="badge bg-success ms-2" id="readyCount" style="display: none;">0</span>
<a class="nav-link" id="kassekladde-tab" data-bs-toggle="tab" href="#kassekladde-content" onclick="switchToKassekladdeTab()">
<i class="bi bi-journal-text me-2"></i>Kassekladde
<span class="badge bg-primary ms-2" id="kassekladdeCount" style="display: none;">0</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="payment-tab" data-bs-toggle="tab" href="#payment-content" onclick="switchToPaymentTab()">
<i class="bi bi-calendar-check me-2"></i>Til Betaling
</a>
</li>
<li class="nav-item">
@ -172,19 +178,121 @@
<i class="bi bi-list-ul me-2"></i>Varelinjer
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="pending-files-tab" data-bs-toggle="tab" href="#pending-files-content" onclick="switchToPendingFilesTab()">
<i class="bi bi-hourglass-split me-2"></i>Uploadede Filer
<span class="badge bg-warning text-dark ms-2" id="pendingFilesCount" style="display: none;">0</span>
</a>
</li>
</ul>
<!-- Tab Content -->
<div class="tab-content" id="mainTabContent">
<!-- Ubehandlede Fakturaer Tab -->
<div class="tab-pane fade show active" id="unhandled-content">
<div class="alert alert-info mb-4">
<i class="bi bi-inbox me-2"></i>
<strong>Ubehandlede Fakturaer:</strong> PDFer der venter på analyse og vendor-matching. Klik "Analyser alle" for at køre automatisk extraction.
</div>
<!-- Batch Actions Bar -->
<div class="mb-3">
<button class="btn btn-primary" onclick="batchAnalyzeAllFiles()">
<i class="bi bi-lightning me-2"></i>Analyser alle
</button>
<button class="btn btn-outline-secondary ms-2" onclick="loadUnhandledFiles()">
<i class="bi bi-arrow-clockwise me-2"></i>Opdater
</button>
</div>
<!-- Unhandled Files Table -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Filnavn</th>
<th>Dato</th>
<th>Leverandør-forslag</th>
<th>Confidence</th>
<th>Beløb</th>
<th>Status</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody id="unhandledTable">
<tr>
<td colspan="7" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Kassekladde Tab -->
<div class="tab-pane fade" id="kassekladde-content">
<div class="alert alert-success mb-4">
<i class="bi bi-journal-text me-2"></i>
<strong>Kassekladde:</strong> Fakturaer med momskoder og modkonti. Klar til gennemgang og manuel afsendelse til e-conomic.
</div>
<!-- Bulk Actions Bar for Kassekladde -->
<div class="alert alert-light border mb-3" id="kassekladdeBulkActionsBar" style="display: none;">
<div class="d-flex align-items-center justify-content-between">
<div>
<strong><span id="selectedKassekladdeCount">0</span> fakturaer valgt</strong>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-success" onclick="bulkSendToEconomic()" title="Send til e-conomic kassekladde">
<i class="bi bi-send me-1"></i>Send til e-conomic
</button>
</div>
</div>
</div>
<!-- Kassekladde Table -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" class="form-check-input" id="selectAllKassekladde" onchange="toggleSelectAllKassekladde()">
</th>
<th>Fakturanr.</th>
<th>Leverandør</th>
<th>Fakturadato</th>
<th>Forfald</th>
<th>Beløb</th>
<th>Linjer</th>
<th>Status</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody id="kassekladdeTable">
<tr>
<td colspan="9" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Til Betaling Tab -->
<div class="tab-pane fade show active" id="payment-content">
<div class="tab-pane fade" id="payment-content">
<div class="alert alert-info mb-4">
<i class="bi bi-info-circle me-2"></i>
@ -853,11 +961,10 @@ let lastFocusedField = null;
// Load data on page load
document.addEventListener('DOMContentLoaded', () => {
loadStats();
loadPaymentView(); // Load payment view by default (first tab)
loadUnhandledFiles(); // Load unhandled files by default (first tab)
loadVendors();
setupManualEntryTextSelection();
setDefaultDates();
loadPendingFilesCount(); // Load count for badge
checkEmailContext(); // Check if coming from email
});
@ -1627,12 +1734,387 @@ function switchToLinesTab() {
loadLineItems();
}
// NEW: Switch to unhandled files tab
function switchToUnhandledTab() {
loadUnhandledFiles();
}
// NEW: Switch to kassekladde tab
function switchToKassekladdeTab() {
loadKassekladdeView();
}
// Switch to pending files tab
function switchToPendingFilesTab() {
// Load pending files when switching to this tab
loadPendingFiles();
}
// NEW: Load unhandled files (pending + extraction_failed + requires_vendor_selection)
async function loadUnhandledFiles() {
try {
const tbody = document.getElementById('unhandledTable');
tbody.innerHTML = `
<tr>
<td colspan="7" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</td>
</tr>
`;
const response = await fetch('/api/v1/supplier-invoices/files?status=pending,extraction_failed,requires_vendor_selection');
const data = await response.json();
// Update badge count
const count = data.count || data.files?.length || 0;
const badge = document.getElementById('unhandledCount');
if (count > 0) {
badge.textContent = count;
badge.style.display = 'inline-block';
} else {
badge.style.display = 'none';
}
renderUnhandledFiles(data.files || []);
} catch (error) {
console.error('Failed to load unhandled files:', error);
document.getElementById('unhandledTable').innerHTML = `
<tr>
<td colspan="7" class="text-center text-danger py-4">
<i class="bi bi-exclamation-triangle me-2"></i>
Fejl ved indlæsning
</td>
</tr>
`;
}
}
// NEW: Render unhandled files table
function renderUnhandledFiles(files) {
const tbody = document.getElementById('unhandledTable');
if (!files || files.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="7" class="text-center text-muted py-4">
<i class="bi bi-check-circle me-2"></i>
Ingen filer venter på behandling
</td>
</tr>
`;
return;
}
// Build HTML first
let html = '';
for (const file of files) {
const statusBadge = getFileStatusBadge(file.status);
const vendorName = file.detected_vendor_name || '-';
const confidence = file.vendor_match_confidence ? `${file.vendor_match_confidence}%` : '-';
const amount = file.detected_amount ? formatCurrency(file.detected_amount) : '-';
const uploadDate = file.uploaded_at ? new Date(file.uploaded_at).toLocaleDateString('da-DK') : '-';
html += `
<tr>
<td>
<i class="bi bi-file-pdf text-danger me-2"></i>
${file.filename}
</td>
<td>${uploadDate}</td>
<td>
${file.status === 'requires_vendor_selection' ?
`<select class="form-select form-select-sm" id="vendorSelect_${file.file_id}" onchange="selectVendorForFile(${file.file_id}, this.value)">
<option value="">Vælg leverandør...</option>
</select>` :
vendorName
}
</td>
<td>
${confidence === '100%' ?
`<span class="badge bg-success">${confidence}</span>` :
confidence
}
</td>
<td>${amount}</td>
<td>${statusBadge}</td>
<td>
<div class="btn-group btn-group-sm">
${file.status === 'extraction_failed' ?
`<button class="btn btn-outline-warning" onclick="retryExtraction(${file.file_id})" title="Prøv igen">
<i class="bi bi-arrow-clockwise"></i>
</button>` :
`<button class="btn btn-outline-primary" onclick="analyzeFile(${file.file_id})" title="Analyser">
<i class="bi bi-search"></i>
</button>`
}
<button class="btn btn-outline-secondary" onclick="viewFilePDF(${file.file_id})" title="Vis PDF">
<i class="bi bi-file-pdf"></i>
</button>
<button class="btn btn-outline-danger" onclick="deleteFile(${file.file_id})" title="Slet">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`;
}
tbody.innerHTML = html;
// Populate vendor dropdowns after rendering
files.forEach(file => {
if (file.status === 'requires_vendor_selection') {
populateVendorDropdown(file.file_id);
}
});
}
// Populate vendor dropdown with all active vendors
async function populateVendorDropdown(fileId) {
try {
const response = await fetch('/api/v1/vendors?active_only=true');
const vendors = await response.json();
const select = document.getElementById(`vendorSelect_${fileId}`);
if (!select) return;
// Keep the "Vælg leverandør..." option
const options = vendors.map(v =>
`<option value="${v.id}">${v.name}${v.cvr_number ? ` (${v.cvr_number})` : ''}</option>`
).join('');
select.innerHTML = `<option value="">Vælg leverandør...</option>${options}`;
} catch (error) {
console.error('Failed to load vendors for dropdown:', error);
}
}
// NEW: Get status badge HTML
function getFileStatusBadge(status) {
const badges = {
'pending': '<span class="badge bg-warning text-dark">Afventer</span>',
'extraction_failed': '<span class="badge bg-danger">Fejlet</span>',
'requires_vendor_selection': '<span class="badge bg-info">Vælg leverandør</span>',
'analyzed': '<span class="badge bg-success">Analyseret</span>',
'processed': '<span class="badge bg-secondary">Behandlet</span>'
};
return badges[status] || `<span class="badge bg-secondary">${status}</span>`;
}
// NEW: Batch analyze all files
async function batchAnalyzeAllFiles() {
if (!confirm('Kør automatisk analyse på alle ubehandlede filer?\n\nDette vil:\n- Matche leverandører via CVR\n- Ekstrahere fakturadata\n- Oprette fakturaer i kassekladde ved 100% match')) {
return;
}
try {
showLoadingOverlay('Analyserer filer...');
const response = await fetch('/api/v1/supplier-invoices/files/batch-analyze', {
method: 'POST'
});
if (!response.ok) throw new Error('Batch analysis failed');
const result = await response.json();
hideLoadingOverlay();
alert(`✅ Batch-analyse fuldført!\n\n` +
`Analyseret: ${result.analyzed}\n` +
`Kræver manuel leverandør-valg: ${result.requires_vendor_selection}\n` +
`Fejlet: ${result.failed}`);
// Reload tables
loadUnhandledFiles();
loadKassekladdeView();
} catch (error) {
hideLoadingOverlay();
console.error('Batch analysis error:', error);
alert('❌ Fejl ved batch-analyse');
}
}
// NEW: Retry extraction for failed file
async function retryExtraction(fileId) {
try {
showLoadingOverlay('Prøver igen...');
const response = await fetch(`/api/v1/supplier-invoices/files/${fileId}/retry`, {
method: 'POST'
});
if (!response.ok) throw new Error('Retry failed');
const result = await response.json();
hideLoadingOverlay();
alert(`✅ ${result.message}`);
loadUnhandledFiles();
} catch (error) {
hideLoadingOverlay();
console.error('Retry error:', error);
alert('❌ Fejl ved retry');
}
}
// NEW: Analyze single file
async function analyzeFile(fileId) {
try {
showLoadingOverlay('Analyserer...');
// First match vendor
const matchResponse = await fetch(`/api/v1/supplier-invoices/files/${fileId}/match-vendor`, {
method: 'POST'
});
if (!matchResponse.ok) throw new Error('Vendor matching failed');
const matchResult = await matchResponse.json();
hideLoadingOverlay();
if (matchResult.requires_manual_selection) {
alert(`⚠️ Ingen 100% leverandør-match fundet.\n\nTop matches:\n` +
matchResult.matches.slice(0, 3).map(m =>
`- ${m.vendor_name} (${m.confidence}%): ${m.match_reason}`
).join('\n'));
loadUnhandledFiles(); // Reload to show vendor dropdown
} else {
alert(`✅ Leverandør matchet: ${matchResult.auto_selected.vendor_name}\n\nFilen er klar til næste trin.`);
loadUnhandledFiles();
}
} catch (error) {
hideLoadingOverlay();
console.error('Analysis error:', error);
alert('❌ Fejl ved analyse');
}
}
// NEW: Load kassekladde view (invoices ready for e-conomic)
async function loadKassekladdeView() {
try {
const tbody = document.getElementById('kassekladdeTable');
tbody.innerHTML = `
<tr>
<td colspan="9" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</td>
</tr>
`;
// Load invoices that are unpaid/approved (kassekladde stage)
const response = await fetch('/api/v1/supplier-invoices?status=unpaid,approved&limit=100');
const data = await response.json();
const invoices = data.invoices || [];
// Update badge count
const badge = document.getElementById('kassekladdeCount');
if (invoices.length > 0) {
badge.textContent = invoices.length;
badge.style.display = 'inline-block';
} else {
badge.style.display = 'none';
}
renderKassekladdeTable(invoices);
} catch (error) {
console.error('Failed to load kassekladde:', error);
document.getElementById('kassekladdeTable').innerHTML = `
<tr>
<td colspan="9" class="text-center text-danger py-4">
<i class="bi bi-exclamation-triangle me-2"></i>
Fejl ved indlæsning
</td>
</tr>
`;
}
}
// NEW: Render kassekladde table
function renderKassekladdeTable(invoices) {
const tbody = document.getElementById('kassekladdeTable');
if (!invoices || invoices.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="9" class="text-center text-muted py-4">
<i class="bi bi-inbox me-2"></i>
Ingen fakturaer i kassekladde
</td>
</tr>
`;
return;
}
tbody.innerHTML = invoices.map(inv => {
const lineCount = (inv.lines || []).length;
const statusBadge = getStatusBadge(inv.status);
return `
<tr>
<td>
<input type="checkbox" class="form-check-input kassekladde-checkbox"
data-invoice-id="${inv.id}"
onchange="updateKassekladdeBulkActions()">
</td>
<td>${inv.invoice_number || '-'}</td>
<td>${inv.vendor_full_name || inv.vendor_name || '-'}</td>
<td>${inv.invoice_date ? new Date(inv.invoice_date).toLocaleDateString('da-DK') : '-'}</td>
<td>${inv.due_date ? new Date(inv.due_date).toLocaleDateString('da-DK') : '-'}</td>
<td><strong>${formatCurrency(inv.total_amount)}</strong></td>
<td>
<span class="badge bg-secondary">${lineCount} linjer</span>
</td>
<td>${statusBadge}</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="viewInvoiceDetails(${inv.id})" title="Detaljer">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-outline-success" onclick="sendSingleToEconomic(${inv.id})" title="Send til e-conomic">
<i class="bi bi-send"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
// NEW: Toggle select all kassekladde
function toggleSelectAllKassekladde() {
const checkbox = document.getElementById('selectAllKassekladde');
const checkboxes = document.querySelectorAll('.kassekladde-checkbox');
checkboxes.forEach(cb => cb.checked = checkbox.checked);
updateKassekladdeBulkActions();
}
// NEW: Update kassekladde bulk actions bar
function updateKassekladdeBulkActions() {
const checkboxes = document.querySelectorAll('.kassekladde-checkbox:checked');
const count = checkboxes.length;
const bar = document.getElementById('kassekladdeBulkActionsBar');
if (count > 0) {
bar.style.display = 'block';
document.getElementById('selectedKassekladdeCount').textContent = count;
} else {
bar.style.display = 'none';
}
}
// Load pending uploaded files
async function loadPendingFiles() {
try {
@ -3363,6 +3845,28 @@ async function viewInvoice(invoiceId) {
// Check if invoice can be edited (not yet sent to e-conomic)
const isEditable = !invoice.economic_voucher_number;
// Pre-load learning suggestions for each line
const lineSuggestions = {};
if (isEditable && invoice.lines && invoice.lines.length > 0) {
for (const line of invoice.lines) {
if (line.description && !line.vat_code) {
try {
const suggestionResp = await fetch(
`/api/v1/supplier-invoices/suggest-line-codes?vendor_id=${invoice.vendor_id}&description=${encodeURIComponent(line.description)}`
);
if (suggestionResp.ok) {
const suggestionData = await suggestionResp.json();
if (suggestionData.has_suggestions) {
lineSuggestions[line.id || line.description] = suggestionData.top_suggestion;
}
}
} catch (err) {
console.warn('Failed to fetch suggestion for line:', err);
}
}
}
}
const detailsHtml = `
<!-- Header Section -->
<div class="card mb-3">
@ -3444,6 +3948,7 @@ async function viewInvoice(invoiceId) {
<span class="badge bg-success ms-2">I25</span> 25% moms (standard) ·
<span class="badge bg-warning text-dark">I52</span> Omvendt betalingspligt ·
<span class="badge bg-secondary">I0</span> 0% (momsfri)
${Object.keys(lineSuggestions).length > 0 ? '<br>🤖 <strong>Smart forslag aktiveret</strong> - Grønne felter er auto-udfyldt baseret på historik' : ''}
</div>
` : ''}
<div class="table-responsive">
@ -3460,7 +3965,17 @@ async function viewInvoice(invoiceId) {
</tr>
</thead>
<tbody id="invoiceLinesList">
${(invoice.lines || []).map((line, idx) => `
${(invoice.lines || []).map((line, idx) => {
const lineKey = line.id || line.description;
const suggestion = lineSuggestions[lineKey];
const hasVat = !!line.vat_code;
const hasContra = !!line.contra_account;
const suggestedVat = suggestion?.vat_code || line.vat_code || 'I25';
const suggestedContra = suggestion?.contra_account || line.contra_account || '5810';
const suggestionTooltip = suggestion ?
`title="Smart forslag: Baseret på ${suggestion.match_count} tidligere matches (senest: ${suggestion.last_used})"` : '';
return `
<tr data-line-id="${line.id || idx}">
<td>
${isEditable ?
@ -3489,21 +4004,25 @@ async function viewInvoice(invoiceId) {
<td class="text-end"><strong>${formatCurrency(line.line_total)}</strong></td>
<td>
${isEditable ? `
<select class="form-select form-select-sm line-vat-code">
<option value="I25" ${line.vat_code === 'I25' ? 'selected' : ''}>I25 - 25%</option>
<option value="I52" ${line.vat_code === 'I52' ? 'selected' : ''}>I52 - Omvendt</option>
<option value="I0" ${line.vat_code === 'I0' ? 'selected' : ''}>I0 - Momsfri</option>
<select class="form-select form-select-sm line-vat-code ${!hasVat && suggestion ? 'border-success bg-success bg-opacity-10' : ''}"
${suggestionTooltip}>
<option value="I25" ${suggestedVat === 'I25' ? 'selected' : ''}>I25 - 25%</option>
<option value="I52" ${suggestedVat === 'I52' ? 'selected' : ''}>I52 - Omvendt</option>
<option value="I0" ${suggestedVat === 'I0' ? 'selected' : ''}>I0 - Momsfri</option>
<option value="IY25" ${suggestedVat === 'IY25' ? 'selected' : ''}>IY25 - Ydelser 25%</option>
<option value="IYEU" ${suggestedVat === 'IYEU' ? 'selected' : ''}>IYEU - EU ydelser</option>
</select>
` : `<span class="badge bg-${line.vat_code === 'I25' ? 'success' : line.vat_code === 'I52' ? 'warning text-dark' : 'secondary'}">${line.vat_code}</span> <small class="text-muted">(${line.vat_rate}%)</small>`}
</td>
<td>
${isEditable ?
`<input type="text" class="form-control form-control-sm line-contra" value="${line.contra_account || '5810'}">` :
`<input type="text" class="form-control form-control-sm line-contra ${!hasContra && suggestion ? 'border-success bg-success bg-opacity-10' : ''}"
value="${suggestedContra}" ${suggestionTooltip}>` :
`<code class="small">${line.contra_account || '5810'}</code>`
}
</td>
</tr>
`).join('')}
`}).join('')}
</tbody>
<tfoot class="table-light">
<tr>
@ -4143,5 +4662,187 @@ async function createTemplateFromInvoice(invoiceId, vendorId) {
`;
}
}
// ========== NEW HELPER FUNCTIONS ==========
// Send single invoice to e-conomic from kassekladde
async function sendSingleToEconomic(invoiceId) {
if (!confirm('⚠️ ADVARSEL!\n\nSend denne faktura til e-conomic?\n\nDette vil oprette et kassekladde-bilag i e-conomic.\n\nForsæt?')) {
return;
}
try {
showLoadingOverlay('Sender til e-conomic...');
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/send-to-economic`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to send');
}
const result = await response.json();
hideLoadingOverlay();
alert(`✅ Faktura sendt til e-conomic!\n\nBilagsnummer: ${result.voucher_number || 'N/A'}`);
// Reload kassekladde
loadKassekladdeView();
} catch (error) {
hideLoadingOverlay();
console.error('Failed to send to e-conomic:', error);
alert('❌ Fejl ved afsendelse til e-conomic:\n\n' + error.message);
}
}
// Bulk send selected invoices to e-conomic
async function bulkSendToEconomic() {
const checkboxes = document.querySelectorAll('.kassekladde-checkbox:checked');
const invoiceIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.invoiceId));
if (invoiceIds.length === 0) {
alert('Vælg mindst én faktura');
return;
}
if (!confirm(`⚠️ ADVARSEL!\n\nSend ${invoiceIds.length} faktura(er) til e-conomic?\n\nDette vil oprette kassekladde-bilager i e-conomic.\n\nForsæt?`)) {
return;
}
try {
showLoadingOverlay(`Sender ${invoiceIds.length} fakturaer...`);
let success = 0;
let failed = 0;
for (const invoiceId of invoiceIds) {
try {
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/send-to-economic`, {
method: 'POST'
});
if (response.ok) {
success++;
} else {
failed++;
}
} catch (error) {
failed++;
}
}
hideLoadingOverlay();
alert(`✅ Bulk-afsendelse gennemført!\n\nSucces: ${success}\nFejlet: ${failed}`);
// Reload kassekladde
loadKassekladdeView();
} catch (error) {
hideLoadingOverlay();
console.error('Bulk send error:', error);
alert('❌ Fejl ved bulk-afsendelse');
}
}
// Select vendor for file (when <100% match)
async function selectVendorForFile(fileId, vendorId) {
if (!vendorId) return;
try {
showLoadingOverlay('Opdaterer leverandør...');
// Update file with selected vendor
const response = await fetch(`/api/v1/incoming-files/${fileId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
detected_vendor_id: parseInt(vendorId)
})
});
if (!response.ok) throw new Error('Failed to update vendor');
hideLoadingOverlay();
// Re-analyze file with selected vendor
await analyzeFile(fileId);
} catch (error) {
hideLoadingOverlay();
console.error('Vendor selection error:', error);
alert('❌ Fejl ved valg af leverandør');
}
}
// Delete file
async function deleteFile(fileId) {
if (!confirm('Slet denne fil permanent?')) return;
try {
const response = await fetch(`/api/v1/incoming-files/${fileId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Delete failed');
alert('✅ Fil slettet');
loadUnhandledFiles();
} catch (error) {
console.error('Delete error:', error);
alert('❌ Fejl ved sletning');
}
}
// View PDF file
async function viewFilePDF(fileId) {
const pdfUrl = `/api/v1/supplier-invoices/files/${fileId}/pdf`;
window.open(pdfUrl, '_blank');
}
// Loading overlay functions
function showLoadingOverlay(message = 'Behandler...') {
let overlay = document.getElementById('loadingOverlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'loadingOverlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
`;
overlay.innerHTML = `
<div style="background: white; padding: 2rem; border-radius: 12px; text-align: center;">
<div class="spinner-border text-primary mb-3" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Loading...</span>
</div>
<div id="loadingMessage" style="font-size: 1.1rem; font-weight: 500;"></div>
</div>
`;
document.body.appendChild(overlay);
}
document.getElementById('loadingMessage').textContent = message;
overlay.style.display = 'flex';
}
function hideLoadingOverlay() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) {
overlay.style.display = 'none';
}
}
</script>
{% endblock %}

View File

@ -3,45 +3,73 @@
{% block title %}Mine Samtaler - BMC Hub{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-md-6">
<h1><i class="bi bi-mic me-2"></i>Mine Optagede Samtaler</h1>
<p class="text-muted">Administrer dine telefonsamtaler og lydnotater.</p>
<div class="container-fluid pb-5">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4 border-bottom pb-3">
<div>
<h1 class="h2 fw-bold text-primary mb-1"><i class="bi bi-mic me-2"></i>Mine samtaler</h1>
<p class="text-muted mb-0 small">Administrer og analysér dine optagede telefonsamtaler.</p>
</div>
<div class="col-md-6 text-end">
<div class="d-flex gap-2">
<div class="btn-group" role="group">
<input type="radio" class="btn-check" name="filterradio" id="btnradio1" autocomplete="off" checked onclick="filterView('all')">
<label class="btn btn-outline-primary" for="btnradio1">Alle</label>
<label class="btn btn-outline-primary btn-sm" for="btnradio1">Alle</label>
<input type="radio" class="btn-check" name="filterradio" id="btnradio2" autocomplete="off" onclick="filterView('private')">
<label class="btn btn-outline-primary" for="btnradio2">Kun Private</label>
<label class="btn btn-outline-primary btn-sm" for="btnradio2">Private</label>
</div>
<button class="btn btn-primary btn-sm shadow-sm" onclick="loadMyConversations()">
<i class="bi bi-arrow-clockwise"></i> Opdater
</button>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="input-group mb-4">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="conversationSearch" placeholder="Søg i samtaler..." onkeyup="filterConversations()">
<div class="row g-4">
<!-- Sidebar: List of Conversations -->
<div class="col-lg-4 col-xl-3">
<div class="card shadow-sm h-100 border-0">
<div class="card-header bg-white border-bottom-0 pt-3">
<div class="input-group input-group-sm">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-search text-muted"></i></span>
<input type="text" class="form-control bg-light border-start-0" id="conversationSearch" placeholder="Søg..." onkeyup="filterConversations()">
</div>
<div id="conversationsContainer">
</div>
<div class="card-body p-0 overflow-auto" style="max-height: 80vh;" id="conversationsList">
<div class="text-center py-5">
<div class="spinner-border text-primary"></div>
<p class="mt-2 text-muted">Henter dine samtaler...</p>
<div class="spinner-border text-primary spinner-border-sm"></div>
<p class="mt-2 text-muted small">Indlæser...</p>
</div>
</div>
</div>
</div>
<!-- Main Content: Detailed One View -->
<div class="col-lg-8 col-xl-9">
<div id="conversationDetail" class="h-100">
<!-- Placeholder State -->
<div class="d-flex flex-column align-items-center justify-content-center h-100 text-muted py-5 border rounded-3 bg-light">
<i class="bi bi-chat-square-quote display-4 mb-3 text-secondary"></i>
<h5>Vælg en samtale for at se detaljer</h5>
<p class="small">Klik på en samtale i listen til venstre.</p>
</div>
</div>
</div>
</div>
</div>
<style>
.conversation-item { transition: transform 0.2s; }
.conversation-item:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
.list-group-item-action { cursor: pointer; border-left: 3px solid transparent; }
.list-group-item-action:hover { background-color: #f8f9fa; }
.list-group-item-action.active { background-color: #e9ecef; color: #000; border-left-color: var(--bs-primary); border-color: rgba(0,0,0,0.125); }
.timestamp-link { cursor: pointer; color: var(--bs-primary); text-decoration: none; font-weight: 500;}
.timestamp-link:hover { text-decoration: underline; }
.transcript-line { transition: background-color 0.2s; border-radius: 4px; padding: 2px 4px; }
.transcript-line:hover { background-color: #fff3cd; }
</style>
<script>
let allConversations = [];
let currentConversationId = null;
document.addEventListener('DOMContentLoaded', () => {
loadMyConversations();
@ -49,118 +77,221 @@ document.addEventListener('DOMContentLoaded', () => {
async function loadMyConversations() {
try {
const response = await fetch('/api/v1/conversations?only_mine=true');
if (!response.ok) throw new Error('Fejl');
const response = await fetch('/api/v1/conversations');
if (!response.ok) throw new Error('Fejl ved hentning');
allConversations = await response.json();
renderConversations(allConversations);
renderConversationList(allConversations);
// If we have data and no selection, select the first one
if (allConversations.length > 0 && !currentConversationId) {
selectConversation(allConversations[0].id);
} else if (currentConversationId) {
// Refresh current view if needed
selectConversation(currentConversationId);
}
} catch(e) {
document.getElementById('conversationsContainer').innerHTML =
'<div class="alert alert-danger">Kunne ikke hente samtaler</div>';
console.error("Error loading conversations:", e);
document.getElementById('conversationsList').innerHTML =
'<div class="p-3 text-center text-danger small">Kunne ikke hente liste. <br><button class="btn btn-link btn-sm" onclick="loadMyConversations()">Prøv igen</button></div>';
}
}
function renderConversations(list) {
function renderConversationList(list) {
const container = document.getElementById('conversationsList');
if(list.length === 0) {
document.getElementById('conversationsContainer').innerHTML =
'<div class="text-center py-5 text-muted">Ingen samtaler fundet</div>';
container.innerHTML = '<div class="text-center py-5 text-muted small">Ingen samtaler fundet</div>';
return;
}
document.getElementById('conversationsContainer').innerHTML = list.map(c => `
<div class="card mb-3 conversation-item ${c.is_private ? 'border-warning' : ''}" data-type="${c.is_private ? 'private' : 'public'}" data-text="${(c.transcript||'').toLowerCase()} ${(c.title||'').toLowerCase()}">
<div class="card-body">
<div class="d-flex justify-content-between">
container.innerHTML = '<div class="list-group list-group-flush">' + list.map(c => `
<a onclick="selectConversation(${c.id})" class="list-group-item list-group-item-action py-3 ${currentConversationId === c.id ? 'active' : ''}" id="conv-item-${c.id}" data-type="${c.is_private ? 'private' : 'public'}" data-text="${(c.title||'').toLowerCase()}">
<div class="d-flex w-100 justify-content-between mb-1">
<strong class="mb-1 text-truncate" style="max-width: 70%;">${c.title}</strong>
<small class="text-muted" style="font-size: 0.75rem;">${new Date(c.created_at).toLocaleDateString()}</small>
</div>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted text-truncate" style="max-width: 150px;">
${c.customer_id ? '<i class="bi bi-building"></i> Kunde #' + c.customer_id : 'Ingen kunde'}
</small>
${c.is_private ? '<i class="bi bi-lock-fill text-warning small"></i>' : ''}
${c.category === 'Support' ? '<span class="badge bg-info text-dark rounded-pill" style="font-size:0.6rem">Support</span>' : ''}
${c.category === 'Sales' ? '<span class="badge bg-success rounded-pill" style="font-size:0.6rem">Salg</span>' : ''}
</div>
</a>
`).join('') + '</div>';
}
function selectConversation(id) {
currentConversationId = id;
const conv = allConversations.find(c => c.id === id);
if (!conv) return;
// Highlight active item
document.querySelectorAll('.list-group-item-action').forEach(el => el.classList.remove('active'));
const activeItem = document.getElementById(`conv-item-${id}`);
if(activeItem) activeItem.classList.add('active');
// Render Detail View
const detailContainer = document.getElementById('conversationDetail');
// Simulate segments if not present (simple sentence splitting)
const segments = conv.transcript ? splitIntoSegments(conv.transcript) : [];
const formattedTranscript = segments.map((seg, idx) => {
// Mock timestamps if simple text
const time = formatTime(idx * 5); // Fake 5 sec increments for demo if no real timestamps
return `<div class="d-flex mb-3 transcript-line">
<div class="me-3 text-muted font-monospace small pt-1" style="min-width: 50px;">
<a href="#" onclick="seekAudio(${idx * 5}); return false;" class="timestamp-link">${time}</a>
</div>
<div class="flex-grow-1">${seg}</div>
</div>`;
}).join('');
detailContainer.innerHTML = `
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white py-3 border-bottom-0">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="card-title fw-bold">
${c.is_private ? '<i class="bi bi-lock-fill text-warning"></i> ' : ''}
${c.title}
</h5>
<p class="card-text text-muted small mb-2">
${new Date(c.created_at).toLocaleString()}
${c.customer_id ? `• Customer #${c.customer_id}` : ''}
<h3 class="mb-1 fw-bold text-dark">${conv.title}</h3>
<p class="text-muted small mb-2">
<i class="bi bi-clock"></i> ${new Date(conv.created_at).toLocaleString()}
<span class="mx-2"></span>
<span class="badge bg-light text-dark border">${conv.category || 'Generelt'}</span>
</p>
<div class="mb-2" style="max-width: 150px;">
<select class="form-select form-select-sm py-0" style="font-size: 0.8rem;" onchange="updateCategory(${c.id}, this.value)">
<option value="General" ${(!c.category || c.category === 'General') ? 'selected' : ''}>Generelt</option>
<option value="Support" ${c.category === 'Support' ? 'selected' : ''}>Support</option>
<option value="Sales" ${c.category === 'Sales' ? 'selected' : ''}>Salg</option>
<option value="Internal" ${c.category === 'Internal' ? 'selected' : ''}>Internt</option>
</select>
</div>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">Handlinger</h6></li>
<li><a class="dropdown-item" href="#" onclick="togglePrivacy(${conv.id}, ${!conv.is_private})">${conv.is_private ? 'Gør offentlig' : 'Gør privat'}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="#" onclick="deleteConversation(${conv.id})">Slet samtale</a></li>
</ul>
</div>
<div>
<button class="btn btn-sm btn-outline-secondary" onclick="togglePrivacy(${c.id}, ${!c.is_private})">
${c.is_private ? 'Gør Offentlig' : 'Gør Privat'}
</button>
<button class="btn btn-sm btn-outline-danger ms-2" onclick="deleteConversation(${c.id})">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<audio controls class="w-100 my-2 bg-light rounded">
<source src="/api/v1/conversations/${c.id}/audio" type="audio/mpeg">
<div class="card-body overflow-auto">
<!-- Audio Player -->
<div class="card bg-light border-0 mb-4">
<div class="card-body p-3">
<div class="d-flex align-items-center mb-2">
<div class="bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-3" style="width:40px;height:40px">
<i class="bi bi-play-fill fs-4"></i>
</div>
<div class="flex-grow-1">
<h6 class="mb-0 small fw-bold">Optagelse</h6>
<span class="text-muted small" style="font-size: 0.7rem">MP3 • ${conv.duration_seconds || '--:--'}</span>
</div>
</div>
<audio controls class="w-100" id="audioPlayer">
<source src="/api/v1/conversations/${conv.id}/audio" type="audio/mpeg">
</audio>
${c.transcript ? `
<details>
<summary class="text-primary" style="cursor:pointer">Vis Transskription</summary>
<div class="mt-2 p-3 bg-light rounded font-monospace small">${c.transcript}</div>
</details>
` : ''}
</div>
</div>
`).join('');
<!-- Summary Section -->
<div class="mb-4">
<h6 class="text-uppercase text-muted small fw-bold mb-2">Resumé</h6>
<div class="p-3 bg-light rounded-3 border-start border-4 border-info">
${conv.summary || '<span class="text-muted fst-italic">Intet resumé genereret endnu.</span>'}
</div>
</div>
<!-- Transcript Section -->
<div>
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-uppercase text-muted small fw-bold mb-0">Transskription</h6>
<button class="btn btn-sm btn-link text-decoration-none p-0" onclick="copyTranscript()">Kopier tekst</button>
</div>
${conv.transcript ?
`<div class="p-2">${formattedTranscript}</div>` :
'<div class="alert alert-info small">Ingen transskription tilgængelig.</div>'
}
</div>
</div>
</div>
`;
}
function filterView(type) {
const items = document.querySelectorAll('.conversation-item');
items.forEach(item => {
if (type === 'all') item.style.display = 'block';
else if (type === 'private') item.style.display = item.dataset.type === 'private' ? 'block' : 'none';
});
function splitIntoSegments(text) {
// If text already has timestamps like [00:00], preserve them.
// Otherwise split by sentence endings.
if (!text) return [];
// Very basic sentence splitter
return text.match( /[^\.!\?]+[\.!\?]+/g ) || [text];
}
function filterConversations() {
const query = document.getElementById('conversationSearch').value.toLowerCase();
const items = document.querySelectorAll('.conversation-item');
items.forEach(item => {
const text = item.dataset.text;
item.style.display = text.includes(query) ? 'block' : 'none';
});
function formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
}
function seekAudio(seconds) {
const audio = document.getElementById('audioPlayer');
if(audio) {
audio.currentTime = seconds;
audio.play();
}
}
function copyTranscript() {
// Logic to copy text
const conv = allConversations.find(c => c.id === currentConversationId);
if(conv && conv.transcript) {
navigator.clipboard.writeText(conv.transcript);
alert('Tekst kopieret!');
}
}
// ... Keep existing helper functions (togglePrivacy, deleteConversation, etc) but allow them to refresh list properly ...
async function togglePrivacy(id, makePrivate) {
await fetch(\`/api/v1/conversations/\${id}\`, {
await fetch(`/api/v1/conversations/${id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({is_private: makePrivate})
});
loadMyConversations();
// Update local state without full reload
const c = allConversations.find(x => x.id === id);
if(c) c.is_private = makePrivate;
loadMyConversations(); // Reload for sorting/filtering
}
async function deleteConversation(id) {
if(!confirm('Vil du slette denne samtale?')) return;
const hard = confirm('Skal dette være en permanent sletning af fil og data? (Kan ikke fortrydes)');
await fetch(\`/api/v1/conversations/\${id}?hard_delete=\${hard}\`, { method: 'DELETE' });
const hard = confirm('Permanent sletning?');
await fetch(`/api/v1/conversations/${id}?hard_delete=${hard}`, { method: 'DELETE' });
currentConversationId = null;
loadMyConversations();
document.getElementById('conversationDetail').innerHTML = '<div class="d-flex flex-column align-items-center justify-content-center h-100 text-muted py-5"><i class="bi bi-check-circle display-4 mb-3"></i><h5>Slettet</h5></div>';
}
async function updateCategory(id, newCategory) {
try {
const response = await fetch(`/api/v1/conversations/${id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({category: newCategory})
});
if (!response.ok) throw new Error('Update failed');
} catch (e) {
alert("Kunne ikke opdatere kategori");
console.error(e);
loadMyConversations(); // Revert UI on error
function filterView(type) {
const items = document.querySelectorAll('.list-group-item');
items.forEach(item => {
if (type === 'all') item.classList.remove('d-none');
else if (type === 'private') {
item.dataset.type === 'private' ? item.classList.remove('d-none') : item.classList.add('d-none');
}
}</script>
});
}
function filterConversations() {
const query = document.getElementById('conversationSearch').value.toLowerCase();
const items = document.querySelectorAll('.list-group-item');
items.forEach(item => {
const text = item.dataset.text;
text.includes(query) ? item.classList.remove('d-none') : item.classList.add('d-none');
});
}
</script>
{% endblock %}

View File

@ -154,7 +154,7 @@ class Settings(BaseSettings):
# Whisper Transcription
WHISPER_ENABLED: bool = True
WHISPER_API_URL: str = "http://172.16.31.115:5000/transcribe"
WHISPER_TIMEOUT: int = 30
WHISPER_TIMEOUT: int = 300
WHISPER_SUPPORTED_FORMATS: List[str] = [".mp3", ".wav", ".m4a", ".ogg"]
@field_validator('*', mode='before')

View File

@ -354,7 +354,7 @@ async def delete_email(email_id: int):
@router.post("/emails/{email_id}/reprocess")
async def reprocess_email(email_id: int):
"""Reprocess email (re-classify and apply rules)"""
"""Reprocess email (re-classify, run workflows, and apply rules)"""
try:
# Get email
query = "SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL"
@ -365,9 +365,9 @@ async def reprocess_email(email_id: int):
email = result[0]
# Re-classify using processor service
# Re-classify and run full processing pipeline
processor = EmailProcessorService()
await processor._classify_and_update(email)
processing_result = await processor.process_single_email(email)
# Re-fetch updated email
result = execute_query(query, (email_id,))
@ -376,9 +376,10 @@ async def reprocess_email(email_id: int):
logger.info(f"🔄 Reprocessed email {email_id}: {email['classification']} ({email.get('confidence_score', 0):.2f})")
return {
"success": True,
"message": "Email reprocessed",
"message": "Email reprocessed with workflows",
"classification": email['classification'],
"confidence": email.get('confidence_score', 0)
"confidence": email.get('confidence_score', 0),
"workflows_executed": processing_result.get('workflows_executed', 0)
}
except HTTPException:

View File

@ -1584,7 +1584,9 @@ async function loadEmailDetail(emailId) {
}
} catch (error) {
console.error('Failed to load email detail:', error);
showError('Kunne ikke indlæse email detaljer: ' + error.message);
const errorMsg = error?.message || String(error) || 'Ukendt fejl';
alert('Kunne ikke indlæse email detaljer: ' + errorMsg);
showEmptyState();
}
}
@ -1746,6 +1748,11 @@ function renderEmailDetail(email) {
function renderEmailAnalysis(email) {
const aiAnalysisTab = document.getElementById('aiAnalysisTab');
if (!aiAnalysisTab) {
console.error('aiAnalysisTab element not found in DOM');
return;
}
const classification = email.classification || 'general';
const confidence = email.confidence_score || 0;
@ -1779,6 +1786,7 @@ function renderEmailAnalysis(email) {
<option value="freight_note" ${classification === 'freight_note' ? 'selected' : ''}>🚚 Fragtnote</option>
<option value="time_confirmation" ${classification === 'time_confirmation' ? 'selected' : ''}>⏰ Tidsregistrering</option>
<option value="case_notification" ${classification === 'case_notification' ? 'selected' : ''}>📋 Sagsnotifikation</option>
<option value="recording" ${classification === 'recording' ? 'selected' : ''}>🎤 Optagelse</option>
<option value="bankruptcy" ${classification === 'bankruptcy' ? 'selected' : ''}>⚠️ Konkurs</option>
<option value="spam" ${classification === 'spam' ? 'selected' : ''}>🚫 Spam</option>
<option value="general" ${classification === 'general' ? 'selected' : ''}>📧 Generel</option>

3
app/jobs/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""
Scheduled Jobs Module
"""

View File

@ -0,0 +1,115 @@
"""
Daily sync job for e-conomic chart of accounts (kontoplan)
Scheduled to run every day at 06:00
Updates the economic_accounts cache table with latest data from e-conomic API
"""
import logging
from datetime import datetime
from app.core.database import execute_query, execute_update, execute_insert
from app.services.economic_service import get_economic_service
from app.core.config import settings
logger = logging.getLogger(__name__)
async def sync_economic_accounts():
"""
Sync e-conomic chart of accounts to local cache
This job:
1. Fetches all accounts from e-conomic API
2. Updates economic_accounts table
3. Logs sync statistics
"""
try:
logger.info("🔄 Starting daily e-conomic accounts sync...")
# Check if e-conomic is configured
if not settings.ECONOMIC_APP_SECRET_TOKEN or not settings.ECONOMIC_AGREEMENT_GRANT_TOKEN:
logger.warning("⚠️ e-conomic credentials not configured - skipping sync")
return {
"success": False,
"reason": "e-conomic credentials not configured"
}
# Get economic service
economic_service = get_economic_service()
# Fetch accounts from e-conomic
logger.info("📥 Fetching accounts from e-conomic API...")
accounts = economic_service.get_accounts()
if not accounts:
logger.warning("⚠️ No accounts returned from e-conomic API")
return {
"success": False,
"reason": "No accounts returned from API"
}
logger.info(f"📦 Fetched {len(accounts)} accounts from e-conomic")
# Update database - upsert each account
updated_count = 0
inserted_count = 0
for account in accounts:
account_number = account.get('accountNumber')
name = account.get('name')
account_type = account.get('accountType')
vat_code = account.get('vatCode', {}).get('vatCode') if account.get('vatCode') else None
if not account_number:
continue
# Check if account exists
existing = execute_query(
"SELECT account_number FROM economic_accounts WHERE account_number = %s",
(account_number,)
)
if existing:
# Update existing
execute_update(
"""UPDATE economic_accounts
SET name = %s, account_type = %s, vat_code = %s,
updated_at = CURRENT_TIMESTAMP
WHERE account_number = %s""",
(name, account_type, vat_code, account_number)
)
updated_count += 1
else:
# Insert new
execute_insert(
"""INSERT INTO economic_accounts
(account_number, name, account_type, vat_code, updated_at)
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)""",
(account_number, name, account_type, vat_code)
)
inserted_count += 1
logger.info(f"✅ e-conomic accounts sync complete: {inserted_count} inserted, {updated_count} updated")
return {
"success": True,
"fetched": len(accounts),
"inserted": inserted_count,
"updated": updated_count,
"timestamp": datetime.now().isoformat()
}
except Exception as e:
logger.error(f"❌ e-conomic accounts sync failed: {e}", exc_info=True)
return {
"success": False,
"error": str(e),
"timestamp": datetime.now().isoformat()
}
if __name__ == "__main__":
# Allow running this job manually for testing
import asyncio
result = asyncio.run(sync_economic_accounts())
print(f"Sync result: {result}")

View File

@ -0,0 +1,174 @@
# Webshop Module
Dette er template strukturen for nye BMC Hub moduler.
## Struktur
```
webshop/
├── module.json # Metadata og konfiguration
├── README.md # Dokumentation
├── backend/
│ ├── __init__.py
│ └── router.py # FastAPI routes (API endpoints)
├── frontend/
│ ├── __init__.py
│ └── views.py # HTML view routes
├── templates/
│ └── index.html # Jinja2 templates
└── migrations/
└── 001_init.sql # Database migrations
```
## Opret nyt modul
```bash
python scripts/create_module.py webshop "My Module Description"
```
## Database Tables
Alle tabeller SKAL bruge `table_prefix` fra module.json:
```sql
-- Hvis table_prefix = "webshop_"
CREATE TABLE webshop_items (
id SERIAL PRIMARY KEY,
name VARCHAR(255)
);
```
Dette sikrer at moduler ikke kolliderer med core eller andre moduler.
### Customer Linking (Hvis nødvendigt)
Hvis dit modul skal have sin egen kunde-tabel (f.eks. ved sync fra eksternt system):
**SKAL altid linke til core customers:**
```sql
CREATE TABLE webshop_customers (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
external_id VARCHAR(100), -- ID fra eksternt system
hub_customer_id INTEGER REFERENCES customers(id), -- VIGTIG!
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Auto-link trigger (se migrations/001_init.sql for komplet eksempel)
CREATE TRIGGER trigger_auto_link_webshop_customer
BEFORE INSERT OR UPDATE OF name
ON webshop_customers
FOR EACH ROW
EXECUTE FUNCTION auto_link_webshop_customer();
```
**Hvorfor?** Dette sikrer at:
- ✅ E-conomic export virker automatisk
- ✅ Billing integration fungerer
- ✅ Ingen manuel linking nødvendig
**Alternativ:** Hvis modulet kun har simple kunde-relationer, brug direkte FK:
```sql
CREATE TABLE webshop_orders (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) -- Direkte link
);
```
## Konfiguration
Modul-specifikke miljøvariable følger mønsteret:
```bash
MODULES__MY_MODULE__API_KEY=secret123
MODULES__MY_MODULE__READ_ONLY=true
```
Tilgå i kode:
```python
from app.core.config import get_module_config
api_key = get_module_config("webshop", "API_KEY")
read_only = get_module_config("webshop", "READ_ONLY", default="true") == "true"
```
## Database Queries
Brug ALTID helper functions fra `app.core.database`:
```python
from app.core.database import execute_query, execute_insert
# Fetch
customers = execute_query(
"SELECT * FROM webshop_customers WHERE active = %s",
(True,)
)
# Insert
customer_id = execute_insert(
"INSERT INTO webshop_customers (name) VALUES (%s)",
("Test Customer",)
)
```
## Migrations
Migrations ligger i `migrations/` og køres manuelt eller via migration tool:
```python
from app.core.database import execute_module_migration
with open("migrations/001_init.sql") as f:
migration_sql = f.read()
success = execute_module_migration("webshop", migration_sql)
```
## Enable/Disable
```bash
# Enable via API
curl -X POST http://localhost:8000/api/v1/modules/webshop/enable
# Eller rediger module.json
{
"enabled": true
}
# Restart app
docker-compose restart api
```
## Fejlhåndtering
Moduler er isolerede - hvis dit modul crasher ved opstart:
- Core systemet kører videre
- Modulet bliver ikke loaded
- Fejl logges til console og logs/app.log
Runtime fejl i endpoints påvirker ikke andre moduler.
## Testing
```python
import pytest
from app.core.database import execute_query
def test_webshop():
# Test bruger samme database helpers
result = execute_query("SELECT 1 as test")
assert result[0]["test"] == 1
```
## Best Practices
1. **Database isolering**: Brug ALTID `table_prefix` fra module.json
2. **Safety switches**: Tilføj `READ_ONLY` og `DRY_RUN` flags
3. **Error handling**: Log fejl, raise HTTPException med status codes
4. **Dependencies**: Deklarer i `module.json` hvis du bruger andre moduler
5. **Migrations**: Nummer sekventielt (001, 002, 003...)
6. **Documentation**: Opdater README.md med API endpoints og use cases

View File

@ -0,0 +1 @@
# Backend package for template module

View File

@ -0,0 +1,556 @@
"""
Webshop Module - API Router
Backend endpoints for webshop administration og konfiguration
"""
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from typing import List, Optional
from pydantic import BaseModel
from datetime import datetime
import logging
import os
import shutil
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
logger = logging.getLogger(__name__)
# APIRouter instance (module_loader kigger efter denne)
router = APIRouter()
# Upload directory for logos
LOGO_UPLOAD_DIR = "/app/uploads/webshop_logos"
os.makedirs(LOGO_UPLOAD_DIR, exist_ok=True)
# ============================================================================
# PYDANTIC MODELS
# ============================================================================
class WebshopConfigCreate(BaseModel):
customer_id: int
name: str
allowed_email_domains: str # Comma-separated
header_text: Optional[str] = None
intro_text: Optional[str] = None
primary_color: str = "#0f4c75"
accent_color: str = "#3282b8"
default_margin_percent: float = 10.0
min_order_amount: float = 0.0
shipping_cost: float = 0.0
enabled: bool = True
class WebshopConfigUpdate(BaseModel):
name: Optional[str] = None
allowed_email_domains: Optional[str] = None
header_text: Optional[str] = None
intro_text: Optional[str] = None
primary_color: Optional[str] = None
accent_color: Optional[str] = None
default_margin_percent: Optional[float] = None
min_order_amount: Optional[float] = None
shipping_cost: Optional[float] = None
enabled: Optional[bool] = None
class WebshopProductCreate(BaseModel):
webshop_config_id: int
product_number: str
ean: Optional[str] = None
name: str
description: Optional[str] = None
unit: str = "stk"
base_price: float
category: Optional[str] = None
custom_margin_percent: Optional[float] = None
visible: bool = True
sort_order: int = 0
# ============================================================================
# WEBSHOP CONFIG ENDPOINTS
# ============================================================================
@router.get("/webshop/configs")
async def get_all_webshop_configs():
"""
Hent alle webshop konfigurationer med kunde info
"""
try:
query = """
SELECT
wc.*,
c.name as customer_name,
c.cvr_number as customer_cvr,
(SELECT COUNT(*) FROM webshop_products WHERE webshop_config_id = wc.id) as product_count
FROM webshop_configs wc
LEFT JOIN customers c ON c.id = wc.customer_id
ORDER BY wc.created_at DESC
"""
configs = execute_query(query)
return {
"success": True,
"configs": configs
}
except Exception as e:
logger.error(f"❌ Error fetching webshop configs: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/webshop/configs/{config_id}")
async def get_webshop_config(config_id: int):
"""
Hent enkelt webshop konfiguration
"""
try:
query = """
SELECT
wc.*,
c.name as customer_name,
c.cvr_number as customer_cvr,
c.email as customer_email
FROM webshop_configs wc
LEFT JOIN customers c ON c.id = wc.customer_id
WHERE wc.id = %s
"""
config = execute_query_single(query, (config_id,))
if not config:
raise HTTPException(status_code=404, detail="Webshop config not found")
# Hent produkter
products_query = """
SELECT * FROM webshop_products
WHERE webshop_config_id = %s
ORDER BY sort_order, name
"""
products = execute_query(products_query, (config_id,))
return {
"success": True,
"config": config,
"products": products
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error fetching webshop config {config_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/webshop/configs")
async def create_webshop_config(config: WebshopConfigCreate):
"""
Opret ny webshop konfiguration
"""
try:
# Check if customer already has a webshop
existing = execute_query_single(
"SELECT id FROM webshop_configs WHERE customer_id = %s",
(config.customer_id,)
)
if existing:
raise HTTPException(
status_code=400,
detail="Customer already has a webshop configuration"
)
query = """
INSERT INTO webshop_configs (
customer_id, name, allowed_email_domains, header_text, intro_text,
primary_color, accent_color, default_margin_percent,
min_order_amount, shipping_cost, enabled
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
result = execute_query_single(
query,
(
config.customer_id, config.name, config.allowed_email_domains,
config.header_text, config.intro_text, config.primary_color,
config.accent_color, config.default_margin_percent,
config.min_order_amount, config.shipping_cost, config.enabled
)
)
logger.info(f"✅ Created webshop config {result['id']} for customer {config.customer_id}")
return {
"success": True,
"config": result,
"message": "Webshop configuration created successfully"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating webshop config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/webshop/configs/{config_id}")
async def update_webshop_config(config_id: int, config: WebshopConfigUpdate):
"""
Opdater webshop konfiguration
"""
try:
# Build dynamic update query
update_fields = []
params = []
if config.name is not None:
update_fields.append("name = %s")
params.append(config.name)
if config.allowed_email_domains is not None:
update_fields.append("allowed_email_domains = %s")
params.append(config.allowed_email_domains)
if config.header_text is not None:
update_fields.append("header_text = %s")
params.append(config.header_text)
if config.intro_text is not None:
update_fields.append("intro_text = %s")
params.append(config.intro_text)
if config.primary_color is not None:
update_fields.append("primary_color = %s")
params.append(config.primary_color)
if config.accent_color is not None:
update_fields.append("accent_color = %s")
params.append(config.accent_color)
if config.default_margin_percent is not None:
update_fields.append("default_margin_percent = %s")
params.append(config.default_margin_percent)
if config.min_order_amount is not None:
update_fields.append("min_order_amount = %s")
params.append(config.min_order_amount)
if config.shipping_cost is not None:
update_fields.append("shipping_cost = %s")
params.append(config.shipping_cost)
if config.enabled is not None:
update_fields.append("enabled = %s")
params.append(config.enabled)
if not update_fields:
raise HTTPException(status_code=400, detail="No fields to update")
params.append(config_id)
query = f"""
UPDATE webshop_configs
SET {', '.join(update_fields)}
WHERE id = %s
RETURNING *
"""
result = execute_query_single(query, tuple(params))
if not result:
raise HTTPException(status_code=404, detail="Webshop config not found")
logger.info(f"✅ Updated webshop config {config_id}")
return {
"success": True,
"config": result,
"message": "Webshop configuration updated successfully"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating webshop config {config_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/webshop/configs/{config_id}/upload-logo")
async def upload_webshop_logo(config_id: int, logo: UploadFile = File(...)):
"""
Upload logo til webshop
"""
try:
# Validate file type
if not logo.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
# Generate filename
ext = logo.filename.split(".")[-1]
filename = f"webshop_{config_id}.{ext}"
filepath = os.path.join(LOGO_UPLOAD_DIR, filename)
# Save file
with open(filepath, 'wb') as f:
shutil.copyfileobj(logo.file, f)
# Update database
query = """
UPDATE webshop_configs
SET logo_filename = %s
WHERE id = %s
RETURNING *
"""
result = execute_query_single(query, (filename, config_id))
if not result:
raise HTTPException(status_code=404, detail="Webshop config not found")
logger.info(f"✅ Uploaded logo for webshop {config_id}: {filename}")
return {
"success": True,
"filename": filename,
"config": result,
"message": "Logo uploaded successfully"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error uploading logo for webshop {config_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/webshop/configs/{config_id}")
async def delete_webshop_config(config_id: int):
"""
Slet webshop konfiguration (soft delete - disable)
"""
try:
query = """
UPDATE webshop_configs
SET enabled = FALSE
WHERE id = %s
RETURNING *
"""
result = execute_query_single(query, (config_id,))
if not result:
raise HTTPException(status_code=404, detail="Webshop config not found")
logger.info(f"✅ Disabled webshop config {config_id}")
return {
"success": True,
"message": "Webshop configuration disabled"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error deleting webshop config {config_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# WEBSHOP PRODUCT ENDPOINTS
# ============================================================================
@router.post("/webshop/products")
async def add_webshop_product(product: WebshopProductCreate):
"""
Tilføj produkt til webshop whitelist
"""
try:
query = """
INSERT INTO webshop_products (
webshop_config_id, product_number, ean, name, description,
unit, base_price, category, custom_margin_percent,
visible, sort_order
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
result = execute_query_single(
query,
(
product.webshop_config_id, product.product_number, product.ean,
product.name, product.description, product.unit, product.base_price,
product.category, product.custom_margin_percent, product.visible,
product.sort_order
)
)
logger.info(f"✅ Added product {product.product_number} to webshop {product.webshop_config_id}")
return {
"success": True,
"product": result,
"message": "Product added to webshop"
}
except Exception as e:
if "unique" in str(e).lower():
raise HTTPException(status_code=400, detail="Product already exists in this webshop")
logger.error(f"❌ Error adding product to webshop: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/webshop/products/{product_id}")
async def remove_webshop_product(product_id: int):
"""
Fjern produkt fra webshop whitelist
"""
try:
query = "DELETE FROM webshop_products WHERE id = %s RETURNING *"
result = execute_query_single(query, (product_id,))
if not result:
raise HTTPException(status_code=404, detail="Product not found")
logger.info(f"✅ Removed product {product_id} from webshop")
return {
"success": True,
"message": "Product removed from webshop"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error removing product {product_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# PUBLISH TO GATEWAY
# ============================================================================
@router.post("/webshop/configs/{config_id}/publish")
async def publish_webshop_to_gateway(config_id: int):
"""
Push webshop konfiguration til API Gateway
"""
try:
from app.core.config import settings
import aiohttp
# Hent config og produkter
config_data = await get_webshop_config(config_id)
if not config_data["config"]["enabled"]:
raise HTTPException(status_code=400, detail="Cannot publish disabled webshop")
# Byg payload
payload = {
"config_id": config_id,
"customer_id": config_data["config"]["customer_id"],
"company_name": config_data["config"]["customer_name"],
"config_version": config_data["config"]["config_version"].isoformat(),
"branding": {
"logo_url": f"{settings.BASE_URL}/uploads/webshop_logos/{config_data['config']['logo_filename']}" if config_data['config'].get('logo_filename') else None,
"header_text": config_data["config"]["header_text"],
"intro_text": config_data["config"]["intro_text"],
"primary_color": config_data["config"]["primary_color"],
"accent_color": config_data["config"]["accent_color"]
},
"allowed_email_domains": config_data["config"]["allowed_email_domains"].split(","),
"products": [
{
"id": p["id"],
"product_number": p["product_number"],
"ean": p["ean"],
"name": p["name"],
"description": p["description"],
"unit": p["unit"],
"base_price": float(p["base_price"]),
"calculated_price": float(p["base_price"]) * (1 + (float(p.get("custom_margin_percent") or config_data["config"]["default_margin_percent"]) / 100)),
"margin_percent": float(p.get("custom_margin_percent") or config_data["config"]["default_margin_percent"]),
"category": p["category"],
"visible": p["visible"]
}
for p in config_data["products"]
],
"settings": {
"min_order_amount": float(config_data["config"]["min_order_amount"]),
"shipping_cost": float(config_data["config"]["shipping_cost"]),
"default_margin_percent": float(config_data["config"]["default_margin_percent"])
}
}
# Send til Gateway
gateway_url = "https://apigateway.bmcnetworks.dk/webshop/ingest"
# TODO: Uncomment når Gateway er klar
# async with aiohttp.ClientSession() as session:
# async with session.post(gateway_url, json=payload) as response:
# if response.status != 200:
# error_text = await response.text()
# raise HTTPException(status_code=500, detail=f"Gateway error: {error_text}")
#
# gateway_response = await response.json()
# Mock response for now
logger.warning("⚠️ Gateway push disabled - mock response returned")
gateway_response = {"success": True, "message": "Config received (MOCK)"}
# Update last_published timestamps
update_query = """
UPDATE webshop_configs
SET last_published_at = CURRENT_TIMESTAMP,
last_published_version = config_version
WHERE id = %s
RETURNING *
"""
updated_config = execute_query_single(update_query, (config_id,))
logger.info(f"✅ Published webshop {config_id} to Gateway")
return {
"success": True,
"config": updated_config,
"payload": payload,
"gateway_response": gateway_response,
"message": "Webshop configuration published to Gateway"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error publishing webshop {config_id} to Gateway: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# ORDERS FROM GATEWAY
# ============================================================================
@router.get("/webshop/orders")
async def get_webshop_orders(config_id: Optional[int] = None, status: Optional[str] = None):
"""
Hent importerede ordrer fra Gateway
"""
try:
query = """
SELECT
wo.*,
wc.name as webshop_name,
c.name as customer_name
FROM webshop_orders wo
LEFT JOIN webshop_configs wc ON wc.id = wo.webshop_config_id
LEFT JOIN customers c ON c.id = wo.customer_id
WHERE 1=1
"""
params = []
if config_id:
query += " AND wo.webshop_config_id = %s"
params.append(config_id)
if status:
query += " AND wo.status = %s"
params.append(status)
query += " ORDER BY wo.created_at DESC"
orders = execute_query(query, tuple(params) if params else None)
return {
"success": True,
"orders": orders
}
except Exception as e:
logger.error(f"❌ Error fetching webshop orders: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@ -0,0 +1 @@
# Frontend package for template module

View File

@ -0,0 +1,634 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Webshop Administration - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.webshop-card {
transition: all 0.2s;
border: 1px solid rgba(0,0,0,0.1);
}
.webshop-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.status-badge {
padding: 0.375rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.color-preview {
width: 30px;
height: 30px;
border-radius: 50%;
border: 2px solid rgba(0,0,0,0.1);
}
.form-label-required::after {
content: " *";
color: #dc3545;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-5">
<div>
<h2 class="fw-bold mb-1">Webshop Administration</h2>
<p class="text-muted mb-0">Administrer kunde-webshops og konfigurationer</p>
</div>
<div class="d-flex gap-3">
<button class="btn btn-primary" onclick="openCreateModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Webshop
</button>
</div>
</div>
<div class="row g-4" id="webshopsGrid">
<div class="col-12 text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="modal fade" id="webshopModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalTitle">Opret Webshop</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="webshopForm">
<input type="hidden" id="configId">
<!-- Kunde Selection -->
<div class="mb-3">
<label for="customerId" class="form-label form-label-required">Kunde</label>
<select class="form-select" id="customerId" required>
<option value="">Vælg kunde...</option>
</select>
</div>
<!-- Webshop Navn -->
<div class="mb-3">
<label for="webshopName" class="form-label form-label-required">Webshop Navn</label>
<input type="text" class="form-control" id="webshopName" required
placeholder="fx 'Advokatfirmaet A/S Webshop'">
</div>
<!-- Email Domæner -->
<div class="mb-3">
<label for="emailDomains" class="form-label form-label-required">Tilladte Email Domæner</label>
<input type="text" class="form-control" id="emailDomains" required
placeholder="fx 'firma.dk,firma.com' (komma-separeret)">
<small class="form-text text-muted">Kun brugere med disse email-domæner kan logge ind</small>
</div>
<!-- Header & Intro Text -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="headerText" class="form-label">Header Tekst</label>
<input type="text" class="form-control" id="headerText"
placeholder="fx 'Velkommen til vores webshop'">
</div>
<div class="col-md-6 mb-3">
<label for="introText" class="form-label">Intro Tekst</label>
<textarea class="form-control" id="introText" rows="2"
placeholder="Kort introduktion til webshoppen"></textarea>
</div>
</div>
<!-- Colors -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="primaryColor" class="form-label">Primær Farve</label>
<div class="input-group">
<input type="color" class="form-control form-control-color"
id="primaryColor" value="#0f4c75">
<input type="text" class="form-control" id="primaryColorHex"
value="#0f4c75" maxlength="7">
</div>
</div>
<div class="col-md-6 mb-3">
<label for="accentColor" class="form-label">Accent Farve</label>
<div class="input-group">
<input type="color" class="form-control form-control-color"
id="accentColor" value="#3282b8">
<input type="text" class="form-control" id="accentColorHex"
value="#3282b8" maxlength="7">
</div>
</div>
</div>
<!-- Pricing -->
<div class="row">
<div class="col-md-4 mb-3">
<label for="defaultMargin" class="form-label">Standard Avance (%)</label>
<input type="number" class="form-control" id="defaultMargin"
value="10" min="0" max="100" step="0.1">
</div>
<div class="col-md-4 mb-3">
<label for="minOrderAmount" class="form-label">Min. Ordre Beløb (DKK)</label>
<input type="number" class="form-control" id="minOrderAmount"
value="0" min="0" step="0.01">
</div>
<div class="col-md-4 mb-3">
<label for="shippingCost" class="form-label">Forsendelse (DKK)</label>
<input type="number" class="form-control" id="shippingCost"
value="0" min="0" step="0.01">
</div>
</div>
<!-- Enabled -->
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enabled" checked>
<label class="form-check-label" for="enabled">
Webshop aktiveret
</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveWebshop()">
<i class="bi bi-check-lg me-2"></i>Gem Webshop
</button>
</div>
</div>
</div>
</div>
<!-- Products Modal -->
<div class="modal fade" id="productsModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Produkter - <span id="productsModalWebshopName"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<p class="mb-0 text-muted">Administrer tilladte produkter for denne webshop</p>
</div>
<button class="btn btn-sm btn-primary" onclick="openAddProductModal()">
<i class="bi bi-plus-lg me-2"></i>Tilføj Produkt
</button>
</div>
<input type="hidden" id="currentConfigId">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Varenr</th>
<th>Navn</th>
<th>EAN</th>
<th>Basispris</th>
<th>Avance %</th>
<th>Salgspris</th>
<th>Synlig</th>
<th></th>
</tr>
</thead>
<tbody id="productsTableBody">
<tr>
<td colspan="8" class="text-center py-4 text-muted">Ingen produkter endnu</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
<!-- Add Product Modal -->
<div class="modal fade" id="addProductModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Tilføj Produkt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="addProductForm">
<div class="mb-3">
<label for="productNumber" class="form-label form-label-required">Varenummer</label>
<input type="text" class="form-control" id="productNumber" required>
</div>
<div class="mb-3">
<label for="productEan" class="form-label">EAN</label>
<input type="text" class="form-control" id="productEan">
</div>
<div class="mb-3">
<label for="productName" class="form-label form-label-required">Navn</label>
<input type="text" class="form-control" id="productName" required>
</div>
<div class="mb-3">
<label for="productDescription" class="form-label">Beskrivelse</label>
<textarea class="form-control" id="productDescription" rows="2"></textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="productBasePrice" class="form-label form-label-required">Basispris (DKK)</label>
<input type="number" class="form-control" id="productBasePrice" required step="0.01">
</div>
<div class="col-md-6 mb-3">
<label for="productCustomMargin" class="form-label">Custom Avance (%)</label>
<input type="number" class="form-control" id="productCustomMargin" step="0.1" placeholder="Standard bruges">
</div>
</div>
<div class="mb-3">
<label for="productCategory" class="form-label">Kategori</label>
<input type="text" class="form-control" id="productCategory" placeholder="fx 'Network Security'">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="addProduct()">
<i class="bi bi-plus-lg me-2"></i>Tilføj
</button>
</div>
</div>
</div>
</div>
<script>
let webshopsData = [];
let currentWebshopConfig = null;
let webshopModal, productsModal, addProductModal;
// Load on page ready
document.addEventListener('DOMContentLoaded', () => {
// Initialize Bootstrap modals after DOM is loaded
webshopModal = new bootstrap.Modal(document.getElementById('webshopModal'));
productsModal = new bootstrap.Modal(document.getElementById('productsModal'));
addProductModal = new bootstrap.Modal(document.getElementById('addProductModal'));
loadWebshops();
loadCustomers();
// Color picker sync
document.getElementById('primaryColor').addEventListener('input', (e) => {
document.getElementById('primaryColorHex').value = e.target.value;
});
document.getElementById('primaryColorHex').addEventListener('input', (e) => {
document.getElementById('primaryColor').value = e.target.value;
});
document.getElementById('accentColor').addEventListener('input', (e) => {
document.getElementById('accentColorHex').value = e.target.value;
});
document.getElementById('accentColorHex').addEventListener('input', (e) => {
document.getElementById('accentColor').value = e.target.value;
});
});
async function loadWebshops() {
try {
const response = await fetch('/api/v1/webshop/configs');
const data = await response.json();
if (data.success) {
webshopsData = data.configs;
renderWebshops();
}
} catch (error) {
console.error('Error loading webshops:', error);
showToast('Fejl ved indlæsning af webshops', 'danger');
}
}
function renderWebshops() {
const grid = document.getElementById('webshopsGrid');
if (webshopsData.length === 0) {
grid.innerHTML = `
<div class="col-12 text-center py-5">
<i class="bi bi-shop display-1 text-muted mb-3"></i>
<p class="text-muted">Ingen webshops oprettet endnu</p>
<button class="btn btn-primary" onclick="openCreateModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Din Første Webshop
</button>
</div>
`;
return;
}
grid.innerHTML = webshopsData.map(ws => `
<div class="col-md-6 col-lg-4">
<div class="card webshop-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h5 class="card-title mb-1">${ws.name}</h5>
<small class="text-muted">${ws.customer_name || 'Ingen kunde'}</small>
</div>
<span class="status-badge ${ws.enabled ? 'bg-success bg-opacity-10 text-success' : 'bg-danger bg-opacity-10 text-danger'}">
${ws.enabled ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
<div class="mb-3">
<small class="text-muted d-block mb-1">Branding</small>
<div class="d-flex gap-2">
<div class="color-preview" style="background-color: ${ws.primary_color}"></div>
<div class="color-preview" style="background-color: ${ws.accent_color}"></div>
</div>
</div>
<div class="mb-3">
<small class="text-muted d-block mb-1">Email Domæner</small>
<div class="small">${ws.allowed_email_domains}</div>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<small class="text-muted d-block">Produkter</small>
<strong>${ws.product_count || 0}</strong>
</div>
<div class="col-6">
<small class="text-muted d-block">Avance</small>
<strong>${ws.default_margin_percent}%</strong>
</div>
</div>
${ws.last_published_at ? `
<div class="mb-3">
<small class="text-muted">Sidst publiceret: ${new Date(ws.last_published_at).toLocaleString('da-DK')}</small>
</div>
` : '<div class="mb-3"><small class="text-warning">⚠️ Ikke publiceret endnu</small></div>'}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary flex-fill" onclick="openEditModal(${ws.id})">
<i class="bi bi-pencil me-1"></i>Rediger
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="openProductsModal(${ws.id})">
<i class="bi bi-box-seam"></i>
</button>
<button class="btn btn-sm btn-success" onclick="publishWebshop(${ws.id})"
${!ws.enabled ? 'disabled' : ''}>
<i class="bi bi-cloud-upload"></i>
</button>
</div>
</div>
</div>
</div>
`).join('');
}
async function loadCustomers() {
try {
const response = await fetch('/api/v1/customers?limit=1000');
const data = await response.json();
const customers = Array.isArray(data) ? data : (data.customers || []);
const select = document.getElementById('customerId');
select.innerHTML = '<option value="">Vælg kunde...</option>' +
customers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
} catch (error) {
console.error('Error loading customers:', error);
}
}
function openCreateModal() {
document.getElementById('modalTitle').textContent = 'Opret Webshop';
document.getElementById('webshopForm').reset();
document.getElementById('configId').value = '';
document.getElementById('enabled').checked = true;
webshopModal.show();
}
async function openEditModal(configId) {
const ws = webshopsData.find(w => w.id === configId);
if (!ws) return;
document.getElementById('modalTitle').textContent = 'Rediger Webshop';
document.getElementById('configId').value = ws.id;
document.getElementById('customerId').value = ws.customer_id;
document.getElementById('webshopName').value = ws.name;
document.getElementById('emailDomains').value = ws.allowed_email_domains;
document.getElementById('headerText').value = ws.header_text || '';
document.getElementById('introText').value = ws.intro_text || '';
document.getElementById('primaryColor').value = ws.primary_color;
document.getElementById('primaryColorHex').value = ws.primary_color;
document.getElementById('accentColor').value = ws.accent_color;
document.getElementById('accentColorHex').value = ws.accent_color;
document.getElementById('defaultMargin').value = ws.default_margin_percent;
document.getElementById('minOrderAmount').value = ws.min_order_amount;
document.getElementById('shippingCost').value = ws.shipping_cost;
document.getElementById('enabled').checked = ws.enabled;
webshopModal.show();
}
async function saveWebshop() {
const configId = document.getElementById('configId').value;
const isEdit = !!configId;
const payload = {
customer_id: parseInt(document.getElementById('customerId').value),
name: document.getElementById('webshopName').value,
allowed_email_domains: document.getElementById('emailDomains').value,
header_text: document.getElementById('headerText').value || null,
intro_text: document.getElementById('introText').value || null,
primary_color: document.getElementById('primaryColorHex').value,
accent_color: document.getElementById('accentColorHex').value,
default_margin_percent: parseFloat(document.getElementById('defaultMargin').value),
min_order_amount: parseFloat(document.getElementById('minOrderAmount').value),
shipping_cost: parseFloat(document.getElementById('shippingCost').value),
enabled: document.getElementById('enabled').checked
};
try {
const url = isEdit ? `/api/v1/webshop/configs/${configId}` : '/api/v1/webshop/configs';
const method = isEdit ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.success) {
showToast(data.message || 'Webshop gemt', 'success');
webshopModal.hide();
loadWebshops();
} else {
showToast(data.message || 'Fejl ved gemning', 'danger');
}
} catch (error) {
console.error('Error saving webshop:', error);
showToast('Fejl ved gemning af webshop', 'danger');
}
}
async function openProductsModal(configId) {
currentWebshopConfig = configId;
document.getElementById('currentConfigId').value = configId;
const ws = webshopsData.find(w => w.id === configId);
document.getElementById('productsModalWebshopName').textContent = ws ? ws.name : '';
productsModal.show();
loadProducts(configId);
}
async function loadProducts(configId) {
try {
const response = await fetch(`/api/v1/webshop/configs/${configId}`);
const data = await response.json();
if (data.success) {
renderProducts(data.products, data.config.default_margin_percent);
}
} catch (error) {
console.error('Error loading products:', error);
showToast('Fejl ved indlæsning af produkter', 'danger');
}
}
function renderProducts(products, defaultMargin) {
const tbody = document.getElementById('productsTableBody');
if (!products || products.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-4 text-muted">Ingen produkter endnu</td></tr>';
return;
}
tbody.innerHTML = products.map(p => {
const margin = p.custom_margin_percent || defaultMargin;
const salePrice = p.base_price * (1 + margin / 100);
return `
<tr>
<td><code>${p.product_number}</code></td>
<td>${p.name}</td>
<td>${p.ean || '-'}</td>
<td>${p.base_price.toFixed(2)} kr</td>
<td>${margin.toFixed(1)}%</td>
<td><strong>${salePrice.toFixed(2)} kr</strong></td>
<td>
<span class="badge ${p.visible ? 'bg-success' : 'bg-secondary'}">
${p.visible ? 'Ja' : 'Nej'}
</span>
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" onclick="removeProduct(${p.id})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`;
}).join('');
}
function openAddProductModal() {
document.getElementById('addProductForm').reset();
addProductModal.show();
}
async function addProduct() {
const configId = document.getElementById('currentConfigId').value;
const payload = {
webshop_config_id: parseInt(configId),
product_number: document.getElementById('productNumber').value,
ean: document.getElementById('productEan').value || null,
name: document.getElementById('productName').value,
description: document.getElementById('productDescription').value || null,
base_price: parseFloat(document.getElementById('productBasePrice').value),
custom_margin_percent: document.getElementById('productCustomMargin').value ?
parseFloat(document.getElementById('productCustomMargin').value) : null,
category: document.getElementById('productCategory').value || null,
visible: true,
sort_order: 0
};
try {
const response = await fetch('/api/v1/webshop/products', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.success) {
showToast('Produkt tilføjet', 'success');
addProductModal.hide();
loadProducts(configId);
} else {
showToast(data.detail || 'Fejl ved tilføjelse', 'danger');
}
} catch (error) {
console.error('Error adding product:', error);
showToast('Fejl ved tilføjelse af produkt', 'danger');
}
}
async function removeProduct(productId) {
if (!confirm('Er du sikker på at du vil fjerne dette produkt?')) return;
try {
const response = await fetch(`/api/v1/webshop/products/${productId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showToast('Produkt fjernet', 'success');
const configId = document.getElementById('currentConfigId').value;
loadProducts(configId);
}
} catch (error) {
console.error('Error removing product:', error);
showToast('Fejl ved fjernelse', 'danger');
}
}
async function publishWebshop(configId) {
if (!confirm('Vil du publicere denne webshop til Gateway?')) return;
try {
const response = await fetch(`/api/v1/webshop/configs/${configId}/publish`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showToast('Webshop publiceret til Gateway!', 'success');
loadWebshops();
} else {
showToast(data.detail || 'Fejl ved publicering', 'danger');
}
} catch (error) {
console.error('Error publishing webshop:', error);
showToast('Fejl ved publicering', 'danger');
}
}
function showToast(message, type = 'info') {
// Reuse existing toast system if available
console.log(`[${type.toUpperCase()}] ${message}`);
alert(message); // Replace with proper toast when available
}
</script>
{% endblock %}

View File

@ -0,0 +1,23 @@
"""
Webshop Module - Views Router
HTML page serving for webshop admin interface
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import os
# Router for HTML views
router = APIRouter()
# Template directory - must be root "app" to allow extending shared/frontend/base.html
templates = Jinja2Templates(directory="app")
@router.get("/webshop", response_class=HTMLResponse, include_in_schema=False)
async def webshop_admin(request: Request):
"""
Webshop administration interface
"""
return templates.TemplateResponse("modules/webshop/frontend/index.html", {"request": request})

View File

@ -0,0 +1,161 @@
-- Webshop Module - Initial Migration
-- Opret basis tabeller for webshop konfiguration og administration
-- Webshop konfigurationer (én per kunde/domæne)
CREATE TABLE IF NOT EXISTS webshop_configs (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL, -- Webshop navn (fx "Advokatfirmaet A/S Webshop")
-- Email domæner der må logge ind (komma-separeret, fx "firma.dk,firma.com")
allowed_email_domains TEXT NOT NULL,
-- Branding
logo_filename VARCHAR(255), -- Filnavn i uploads/webshop_logos/
header_text TEXT,
intro_text TEXT,
primary_color VARCHAR(7) DEFAULT '#0f4c75', -- Hex color
accent_color VARCHAR(7) DEFAULT '#3282b8',
-- Pricing regler
default_margin_percent DECIMAL(5, 2) DEFAULT 10.00, -- Standard avance %
min_order_amount DECIMAL(10, 2) DEFAULT 0.00,
shipping_cost DECIMAL(10, 2) DEFAULT 0.00,
-- Status og versioning
enabled BOOLEAN DEFAULT TRUE,
config_version TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp for seneste ændring
last_published_at TIMESTAMP, -- Hvornår blev config sendt til Gateway
last_published_version TIMESTAMP, -- Hvilken version blev sendt
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(customer_id) -- Én webshop per kunde
);
-- Tilladte produkter per webshop (whitelist)
CREATE TABLE IF NOT EXISTS webshop_products (
id SERIAL PRIMARY KEY,
webshop_config_id INTEGER REFERENCES webshop_configs(id) ON DELETE CASCADE,
-- Produkt identifikation (fra e-conomic)
product_number VARCHAR(100) NOT NULL,
ean VARCHAR(50),
-- Produkt info (synced fra e-conomic)
name VARCHAR(255) NOT NULL,
description TEXT,
unit VARCHAR(50) DEFAULT 'stk',
base_price DECIMAL(10, 2), -- Pris fra e-conomic
category VARCHAR(100),
-- Webshop-specifik konfiguration
custom_margin_percent DECIMAL(5, 2), -- Override standard avance for dette produkt
visible BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(webshop_config_id, product_number)
);
-- Ordre importeret fra Gateway (når Hub poller Gateway)
CREATE TABLE IF NOT EXISTS webshop_orders (
id SERIAL PRIMARY KEY,
webshop_config_id INTEGER REFERENCES webshop_configs(id) ON DELETE SET NULL,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
-- Order data fra Gateway
gateway_order_id VARCHAR(100) NOT NULL UNIQUE, -- ORD-2026-00123
order_email VARCHAR(255), -- Hvem bestilte
total_amount DECIMAL(10, 2),
status VARCHAR(50), -- pending, processing, completed, cancelled
-- Shipping info
shipping_company_name VARCHAR(255),
shipping_street TEXT,
shipping_postal_code VARCHAR(20),
shipping_city VARCHAR(100),
shipping_country VARCHAR(2) DEFAULT 'DK',
delivery_note TEXT,
-- Payload fra Gateway (JSON)
gateway_payload JSONB, -- Komplet order data fra Gateway
-- Import tracking
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
processed_in_economic BOOLEAN DEFAULT FALSE,
economic_order_number INTEGER, -- e-conomic ordre nummer når oprettet
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Ordre linjer (items på ordre)
CREATE TABLE IF NOT EXISTS webshop_order_items (
id SERIAL PRIMARY KEY,
webshop_order_id INTEGER REFERENCES webshop_orders(id) ON DELETE CASCADE,
product_number VARCHAR(100),
product_name VARCHAR(255),
quantity INTEGER NOT NULL,
unit_price DECIMAL(10, 2),
total_price DECIMAL(10, 2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_webshop_configs_customer ON webshop_configs(customer_id);
CREATE INDEX IF NOT EXISTS idx_webshop_configs_enabled ON webshop_configs(enabled);
CREATE INDEX IF NOT EXISTS idx_webshop_products_config ON webshop_products(webshop_config_id);
CREATE INDEX IF NOT EXISTS idx_webshop_products_visible ON webshop_products(visible);
CREATE INDEX IF NOT EXISTS idx_webshop_orders_gateway_id ON webshop_orders(gateway_order_id);
CREATE INDEX IF NOT EXISTS idx_webshop_orders_customer ON webshop_orders(customer_id);
CREATE INDEX IF NOT EXISTS idx_webshop_orders_status ON webshop_orders(status);
CREATE INDEX IF NOT EXISTS idx_webshop_order_items_order ON webshop_order_items(webshop_order_id);
-- Trigger for updated_at på configs
CREATE OR REPLACE FUNCTION update_webshop_configs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
NEW.config_version = CURRENT_TIMESTAMP; -- Bump version ved hver ændring
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_webshop_configs_updated_at
BEFORE UPDATE ON webshop_configs
FOR EACH ROW
EXECUTE FUNCTION update_webshop_configs_updated_at();
-- Trigger for updated_at på products
CREATE OR REPLACE FUNCTION update_webshop_products_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_webshop_products_updated_at
BEFORE UPDATE ON webshop_products
FOR EACH ROW
EXECUTE FUNCTION update_webshop_products_updated_at();
-- Trigger for updated_at på orders
CREATE OR REPLACE FUNCTION update_webshop_orders_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_webshop_orders_updated_at
BEFORE UPDATE ON webshop_orders
FOR EACH ROW
EXECUTE FUNCTION update_webshop_orders_updated_at();

View File

@ -0,0 +1,19 @@
{
"name": "webshop",
"version": "1.0.0",
"description": "Webshop administration og konfiguration",
"author": "BMC Networks",
"enabled": true,
"dependencies": [],
"table_prefix": "webshop_",
"api_prefix": "/api/v1/webshop",
"tags": [
"Webshop"
],
"config": {
"safety_switches": {
"read_only": false,
"dry_run": false
}
}
}

View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title }} - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h1>{{ page_title }}</h1>
{% if error %}
<div class="alert alert-danger">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<div class="card mt-4">
<div class="card-header">
<h5>Template Items</h5>
</div>
<div class="card-body">
{% if items %}
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Description</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.id }}</td>
<td>{{ item.name }}</td>
<td>{{ item.description or '-' }}</td>
<td>{{ item.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">No items found. This is a template module.</p>
{% endif %}
</div>
</div>
<div class="mt-4">
<a href="/api/docs#/Template" class="btn btn-primary">API Documentation</a>
<a href="/" class="btn btn-secondary">Back to Dashboard</a>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@ -5,6 +5,7 @@ Based on OmniSync architecture adapted for BMC Hub
"""
import logging
import re
from typing import List, Dict, Optional
from datetime import datetime
@ -546,25 +547,48 @@ class EmailProcessorService:
if transcript:
transcripts.append(f"--- TRANSKRIBERET LYDFIL ({filename}) ---\n{transcript}\n----------------------------------")
# Create conversation record
# Create conversation record (ALWAYS for supported audio, even if transcription fails)
try:
# Reconstruct path - mirroring EmailService._save_attachments logic
md5_hash = hashlib.md5(content).hexdigest()
# Default path in EmailService is "uploads/email_attachments"
file_path = f"uploads/email_attachments/{md5_hash}_{filename}"
# Determine Title from Subject if possible
title = f"Email Attachment: {filename}"
subject = email_data.get('subject', '')
# Pattern: "Optagelse af samtale(n) mellem 204 og 209"
# Handles both "samtale" and "samtalen", case insensitive
match = re.search(r'Optagelse af samtalen?\s+mellem\s+(\S+)\s+og\s+(\S+)', subject, re.IGNORECASE)
if match:
num1 = match.group(1)
num2 = match.group(2)
title = f"Samtale: {num1}{num2}"
# Generate Summary
summary = None
try:
from app.services.ollama_service import ollama_service
if transcript:
logger.info("🧠 Generating conversation summary...")
summary = await ollama_service.generate_summary(transcript)
except Exception as e:
logger.error(f"⚠️ Failed to generate summary: {e}")
# Determine user_id (optional, maybe from sender if internal?)
# For now, create as system/unassigned
query = """
INSERT INTO conversations
(title, transcript, audio_file_path, source, email_message_id, created_at)
VALUES (%s, %s, %s, 'email', %s, CURRENT_TIMESTAMP)
(title, transcript, summary, audio_file_path, source, email_message_id, created_at)
VALUES (%s, %s, %s, %s, 'email', %s, CURRENT_TIMESTAMP)
RETURNING id
"""
conversation_id = execute_insert(query, (
f"Email Attachment: {filename}",
title,
transcript,
summary,
file_path,
email_data.get('id')
))
@ -612,6 +636,33 @@ class EmailProcessorService:
email_data = result[0]
# Fetch attachments from DB to allow transcription on reprocess
query_att = "SELECT * FROM email_attachments WHERE email_id = %s"
atts = execute_query(query_att, (email_id,))
loaded_atts = []
if atts:
from pathlib import Path
for att in atts:
# 'file_path' is in DB
fpath = att.get('file_path')
if fpath:
try:
# If path is relative to cwd
path_obj = Path(fpath)
if path_obj.exists():
att['content'] = path_obj.read_bytes()
loaded_atts.append(att)
logger.info(f"📎 Loaded attachment content for reprocess: {att['filename']}")
except Exception as e:
logger.error(f"❌ Could not verify/load attachment {fpath}: {e}")
email_data['attachments'] = loaded_atts
# Run Transcription (Step 2.5 equivalent)
if settings.WHISPER_ENABLED and loaded_atts:
await self._process_attachments_for_transcription(email_data)
# Reclassify (either AI or keyword-based)
if settings.EMAIL_AUTO_CLASSIFY:
await self._classify_and_update(email_data)

View File

@ -297,11 +297,22 @@ class EmailService:
continue
# Skip text parts (body content)
if part.get_content_type() in ['text/plain', 'text/html']:
content_type = part.get_content_type()
if content_type in ['text/plain', 'text/html']:
continue
# Check if part has a filename (indicates attachment)
filename = part.get_filename()
# FALLBACK: If no filename but content-type is audio, generate one
if not filename and content_type.startswith('audio/'):
ext = '.mp3'
if 'wav' in content_type: ext = '.wav'
elif 'ogg' in content_type: ext = '.ogg'
elif 'm4a' in content_type: ext = '.m4a'
filename = f"audio_attachment{ext}"
logger.info(f"⚠️ Found audio attachment without filename. Generated: {filename}")
if filename:
# Decode filename if needed
filename = self._decode_header(filename)
@ -412,14 +423,26 @@ class EmailService:
else:
content = b''
# Handle missing filenames for audio (FALLBACK)
filename = att.get('name')
content_type = att.get('contentType', 'application/octet-stream')
if not filename and content_type.startswith('audio/'):
ext = '.mp3'
if 'wav' in content_type: ext = '.wav'
elif 'ogg' in content_type: ext = '.ogg'
elif 'm4a' in content_type: ext = '.m4a'
filename = f"audio_attachment{ext}"
logger.info(f"⚠️ Found (Graph) audio attachment without filename. Generated: {filename}")
attachments.append({
'filename': att.get('name', 'unknown'),
'filename': filename or 'unknown',
'content': content,
'content_type': att.get('contentType', 'application/octet-stream'),
'content_type': content_type,
'size': att.get('size', len(content))
})
logger.info(f"📎 Fetched attachment: {att.get('name')} ({att.get('size', 0)} bytes)")
logger.info(f"📎 Fetched attachment: {filename} ({att.get('size', 0)} bytes)")
except Exception as e:
logger.error(f"❌ Error fetching attachments for message {message_id}: {e}")

View File

@ -6,6 +6,7 @@ Handles supplier invoice extraction using Ollama LLM with CVR matching
import json
import hashlib
import logging
import os
from pathlib import Path
from typing import Optional, Dict, List, Tuple
from datetime import datetime
@ -593,6 +594,42 @@ Output: {
logger.info(f"⚠️ No vendor found with CVR: {cvr_clean}")
return None
async def generate_summary(self, text: str) -> str:
"""
Generate a short summary of the text using Ollama
"""
if not text:
return ""
system_prompt = "Du er en hjælpsom assistent, der laver korte, præcise resuméer på dansk."
user_prompt = f"Lav et kort resumé (max 50 ord) af følgende tekst:\n\n{text}"
try:
import aiohttp
logger.info(f"🧠 Generating summary with Ollama ({self.model})...")
async with aiohttp.ClientSession() as session:
payload = {
"model": self.model,
"prompt": system_prompt + "\n\n" + user_prompt,
"stream": False,
"options": {"temperature": 0.3}
}
async with session.post(f"{self.endpoint}/api/generate", json=payload, timeout=60.0) as response:
if response.status == 200:
data = await response.json()
summary = data.get("response", "").strip()
logger.info("✅ Summary generated")
return summary
else:
error_text = await response.text()
logger.error(f"❌ Ollama error: {error_text}")
return "Kunne ikke generere resumé (API fejl)."
except Exception as e:
logger.error(f"❌ Ollama summary failed: {e}")
return f"Ingen resumé (Fejl: {str(e)})"
# Global instance
ollama_service = OllamaService()

View File

@ -35,6 +35,10 @@ class SimpleEmailClassifier:
'case_notification': [
'cc[0-9]{4}', 'case #', 'sag ', 'ticket', 'support'
],
'recording': [
'lydbesked', 'optagelse', 'voice note', 'voicemail',
'telefonsvarer', 'samtale', 'recording', 'audio note'
],
'bankruptcy': [
'konkurs', 'bankruptcy', 'rekonstruktion', 'insolvency',
'betalingsstandsning', 'administration'

View File

@ -251,6 +251,8 @@
<li><a class="dropdown-item py-2" href="#">Ordre</a></li>
<li><a class="dropdown-item py-2" href="#">Produkter</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Pipeline</a></li>
</ul>
</li>

View File

@ -0,0 +1,241 @@
# GitHub Copilot Instructions - BMC Webshop (Frontend)
## Project Overview
BMC Webshop er en kunde-styret webshop løsning, hvor **BMC Hub** ejer indholdet, **API Gateway** (`apigateway.bmcnetworks.dk`) styrer logikken, og **Webshoppen** (dette projekt) kun viser og indsamler input.
**Tech Stack**: React/Next.js/Vue.js (vælg én), TypeScript, Tailwind CSS eller Bootstrap 5
---
## 3-Lags Arkitektur
```
┌─────────────────────────────────────────────────────────┐
│ TIER 1: BMC HUB (Admin System) │
│ - Administrerer webshop-opsætning │
│ - Pusher data til Gateway │
│ - Poller Gateway for nye ordrer │
│ https://hub.bmcnetworks.dk │
└─────────────────────────────────────────────────────────┘
▼ (Push config)
┌─────────────────────────────────────────────────────────┐
│ TIER 2: API GATEWAY (Forretningslogik + Database) │
│ - Modtager og gemmer webshop-config fra Hub │
│ - Ejer PostgreSQL database med produkter, priser, ordrer│
│ - Håndterer email/OTP login │
│ - Beregner priser og filtrerer varer │
│ - Leverer sikre API'er til Webshoppen │
│ https://apigateway.bmcnetworks.dk │
└─────────────────────────────────────────────────────────┘
▲ (API calls)
┌─────────────────────────────────────────────────────────┐
│ TIER 3: WEBSHOP (Dette projekt - Kun Frontend) │
│ - Viser logo, tekster, produkter, priser │
│ - Shopping cart (kun i frontend state) │
│ - Sender ordre som payload til Gateway │
│ - INGEN forretningslogik eller datapersistering │
└─────────────────────────────────────────────────────────┘
```
---
## Webshoppens Ansvar
### ✅ Hvad Webshoppen GØR
- Viser kundens logo, header-tekst, intro-tekst (fra Gateway)
- Viser produktkatalog med navn, beskrivelse, pris (fra Gateway)
- Samler kurv i browser state (localStorage/React state)
- Sender ordre til Gateway ved checkout
- Email/OTP login flow (kalder Gateway's auth-endpoint)
### ❌ Hvad Webshoppen IKKE GØR
- Gemmer INGEN data (hverken kurv, produkter, eller ordrer)
- Beregner INGEN priser eller avance
- Håndterer INGEN produkt-filtrering (Gateway leverer klar liste)
- Snakker IKKE direkte med Hub eller e-conomic
- Håndterer IKKE betalingsgateway (Gateway's ansvar)
---
## API Gateway Kontrakt
Base URL: `https://apigateway.bmcnetworks.dk`
### 1. Login med Email + Engangskode
**Step 1: Anmod om engangskode**
```http
POST /webshop/auth/request-code
Content-Type: application/json
{
"email": "kunde@firma.dk"
}
Response 200:
{
"success": true,
"message": "Engangskode sendt til kunde@firma.dk"
}
```
**Step 2: Verificer kode og få JWT token**
```http
POST /webshop/auth/verify-code
Content-Type: application/json
{
"email": "kunde@firma.dk",
"code": "123456"
}
Response 200:
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"customer_id": 42,
"expires_at": "2026-01-13T15:00:00Z"
}
```
### 2. Hent Webshop Context (Komplet Webshop-Data)
```http
GET /webshop/{customer_id}/context
Authorization: Bearer {jwt_token}
Response 200:
{
"customer_id": 42,
"company_name": "Advokatfirma A/S",
"config_version": "2026-01-13T12:00:00Z",
"branding": {
"logo_url": "https://apigateway.bmcnetworks.dk/assets/logos/42.png",
"header_text": "Velkommen til vores webshop",
"intro_text": "Bestil nemt og hurtigt direkte her.",
"primary_color": "#0f4c75",
"accent_color": "#3282b8"
},
"products": [
{
"id": 101,
"ean": "5711045071324",
"product_number": "FIRE-001",
"name": "Cisco Firewall ASA 5506-X",
"description": "Next-generation firewall med 8 porte",
"unit": "stk",
"base_price": 8500.00,
"calculated_price": 9350.00,
"margin_percent": 10.0,
"currency": "DKK",
"stock_available": true,
"category": "Network Security"
}
],
"allowed_payment_methods": ["invoice", "card"],
"min_order_amount": 500.00,
"shipping_cost": 0.00
}
```
### 3. Opret Ordre
```http
POST /webshop/orders
Authorization: Bearer {jwt_token}
Content-Type: application/json
{
"customer_id": 42,
"order_items": [
{
"product_id": 101,
"quantity": 2,
"unit_price": 9350.00
}
],
"shipping_address": {
"company_name": "Advokatfirma A/S",
"street": "Hovedgaden 1",
"postal_code": "1000",
"city": "København K",
"country": "DK"
},
"delivery_note": "Levering til bagsiden, ring på døren",
"total_amount": 18700.00
}
Response 201:
{
"success": true,
"order_id": "ORD-2026-00123",
"status": "pending",
"total_amount": 18700.00,
"created_at": "2026-01-13T14:30:00Z",
"message": "Ordre modtaget. Du vil modtage en bekræftelse på email."
}
```
---
## Frontend Krav
### Mandatory Features
1. **Responsive Design**
- Mobile-first approach
- Breakpoints: 576px (mobile), 768px (tablet), 992px (desktop)
2. **Dark Mode Support**
- Toggle mellem light/dark theme
- Gem præference i localStorage
3. **Shopping Cart**
- Gem kurv i localStorage (persist ved page reload)
- Vis antal varer i header badge
- Real-time opdatering af total pris
4. **Login Flow**
- Email input → Send kode
- Vis countdown timer (5 minutter)
- Verificer kode → Få JWT token
- Auto-logout ved token expiry
5. **Product Catalog**
- Vis produkter i grid layout
- Søgning i produktnavn/beskrivelse
- "Tilføj til kurv" knap
6. **Checkout Flow**
- Vis kurv-oversigt
- Leveringsadresse
- "Bekræft ordre" knap
- Success/error feedback
### Design Guidelines
**Stil**: Minimalistisk, clean, "Nordic" æstetik
**Farver** (kan overskrives af Gateway's branding):
- Primary: `#0f4c75` (Deep Blue)
- Accent: `#3282b8` (Bright Blue)
---
## Security
1. **HTTPS Only** - Al kommunikation med Gateway over HTTPS
2. **JWT Token** - Gem i localStorage, send i Authorization header
3. **Input Validation** - Validér email, antal, adresse
4. **CORS** - Gateway skal have CORS headers
---
## Common Pitfalls to Avoid
1. **Gem IKKE data i Webshoppen** - alt kommer fra Gateway
2. **Beregn IKKE priser selv** - Gateway leverer `calculated_price`
3. **Snakker IKKE direkte med Hub** - kun via Gateway
4. **Gem IKKE kurv i database** - kun localStorage
5. **Hardcode IKKE customer_id** - hent fra JWT token

View File

@ -54,6 +54,10 @@ from app.backups.backend.scheduler import backup_scheduler
from app.conversations.backend import router as conversations_api
from app.conversations.frontend import views as conversations_views
# Modules
from app.modules.webshop.backend import router as webshop_api
from app.modules.webshop.frontend import views as webshop_views
# Configure logging
logging.basicConfig(
level=logging.INFO,
@ -131,6 +135,9 @@ app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])
app.include_router(backups_api, prefix="/api/v1", tags=["Backups"])
app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversations"])
# Module Routers
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
# Frontend Routers
app.include_router(dashboard_views.router, tags=["Frontend"])
app.include_router(customers_views.router, tags=["Frontend"])
@ -145,6 +152,7 @@ app.include_router(settings_views.router, tags=["Frontend"])
app.include_router(emails_views.router, tags=["Frontend"])
app.include_router(backups_views.router, tags=["Frontend"])
app.include_router(conversations_views.router, tags=["Frontend"])
app.include_router(webshop_views.router, tags=["Frontend"])
# Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static")

View File

@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS conversations (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
ticket_id INTEGER REFERENCES tticket_tickets(id) ON DELETE SET NULL,
user_id INTEGER REFERENCES auth_users(id) ON DELETE SET NULL,
user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
email_message_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,

View File

@ -0,0 +1,38 @@
import asyncio
import os
import sys
import logging
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from app.core.database import init_db, execute_query, execute_update
from app.services.ollama_service import ollama_service
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def main(conv_id):
init_db()
# Get transcript
rows = execute_query("SELECT transcript FROM conversations WHERE id = %s", (conv_id,))
if not rows or not rows[0]['transcript']:
print("No transcript found")
return
transcript = rows[0]['transcript']
print(f"Transcript length: {len(transcript)}")
# Override endpoint for Docker Mac access
ollama_service.endpoint = os.environ.get("OLLAMA_ENDPOINT", "http://host.docker.internal:11434")
print(f"Using Ollama endpoint: {ollama_service.endpoint}")
summary = await ollama_service.generate_summary(transcript)
print(f"Summary generated: {summary}")
execute_update("UPDATE conversations SET summary = %s WHERE id = %s", (summary, conv_id))
print("Database updated")
if __name__ == "__main__":
asyncio.run(main(1))

View File

@ -0,0 +1,61 @@
import aiohttp
import asyncio
import os
import json
async def test_whisper_variant(session, url, file_path, params=None, form_fields=None, description=""):
print(f"\n--- Testing: {description} ---")
try:
data = aiohttp.FormData()
# Re-open file for each request to ensure pointer is at start
data.add_field('file', open(file_path, 'rb'), filename='rec.mp3')
if form_fields:
for k, v in form_fields.items():
data.add_field(k, str(v))
async with session.post(url, data=data, params=params) as response:
print(f"Status: {response.status}")
if response.status == 200:
try:
text_content = await response.text()
try:
result = json.loads(text_content)
# Print keys to see if we got something new
if isinstance(result, dict):
print("Keys:", result.keys())
if 'results' in result and len(result['results']) > 0:
print("Result[0] keys:", result['results'][0].keys())
if 'segments' in result:
print("FOUND SEGMENTS!")
print("Raw (truncated):", text_content[:300])
except:
print("Non-JSON Response:", text_content[:300])
except Exception as e:
print(f"Reading error: {e}")
else:
print("Error:", await response.text())
except Exception as e:
print(f"Exception: {e}")
async def test_whisper():
url = "http://172.16.31.115:5000/transcribe"
file_path = "uploads/email_attachments/65d2ca781a6bf3cee9cee0a8ce80acac_rec.mp3"
if not os.path.exists(file_path):
print(f"File not found: {file_path}")
return
async with aiohttp.ClientSession() as session:
# Variant 1: 'timestamps': 'true' as form field
await test_whisper_variant(session, url, file_path, form_fields={'timestamps': 'true'}, description="Form: timestamps=true")
# Variant 2: 'response_format': 'verbose_json' (OpenAI style)
await test_whisper_variant(session, url, file_path, form_fields={'response_format': 'verbose_json'}, description="Form: verbose_json")
# Variant 3: 'verbose': 'true'
await test_whisper_variant(session, url, file_path, form_fields={'verbose': 'true'}, description="Form: verbose=true")
if __name__ == "__main__":
asyncio.run(test_whisper())