Initial commit - Journey book (kniha jízd) automation system

Features:
- FastAPI backend for scraping attendance and journey book data
- Deterministic kilometer distribution with random variance
- Refueling form filling with km values
- Next.js frontend with date range selector
- Docker deployment setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Docker Config Backup
2025-10-10 15:41:11 +02:00
commit 3b5d9fd940
40 changed files with 3777 additions and 0 deletions

243
backend/api/main.py Normal file
View File

@@ -0,0 +1,243 @@
from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
import pandas as pd
from io import BytesIO
from fastapi.responses import StreamingResponse
import logging
from datetime import datetime
from dateutil.relativedelta import relativedelta
from scrapers import AttendanceScraper, JourneybookScraper
from calculators.kilometer_calculator import KilometerCalculator
from fillers import JourneybookFiller
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Kniha Jízd API", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class ScrapeRequest(BaseModel):
username: str
password: str
month: str
vehicle_registration: str = "4SH1148"
class CalculateRequest(BaseModel):
username: str
password: str
start_date: str # Format: YYYY-MM-DD
end_date: str # Format: YYYY-MM-DD
start_km: int
end_km: int
vehicle_registration: str = "4SH1148"
variance: float = 0.1
class FillRequest(CalculateRequest):
dry_run: bool = True
@app.get("/health")
async def health_check():
return {"status": "healthy"}
@app.post("/api/scrape/attendance")
async def scrape_attendance(request: ScrapeRequest):
"""Scrape attendance data for a month"""
try:
scraper = AttendanceScraper(request.username, request.password)
attendance_dates = scraper.scrape_month(request.month)
return {
"month": request.month,
"sick_vacation_days": attendance_dates,
"count": len(attendance_dates)
}
except Exception as e:
logger.error(f"Attendance scraping failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/scrape/journeybook")
async def scrape_journeybook(request: ScrapeRequest):
"""Scrape journeybook data for a month"""
try:
scraper = JourneybookScraper(request.username, request.password, request.vehicle_registration)
df = scraper.scrape_month(request.month)
return {
"month": request.month,
"entries": df.to_dict(orient='records'),
"count": len(df)
}
except Exception as e:
logger.error(f"Journeybook scraping failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/calculate")
async def calculate_kilometers(request: CalculateRequest):
"""Scrape data, filter sick days, and recalculate kilometers"""
try:
attendance_scraper = AttendanceScraper(request.username, request.password)
journeybook_scraper = JourneybookScraper(request.username, request.password, request.vehicle_registration)
# Get all months in the date range
start = datetime.strptime(request.start_date, "%Y-%m-%d")
end = datetime.strptime(request.end_date, "%Y-%m-%d")
# Collect data from all months
all_attendance_dates = []
all_dfs = []
current = start
while current <= end:
month_str = current.strftime("%Y-%m")
logger.info(f"Scraping attendance for {month_str}")
attendance_dates = attendance_scraper.scrape_month(month_str)
all_attendance_dates.extend(attendance_dates)
logger.info(f"Scraping journeybook for {month_str}")
df_month = journeybook_scraper.scrape_month(month_str)
all_dfs.append(df_month)
current = current + relativedelta(months=1)
# Combine all months
df = pd.concat(all_dfs, ignore_index=True) if all_dfs else pd.DataFrame()
# Filter by actual date range, but preserve refueling rows (empty Datum)
if not df.empty:
date_parsed = pd.to_datetime(df["Datum"], format="%d.%m.%Y", errors='coerce')
is_refuel = df["Datum"].isna() | (df["Datum"] == "")
is_in_range = (date_parsed >= start) & (date_parsed <= end)
df = df[is_in_range | is_refuel]
logger.info(f"Filtering out {len(all_attendance_dates)} sick/vacation days")
# Only filter journey rows, not refueling rows
is_refuel = df["Datum"].isna() | (df["Datum"] == "")
df = df[~df["Datum"].isin(all_attendance_dates) | is_refuel]
logger.info("Recalculating kilometers")
df = KilometerCalculator.recalculate(df, request.start_km, request.end_km, request.variance)
return {
"month": f"{request.start_date} - {request.end_date}",
"start_km": request.start_km,
"end_km": request.end_km,
"filtered_days": len(all_attendance_dates),
"entries": df.to_dict(orient='records'),
"total_entries": len(df)
}
except Exception as e:
import traceback
logger.error(f"Calculate failed: {e}")
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/export/excel")
async def export_to_excel(request: CalculateRequest):
"""Generate and download Excel file"""
try:
attendance_scraper = AttendanceScraper(request.username, request.password)
journeybook_scraper = JourneybookScraper(request.username, request.password, request.vehicle_registration)
# Get all months in the date range
start = datetime.strptime(request.start_date, "%Y-%m-%d")
end = datetime.strptime(request.end_date, "%Y-%m-%d")
# Collect data from all months
all_attendance_dates = []
all_dfs = []
current = start
while current <= end:
month_str = current.strftime("%Y-%m")
attendance_dates = attendance_scraper.scrape_month(month_str)
all_attendance_dates.extend(attendance_dates)
df_month = journeybook_scraper.scrape_month(month_str)
all_dfs.append(df_month)
current = current + relativedelta(months=1)
# Combine all months
df = pd.concat(all_dfs, ignore_index=True) if all_dfs else pd.DataFrame()
# Filter by actual date range, but preserve refueling rows (empty Datum)
if not df.empty:
date_parsed = pd.to_datetime(df["Datum"], format="%d.%m.%Y", errors='coerce')
is_refuel = df["Datum"].isna() | (df["Datum"] == "")
is_in_range = (date_parsed >= start) & (date_parsed <= end)
df = df[is_in_range | is_refuel]
# Only filter journey rows, not refueling rows
is_refuel = df["Datum"].isna() | (df["Datum"] == "")
df = df[~df["Datum"].isin(all_attendance_dates) | is_refuel]
df = KilometerCalculator.recalculate(df, request.start_km, request.end_km, request.variance)
output = BytesIO()
df.to_excel(output, index=False, engine='openpyxl')
output.seek(0)
filename = f"journeybook_{request.start_date}_{request.end_date}.xlsx"
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
except Exception as e:
logger.error(f"Export failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/fill/journeybook")
async def fill_journeybook(request: FillRequest):
"""Fill calculated data back to kj.colsys.cz (restricted to January 2025)"""
try:
logger.info(f"Fill request received with dry_run={request.dry_run}")
# Only allow January 2025 for testing
if request.start_date != "2025-01-01" or request.end_date < "2025-01-31":
raise HTTPException(status_code=400, detail="Only January 2025 is allowed for testing")
attendance_scraper = AttendanceScraper(request.username, request.password)
journeybook_scraper = JourneybookScraper(request.username, request.password, request.vehicle_registration)
# Get January data
month_str = "2025-01"
attendance_dates = attendance_scraper.scrape_month(month_str)
df = journeybook_scraper.scrape_month(month_str)
# Filter sick/vacation days
df = df[~df["Datum"].isin(attendance_dates)]
# Recalculate kilometers
df = KilometerCalculator.recalculate(df, request.start_km, request.end_km, request.variance)
# Fill data back (supports dry_run mode)
filler = JourneybookFiller(request.username, request.password, request.vehicle_registration)
result = filler.fill_month(df, month_str, dry_run=request.dry_run)
return result
except Exception as e:
import traceback
logger.error(f"Fill failed: {e}")
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)