added amazon importer and logging smtp
This commit is contained in:
parent
9fdada5dbe
commit
a4e39332c7
28
Dockerfile
28
Dockerfile
|
|
@ -1,15 +1,39 @@
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends libzbar0 && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libzbar0 \
|
||||||
|
# Chromium dependencies for Playwright
|
||||||
|
libglib2.0-0t64 \
|
||||||
|
libnss3 \
|
||||||
|
libnspr4 \
|
||||||
|
libatk1.0-0t64 \
|
||||||
|
libatk-bridge2.0-0t64 \
|
||||||
|
libcups2t64 \
|
||||||
|
libdrm2 \
|
||||||
|
libexpat1 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxext6 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxrandr2 \
|
||||||
|
libgbm1 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
libasound2t64 \
|
||||||
|
libatspi2.0-0t64 \
|
||||||
|
fonts-liberation \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
RUN playwright install chromium
|
||||||
|
|
||||||
COPY app/ ./app/
|
COPY app/ ./app/
|
||||||
|
|
||||||
RUN mkdir -p /data/uploads
|
RUN mkdir -p /data/uploads /data/amazon_session
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
166
app/database.py
166
app/database.py
|
|
@ -4,13 +4,13 @@ import aiosqlite
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
DB_PATH = os.environ.get("DB_PATH", "/data/belegimport.db")
|
DB_PATH = os.environ.get("DB_PATH", "/data/belegimport.db")
|
||||||
SCHEMA_VERSION = 2
|
SCHEMA_VERSION = 8
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_fernet = None
|
_fernet = None
|
||||||
|
|
||||||
ENCRYPTED_KEYS = {"imap_password", "smtp_password", "smb_password"}
|
ENCRYPTED_KEYS = {"imap_password", "smtp_password", "smb_password", "amazon_password"}
|
||||||
|
|
||||||
DEFAULT_SETTINGS = {
|
DEFAULT_SETTINGS = {
|
||||||
"imap_server": "",
|
"imap_server": "",
|
||||||
|
|
@ -24,8 +24,12 @@ DEFAULT_SETTINGS = {
|
||||||
"smtp_username": "",
|
"smtp_username": "",
|
||||||
"smtp_password": "",
|
"smtp_password": "",
|
||||||
"import_email": "",
|
"import_email": "",
|
||||||
|
"import_email_eingang": "",
|
||||||
|
"import_email_ausgang": "",
|
||||||
"source_folder": "Rechnungen",
|
"source_folder": "Rechnungen",
|
||||||
"processed_folder": "Rechnungen/Verarbeitet",
|
"processed_folder": "Rechnungen/Verarbeitet",
|
||||||
|
"source_folder_ausgang": "",
|
||||||
|
"processed_folder_ausgang": "",
|
||||||
"interval_minutes": "5",
|
"interval_minutes": "5",
|
||||||
"scheduler_enabled": "false",
|
"scheduler_enabled": "false",
|
||||||
"fetch_since_date": "",
|
"fetch_since_date": "",
|
||||||
|
|
@ -39,7 +43,18 @@ DEFAULT_SETTINGS = {
|
||||||
"smb_share": "",
|
"smb_share": "",
|
||||||
"smb_source_path": "",
|
"smb_source_path": "",
|
||||||
"smb_processed_path": "Verarbeitet",
|
"smb_processed_path": "Verarbeitet",
|
||||||
|
"smb_source_path_ausgang": "",
|
||||||
|
"smb_processed_path_ausgang": "",
|
||||||
"smb_mode": "forward",
|
"smb_mode": "forward",
|
||||||
|
# Amazon
|
||||||
|
"amazon_enabled": "false",
|
||||||
|
"amazon_email": "",
|
||||||
|
"amazon_password": "",
|
||||||
|
"amazon_domain": "amazon.de",
|
||||||
|
"amazon_last_sync": "",
|
||||||
|
"amazon_since_date": "",
|
||||||
|
# Debug
|
||||||
|
"debug_save_amazon_pdfs": "false",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -121,7 +136,10 @@ async def _run_migrations(db: aiosqlite.Connection, current_version: int):
|
||||||
email_from TEXT,
|
email_from TEXT,
|
||||||
attachments_count INTEGER DEFAULT 0,
|
attachments_count INTEGER DEFAULT 0,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
error_message TEXT
|
error_message TEXT,
|
||||||
|
sent_to TEXT DEFAULT '',
|
||||||
|
smtp_log TEXT DEFAULT '',
|
||||||
|
beleg_type TEXT DEFAULT 'eingang'
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
@ -150,10 +168,72 @@ async def _run_migrations(db: aiosqlite.Connection, current_version: int):
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await _set_schema_version(db, 2)
|
await _set_schema_version(db, 2)
|
||||||
|
|
||||||
# --- Future migrations go here ---
|
if current_version < 3:
|
||||||
# if current_version < 3:
|
logger.info("Migration v3: Amazon-Plattform hinzugefügt")
|
||||||
# logger.info("Migration v3: ...")
|
await db.execute("""
|
||||||
# await _set_schema_version(db, 3)
|
CREATE TABLE IF NOT EXISTS amazon_downloaded (
|
||||||
|
order_id TEXT PRIMARY KEY,
|
||||||
|
downloaded_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await db.commit()
|
||||||
|
await _set_schema_version(db, 3)
|
||||||
|
|
||||||
|
if current_version < 4:
|
||||||
|
logger.info("Migration v4: sent_to Spalte im Verarbeitungslog")
|
||||||
|
await db.execute("""
|
||||||
|
ALTER TABLE processing_log ADD COLUMN sent_to TEXT DEFAULT ''
|
||||||
|
""")
|
||||||
|
await db.commit()
|
||||||
|
await _set_schema_version(db, 4)
|
||||||
|
|
||||||
|
if current_version < 5:
|
||||||
|
logger.info("Migration v5: SMTP-Protokoll im Verarbeitungslog")
|
||||||
|
await db.execute("""
|
||||||
|
ALTER TABLE processing_log ADD COLUMN smtp_log TEXT DEFAULT ''
|
||||||
|
""")
|
||||||
|
await db.commit()
|
||||||
|
await _set_schema_version(db, 5)
|
||||||
|
|
||||||
|
if current_version < 6:
|
||||||
|
logger.info("Migration v6: Per-Invoice Tracking statt per-Order")
|
||||||
|
try:
|
||||||
|
await db.execute("""
|
||||||
|
ALTER TABLE amazon_downloaded ADD COLUMN invoice_url TEXT DEFAULT ''
|
||||||
|
""")
|
||||||
|
except Exception:
|
||||||
|
pass # column already exists
|
||||||
|
await db.commit()
|
||||||
|
await _set_schema_version(db, 6)
|
||||||
|
|
||||||
|
if current_version < 8:
|
||||||
|
logger.info("Migration v7/8: Eingangs-/Ausgangsbelege Unterscheidung")
|
||||||
|
# Add beleg_type column to processing_log (check if it exists first)
|
||||||
|
cursor = await db.execute("PRAGMA table_info(processing_log)")
|
||||||
|
columns = [row[1] for row in await cursor.fetchall()]
|
||||||
|
if "beleg_type" not in columns:
|
||||||
|
await db.execute("""
|
||||||
|
ALTER TABLE processing_log ADD COLUMN beleg_type TEXT DEFAULT 'eingang'
|
||||||
|
""")
|
||||||
|
logger.info(" beleg_type Spalte hinzugefügt")
|
||||||
|
# Migrate import_email -> import_email_eingang
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT value FROM settings WHERE key = 'import_email'"
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row and row[0]:
|
||||||
|
cursor2 = await db.execute(
|
||||||
|
"SELECT value FROM settings WHERE key = 'import_email_eingang'"
|
||||||
|
)
|
||||||
|
row2 = await cursor2.fetchone()
|
||||||
|
if not row2 or not row2[0]:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('import_email_eingang', ?)",
|
||||||
|
(row[0],),
|
||||||
|
)
|
||||||
|
logger.info(" import_email nach import_email_eingang übertragen")
|
||||||
|
await db.commit()
|
||||||
|
await _set_schema_version(db, 8)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
@ -176,7 +256,18 @@ async def init_db():
|
||||||
email_from TEXT,
|
email_from TEXT,
|
||||||
attachments_count INTEGER DEFAULT 0,
|
attachments_count INTEGER DEFAULT 0,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
error_message TEXT
|
error_message TEXT,
|
||||||
|
sent_to TEXT DEFAULT '',
|
||||||
|
smtp_log TEXT DEFAULT '',
|
||||||
|
beleg_type TEXT DEFAULT 'eingang'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS amazon_downloaded (
|
||||||
|
order_id TEXT NOT NULL,
|
||||||
|
downloaded_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||||
|
invoice_url TEXT DEFAULT '',
|
||||||
|
PRIMARY KEY (order_id, invoice_url)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
@ -235,19 +326,29 @@ async def save_settings(data: dict):
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_import_email(settings: dict, beleg_type: str = "eingang") -> str:
|
||||||
|
"""Resolve the correct import email address based on document type."""
|
||||||
|
if beleg_type == "ausgang":
|
||||||
|
return settings.get("import_email_ausgang", "")
|
||||||
|
return settings.get("import_email_eingang", "") or settings.get("import_email", "")
|
||||||
|
|
||||||
|
|
||||||
async def add_log_entry(
|
async def add_log_entry(
|
||||||
email_subject: str,
|
email_subject: str,
|
||||||
email_from: str,
|
email_from: str,
|
||||||
attachments_count: int,
|
attachments_count: int,
|
||||||
status: str,
|
status: str,
|
||||||
error_message: str = "",
|
error_message: str = "",
|
||||||
|
sent_to: str = "",
|
||||||
|
smtp_log: str = "",
|
||||||
|
beleg_type: str = "eingang",
|
||||||
):
|
):
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO processing_log
|
"""INSERT INTO processing_log
|
||||||
(email_subject, email_from, attachments_count, status, error_message)
|
(email_subject, email_from, attachments_count, status, error_message, sent_to, smtp_log, beleg_type)
|
||||||
VALUES (?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(email_subject, email_from, attachments_count, status, error_message),
|
(email_subject, email_from, attachments_count, status, error_message, sent_to, smtp_log, beleg_type),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
@ -260,3 +361,46 @@ async def get_log_entries(limit: int = 100) -> list[dict]:
|
||||||
)
|
)
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_log_entries() -> int:
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
cursor = await db.execute("SELECT COUNT(*) FROM processing_log")
|
||||||
|
count = (await cursor.fetchone())[0]
|
||||||
|
await db.execute("DELETE FROM processing_log")
|
||||||
|
await db.commit()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
async def is_invoice_downloaded(order_id: str, invoice_url: str = "") -> bool:
|
||||||
|
"""Check if a specific invoice has been downloaded.
|
||||||
|
If invoice_url is given, check per-URL. Otherwise check per order_id."""
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
if invoice_url:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT 1 FROM amazon_downloaded WHERE order_id = ? AND invoice_url = ?",
|
||||||
|
(order_id, invoice_url),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT 1 FROM amazon_downloaded WHERE order_id = ?", (order_id,)
|
||||||
|
)
|
||||||
|
return await cursor.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_invoice_downloaded(order_id: str, invoice_url: str = ""):
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT OR IGNORE INTO amazon_downloaded (order_id, invoice_url) VALUES (?, ?)",
|
||||||
|
(order_id, invoice_url),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_downloaded_invoices() -> int:
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
cursor = await db.execute("SELECT COUNT(*) FROM amazon_downloaded")
|
||||||
|
count = (await cursor.fetchone())[0]
|
||||||
|
await db.execute("DELETE FROM amazon_downloaded")
|
||||||
|
await db.commit()
|
||||||
|
return count
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,30 @@ from email.mime.text import MIMEText
|
||||||
from email import encoders
|
from email import encoders
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.database import get_settings, add_log_entry
|
from app.database import get_settings, add_log_entry, get_import_email
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_with_log(smtp_conn: smtplib.SMTP, msg) -> str:
|
||||||
|
"""Send email and capture SMTP protocol exchange."""
|
||||||
|
log_lines = []
|
||||||
|
original_print_debug = smtp_conn._print_debug
|
||||||
|
|
||||||
|
def _capture(*args):
|
||||||
|
log_lines.append(" ".join(str(a) for a in args))
|
||||||
|
|
||||||
|
smtp_conn._print_debug = _capture
|
||||||
|
old_level = smtp_conn.debuglevel
|
||||||
|
smtp_conn.set_debuglevel(1)
|
||||||
|
try:
|
||||||
|
smtp_conn.send_message(msg)
|
||||||
|
finally:
|
||||||
|
smtp_conn.set_debuglevel(old_level)
|
||||||
|
smtp_conn._print_debug = original_print_debug
|
||||||
|
return "\n".join(log_lines)
|
||||||
|
|
||||||
|
|
||||||
def _connect_imap(settings: dict) -> imaplib.IMAP4_SSL | imaplib.IMAP4:
|
def _connect_imap(settings: dict) -> imaplib.IMAP4_SSL | imaplib.IMAP4:
|
||||||
server = settings["imap_server"]
|
server = settings["imap_server"]
|
||||||
port = int(settings.get("imap_port", 993))
|
port = int(settings.get("imap_port", 993))
|
||||||
|
|
@ -118,21 +137,117 @@ def _move_email(conn: imaplib.IMAP4, msg_uid: bytes, dest_folder: str):
|
||||||
conn.expunge()
|
conn.expunge()
|
||||||
|
|
||||||
|
|
||||||
async def process_mailbox() -> dict:
|
async def _process_folder(
|
||||||
settings = await get_settings()
|
imap_conn, smtp_conn, settings: dict,
|
||||||
|
source_folder: str, processed_folder: str,
|
||||||
if not settings.get("imap_server") or not settings.get("import_email"):
|
import_email: str, beleg_type: str, fetch_since: str,
|
||||||
logger.warning("IMAP oder Import-Email nicht konfiguriert")
|
) -> dict:
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0, "error": "Nicht konfiguriert"}
|
"""Process one IMAP folder pair. Returns counts dict."""
|
||||||
|
|
||||||
source_folder = settings.get("source_folder", "INBOX")
|
|
||||||
processed_folder = settings.get("processed_folder", "INBOX/Verarbeitet")
|
|
||||||
import_email = settings["import_email"]
|
|
||||||
smtp_from = settings.get("smtp_username", "")
|
smtp_from = settings.get("smtp_username", "")
|
||||||
|
|
||||||
processed = 0
|
processed = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
|
|
||||||
|
_ensure_folder_exists(imap_conn, processed_folder)
|
||||||
|
|
||||||
|
status, _ = imap_conn.select(f'"{source_folder}"')
|
||||||
|
if status != "OK":
|
||||||
|
logger.warning(f"Ordner '{source_folder}' konnte nicht geöffnet werden")
|
||||||
|
return {"processed": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
search_criteria = "ALL"
|
||||||
|
if fetch_since:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
dt = datetime.strptime(fetch_since, "%Y-%m-%d")
|
||||||
|
imap_date = dt.strftime("%d-%b-%Y")
|
||||||
|
search_criteria = f'(SINCE {imap_date})'
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"Ungültiges Datum: {fetch_since}, verwende ALL")
|
||||||
|
|
||||||
|
status, data = imap_conn.uid("SEARCH", None, search_criteria)
|
||||||
|
if status != "OK" or not data[0]:
|
||||||
|
logger.info(f"Keine Emails im Ordner '{source_folder}' ({beleg_type})")
|
||||||
|
return {"processed": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
msg_uids = data[0].split()
|
||||||
|
logger.info(f"{len(msg_uids)} Email(s) im Ordner '{source_folder}' ({beleg_type})")
|
||||||
|
|
||||||
|
for msg_uid in msg_uids:
|
||||||
|
subject = "?"
|
||||||
|
from_addr = "?"
|
||||||
|
try:
|
||||||
|
status, msg_data = imap_conn.uid("FETCH", msg_uid, "(RFC822)")
|
||||||
|
if status != "OK":
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_email = msg_data[0][1]
|
||||||
|
msg = email.message_from_bytes(raw_email, policy=policy.default)
|
||||||
|
|
||||||
|
subject = str(msg.get("Subject", "(Kein Betreff)"))
|
||||||
|
from_addr = str(msg.get("From", "(Unbekannt)"))
|
||||||
|
|
||||||
|
attachments = _extract_attachments(msg)
|
||||||
|
|
||||||
|
if not attachments:
|
||||||
|
skipped += 1
|
||||||
|
logger.debug(f"Übersprungen (keine Anhänge): {subject}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
forward_msg = _build_forward_email(
|
||||||
|
from_addr=smtp_from,
|
||||||
|
to_addr=import_email,
|
||||||
|
original_subject=subject,
|
||||||
|
original_from=from_addr,
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
|
||||||
|
smtp_log = _send_with_log(smtp_conn, forward_msg)
|
||||||
|
|
||||||
|
imap_conn.select(f'"{source_folder}"')
|
||||||
|
_move_email(imap_conn, msg_uid, processed_folder)
|
||||||
|
imap_conn.select(f'"{source_folder}"')
|
||||||
|
|
||||||
|
processed += 1
|
||||||
|
logger.info(f"Verarbeitet ({beleg_type}): {subject} ({len(attachments)} Anhänge)")
|
||||||
|
await add_log_entry(
|
||||||
|
email_subject=subject,
|
||||||
|
email_from=from_addr,
|
||||||
|
attachments_count=len(attachments),
|
||||||
|
status="success",
|
||||||
|
sent_to=import_email,
|
||||||
|
smtp_log=smtp_log,
|
||||||
|
beleg_type=beleg_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
logger.error(f"Fehler bei Email UID {msg_uid}: {e}")
|
||||||
|
try:
|
||||||
|
await add_log_entry(
|
||||||
|
email_subject=subject,
|
||||||
|
email_from=from_addr,
|
||||||
|
attachments_count=0,
|
||||||
|
status="error",
|
||||||
|
error_message=str(e),
|
||||||
|
beleg_type=beleg_type,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"processed": processed, "skipped": skipped, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
|
async def process_mailbox() -> dict:
|
||||||
|
settings = await get_settings()
|
||||||
|
|
||||||
|
import_email_eingang = get_import_email(settings, "eingang")
|
||||||
|
if not settings.get("imap_server") or not import_email_eingang:
|
||||||
|
logger.warning("IMAP oder Import-Email nicht konfiguriert")
|
||||||
|
return {"processed": 0, "skipped": 0, "errors": 0, "error": "Nicht konfiguriert"}
|
||||||
|
|
||||||
|
fetch_since = settings.get("fetch_since_date", "")
|
||||||
|
total = {"processed": 0, "skipped": 0, "errors": 0}
|
||||||
imap_conn = None
|
imap_conn = None
|
||||||
smtp_conn = None
|
smtp_conn = None
|
||||||
|
|
||||||
|
|
@ -140,92 +255,31 @@ async def process_mailbox() -> dict:
|
||||||
imap_conn = _connect_imap(settings)
|
imap_conn = _connect_imap(settings)
|
||||||
smtp_conn = _connect_smtp(settings)
|
smtp_conn = _connect_smtp(settings)
|
||||||
|
|
||||||
_ensure_folder_exists(imap_conn, processed_folder)
|
# Eingangsbelege
|
||||||
|
source = settings.get("source_folder", "INBOX")
|
||||||
|
processed_folder = settings.get("processed_folder", "INBOX/Verarbeitet")
|
||||||
|
result = await _process_folder(
|
||||||
|
imap_conn, smtp_conn, settings,
|
||||||
|
source, processed_folder,
|
||||||
|
import_email_eingang, "eingang", fetch_since,
|
||||||
|
)
|
||||||
|
for k in total:
|
||||||
|
total[k] += result[k]
|
||||||
|
|
||||||
status, _ = imap_conn.select(f'"{source_folder}"')
|
# Ausgangsbelege (optional)
|
||||||
if status != "OK":
|
import_email_ausgang = get_import_email(settings, "ausgang")
|
||||||
raise Exception(f"Ordner '{source_folder}' konnte nicht geöffnet werden")
|
source_ausgang = settings.get("source_folder_ausgang", "")
|
||||||
|
processed_ausgang = settings.get("processed_folder_ausgang", "")
|
||||||
# Build IMAP search criteria
|
if import_email_ausgang and source_ausgang:
|
||||||
search_criteria = "ALL"
|
if not processed_ausgang:
|
||||||
fetch_since = settings.get("fetch_since_date", "")
|
processed_ausgang = source_ausgang + "/Verarbeitet"
|
||||||
if fetch_since:
|
result = await _process_folder(
|
||||||
try:
|
imap_conn, smtp_conn, settings,
|
||||||
from datetime import datetime
|
source_ausgang, processed_ausgang,
|
||||||
dt = datetime.strptime(fetch_since, "%Y-%m-%d")
|
import_email_ausgang, "ausgang", fetch_since,
|
||||||
imap_date = dt.strftime("%d-%b-%Y")
|
)
|
||||||
search_criteria = f'(SINCE {imap_date})'
|
for k in total:
|
||||||
except ValueError:
|
total[k] += result[k]
|
||||||
logger.warning(f"Ungültiges Datum: {fetch_since}, verwende ALL")
|
|
||||||
|
|
||||||
status, data = imap_conn.uid("SEARCH", None, search_criteria)
|
|
||||||
if status != "OK" or not data[0]:
|
|
||||||
logger.info("Keine Emails im Ordner gefunden")
|
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
msg_uids = data[0].split()
|
|
||||||
logger.info(f"{len(msg_uids)} Email(s) im Ordner '{source_folder}' gefunden")
|
|
||||||
|
|
||||||
for msg_uid in msg_uids:
|
|
||||||
try:
|
|
||||||
status, msg_data = imap_conn.uid("FETCH", msg_uid, "(RFC822)")
|
|
||||||
if status != "OK":
|
|
||||||
continue
|
|
||||||
|
|
||||||
raw_email = msg_data[0][1]
|
|
||||||
msg = email.message_from_bytes(raw_email, policy=policy.default)
|
|
||||||
|
|
||||||
subject = str(msg.get("Subject", "(Kein Betreff)"))
|
|
||||||
from_addr = str(msg.get("From", "(Unbekannt)"))
|
|
||||||
|
|
||||||
attachments = _extract_attachments(msg)
|
|
||||||
|
|
||||||
if not attachments:
|
|
||||||
skipped += 1
|
|
||||||
logger.debug(f"Übersprungen (keine Anhänge): {subject}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
forward_msg = _build_forward_email(
|
|
||||||
from_addr=smtp_from,
|
|
||||||
to_addr=import_email,
|
|
||||||
original_subject=subject,
|
|
||||||
original_from=from_addr,
|
|
||||||
attachments=attachments,
|
|
||||||
)
|
|
||||||
|
|
||||||
smtp_conn.send_message(forward_msg)
|
|
||||||
|
|
||||||
# Re-select source folder before move (in case _ensure_folder changed it)
|
|
||||||
imap_conn.select(f'"{source_folder}"')
|
|
||||||
_move_email(imap_conn, msg_uid, processed_folder)
|
|
||||||
|
|
||||||
# Re-select after expunge to keep UIDs valid
|
|
||||||
imap_conn.select(f'"{source_folder}"')
|
|
||||||
|
|
||||||
processed += 1
|
|
||||||
logger.info(
|
|
||||||
f"Verarbeitet: {subject} ({len(attachments)} Anhänge)"
|
|
||||||
)
|
|
||||||
await add_log_entry(
|
|
||||||
email_subject=subject,
|
|
||||||
email_from=from_addr,
|
|
||||||
attachments_count=len(attachments),
|
|
||||||
status="success",
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
errors += 1
|
|
||||||
logger.error(f"Fehler bei Email UID {msg_uid}: {e}")
|
|
||||||
try:
|
|
||||||
await add_log_entry(
|
|
||||||
email_subject=subject if "subject" in dir() else "?",
|
|
||||||
email_from=from_addr if "from_addr" in dir() else "?",
|
|
||||||
attachments_count=0,
|
|
||||||
status="error",
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Verbindungsfehler: {e}")
|
logger.error(f"Verbindungsfehler: {e}")
|
||||||
|
|
@ -236,7 +290,7 @@ async def process_mailbox() -> dict:
|
||||||
status="error",
|
status="error",
|
||||||
error_message=f"Verbindungsfehler: {e}",
|
error_message=f"Verbindungsfehler: {e}",
|
||||||
)
|
)
|
||||||
return {"processed": processed, "skipped": skipped, "errors": errors + 1, "error": str(e)}
|
return {**total, "errors": total["errors"] + 1, "error": str(e)}
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if imap_conn:
|
if imap_conn:
|
||||||
|
|
@ -250,36 +304,52 @@ async def process_mailbox() -> dict:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info(
|
logger.info(f"Fertig: {total['processed']} verarbeitet, {total['skipped']} übersprungen, {total['errors']} Fehler")
|
||||||
f"Fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler"
|
return total
|
||||||
)
|
|
||||||
return {"processed": processed, "skipped": skipped, "errors": errors}
|
|
||||||
|
|
||||||
|
|
||||||
async def send_test_email() -> dict:
|
async def send_test_email() -> dict:
|
||||||
settings = await get_settings()
|
settings = await get_settings()
|
||||||
|
|
||||||
if not settings.get("smtp_server") or not settings.get("import_email"):
|
import_email_eingang = get_import_email(settings, "eingang")
|
||||||
return {"success": False, "error": "SMTP oder Import-Email nicht konfiguriert"}
|
import_email_ausgang = get_import_email(settings, "ausgang")
|
||||||
|
|
||||||
|
if not settings.get("smtp_server") or not import_email_eingang:
|
||||||
|
return {"success": False, "error": "SMTP oder Import-Email (Eingang) nicht konfiguriert"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
smtp_conn = _connect_smtp(settings)
|
smtp_conn = _connect_smtp(settings)
|
||||||
|
smtp_logs = []
|
||||||
|
|
||||||
|
# Test Eingangsbelege
|
||||||
msg = MIMEMultipart()
|
msg = MIMEMultipart()
|
||||||
msg["From"] = settings["smtp_username"]
|
msg["From"] = settings["smtp_username"]
|
||||||
msg["To"] = settings["import_email"]
|
msg["To"] = import_email_eingang
|
||||||
msg["Subject"] = "Belegimport - Test-Email"
|
msg["Subject"] = "Belegimport - Test-Email (Eingangsbelege)"
|
||||||
msg.attach(MIMEText(
|
msg.attach(MIMEText(
|
||||||
"Dies ist eine Test-Email vom Belegimport Service.\n"
|
"Dies ist eine Test-Email vom Belegimport Service.\n"
|
||||||
"Wenn Sie diese Email erhalten, funktioniert die SMTP-Verbindung.",
|
"Ziel: Eingangsbelege",
|
||||||
"plain",
|
"plain", "utf-8",
|
||||||
"utf-8",
|
|
||||||
))
|
))
|
||||||
|
smtp_logs.append("=== Eingangsbelege ===")
|
||||||
|
smtp_logs.append(_send_with_log(smtp_conn, msg))
|
||||||
|
|
||||||
|
# Test Ausgangsbelege (if configured)
|
||||||
|
if import_email_ausgang:
|
||||||
|
msg2 = MIMEMultipart()
|
||||||
|
msg2["From"] = settings["smtp_username"]
|
||||||
|
msg2["To"] = import_email_ausgang
|
||||||
|
msg2["Subject"] = "Belegimport - Test-Email (Ausgangsbelege)"
|
||||||
|
msg2.attach(MIMEText(
|
||||||
|
"Dies ist eine Test-Email vom Belegimport Service.\n"
|
||||||
|
"Ziel: Ausgangsbelege",
|
||||||
|
"plain", "utf-8",
|
||||||
|
))
|
||||||
|
smtp_logs.append("=== Ausgangsbelege ===")
|
||||||
|
smtp_logs.append(_send_with_log(smtp_conn, msg2))
|
||||||
|
|
||||||
smtp_conn.send_message(msg)
|
|
||||||
smtp_conn.quit()
|
smtp_conn.quit()
|
||||||
|
return {"success": True, "smtp_log": "\n".join(smtp_logs)}
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Test-Email fehlgeschlagen: {e}")
|
logger.error(f"Test-Email fehlgeschlagen: {e}")
|
||||||
|
|
|
||||||
153
app/main.py
153
app/main.py
|
|
@ -6,19 +6,34 @@ from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Form, UploadFile
|
from fastapi import FastAPI, Request, Form, UploadFile
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
from app.database import init_db, get_settings, save_settings, get_log_entries
|
from app.database import init_db, get_settings, save_settings, get_log_entries, clear_log_entries, reset_downloaded_invoices
|
||||||
from app.mail_processor import process_mailbox, send_test_email, test_imap_connection, create_imap_folder
|
from app.mail_processor import process_mailbox, send_test_email, test_imap_connection, create_imap_folder
|
||||||
from app.scheduler import start_scheduler, configure_job, get_scheduler_status
|
from app.scheduler import start_scheduler, configure_job, get_scheduler_status
|
||||||
from app.scanner import process_scanned_pdf, generate_separator_pdf, UPLOAD_DIR
|
from app.scanner import process_scanned_pdf, generate_separator_pdf, UPLOAD_DIR
|
||||||
from app.smb_processor import process_smb_share, test_smb_connection, create_smb_folder, list_smb_folders
|
from app.smb_processor import process_smb_share, test_smb_connection, create_smb_folder, list_smb_folders
|
||||||
|
from app.amazon_processor import (
|
||||||
|
start_login as amazon_start_login,
|
||||||
|
submit_otp as amazon_submit_otp,
|
||||||
|
get_login_state as amazon_get_login_state,
|
||||||
|
check_session_valid as amazon_check_session,
|
||||||
|
clear_session as amazon_clear_session,
|
||||||
|
process_amazon,
|
||||||
|
start_interactive_login as amazon_start_interactive,
|
||||||
|
get_browser_screenshot as amazon_get_screenshot,
|
||||||
|
send_browser_click as amazon_browser_click,
|
||||||
|
send_browser_type as amazon_browser_type,
|
||||||
|
send_browser_key as amazon_browser_key,
|
||||||
|
close_interactive_login as amazon_close_interactive,
|
||||||
|
is_interactive_login_active as amazon_login_active,
|
||||||
|
)
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO),
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -44,6 +59,11 @@ templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def index(request: Request):
|
async def index(request: Request):
|
||||||
|
return templates.TemplateResponse("scan.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/settings", response_class=HTMLResponse)
|
||||||
|
async def settings_page(request: Request):
|
||||||
settings = await get_settings()
|
settings = await get_settings()
|
||||||
logs = await get_log_entries(limit=20)
|
logs = await get_log_entries(limit=20)
|
||||||
status = get_scheduler_status()
|
status = get_scheduler_status()
|
||||||
|
|
@ -74,8 +94,12 @@ async def _save_form_settings(request: Request) -> dict:
|
||||||
"smtp_username": form.get("smtp_username", ""),
|
"smtp_username": form.get("smtp_username", ""),
|
||||||
"smtp_password": form.get("smtp_password") or current.get("smtp_password", ""),
|
"smtp_password": form.get("smtp_password") or current.get("smtp_password", ""),
|
||||||
"import_email": form.get("import_email", ""),
|
"import_email": form.get("import_email", ""),
|
||||||
|
"import_email_eingang": form.get("import_email_eingang", ""),
|
||||||
|
"import_email_ausgang": form.get("import_email_ausgang", ""),
|
||||||
"source_folder": form.get("source_folder", "Rechnungen"),
|
"source_folder": form.get("source_folder", "Rechnungen"),
|
||||||
"processed_folder": form.get("processed_folder", "Rechnungen/Verarbeitet"),
|
"processed_folder": form.get("processed_folder", "Rechnungen/Verarbeitet"),
|
||||||
|
"source_folder_ausgang": form.get("source_folder_ausgang", ""),
|
||||||
|
"processed_folder_ausgang": form.get("processed_folder_ausgang", ""),
|
||||||
"interval_minutes": form.get("interval_minutes", "5"),
|
"interval_minutes": form.get("interval_minutes", "5"),
|
||||||
"scheduler_enabled": form.get("scheduler_enabled", "false"),
|
"scheduler_enabled": form.get("scheduler_enabled", "false"),
|
||||||
"fetch_since_date": form.get("fetch_since_date", ""),
|
"fetch_since_date": form.get("fetch_since_date", ""),
|
||||||
|
|
@ -89,7 +113,11 @@ async def _save_form_settings(request: Request) -> dict:
|
||||||
"smb_share": form.get("smb_share", ""),
|
"smb_share": form.get("smb_share", ""),
|
||||||
"smb_source_path": form.get("smb_source_path", ""),
|
"smb_source_path": form.get("smb_source_path", ""),
|
||||||
"smb_processed_path": form.get("smb_processed_path", "Verarbeitet"),
|
"smb_processed_path": form.get("smb_processed_path", "Verarbeitet"),
|
||||||
|
"smb_source_path_ausgang": form.get("smb_source_path_ausgang", ""),
|
||||||
|
"smb_processed_path_ausgang": form.get("smb_processed_path_ausgang", ""),
|
||||||
"smb_mode": form.get("smb_mode", "forward"),
|
"smb_mode": form.get("smb_mode", "forward"),
|
||||||
|
# Debug
|
||||||
|
"debug_save_amazon_pdfs": form.get("debug_save_amazon_pdfs", "false"),
|
||||||
}
|
}
|
||||||
|
|
||||||
await save_settings(data)
|
await save_settings(data)
|
||||||
|
|
@ -194,6 +222,12 @@ async def log_page(request: Request):
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/clear-log")
|
||||||
|
async def api_clear_log():
|
||||||
|
count = await clear_log_entries()
|
||||||
|
return JSONResponse({"success": True, "count": count})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
async def api_status():
|
async def api_status():
|
||||||
return get_scheduler_status()
|
return get_scheduler_status()
|
||||||
|
|
@ -247,6 +281,7 @@ async def scan_upload_chunk(
|
||||||
async def scan_process(request: Request):
|
async def scan_process(request: Request):
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
upload_id = body.get("upload_id", "")
|
upload_id = body.get("upload_id", "")
|
||||||
|
beleg_type = body.get("beleg_type", "eingang")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
uuid.UUID(upload_id)
|
uuid.UUID(upload_id)
|
||||||
|
|
@ -269,7 +304,7 @@ async def scan_process(request: Request):
|
||||||
# Process in background task
|
# Process in background task
|
||||||
async def _process():
|
async def _process():
|
||||||
try:
|
try:
|
||||||
result = await process_scanned_pdf(str(pdf_path), progress_callback)
|
result = await process_scanned_pdf(str(pdf_path), progress_callback, beleg_type=beleg_type)
|
||||||
_scan_progress.setdefault(upload_id, []).append({
|
_scan_progress.setdefault(upload_id, []).append({
|
||||||
"stage": "done", "result": result
|
"stage": "done", "result": result
|
||||||
})
|
})
|
||||||
|
|
@ -325,3 +360,113 @@ async def separator_pdf():
|
||||||
media_type="application/pdf",
|
media_type="application/pdf",
|
||||||
headers={"Content-Disposition": "attachment; filename=Trennseite.pdf"},
|
headers={"Content-Disposition": "attachment; filename=Trennseite.pdf"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Plattformen (Amazon) ---
|
||||||
|
|
||||||
|
@app.get("/platforms", response_class=HTMLResponse)
|
||||||
|
async def platforms_page(request: Request):
|
||||||
|
settings = await get_settings()
|
||||||
|
status = get_scheduler_status()
|
||||||
|
return templates.TemplateResponse("platforms.html", {
|
||||||
|
"request": request,
|
||||||
|
"settings": settings,
|
||||||
|
"status": status,
|
||||||
|
"message": None,
|
||||||
|
"message_type": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/amazon-settings")
|
||||||
|
async def api_amazon_settings(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
current = await get_settings()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"amazon_enabled": body.get("amazon_enabled", "false"),
|
||||||
|
"amazon_domain": body.get("amazon_domain", "amazon.de"),
|
||||||
|
"amazon_email": body.get("amazon_email", ""),
|
||||||
|
"amazon_password": body.get("amazon_password") or current.get("amazon_password", ""),
|
||||||
|
"amazon_since_date": body.get("amazon_since_date", ""),
|
||||||
|
}
|
||||||
|
await save_settings(data)
|
||||||
|
return JSONResponse({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/amazon-status")
|
||||||
|
async def api_amazon_status():
|
||||||
|
valid = await amazon_check_session()
|
||||||
|
login_active = amazon_login_active()
|
||||||
|
return JSONResponse({"session_valid": valid, "login_active": login_active})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/amazon-login")
|
||||||
|
async def api_amazon_login():
|
||||||
|
"""Start interactive browser login."""
|
||||||
|
await amazon_start_interactive()
|
||||||
|
return JSONResponse({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/amazon-login-state")
|
||||||
|
async def api_amazon_login_state():
|
||||||
|
return JSONResponse(amazon_get_login_state())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/amazon-browser-screenshot")
|
||||||
|
async def api_amazon_browser_screenshot():
|
||||||
|
img = await amazon_get_screenshot()
|
||||||
|
if img is None:
|
||||||
|
return JSONResponse({"error": "Kein Browser offen"}, status_code=404)
|
||||||
|
return Response(content=img, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/amazon-browser-click")
|
||||||
|
async def api_amazon_browser_click(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
await amazon_browser_click(int(body["x"]), int(body["y"]))
|
||||||
|
return JSONResponse({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/amazon-browser-type")
|
||||||
|
async def api_amazon_browser_type(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
await amazon_browser_type(body["text"])
|
||||||
|
return JSONResponse({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/amazon-browser-key")
|
||||||
|
async def api_amazon_browser_key(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
await amazon_browser_key(body["key"])
|
||||||
|
return JSONResponse({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/amazon-login-close")
|
||||||
|
async def api_amazon_login_close():
|
||||||
|
await amazon_close_interactive()
|
||||||
|
return JSONResponse({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/amazon-otp")
|
||||||
|
async def api_amazon_otp(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
ok = await amazon_submit_otp(body.get("code", ""))
|
||||||
|
return JSONResponse({"success": ok})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/amazon-logout")
|
||||||
|
async def api_amazon_logout():
|
||||||
|
await amazon_clear_session()
|
||||||
|
return JSONResponse({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/amazon-process")
|
||||||
|
async def api_amazon_process():
|
||||||
|
result = await process_amazon()
|
||||||
|
return JSONResponse(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/amazon-reset")
|
||||||
|
async def api_amazon_reset():
|
||||||
|
count = await reset_downloaded_invoices()
|
||||||
|
return JSONResponse({"success": True, "count": count})
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ from pypdf import PdfReader, PdfWriter
|
||||||
import qrcode
|
import qrcode
|
||||||
from qrcode.constants import ERROR_CORRECT_H
|
from qrcode.constants import ERROR_CORRECT_H
|
||||||
|
|
||||||
from app.database import get_settings, add_log_entry
|
from app.database import get_settings, add_log_entry, get_import_email
|
||||||
from app.mail_processor import _connect_smtp, _build_forward_email
|
from app.mail_processor import _connect_smtp, _build_forward_email, _send_with_log
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -84,11 +84,12 @@ def split_pdf(pdf_path: str, separator_pages: list[int]) -> list[bytes]:
|
||||||
return documents
|
return documents
|
||||||
|
|
||||||
|
|
||||||
async def process_scanned_pdf(pdf_path: str, progress_callback=None) -> dict:
|
async def process_scanned_pdf(pdf_path: str, progress_callback=None, beleg_type: str = "eingang") -> dict:
|
||||||
"""Full pipeline: detect separators, split, send each document via email."""
|
"""Full pipeline: detect separators, split, send each document via email."""
|
||||||
settings = await get_settings()
|
settings = await get_settings()
|
||||||
|
|
||||||
if not settings.get("smtp_server") or not settings.get("import_email"):
|
import_email = get_import_email(settings, beleg_type)
|
||||||
|
if not settings.get("smtp_server") or not import_email:
|
||||||
return {"error": "SMTP oder Import-Email nicht konfiguriert", "total_pages": 0, "documents": 0, "sent": 0, "errors": 1}
|
return {"error": "SMTP oder Import-Email nicht konfiguriert", "total_pages": 0, "documents": 0, "sent": 0, "errors": 1}
|
||||||
|
|
||||||
# Step 1: Detect separator pages (CPU-bound, run in thread)
|
# Step 1: Detect separator pages (CPU-bound, run in thread)
|
||||||
|
|
@ -135,12 +136,12 @@ async def process_scanned_pdf(pdf_path: str, progress_callback=None) -> dict:
|
||||||
filename = f"Scan_Dokument_{i + 1}.pdf"
|
filename = f"Scan_Dokument_{i + 1}.pdf"
|
||||||
msg = _build_forward_email(
|
msg = _build_forward_email(
|
||||||
from_addr=settings["smtp_username"],
|
from_addr=settings["smtp_username"],
|
||||||
to_addr=settings["import_email"],
|
to_addr=import_email,
|
||||||
original_subject=f"Scan-Upload Dokument {i + 1}/{len(documents)}",
|
original_subject=f"Scan-Upload Dokument {i + 1}/{len(documents)}",
|
||||||
original_from="Scan-Upload",
|
original_from="Scan-Upload",
|
||||||
attachments=[(filename, doc_bytes)],
|
attachments=[(filename, doc_bytes)],
|
||||||
)
|
)
|
||||||
smtp_conn.send_message(msg)
|
smtp_log = _send_with_log(smtp_conn, msg)
|
||||||
sent += 1
|
sent += 1
|
||||||
|
|
||||||
await add_log_entry(
|
await add_log_entry(
|
||||||
|
|
@ -148,6 +149,9 @@ async def process_scanned_pdf(pdf_path: str, progress_callback=None) -> dict:
|
||||||
email_from="Scan-Upload",
|
email_from="Scan-Upload",
|
||||||
attachments_count=1,
|
attachments_count=1,
|
||||||
status="success",
|
status="success",
|
||||||
|
sent_to=import_email,
|
||||||
|
smtp_log=smtp_log,
|
||||||
|
beleg_type=beleg_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -159,6 +163,8 @@ async def process_scanned_pdf(pdf_path: str, progress_callback=None) -> dict:
|
||||||
attachments_count=1,
|
attachments_count=1,
|
||||||
status="error",
|
status="error",
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
|
sent_to=import_email,
|
||||||
|
beleg_type=beleg_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
|
||||||
from app.mail_processor import process_mailbox
|
from app.mail_processor import process_mailbox
|
||||||
from app.smb_processor import process_smb_share
|
from app.smb_processor import process_smb_share
|
||||||
|
from app.amazon_processor import process_amazon
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -21,6 +22,7 @@ async def _run_processor():
|
||||||
return
|
return
|
||||||
_is_processing = True
|
_is_processing = True
|
||||||
try:
|
try:
|
||||||
|
# Email and SMB first - these are fast and must not be blocked by Amazon
|
||||||
logger.info("Starte automatische Email-Verarbeitung...")
|
logger.info("Starte automatische Email-Verarbeitung...")
|
||||||
result = await process_mailbox()
|
result = await process_mailbox()
|
||||||
logger.info(f"Email-Verarbeitung abgeschlossen: {result}")
|
logger.info(f"Email-Verarbeitung abgeschlossen: {result}")
|
||||||
|
|
@ -28,6 +30,16 @@ async def _run_processor():
|
||||||
logger.info("Starte automatische SMB-Verarbeitung...")
|
logger.info("Starte automatische SMB-Verarbeitung...")
|
||||||
smb_result = await process_smb_share()
|
smb_result = await process_smb_share()
|
||||||
logger.info(f"SMB-Verarbeitung abgeschlossen: {smb_result}")
|
logger.info(f"SMB-Verarbeitung abgeschlossen: {smb_result}")
|
||||||
|
|
||||||
|
# Amazon separately with timeout - must not block next scheduler runs
|
||||||
|
logger.info("Starte automatische Amazon-Verarbeitung...")
|
||||||
|
try:
|
||||||
|
amazon_result = await asyncio.wait_for(process_amazon(), timeout=300)
|
||||||
|
logger.info(f"Amazon-Verarbeitung abgeschlossen: {amazon_result}")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error("Amazon-Verarbeitung nach 5 Minuten abgebrochen (Timeout)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fehler bei Amazon-Verarbeitung: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Fehler bei automatischer Verarbeitung: {e}")
|
logger.error(f"Fehler bei automatischer Verarbeitung: {e}")
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import tempfile
|
||||||
|
|
||||||
import smbclient
|
import smbclient
|
||||||
|
|
||||||
from app.database import get_settings, add_log_entry
|
from app.database import get_settings, add_log_entry, get_import_email
|
||||||
from app.mail_processor import _connect_smtp, _build_forward_email
|
from app.mail_processor import _connect_smtp, _build_forward_email, _send_with_log
|
||||||
from app.scanner import detect_separator_pages, split_pdf
|
from app.scanner import detect_separator_pages, split_pdf
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -114,6 +114,119 @@ def _list_smb_folders_recursive(
|
||||||
return folders
|
return folders
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_smb_folder(
|
||||||
|
smtp_conn, settings: dict, base_path: str,
|
||||||
|
source_rel: str, processed_rel: str,
|
||||||
|
import_email: str, beleg_type: str, mode: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Process one SMB folder pair. Returns counts dict."""
|
||||||
|
smtp_from = settings.get("smtp_username", "")
|
||||||
|
processed = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
source_path = _smb_unc_path(base_path, source_rel)
|
||||||
|
processed_path = _smb_unc_path(base_path, processed_rel)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_ensure_smb_folder, processed_path)
|
||||||
|
|
||||||
|
pdf_files = await asyncio.to_thread(_list_pdf_files, source_path)
|
||||||
|
if not pdf_files:
|
||||||
|
logger.info(f"Keine PDF-Dateien im SMB-Ordner '{source_rel}' ({beleg_type})")
|
||||||
|
return {"processed": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
logger.info(f"{len(pdf_files)} PDF-Datei(en) im SMB-Ordner '{source_rel}' ({beleg_type})")
|
||||||
|
|
||||||
|
for filename in pdf_files:
|
||||||
|
file_path = _smb_unc_path(source_path, filename)
|
||||||
|
try:
|
||||||
|
pdf_data = await asyncio.to_thread(_read_smb_file, file_path)
|
||||||
|
|
||||||
|
if mode == "separator":
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
||||||
|
tmp.write(pdf_data)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
try:
|
||||||
|
separator_pages = await asyncio.to_thread(
|
||||||
|
detect_separator_pages, tmp_path, None
|
||||||
|
)
|
||||||
|
documents = await asyncio.to_thread(
|
||||||
|
split_pdf, tmp_path, separator_pages
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
if not documents:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
smtp_log_parts = []
|
||||||
|
for i, doc_bytes in enumerate(documents):
|
||||||
|
doc_filename = f"{os.path.splitext(filename)[0]}_Teil_{i + 1}.pdf"
|
||||||
|
subject = f"SMB-Import: {filename} (Dokument {i + 1}/{len(documents)})"
|
||||||
|
msg = _build_forward_email(
|
||||||
|
from_addr=smtp_from,
|
||||||
|
to_addr=import_email,
|
||||||
|
original_subject=subject,
|
||||||
|
original_from="SMB-Import",
|
||||||
|
attachments=[(doc_filename, doc_bytes)],
|
||||||
|
)
|
||||||
|
smtp_log_parts.append(_send_with_log(smtp_conn, msg))
|
||||||
|
|
||||||
|
await add_log_entry(
|
||||||
|
email_subject=f"SMB: {filename}",
|
||||||
|
email_from="SMB-Import",
|
||||||
|
attachments_count=len(documents),
|
||||||
|
status="success",
|
||||||
|
sent_to=import_email,
|
||||||
|
smtp_log="\n---\n".join(smtp_log_parts),
|
||||||
|
beleg_type=beleg_type,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"SMB verarbeitet ({beleg_type}): {filename} -> {len(documents)} Dokument(e)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = _build_forward_email(
|
||||||
|
from_addr=smtp_from,
|
||||||
|
to_addr=import_email,
|
||||||
|
original_subject=f"SMB-Import: {filename}",
|
||||||
|
original_from="SMB-Import",
|
||||||
|
attachments=[(filename, pdf_data)],
|
||||||
|
)
|
||||||
|
smtp_log = _send_with_log(smtp_conn, msg)
|
||||||
|
|
||||||
|
await add_log_entry(
|
||||||
|
email_subject=f"SMB: {filename}",
|
||||||
|
email_from="SMB-Import",
|
||||||
|
attachments_count=1,
|
||||||
|
status="success",
|
||||||
|
sent_to=import_email,
|
||||||
|
smtp_log=smtp_log,
|
||||||
|
beleg_type=beleg_type,
|
||||||
|
)
|
||||||
|
logger.info(f"SMB verarbeitet ({beleg_type}): {filename}")
|
||||||
|
|
||||||
|
await asyncio.to_thread(_move_smb_file, file_path, processed_path, filename)
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
logger.error(f"Fehler bei SMB-Datei {filename}: {e}")
|
||||||
|
try:
|
||||||
|
await add_log_entry(
|
||||||
|
email_subject=f"SMB: {filename}",
|
||||||
|
email_from="SMB-Import",
|
||||||
|
attachments_count=0,
|
||||||
|
status="error",
|
||||||
|
error_message=str(e),
|
||||||
|
beleg_type=beleg_type,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"processed": processed, "skipped": skipped, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
async def process_smb_share() -> dict:
|
async def process_smb_share() -> dict:
|
||||||
"""Process PDF files from SMB share - main pipeline."""
|
"""Process PDF files from SMB share - main pipeline."""
|
||||||
settings = await get_settings()
|
settings = await get_settings()
|
||||||
|
|
@ -124,113 +237,43 @@ async def process_smb_share() -> dict:
|
||||||
if not settings.get("smb_server") or not settings.get("smb_share"):
|
if not settings.get("smb_server") or not settings.get("smb_share"):
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0, "error": "SMB nicht konfiguriert"}
|
return {"processed": 0, "skipped": 0, "errors": 0, "error": "SMB nicht konfiguriert"}
|
||||||
|
|
||||||
if not settings.get("import_email"):
|
import_email_eingang = get_import_email(settings, "eingang")
|
||||||
|
if not import_email_eingang:
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0, "error": "Import-Email nicht konfiguriert"}
|
return {"processed": 0, "skipped": 0, "errors": 0, "error": "Import-Email nicht konfiguriert"}
|
||||||
|
|
||||||
mode = settings.get("smb_mode", "forward")
|
mode = settings.get("smb_mode", "forward")
|
||||||
smtp_from = settings.get("smtp_username", "")
|
total = {"processed": 0, "skipped": 0, "errors": 0}
|
||||||
import_email = settings["import_email"]
|
|
||||||
|
|
||||||
processed = 0
|
|
||||||
skipped = 0
|
|
||||||
errors = 0
|
|
||||||
smtp_conn = None
|
smtp_conn = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
base_path = await asyncio.to_thread(_smb_register_session, settings)
|
base_path = await asyncio.to_thread(_smb_register_session, settings)
|
||||||
source_path = _smb_unc_path(base_path, settings.get("smb_source_path", ""))
|
|
||||||
processed_path = _smb_unc_path(base_path, settings.get("smb_processed_path", "Verarbeitet"))
|
|
||||||
|
|
||||||
await asyncio.to_thread(_ensure_smb_folder, processed_path)
|
|
||||||
|
|
||||||
pdf_files = await asyncio.to_thread(_list_pdf_files, source_path)
|
|
||||||
if not pdf_files:
|
|
||||||
logger.info("Keine PDF-Dateien im SMB-Ordner gefunden")
|
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
logger.info(f"{len(pdf_files)} PDF-Datei(en) im SMB-Ordner gefunden")
|
|
||||||
|
|
||||||
smtp_conn = _connect_smtp(settings)
|
smtp_conn = _connect_smtp(settings)
|
||||||
|
|
||||||
for filename in pdf_files:
|
# Eingangsbelege
|
||||||
file_path = _smb_unc_path(source_path, filename)
|
source = settings.get("smb_source_path", "")
|
||||||
try:
|
processed_rel = settings.get("smb_processed_path", "Verarbeitet")
|
||||||
pdf_data = await asyncio.to_thread(_read_smb_file, file_path)
|
result = await _process_smb_folder(
|
||||||
|
smtp_conn, settings, base_path,
|
||||||
|
source, processed_rel,
|
||||||
|
import_email_eingang, "eingang", mode,
|
||||||
|
)
|
||||||
|
for k in total:
|
||||||
|
total[k] += result[k]
|
||||||
|
|
||||||
if mode == "separator":
|
# Ausgangsbelege (optional)
|
||||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
import_email_ausgang = get_import_email(settings, "ausgang")
|
||||||
tmp.write(pdf_data)
|
source_ausgang = settings.get("smb_source_path_ausgang", "")
|
||||||
tmp_path = tmp.name
|
processed_ausgang = settings.get("smb_processed_path_ausgang", "")
|
||||||
try:
|
if import_email_ausgang and source_ausgang:
|
||||||
separator_pages = await asyncio.to_thread(
|
if not processed_ausgang:
|
||||||
detect_separator_pages, tmp_path, None
|
processed_ausgang = source_ausgang + "/Verarbeitet"
|
||||||
)
|
result = await _process_smb_folder(
|
||||||
documents = await asyncio.to_thread(
|
smtp_conn, settings, base_path,
|
||||||
split_pdf, tmp_path, separator_pages
|
source_ausgang, processed_ausgang,
|
||||||
)
|
import_email_ausgang, "ausgang", mode,
|
||||||
finally:
|
)
|
||||||
os.unlink(tmp_path)
|
for k in total:
|
||||||
|
total[k] += result[k]
|
||||||
if not documents:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
for i, doc_bytes in enumerate(documents):
|
|
||||||
doc_filename = f"{os.path.splitext(filename)[0]}_Teil_{i + 1}.pdf"
|
|
||||||
subject = f"SMB-Import: {filename} (Dokument {i + 1}/{len(documents)})"
|
|
||||||
msg = _build_forward_email(
|
|
||||||
from_addr=smtp_from,
|
|
||||||
to_addr=import_email,
|
|
||||||
original_subject=subject,
|
|
||||||
original_from="SMB-Import",
|
|
||||||
attachments=[(doc_filename, doc_bytes)],
|
|
||||||
)
|
|
||||||
smtp_conn.send_message(msg)
|
|
||||||
|
|
||||||
await add_log_entry(
|
|
||||||
email_subject=f"SMB: {filename}",
|
|
||||||
email_from="SMB-Import",
|
|
||||||
attachments_count=len(documents),
|
|
||||||
status="success",
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"SMB verarbeitet: {filename} -> {len(documents)} Dokument(e) "
|
|
||||||
f"({len(separator_pages)} Trennseite(n))"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
msg = _build_forward_email(
|
|
||||||
from_addr=smtp_from,
|
|
||||||
to_addr=import_email,
|
|
||||||
original_subject=f"SMB-Import: {filename}",
|
|
||||||
original_from="SMB-Import",
|
|
||||||
attachments=[(filename, pdf_data)],
|
|
||||||
)
|
|
||||||
smtp_conn.send_message(msg)
|
|
||||||
|
|
||||||
await add_log_entry(
|
|
||||||
email_subject=f"SMB: {filename}",
|
|
||||||
email_from="SMB-Import",
|
|
||||||
attachments_count=1,
|
|
||||||
status="success",
|
|
||||||
)
|
|
||||||
logger.info(f"SMB verarbeitet: {filename}")
|
|
||||||
|
|
||||||
await asyncio.to_thread(_move_smb_file, file_path, processed_path, filename)
|
|
||||||
processed += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
errors += 1
|
|
||||||
logger.error(f"Fehler bei SMB-Datei {filename}: {e}")
|
|
||||||
try:
|
|
||||||
await add_log_entry(
|
|
||||||
email_subject=f"SMB: {filename}",
|
|
||||||
email_from="SMB-Import",
|
|
||||||
attachments_count=0,
|
|
||||||
status="error",
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"SMB-Verbindungsfehler: {e}")
|
logger.error(f"SMB-Verbindungsfehler: {e}")
|
||||||
|
|
@ -244,7 +287,7 @@ async def process_smb_share() -> dict:
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {"processed": processed, "skipped": skipped, "errors": errors + 1, "error": str(e)}
|
return {**total, "errors": total["errors"] + 1, "error": str(e)}
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if smtp_conn:
|
if smtp_conn:
|
||||||
|
|
@ -253,8 +296,8 @@ async def process_smb_share() -> dict:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info(f"SMB fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler")
|
logger.info(f"SMB fertig: {total['processed']} verarbeitet, {total['skipped']} übersprungen, {total['errors']} Fehler")
|
||||||
return {"processed": processed, "skipped": skipped, "errors": errors}
|
return total
|
||||||
|
|
||||||
|
|
||||||
async def test_smb_connection() -> dict:
|
async def test_smb_connection() -> dict:
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,11 @@ main {
|
||||||
color: #856404;
|
color: #856404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
background: #d1ecf1;
|
||||||
|
color: #0c5460;
|
||||||
|
}
|
||||||
|
|
||||||
.badge-inactive {
|
.badge-inactive {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
|
@ -236,6 +241,16 @@ main {
|
||||||
border: 1px solid #bee5eb;
|
border: 1px solid #bee5eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Allow cards with tables to scroll horizontally */
|
||||||
|
.card-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wider main container for pages with large tables */
|
||||||
|
.main-wide {
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,9 @@
|
||||||
<nav>
|
<nav>
|
||||||
<div class="nav-brand">Belegimport</div>
|
<div class="nav-brand">Belegimport</div>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="/" class="{% if active_page == 'settings' %}active{% endif %}">Einstellungen</a>
|
<a href="/" class="{% if active_page == 'scan' %}active{% endif %}">Scan-Upload</a>
|
||||||
<a href="/scan" class="{% if active_page == 'scan' %}active{% endif %}">Scan-Upload</a>
|
<a href="/settings" class="{% if active_page == 'settings' %}active{% endif %}">Einstellungen</a>
|
||||||
|
<a href="/platforms" class="{% if active_page == 'platforms' %}active{% endif %}">Plattformen</a>
|
||||||
<a href="/log" class="{% if active_page == 'log' %}active{% endif %}">Verarbeitungslog</a>
|
<a href="/log" class="{% if active_page == 'log' %}active{% endif %}">Verarbeitungslog</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-status">
|
<div class="nav-status">
|
||||||
|
|
@ -29,7 +30,7 @@
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main>
|
<main class="{% if main_class is defined %}{{ main_class }}{% endif %}">
|
||||||
{% if message %}
|
{% if message %}
|
||||||
<div class="alert alert-{{ message_type or 'info' }}">
|
<div class="alert alert-{{ message_type or 'info' }}">
|
||||||
{{ message }}
|
{{ message }}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,30 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% set active_page = "log" %}
|
{% set active_page = "log" %}
|
||||||
|
{% set main_class = "main-wide" %}
|
||||||
{% set message = None %}
|
{% set message = None %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card">
|
<div class="card card-table">
|
||||||
<h2>Verarbeitungslog</h2>
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;padding-bottom:0.5rem;border-bottom:1px solid var(--border);">
|
||||||
|
<h2 style="margin:0;border:none;padding:0;">Verarbeitungslog</h2>
|
||||||
|
{% if logs %}
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" onclick="clearLog()">Log leeren</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% if logs %}
|
{% if logs %}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Zeitpunkt</th>
|
<th>Zeitpunkt</th>
|
||||||
|
<th>Art</th>
|
||||||
<th>Betreff</th>
|
<th>Betreff</th>
|
||||||
<th>Absender</th>
|
<th>Absender</th>
|
||||||
<th>Anhänge</th>
|
<th>Anhänge</th>
|
||||||
|
<th>Gesendet an</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Fehlermeldung</th>
|
<th>Fehlermeldung</th>
|
||||||
|
<th>SMTP</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -23,9 +32,17 @@
|
||||||
<tr class="{% if log.status == 'error' %}row-error{% endif %}">
|
<tr class="{% if log.status == 'error' %}row-error{% endif %}">
|
||||||
<td>{{ log.id }}</td>
|
<td>{{ log.id }}</td>
|
||||||
<td>{{ log.timestamp }}</td>
|
<td>{{ log.timestamp }}</td>
|
||||||
|
<td>
|
||||||
|
{% if log.get('beleg_type', 'eingang') == 'ausgang' %}
|
||||||
|
<span class="badge badge-warning">Ausgang</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-info">Eingang</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ log.email_subject or '-' }}</td>
|
<td>{{ log.email_subject or '-' }}</td>
|
||||||
<td>{{ log.email_from or '-' }}</td>
|
<td>{{ log.email_from or '-' }}</td>
|
||||||
<td>{{ log.attachments_count }}</td>
|
<td>{{ log.attachments_count }}</td>
|
||||||
|
<td>{{ log.sent_to or '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if log.status == 'success' %}
|
{% if log.status == 'success' %}
|
||||||
<span class="badge badge-success">OK</span>
|
<span class="badge badge-success">OK</span>
|
||||||
|
|
@ -34,6 +51,12 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ log.error_message or '-' }}</td>
|
<td>{{ log.error_message or '-' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if log.smtp_log %}
|
||||||
|
<button type="button" class="btn btn-small btn-secondary" onclick="showSmtpLog({{ log.id }})">Anzeigen</button>
|
||||||
|
<script>window._smtpLogs = window._smtpLogs || {}; window._smtpLogs[{{ log.id }}] = {{ log.smtp_log | tojson }};</script>
|
||||||
|
{% else %}-{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -42,4 +65,54 @@
|
||||||
<p class="text-muted">Noch keine Einträge vorhanden.</p>
|
<p class="text-muted">Noch keine Einträge vorhanden.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- SMTP Log Modal -->
|
||||||
|
<div id="smtpModal" class="modal-overlay" style="display:none;" onclick="if(event.target===this)closeSmtpModal()">
|
||||||
|
<div class="modal" style="max-width:700px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>SMTP-Protokoll</h3>
|
||||||
|
<button type="button" class="modal-close" onclick="closeSmtpModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding:1.25rem;background:var(--bg,#f5f5f5);">
|
||||||
|
<pre id="smtpModalBody" style="margin:0;font-size:0.8rem;white-space:pre-wrap;word-break:break-all;"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.btn-small {
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showSmtpLog(logId) {
|
||||||
|
const log = window._smtpLogs && window._smtpLogs[logId];
|
||||||
|
if (!log) return;
|
||||||
|
document.getElementById('smtpModalBody').textContent = log;
|
||||||
|
document.getElementById('smtpModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSmtpModal() {
|
||||||
|
document.getElementById('smtpModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') closeSmtpModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function clearLog() {
|
||||||
|
if (!confirm('Verarbeitungslog wirklich leeren?')) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/clear-log', {method: 'POST'});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fehler: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,363 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "platforms" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert alert-{{ message_type or 'info' }}">{{ message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Amazon Business - Einstellungen</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="amazon_enabled">Status</label>
|
||||||
|
<select id="amazon_enabled" name="amazon_enabled">
|
||||||
|
<option value="false" {% if settings.get('amazon_enabled') != 'true' %}selected{% endif %}>Deaktiviert</option>
|
||||||
|
<option value="true" {% if settings.get('amazon_enabled') == 'true' %}selected{% endif %}>Aktiviert</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="amazon_domain">Amazon-Domain</label>
|
||||||
|
<select id="amazon_domain" name="amazon_domain">
|
||||||
|
<option value="amazon.de" {% if settings.get('amazon_domain') == 'amazon.de' %}selected{% endif %}>amazon.de</option>
|
||||||
|
<option value="amazon.at" {% if settings.get('amazon_domain') == 'amazon.at' %}selected{% endif %}>amazon.at</option>
|
||||||
|
<option value="amazon.com" {% if settings.get('amazon_domain') == 'amazon.com' %}selected{% endif %}>amazon.com</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="amazon_email">Amazon E-Mail</label>
|
||||||
|
<input type="email" id="amazon_email" name="amazon_email"
|
||||||
|
value="{{ settings.get('amazon_email', '') }}"
|
||||||
|
placeholder="email@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="amazon_password">Amazon Passwort</label>
|
||||||
|
<input type="password" id="amazon_password" name="amazon_password"
|
||||||
|
placeholder="{% if settings.get('amazon_password') %}(gespeichert){% else %}Passwort eingeben{% endif %}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="amazon_since_date">Rechnungen ab Datum</label>
|
||||||
|
<input type="date" id="amazon_since_date" name="amazon_since_date"
|
||||||
|
value="{{ settings.get('amazon_since_date', '') }}">
|
||||||
|
<small class="text-muted">Leer = letzte 30 Tage</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="align-self:end;">
|
||||||
|
{% if settings.get('amazon_last_sync') %}
|
||||||
|
<small class="text-muted">Letzter Abruf: {{ settings.get('amazon_last_sync') }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions" style="margin-top:1rem;">
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveAmazonSettings()">Einstellungen speichern</button>
|
||||||
|
</div>
|
||||||
|
<div id="settingsMsg" style="margin-top:0.75rem;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Anmeldung & Abruf</h2>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;">
|
||||||
|
<span>Session:</span>
|
||||||
|
<span id="sessionBadge" class="badge badge-inactive">Wird geprüft...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;">
|
||||||
|
<button type="button" id="btnLogin" class="btn btn-primary" onclick="doLogin()">Bei Amazon anmelden</button>
|
||||||
|
<button type="button" id="btnLogout" class="btn btn-secondary" onclick="doLogout()" style="display:none;">Session löschen</button>
|
||||||
|
<button type="button" id="btnProcess" class="btn btn-success" onclick="doProcess()" style="display:none;">Jetzt Rechnungen abrufen</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="doReset()">Importierte zurücksetzen</button>
|
||||||
|
</div>
|
||||||
|
<div id="processMsg" style="margin-top:0.75rem;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Interactive Browser Modal -->
|
||||||
|
<div id="browserModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:1000;overflow:auto;">
|
||||||
|
<div style="max-width:1340px;margin:2rem auto;background:var(--card-bg);border-radius:8px;padding:1.5rem;position:relative;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||||
|
<h3 style="margin:0;">Amazon Login - Interaktiver Browser</h3>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeBrowser()" style="padding:0.25rem 0.75rem;">Schließen</button>
|
||||||
|
</div>
|
||||||
|
<div id="browserInfo" class="alert alert-info" style="margin-bottom:1rem;">
|
||||||
|
Klicken und tippen Sie im Browser-Bild, um sich bei Amazon anzumelden. CAPTCHAs und 2FA können Sie direkt lösen.
|
||||||
|
</div>
|
||||||
|
<div style="position:relative;display:inline-block;border:1px solid var(--border-color);cursor:crosshair;">
|
||||||
|
<img id="browserImg" src="" alt="Browser" style="display:block;max-width:100%;height:auto;"
|
||||||
|
onclick="onBrowserClick(event)">
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:1rem;display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
||||||
|
<input type="text" id="browserInput" placeholder="Text eingeben und Enter drücken..."
|
||||||
|
style="flex:1;min-width:200px;padding:0.5rem;border:1px solid var(--border-color);border-radius:4px;background:var(--input-bg);color:var(--text-color);"
|
||||||
|
onkeydown="onBrowserKeydown(event)">
|
||||||
|
<button type="button" class="btn btn-primary" onclick="sendBrowserText()">Senden</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="sendKey('Tab')">Tab</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="sendKey('Escape')">Esc</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="sendKey('Backspace')">⌫</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- Settings ---
|
||||||
|
async function saveAmazonSettings() {
|
||||||
|
const btn = event.target;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Speichert...';
|
||||||
|
const msgEl = document.getElementById('settingsMsg');
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
amazon_enabled: document.getElementById('amazon_enabled').value,
|
||||||
|
amazon_domain: document.getElementById('amazon_domain').value,
|
||||||
|
amazon_email: document.getElementById('amazon_email').value,
|
||||||
|
amazon_password: document.getElementById('amazon_password').value,
|
||||||
|
amazon_since_date: document.getElementById('amazon_since_date').value,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/amazon-settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
if (result.success) {
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-success">Einstellungen gespeichert</div>';
|
||||||
|
if (data.amazon_password) {
|
||||||
|
document.getElementById('amazon_password').value = '';
|
||||||
|
document.getElementById('amazon_password').placeholder = '(gespeichert)';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-error">' + escapeHtml(result.error || 'Fehler') + '</div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-error">Verbindungsfehler</div>';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Einstellungen speichern';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session Status ---
|
||||||
|
async function checkSession() {
|
||||||
|
const badge = document.getElementById('sessionBadge');
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/amazon-status');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.login_active) {
|
||||||
|
badge.className = 'badge badge-warning';
|
||||||
|
badge.textContent = 'Login läuft...';
|
||||||
|
document.getElementById('btnLogout').style.display = 'none';
|
||||||
|
document.getElementById('btnProcess').style.display = 'none';
|
||||||
|
} else if (data.session_valid) {
|
||||||
|
badge.className = 'badge badge-success';
|
||||||
|
badge.textContent = 'Angemeldet';
|
||||||
|
document.getElementById('btnLogout').style.display = '';
|
||||||
|
document.getElementById('btnProcess').style.display = '';
|
||||||
|
} else {
|
||||||
|
badge.className = 'badge badge-inactive';
|
||||||
|
badge.textContent = 'Nicht angemeldet';
|
||||||
|
document.getElementById('btnLogout').style.display = 'none';
|
||||||
|
document.getElementById('btnProcess').style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
badge.className = 'badge badge-inactive';
|
||||||
|
badge.textContent = 'Unbekannt';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Interactive Browser Login ---
|
||||||
|
let screenshotInterval = null;
|
||||||
|
let loginPollInterval = null;
|
||||||
|
|
||||||
|
async function doLogin() {
|
||||||
|
const msgEl = document.getElementById('processMsg');
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-info">Browser wird gestartet...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/amazon-login', {method: 'POST'});
|
||||||
|
// Open browser modal
|
||||||
|
document.getElementById('browserModal').style.display = '';
|
||||||
|
msgEl.innerHTML = '';
|
||||||
|
startScreenshotPolling();
|
||||||
|
startLoginStatePolling();
|
||||||
|
} catch (e) {
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-error">Browser konnte nicht gestartet werden</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startScreenshotPolling() {
|
||||||
|
if (screenshotInterval) clearInterval(screenshotInterval);
|
||||||
|
refreshScreenshot();
|
||||||
|
screenshotInterval = setInterval(refreshScreenshot, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshScreenshot() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/amazon-browser-screenshot');
|
||||||
|
if (resp.ok) {
|
||||||
|
const blob = await resp.blob();
|
||||||
|
document.getElementById('browserImg').src = URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLoginStatePolling() {
|
||||||
|
if (loginPollInterval) clearInterval(loginPollInterval);
|
||||||
|
loginPollInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/amazon-login-state');
|
||||||
|
const data = await resp.json();
|
||||||
|
const info = document.getElementById('browserInfo');
|
||||||
|
|
||||||
|
if (data.status === 'logged_in') {
|
||||||
|
info.className = 'alert alert-success';
|
||||||
|
info.textContent = 'Erfolgreich angemeldet! Sie können das Fenster schließen.';
|
||||||
|
} else if (data.status === 'login_failed') {
|
||||||
|
info.className = 'alert alert-error';
|
||||||
|
info.textContent = data.message || 'Login fehlgeschlagen';
|
||||||
|
} else if (data.status === 'interactive') {
|
||||||
|
info.className = 'alert alert-info';
|
||||||
|
info.textContent = data.message || 'Bitte im Browser anmelden...';
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBrowserClick(event) {
|
||||||
|
const img = event.target;
|
||||||
|
const rect = img.getBoundingClientRect();
|
||||||
|
// Scale click coordinates to actual browser viewport (1280x800)
|
||||||
|
const scaleX = 1280 / img.clientWidth;
|
||||||
|
const scaleY = 800 / img.clientHeight;
|
||||||
|
const x = Math.round((event.clientX - rect.left) * scaleX);
|
||||||
|
const y = Math.round((event.clientY - rect.top) * scaleY);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/amazon-browser-click', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({x, y}),
|
||||||
|
});
|
||||||
|
setTimeout(refreshScreenshot, 500);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendBrowserText() {
|
||||||
|
const input = document.getElementById('browserInput');
|
||||||
|
const text = input.value;
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/amazon-browser-type', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({text}),
|
||||||
|
});
|
||||||
|
input.value = '';
|
||||||
|
setTimeout(refreshScreenshot, 500);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendKey(key) {
|
||||||
|
try {
|
||||||
|
await fetch('/api/amazon-browser-key', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({key}),
|
||||||
|
});
|
||||||
|
setTimeout(refreshScreenshot, 500);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBrowserKeydown(event) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
sendBrowserText();
|
||||||
|
// Also send Enter to the browser
|
||||||
|
sendKey('Enter');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeBrowser() {
|
||||||
|
if (screenshotInterval) { clearInterval(screenshotInterval); screenshotInterval = null; }
|
||||||
|
if (loginPollInterval) { clearInterval(loginPollInterval); loginPollInterval = null; }
|
||||||
|
document.getElementById('browserModal').style.display = 'none';
|
||||||
|
try {
|
||||||
|
await fetch('/api/amazon-login-close', {method: 'POST'});
|
||||||
|
} catch (e) {}
|
||||||
|
checkSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Logout ---
|
||||||
|
async function doLogout() {
|
||||||
|
if (!confirm('Amazon-Session wirklich löschen? Sie müssen sich danach neu anmelden.')) return;
|
||||||
|
const btn = document.getElementById('btnLogout');
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await fetch('/api/amazon-logout', {method: 'POST'});
|
||||||
|
document.getElementById('processMsg').innerHTML = '<div class="alert alert-info">Session gelöscht</div>';
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('processMsg').innerHTML = '<div class="alert alert-error">Fehler</div>';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
checkSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Process ---
|
||||||
|
async function doProcess() {
|
||||||
|
const btn = document.getElementById('btnProcess');
|
||||||
|
const msgEl = document.getElementById('processMsg');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Rechnungen werden abgerufen...';
|
||||||
|
msgEl.innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/amazon-process', {method: 'POST'});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) {
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-error">' + escapeHtml(data.error) + '</div>';
|
||||||
|
} else if (data.processed > 0 || data.errors > 0) {
|
||||||
|
let msg = data.processed + ' Rechnung(en) importiert';
|
||||||
|
if (data.skipped > 0) msg += ', ' + data.skipped + ' übersprungen';
|
||||||
|
if (data.errors > 0) msg += ', ' + data.errors + ' Fehler';
|
||||||
|
const cls = data.errors > 0 ? 'warning' : 'success';
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-' + cls + '">' + escapeHtml(msg) + '</div>';
|
||||||
|
} else {
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-info">Keine neuen Rechnungen gefunden</div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-error">Verbindungsfehler</div>';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Jetzt Rechnungen abrufen';
|
||||||
|
checkSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reset ---
|
||||||
|
async function doReset() {
|
||||||
|
if (!confirm('Alle als importiert markierten Rechnungen zurücksetzen? Beim nächsten Abruf werden alle Rechnungen erneut heruntergeladen und gesendet.')) return;
|
||||||
|
const msgEl = document.getElementById('processMsg');
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/amazon-reset', {method: 'POST'});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-info">' + data.count + ' Bestellung(en) zurückgesetzt</div>';
|
||||||
|
} else {
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-error">' + escapeHtml(data.error || 'Fehler') + '</div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-error">Verbindungsfehler</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = str;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
checkSession();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -8,6 +8,13 @@
|
||||||
Mehrseitige PDF hochladen. Trennseiten mit QR-Code werden automatisch erkannt und die einzelnen Dokumente gesendet.
|
Mehrseitige PDF hochladen. Trennseiten mit QR-Code werden automatisch erkannt und die einzelnen Dokumente gesendet.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Belegart -->
|
||||||
|
<div style="margin-bottom:1rem;display:flex;gap:1rem;align-items:center;">
|
||||||
|
<label style="margin:0;font-weight:600;">Belegart:</label>
|
||||||
|
<label style="margin:0;cursor:pointer;"><input type="radio" name="beleg_type" value="eingang" checked> Eingangsbeleg (Einkauf)</label>
|
||||||
|
<label style="margin:0;cursor:pointer;"><input type="radio" name="beleg_type" value="ausgang"> Ausgangsbeleg (Verkauf/Gutschrift)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Upload Zone -->
|
<!-- Upload Zone -->
|
||||||
<div id="uploadZone" class="upload-zone" onclick="document.getElementById('fileInput').click()">
|
<div id="uploadZone" class="upload-zone" onclick="document.getElementById('fileInput').click()">
|
||||||
<div class="upload-icon">📄</div>
|
<div class="upload-icon">📄</div>
|
||||||
|
|
@ -175,7 +182,7 @@ async function startProcessing(uploadId) {
|
||||||
const resp = await fetch('/api/scan-process', {
|
const resp = await fetch('/api/scan-process', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ upload_id: uploadId }),
|
body: JSON.stringify({ upload_id: uploadId, beleg_type: document.querySelector('input[name="beleg_type"]:checked').value }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
|
|
||||||
|
|
@ -77,13 +77,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Import & Ordner</h2>
|
<h2>Import - Eingangsbelege</h2>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group form-group-wide">
|
<div class="form-group form-group-wide">
|
||||||
<label for="import_email">Import-Emailadresse</label>
|
<label for="import_email_eingang">Import-Email Eingangsbelege</label>
|
||||||
<input type="email" id="import_email" name="import_email"
|
<input type="email" id="import_email_eingang" name="import_email_eingang"
|
||||||
value="{{ settings.get('import_email', '') }}" placeholder="import@example.com">
|
value="{{ settings.get('import_email_eingang', '') or settings.get('import_email', '') }}" placeholder="eingang@buchhaltung.example.com">
|
||||||
</div>
|
</div>
|
||||||
|
<input type="hidden" id="import_email" name="import_email" value="{{ settings.get('import_email_eingang', '') or settings.get('import_email', '') }}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="source_folder">Eingangsordner (IMAP)</label>
|
<label for="source_folder">Eingangsordner (IMAP)</label>
|
||||||
<div class="input-with-btn">
|
<div class="input-with-btn">
|
||||||
|
|
@ -101,6 +102,34 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Import - Ausgangsbelege <small style="font-weight:normal;color:var(--text-muted);">(optional)</small></h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group form-group-wide">
|
||||||
|
<label for="import_email_ausgang">Import-Email Ausgangsbelege</label>
|
||||||
|
<input type="email" id="import_email_ausgang" name="import_email_ausgang"
|
||||||
|
value="{{ settings.get('import_email_ausgang', '') }}" placeholder="ausgang@buchhaltung.example.com">
|
||||||
|
<small class="text-muted">Leer lassen wenn keine Ausgangsbelege importiert werden sollen</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="source_folder_ausgang">Eingangsordner Ausgangsbelege (IMAP)</label>
|
||||||
|
<div class="input-with-btn">
|
||||||
|
<input type="text" id="source_folder_ausgang" name="source_folder_ausgang"
|
||||||
|
value="{{ settings.get('source_folder_ausgang', '') }}" placeholder="Ausgangsrechnungen">
|
||||||
|
<button type="button" class="btn btn-icon" onclick="openFolderPicker('source_folder_ausgang')" title="Ordner auswählen">📁</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="processed_folder_ausgang">Verarbeitet-Ordner Ausgangsbelege (IMAP)</label>
|
||||||
|
<div class="input-with-btn">
|
||||||
|
<input type="text" id="processed_folder_ausgang" name="processed_folder_ausgang"
|
||||||
|
value="{{ settings.get('processed_folder_ausgang', '') }}" placeholder="Ausgangsrechnungen/Verarbeitet">
|
||||||
|
<button type="button" class="btn btn-icon" onclick="openFolderPicker('processed_folder_ausgang')" title="Ordner auswählen">📁</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn btn-secondary" onclick="testEmail()">
|
<button type="button" class="btn btn-secondary" onclick="testEmail()">
|
||||||
<span class="btn-text">Test-Email senden</span>
|
<span class="btn-text">Test-Email senden</span>
|
||||||
|
|
@ -158,7 +187,7 @@
|
||||||
value="{{ settings.get('smb_share', '') }}" placeholder="Scans">
|
value="{{ settings.get('smb_share', '') }}" placeholder="Scans">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="smb_source_path">Quellordner</label>
|
<label for="smb_source_path">Quellordner Eingangsbelege</label>
|
||||||
<div class="input-with-btn">
|
<div class="input-with-btn">
|
||||||
<input type="text" id="smb_source_path" name="smb_source_path"
|
<input type="text" id="smb_source_path" name="smb_source_path"
|
||||||
value="{{ settings.get('smb_source_path', '') }}" placeholder="(Wurzel der Freigabe)">
|
value="{{ settings.get('smb_source_path', '') }}" placeholder="(Wurzel der Freigabe)">
|
||||||
|
|
@ -166,13 +195,29 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="smb_processed_path">Verarbeitet-Ordner</label>
|
<label for="smb_processed_path">Verarbeitet-Ordner Eingangsbelege</label>
|
||||||
<div class="input-with-btn">
|
<div class="input-with-btn">
|
||||||
<input type="text" id="smb_processed_path" name="smb_processed_path"
|
<input type="text" id="smb_processed_path" name="smb_processed_path"
|
||||||
value="{{ settings.get('smb_processed_path', 'Verarbeitet') }}" placeholder="Verarbeitet">
|
value="{{ settings.get('smb_processed_path', 'Verarbeitet') }}" placeholder="Verarbeitet">
|
||||||
<button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_processed_path')" title="Ordner auswählen">📁</button>
|
<button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_processed_path')" title="Ordner auswählen">📁</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smb_source_path_ausgang">Quellordner Ausgangsbelege</label>
|
||||||
|
<div class="input-with-btn">
|
||||||
|
<input type="text" id="smb_source_path_ausgang" name="smb_source_path_ausgang"
|
||||||
|
value="{{ settings.get('smb_source_path_ausgang', '') }}" placeholder="(optional)">
|
||||||
|
<button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_source_path_ausgang')" title="Ordner auswählen">📁</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smb_processed_path_ausgang">Verarbeitet-Ordner Ausgangsbelege</label>
|
||||||
|
<div class="input-with-btn">
|
||||||
|
<input type="text" id="smb_processed_path_ausgang" name="smb_processed_path_ausgang"
|
||||||
|
value="{{ settings.get('smb_processed_path_ausgang', '') }}" placeholder="(optional)">
|
||||||
|
<button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_processed_path_ausgang')" title="Ordner auswählen">📁</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn btn-secondary" onclick="testSmb()">
|
<button type="button" class="btn btn-secondary" onclick="testSmb()">
|
||||||
|
|
@ -206,6 +251,20 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Debug</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="debug_save_amazon_pdfs">Amazon-PDFs zwischenspeichern</label>
|
||||||
|
<select id="debug_save_amazon_pdfs" name="debug_save_amazon_pdfs">
|
||||||
|
<option value="false" {% if settings.get('debug_save_amazon_pdfs') != 'true' %}selected{% endif %}>Aus</option>
|
||||||
|
<option value="true" {% if settings.get('debug_save_amazon_pdfs') == 'true' %}selected{% endif %}>An</option>
|
||||||
|
</select>
|
||||||
|
<small class="text-muted">Speichert heruntergeladene Amazon-Rechnungen in /data/uploads/amazon_invoices/</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-actions-main">
|
<div class="form-actions-main">
|
||||||
<button type="submit" class="btn btn-primary">Einstellungen speichern</button>
|
<button type="submit" class="btn btn-primary">Einstellungen speichern</button>
|
||||||
<button type="button" class="btn btn-success" onclick="manualProcess()">
|
<button type="button" class="btn btn-success" onclick="manualProcess()">
|
||||||
|
|
@ -228,6 +287,7 @@
|
||||||
<th>Betreff</th>
|
<th>Betreff</th>
|
||||||
<th>Absender</th>
|
<th>Absender</th>
|
||||||
<th>Anhänge</th>
|
<th>Anhänge</th>
|
||||||
|
<th>Art</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -238,6 +298,13 @@
|
||||||
<td>{{ log.email_subject or '-' }}</td>
|
<td>{{ log.email_subject or '-' }}</td>
|
||||||
<td>{{ log.email_from or '-' }}</td>
|
<td>{{ log.email_from or '-' }}</td>
|
||||||
<td>{{ log.attachments_count }}</td>
|
<td>{{ log.attachments_count }}</td>
|
||||||
|
<td>
|
||||||
|
{% if log.get('beleg_type', 'eingang') == 'ausgang' %}
|
||||||
|
<span class="badge badge-warning">Ausgang</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-info">Eingang</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if log.status == 'success' %}
|
{% if log.status == 'success' %}
|
||||||
<span class="badge badge-success">OK</span>
|
<span class="badge badge-success">OK</span>
|
||||||
|
|
@ -343,8 +410,11 @@ async function testEmail() {
|
||||||
const resp = await fetch('/api/test-email', { method: 'POST', body: getFormData() });
|
const resp = await fetch('/api/test-email', { method: 'POST', body: getFormData() });
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
const addr = document.getElementById('import_email').value;
|
const eingang = document.getElementById('import_email_eingang').value;
|
||||||
showAlert('Test-Email erfolgreich an ' + addr + ' gesendet! Einstellungen gespeichert.', 'success');
|
const ausgang = document.getElementById('import_email_ausgang').value;
|
||||||
|
let targets = eingang;
|
||||||
|
if (ausgang) targets += ' + ' + ausgang;
|
||||||
|
showAlert('Test-Email erfolgreich an ' + targets + ' gesendet! Einstellungen gespeichert.', 'success');
|
||||||
} else {
|
} else {
|
||||||
showAlert('Test-Email fehlgeschlagen: ' + data.error, 'error');
|
showAlert('Test-Email fehlgeschlagen: ' + data.error, 'error');
|
||||||
}
|
}
|
||||||
|
|
@ -447,8 +517,10 @@ function showFolderModal(targetField) {
|
||||||
const currentValue = folderTargetField ? document.getElementById(folderTargetField).value : '';
|
const currentValue = folderTargetField ? document.getElementById(folderTargetField).value : '';
|
||||||
|
|
||||||
let html = '<div class="folder-picker-fields">';
|
let html = '<div class="folder-picker-fields">';
|
||||||
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'source_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'source_folder\')">Eingangsordner: <strong>' + esc(document.getElementById('source_folder').value) + '</strong></button>';
|
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'source_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'source_folder\')">Eingang Quelle: <strong>' + esc(document.getElementById('source_folder').value) + '</strong></button>';
|
||||||
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'processed_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'processed_folder\')">Verarbeitet-Ordner: <strong>' + esc(document.getElementById('processed_folder').value) + '</strong></button>';
|
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'processed_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'processed_folder\')">Eingang Verarbeitet: <strong>' + esc(document.getElementById('processed_folder').value) + '</strong></button>';
|
||||||
|
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'source_folder_ausgang' ? 'active' : '') + '" onclick="switchFolderTarget(\'source_folder_ausgang\')">Ausgang Quelle: <strong>' + esc(document.getElementById('source_folder_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
|
||||||
|
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'processed_folder_ausgang' ? 'active' : '') + '" onclick="switchFolderTarget(\'processed_folder_ausgang\')">Ausgang Verarbeitet: <strong>' + esc(document.getElementById('processed_folder_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
html += '<div class="folder-items">';
|
html += '<div class="folder-items">';
|
||||||
if (cachedFolders && cachedFolders.length > 0) {
|
if (cachedFolders && cachedFolders.length > 0) {
|
||||||
|
|
@ -651,8 +723,10 @@ function showSmbFolderModal(targetField) {
|
||||||
const currentValue = smbFolderTargetField ? document.getElementById(smbFolderTargetField).value : '';
|
const currentValue = smbFolderTargetField ? document.getElementById(smbFolderTargetField).value : '';
|
||||||
|
|
||||||
let html = '<div class="folder-picker-fields">';
|
let html = '<div class="folder-picker-fields">';
|
||||||
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_source_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_source_path\')">Quellordner: <strong>' + esc(document.getElementById('smb_source_path').value || '(Wurzel)') + '</strong></button>';
|
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_source_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_source_path\')">Eingang Quelle: <strong>' + esc(document.getElementById('smb_source_path').value || '(Wurzel)') + '</strong></button>';
|
||||||
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_processed_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_processed_path\')">Verarbeitet-Ordner: <strong>' + esc(document.getElementById('smb_processed_path').value) + '</strong></button>';
|
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_processed_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_processed_path\')">Eingang Verarbeitet: <strong>' + esc(document.getElementById('smb_processed_path').value) + '</strong></button>';
|
||||||
|
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_source_path_ausgang' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_source_path_ausgang\')">Ausgang Quelle: <strong>' + esc(document.getElementById('smb_source_path_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
|
||||||
|
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_processed_path_ausgang' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_processed_path_ausgang\')">Ausgang Verarbeitet: <strong>' + esc(document.getElementById('smb_processed_path_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
html += '<div class="folder-items">';
|
html += '<div class="folder-items">';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,5 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- DB_PATH=/data/belegimport.db
|
- DB_PATH=/data/belegimport.db
|
||||||
- TZ=Europe/Berlin
|
- TZ=Europe/Berlin
|
||||||
|
- LOG_LEVEL=DEBUG
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,5 @@ PyMuPDF==1.25.3
|
||||||
qrcode==8.0
|
qrcode==8.0
|
||||||
sse-starlette==2.2.1
|
sse-starlette==2.2.1
|
||||||
smbprotocol==1.14.0
|
smbprotocol==1.14.0
|
||||||
|
playwright==1.49.1
|
||||||
|
playwright-stealth==2.0.2
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue