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
|
||||
|
||||
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
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN playwright install chromium
|
||||
|
||||
COPY app/ ./app/
|
||||
|
||||
RUN mkdir -p /data/uploads
|
||||
RUN mkdir -p /data/uploads /data/amazon_session
|
||||
|
||||
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
|
||||
|
||||
DB_PATH = os.environ.get("DB_PATH", "/data/belegimport.db")
|
||||
SCHEMA_VERSION = 2
|
||||
SCHEMA_VERSION = 8
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_fernet = None
|
||||
|
||||
ENCRYPTED_KEYS = {"imap_password", "smtp_password", "smb_password"}
|
||||
ENCRYPTED_KEYS = {"imap_password", "smtp_password", "smb_password", "amazon_password"}
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"imap_server": "",
|
||||
|
|
@ -24,8 +24,12 @@ DEFAULT_SETTINGS = {
|
|||
"smtp_username": "",
|
||||
"smtp_password": "",
|
||||
"import_email": "",
|
||||
"import_email_eingang": "",
|
||||
"import_email_ausgang": "",
|
||||
"source_folder": "Rechnungen",
|
||||
"processed_folder": "Rechnungen/Verarbeitet",
|
||||
"source_folder_ausgang": "",
|
||||
"processed_folder_ausgang": "",
|
||||
"interval_minutes": "5",
|
||||
"scheduler_enabled": "false",
|
||||
"fetch_since_date": "",
|
||||
|
|
@ -39,7 +43,18 @@ DEFAULT_SETTINGS = {
|
|||
"smb_share": "",
|
||||
"smb_source_path": "",
|
||||
"smb_processed_path": "Verarbeitet",
|
||||
"smb_source_path_ausgang": "",
|
||||
"smb_processed_path_ausgang": "",
|
||||
"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,
|
||||
attachments_count INTEGER DEFAULT 0,
|
||||
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()
|
||||
|
|
@ -150,10 +168,72 @@ async def _run_migrations(db: aiosqlite.Connection, current_version: int):
|
|||
await db.commit()
|
||||
await _set_schema_version(db, 2)
|
||||
|
||||
# --- Future migrations go here ---
|
||||
# if current_version < 3:
|
||||
# logger.info("Migration v3: ...")
|
||||
# await _set_schema_version(db, 3)
|
||||
if current_version < 3:
|
||||
logger.info("Migration v3: Amazon-Plattform hinzugefügt")
|
||||
await db.execute("""
|
||||
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()
|
||||
|
||||
|
|
@ -176,7 +256,18 @@ async def init_db():
|
|||
email_from TEXT,
|
||||
attachments_count INTEGER DEFAULT 0,
|
||||
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()
|
||||
|
|
@ -235,19 +326,29 @@ async def save_settings(data: dict):
|
|||
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(
|
||||
email_subject: str,
|
||||
email_from: str,
|
||||
attachments_count: int,
|
||||
status: str,
|
||||
error_message: str = "",
|
||||
sent_to: str = "",
|
||||
smtp_log: str = "",
|
||||
beleg_type: str = "eingang",
|
||||
):
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO processing_log
|
||||
(email_subject, email_from, attachments_count, status, error_message)
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(email_subject, email_from, attachments_count, status, error_message, sent_to, smtp_log, beleg_type),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
|
@ -260,3 +361,46 @@ async def get_log_entries(limit: int = 100) -> list[dict]:
|
|||
)
|
||||
rows = await cursor.fetchall()
|
||||
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
|
||||
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__)
|
||||
|
||||
|
||||
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:
|
||||
server = settings["imap_server"]
|
||||
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()
|
||||
|
||||
|
||||
async def process_mailbox() -> dict:
|
||||
settings = await get_settings()
|
||||
|
||||
if not settings.get("imap_server") or not settings.get("import_email"):
|
||||
logger.warning("IMAP oder Import-Email nicht konfiguriert")
|
||||
return {"processed": 0, "skipped": 0, "errors": 0, "error": "Nicht konfiguriert"}
|
||||
|
||||
source_folder = settings.get("source_folder", "INBOX")
|
||||
processed_folder = settings.get("processed_folder", "INBOX/Verarbeitet")
|
||||
import_email = settings["import_email"]
|
||||
async def _process_folder(
|
||||
imap_conn, smtp_conn, settings: dict,
|
||||
source_folder: str, processed_folder: str,
|
||||
import_email: str, beleg_type: str, fetch_since: str,
|
||||
) -> dict:
|
||||
"""Process one IMAP folder pair. Returns counts dict."""
|
||||
smtp_from = settings.get("smtp_username", "")
|
||||
|
||||
processed = 0
|
||||
skipped = 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
|
||||
smtp_conn = None
|
||||
|
||||
|
|
@ -140,92 +255,31 @@ async def process_mailbox() -> dict:
|
|||
imap_conn = _connect_imap(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}"')
|
||||
if status != "OK":
|
||||
raise Exception(f"Ordner '{source_folder}' konnte nicht geöffnet werden")
|
||||
|
||||
# Build IMAP search criteria
|
||||
search_criteria = "ALL"
|
||||
fetch_since = settings.get("fetch_since_date", "")
|
||||
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("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
|
||||
# Ausgangsbelege (optional)
|
||||
import_email_ausgang = get_import_email(settings, "ausgang")
|
||||
source_ausgang = settings.get("source_folder_ausgang", "")
|
||||
processed_ausgang = settings.get("processed_folder_ausgang", "")
|
||||
if import_email_ausgang and source_ausgang:
|
||||
if not processed_ausgang:
|
||||
processed_ausgang = source_ausgang + "/Verarbeitet"
|
||||
result = await _process_folder(
|
||||
imap_conn, smtp_conn, settings,
|
||||
source_ausgang, processed_ausgang,
|
||||
import_email_ausgang, "ausgang", fetch_since,
|
||||
)
|
||||
for k in total:
|
||||
total[k] += result[k]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Verbindungsfehler: {e}")
|
||||
|
|
@ -236,7 +290,7 @@ async def process_mailbox() -> dict:
|
|||
status="error",
|
||||
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:
|
||||
if imap_conn:
|
||||
|
|
@ -250,36 +304,52 @@ async def process_mailbox() -> dict:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
f"Fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler"
|
||||
)
|
||||
return {"processed": processed, "skipped": skipped, "errors": errors}
|
||||
logger.info(f"Fertig: {total['processed']} verarbeitet, {total['skipped']} übersprungen, {total['errors']} Fehler")
|
||||
return total
|
||||
|
||||
|
||||
async def send_test_email() -> dict:
|
||||
settings = await get_settings()
|
||||
|
||||
if not settings.get("smtp_server") or not settings.get("import_email"):
|
||||
return {"success": False, "error": "SMTP oder Import-Email nicht konfiguriert"}
|
||||
import_email_eingang = get_import_email(settings, "eingang")
|
||||
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:
|
||||
smtp_conn = _connect_smtp(settings)
|
||||
smtp_logs = []
|
||||
|
||||
# Test Eingangsbelege
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = settings["smtp_username"]
|
||||
msg["To"] = settings["import_email"]
|
||||
msg["Subject"] = "Belegimport - Test-Email"
|
||||
msg["To"] = import_email_eingang
|
||||
msg["Subject"] = "Belegimport - Test-Email (Eingangsbelege)"
|
||||
msg.attach(MIMEText(
|
||||
"Dies ist eine Test-Email vom Belegimport Service.\n"
|
||||
"Wenn Sie diese Email erhalten, funktioniert die SMTP-Verbindung.",
|
||||
"plain",
|
||||
"utf-8",
|
||||
"Ziel: Eingangsbelege",
|
||||
"plain", "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()
|
||||
|
||||
return {"success": True}
|
||||
return {"success": True, "smtp_log": "\n".join(smtp_logs)}
|
||||
|
||||
except Exception as 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 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.templating import Jinja2Templates
|
||||
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.scheduler import start_scheduler, configure_job, get_scheduler_status
|
||||
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.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(
|
||||
level=logging.INFO,
|
||||
level=getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO),
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -44,6 +59,11 @@ templates = Jinja2Templates(directory="app/templates")
|
|||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
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()
|
||||
logs = await get_log_entries(limit=20)
|
||||
status = get_scheduler_status()
|
||||
|
|
@ -74,8 +94,12 @@ async def _save_form_settings(request: Request) -> dict:
|
|||
"smtp_username": form.get("smtp_username", ""),
|
||||
"smtp_password": form.get("smtp_password") or current.get("smtp_password", ""),
|
||||
"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"),
|
||||
"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"),
|
||||
"scheduler_enabled": form.get("scheduler_enabled", "false"),
|
||||
"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_source_path": form.get("smb_source_path", ""),
|
||||
"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"),
|
||||
# Debug
|
||||
"debug_save_amazon_pdfs": form.get("debug_save_amazon_pdfs", "false"),
|
||||
}
|
||||
|
||||
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")
|
||||
async def api_status():
|
||||
return get_scheduler_status()
|
||||
|
|
@ -247,6 +281,7 @@ async def scan_upload_chunk(
|
|||
async def scan_process(request: Request):
|
||||
body = await request.json()
|
||||
upload_id = body.get("upload_id", "")
|
||||
beleg_type = body.get("beleg_type", "eingang")
|
||||
|
||||
try:
|
||||
uuid.UUID(upload_id)
|
||||
|
|
@ -269,7 +304,7 @@ async def scan_process(request: Request):
|
|||
# Process in background task
|
||||
async def _process():
|
||||
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({
|
||||
"stage": "done", "result": result
|
||||
})
|
||||
|
|
@ -325,3 +360,113 @@ async def separator_pdf():
|
|||
media_type="application/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
|
||||
from qrcode.constants import ERROR_CORRECT_H
|
||||
|
||||
from app.database import get_settings, add_log_entry
|
||||
from app.mail_processor import _connect_smtp, _build_forward_email
|
||||
from app.database import get_settings, add_log_entry, get_import_email
|
||||
from app.mail_processor import _connect_smtp, _build_forward_email, _send_with_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -84,11 +84,12 @@ def split_pdf(pdf_path: str, separator_pages: list[int]) -> list[bytes]:
|
|||
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."""
|
||||
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}
|
||||
|
||||
# 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"
|
||||
msg = _build_forward_email(
|
||||
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_from="Scan-Upload",
|
||||
attachments=[(filename, doc_bytes)],
|
||||
)
|
||||
smtp_conn.send_message(msg)
|
||||
smtp_log = _send_with_log(smtp_conn, msg)
|
||||
sent += 1
|
||||
|
||||
await add_log_entry(
|
||||
|
|
@ -148,6 +149,9 @@ async def process_scanned_pdf(pdf_path: str, progress_callback=None) -> dict:
|
|||
email_from="Scan-Upload",
|
||||
attachments_count=1,
|
||||
status="success",
|
||||
sent_to=import_email,
|
||||
smtp_log=smtp_log,
|
||||
beleg_type=beleg_type,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -159,6 +163,8 @@ async def process_scanned_pdf(pdf_path: str, progress_callback=None) -> dict:
|
|||
attachments_count=1,
|
||||
status="error",
|
||||
error_message=str(e),
|
||||
sent_to=import_email,
|
||||
beleg_type=beleg_type,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from apscheduler.triggers.interval import IntervalTrigger
|
|||
|
||||
from app.mail_processor import process_mailbox
|
||||
from app.smb_processor import process_smb_share
|
||||
from app.amazon_processor import process_amazon
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ async def _run_processor():
|
|||
return
|
||||
_is_processing = True
|
||||
try:
|
||||
# Email and SMB first - these are fast and must not be blocked by Amazon
|
||||
logger.info("Starte automatische Email-Verarbeitung...")
|
||||
result = await process_mailbox()
|
||||
logger.info(f"Email-Verarbeitung abgeschlossen: {result}")
|
||||
|
|
@ -28,6 +30,16 @@ async def _run_processor():
|
|||
logger.info("Starte automatische SMB-Verarbeitung...")
|
||||
smb_result = await process_smb_share()
|
||||
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:
|
||||
logger.error(f"Fehler bei automatischer Verarbeitung: {e}")
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import tempfile
|
|||
|
||||
import smbclient
|
||||
|
||||
from app.database import get_settings, add_log_entry
|
||||
from app.mail_processor import _connect_smtp, _build_forward_email
|
||||
from app.database import get_settings, add_log_entry, get_import_email
|
||||
from app.mail_processor import _connect_smtp, _build_forward_email, _send_with_log
|
||||
from app.scanner import detect_separator_pages, split_pdf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -114,6 +114,119 @@ def _list_smb_folders_recursive(
|
|||
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:
|
||||
"""Process PDF files from SMB share - main pipeline."""
|
||||
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"):
|
||||
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"}
|
||||
|
||||
mode = settings.get("smb_mode", "forward")
|
||||
smtp_from = settings.get("smtp_username", "")
|
||||
import_email = settings["import_email"]
|
||||
|
||||
processed = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
total = {"processed": 0, "skipped": 0, "errors": 0}
|
||||
smtp_conn = None
|
||||
|
||||
try:
|
||||
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)
|
||||
|
||||
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)
|
||||
# Eingangsbelege
|
||||
source = settings.get("smb_source_path", "")
|
||||
processed_rel = settings.get("smb_processed_path", "Verarbeitet")
|
||||
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":
|
||||
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
|
||||
|
||||
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
|
||||
# Ausgangsbelege (optional)
|
||||
import_email_ausgang = get_import_email(settings, "ausgang")
|
||||
source_ausgang = settings.get("smb_source_path_ausgang", "")
|
||||
processed_ausgang = settings.get("smb_processed_path_ausgang", "")
|
||||
if import_email_ausgang and source_ausgang:
|
||||
if not processed_ausgang:
|
||||
processed_ausgang = source_ausgang + "/Verarbeitet"
|
||||
result = await _process_smb_folder(
|
||||
smtp_conn, settings, base_path,
|
||||
source_ausgang, processed_ausgang,
|
||||
import_email_ausgang, "ausgang", mode,
|
||||
)
|
||||
for k in total:
|
||||
total[k] += result[k]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SMB-Verbindungsfehler: {e}")
|
||||
|
|
@ -244,7 +287,7 @@ async def process_smb_share() -> dict:
|
|||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {"processed": processed, "skipped": skipped, "errors": errors + 1, "error": str(e)}
|
||||
return {**total, "errors": total["errors"] + 1, "error": str(e)}
|
||||
|
||||
finally:
|
||||
if smtp_conn:
|
||||
|
|
@ -253,8 +296,8 @@ async def process_smb_share() -> dict:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"SMB fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler")
|
||||
return {"processed": processed, "skipped": skipped, "errors": errors}
|
||||
logger.info(f"SMB fertig: {total['processed']} verarbeitet, {total['skipped']} übersprungen, {total['errors']} Fehler")
|
||||
return total
|
||||
|
||||
|
||||
async def test_smb_connection() -> dict:
|
||||
|
|
|
|||
|
|
@ -200,6 +200,11 @@ main {
|
|||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
|
|
@ -236,6 +241,16 @@ main {
|
|||
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 {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@
|
|||
<nav>
|
||||
<div class="nav-brand">Belegimport</div>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="{% if active_page == 'settings' %}active{% endif %}">Einstellungen</a>
|
||||
<a href="/scan" class="{% if active_page == 'scan' %}active{% endif %}">Scan-Upload</a>
|
||||
<a href="/" 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>
|
||||
</div>
|
||||
<div class="nav-status">
|
||||
|
|
@ -29,7 +30,7 @@
|
|||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<main class="{% if main_class is defined %}{{ main_class }}{% endif %}">
|
||||
{% if message %}
|
||||
<div class="alert alert-{{ message_type or 'info' }}">
|
||||
{{ message }}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,30 @@
|
|||
{% extends "base.html" %}
|
||||
{% set active_page = "log" %}
|
||||
{% set main_class = "main-wide" %}
|
||||
{% set message = None %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h2>Verarbeitungslog</h2>
|
||||
<div class="card card-table">
|
||||
<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 %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Art</th>
|
||||
<th>Betreff</th>
|
||||
<th>Absender</th>
|
||||
<th>Anhänge</th>
|
||||
<th>Gesendet an</th>
|
||||
<th>Status</th>
|
||||
<th>Fehlermeldung</th>
|
||||
<th>SMTP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -23,9 +32,17 @@
|
|||
<tr class="{% if log.status == 'error' %}row-error{% endif %}">
|
||||
<td>{{ log.id }}</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_from or '-' }}</td>
|
||||
<td>{{ log.attachments_count }}</td>
|
||||
<td>{{ log.sent_to or '-' }}</td>
|
||||
<td>
|
||||
{% if log.status == 'success' %}
|
||||
<span class="badge badge-success">OK</span>
|
||||
|
|
@ -34,6 +51,12 @@
|
|||
{% endif %}
|
||||
</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>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
@ -42,4 +65,54 @@
|
|||
<p class="text-muted">Noch keine Einträge vorhanden.</p>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
</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 -->
|
||||
<div id="uploadZone" class="upload-zone" onclick="document.getElementById('fileInput').click()">
|
||||
<div class="upload-icon">📄</div>
|
||||
|
|
@ -175,7 +182,7 @@ async function startProcessing(uploadId) {
|
|||
const resp = await fetch('/api/scan-process', {
|
||||
method: 'POST',
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -77,13 +77,14 @@
|
|||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Import & Ordner</h2>
|
||||
<h2>Import - Eingangsbelege</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group form-group-wide">
|
||||
<label for="import_email">Import-Emailadresse</label>
|
||||
<input type="email" id="import_email" name="import_email"
|
||||
value="{{ settings.get('import_email', '') }}" placeholder="import@example.com">
|
||||
<label for="import_email_eingang">Import-Email Eingangsbelege</label>
|
||||
<input type="email" id="import_email_eingang" name="import_email_eingang"
|
||||
value="{{ settings.get('import_email_eingang', '') or settings.get('import_email', '') }}" placeholder="eingang@buchhaltung.example.com">
|
||||
</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">
|
||||
<label for="source_folder">Eingangsordner (IMAP)</label>
|
||||
<div class="input-with-btn">
|
||||
|
|
@ -101,6 +102,34 @@
|
|||
</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">
|
||||
<button type="button" class="btn btn-secondary" onclick="testEmail()">
|
||||
<span class="btn-text">Test-Email senden</span>
|
||||
|
|
@ -158,7 +187,7 @@
|
|||
value="{{ settings.get('smb_share', '') }}" placeholder="Scans">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smb_source_path">Quellordner</label>
|
||||
<label for="smb_source_path">Quellordner Eingangsbelege</label>
|
||||
<div class="input-with-btn">
|
||||
<input type="text" id="smb_source_path" name="smb_source_path"
|
||||
value="{{ settings.get('smb_source_path', '') }}" placeholder="(Wurzel der Freigabe)">
|
||||
|
|
@ -166,13 +195,29 @@
|
|||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<input type="text" id="smb_processed_path" name="smb_processed_path"
|
||||
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>
|
||||
</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 class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="testSmb()">
|
||||
|
|
@ -206,6 +251,20 @@
|
|||
</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">
|
||||
<button type="submit" class="btn btn-primary">Einstellungen speichern</button>
|
||||
<button type="button" class="btn btn-success" onclick="manualProcess()">
|
||||
|
|
@ -228,6 +287,7 @@
|
|||
<th>Betreff</th>
|
||||
<th>Absender</th>
|
||||
<th>Anhänge</th>
|
||||
<th>Art</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -238,6 +298,13 @@
|
|||
<td>{{ log.email_subject or '-' }}</td>
|
||||
<td>{{ log.email_from or '-' }}</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>
|
||||
{% if log.status == 'success' %}
|
||||
<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 data = await resp.json();
|
||||
if (data.success) {
|
||||
const addr = document.getElementById('import_email').value;
|
||||
showAlert('Test-Email erfolgreich an ' + addr + ' gesendet! Einstellungen gespeichert.', 'success');
|
||||
const eingang = document.getElementById('import_email_eingang').value;
|
||||
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 {
|
||||
showAlert('Test-Email fehlgeschlagen: ' + data.error, 'error');
|
||||
}
|
||||
|
|
@ -447,8 +517,10 @@ function showFolderModal(targetField) {
|
|||
const currentValue = folderTargetField ? document.getElementById(folderTargetField).value : '';
|
||||
|
||||
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 === '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 === '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\')">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 class="folder-items">';
|
||||
if (cachedFolders && cachedFolders.length > 0) {
|
||||
|
|
@ -651,8 +723,10 @@ function showSmbFolderModal(targetField) {
|
|||
const currentValue = smbFolderTargetField ? document.getElementById(smbFolderTargetField).value : '';
|
||||
|
||||
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_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_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\')">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 class="folder-items">';
|
||||
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ services:
|
|||
environment:
|
||||
- DB_PATH=/data/belegimport.db
|
||||
- TZ=Europe/Berlin
|
||||
- LOG_LEVEL=DEBUG
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -12,3 +12,5 @@ PyMuPDF==1.25.3
|
|||
qrcode==8.0
|
||||
sse-starlette==2.2.1
|
||||
smbprotocol==1.14.0
|
||||
playwright==1.49.1
|
||||
playwright-stealth==2.0.2
|
||||
|
|
|
|||
Loading…
Reference in New Issue