Files
AI_portal/offer-to-order/main.py
2026-07-07 13:16:26 +02:00

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}",
"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")