Amazon Business API integration replacing browser automation
- Add amazon_api.py with Reconciliation + Document API client - OAuth flow with manual code exchange for local installations - Dual mode: API (recommended) or Browser automation (fallback) - New settings: amazon_app_id, amazon_client_id, amazon_client_secret, amazon_refresh_token - Platform UI with mode switcher, API credential fields, OAuth button - Scheduler supports both API and browser modes - README with full Amazon API setup guide - httpx added for async HTTP requests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a4e39332c7
commit
337e0e99a5
|
|
@ -0,0 +1,195 @@
|
||||||
|
# Belegimport
|
||||||
|
|
||||||
|
Automatischer Import von Belegen (Rechnungen, Gutschriften) aus verschiedenen Quellen und Weiterleitung per SMTP an Buchhaltungssoftware (z.B. Buchhaltungsbutler).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Scan-Upload**: PDF hochladen, automatische Trennung per QR-Code-Trennseiten
|
||||||
|
- **IMAP**: Automatischer Abruf von Belegen aus Email-Postfachern
|
||||||
|
- **SMB/Netzlaufwerk**: Automatischer Abruf von Belegen aus Netzwerkordnern
|
||||||
|
- **Amazon Business**: Automatischer Abruf von Amazon-Rechnungen per API
|
||||||
|
- **Eingangs-/Ausgangsbelege**: Getrennte Import-Adressen fur Einkauf und Verkauf
|
||||||
|
- **Scheduler**: Automatischer Abruf in konfigurierbaren Intervallen
|
||||||
|
- **Verarbeitungslog**: Ubersicht aller importierten Belege mit Status
|
||||||
|
|
||||||
|
## Technologie
|
||||||
|
|
||||||
|
- Python 3.12, FastAPI, Jinja2, SQLite (aiosqlite)
|
||||||
|
- Docker / docker-compose
|
||||||
|
- Playwright (optional, fur Amazon Browser-Automation als Fallback)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Docker (empfohlen)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd lex-office-belegimport-mail
|
||||||
|
sudo docker-compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Webanwendung ist erreichbar unter: `http://localhost:8081`
|
||||||
|
|
||||||
|
### Konfiguration
|
||||||
|
|
||||||
|
Alle Einstellungen werden uber die Weboberflache vorgenommen:
|
||||||
|
|
||||||
|
1. **Einstellungen** (`/settings`): SMTP-Server, IMAP, SMB, Import-Emailadressen
|
||||||
|
2. **Plattformen** (`/platforms`): Amazon Business API-Zugangsdaten
|
||||||
|
3. **Scan-Upload** (`/`): Manueller PDF-Upload mit Belegart-Auswahl
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Amazon Business API Einrichtung
|
||||||
|
|
||||||
|
Die Amazon-Integration nutzt die offizielle Amazon Business API (Reconciliation + Document API) um Rechnungen automatisch abzurufen. Kein Browser-Login, keine CAPTCHAs, vollautomatisch.
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
|
||||||
|
- Amazon Business Konto (mit Business Prime)
|
||||||
|
- Zugang zum [Amazon Solution Provider Portal](https://solutionproviderportal.amazon.com/)
|
||||||
|
|
||||||
|
### Schritt 1: Als Entwickler registrieren
|
||||||
|
|
||||||
|
1. Offne das [Solution Provider Portal](https://solutionproviderportal.amazon.com/)
|
||||||
|
2. Wahle **"Private seller applications"** (fur eigene Integrationen)
|
||||||
|
3. Wahle **"Erstellen Sie Anwendungen, die SP-APIs verwenden"**
|
||||||
|
4. Fulle die Unternehmensdaten aus (Name, Handelsregisternummer, Adresse)
|
||||||
|
5. Verifiziere per SMS
|
||||||
|
|
||||||
|
### Schritt 2: Rollen auswahlen
|
||||||
|
|
||||||
|
Wahle folgende Rollen:
|
||||||
|
|
||||||
|
- **Abgleichen von Business-Einkaufen** (Reconciliation API)
|
||||||
|
- **Amazon Business-Bestellung** (Business Orders API)
|
||||||
|
|
||||||
|
### Schritt 3: Sicherheitskontrollen
|
||||||
|
|
||||||
|
Beantworte alle Sicherheitsfragen mit **"Ja"**.
|
||||||
|
|
||||||
|
Bei den Textfeldern:
|
||||||
|
- **Externe Parteien**: `Keine. Die Daten werden ausschliesslich intern fur die eigene Buchhaltung verwendet.`
|
||||||
|
- **Externe Quellen**: `Keine.`
|
||||||
|
|
||||||
|
### Schritt 4: App registrieren
|
||||||
|
|
||||||
|
Nach der Freischaltung (kann einige Tage dauern):
|
||||||
|
|
||||||
|
1. Im Developer Central: **"+ Neuen App-Client hinzufugen"**
|
||||||
|
2. Einstellungen:
|
||||||
|
- **App-Name**: `Beleg import` (oder eigener Name)
|
||||||
|
- **API-Typ**: `SP-API`
|
||||||
|
- **App-Typ**: `Produktion`
|
||||||
|
- **Amazon Business**: angehakt
|
||||||
|
- **Verkaufer**: nicht angehakt
|
||||||
|
- **Rollen**: Abgleichen von Business-Einkaufen + Amazon Business-Bestellung
|
||||||
|
- **RDT**: Nein
|
||||||
|
- **OAuth-Anmeldungs-URI**: `https://ihre-domain.de/api/amazon-oauth-callback`
|
||||||
|
- **OAuth-Umleitungs-URI**: `https://ihre-domain.de/api/amazon-oauth-callback`
|
||||||
|
|
||||||
|
3. Nach dem Speichern: **"Anmeldedaten fur Login mit Amazon" -> "Anzeigen"**
|
||||||
|
- Notiere **Client-ID** (`amzn1.application-oa2-client.xxxxx`)
|
||||||
|
- Notiere **Client-Sicherheitsschluessel** (`amzn1.oa2-cs.v1.xxxxx`)
|
||||||
|
|
||||||
|
4. Die **App-ID** (`amzn1.sp.solution.xxxxx`) steht in der App-Ubersicht unter dem App-Namen
|
||||||
|
|
||||||
|
> **Hinweis**: Die OAuth-Umleitungs-URI muss eine echte Domain mit Top-Level-Domain sein.
|
||||||
|
> `localhost` und `.local` Domains werden von Amazon nicht akzeptiert.
|
||||||
|
> Die URI muss nicht offentlich erreichbar sein - Amazon leitet nur den Browser des Benutzers dorthin weiter.
|
||||||
|
|
||||||
|
> **Fehler SPSA0404**: Falls beim Autorisieren der Fehler "Keine unterstuetzte Geschaeftseinheit" erscheint,
|
||||||
|
> muss die Autorisierung uber den OAuth-Flow (Website) statt uber Self-Authorization erfolgen.
|
||||||
|
> Der Belegimport unterstutzt dies automatisch.
|
||||||
|
|
||||||
|
### Schritt 5: Im Belegimport konfigurieren
|
||||||
|
|
||||||
|
1. Offne die Plattformen-Seite im Belegimport
|
||||||
|
2. Setze **Abruf-Modus** auf **"API (empfohlen)"**
|
||||||
|
3. Trage ein:
|
||||||
|
- **App-ID**: `amzn1.sp.solution.xxxxx`
|
||||||
|
- **Client-ID**: `amzn1.application-oa2-client.xxxxx`
|
||||||
|
- **Client-Sicherheitsschluessel**: Der Secret-Wert
|
||||||
|
4. **Einstellungen speichern**
|
||||||
|
5. Klicke **"Bei Amazon autorisieren"**
|
||||||
|
6. Melde dich bei Amazon an und erlaube den Zugriff
|
||||||
|
7. Kopiere den `spapi_oauth_code` (oder die ganze URL) aus der Browser-Adressleiste
|
||||||
|
8. Trage den Code im Belegimport ein und klicke **"Token tauschen"**
|
||||||
|
9. Status sollte auf **"API autorisiert"** wechseln
|
||||||
|
|
||||||
|
### Schritt 6: Rechnungen abrufen
|
||||||
|
|
||||||
|
- **Manuell**: Klicke "Jetzt Rechnungen abrufen"
|
||||||
|
- **Automatisch**: Aktiviere den Scheduler unter Einstellungen (z.B. alle 60 Minuten)
|
||||||
|
|
||||||
|
Die Rechnungen werden als PDF per SMTP an die konfigurierte Eingangsbeleg-Adresse gesendet.
|
||||||
|
Bereits abgerufene Rechnungen werden automatisch ubersprungen.
|
||||||
|
|
||||||
|
### OAuth Redirect URI (lokale Installation)
|
||||||
|
|
||||||
|
Da Amazon keine `localhost`-URIs akzeptiert, gibt es zwei Optionen:
|
||||||
|
|
||||||
|
**Option A: Eigene Domain verwenden (empfohlen)**
|
||||||
|
|
||||||
|
Trage eine echte Domain ein (z.B. `https://ihre-domain.de/api/amazon-oauth-callback`).
|
||||||
|
Nach der Amazon-Autorisierung leitet der Browser dorthin weiter - die Seite ladt nicht,
|
||||||
|
aber der Auth-Code steht in der URL-Leiste. Diesen Code im Belegimport eintragen.
|
||||||
|
|
||||||
|
**Option B: /etc/hosts Eintrag**
|
||||||
|
|
||||||
|
Falls der Server lokal erreichbar sein soll:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In /etc/hosts eintragen:
|
||||||
|
127.0.0.1 app.belegimport.de
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann in der Amazon App als Redirect URI eintragen:
|
||||||
|
`https://app.belegimport.de/api/amazon-oauth-callback`
|
||||||
|
|
||||||
|
> **Achtung**: Amazon pruft ob die Domain eine Top-Level-Domain hat.
|
||||||
|
> `.local` funktioniert nicht, aber `.de` schon.
|
||||||
|
|
||||||
|
### Umgebungsvariablen
|
||||||
|
|
||||||
|
In `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- OAUTH_REDIRECT_BASE=https://ihre-domain.de # Muss zur Amazon App passen
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Eingangs- und Ausgangsbelege
|
||||||
|
|
||||||
|
Der Belegimport unterscheidet zwischen:
|
||||||
|
|
||||||
|
- **Eingangsbelege (Einkauf)**: Rechnungen die Sie von Lieferanten erhalten
|
||||||
|
- **Ausgangsbelege (Verkauf/Gutschrift)**: Rechnungen die Sie an Kunden senden
|
||||||
|
|
||||||
|
Fur beide Typen konnen separate Import-Emailadressen konfiguriert werden (z.B. fur Buchhaltungsbutler).
|
||||||
|
Amazon-Rechnungen werden automatisch als Eingangsbelege klassifiziert.
|
||||||
|
|
||||||
|
Bei IMAP und SMB konnen jeweils getrennte Quell- und Verarbeitungsordner fur Eingangs- und Ausgangsbelege konfiguriert werden.
|
||||||
|
|
||||||
|
Beim Scan-Upload kann die Belegart per Radio-Button ausgewahlt werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verarbeitungslog
|
||||||
|
|
||||||
|
Unter `/log` werden alle verarbeiteten Belege angezeigt mit:
|
||||||
|
|
||||||
|
- Zeitpunkt, Betreff, Absender
|
||||||
|
- Belegart (Eingang/Ausgang)
|
||||||
|
- Anzahl Anhange
|
||||||
|
- Status (OK/Fehler)
|
||||||
|
- Fehlermeldung (falls vorhanden)
|
||||||
|
- SMTP-Protokoll (anzeigbar)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Privates Projekt.
|
||||||
|
|
@ -0,0 +1,515 @@
|
||||||
|
"""Amazon Business API client using SP-API (Reconciliation + Document API).
|
||||||
|
|
||||||
|
This module provides API-based invoice retrieval as an alternative to browser automation.
|
||||||
|
Uses OAuth2 with LWA (Login with Amazon) for authentication.
|
||||||
|
|
||||||
|
Document API workflow (EU):
|
||||||
|
1. POST /reports/.../reports → reportId
|
||||||
|
2. GET /reports/.../reports/{reportId} → poll until DONE → reportDocumentId
|
||||||
|
3. GET /reports/.../documents/{reportDocumentId} → presigned URL
|
||||||
|
4. Download + decompress (gzip then zip) → PDF
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import gzip
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import urllib.parse
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.database import get_settings, save_settings, add_log_entry, is_invoice_downloaded, mark_invoice_downloaded
|
||||||
|
from app.mail_processor import _connect_smtp, _build_forward_email, _send_with_log
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Amazon LWA (Login with Amazon) endpoints
|
||||||
|
LWA_TOKEN_URL = "https://api.amazon.com/auth/o2/token"
|
||||||
|
|
||||||
|
# Amazon Business OAuth consent URLs per domain (NOT sellercentral!)
|
||||||
|
AB_OAUTH_URLS = {
|
||||||
|
"amazon.de": "https://www.amazon.de/b2b/abws/oauth",
|
||||||
|
"amazon.at": "https://www.amazon.de/b2b/abws/oauth", # AT uses DE
|
||||||
|
"amazon.fr": "https://www.amazon.fr/b2b/abws/oauth",
|
||||||
|
"amazon.it": "https://www.amazon.it/b2b/abws/oauth",
|
||||||
|
"amazon.es": "https://www.amazon.es/b2b/abws/oauth",
|
||||||
|
"amazon.co.uk": "https://www.amazon.co.uk/b2b/abws/oauth",
|
||||||
|
"amazon.com": "https://www.amazon.com/b2b/abws/oauth",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Amazon Business API endpoints per region
|
||||||
|
AB_API_ENDPOINTS = {
|
||||||
|
"eu": "https://eu.business-api.amazon.com",
|
||||||
|
"na": "https://na.business-api.amazon.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
# API versions
|
||||||
|
RECONCILIATION_VERSION = "2021-01-08"
|
||||||
|
REPORTS_VERSION = "2021-09-30"
|
||||||
|
|
||||||
|
# Domain to region mapping
|
||||||
|
DOMAIN_REGION = {
|
||||||
|
"amazon.de": "eu",
|
||||||
|
"amazon.at": "eu",
|
||||||
|
"amazon.fr": "eu",
|
||||||
|
"amazon.it": "eu",
|
||||||
|
"amazon.es": "eu",
|
||||||
|
"amazon.co.uk": "eu",
|
||||||
|
"amazon.com": "na",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Domain to marketplace ID
|
||||||
|
DOMAIN_MARKETPLACE = {
|
||||||
|
"amazon.de": "A1PA6795UKMFR9",
|
||||||
|
"amazon.at": "A2NODRKZP88ZB9",
|
||||||
|
"amazon.fr": "A13V1IB3VIYZZH",
|
||||||
|
"amazon.it": "APJ6JRA9NG5V4",
|
||||||
|
"amazon.es": "A1RKKUPIHCS9HS",
|
||||||
|
"amazon.co.uk": "A1F83G8C2ARO7P",
|
||||||
|
"amazon.com": "ATVPDKIKX0DER",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_oauth_authorize_url(application_id: str, redirect_uri: str, domain: str = "amazon.de", state: str = "") -> str:
|
||||||
|
"""Generate the OAuth authorization URL for Amazon Business API consent."""
|
||||||
|
base_url = AB_OAUTH_URLS.get(domain, AB_OAUTH_URLS["amazon.de"])
|
||||||
|
params = {
|
||||||
|
"applicationId": application_id,
|
||||||
|
"state": state or "auth",
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
}
|
||||||
|
return f"{base_url}?{urllib.parse.urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def exchange_auth_code(code: str, client_id: str, client_secret: str, redirect_uri: str) -> dict:
|
||||||
|
"""Exchange authorization code for refresh token via LWA."""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(LWA_TOKEN_URL, data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
})
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"LWA Token-Exchange fehlgeschlagen: {resp.status_code} {resp.text}")
|
||||||
|
return {"error": f"Token-Exchange fehlgeschlagen: {resp.status_code} - {resp.text}"}
|
||||||
|
data = resp.json()
|
||||||
|
logger.info("LWA Token-Exchange erfolgreich")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def get_access_token(client_id: str, client_secret: str, refresh_token: str) -> str | None:
|
||||||
|
"""Get a fresh access token using the refresh token."""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(LWA_TOKEN_URL, data={
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
})
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"Access-Token-Refresh fehlgeschlagen: {resp.status_code} {resp.text}")
|
||||||
|
return None
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("access_token")
|
||||||
|
|
||||||
|
|
||||||
|
async def check_api_configured() -> dict:
|
||||||
|
"""Check if API credentials are configured and valid."""
|
||||||
|
settings = await get_settings()
|
||||||
|
client_id = settings.get("amazon_client_id", "")
|
||||||
|
client_secret = settings.get("amazon_client_secret", "")
|
||||||
|
refresh_token = settings.get("amazon_refresh_token", "")
|
||||||
|
|
||||||
|
if not client_id or not client_secret:
|
||||||
|
return {"configured": False, "authorized": False, "error": "Client-ID oder Client-Secret fehlt"}
|
||||||
|
|
||||||
|
if not refresh_token:
|
||||||
|
return {"configured": True, "authorized": False, "error": "Noch nicht autorisiert (Refresh-Token fehlt)"}
|
||||||
|
|
||||||
|
# Try to get an access token to verify credentials
|
||||||
|
access_token = await get_access_token(client_id, client_secret, refresh_token)
|
||||||
|
if not access_token:
|
||||||
|
return {"configured": True, "authorized": False, "error": "Autorisierung abgelaufen - bitte erneut autorisieren"}
|
||||||
|
|
||||||
|
return {"configured": True, "authorized": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_api_client(settings: dict) -> tuple[httpx.AsyncClient, str] | None:
|
||||||
|
"""Create an authenticated API client. Returns (client, region) or None."""
|
||||||
|
client_id = settings.get("amazon_client_id", "")
|
||||||
|
client_secret = settings.get("amazon_client_secret", "")
|
||||||
|
refresh_token = settings.get("amazon_refresh_token", "")
|
||||||
|
|
||||||
|
if not all([client_id, client_secret, refresh_token]):
|
||||||
|
return None
|
||||||
|
|
||||||
|
access_token = await get_access_token(client_id, client_secret, refresh_token)
|
||||||
|
if not access_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
domain = settings.get("amazon_domain", "amazon.de")
|
||||||
|
region = DOMAIN_REGION.get(domain, "eu")
|
||||||
|
|
||||||
|
client = httpx.AsyncClient(
|
||||||
|
base_url=AB_API_ENDPOINTS.get(region, AB_API_ENDPOINTS["eu"]),
|
||||||
|
headers={
|
||||||
|
"x-amz-access-token": access_token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"user-agent": "Belegimport/1.0 (Language=Python/3.12)",
|
||||||
|
},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
return client, region
|
||||||
|
|
||||||
|
|
||||||
|
async def get_transactions(settings: dict, since_date: datetime) -> list[dict]:
|
||||||
|
"""Get transactions via Reconciliation API."""
|
||||||
|
result = await _get_api_client(settings)
|
||||||
|
if not result:
|
||||||
|
return []
|
||||||
|
|
||||||
|
client, region = result
|
||||||
|
|
||||||
|
transactions = []
|
||||||
|
try:
|
||||||
|
# feedEndDate must not exceed current UTC time
|
||||||
|
now_utc = datetime.utcnow()
|
||||||
|
params = {
|
||||||
|
"feedStartDate": since_date.strftime("%Y-%m-%dT00:00:00Z"),
|
||||||
|
"feedEndDate": now_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
}
|
||||||
|
|
||||||
|
next_token = None
|
||||||
|
page = 0
|
||||||
|
while True:
|
||||||
|
page += 1
|
||||||
|
if next_token:
|
||||||
|
params["nextPageToken"] = next_token
|
||||||
|
|
||||||
|
logger.info(f"Amazon API: Reconciliation-Abfrage Seite {page}...")
|
||||||
|
resp = await client.get(
|
||||||
|
f"/reconciliation/{RECONCILIATION_VERSION}/transactions",
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"Amazon API: Reconciliation fehlgeschlagen: {resp.status_code} {resp.text}")
|
||||||
|
break
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
page_transactions = data.get("transactions", [])
|
||||||
|
transactions.extend(page_transactions)
|
||||||
|
logger.info(f"Amazon API: Seite {page}: {len(page_transactions)} Transaktionen")
|
||||||
|
|
||||||
|
next_token = data.get("nextPageToken")
|
||||||
|
if not next_token:
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Amazon API: Reconciliation-Fehler: {e}")
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
logger.info(f"Amazon API: {len(transactions)} Transaktionen gesamt")
|
||||||
|
return transactions
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_invoice_report(client: httpx.AsyncClient, order_id: str, marketplace_id: str) -> str | None:
|
||||||
|
"""Step 1: Create a report request for invoice PDF."""
|
||||||
|
body = {
|
||||||
|
"reportType": "GET_AB_INVOICE_PDF",
|
||||||
|
"marketplaceIds": [marketplace_id],
|
||||||
|
"reportOptions": {
|
||||||
|
"orderId": order_id,
|
||||||
|
"documentType": "Invoice",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = await client.post(f"/reports/{REPORTS_VERSION}/reports", json=body)
|
||||||
|
if resp.status_code in (200, 202):
|
||||||
|
data = resp.json()
|
||||||
|
report_id = data.get("reportId")
|
||||||
|
logger.info(f"Amazon API: Report erstellt für {order_id}: {report_id}")
|
||||||
|
return report_id
|
||||||
|
else:
|
||||||
|
logger.warning(f"Amazon API: Report-Erstellung fehlgeschlagen für {order_id}: {resp.status_code} {resp.text}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Amazon API: Report-Erstellung Fehler: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _poll_report_status(client: httpx.AsyncClient, report_id: str, max_wait: int = 120) -> str | None:
|
||||||
|
"""Step 2: Poll report status until DONE. Returns reportDocumentId."""
|
||||||
|
for i in range(max_wait // 15 + 1):
|
||||||
|
try:
|
||||||
|
resp = await client.get(f"/reports/{REPORTS_VERSION}/reports/{report_id}")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warning(f"Amazon API: Report-Status fehlgeschlagen: {resp.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
status = data.get("processingStatus", "")
|
||||||
|
|
||||||
|
if status == "DONE":
|
||||||
|
doc_id = data.get("reportDocumentId")
|
||||||
|
logger.info(f"Amazon API: Report {report_id} fertig: documentId={doc_id}")
|
||||||
|
return doc_id
|
||||||
|
elif status in ("CANCELLED", "FATAL"):
|
||||||
|
logger.warning(f"Amazon API: Report {report_id} fehlgeschlagen: {status}")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
logger.debug(f"Amazon API: Report {report_id} Status: {status}, warte...")
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Amazon API: Report-Status Fehler: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.warning(f"Amazon API: Report {report_id} Timeout nach {max_wait}s")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_report_document(client: httpx.AsyncClient, document_id: str) -> bytes | None:
|
||||||
|
"""Step 3: Get presigned URL and download + decompress the PDF."""
|
||||||
|
try:
|
||||||
|
resp = await client.get(f"/reports/{REPORTS_VERSION}/documents/{document_id}")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warning(f"Amazon API: Document-URL fehlgeschlagen: {resp.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
url = data.get("url", "")
|
||||||
|
compression = data.get("compressionAlgorithm", "")
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
logger.warning(f"Amazon API: Keine Download-URL für Document {document_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Download the document (presigned S3 URL, expires in 5 min)
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as dl_client:
|
||||||
|
dl_resp = await dl_client.get(url)
|
||||||
|
if dl_resp.status_code != 200:
|
||||||
|
logger.warning(f"Amazon API: Document-Download fehlgeschlagen: {dl_resp.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = dl_resp.content
|
||||||
|
|
||||||
|
# Decompress: EU documents are gzip-compressed, then the content is a zip file
|
||||||
|
if compression == "GZIP" or content[:2] == b'\x1f\x8b':
|
||||||
|
try:
|
||||||
|
content = gzip.decompress(content)
|
||||||
|
except Exception:
|
||||||
|
pass # might not be gzipped
|
||||||
|
|
||||||
|
# Check if it's a zip file containing the PDF
|
||||||
|
if content[:2] == b'PK':
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(content)) as zf:
|
||||||
|
for name in zf.namelist():
|
||||||
|
if name.lower().endswith('.pdf'):
|
||||||
|
content = zf.read(name)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass # might not be a zip
|
||||||
|
|
||||||
|
# Verify it's a PDF
|
||||||
|
if content[:4] == b'%PDF':
|
||||||
|
logger.info(f"Amazon API: PDF heruntergeladen: {len(content)} Bytes")
|
||||||
|
return content
|
||||||
|
else:
|
||||||
|
logger.warning(f"Amazon API: Heruntergeladenes Dokument ist kein PDF (starts: {content[:20]})")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Amazon API: Document-Download Fehler: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def download_invoice(settings: dict, order_id: str) -> bytes | None:
|
||||||
|
"""Download invoice PDF via Document API (3-step async process)."""
|
||||||
|
result = await _get_api_client(settings)
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
|
||||||
|
client, region = result
|
||||||
|
domain = settings.get("amazon_domain", "amazon.de")
|
||||||
|
marketplace_id = DOMAIN_MARKETPLACE.get(domain, DOMAIN_MARKETPLACE["amazon.de"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 1: Create report
|
||||||
|
report_id = await _create_invoice_report(client, order_id, marketplace_id)
|
||||||
|
if not report_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Step 2: Poll until done
|
||||||
|
document_id = await _poll_report_status(client, report_id)
|
||||||
|
if not document_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Step 3: Download document
|
||||||
|
return await _download_report_document(client, document_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Amazon API: Invoice-Download-Fehler für {order_id}: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def process_amazon_api() -> dict:
|
||||||
|
"""Process Amazon invoices via API (Reconciliation + Document API)."""
|
||||||
|
settings = await get_settings()
|
||||||
|
|
||||||
|
if settings.get("amazon_enabled") != "true":
|
||||||
|
return {"processed": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
# Check API credentials
|
||||||
|
status = await check_api_configured()
|
||||||
|
if not status.get("authorized"):
|
||||||
|
error_msg = status.get("error", "API nicht konfiguriert")
|
||||||
|
logger.warning(f"Amazon API: {error_msg}")
|
||||||
|
return {"processed": 0, "skipped": 0, "errors": 0, "error": error_msg}
|
||||||
|
|
||||||
|
domain = settings.get("amazon_domain", "amazon.de")
|
||||||
|
|
||||||
|
# Determine date range
|
||||||
|
since_str = settings.get("amazon_since_date", "")
|
||||||
|
if since_str:
|
||||||
|
try:
|
||||||
|
since_date = datetime.strptime(since_str, "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
since_date = datetime.now() - timedelta(days=30)
|
||||||
|
else:
|
||||||
|
since_date = datetime.now() - timedelta(days=30)
|
||||||
|
|
||||||
|
logger.info(f"Amazon API: Import gestartet: domain={domain}, seit={since_date.strftime('%Y-%m-%d')}")
|
||||||
|
|
||||||
|
# Connect SMTP
|
||||||
|
import_email = settings.get("import_email_eingang") or settings.get("import_email", "")
|
||||||
|
if not import_email:
|
||||||
|
error_msg = "Keine Import-Email für Eingangsbelege konfiguriert"
|
||||||
|
logger.error(f"Amazon API: {error_msg}")
|
||||||
|
await add_log_entry("Amazon-Import", f"Amazon ({domain})", 0, "error", error_msg, beleg_type="eingang")
|
||||||
|
return {"processed": 0, "skipped": 0, "errors": 1, "error": error_msg}
|
||||||
|
|
||||||
|
smtp = _connect_smtp(settings)
|
||||||
|
if not smtp:
|
||||||
|
error_msg = "SMTP-Verbindung fehlgeschlagen"
|
||||||
|
logger.error(f"Amazon API: {error_msg}")
|
||||||
|
await add_log_entry("Amazon-Import", f"Amazon ({domain})", 0, "error", error_msg, beleg_type="eingang")
|
||||||
|
return {"processed": 0, "skipped": 0, "errors": 1, "error": error_msg}
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get transactions via Reconciliation API
|
||||||
|
transactions = await get_transactions(settings, since_date)
|
||||||
|
|
||||||
|
if not transactions:
|
||||||
|
logger.info("Amazon API: Keine Transaktionen gefunden")
|
||||||
|
await save_settings({"amazon_last_sync": datetime.now().strftime("%Y-%m-%d %H:%M")})
|
||||||
|
await add_log_entry(
|
||||||
|
"Amazon-Import (API)", f"Amazon ({domain})", 0,
|
||||||
|
"success", "Keine neuen Rechnungen gefunden", beleg_type="eingang",
|
||||||
|
)
|
||||||
|
smtp.quit()
|
||||||
|
return {"processed": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
# Extract unique orders with their line items
|
||||||
|
orders = {}
|
||||||
|
for txn in transactions:
|
||||||
|
line_items = txn.get("transactionLineItems", [])
|
||||||
|
for item in line_items:
|
||||||
|
oid = item.get("orderId", "")
|
||||||
|
if oid and oid not in orders:
|
||||||
|
orders[oid] = {
|
||||||
|
"orderId": oid,
|
||||||
|
"invoiceNumber": txn.get("invoiceNumber", ""),
|
||||||
|
"transactionDate": txn.get("transactionDate", ""),
|
||||||
|
}
|
||||||
|
# Fallback: if no line items, use transaction-level orderId
|
||||||
|
if not line_items:
|
||||||
|
oid = txn.get("orderId", "")
|
||||||
|
if oid and oid not in orders:
|
||||||
|
orders[oid] = {
|
||||||
|
"orderId": oid,
|
||||||
|
"invoiceNumber": txn.get("invoiceNumber", ""),
|
||||||
|
"transactionDate": txn.get("transactionDate", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Amazon API: {len(orders)} eindeutige Bestellungen gefunden")
|
||||||
|
|
||||||
|
for oid, order_info in orders.items():
|
||||||
|
# Check if already downloaded
|
||||||
|
if await is_invoice_downloaded(oid, oid):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Download invoice PDF
|
||||||
|
pdf_data = await download_invoice(settings, oid)
|
||||||
|
|
||||||
|
if pdf_data:
|
||||||
|
# Save debug copy if enabled
|
||||||
|
if settings.get("debug_save_amazon_pdfs") == "true":
|
||||||
|
debug_dir = Path("/data/uploads") / "amazon_invoices"
|
||||||
|
debug_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
debug_path = debug_dir / f"Amazon_Rechnung_{oid}.pdf"
|
||||||
|
debug_path.write_bytes(pdf_data)
|
||||||
|
logger.info(f"Amazon API: Debug-PDF gespeichert: {debug_path}")
|
||||||
|
|
||||||
|
# Send via SMTP
|
||||||
|
filename = f"Amazon_Rechnung_{oid}.pdf"
|
||||||
|
subject = f"Amazon Rechnung - {oid}"
|
||||||
|
from_addr = settings.get("smtp_username", "belegimport@local")
|
||||||
|
msg = _build_forward_email(
|
||||||
|
from_addr=from_addr,
|
||||||
|
to_addr=import_email,
|
||||||
|
original_subject=subject,
|
||||||
|
original_from=f"Amazon ({domain})",
|
||||||
|
attachments=[(filename, pdf_data)],
|
||||||
|
)
|
||||||
|
smtp_log = _send_with_log(smtp, msg)
|
||||||
|
await add_log_entry(
|
||||||
|
subject, f"Amazon ({domain})", 1,
|
||||||
|
"success", "", import_email, smtp_log, beleg_type="eingang",
|
||||||
|
)
|
||||||
|
await mark_invoice_downloaded(oid, oid)
|
||||||
|
processed += 1
|
||||||
|
logger.info(f"Amazon API: Rechnung für {oid} gesendet")
|
||||||
|
else:
|
||||||
|
# No invoice available for this order
|
||||||
|
await mark_invoice_downloaded(oid, oid)
|
||||||
|
skipped += 1
|
||||||
|
logger.debug(f"Amazon API: Keine Rechnung für {oid}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Amazon API: Import-Fehler: {e}", exc_info=True)
|
||||||
|
errors += 1
|
||||||
|
await add_log_entry(
|
||||||
|
"Amazon-Import (API)", f"Amazon ({domain})", 0,
|
||||||
|
"error", str(e), beleg_type="eingang",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
smtp.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await save_settings({"amazon_last_sync": datetime.now().strftime("%Y-%m-%d %H:%M")})
|
||||||
|
|
||||||
|
if processed > 0 or errors > 0:
|
||||||
|
summary = f"{processed} verarbeitet, {skipped} übersprungen, {errors} Fehler"
|
||||||
|
await add_log_entry(
|
||||||
|
"Amazon-Import (API, Zusammenfassung)", f"Amazon ({domain})", processed,
|
||||||
|
"success" if errors == 0 else "warning", summary, beleg_type="eingang",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Amazon API: Import fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler")
|
||||||
|
return {"processed": processed, "skipped": skipped, "errors": errors}
|
||||||
|
|
@ -736,12 +736,23 @@ async def _process_amazon_inner() -> dict:
|
||||||
return {"processed": 0, "errors": 0, "error": error_detail}
|
return {"processed": 0, "errors": 0, "error": error_detail}
|
||||||
|
|
||||||
processed, skipped, errors = result["processed"], result["skipped"], result["errors"]
|
processed, skipped, errors = result["processed"], result["skipped"], result["errors"]
|
||||||
|
batch_done = result.get("batch_done", False)
|
||||||
|
|
||||||
# Update last sync date
|
# Update last sync date
|
||||||
await save_settings({"amazon_last_sync": datetime.now().strftime("%Y-%m-%d %H:%M")})
|
await save_settings({"amazon_last_sync": datetime.now().strftime("%Y-%m-%d %H:%M")})
|
||||||
|
|
||||||
# Log summary if nothing was processed
|
# Log summary
|
||||||
if processed == 0 and errors == 0:
|
if processed > 0 and batch_done:
|
||||||
|
summary = f"{processed} Rechnung(en) importiert. Weitere beim nächsten Abruf."
|
||||||
|
await add_log_entry(
|
||||||
|
email_subject="Amazon-Import (Batch)",
|
||||||
|
email_from=f"Amazon ({domain})",
|
||||||
|
attachments_count=processed,
|
||||||
|
status="success",
|
||||||
|
error_message=summary,
|
||||||
|
sent_to=import_email,
|
||||||
|
)
|
||||||
|
elif processed == 0 and errors == 0:
|
||||||
if skipped > 0:
|
if skipped > 0:
|
||||||
summary = f"Alle Rechnungen bereits importiert ({skipped} übersprungen)"
|
summary = f"Alle Rechnungen bereits importiert ({skipped} übersprungen)"
|
||||||
else:
|
else:
|
||||||
|
|
@ -787,13 +798,18 @@ async def _process_amazon_inner() -> dict:
|
||||||
async def _collect_and_process_orders(page, domain, since_date, smtp_conn, settings, import_email) -> dict | None:
|
async def _collect_and_process_orders(page, domain, since_date, smtp_conn, settings, import_email) -> dict | None:
|
||||||
"""Collect orders AND process invoices page by page.
|
"""Collect orders AND process invoices page by page.
|
||||||
|
|
||||||
This ensures invoice buttons are visible when we try to click them,
|
Uses BATCH processing: only processes a limited number of invoices per run
|
||||||
because we process each page's orders before navigating to the next page.
|
to avoid Amazon session degradation. The scheduler will pick up remaining
|
||||||
|
orders in subsequent runs (already-imported orders are skipped automatically).
|
||||||
|
|
||||||
Returns None if session is invalid, otherwise dict with processed/skipped/errors counts.
|
Returns None if session is invalid, otherwise dict with processed/skipped/errors counts.
|
||||||
"""
|
"""
|
||||||
|
MAX_INVOICES_PER_RUN = 2 # Limit to avoid Amazon session issues
|
||||||
|
|
||||||
processed = 0
|
processed = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
|
batch_done = False # Flag: batch limit reached, stop processing
|
||||||
|
|
||||||
# Navigate to orders page if needed
|
# Navigate to orders page if needed
|
||||||
actual_url = page.url
|
actual_url = page.url
|
||||||
|
|
@ -813,6 +829,50 @@ async def _collect_and_process_orders(page, domain, since_date, smtp_conn, setti
|
||||||
if "order-history" not in actual_url and "your-orders" not in actual_url:
|
if "order-history" not in actual_url and "your-orders" not in actual_url:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Reset to page 1 via SPA navigation (NOT page.reload() which kills session!)
|
||||||
|
# Click the "Bestellungen" tab or use the time filter to refresh the order list
|
||||||
|
logger.info(f"Amazon: Refreshe Bestellliste via SPA (aktuelle URL: {actual_url})...")
|
||||||
|
try:
|
||||||
|
refreshed = await page.evaluate("""() => {
|
||||||
|
// Strategy 1: Click the "Bestellungen" tab to reset to page 1
|
||||||
|
const tabs = document.querySelectorAll('a[href*="your-orders"], a[href*="order-history"]');
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const text = (tab.innerText || '').trim();
|
||||||
|
if ((text === 'Bestellungen' || text === 'Orders') && tab.offsetParent !== null) {
|
||||||
|
tab.click();
|
||||||
|
return 'tab';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strategy 2: Click pagination page 1 link
|
||||||
|
const page1Links = document.querySelectorAll('.a-pagination a[href*="pagination/1"], .a-pagination li:first-child a');
|
||||||
|
for (const link of page1Links) {
|
||||||
|
if (link.offsetParent !== null) {
|
||||||
|
link.click();
|
||||||
|
return 'pagination';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strategy 3: Click the time filter to trigger a refresh
|
||||||
|
const filterSelect = document.querySelector('select[name="orderFilter"], select#orderFilter, select#time-filter');
|
||||||
|
if (filterSelect) {
|
||||||
|
// Re-select the current value to trigger change event
|
||||||
|
const event = new Event('change', {bubbles: true});
|
||||||
|
filterSelect.dispatchEvent(event);
|
||||||
|
return 'filter';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}""")
|
||||||
|
if refreshed:
|
||||||
|
logger.info(f"Amazon: Bestellliste refreshed via {refreshed}")
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
try:
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=15000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
logger.info("Amazon: Kein SPA-Refresh möglich, verwende aktuelle Ansicht")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Amazon: SPA-Refresh fehlgeschlagen: {e}")
|
||||||
|
|
||||||
# Try to set time filter
|
# Try to set time filter
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
days_back = (now - since_date).days
|
days_back = (now - since_date).days
|
||||||
|
|
@ -862,8 +922,14 @@ async def _collect_and_process_orders(page, domain, since_date, smtp_conn, setti
|
||||||
logger.info(f"Amazon: Seite {page_num}: {len(page_orders)} gefunden, {len(new_orders)} neu")
|
logger.info(f"Amazon: Seite {page_num}: {len(page_orders)} gefunden, {len(new_orders)} neu")
|
||||||
total_orders += len(new_orders)
|
total_orders += len(new_orders)
|
||||||
|
|
||||||
# Process invoices for THIS page's orders immediately (buttons are visible now)
|
# Process invoices for THIS page's orders immediately
|
||||||
for order in new_orders:
|
for order in new_orders:
|
||||||
|
# Check batch limit
|
||||||
|
if processed >= MAX_INVOICES_PER_RUN:
|
||||||
|
batch_done = True
|
||||||
|
logger.info(f"Amazon: Batch-Limit erreicht ({MAX_INVOICES_PER_RUN} Rechnungen). Rest beim nächsten Abruf.")
|
||||||
|
break
|
||||||
|
|
||||||
order_id = order.get("id", "?")
|
order_id = order.get("id", "?")
|
||||||
try:
|
try:
|
||||||
if await is_invoice_downloaded(order_id, order_id):
|
if await is_invoice_downloaded(order_id, order_id):
|
||||||
|
|
@ -920,7 +986,8 @@ async def _collect_and_process_orders(page, domain, since_date, smtp_conn, setti
|
||||||
)
|
)
|
||||||
|
|
||||||
await mark_invoice_downloaded(order_id, order_id)
|
await mark_invoice_downloaded(order_id, order_id)
|
||||||
await _human_delay(2.0, 4.0)
|
# Long delay between orders to avoid Amazon rate-limiting
|
||||||
|
await _human_delay(8.0, 15.0)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors += 1
|
errors += 1
|
||||||
|
|
@ -933,6 +1000,10 @@ async def _collect_and_process_orders(page, domain, since_date, smtp_conn, setti
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Stop if batch limit reached
|
||||||
|
if batch_done:
|
||||||
|
break
|
||||||
|
|
||||||
# Navigate to next page
|
# Navigate to next page
|
||||||
has_next = await page.evaluate("""() => {
|
has_next = await page.evaluate("""() => {
|
||||||
const nextLink = document.querySelector('.a-pagination .a-last:not(.a-disabled) a');
|
const nextLink = document.querySelector('.a-pagination .a-last:not(.a-disabled) a');
|
||||||
|
|
@ -960,8 +1031,9 @@ async def _collect_and_process_orders(page, domain, since_date, smtp_conn, setti
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
logger.info(f"Amazon: Gesamt {total_orders} Bestellungen auf {page_num} Seite(n)")
|
status = "Batch-Limit" if batch_done else "komplett"
|
||||||
return {"processed": processed, "skipped": skipped, "errors": errors}
|
logger.info(f"Amazon: Gesamt {total_orders} Bestellungen auf {page_num} Seite(n), Status: {status}")
|
||||||
|
return {"processed": processed, "skipped": skipped, "errors": errors, "batch_done": batch_done}
|
||||||
|
|
||||||
|
|
||||||
async def _collect_orders(page, domain: str, since_date: datetime) -> list[dict] | None:
|
async def _collect_orders(page, domain: str, since_date: datetime) -> list[dict] | None:
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_fernet = None
|
_fernet = None
|
||||||
|
|
||||||
ENCRYPTED_KEYS = {"imap_password", "smtp_password", "smb_password", "amazon_password"}
|
ENCRYPTED_KEYS = {"imap_password", "smtp_password", "smb_password", "amazon_password", "amazon_client_secret", "amazon_refresh_token"}
|
||||||
|
|
||||||
DEFAULT_SETTINGS = {
|
DEFAULT_SETTINGS = {
|
||||||
"imap_server": "",
|
"imap_server": "",
|
||||||
|
|
@ -53,6 +53,12 @@ DEFAULT_SETTINGS = {
|
||||||
"amazon_domain": "amazon.de",
|
"amazon_domain": "amazon.de",
|
||||||
"amazon_last_sync": "",
|
"amazon_last_sync": "",
|
||||||
"amazon_since_date": "",
|
"amazon_since_date": "",
|
||||||
|
# Amazon API (SP-API / Business API)
|
||||||
|
"amazon_app_id": "", # amzn1.sp.solution.xxxxx (from Developer Portal)
|
||||||
|
"amazon_client_id": "", # amzn1.application-oa2-client.xxxxx (LWA Client ID)
|
||||||
|
"amazon_client_secret": "", # LWA Client Secret
|
||||||
|
"amazon_refresh_token": "",
|
||||||
|
"amazon_mode": "browser", # "browser" or "api"
|
||||||
# Debug
|
# Debug
|
||||||
"debug_save_amazon_pdfs": "false",
|
"debug_save_amazon_pdfs": "false",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
118
app/main.py
118
app/main.py
|
|
@ -31,6 +31,12 @@ from app.amazon_processor import (
|
||||||
close_interactive_login as amazon_close_interactive,
|
close_interactive_login as amazon_close_interactive,
|
||||||
is_interactive_login_active as amazon_login_active,
|
is_interactive_login_active as amazon_login_active,
|
||||||
)
|
)
|
||||||
|
from app.amazon_api import (
|
||||||
|
get_oauth_authorize_url,
|
||||||
|
exchange_auth_code,
|
||||||
|
check_api_configured,
|
||||||
|
process_amazon_api,
|
||||||
|
)
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO),
|
level=getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO),
|
||||||
|
|
@ -388,6 +394,10 @@ async def api_amazon_settings(request: Request):
|
||||||
"amazon_email": body.get("amazon_email", ""),
|
"amazon_email": body.get("amazon_email", ""),
|
||||||
"amazon_password": body.get("amazon_password") or current.get("amazon_password", ""),
|
"amazon_password": body.get("amazon_password") or current.get("amazon_password", ""),
|
||||||
"amazon_since_date": body.get("amazon_since_date", ""),
|
"amazon_since_date": body.get("amazon_since_date", ""),
|
||||||
|
"amazon_mode": body.get("amazon_mode", "browser"),
|
||||||
|
"amazon_app_id": body.get("amazon_app_id", ""),
|
||||||
|
"amazon_client_id": body.get("amazon_client_id", ""),
|
||||||
|
"amazon_client_secret": body.get("amazon_client_secret") or current.get("amazon_client_secret", ""),
|
||||||
}
|
}
|
||||||
await save_settings(data)
|
await save_settings(data)
|
||||||
return JSONResponse({"success": True})
|
return JSONResponse({"success": True})
|
||||||
|
|
@ -395,9 +405,110 @@ async def api_amazon_settings(request: Request):
|
||||||
|
|
||||||
@app.get("/api/amazon-status")
|
@app.get("/api/amazon-status")
|
||||||
async def api_amazon_status():
|
async def api_amazon_status():
|
||||||
|
settings = await get_settings()
|
||||||
|
mode = settings.get("amazon_mode", "browser")
|
||||||
|
|
||||||
|
if mode == "api":
|
||||||
|
api_status = await check_api_configured()
|
||||||
|
return JSONResponse({
|
||||||
|
"mode": "api",
|
||||||
|
"session_valid": api_status.get("authorized", False),
|
||||||
|
"login_active": False,
|
||||||
|
"api_configured": api_status.get("configured", False),
|
||||||
|
"api_authorized": api_status.get("authorized", False),
|
||||||
|
})
|
||||||
|
else:
|
||||||
valid = await amazon_check_session()
|
valid = await amazon_check_session()
|
||||||
login_active = amazon_login_active()
|
login_active = amazon_login_active()
|
||||||
return JSONResponse({"session_valid": valid, "login_active": login_active})
|
return JSONResponse({
|
||||||
|
"mode": "browser",
|
||||||
|
"session_valid": valid,
|
||||||
|
"login_active": login_active,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _get_oauth_redirect_uri(request: Request) -> str:
|
||||||
|
"""Get OAuth redirect URI from env var or request."""
|
||||||
|
base = os.environ.get("OAUTH_REDIRECT_BASE", "").rstrip("/")
|
||||||
|
if not base:
|
||||||
|
base = str(request.base_url).rstrip("/")
|
||||||
|
return f"{base}/api/amazon-oauth-callback"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/amazon-oauth-url")
|
||||||
|
async def api_amazon_oauth_url(request: Request):
|
||||||
|
"""Generate OAuth authorization URL for Amazon Business API."""
|
||||||
|
settings = await get_settings()
|
||||||
|
app_id = settings.get("amazon_app_id", "")
|
||||||
|
if not app_id:
|
||||||
|
return JSONResponse({"error": "App-ID nicht konfiguriert"}, status_code=400)
|
||||||
|
|
||||||
|
redirect_uri = _get_oauth_redirect_uri(request)
|
||||||
|
domain = settings.get("amazon_domain", "amazon.de")
|
||||||
|
state = str(uuid.uuid4())
|
||||||
|
|
||||||
|
url = get_oauth_authorize_url(app_id, redirect_uri, domain, state)
|
||||||
|
return JSONResponse({"url": url, "state": state})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/amazon-oauth-callback")
|
||||||
|
async def api_amazon_oauth_callback(request: Request):
|
||||||
|
"""Handle OAuth callback from Amazon."""
|
||||||
|
code = request.query_params.get("spapi_oauth_code") or request.query_params.get("code", "")
|
||||||
|
error = request.query_params.get("error", "")
|
||||||
|
|
||||||
|
if error:
|
||||||
|
return HTMLResponse(f"<h2>Autorisierung fehlgeschlagen</h2><p>{error}</p><p>Fenster kann geschlossen werden.</p>")
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
return HTMLResponse("<h2>Fehler: Kein Autorisierungscode erhalten</h2><p>Fenster kann geschlossen werden.</p>")
|
||||||
|
|
||||||
|
settings = await get_settings()
|
||||||
|
client_id = settings.get("amazon_client_id", "")
|
||||||
|
client_secret = settings.get("amazon_client_secret", "")
|
||||||
|
redirect_uri = _get_oauth_redirect_uri(request)
|
||||||
|
|
||||||
|
result = await exchange_auth_code(code, client_id, client_secret, redirect_uri)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
return HTMLResponse(f"<h2>Token-Exchange fehlgeschlagen</h2><p>{result['error']}</p>")
|
||||||
|
|
||||||
|
refresh_token = result.get("refresh_token", "")
|
||||||
|
if refresh_token:
|
||||||
|
await save_settings({"amazon_refresh_token": refresh_token})
|
||||||
|
return HTMLResponse(
|
||||||
|
"<h2>Autorisierung erfolgreich!</h2>"
|
||||||
|
"<p>Refresh-Token wurde gespeichert. Dieses Fenster kann geschlossen werden.</p>"
|
||||||
|
"<script>window.close();</script>"
|
||||||
|
)
|
||||||
|
|
||||||
|
return HTMLResponse("<h2>Fehler: Kein Refresh-Token erhalten</h2>")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/amazon-oauth-exchange")
|
||||||
|
async def api_amazon_oauth_exchange(request: Request):
|
||||||
|
"""Manual OAuth code exchange - user pastes the code from the redirect URL."""
|
||||||
|
body = await request.json()
|
||||||
|
code = body.get("code", "").strip()
|
||||||
|
if not code:
|
||||||
|
return JSONResponse({"error": "Kein Code angegeben"}, status_code=400)
|
||||||
|
|
||||||
|
settings = await get_settings()
|
||||||
|
client_id = settings.get("amazon_client_id", "")
|
||||||
|
client_secret = settings.get("amazon_client_secret", "")
|
||||||
|
redirect_uri = _get_oauth_redirect_uri(request)
|
||||||
|
|
||||||
|
result = await exchange_auth_code(code, client_id, client_secret, redirect_uri)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
return JSONResponse({"error": result["error"]}, status_code=400)
|
||||||
|
|
||||||
|
refresh_token = result.get("refresh_token", "")
|
||||||
|
if refresh_token:
|
||||||
|
await save_settings({"amazon_refresh_token": refresh_token})
|
||||||
|
return JSONResponse({"success": True})
|
||||||
|
|
||||||
|
return JSONResponse({"error": "Kein Refresh-Token erhalten"}, status_code=400)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/amazon-login")
|
@app.post("/api/amazon-login")
|
||||||
|
|
@ -462,6 +573,11 @@ async def api_amazon_logout():
|
||||||
|
|
||||||
@app.post("/api/amazon-process")
|
@app.post("/api/amazon-process")
|
||||||
async def api_amazon_process():
|
async def api_amazon_process():
|
||||||
|
settings = await get_settings()
|
||||||
|
mode = settings.get("amazon_mode", "browser")
|
||||||
|
if mode == "api":
|
||||||
|
result = await process_amazon_api()
|
||||||
|
else:
|
||||||
result = await process_amazon()
|
result = await process_amazon()
|
||||||
return JSONResponse(result)
|
return JSONResponse(result)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ 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
|
from app.amazon_processor import process_amazon
|
||||||
|
from app.amazon_api import process_amazon_api
|
||||||
|
from app.database import get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -34,6 +36,11 @@ async def _run_processor():
|
||||||
# Amazon separately with timeout - must not block next scheduler runs
|
# Amazon separately with timeout - must not block next scheduler runs
|
||||||
logger.info("Starte automatische Amazon-Verarbeitung...")
|
logger.info("Starte automatische Amazon-Verarbeitung...")
|
||||||
try:
|
try:
|
||||||
|
settings = await get_settings()
|
||||||
|
amazon_mode = settings.get("amazon_mode", "browser")
|
||||||
|
if amazon_mode == "api":
|
||||||
|
amazon_result = await asyncio.wait_for(process_amazon_api(), timeout=300)
|
||||||
|
else:
|
||||||
amazon_result = await asyncio.wait_for(process_amazon(), timeout=300)
|
amazon_result = await asyncio.wait_for(process_amazon(), timeout=300)
|
||||||
logger.info(f"Amazon-Verarbeitung abgeschlossen: {amazon_result}")
|
logger.info(f"Amazon-Verarbeitung abgeschlossen: {amazon_result}")
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,13 @@
|
||||||
<option value="true" {% if settings.get('amazon_enabled') == 'true' %}selected{% endif %}>Aktiviert</option>
|
<option value="true" {% if settings.get('amazon_enabled') == 'true' %}selected{% endif %}>Aktiviert</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="amazon_mode">Abruf-Modus</label>
|
||||||
|
<select id="amazon_mode" name="amazon_mode" onchange="toggleAmazonMode()">
|
||||||
|
<option value="api" {% if settings.get('amazon_mode') == 'api' %}selected{% endif %}>API (empfohlen)</option>
|
||||||
|
<option value="browser" {% if settings.get('amazon_mode', 'browser') == 'browser' %}selected{% endif %}>Browser-Automation</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="amazon_domain">Amazon-Domain</label>
|
<label for="amazon_domain">Amazon-Domain</label>
|
||||||
<select id="amazon_domain" name="amazon_domain">
|
<select id="amazon_domain" name="amazon_domain">
|
||||||
|
|
@ -25,6 +32,52 @@
|
||||||
<option value="amazon.com" {% if settings.get('amazon_domain') == 'amazon.com' %}selected{% endif %}>amazon.com</option>
|
<option value="amazon.com" {% if settings.get('amazon_domain') == 'amazon.com' %}selected{% endif %}>amazon.com</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
<!-- API Mode Fields -->
|
||||||
|
<div id="apiFields" style="{% if settings.get('amazon_mode', 'browser') != 'api' %}display:none;{% endif %}margin-top:1rem;">
|
||||||
|
<h3 style="font-size:1rem;margin-bottom:0.75rem;">API-Zugangsdaten (Amazon Business API)</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="amazon_app_id">App-ID</label>
|
||||||
|
<input type="text" id="amazon_app_id" name="amazon_app_id"
|
||||||
|
value="{{ settings.get('amazon_app_id', '') }}"
|
||||||
|
placeholder="amzn1.sp.solution.xxxxx">
|
||||||
|
<small class="text-muted">Aus dem Solution Provider Portal</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="amazon_client_id">Client-ID (LWA)</label>
|
||||||
|
<input type="text" id="amazon_client_id" name="amazon_client_id"
|
||||||
|
value="{{ settings.get('amazon_client_id', '') }}"
|
||||||
|
placeholder="amzn1.application-oa2-client.xxxxx">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="amazon_client_secret">Client-Sicherheitsschluessel (LWA)</label>
|
||||||
|
<input type="password" id="amazon_client_secret" name="amazon_client_secret"
|
||||||
|
placeholder="{% if settings.get('amazon_client_secret') %}(gespeichert){% else %}Client Secret eingeben{% endif %}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="align-self:end;">
|
||||||
|
<small class="text-muted">
|
||||||
|
{% if settings.get('amazon_refresh_token') %}
|
||||||
|
Refresh-Token: gespeichert
|
||||||
|
{% else %}
|
||||||
|
Refresh-Token: fehlt - bitte autorisieren
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Browser Mode Fields -->
|
||||||
|
<div id="browserFields" style="{% if settings.get('amazon_mode', 'browser') != 'browser' %}display:none;{% endif %}margin-top:1rem;">
|
||||||
|
<h3 style="font-size:1rem;margin-bottom:0.75rem;">Browser-Zugangsdaten</h3>
|
||||||
|
<div class="form-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="amazon_email">Amazon E-Mail</label>
|
<label for="amazon_email">Amazon E-Mail</label>
|
||||||
<input type="email" id="amazon_email" name="amazon_email"
|
<input type="email" id="amazon_email" name="amazon_email"
|
||||||
|
|
@ -36,18 +89,14 @@
|
||||||
<input type="password" id="amazon_password" name="amazon_password"
|
<input type="password" id="amazon_password" name="amazon_password"
|
||||||
placeholder="{% if settings.get('amazon_password') %}(gespeichert){% else %}Passwort eingeben{% endif %}">
|
placeholder="{% if settings.get('amazon_password') %}(gespeichert){% else %}Passwort eingeben{% endif %}">
|
||||||
</div>
|
</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>
|
||||||
<div class="form-group" style="align-self:end;">
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:1rem;">
|
||||||
{% if settings.get('amazon_last_sync') %}
|
{% if settings.get('amazon_last_sync') %}
|
||||||
<small class="text-muted">Letzter Abruf: {{ settings.get('amazon_last_sync') }}</small>
|
<small class="text-muted">Letzter Abruf: {{ settings.get('amazon_last_sync') }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="form-actions" style="margin-top:1rem;">
|
<div class="form-actions" style="margin-top:1rem;">
|
||||||
<button type="button" class="btn btn-primary" onclick="saveAmazonSettings()">Einstellungen speichern</button>
|
<button type="button" class="btn btn-primary" onclick="saveAmazonSettings()">Einstellungen speichern</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -57,16 +106,41 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Anmeldung & Abruf</h2>
|
<h2>Anmeldung & Abruf</h2>
|
||||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;">
|
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;">
|
||||||
<span>Session:</span>
|
<span>Status:</span>
|
||||||
<span id="sessionBadge" class="badge badge-inactive">Wird geprüft...</span>
|
<span id="sessionBadge" class="badge badge-inactive">Wird geprüft...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- API Mode Buttons -->
|
||||||
|
<div id="apiButtons" style="display:none;">
|
||||||
|
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:center;">
|
||||||
|
<button type="button" id="btnOAuth" class="btn btn-primary" onclick="doOAuth()">Bei Amazon autorisieren</button>
|
||||||
|
<button type="button" id="btnProcessApi" 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>
|
||||||
|
<!-- Manual code exchange for local setups -->
|
||||||
|
<div id="oauthCodeBox" style="display:none;margin-top:1rem;">
|
||||||
|
<p class="text-muted" style="font-size:0.85rem;">
|
||||||
|
Nach der Autorisierung bei Amazon werden Sie zu einer Seite weitergeleitet die nicht laden wird.
|
||||||
|
Kopieren Sie den Wert von <code>spapi_oauth_code</code> aus der URL-Leiste und tragen ihn hier ein:
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;gap:0.5rem;align-items:center;">
|
||||||
|
<input type="text" id="oauthCode" placeholder="spapi_oauth_code hier einfuegen..."
|
||||||
|
style="flex:1;padding:0.5rem;border:1px solid var(--border-color);border-radius:4px;background:var(--input-bg);color:var(--text-color);">
|
||||||
|
<button type="button" class="btn btn-primary" onclick="exchangeCode()">Token tauschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Browser Mode Buttons -->
|
||||||
|
<div id="browserButtons">
|
||||||
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;">
|
<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="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="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" 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>
|
<button type="button" class="btn btn-secondary" onclick="doReset()">Importierte zurücksetzen</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="processMsg" style="margin-top:0.75rem;"></div>
|
<div id="processMsg" style="margin-top:0.75rem;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -97,6 +171,16 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// --- Mode Toggle ---
|
||||||
|
function toggleAmazonMode() {
|
||||||
|
const mode = document.getElementById('amazon_mode').value;
|
||||||
|
document.getElementById('apiFields').style.display = mode === 'api' ? '' : 'none';
|
||||||
|
document.getElementById('browserFields').style.display = mode === 'browser' ? '' : 'none';
|
||||||
|
document.getElementById('apiButtons').style.display = mode === 'api' ? '' : 'none';
|
||||||
|
document.getElementById('browserButtons').style.display = mode === 'browser' ? '' : 'none';
|
||||||
|
checkSession();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Settings ---
|
// --- Settings ---
|
||||||
async function saveAmazonSettings() {
|
async function saveAmazonSettings() {
|
||||||
const btn = event.target;
|
const btn = event.target;
|
||||||
|
|
@ -106,10 +190,14 @@ async function saveAmazonSettings() {
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
amazon_enabled: document.getElementById('amazon_enabled').value,
|
amazon_enabled: document.getElementById('amazon_enabled').value,
|
||||||
|
amazon_mode: document.getElementById('amazon_mode').value,
|
||||||
amazon_domain: document.getElementById('amazon_domain').value,
|
amazon_domain: document.getElementById('amazon_domain').value,
|
||||||
amazon_email: document.getElementById('amazon_email').value,
|
amazon_email: document.getElementById('amazon_email').value,
|
||||||
amazon_password: document.getElementById('amazon_password').value,
|
amazon_password: document.getElementById('amazon_password').value,
|
||||||
amazon_since_date: document.getElementById('amazon_since_date').value,
|
amazon_since_date: document.getElementById('amazon_since_date').value,
|
||||||
|
amazon_app_id: document.getElementById('amazon_app_id').value,
|
||||||
|
amazon_client_id: document.getElementById('amazon_client_id').value,
|
||||||
|
amazon_client_secret: document.getElementById('amazon_client_secret').value,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -142,6 +230,27 @@ async function checkSession() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/amazon-status');
|
const resp = await fetch('/api/amazon-status');
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.mode === 'api') {
|
||||||
|
// API mode
|
||||||
|
if (data.api_authorized) {
|
||||||
|
badge.className = 'badge badge-success';
|
||||||
|
badge.textContent = 'API autorisiert';
|
||||||
|
document.getElementById('btnOAuth').style.display = 'none';
|
||||||
|
document.getElementById('btnProcessApi').style.display = '';
|
||||||
|
} else if (data.api_configured) {
|
||||||
|
badge.className = 'badge badge-warning';
|
||||||
|
badge.textContent = 'Nicht autorisiert';
|
||||||
|
document.getElementById('btnOAuth').style.display = '';
|
||||||
|
document.getElementById('btnProcessApi').style.display = 'none';
|
||||||
|
} else {
|
||||||
|
badge.className = 'badge badge-inactive';
|
||||||
|
badge.textContent = 'API nicht konfiguriert';
|
||||||
|
document.getElementById('btnOAuth').style.display = 'none';
|
||||||
|
document.getElementById('btnProcessApi').style.display = 'none';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Browser mode
|
||||||
if (data.login_active) {
|
if (data.login_active) {
|
||||||
badge.className = 'badge badge-warning';
|
badge.className = 'badge badge-warning';
|
||||||
badge.textContent = 'Login läuft...';
|
badge.textContent = 'Login läuft...';
|
||||||
|
|
@ -158,12 +267,69 @@ async function checkSession() {
|
||||||
document.getElementById('btnLogout').style.display = 'none';
|
document.getElementById('btnLogout').style.display = 'none';
|
||||||
document.getElementById('btnProcess').style.display = 'none';
|
document.getElementById('btnProcess').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
badge.className = 'badge badge-inactive';
|
badge.className = 'badge badge-inactive';
|
||||||
badge.textContent = 'Unbekannt';
|
badge.textContent = 'Unbekannt';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- OAuth Authorization (API mode) ---
|
||||||
|
async function doOAuth() {
|
||||||
|
const msgEl = document.getElementById('processMsg');
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/amazon-oauth-url');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.url) {
|
||||||
|
window.open(data.url, '_blank');
|
||||||
|
// Show code input box for manual exchange
|
||||||
|
document.getElementById('oauthCodeBox').style.display = '';
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-info">Amazon-Autorisierungsseite wurde geöffnet. Nach der Autorisierung den Code aus der URL kopieren und unten eintragen.</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>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeCode() {
|
||||||
|
const codeInput = document.getElementById('oauthCode');
|
||||||
|
let code = codeInput.value.trim();
|
||||||
|
const msgEl = document.getElementById('processMsg');
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-error">Bitte den Code eintragen</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user pasted the full URL, extract the code
|
||||||
|
if (code.includes('spapi_oauth_code=')) {
|
||||||
|
const url = new URL(code.startsWith('http') ? code : 'https://x.com?' + code);
|
||||||
|
code = url.searchParams.get('spapi_oauth_code') || code;
|
||||||
|
}
|
||||||
|
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-info">Token wird getauscht...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/amazon-oauth-exchange', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({code}),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-success">Erfolgreich autorisiert! Refresh-Token gespeichert.</div>';
|
||||||
|
document.getElementById('oauthCodeBox').style.display = 'none';
|
||||||
|
checkSession();
|
||||||
|
} else {
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-error">' + escapeHtml(data.error || 'Token-Exchange fehlgeschlagen') + '</div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
msgEl.innerHTML = '<div class="alert alert-error">Verbindungsfehler</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Interactive Browser Login ---
|
// --- Interactive Browser Login ---
|
||||||
let screenshotInterval = null;
|
let screenshotInterval = null;
|
||||||
let loginPollInterval = null;
|
let loginPollInterval = null;
|
||||||
|
|
@ -357,7 +523,8 @@ function escapeHtml(str) {
|
||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial check
|
// Initial setup
|
||||||
|
toggleAmazonMode();
|
||||||
checkSession();
|
checkSession();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,5 @@ services:
|
||||||
- DB_PATH=/data/belegimport.db
|
- DB_PATH=/data/belegimport.db
|
||||||
- TZ=Europe/Berlin
|
- TZ=Europe/Berlin
|
||||||
- LOG_LEVEL=DEBUG
|
- LOG_LEVEL=DEBUG
|
||||||
|
- OAUTH_REDIRECT_BASE=https://hacker-net.de
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,4 @@ sse-starlette==2.2.1
|
||||||
smbprotocol==1.14.0
|
smbprotocol==1.14.0
|
||||||
playwright==1.49.1
|
playwright==1.49.1
|
||||||
playwright-stealth==2.0.2
|
playwright-stealth==2.0.2
|
||||||
|
httpx==0.28.1
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue