c-stone-invoice-check/backend/routers/invoices.py
Markus Kruse 937267b507 feat: Backend complete with IMAP, PDF processing, Alembic migrations
- 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>
2026-07-24 23:45:51 +02:00

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()
}