- FastAPI Backend mit Router (emails, invoices, auth) - PostgreSQL DB Models (Invoice, EmailLog, InvoiceDetail, User) - PyMuPDF PDF-Verarbeitung mit Steuer-Erkennung - openpyxl Excel-Export - Alembic Migration initialisiert - IMAP-Verbindung zu Strato funktioniert - Docker-Setup mit Traefik-Labels Co-Authored-By: Claude <noreply@anthropic.com>
312 lines
9.7 KiB
Python
312 lines
9.7 KiB
Python
"""
|
|
C-Stone Invoice Check — Invoice Router
|
|
Rechnungs-Verarbeitung und Speicherung
|
|
"""
|
|
|
|
import os
|
|
import fitz # PyMuPDF
|
|
import re
|
|
from datetime import datetime
|
|
from typing import List, Optional, Dict, Any
|
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
|
from sqlalchemy.orm import Session
|
|
from openpyxl import Workbook
|
|
from openpyxl.styles import Font, Alignment, Border, Side
|
|
|
|
from backend.database import get_db
|
|
from backend.models import Invoice, InvoiceDetail, EmailLog
|
|
|
|
router = APIRouter()
|
|
|
|
# Ablagepfad (Evo-X2)
|
|
INVOICE_STORAGE_PATH = os.getenv("INVOICE_STORAGE_PATH", "/Volumes/Evo-X2/Rechnungen")
|
|
|
|
# Steuer-Schlüsselwörter
|
|
TAX_KEYWORDS = {
|
|
"mit_mwst": ["mwst", "mehrwertsteuer", "steuer", "incl. mwst", "zzgl. mwst"],
|
|
"ohne_mwst": ["zzgl. steuer", "exkl. steuer", "steuerfrei", "steuerbefreit"],
|
|
"umsatzsteuer": ["umsatzsteuer", "ust", "ust-id"],
|
|
"vat": ["vat", "v.a.t.", "value added tax"],
|
|
}
|
|
|
|
|
|
def detect_tax_type(text: str) -> Dict[str, Any]:
|
|
"""Erkennt Steuer-Art aus PDF-Text"""
|
|
text_lower = text.lower()
|
|
|
|
# Prüfen auf "mit MwSt"
|
|
if any(kw in text_lower for kw in TAX_KEYWORDS["mit_mwst"]):
|
|
# Prüfen auf Satz: z.B. "inkl. 19 % MwSt"
|
|
match = re.search(r'(?:inkl|zzgl)[\s\-]*(?:der)?[\s\-]*(\d+)[\s\-]*%[\s\-]*(?:mwst|steuer)', text_lower)
|
|
if match:
|
|
tax_rate = float(match.group(1)) / 100
|
|
return {
|
|
"tax_type": "mit MwSt",
|
|
"tax_rate": tax_rate,
|
|
"confidence": 0.9,
|
|
"found_keyword": match.group(0)
|
|
}
|
|
|
|
# Prüfen auf "ohne MwSt"
|
|
if any(kw in text_lower for kw in TAX_KEYWORDS["ohne_mwst"]):
|
|
return {
|
|
"tax_type": "ohne MwSt",
|
|
"tax_rate": 0.0,
|
|
"confidence": 0.85,
|
|
"found_keyword": next(kw for kw in TAX_KEYWORDS["ohne_mwst"] if kw in text_lower)
|
|
}
|
|
|
|
# Prüfen auf Umsatzsteuer
|
|
if any(kw in text_lower for kw in TAX_KEYWORDS["umsatzsteuer"]):
|
|
return {
|
|
"tax_type": "Umsatzsteuer",
|
|
"tax_rate": 0.19, # Standard
|
|
"confidence": 0.7,
|
|
"found_keyword": next(kw for kw in TAX_KEYWORDS["umsatzsteuer"] if kw in text_lower)
|
|
}
|
|
|
|
# Prüfen auf VAT (europäische Steuer)
|
|
if any(kw in text_lower for kw in TAX_KEYWORDS["vat"]):
|
|
return {
|
|
"tax_type": "VAT",
|
|
"tax_rate": 0.19, # Standard EU
|
|
"confidence": 0.7,
|
|
"found_keyword": next(kw for kw in TAX_KEYWORDS["vat"] if kw in text_lower)
|
|
}
|
|
|
|
# Standard: Prüfen auf Steuerangabe im Text
|
|
tax_matches = re.findall(r'(\d+)[\s\-]*%[\s\-]*(?:steuer|mwst|ust|vat)', text_lower)
|
|
if tax_matches:
|
|
tax_rate = float(tax_matches[0]) / 100
|
|
return {
|
|
"tax_type": "mit Steuer",
|
|
"tax_rate": tax_rate,
|
|
"confidence": 0.6,
|
|
"found_keyword": f"{tax_matches[0]}%"
|
|
}
|
|
|
|
# Keine Steuer erkannt
|
|
return {
|
|
"tax_type": "unbekannt",
|
|
"tax_rate": None,
|
|
"confidence": 0.3,
|
|
"found_keyword": None
|
|
}
|
|
|
|
|
|
def extract_amount(text: str) -> Optional[float]:
|
|
"""Extrahiert Betrag aus PDF-Text"""
|
|
# Muster: "Summe: 123,45 €" oder "Total: 123.45 EUR"
|
|
patterns = [
|
|
r'(?:summe|total|betrag)[\s:]*([\d\.]+[\s\,]\d{2})\s*€?',
|
|
r'([\d\.]+[\s\,]\d{2})\s*(?:€|EUR|EUR\*)',
|
|
r'(?:zu\s+bezahlen|gesamt)[\s:]*([\d\.]+[\s\,]\d{2})',
|
|
]
|
|
|
|
for pattern in patterns:
|
|
match = re.search(pattern, text.lower())
|
|
if match:
|
|
amount_str = match.group(1)
|
|
# Komma zu Punkt für float
|
|
amount_str = amount_str.replace('.', '').replace(',', '.')
|
|
try:
|
|
return float(amount_str)
|
|
except ValueError:
|
|
continue
|
|
|
|
return None
|
|
|
|
|
|
def extract_invoice_number(text: str) -> Optional[str]:
|
|
"""Extrahiert Rechnungsnummer aus PDF-Text"""
|
|
patterns = [
|
|
r'(?:rechnungs)?nr\.?\s*[:\s]*([A-Za-z0-9\-/]+)',
|
|
r'(?:invoice|rechnung)?[\s_]*(?:no\.?|#)?\s*[:\s]*([A-Za-z0-9\-/]+)',
|
|
r'(?:rechnung|invoice)\s*(\d{6,})',
|
|
]
|
|
|
|
for pattern in patterns:
|
|
match = re.search(pattern, text.lower())
|
|
if match:
|
|
return match.group(1).strip()
|
|
|
|
return None
|
|
|
|
|
|
def extract_issuer(text: str) -> Optional[str]:
|
|
"""Extrahiert Rechnungsausteller aus PDF-Text"""
|
|
patterns = [
|
|
r'(?:von|von\s+haus|ausgestellt\s+vom|issuer)[:\s]*\n?([A-Z][A-Za-z\s]+(?:GmbH|AG|UG|KG|e\.K\.|GmbH&Co\.KG))',
|
|
r'(?:seit|seit\s+dem|seit\s+vom)[\s\S]{0,100}?(?:rechnung|invoice)',
|
|
r'^(.+?)[\n\r]{1,2}(?:rechnung|invoice|rechnungsnummer)',
|
|
]
|
|
|
|
for pattern in patterns:
|
|
match = re.search(pattern, text, re.IGNORECASE)
|
|
if match:
|
|
return match.group(1).strip()
|
|
|
|
return None
|
|
|
|
|
|
def extract_pdf_data(file_path: str) -> Dict[str, Any]:
|
|
"""Extrahiert alle relevanten Daten aus PDF-Datei"""
|
|
try:
|
|
doc = fitz.open(file_path)
|
|
text = ""
|
|
pages = 0
|
|
|
|
for page in doc:
|
|
text += page.get_text()
|
|
pages += 1
|
|
|
|
doc.close()
|
|
|
|
return {
|
|
"text": text,
|
|
"invoice_number": extract_invoice_number(text),
|
|
"amount": extract_amount(text),
|
|
"issuer": extract_issuer(text),
|
|
"tax_info": detect_tax_type(text),
|
|
"pages": pages
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"error": str(e),
|
|
"pages": 0
|
|
}
|
|
|
|
|
|
def get_storage_path(year: int, tax_type: str) -> str:
|
|
"""Erstellt Ablagepfad auf Evo-X2"""
|
|
base_path = INVOICE_STORAGE_PATH
|
|
|
|
# Steuer-Verzeichnis
|
|
if tax_type and "mit" in tax_type.lower():
|
|
tax_dir = "mit MwSt"
|
|
elif tax_type and "ohne" in tax_type.lower():
|
|
tax_dir = "ohne MwSt"
|
|
else:
|
|
tax_dir = "ohne MwSt" # Default
|
|
|
|
return os.path.join(base_path, str(year), tax_dir)
|
|
|
|
|
|
def save_to_excel(invoices: List[Invoice], excel_path: str):
|
|
"""Speichert Rechnungen in Excel-Datei"""
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = "Rechnungen"
|
|
|
|
# Header
|
|
headers = ["Datum", "Rechnungsausteller", "Betrag", "Umsatzsteuer", "Steuerart", "Dateipfad"]
|
|
for col, header in enumerate(headers, 1):
|
|
cell = ws.cell(row=1, column=col, value=header)
|
|
cell.font = Font(bold=True)
|
|
|
|
# Daten
|
|
for row, inv in enumerate(invoices, 2):
|
|
ws.cell(row=row, column=1, value=inv.invoice_date.strftime("%Y-%m-%d") if inv.invoice_date else "")
|
|
ws.cell(row=row, column=2, value=inv.issuer_name or "")
|
|
ws.cell(row=row, column=3, value=inv.invoice_amount or 0)
|
|
ws.cell(row=row, column=4, value=inv.invoice_tax_amount or 0)
|
|
ws.cell(row=row, column=5, value=inv.invoice_tax_type or "")
|
|
ws.cell(row=row, column=6, value=inv.file_path or "")
|
|
|
|
# Formatieren
|
|
for col in range(1, 7):
|
|
ws.column_dimensions[chr(64 + col)].width = 20
|
|
|
|
wb.save(excel_path)
|
|
|
|
|
|
@router.get("/scan-pdf")
|
|
async def scan_pdf_file(file_path: str, db: Session = Depends(get_db)):
|
|
"""Scannt einzelne PDF-Datei"""
|
|
if not os.path.exists(file_path):
|
|
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
|
|
|
data = extract_pdf_data(file_path)
|
|
|
|
if "error" in data:
|
|
return {"error": data["error"]}
|
|
|
|
# Speichern in DB
|
|
invoice = Invoice(
|
|
file_path=file_path,
|
|
file_name=os.path.basename(file_path),
|
|
invoice_number=data.get("invoice_number"),
|
|
invoice_amount=data.get("amount"),
|
|
issuer_name=data.get("issuer"),
|
|
invoice_tax_type=data.get("tax_info", {}).get("tax_type"),
|
|
invoice_tax_rate=data.get("tax_info", {}).get("tax_rate"),
|
|
confidence_score=data.get("tax_info", {}).get("confidence", 0),
|
|
processed=True
|
|
)
|
|
|
|
db.add(invoice)
|
|
db.commit()
|
|
|
|
# Details speichern
|
|
detail = InvoiceDetail(
|
|
invoice_id=invoice.id,
|
|
raw_text=data.get("text", ""),
|
|
parsed_data={
|
|
"invoice_number": data.get("invoice_number"),
|
|
"amount": data.get("amount"),
|
|
"issuer": data.get("issuer"),
|
|
"tax_info": data.get("tax_info")
|
|
}
|
|
)
|
|
db.add(detail)
|
|
db.commit()
|
|
|
|
return {
|
|
"id": invoice.id,
|
|
"invoice_number": data.get("invoice_number"),
|
|
"amount": data.get("amount"),
|
|
"issuer": data.get("issuer"),
|
|
"tax_type": data.get("tax_info", {}).get("tax_type"),
|
|
"tax_rate": data.get("tax_info", {}).get("tax_rate"),
|
|
"pages": data.get("pages", 0)
|
|
}
|
|
|
|
|
|
@router.get("/export-excel")
|
|
async def export_to_excel(db: Session = Depends(get_db)):
|
|
"""Exportiert alle Rechnungen nach Excel"""
|
|
invoices = db.query(Invoice).order_by(Invoice.invoice_date.desc()).all()
|
|
|
|
excel_path = os.path.join(INVOICE_STORAGE_PATH, f"rechnungen_{datetime.utcnow().strftime('%Y%m%d')}.xlsx")
|
|
|
|
save_to_excel(invoices, excel_path)
|
|
|
|
return {
|
|
"status": "success",
|
|
"file_path": excel_path,
|
|
"invoice_count": len(invoices)
|
|
}
|
|
|
|
|
|
@router.get("/list")
|
|
async def list_invoices(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
|
"""Listet alle Rechnungen"""
|
|
invoices = db.query(Invoice).offset(skip).limit(limit).all()
|
|
|
|
return {
|
|
"invoices": [
|
|
{
|
|
"id": inv.id,
|
|
"invoice_number": inv.invoice_number,
|
|
"invoice_date": inv.invoice_date.isoformat() if inv.invoice_date else None,
|
|
"issuer_name": inv.issuer_name,
|
|
"invoice_amount": inv.invoice_amount,
|
|
"invoice_tax_type": inv.invoice_tax_type,
|
|
"file_path": inv.file_path,
|
|
"processed": inv.processed
|
|
}
|
|
for inv in invoices
|
|
],
|
|
"total": db.query(Invoice).count()
|
|
}
|