252 lines
8.3 KiB
Python
252 lines
8.3 KiB
Python
"""FastAPI: supplier offer (PDF/XLSX) → structured data → editable form → XLSX."""
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import base64
|
|
|
|
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel
|
|
|
|
from datetime import date
|
|
|
|
from excel_export import write_offer_xlsx
|
|
from extractor import extract_offer
|
|
from order_form import build_order_pdf
|
|
from store import (delete_history_value, delete_project, get_defaults,
|
|
list_projects, next_order_number, remember_order_number,
|
|
save_defaults, upsert_project)
|
|
|
|
# Buyer is always Colsys — fixed default for the order form's "Objednatel".
|
|
OBJEDNATEL = {
|
|
"name": "Colsys s.r.o.",
|
|
"address": "Buštěhradská 109, 272 03 Kladno - Dubí",
|
|
"ico": "14799634",
|
|
"dic": "CZ14799634",
|
|
}
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
app = FastAPI(title="Offer to Order")
|
|
app.add_middleware(CORSMiddleware, allow_origins=["*"],
|
|
allow_methods=["*"], allow_headers=["*"])
|
|
|
|
WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/offer-to-order"))
|
|
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
jobs: dict[str, dict] = {}
|
|
|
|
ALLOWED_EXT = {".pdf", ".xlsx", ".xlsm", ".xls"}
|
|
|
|
|
|
@app.get("/")
|
|
async def root():
|
|
return FileResponse("static/index.html")
|
|
|
|
|
|
@app.post("/api/upload")
|
|
async def upload(file: UploadFile = File(...)):
|
|
suffix = Path(file.filename or "").suffix.lower()
|
|
if suffix not in ALLOWED_EXT:
|
|
raise HTTPException(400, f"Podporované formáty: {', '.join(sorted(ALLOWED_EXT))}")
|
|
|
|
job_id = str(uuid.uuid4())
|
|
job_dir = WORK_DIR / job_id
|
|
job_dir.mkdir()
|
|
input_path = job_dir / f"input{suffix}"
|
|
input_path.write_bytes(await file.read())
|
|
logger.info("Job %s: %s (%d bytes)", job_id, file.filename,
|
|
input_path.stat().st_size)
|
|
|
|
try:
|
|
data = await extract_offer(input_path)
|
|
except Exception as exc:
|
|
logger.exception("Extraction failed")
|
|
raise HTTPException(500, str(exc))
|
|
|
|
jobs[job_id] = {
|
|
"filename": file.filename,
|
|
"job_dir": str(job_dir),
|
|
"data": data,
|
|
}
|
|
return {"job_id": job_id, "data": data}
|
|
|
|
|
|
class SaveRequest(BaseModel):
|
|
data: dict
|
|
|
|
|
|
@app.post("/api/save/{job_id}")
|
|
async def save_data(job_id: str, req: SaveRequest):
|
|
if job_id not in jobs:
|
|
raise HTTPException(404, "Nenalezeno")
|
|
jobs[job_id]["data"] = req.data
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/export/{job_id}")
|
|
async def export(job_id: str):
|
|
if job_id not in jobs:
|
|
raise HTTPException(404, "Nenalezeno")
|
|
job = jobs[job_id]
|
|
out_path = Path(job["job_dir"]) / "nabidka.xlsx"
|
|
write_offer_xlsx(job["data"], str(out_path))
|
|
sup = (job["data"].get("supplier") or {}).get("name")
|
|
base = sup or (Path(job["filename"]).stem if job.get("filename") else "nabidka")
|
|
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in str(base))
|
|
return FileResponse(
|
|
str(out_path),
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
filename=f"nabidka_{safe}.xlsx",
|
|
)
|
|
|
|
|
|
_NO_STORE = {"Cache-Control": "no-store"}
|
|
|
|
|
|
@app.get("/api/order-defaults")
|
|
async def order_defaults(code: str = ""):
|
|
"""Remembered order-form fields for a given project code (Kód zakázky)."""
|
|
return JSONResponse({"defaults": get_defaults(code)}, headers=_NO_STORE)
|
|
|
|
|
|
class HistoryDeleteRequest(BaseModel):
|
|
code: str
|
|
field: str
|
|
value: str
|
|
|
|
|
|
@app.post("/api/order-history/delete")
|
|
async def order_history_delete(req: HistoryDeleteRequest):
|
|
delete_history_value(req.code, req.field, req.value)
|
|
return {"defaults": get_defaults(req.code)}
|
|
|
|
|
|
@app.get("/api/projects")
|
|
async def projects():
|
|
"""Reusable zakázky (Kód + Název) + the suggested next order number."""
|
|
return JSONResponse({"projects": list_projects(),
|
|
"next_order_number": next_order_number()},
|
|
headers=_NO_STORE)
|
|
|
|
|
|
class ProjectRequest(BaseModel):
|
|
kod: str
|
|
nazev: str = ""
|
|
|
|
|
|
@app.post("/api/projects")
|
|
async def project_upsert(req: ProjectRequest):
|
|
if not req.kod.strip():
|
|
raise HTTPException(400, "Kód zakázky je povinný")
|
|
upsert_project(req.kod, req.nazev)
|
|
return {"projects": list_projects()}
|
|
|
|
|
|
@app.delete("/api/projects/{code}")
|
|
async def project_delete(code: str):
|
|
delete_project(code)
|
|
return {"projects": list_projects()}
|
|
|
|
|
|
class OrderPdfRequest(BaseModel):
|
|
order: dict # the order-form fields (objednatel/dodavatel/terms/body)
|
|
items: list | None = None # falls back to the job's extracted line_items
|
|
|
|
|
|
@app.post("/api/order-pdf/{job_id}")
|
|
async def order_pdf(job_id: str, req: OrderPdfRequest):
|
|
if job_id not in jobs:
|
|
raise HTTPException(404, "Nenalezeno")
|
|
job = jobs[job_id]
|
|
order = dict(req.order or {})
|
|
order.setdefault("objednatel", OBJEDNATEL)
|
|
order.setdefault("datum_vystaveni", date.today().strftime("%d.%m.%Y"))
|
|
items = req.items if req.items is not None else (job["data"].get("line_items") or [])
|
|
|
|
# Remember the sticky fields for this project + the order number used
|
|
# (so the next order is prefilled with this number + 1).
|
|
save_defaults(order.get("kod_zakazky"), order)
|
|
remember_order_number(order.get("objednavka_number"))
|
|
|
|
out_path = Path(job["job_dir"]) / "objednavka.pdf"
|
|
build_order_pdf(order, items, str(out_path))
|
|
num = order.get("objednavka_number") or order.get("kod_zakazky") or "objednavka"
|
|
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in str(num))
|
|
return FileResponse(str(out_path), media_type="application/pdf",
|
|
filename=f"objednavka_{safe}.pdf")
|
|
|
|
|
|
PROCESS_API_KEY = os.getenv("PROCESS_API_KEY", "")
|
|
|
|
|
|
class ProcessRequest(BaseModel):
|
|
filename: str # e.g. "nabidka.pdf"
|
|
file_base64: str # the offer attachment, base64 (Power Automate contentBytes)
|
|
|
|
|
|
@app.post("/api/process")
|
|
async def process(req: ProcessRequest, x_api_key: str = Header(default="")):
|
|
"""Unattended pipeline for Power Automate: offer attachment in →
|
|
extracted items as the unified Excel out (base64)."""
|
|
if not PROCESS_API_KEY:
|
|
raise HTTPException(503, "PROCESS_API_KEY není nastaven na serveru")
|
|
if x_api_key != PROCESS_API_KEY:
|
|
raise HTTPException(401, "Neplatný API klíč")
|
|
|
|
suffix = Path(req.filename or "").suffix.lower()
|
|
if suffix not in ALLOWED_EXT:
|
|
raise HTTPException(400, f"Podporované formáty přílohy: {', '.join(sorted(ALLOWED_EXT))}")
|
|
|
|
job_id = str(uuid.uuid4())
|
|
job_dir = WORK_DIR / job_id
|
|
job_dir.mkdir()
|
|
input_path = job_dir / f"input{suffix}"
|
|
try:
|
|
input_path.write_bytes(base64.b64decode(req.file_base64))
|
|
except Exception:
|
|
raise HTTPException(400, "Přílohu se nepodařilo dekódovat (base64)")
|
|
|
|
try:
|
|
data = await extract_offer(input_path)
|
|
except Exception as exc:
|
|
logger.exception("process: extraction failed")
|
|
raise HTTPException(500, f"Extrakce přílohy selhala: {exc}")
|
|
|
|
items = data.get("line_items") or []
|
|
total = sum(float(it.get("total_price") or 0) for it in items)
|
|
xlsx_path = job_dir / "nabidka.xlsx"
|
|
write_offer_xlsx(data, str(xlsx_path))
|
|
|
|
sup_name = (data.get("supplier") or {}).get("name") or "?"
|
|
stem = sup_name if sup_name != "?" else Path(req.filename).stem
|
|
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in str(stem))
|
|
offer_no = data.get("offer_number") or ""
|
|
return JSONResponse({
|
|
"ok": True,
|
|
"is_offer": data.get("is_offer", True),
|
|
"offer_number": offer_no,
|
|
"supplier_name": sup_name,
|
|
"item_count": len(items),
|
|
"total": round(total, 2),
|
|
"summary": f"Dodavatel: {sup_name}; číslo nabídky: {offer_no or '-'}; "
|
|
f"položek: {len(items)}; celkem bez DPH: {total:,.2f} Kč",
|
|
"excel": {
|
|
"filename": f"nabidka_{safe}.xlsx",
|
|
"content_base64": base64.b64encode(xlsx_path.read_bytes()).decode(),
|
|
},
|
|
}, headers=_NO_STORE)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|