[32788f42] Keine Zusammenfassung angegeben.

Keine Zusammenfassung angegeben.
This commit is contained in:
2026-04-07 18:10:46 +00:00
parent 229ad10e6b
commit 831ec7e71c
4 changed files with 97 additions and 59 deletions

View File

@@ -1 +1 @@
{"task_id": "2f988f42-8544-800e-abc1-d1b1c56ade4d", "token": "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", "readme_path": "readme.md", "session_start_time": "2026-03-25T12:11:18.223098"}
{"task_id": "32788f42-8544-80e1-a13a-c26114cf9b34", "token": "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", "readme_path": "readme.md", "session_start_time": "2026-04-07T18:10:43.355316"}

View File

@@ -21,29 +21,30 @@ Der Service besteht aus zwei Hauptkomponenten:
## ✨ Core Features
### Feature 1: Teilnehmerlisten (Vollständig)
Automatisierter Workflow zum Download und Formatieren der Anmeldelisten von `fotograf.de` als sortiertes PDF (Klassen/Gruppen) inkl. "Kinderfotos Erding" Branding.
Automatisierter Workflow zum Download und Formatieren der Anmeldelisten von `fotograf.de` als sortiertes PDF inkl. "Kinderfotos Erding" Branding.
* **Dynamische Terminologie:** Automatische Anpassung des Wordings basierend auf dem Profil (Kiga: "Kinder"/"Gruppen" vs. Schule: "Schüler"/"Klassen").
* **Intelligentes Datum:** Extraktion des echten Auftragsdatums aus der Datenbank mit automatischer Erweiterung auf einen 2-Tages-Zeitraum (z.B. "15. + 16.04.2026").
### Feature 2: Shooting-Planung (QR-Karten & Terminliste)
Spezielles Modul für Familien-Mini-Shootings, direkt integriert in die Auftragsdetails:
* **Dynamische Event-Auswahl:** Wähle direkt aus deinen Calendly-Event-Typen (z.B. "Neuching") aus.
* **Termin-Filter:** Zeigt nur noch aktuelle und zukünftige Buchungen an (alte recycelte Termine werden ignoriert).
* **QR-Karten-Andruck:**
* Präzises Overlay von Name, Kinderanzahl und Uhrzeit auf vorbereitete QR-Code-Bögen.
* **Korrektur:** Y-Achse um 9mm nach unten verschoben für perfekten Sitz auf den Linien.
* **Einwilligungs-Checkbox (☑):** Automatischer Andruck eines Häkchens, wenn in Calendly der Veröffentlichung zugestimmt wurde.
* **Termin-Übersichtsliste (Neu):**
* Generiert eine A4-Tabelle für den Shooting-Tag.
* **6-Minuten-Takt:** Erzeugt automatisch ein Raster basierend auf dem ersten und letzten Termin, füllt Lücken für nicht gebuchte Slots leer auf.
* Inklusive Spalten für Name, Kinder, Veröffentlichungs-Status und einer Checkbox zum manuellen Abhaken.
* **Termin-Übersichtsliste:**
* Generiert eine A4-Tabelle für den Shooting-Tag im 6-Minuten-Takt.
* Füllt Lücken für nicht gebuchte Slots automatisch leer auf.
### Feature 3: Nachfass-E-Mails (Geplant)
### Feature 3: Nachfass-E-Mails (Vollständig)
* Identifizierung von Käufern/Nicht-Käufern zur Generierung von CSV-Listen für den Supermailer.
### Feature 4: Verkaufs-Statistiken (Vollständig)
* Detaillierte Analyse des Kaufverhaltens pro Album mit Echtzeit-Fortschrittsanzeige im Browser.
## 🛠️ Technische Details & Fixes (März 2026)
## 🛠️ Technische Details & Fixes (April 2026)
* **Zeitzonen:** Automatische Konversion von Calendly-UTC-Zeiten in die lokale Zeit (`Europe/Berlin`).
* **Pagination Fix:** Das Backend blättert nun durch alle Calendly-Seiten, um sicherzustellen, dass keine Buchungen (auch bei großen Event-Historien) übersehen werden.
* **Pagination Fix:** Das Backend blättert durch alle Calendly-Seiten für lückenlose Daten.
* **Logo-Integration:** Dynamisches Einbetten des Firmenlogos in alle generierten Dokumente.
## 🚀 Deployment & Konfiguration
@@ -52,9 +53,10 @@ Der Service wird über eine eigene `docker-compose.yml` im Unterverzeichnis gest
### Umgebungsvariablen (`.env`)
Folgende Variablen müssen in der `.env` im Verzeichnis `/fotograf-de-scraper/` definiert sein:
* `KIGA_USER` / `KIGA_PW` / `SCHULE_USER` / `SCHULE_PW`: Logins.
* `KIGA_USER` / `KIGA_PW` / `SCHULE_USER` / `SCHULE_PW`: Logins für Fotograf.de.
* `CALENDLY_TOKEN`: Personal Access Token (JWT) von Calendly.
### URLs & Ports
* **Frontend:** `http://<HOST_IP>:3009` | **Backend:** `http://<HOST_IP>:8002`
* **Persistenz:** Datenbank unter `./backend/data/fotograf_jobs.db`.
* **Scraper Frontend:** `http://192.168.178.6:3009`
* **Zentrales Dashboard:** `http://192.168.178.6:8090`
* **Persistenz:** Datenbank unter `./backend/data/fotograf_jobs.db`.

View File

@@ -36,6 +36,22 @@ logging.basicConfig(
)
logger = logging.getLogger("fotograf-scraper")
def format_job_date(date_str: str) -> str:
import re
import datetime
# Sucht nach einem Datum im Format DD.MM.YYYY
match = re.search(r'(\d{2})\.(\d{2})\.(\d{4})', date_str)
if match:
try:
day, month, year = match.groups()
dt = datetime.datetime(int(year), int(month), int(day))
next_day = dt + datetime.timedelta(days=1)
# Format: 15. + 16.04.2026
return f"{dt.day:02d}. + {next_day.strftime('%d.%m.%Y')}"
except Exception:
pass
return date_str
# Load environment variables
load_dotenv()
@@ -119,8 +135,8 @@ def generate_pdf_from_csv(csv_path: str, institution: str, date_info: str, list_
df.columns = df.columns.str.strip().str.replace("\"", "")
logger.debug(f"CSV Columns: {list(df.columns)}")
group_label = "Gruppe" if list_type == 'k' else "Klasse"
person_label_plural = "Kinder" if list_type == 'k' else "Schüler"
group_label = "Gruppe" if list_type.startswith('kiga') else "Klasse"
person_label_plural = "Kinder" if list_type.startswith('kiga') else "Schüler"
col_mapping = {}
for col in df.columns:
@@ -806,13 +822,32 @@ async def generate_appointment_list(job_id: str, event_type_name: str, db: Sessi
if not raw_events:
return JSONResponse(status_code=404, content={"message": "Keine passenden Termine für diesen Event-Typ gefunden."})
# Filter out old events (keep only today and future)
from zoneinfo import ZoneInfo
now_berlin = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
midnight_today = now_berlin.replace(hour=0, minute=0, second=0, microsecond=0)
future_events = []
for event in raw_events:
try:
start_dt = datetime.datetime.fromisoformat(event['start_time'].replace('Z', '+00:00'))
start_dt_berlin = start_dt.astimezone(ZoneInfo("Europe/Berlin"))
if start_dt_berlin >= midnight_today:
future_events.append(event)
except Exception as e:
logger.warning(f"Error parsing date for event: {e}")
future_events.append(event) # Fallback: keep event if date parsing fails
if not future_events:
return JSONResponse(status_code=404, content={"message": "Keine zukünftigen Termine für diesen Event-Typ gefunden."})
# 3. Generate PDF
temp_dir = tempfile.gettempdir()
output_name = f"Terminuebersicht_{job_id}_{datetime.datetime.now().strftime('%Y%m%d')}.pdf"
output_path = os.path.join(temp_dir, output_name)
try:
generate_appointment_overview_pdf(raw_events, job_name_clean, event_type_name, output_path)
generate_appointment_overview_pdf(future_events, job_name_clean, event_type_name, output_path)
return FileResponse(path=output_path, filename=output_name, media_type="application/pdf")
except Exception as e:
logger.error(f"Error generating appointment overview pdf: {e}")
@@ -939,7 +974,7 @@ async def download_task_csv(task_id: str):
raise HTTPException(status_code=500, detail="CSV Export fehlgeschlagen.")
@app.get("/api/jobs/{job_id}/generate-pdf")
async def generate_pdf(job_id: str, account_type: str):
async def generate_pdf(job_id: str, account_type: str, db: Session = Depends(get_db)):
logger.info(f"API Request: Generate PDF for job {job_id} ({account_type})")
username = os.getenv(f"{account_type.upper()}_USER")
password = os.getenv(f"{account_type.upper()}_PW")
@@ -966,8 +1001,9 @@ async def generate_pdf(job_id: str, account_type: str):
# 1.5 Click on the "Personen" tab
logger.info("Clicking on 'Personen' tab...")
personen_tab = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "[data-qa-id='link:photo-jobs-tabs-names_list']")))
personen_tab.click()
personen_tab = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "[data-qa-id='link:photo-jobs-tabs-names_list']")))
# Use JS click to avoid 'element click intercepted' errors from loading overlays
driver.execute_script("arguments[0].click();", personen_tab)
# Wait for the export button to become present on the new tab
logger.info("Waiting for Export Dropdown...")
@@ -1013,10 +1049,17 @@ async def generate_pdf(job_id: str, account_type: str):
output_pdf_name = f"Listen_{job_id}.pdf"
output_pdf_path = os.path.join(temp_dir, output_pdf_name)
# Hole Auftragsdatum aus der Datenbank, falls vorhanden
job_record = db.query(DBJob).filter(DBJob.id == job_id).first()
if job_record and job_record.date:
final_date_info = format_job_date(job_record.date)
else:
final_date_info = datetime.datetime.now().strftime("%d.%m.%Y")
generate_pdf_from_csv(
csv_path=csv_file,
institution=institution,
date_info=datetime.datetime.now().strftime("%d.%m.%Y"),
date_info=final_date_info,
list_type=account_type,
output_path=output_pdf_path
)

View File

@@ -171,24 +171,18 @@ function App() {
setProcessingJobId(job.id);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/jobs/${job.id}/generate-pdf?account_type=${activeTab}`);
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.detail || 'PDF Generierung fehlgeschlagen');
}
// Direkter Download über die URL, umgeht das Blob/Mixed-Content-Sicherheitsproblem in Chrome.
const downloadUrl = `${API_BASE_URL}/api/jobs/${job.id}/generate-pdf?account_type=${activeTab}`;
window.open(downloadUrl, '_blank');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Listen_${job.name.replace(/\s+/g, "_")}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
// Wir setzen einen künstlichen Timeout für den Lade-Indikator,
// da wir bei window.open nicht wissen, wann der Download fertig ist.
setTimeout(() => {
setProcessingJobId(null);
}, 3000);
} catch (err: any) {
setError(`PDF Fehler (${job.name}): ${err.message}`);
} finally {
setProcessingJobId(null);
}
};
@@ -228,8 +222,12 @@ function App() {
a.download = `QR_Karten_Andruck_${job.id}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
// Delay removal and revocation to ensure download starts in all browsers
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 100);
} catch (err: any) {
setError(err.message);
} finally {
@@ -241,28 +239,14 @@ function App() {
setIsListGenerating(true);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/jobs/${job.id}/appointment-list?event_type_name=${encodeURIComponent(selectedEventType)}`);
const downloadUrl = `${API_BASE_URL}/api/jobs/${job.id}/appointment-list?event_type_name=${encodeURIComponent(selectedEventType)}`;
window.open(downloadUrl, '_blank');
if (!response.ok) {
if (response.status === 404) {
throw new Error("Keine passenden Calendly-Termine gefunden.");
}
const errData = await response.json();
throw new Error(errData.detail || 'PDF Generierung fehlgeschlagen');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Terminuebersicht_${job.name.replace(/\s+/g, "_")}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
setTimeout(() => {
setIsListGenerating(false);
}, 3000);
} catch (err: any) {
setError(`Listen-Fehler (${job.name}): ${err.message}`);
} finally {
setIsListGenerating(false);
}
};
@@ -300,9 +284,18 @@ function App() {
{/* Top Navigation Bar */}
<header className="bg-white shadow-sm sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl">📸</span>
<h1 className="text-xl font-bold text-gray-800 tracking-tight">Fotograf.de ERP</h1>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-2xl">📸</span>
<h1 className="text-xl font-bold text-gray-800 tracking-tight">Fotograf.de ERP</h1>
</div>
<a
href="http://192.168.178.6:8090"
className="hidden sm:flex text-xs font-medium text-gray-500 hover:text-indigo-600 transition-colors items-center gap-1 bg-gray-50 px-2 py-1 rounded border border-gray-200"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
Zum Dashboard
</a>
</div>
{/* Main Tabs */}