[32788f42] Keine Zusammenfassung angegeben.
Keine Zusammenfassung angegeben.
This commit is contained in:
@@ -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"}
|
||||||
@@ -21,29 +21,30 @@ Der Service besteht aus zwei Hauptkomponenten:
|
|||||||
## ✨ Core Features
|
## ✨ Core Features
|
||||||
|
|
||||||
### Feature 1: Teilnehmerlisten (Vollständig)
|
### 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)
|
### Feature 2: Shooting-Planung (QR-Karten & Terminliste)
|
||||||
Spezielles Modul für Familien-Mini-Shootings, direkt integriert in die Auftragsdetails:
|
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.
|
* **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:**
|
* **QR-Karten-Andruck:**
|
||||||
* Präzises Overlay von Name, Kinderanzahl und Uhrzeit auf vorbereitete QR-Code-Bögen.
|
* 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.
|
* **Einwilligungs-Checkbox (☑):** Automatischer Andruck eines Häkchens, wenn in Calendly der Veröffentlichung zugestimmt wurde.
|
||||||
* **Termin-Übersichtsliste (Neu):**
|
* **Termin-Übersichtsliste:**
|
||||||
* Generiert eine A4-Tabelle für den Shooting-Tag.
|
* Generiert eine A4-Tabelle für den Shooting-Tag im 6-Minuten-Takt.
|
||||||
* **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.
|
* Füllt Lücken für nicht gebuchte Slots automatisch leer auf.
|
||||||
* Inklusive Spalten für Name, Kinder, Veröffentlichungs-Status und einer Checkbox zum manuellen Abhaken.
|
|
||||||
|
|
||||||
### 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.
|
* Identifizierung von Käufern/Nicht-Käufern zur Generierung von CSV-Listen für den Supermailer.
|
||||||
|
|
||||||
### Feature 4: Verkaufs-Statistiken (Vollständig)
|
### Feature 4: Verkaufs-Statistiken (Vollständig)
|
||||||
* Detaillierte Analyse des Kaufverhaltens pro Album mit Echtzeit-Fortschrittsanzeige im Browser.
|
* 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`).
|
* **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.
|
* **Logo-Integration:** Dynamisches Einbetten des Firmenlogos in alle generierten Dokumente.
|
||||||
|
|
||||||
## 🚀 Deployment & Konfiguration
|
## 🚀 Deployment & Konfiguration
|
||||||
@@ -52,9 +53,10 @@ Der Service wird über eine eigene `docker-compose.yml` im Unterverzeichnis gest
|
|||||||
|
|
||||||
### Umgebungsvariablen (`.env`)
|
### Umgebungsvariablen (`.env`)
|
||||||
Folgende Variablen müssen in der `.env` im Verzeichnis `/fotograf-de-scraper/` definiert sein:
|
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.
|
* `CALENDLY_TOKEN`: Personal Access Token (JWT) von Calendly.
|
||||||
|
|
||||||
### URLs & Ports
|
### URLs & Ports
|
||||||
* **Frontend:** `http://<HOST_IP>:3009` | **Backend:** `http://<HOST_IP>:8002`
|
* **Scraper Frontend:** `http://192.168.178.6:3009`
|
||||||
|
* **Zentrales Dashboard:** `http://192.168.178.6:8090`
|
||||||
* **Persistenz:** Datenbank unter `./backend/data/fotograf_jobs.db`.
|
* **Persistenz:** Datenbank unter `./backend/data/fotograf_jobs.db`.
|
||||||
@@ -36,6 +36,22 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger("fotograf-scraper")
|
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 environment variables
|
||||||
load_dotenv()
|
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("\"", "")
|
df.columns = df.columns.str.strip().str.replace("\"", "")
|
||||||
logger.debug(f"CSV Columns: {list(df.columns)}")
|
logger.debug(f"CSV Columns: {list(df.columns)}")
|
||||||
|
|
||||||
group_label = "Gruppe" if list_type == 'k' else "Klasse"
|
group_label = "Gruppe" if list_type.startswith('kiga') else "Klasse"
|
||||||
person_label_plural = "Kinder" if list_type == 'k' else "Schüler"
|
person_label_plural = "Kinder" if list_type.startswith('kiga') else "Schüler"
|
||||||
|
|
||||||
col_mapping = {}
|
col_mapping = {}
|
||||||
for col in df.columns:
|
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:
|
if not raw_events:
|
||||||
return JSONResponse(status_code=404, content={"message": "Keine passenden Termine für diesen Event-Typ gefunden."})
|
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
|
# 3. Generate PDF
|
||||||
temp_dir = tempfile.gettempdir()
|
temp_dir = tempfile.gettempdir()
|
||||||
output_name = f"Terminuebersicht_{job_id}_{datetime.datetime.now().strftime('%Y%m%d')}.pdf"
|
output_name = f"Terminuebersicht_{job_id}_{datetime.datetime.now().strftime('%Y%m%d')}.pdf"
|
||||||
output_path = os.path.join(temp_dir, output_name)
|
output_path = os.path.join(temp_dir, output_name)
|
||||||
|
|
||||||
try:
|
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")
|
return FileResponse(path=output_path, filename=output_name, media_type="application/pdf")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error generating appointment overview pdf: {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.")
|
raise HTTPException(status_code=500, detail="CSV Export fehlgeschlagen.")
|
||||||
|
|
||||||
@app.get("/api/jobs/{job_id}/generate-pdf")
|
@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})")
|
logger.info(f"API Request: Generate PDF for job {job_id} ({account_type})")
|
||||||
username = os.getenv(f"{account_type.upper()}_USER")
|
username = os.getenv(f"{account_type.upper()}_USER")
|
||||||
password = os.getenv(f"{account_type.upper()}_PW")
|
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
|
# 1.5 Click on the "Personen" tab
|
||||||
logger.info("Clicking on '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 = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "[data-qa-id='link:photo-jobs-tabs-names_list']")))
|
||||||
personen_tab.click()
|
# 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
|
# Wait for the export button to become present on the new tab
|
||||||
logger.info("Waiting for Export Dropdown...")
|
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_name = f"Listen_{job_id}.pdf"
|
||||||
output_pdf_path = os.path.join(temp_dir, output_pdf_name)
|
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(
|
generate_pdf_from_csv(
|
||||||
csv_path=csv_file,
|
csv_path=csv_file,
|
||||||
institution=institution,
|
institution=institution,
|
||||||
date_info=datetime.datetime.now().strftime("%d.%m.%Y"),
|
date_info=final_date_info,
|
||||||
list_type=account_type,
|
list_type=account_type,
|
||||||
output_path=output_pdf_path
|
output_path=output_pdf_path
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -171,24 +171,18 @@ function App() {
|
|||||||
setProcessingJobId(job.id);
|
setProcessingJobId(job.id);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/jobs/${job.id}/generate-pdf?account_type=${activeTab}`);
|
// Direkter Download über die URL, umgeht das Blob/Mixed-Content-Sicherheitsproblem in Chrome.
|
||||||
if (!response.ok) {
|
const downloadUrl = `${API_BASE_URL}/api/jobs/${job.id}/generate-pdf?account_type=${activeTab}`;
|
||||||
const errData = await response.json();
|
window.open(downloadUrl, '_blank');
|
||||||
throw new Error(errData.detail || 'PDF Generierung fehlgeschlagen');
|
|
||||||
}
|
// 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);
|
||||||
|
|
||||||
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);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(`PDF Fehler (${job.name}): ${err.message}`);
|
setError(`PDF Fehler (${job.name}): ${err.message}`);
|
||||||
} finally {
|
|
||||||
setProcessingJobId(null);
|
setProcessingJobId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -228,8 +222,12 @@ function App() {
|
|||||||
a.download = `QR_Karten_Andruck_${job.id}.pdf`;
|
a.download = `QR_Karten_Andruck_${job.id}.pdf`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
|
||||||
|
// Delay removal and revocation to ensure download starts in all browsers
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(a);
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
|
}, 100);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -241,28 +239,14 @@ function App() {
|
|||||||
setIsListGenerating(true);
|
setIsListGenerating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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) {
|
setTimeout(() => {
|
||||||
if (response.status === 404) {
|
setIsListGenerating(false);
|
||||||
throw new Error("Keine passenden Calendly-Termine gefunden.");
|
}, 3000);
|
||||||
}
|
|
||||||
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);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(`Listen-Fehler (${job.name}): ${err.message}`);
|
setError(`Listen-Fehler (${job.name}): ${err.message}`);
|
||||||
} finally {
|
|
||||||
setIsListGenerating(false);
|
setIsListGenerating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -300,10 +284,19 @@ function App() {
|
|||||||
{/* Top Navigation Bar */}
|
{/* Top Navigation Bar */}
|
||||||
<header className="bg-white shadow-sm sticky top-0 z-10">
|
<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="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-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-2xl">📸</span>
|
<span className="text-2xl">📸</span>
|
||||||
<h1 className="text-xl font-bold text-gray-800 tracking-tight">Fotograf.de ERP</h1>
|
<h1 className="text-xl font-bold text-gray-800 tracking-tight">Fotograf.de ERP</h1>
|
||||||
</div>
|
</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 */}
|
{/* Main Tabs */}
|
||||||
<nav className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
|
<nav className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
|
||||||
|
|||||||
Reference in New Issue
Block a user