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>
This commit is contained in:
Markus Kruse 2026-07-24 23:45:51 +02:00
parent 48486bf8f5
commit 937267b507
11 changed files with 392 additions and 4 deletions

15
Daily Notes/072426.md Normal file
View file

@ -0,0 +1,15 @@
# 072426 - Daily Work Log
## Decisions
-
## Meetings & Conversations
-
## Notes
- Session gestartet, AI Lab Verbindung getestet: online
- Memory geladen: Phase 2 Architektur in Bearbeitung
- Task Board geprüft: Backlog mit 15 Tickets, 8 erledigt, 6 offen
## End of Day Summary
-

View file

@ -16,6 +16,8 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy application code
COPY backend/ ./backend/ COPY backend/ ./backend/
COPY .env.example .env COPY .env.example .env
COPY alembic.ini .
COPY backend/alembic ./backend/alembic
# Environment # Environment
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1

116
alembic.ini Normal file
View file

@ -0,0 +1,116 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = backend/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

123
backend/alembic/env.py Normal file
View file

@ -0,0 +1,123 @@
"""Alembic Environment Configuration"""
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy import create_engine
from alembic import context
# Import models directly (avoid env file issues)
from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean, Text, JSON
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
Base = declarative_base()
# Import models manually
class Invoice(Base):
__tablename__ = "invoices"
id = Column(Integer, primary_key=True)
email_id = Column(String, index=True)
email_subject = Column(String, nullable=True)
email_from = Column(String, nullable=True)
email_date = Column(DateTime, nullable=True)
invoice_number = Column(String, index=True, nullable=True)
invoice_date = Column(DateTime, nullable=True)
invoice_amount = Column(Float, nullable=True)
invoice_tax_amount = Column(Float, nullable=True)
invoice_tax_rate = Column(Float, nullable=True)
invoice_tax_type = Column(String, nullable=True)
issuer_name = Column(String, nullable=True)
issuer_address = Column(Text, nullable=True)
file_path = Column(String, nullable=True)
file_name = Column(String, nullable=True)
storage_year = Column(Integer, nullable=True)
storage_path = Column(String, nullable=True)
processed = Column(Boolean, default=False)
processing_error = Column(Text, nullable=True)
confidence_score = Column(Float, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class EmailLog(Base):
__tablename__ = "email_logs"
id = Column(Integer, primary_key=True)
email_id = Column(String, unique=True, index=True)
email_subject = Column(String, nullable=True)
email_from = Column(String, nullable=True)
email_date = Column(DateTime, nullable=True)
email_folder = Column(String, nullable=True)
matched_keywords = Column(JSON, nullable=True)
has_attachment = Column(Boolean, default=False)
attachment_type = Column(String, nullable=True)
processed = Column(Boolean, default=False)
processing_result = Column(String, nullable=True)
invoice_id = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
class InvoiceDetail(Base):
__tablename__ = "invoice_details"
id = Column(Integer, primary_key=True)
invoice_id = Column(Integer, nullable=False, index=True)
raw_text = Column(Text, nullable=True)
parsed_data = Column(JSON, nullable=True)
confidence_breakdown = Column(JSON, nullable=True)
processing_notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String, unique=True, index=True)
hashed_password = Column(String)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Set target metadata for autogenerate
target_metadata = Base.metadata
# other values from the config
def get_url():
"""Get database URL from environment"""
import os
return os.getenv("DATABASE_URL", "postgresql+psycopg2://postgres:postgres@localhost:5432/invoice_check")
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = get_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
url = get_url()
connectable = create_engine(
url,
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -10,11 +10,13 @@ from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, declarative_base
from sqlalchemy import Column, Integer, String, Boolean, DateTime from sqlalchemy import Column, Integer, String, Boolean, DateTime
from backend.database import get_db from backend.database import get_db
Base = declarative_base()
# Konfiguration # Konfiguration
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production") SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
ALGORITHM = "HS256" ALGORITHM = "HS256"

View file

@ -7,6 +7,7 @@ from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import text
from backend.database import get_db, engine, Base from backend.database import get_db, engine, Base
@ -39,7 +40,7 @@ app.add_middleware(
async def health_check(db: Session = Depends(get_db)): async def health_check(db: Session = Depends(get_db)):
"""Health Check Endpoint""" """Health Check Endpoint"""
try: try:
db.execute("SELECT 1") db.execute(text("SELECT 1"))
return {"status": "healthy", "database": "connected"} return {"status": "healthy", "database": "connected"}
except Exception as e: except Exception as e:
return {"status": "unhealthy", "database": "disconnected", "error": str(e)} return {"status": "unhealthy", "database": "disconnected", "error": str(e)}

View file

@ -19,7 +19,7 @@ pymupdf==1.24.10
openpyxl==3.1.5 openpyxl==3.1.5
# Mail (IMAP) # Mail (IMAP)
imaplib2==3.5 imaplib2==3.6
# Allgemein # Allgemein
python-dotenv==1.0.1 python-dotenv==1.0.1

View file

@ -154,9 +154,11 @@ def extract_pdf_data(file_path: str) -> Dict[str, Any]:
try: try:
doc = fitz.open(file_path) doc = fitz.open(file_path)
text = "" text = ""
pages = 0
for page in doc: for page in doc:
text += page.get_text() text += page.get_text()
pages += 1
doc.close() doc.close()
@ -166,7 +168,7 @@ def extract_pdf_data(file_path: str) -> Dict[str, Any]:
"amount": extract_amount(text), "amount": extract_amount(text),
"issuer": extract_issuer(text), "issuer": extract_issuer(text),
"tax_info": detect_tax_type(text), "tax_info": detect_tax_type(text),
"pages": len(doc) if 'doc' in dir() else 0 "pages": pages
} }
except Exception as e: except Exception as e:

View file

@ -0,0 +1,47 @@
"""IMAP-Verbindungs-Test"""
import imaplib
import os
import email
IMAP_HOST = os.getenv("IMAP_HOST", "imap.strato.de")
IMAP_PORT = int(os.getenv("IMAP_PORT", 993))
IMAP_USER = os.getenv("IMAP_USER", "info@c-stone-dev.com")
IMAP_PASSWORD = os.getenv("IMAP_PASSWORD", "hH22761,018!")
print(f"Teste IMAP-Verbindung zu {IMAP_HOST}:{IMAP_PORT}")
print(f"Benutzer: {IMAP_USER}")
try:
mail = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
result, data = mail.login(IMAP_USER, IMAP_PASSWORD)
print(f"Login erfolgreich: {result}")
result, folders = mail.list()
print(f"Ordner:")
for folder in folders[:10]: # Erste 10 Ordner
print(f" {folder.decode()}")
# INBOX auswählen
result, data = mail.select("INBOX")
if result == "OK":
result, data = mail.search(None, 'ALL')
if result == "OK":
email_ids = data[0].split()
print(f"\nINBOX enthält {len(email_ids)} Emails")
# Letzte 5 Emails anzeigen
for email_id in email_ids[-5:]:
result, msg_data = mail.fetch(email_id, '(RFC822.HEADER)')
if result == "OK":
msg = email.message_from_bytes(msg_data[0][1])
subject = msg.get("Subject", "Kein Betreff")
from_addr = msg.get("From", "Unbekannt")
print(f" Email {email_id.decode()}: {from_addr} - {subject}")
mail.logout()
print("\n✅ IMAP-Verbindung erfolgreich!")
except imaplib.IMAP4.error as e:
print(f"❌ IMAP-Fehler: {e}")
except Exception as e:
print(f"❌ Fehler: {e}")

54
backend/tests/test_pdf.py Normal file
View file

@ -0,0 +1,54 @@
"""PDF-Verarbeitungs-Test"""
import sys
import os
sys.path.insert(0, '/app')
import fitz
from backend.routers.invoices import extract_pdf_data, detect_tax_type, extract_amount, extract_invoice_number
def test_pdf_extraction(file_path: str):
"""Testet die Extraktion aus einer PDF-Datei"""
print(f"\n{'='*60}")
print(f"Teste: {file_path}")
print('='*60)
if not os.path.exists(file_path):
print(f"❌ Datei nicht gefunden: {file_path}")
return
# Extrahiere Daten
data = extract_pdf_data(file_path)
if "error" in data:
print(f"❌ Fehler: {data['error']}")
return
print(f"Seiten: {data.get('pages', 0)}")
print(f"Rechnungsnummer: {data.get('invoice_number') or 'Nicht gefunden'}")
print(f"Betrag: {data.get('amount')}")
print(f"Austeller: {data.get('issuer') or 'Nicht gefunden'}")
tax_info = data.get('tax_info', {})
print(f"Steuerart: {tax_info.get('tax_type')}")
print(f"Steuersatz: {tax_info.get('tax_rate')}")
print(f"Confidence: {tax_info.get('confidence')}")
# Teste einzelne Funktionen
print(f"\n--- Einzelne Funktionen ---")
text = data.get('text', '')[:500]
print(f"Auszug aus Text:\n{text[:200]}...")
# Speichere extrahierten Text
with open(f'/tmp/test-invoices/{os.path.basename(file_path)}.txt', 'w') as f:
f.write(data.get('text', ''))
print(f"\nExtrahierter Text gespeichert in: /tmp/test-invoices/{os.path.basename(file_path)}.txt")
if __name__ == '__main__':
# Teste lokale Dateien
test_files = [
'/tmp/test-invoices/rechnung_mit_mwst.pdf',
'/tmp/test-invoices/rechnung_ohne_mwst.pdf',
]
for test_file in test_files:
test_pdf_extraction(test_file)