wip: local portal changes (backup before disk cleanup)

This commit is contained in:
Ondřej Glaser
2026-07-07 13:16:26 +02:00
parent 48cef99257
commit 79b17be817
774 changed files with 8592 additions and 245 deletions

View File

@@ -0,0 +1,9 @@
.env
.env.*
samples/
__pycache__/
*.pyc
.git/
.gitignore
*.xlsx
*.pdf

View File

@@ -0,0 +1,4 @@
LITELLM_BASE_URL=http://host.docker.internal:4000/v1
LITELLM_API_KEY=sk-...
LLM_MODEL=anthropic/claude-sonnet-4-20250514
PROCESS_API_KEY=oto_change-me

6
offer-to-order/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.env
.env.local
__pycache__/
*.pyc
samples/
*.xlsx

19
offer-to-order/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM python:3.12-slim
WORKDIR /app
# DejaVu fonts so reportlab renders Czech diacritics in the order-form PDF.
RUN apt-get update \
&& apt-get install -y --no-install-recommends fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /tmp/offer-to-order
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,20 @@
# Standalone production deployment (fresh host, no portal infra).
# The host is VPN-only, so the app port is the access boundary — there is
# no separate login. Bind only to the interface reachable over your VPN.
#
# docker compose -f docker-compose.prod.yml up -d --build
#
# Configure secrets/endpoint in .env (see .env.prod.example).
services:
offer-to-order:
build: .
container_name: offer-to-order
restart: unless-stopped
ports:
# Host port 8080 → container 8000. Reachable over the VPN at
# http://<prod-host>:8080/. Change the left side if 8080 is taken.
- "8080:8000"
environment:
LITELLM_BASE_URL: ${LITELLM_BASE_URL:-https://litellm.klas.chat/v1}
LITELLM_API_KEY: ${LITELLM_API_KEY:?set LITELLM_API_KEY in .env}
LLM_MODEL: ${LLM_MODEL:-anthropic/claude-sonnet-4-20250514}

View File

@@ -0,0 +1,25 @@
services:
offer-to-order:
build: .
container_name: offer-to-order
restart: unless-stopped
ports:
- "127.0.0.1:3037:8000"
environment:
LITELLM_BASE_URL: ${LITELLM_BASE_URL:-http://host.docker.internal:4000/v1}
LITELLM_API_KEY: ${LITELLM_API_KEY:-}
LLM_MODEL: ${LLM_MODEL:-anthropic/claude-sonnet-4-20250514}
PROCESS_API_KEY: ${PROCESS_API_KEY:-}
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- localai
volumes:
- offer-to-order-data:/tmp/offer-to-order
volumes:
offer-to-order-data:
networks:
localai:
external: true

View File

@@ -0,0 +1,83 @@
"""Render extracted supplier-offer data into the Excel form the CFO defined:
Supplier header (Jméno, DIČ/IČO, kontaktní email, adresa)
then a table:
Název zboží | ID zboží dodavatele | Množství | Jednotka | Jednotková cena | Cena celkem
"""
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
HEADER_FILL = PatternFill("solid", fgColor="2563EB")
HEADER_FONT = Font(bold=True, color="FFFFFF", size=11)
LABEL_FONT = Font(bold=True, size=10)
THIN = Side(style="thin", color="D0D5DC")
BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
COLUMNS = [
("Název zboží", "name", 46),
("ID zboží dodavatele", "supplier_id", 20),
("Množství", "quantity", 12),
("Jednotka", "unit", 12),
("Jednotková cena", "unit_price", 16),
("Cena celkem", "total_price", 16),
]
def write_offer_xlsx(data: dict, out_path: str) -> None:
wb = Workbook()
ws = wb.active
ws.title = "Nabídka"
sup = data.get("supplier") or {}
ico_dic = " / ".join(x for x in (sup.get("ico"), sup.get("dic")) if x) or ""
# ── Supplier header block ──
ws.cell(row=1, column=1, value="Dodavatel").font = Font(bold=True, size=13)
header_pairs = [
("Jméno", sup.get("name")),
("DIČ / IČO", ico_dic),
("Kontaktní email", sup.get("email")),
("Adresa", sup.get("address")),
]
row = 2
for label, val in header_pairs:
ws.cell(row=row, column=1, value=label).font = LABEL_FONT
ws.cell(row=row, column=2, value=val if val is not None else "")
row += 1
row += 1 # spacer
# ── Line-items table ──
for c, (title, _key, _w) in enumerate(COLUMNS, 1):
cell = ws.cell(row=row, column=c, value=title)
cell.font = HEADER_FONT
cell.fill = HEADER_FILL
cell.alignment = Alignment(horizontal="center", wrap_text=True)
cell.border = BORDER
row += 1
for item in data.get("line_items") or []:
for c, (_title, key, _w) in enumerate(COLUMNS, 1):
v = item.get(key)
cell = ws.cell(row=row, column=c, value=v if v is not None else "")
cell.border = BORDER
if key in ("unit_price", "total_price") and isinstance(v, (int, float)):
cell.number_format = "#,##0.00"
elif key == "quantity" and isinstance(v, (int, float)):
# Whole numbers get a pure integer format with NO decimal
# section — some viewers render the decimal separator even
# when "0.###" has no fractional digits, leaving a stray
# comma. Only show decimals when the value actually has them.
cell.number_format = "0" if float(v).is_integer() else "0.###"
cell.alignment = Alignment(
horizontal="left" if key in ("name", "supplier_id", "unit")
else "right",
vertical="top", wrap_text=(key == "name"))
row += 1
for c, (_title, _key, w) in enumerate(COLUMNS, 1):
ws.column_dimensions[get_column_letter(c)].width = w
ws.freeze_panes = ws.cell(row=7, column=1) # keep header + table head visible
wb.save(out_path)

284
offer-to-order/extractor.py Normal file
View File

@@ -0,0 +1,284 @@
"""Supplier-offer extraction via Claude.
Inputs are supplier price offers (Czech "nabídka") in wildly varied layouts —
PDF (text or scanned) and XLSX. We extract the SUPPLIER's identity plus the
line items into a fixed schema the CFO defined. Colsys s.r.o. (IČO 14799634)
is always the BUYER, never the supplier.
"""
import base64
import io
import json
import logging
import os
from pathlib import Path
import fitz # PyMuPDF
import openpyxl
from PIL import Image
from openai import AsyncOpenAI
logger = logging.getLogger(__name__)
_client: AsyncOpenAI | None = None
MODEL = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-20250514")
MAX_PAGES = 8
MAX_IMG_LONG_EDGE = 2200
MAX_XLSX_ROWS = 400 # guard against huge sheets blowing the prompt
def _get_client() -> AsyncOpenAI:
global _client
if _client is None:
_client = AsyncOpenAI(
base_url=os.getenv("LITELLM_BASE_URL",
"http://host.docker.internal:4000/v1"),
api_key=os.getenv("LITELLM_API_KEY", "sk-dummy"),
)
return _client
SYSTEM_PROMPT = """Jste přesný extraktor dat z českých dodavatelských nabídek (cenových nabídek).
Z poskytnutého dokumentu vyextrahujete údaje o DODAVATELI a seznam položek do pevného JSON tvaru.
DŮLEŽITÉ — kdo je dodavatel:
- DODAVATEL = firma, která nabídku VYSTAVILA a nabízí zboží (prodávající).
- Firma „Colsys s.r.o." (IČO 14799634, DIČ CZ14799634) je VŽDY ODBĚRATEL (kupující), NIKDY dodavatel. Pokud ji uvidíte, NEzapisujte ji jako dodavatele.
- Dodavatel je tedy ta druhá strana (např. Rasel, ELKOV elektro, ABBAS apod.).
Vraťte POUZE platný JSON, žádné markdown obaly, žádný komentář mimo JSON:
{
"is_offer": true,
"offer_number": "string|null",
"supplier": {
"name": "string|null",
"ico": "string|null",
"dic": "string|null",
"email": "string|null",
"address": "string|null"
},
"currency": "CZK",
"line_items": [
{
"name": "string",
"supplier_id": "string|null",
"quantity": "number|null",
"unit": "string|null",
"unit_price": "number|null",
"total_price": "number|null"
}
]
}
Pravidla:
- is_offer = false, pokud dokument NENÍ dodavatelská nabídka/cenová nabídka zboží (např. mzdová tabulka, výkaz směn, obecný dokument). V takovém případě nech supplier prázdné a line_items jako [].
- "offer_number" = číslo nabídky dodavatele (např. „Nabídka č. NP250703175", „25NA00023"). Pokud není, null.
- "name" = název zboží přesně jak je uvedeno.
- "supplier_id" = kód/ID zboží u dodavatele (sloupce „Kód", „Obj. kód", „Kód zboží", „Katalogové číslo"). Pokud není, null.
- "quantity" = množství (číslo).
- "unit" = měrná jednotka (m, ks, kg, bal, ...). Zachovej přesně.
- "unit_price" = jednotková cena BEZ DPH za jednotku (sloupce „J.cena", „Cena/MJ", „Cena/mj", „Jednotková cena"). Pokud je uvedena jen cena s DPH, použij ji a poznamenej to není potřeba — prostě vrať dostupnou jednotkovou cenu.
- "total_price" = cena za položku celkem BEZ DPH (sloupce „Celkem bez DPH", „Cena celkem", „Celkem"). Pokud není uvedena, dopočítej quantity × unit_price.
- Čísla zapisuj s tečkou jako desetinným oddělovačem, BEZ mezer a BEZ symbolu měny (např. 1289.40, ne „1 289,40 Kč").
- Zachovej PŘESNÉ pořadí položek tak, jak jsou v dokumentu.
- Vynech řádky, které nejsou skutečné položky zboží (mezisoučty, „Součet položek", „CELKEM K ÚHRADĚ", rekapitulace DPH, prázdné řádky).
- Pokud údaj chybí, použij null. NEVYMÝŠLEJ si hodnoty."""
async def extract_offer(path: Path) -> dict:
"""Extract structured supplier-offer data. Routes PDF→vision, XLSX→text."""
suffix = path.suffix.lower()
if suffix == ".pdf":
content = _pdf_content(path)
elif suffix in (".xlsx", ".xlsm", ".xls"):
content = _xlsx_content(path)
else:
raise RuntimeError(f"Nepodporovaný formát: {suffix}")
if not content:
raise RuntimeError("Soubor neobsahuje žádný čitelný obsah")
try:
resp = await _get_client().chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": content},
],
temperature=0.0,
max_tokens=8000,
)
except Exception as exc:
logger.exception("LLM call failed")
raise RuntimeError(f"AI extrakce selhala: {exc}")
raw = (resp.choices[0].message.content or "").strip()
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
logger.error("JSON parse failed: %s\n%s", exc, raw[:500])
raise RuntimeError(f"Nepodařilo se zpracovat odpověď AI: {exc}")
return _normalize(data)
ORDER_EMAIL_PROMPT = """Jste extraktor dat z českého e-mailu, kterým zaměstnanec Colsys žádá o vystavení objednávky.
Z textu e-mailu vytáhněte údaje objednávky do pevného JSON tvaru. Text může pocházet z HTML e-mailu (různé zalomení, mezery).
Vraťte POUZE platný JSON, žádné markdown, žádný komentář:
{
"dodavatel": {
"name": "string|null",
"ico": "string|null",
"dic": "string|null",
"email": "string|null",
"kontakt": "string|null"
},
"nazev_zakazky": "string|null",
"kod_zakazky": "string|null",
"text": "string|null",
"cena_celkem_text": "string|null",
"termin_dodani": "string|null",
"kontaktni_osoba": "string|null",
"misto_dodani": "string|null",
"splatnost": "string|null",
"zaruka": "string|null",
"prilohy": "string|null",
"zpusob_odeslani": "string|null"
}
Pravidla:
- Hodnoty hledejte u odpovídajících štítků: „Dodavatel", „IČ"/„IČO", „DIČ", „KONTAKTNÍ OSOBA DODAVATELE" → dodavatel.kontakt (jméno + tel + email; email i do dodavatel.email), „NÁZEV ZAKÁZKY", „KÓD ZAKÁZKY", „TEXT", „CENA CELKEM" → cena_celkem_text (přepište přesně, např. „-" pokud není), „TERMÍN DODÁNÍ", „KONTAKTNÍ OSOBA V MÍSTĚ DODÁNÍ", „MÍSTO DODÁNÍ", „SPLATNOST", „ZÁRUKA", „PŘÍLOHY", „ZPŮSOB ODESLÁNÍ OBJEDNÁVKY".
- TERMÍN DODÁNÍ zapište jako DD.MM.RRRR pokud je to datum.
- Ignorujte patičkový právní text o VNP/VSP a větu „tento text výše je obsažen v zápatí…".
- Pokud údaj chybí, použijte null. NEVYMÝŠLEJTE si."""
async def parse_order_email(body_text: str) -> dict:
"""Extract order-form fields from the structured request e-mail body."""
body_text = (body_text or "").strip()
if not body_text:
return {}
try:
resp = await _get_client().chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": ORDER_EMAIL_PROMPT},
{"role": "user", "content": body_text[:20000]},
],
temperature=0.0,
max_tokens=2000,
)
except Exception as exc:
logger.exception("order-email LLM call failed")
raise RuntimeError(f"AI zpracování e-mailu selhalo: {exc}")
raw = (resp.choices[0].message.content or "").strip()
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
logger.error("order-email JSON parse failed: %s\n%s", exc, raw[:400])
return {}
def _pdf_content(path: Path) -> list[dict]:
"""PDF → page images for the vision model, plus the extracted text layer
(helps with exact codes/prices that can be hard to OCR from the raster)."""
doc = fitz.open(str(path))
content: list[dict] = [{
"type": "text",
"text": "Níže je dodavatelská nabídka jako obrázky stránek "
"(a pod nimi textová vrstva, pokud existuje). "
"Vyextrahujte data podle definovaného JSON tvaru.",
}]
text_parts = []
for i, page in enumerate(doc):
if i >= MAX_PAGES:
break
pix = page.get_pixmap(dpi=200, alpha=False)
img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{_compress(img)}"},
})
txt = page.get_text("text") or ""
if txt.strip():
text_parts.append(f"--- strana {i + 1} ---\n{txt.strip()}")
doc.close()
if text_parts:
content.append({
"type": "text",
"text": "Textová vrstva PDF:\n" + "\n\n".join(text_parts),
})
return content
def _xlsx_content(path: Path) -> list[dict]:
"""XLSX → a compact text dump of every sheet (rows as tab-joined cells)."""
wb = openpyxl.load_workbook(str(path), data_only=True, read_only=True)
blocks = []
for ws in wb.worksheets:
lines = [f"=== List: {ws.title} ==="]
for r, row in enumerate(ws.iter_rows(values_only=True)):
if r >= MAX_XLSX_ROWS:
lines.append("… (zbytek listu vynechán)")
break
cells = ["" if c is None else str(c) for c in row]
if any(c.strip() for c in cells):
lines.append("\t".join(cells).rstrip())
blocks.append("\n".join(lines))
wb.close()
dump = "\n\n".join(blocks)
return [{
"type": "text",
"text": "Níže je obsah excelové dodavatelské nabídky (buňky listů). "
"Vyextrahujte data podle definovaného JSON tvaru.\n\n" + dump,
}]
def _compress(img: Image.Image) -> str:
if max(img.size) > MAX_IMG_LONG_EDGE:
ratio = MAX_IMG_LONG_EDGE / max(img.size)
img = img.resize((int(img.size[0] * ratio), int(img.size[1] * ratio)),
Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
return base64.b64encode(buf.getvalue()).decode("ascii")
def _normalize(data: dict) -> dict:
def _num(v):
if v is None or v == "":
return None
if isinstance(v, (int, float)):
return float(v)
s = str(v).strip()
# Czech number formatting: "1 289,40" → "1289.40"
s = s.replace(" ", "").replace(" ", "").replace(",", ".")
# strip trailing currency/units the model may have left in
s = "".join(ch for ch in s if ch.isdigit() or ch in ".-")
try:
return float(s) if s not in ("", ".", "-") else None
except ValueError:
return None
if not isinstance(data, dict):
return {"is_offer": False, "supplier": {}, "line_items": []}
data.setdefault("is_offer", True)
data.setdefault("supplier", {})
data.setdefault("currency", "CZK")
items = data.get("line_items") or []
for item in items:
for k in ("quantity", "unit_price", "total_price"):
if k in item:
item[k] = _num(item[k])
# backfill total if missing but quantity & unit price present
if item.get("total_price") is None and \
item.get("quantity") is not None and \
item.get("unit_price") is not None:
item["total_price"] = round(item["quantity"] * item["unit_price"], 2)
data["line_items"] = items
return data

251
offer-to-order/main.py Normal file
View File

@@ -0,0 +1,251 @@
"""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")

View File

@@ -0,0 +1,330 @@
"""Generate the Colsys 'Objednávka' order form as a PDF, replicating the
ERP layout, followed by the line-items table. Output is one combined PDF.
Layout reference: samples/Objednavky_TU_2025_1576.PDF
"""
from __future__ import annotations
import os
from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
PAGE_W, PAGE_H = A4 # 595.27 x 841.89 pt
L = 40 # left margin
R = PAGE_W - 40 # right margin
# ── Fonts (DejaVu = full Czech diacritics) ──
_FONT, _BOLD = "Helvetica", "Helvetica-Bold"
for name, path in (
("DejaVu", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
("DejaVu-Bold", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
):
if os.path.exists(path):
try:
pdfmetrics.registerFont(TTFont(name, path))
except Exception:
pass
if "DejaVu" in pdfmetrics.getRegisteredFontNames():
_FONT, _BOLD = "DejaVu", "DejaVu-Bold"
def _ty(y: float) -> float:
"""Top-origin → reportlab bottom-origin."""
return PAGE_H - y
def _wrap(c, text, font, size, max_w):
text = str(text or "")
if not text:
return [""]
out, line = [], ""
for word in text.split(" "):
trial = (line + " " + word).strip()
if pdfmetrics.stringWidth(trial, font, size) <= max_w or not line:
line = trial
else:
out.append(line)
line = word
out.append(line)
return out
def _money(v) -> str:
if v is None or v == "":
return ""
try:
s = f"{float(v):,.2f}"
# CZ formatting: 1 234,56
return s.replace(",", " ").replace(".", ",")
except (TypeError, ValueError):
return str(v)
def _qty(v) -> str:
if v is None or v == "":
return ""
try:
f = float(v)
return str(int(f)) if f.is_integer() else f"{f:.3f}".rstrip("0").rstrip(".").replace(".", ",")
except (TypeError, ValueError):
return str(v)
# ─────────────────────────── page 1: the form ───────────────────────────
def _party_box(c, x0, x1, top, label, party):
"""Draw one bordered party box (Objednatel / Dodavatel): the label on
its own line, a blank row, then the company name + address below."""
name = party.get("name") or ""
addr = party.get("address") or ""
parts = [p.strip() for p in addr.split(",") if p.strip()]
ico, dic = party.get("ico") or "", party.get("dic") or ""
email = party.get("email")
c.setFont(_FONT, 8)
c.drawString(x0 + 6, _ty(top + 14), label)
# blank row, then the company block on its own lines
yy = top + 36
c.setFont(_BOLD, 9.5)
c.drawString(x0 + 6, _ty(yy), name)
yy += 13
c.setFont(_FONT, 8)
for ln in parts:
c.drawString(x0 + 6, _ty(yy), ln)
yy += 11
idline = " ".join(s for s in (f"IČO: {ico}" if ico else "",
f"DIČ: {dic}" if dic else "") if s)
if idline:
c.drawString(x0 + 6, _ty(yy + 2), idline)
yy += 13
if email:
c.drawString(x0 + 6, _ty(yy + 2), f"e-mail: {email}")
yy += 12
return yy
def _kv_two(c, y, lx, ll, lv, rx, rl, rv):
c.setFont(_FONT, 8)
c.drawString(lx, _ty(y), ll)
c.setFont(_BOLD, 8)
c.drawString(lx + 82, _ty(y), str(lv or ""))
c.setFont(_FONT, 8)
c.drawString(rx, _ty(y), rl)
c.setFont(_BOLD, 8)
c.drawString(rx + 92, _ty(y), str(rv or ""))
def _draw_form_page(c, o):
objednatel = o.get("objednatel") or {}
dodavatel = o.get("dodavatel") or {}
# Terms may arrive flat on the order or nested under "terms".
_terms = o.get("terms") or {}
terms = {k: (o.get(k) if o.get(k) not in (None, "") else _terms.get(k))
for k in ("zpusob_platby", "zpusob_odberu", "zpusob_dopravy",
"forma_objednavky", "termin_dodani", "fakturujte")}
num = o.get("objednavka_number") or ""
# Title (with top breathing room)
c.setFont(_BOLD, 14)
c.drawRightString(R, _ty(44), f"Objednávka: {num}")
# ── Header: two party boxes (with space above the boxes) ──
top = 78
box_h = 104
mid = 300
c.setLineWidth(0.6)
c.rect(L, _ty(top + box_h), R - L, box_h, stroke=1, fill=0)
c.line(mid, _ty(top + box_h), mid, _ty(top))
_party_box(c, L, mid, top, "Objednatel", objednatel)
_party_box(c, mid, R, top, "Dodavatel", dodavatel)
# ── Dodací a platební podmínky ──
t2 = top + box_h
h2 = 60
c.rect(L, _ty(t2 + h2), R - L, h2, stroke=1, fill=0)
c.setFont(_BOLD, 8)
c.drawString(L + 4, _ty(t2 + 11), "Dodací a platební podmínky")
yy = t2 + 26
_kv_two(c, yy, L + 4, "Způsob platby:", terms.get("zpusob_platby"),
mid + 4, "Forma objednávky:", terms.get("forma_objednavky"))
_kv_two(c, yy + 12, L + 4, "Způsob odběru:", terms.get("zpusob_odberu") or "-",
mid + 4, "Termín dodání:", terms.get("termin_dodani"))
_kv_two(c, yy + 24, L + 4, "Způsob dopravy:", terms.get("zpusob_dopravy") or "-",
mid + 4, "Fakturujte:", terms.get("fakturujte"))
# ── Datum vystavení / Popis ──
t3 = t2 + h2
h3 = 22
c.rect(L, _ty(t3 + h3), R - L, h3, stroke=1, fill=0)
c.line(mid, _ty(t3 + h3), mid, _ty(t3))
c.setFont(_FONT, 8)
c.drawString(L + 4, _ty(t3 + 14), "Datum vystavení")
c.setFont(_BOLD, 8)
c.drawString(L + 95, _ty(t3 + 14), str(o.get("datum_vystaveni") or ""))
c.setFont(_FONT, 8)
c.drawString(mid + 4, _ty(t3 + 14), "Popis:")
c.setFont(_BOLD, 8)
c.drawString(mid + 50, _ty(t3 + 14), str(o.get("popis") or ""))
# ── Sub-header: order no + page ──
t4 = t3 + h3 + 16
c.setFont(_BOLD, 9)
c.drawString(L, _ty(t4), f"Objednávka: {num}")
c.drawRightString(R, _ty(t4), "Strana: 1/1")
c.setLineWidth(0.6)
c.line(L, _ty(t4 + 4), R, _ty(t4 + 4))
# ── Body fields ──
y = t4 + 22
def field(label, value, gap_after=14, bold_label=True):
nonlocal y
c.setFont(_BOLD if bold_label else _FONT, 8.5)
c.drawString(L, _ty(y), label)
lw = pdfmetrics.stringWidth(label + " ", _BOLD, 8.5)
c.setFont(_FONT, 8.5)
for i, ln in enumerate(_wrap(c, value, _FONT, 8.5, R - L - lw)):
c.drawString(L + (lw if i == 0 else 0), _ty(y), ln)
if i > 0:
y += 11
y += gap_after
field("NÁZEV ZAKÁZKY:", o.get("nazev_zakazky"))
field("KÓD ZAKÁZKY/NS:", o.get("kod_zakazky"), gap_after=20)
field("TEXT:", o.get("text"))
field("CENA CELKEM (bez DPH):", o.get("cena_celkem_text") or _money(o.get("cena_celkem")) + (" Kč" if o.get("cena_celkem") not in (None, "") else ""))
field("TERMÍN DODÁNÍ:", o.get("termin_dodani_text"), gap_after=20)
field("MÍSTO DODÁNÍ:", o.get("misto_dodani"))
field("KONTAKTNÍ OSOBA V MÍSTĚ DODÁNÍ (jméno, tel, email):",
o.get("kontaktni_osoba"), gap_after=20)
field("SPLATNOST:", o.get("splatnost"))
field("ZÁRUKA:", o.get("zaruka"))
field("POZNÁMKA:", o.get("poznamka"))
field("PŘÍLOHY:", o.get("prilohy"))
_draw_footer(c, o)
def _draw_footer(c, o):
fy = 135 # pt from the bottom of the page; block is drawn downward
# Datum / Vystavil
c.setFont(_FONT, 8)
c.drawString(L, fy, "Datum:")
c.drawString(360, fy, "Vystavil:")
c.setFont(_BOLD, 8)
c.drawString(L, fy - 12, str(o.get("datum_vystaveni") or ""))
c.drawString(360, fy - 12, str(o.get("vystavil") or ""))
c.setFont(_BOLD, 8)
c.drawString(L, fy - 30, "Faktury zasílejte na email adresu: faktury@colsys.cz")
legal = (
"Dodavatel souhlasí a potvrzuje, že se seznámil jako: A) prodávající s "
"Všeobecnými nákupními podmínkami (VNP), B) nebo jako zhotovitel s "
"Všeobecnými smluvními podmínkami (VSP) společnosti Colsys s.r.o. platnými "
"ke dni vystavení objednávky (viz http://www.colsys.cz/dokumenty-ke-stazeni). "
"Ve fakturách uvádějte vždy číslo objednávky přesně (včetně velkých a malých "
"písmen) a zasílejte je v elektronické formě emailem ve formátu isdoc nebo "
"isdocx. Přílohou faktury je vždy odsouhlasený protokol o předání/převzetí "
"(Dodací list, Předávací protokol, Zjišťovací protokol apod.)."
)
c.setFont(_FONT, 6.5)
yy = fy - 44
for ln in _wrap(c, legal, _FONT, 6.5, R - L):
c.drawString(L, yy, ln)
yy -= 8
c.drawString(L, yy - 2,
"Zapsáno: odd.C, vl. 902 obchodního rejstříku vedeného u Městského soudu v Praze.")
# ─────────────────────────── items table ───────────────────────────
_COLS = [
("Název zboží", "name", 200, "left"),
("ID dodavatele", "supplier_id", 80, "left"),
("Množství", "quantity", 55, "right"),
("MJ", "unit", 35, "left"),
("J. cena", "unit_price", 70, "right"),
("Celkem", "total_price", 75, "right"),
]
def _draw_items(c, o, items):
c.showPage()
num = o.get("objednavka_number") or ""
y = 50
c.setFont(_BOLD, 12)
c.drawString(L, _ty(y), "Položky objednávky")
c.setFont(_FONT, 9)
c.drawRightString(R, _ty(y), f"Objednávka: {num}")
y += 22
def header():
nonlocal y
x = L
c.setFont(_BOLD, 8)
c.setFillColorRGB(1, 1, 1)
c.setStrokeColorRGB(0.15, 0.39, 0.92)
c.setFillColorRGB(0.15, 0.39, 0.92)
c.rect(L, _ty(y + 14), R - L, 16, stroke=0, fill=1)
c.setFillColorRGB(1, 1, 1)
for title, _k, w, align in _COLS:
if align == "right":
c.drawRightString(x + w - 4, _ty(y + 10), title)
else:
c.drawString(x + 4, _ty(y + 10), title)
x += w
c.setFillColorRGB(0, 0, 0)
y += 18
header()
c.setFont(_FONT, 8)
total = 0.0
for it in items or []:
# page break
if y > PAGE_H - 80:
c.showPage()
y = 50
header()
c.setFont(_FONT, 8)
name_lines = _wrap(c, it.get("name"), _FONT, 8, _COLS[0][2] - 6)
row_h = max(12, len(name_lines) * 10 + 2)
x = L
for title, k, w, align in _COLS:
if k == "name":
for i, ln in enumerate(name_lines):
c.drawString(x + 4, _ty(y + 9 + i * 10), ln)
else:
v = it.get(k)
if k in ("unit_price", "total_price"):
s = _money(v)
elif k == "quantity":
s = _qty(v)
else:
s = str(v or "")
if align == "right":
c.drawRightString(x + w - 4, _ty(y + 9), s)
else:
c.drawString(x + 4, _ty(y + 9), s)
x += w
c.setStrokeColorRGB(0.85, 0.85, 0.85)
c.setLineWidth(0.4)
c.line(L, _ty(y + row_h), R, _ty(y + row_h))
y += row_h
try:
total += float(it.get("total_price") or 0)
except (TypeError, ValueError):
pass
# total row
y += 6
c.setFont(_BOLD, 9)
c.drawString(L + 4, _ty(y + 9), "Celkem bez DPH")
c.drawRightString(R - 4, _ty(y + 9), _money(total) + " Kč")
def build_order_pdf(order: dict, items: list, out_path: str) -> str:
c = canvas.Canvas(out_path, pagesize=A4)
_draw_form_page(c, order)
_draw_items(c, order, items)
c.save()
return out_path

View File

@@ -0,0 +1,65 @@
{
"swagger": "2.0",
"info": {
"title": "Colsys – Nabídka → Excel",
"description": "Zpracuje dodavatelskou nabídku (PDF/XLSX) a vrátí Excel s položkami.",
"version": "1.0"
},
"host": "ai.klas.chat",
"basePath": "/apps/nabidky",
"schemes": ["https"],
"consumes": ["application/json"],
"produces": ["application/json"],
"securityDefinitions": {
"apiKeyHeader": { "type": "apiKey", "in": "header", "name": "X-API-Key" }
},
"security": [ { "apiKeyHeader": [] } ],
"paths": {
"/api/process": {
"post": {
"summary": "Zpracovat nabídku",
"description": "Pošle přílohu nabídky a vrátí Excel.",
"operationId": "ProcessOffer",
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object",
"required": ["filename", "file_base64"],
"properties": {
"filename": { "type": "string", "x-ms-summary": "Název souboru", "description": "Např. nabidka.pdf" },
"file_base64": { "type": "string", "x-ms-summary": "Obsah souboru (base64)", "description": "ContentBytes přílohy" }
}
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"properties": {
"ok": { "type": "boolean", "x-ms-summary": "OK" },
"offer_number": { "type": "string", "x-ms-summary": "Číslo nabídky" },
"supplier_name": { "type": "string", "x-ms-summary": "Dodavatel" },
"item_count": { "type": "integer", "x-ms-summary": "Počet položek" },
"total": { "type": "number", "x-ms-summary": "Celkem bez DPH" },
"summary": { "type": "string", "x-ms-summary": "Shrnutí" },
"excel": {
"type": "object",
"x-ms-summary": "Excel",
"properties": {
"filename": { "type": "string", "x-ms-summary": "Název Excelu" },
"content_base64": { "type": "string", "x-ms-summary": "Obsah Excelu (base64)" }
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
fastapi>=0.115
uvicorn[standard]>=0.30
PyMuPDF>=1.24
Pillow>=10.0
openpyxl>=3.1
python-multipart>=0.0.9
openai>=1.50
python-dotenv>=1.0
reportlab>=4.0

View File

@@ -0,0 +1,635 @@
// Offer → Excel frontend
(() => {
const $ = (id) => document.getElementById(id);
const sections = {
upload: $("s-upload"),
processing: $("s-processing"),
result: $("s-result"),
};
const show = (name) => {
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
};
let state = { jobId: null, data: null };
// ── Upload ──
const fileInput = $("file-input");
const dropZone = $("drop-zone");
$("browse-btn").addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", (e) => e.target.files[0] && upload(e.target.files[0]));
["dragenter", "dragover"].forEach((ev) =>
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); }));
["dragleave", "drop"].forEach((ev) =>
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); }));
dropZone.addEventListener("drop", (e) => e.dataTransfer.files[0] && upload(e.dataTransfer.files[0]));
// Close any open combobox menu when clicking outside it (bound once).
document.addEventListener("click", (e) => {
document.querySelectorAll(".combo-menu").forEach((m) => {
if (!m.closest(".combo-wrap").contains(e.target)) m.classList.add("hidden");
});
});
async function upload(file) {
show("processing");
try {
const fd = new FormData();
fd.append("file", file);
const r = await fetch("api/upload", { method: "POST", body: fd });
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const json = await r.json();
state.jobId = json.job_id;
state.data = json.data || {};
await initOrder();
renderForm();
show("result");
} catch (err) {
alert("Chyba: " + err.message);
show("upload");
}
}
// ── Render editable form ──
function nestedField(parent, key, label, value, opts = {}) {
const isEmpty = value === null || value === undefined || value === "";
const cls = "inv-input" + (isEmpty ? " is-empty" : "");
return `
<div class="inv-field ${opts.wide ? "inv-field-wide" : ""}">
<label class="inv-label">${escapeHtml(label)}</label>
<input class="${cls}" data-parent="${parent}" data-key="${key}"
type="text" value="${escapeHtmlAttr(isEmpty ? "" : value)}"
placeholder="${escapeHtmlAttr(opts.placeholder || "—")}">
</div>`;
}
function renderForm() {
const d = state.data;
const sup = d.supplier || {};
const items = d.line_items || [];
const warn = (d.is_offer === false)
? `<div class="offer-warn">⚠️ Tento dokument zřejmě není dodavatelská
nabídka. Data mohou být neúplná — zkontrolujte je, nebo nahrajte
jiný soubor.</div>`
: "";
const html = `
${warn}
<!-- Dodavatel -->
<div class="inv-card">
<div class="inv-card-title">Dodavatel</div>
<div class="inv-grid">
${nestedField("supplier", "name", "Jméno", sup.name, {wide: true})}
${nestedField("supplier", "ico", "IČO", sup.ico)}
${nestedField("supplier", "dic", "DIČ", sup.dic)}
${nestedField("supplier", "email", "Kontaktní email", sup.email)}
${nestedField("supplier", "address", "Adresa", sup.address, {wide: true})}
</div>
</div>
<!-- Položky -->
<div class="inv-card">
<div class="inv-card-title">Položky (${items.length})</div>
<div class="inv-table-wrap">
<table class="inv-table" id="items-table">
<thead>
<tr>
<th style="min-width:220px">Název zboží</th>
<th style="width:150px">ID zboží dodavatele</th>
<th style="width:90px">Množství</th>
<th style="width:80px">Jednotka</th>
<th style="width:120px">Jednotková cena</th>
<th style="width:120px">Cena celkem</th>
<th class="inv-row-actions"></th>
</tr>
</thead>
<tbody id="items-body">
${items.map((it, i) => renderItemRow(it, i)).join("")}
</tbody>
</table>
</div>
<div class="inv-table-foot">
<button class="btn-add-row" type="button" id="add-item-btn">+ Přidat položku</button>
</div>
</div>
<div id="order-card-wrap">${orderCardHtml()}</div>
`;
$("offer-form").innerHTML = html;
bindFieldHandlers();
bindOrderHandlers();
initDatePickers();
}
function rerenderOrderCard() {
const wrap = $("order-card-wrap");
if (!wrap) return;
wrap.innerHTML = orderCardHtml();
bindOrderHandlers();
initDatePickers();
}
function initDatePickers() {
if (typeof flatpickr === "undefined") return;
if (flatpickr.l10ns && flatpickr.l10ns.cs) flatpickr.localize(flatpickr.l10ns.cs);
document.querySelectorAll(".fp-date").forEach((el) => {
if (el._flatpickr) return;
flatpickr(el, {
dateFormat: "d.m.Y", // matches the PDF / store format
allowInput: true,
disableMobile: true,
onChange: () => {
state.order[el.dataset.order] = el.value;
el.classList.toggle("is-empty", el.value === "");
},
});
});
}
function renderItemRow(item, i) {
return `
<tr data-row="${i}">
<td><input data-field="name" type="text" value="${escapeHtmlAttr(item.name || "")}"></td>
<td><input data-field="supplier_id" type="text" value="${escapeHtmlAttr(item.supplier_id || "")}"></td>
<td class="num"><input data-field="quantity" type="text" value="${fmtNum(item.quantity)}"></td>
<td><input data-field="unit" type="text" value="${escapeHtmlAttr(item.unit || "")}"></td>
<td class="num"><input data-field="unit_price" type="text" value="${fmtNum(item.unit_price)}"></td>
<td class="num"><input data-field="total_price" type="text" value="${fmtNum(item.total_price)}"></td>
<td class="inv-row-actions"><button class="inv-row-delete" type="button" data-action="delete-item" title="Smazat">×</button></td>
</tr>`;
}
function bindFieldHandlers() {
$("offer-form").querySelectorAll("input[data-key]").forEach((el) => {
el.addEventListener("input", () => {
const key = el.dataset.key;
const parent = el.dataset.parent;
if (parent) {
if (!state.data[parent]) state.data[parent] = {};
state.data[parent][key] = el.value;
} else {
state.data[key] = el.value;
}
el.classList.toggle("is-empty", el.value === "");
});
});
const itemsBody = $("items-body");
if (itemsBody) {
itemsBody.addEventListener("input", (e) => {
const inp = e.target.closest("input");
if (!inp) return;
const i = +inp.closest("tr").dataset.row;
const field = inp.dataset.field;
if (!state.data.line_items) state.data.line_items = [];
if (!state.data.line_items[i]) state.data.line_items[i] = {};
const numFields = ["quantity", "unit_price", "total_price"];
state.data.line_items[i][field] =
numFields.includes(field) ? parseNum(inp.value) : inp.value;
});
itemsBody.addEventListener("click", (e) => {
const btn = e.target.closest('[data-action="delete-item"]');
if (!btn) return;
const i = +btn.closest("tr").dataset.row;
state.data.line_items.splice(i, 1);
renderForm();
});
}
const addItem = $("add-item-btn");
if (addItem) {
addItem.addEventListener("click", () => {
if (!state.data.line_items) state.data.line_items = [];
state.data.line_items.push({name: "", supplier_id: "", quantity: null,
unit: "", unit_price: null, total_price: null});
renderForm();
});
}
}
function parseNum(s) {
if (s === "" || s == null) return null;
const n = parseFloat(String(s).replace(",", ".").replace(/\s/g, ""));
return Number.isFinite(n) ? n : null;
}
function fmtNum(v) {
if (v === null || v === undefined || v === "") return "";
return String(v);
}
// ── Export ──
$("export-btn").addEventListener("click", async () => {
if (!state.jobId) return;
await fetch(`api/save/${state.jobId}`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({data: state.data}),
}).catch(() => {});
window.location.href = `api/export/${state.jobId}`;
});
$("restart-btn").addEventListener("click", () => {
state = { jobId: null, data: null };
fileInput.value = "";
$("offer-form").innerHTML = "";
show("upload");
});
// ── Objednávka (order form) ──────────────────────────────────
function sumTotals() {
return (state.data.line_items || []).reduce(
(a, it) => a + (Number(it.total_price) || 0), 0);
}
async function initOrder() {
const d = state.data;
const offerNo = d.offer_number || "";
state.order = {
objednavka_number: "",
kod_zakazky: "",
nazev_zakazky: "",
dodavatel: d.supplier || {},
text: "Objednáváme u vás zboží dle cenové nabídky " + offerNo,
prilohy: offerNo,
termin_dodani_text: "Co nejdříve",
misto_dodani: "",
kontaktni_osoba: "",
splatnost: "dle VNP nebo VSP",
zaruka: "",
poznamka: "",
vystavil: "",
popis: "",
zpusob_platby: "Bankovním převodem",
zpusob_odberu: "-",
zpusob_dopravy: "-",
forma_objednavky: "mailem",
termin_dodani: "",
fakturujte: "s DPH",
};
state.projects = [];
state.orderHistory = {};
try {
const r = await fetch("api/projects");
if (r.ok) {
const j = await r.json();
state.projects = j.projects || [];
if (j.next_order_number) state.order.objednavka_number = j.next_order_number;
}
} catch (_) {}
}
// Body fields. `hist` = remembered per project, shown with a history
// quick-pick (most-used first).
const ORDER_FIELDS = [
["text", "Text", true, false],
["termin_dodani_text", "Termín dodání (text)", false, false],
["misto_dodani", "Místo dodání", true, true],
["kontaktni_osoba", "Kontaktní osoba v místě dodání", true, true],
["splatnost", "Splatnost", false, true],
["zaruka", "Záruka", false, true],
["poznamka", "Poznámka", false, false],
["prilohy", "Přílohy", false, false],
["vystavil", "Vystavil", false, true],
["popis", "Popis", false, true],
];
function fieldHtml(key, label, wide, hist) {
return hist ? historyField(key, label, wide) : textField(key, label, wide);
}
// ── date helpers (PDF/store use DD.MM.YYYY, <input type=date> uses ISO) ──
function toDateInput(s) {
s = (s == null ? "" : String(s)).trim();
let m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (m) return `${m[3]}-${m[2].padStart(2,"0")}-${m[1].padStart(2,"0")}`;
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
return "";
}
function fromDateInput(s) {
const m = (s || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
return m ? `${+m[3]}.${+m[2]}.${m[1]}` : (s || "");
}
// Combobox: type freely OR pick a previously-used value from a themed
// dropdown attached to the field; each option can be deleted from history.
function comboMenuHtml(key, filter) {
const hist = (state.orderHistory && state.orderHistory[key]) || [];
const f = (filter || "").toLowerCase();
const items = hist.filter((h) => !f || String(h.value).toLowerCase().includes(f));
if (!items.length) return '<div class="combo-empty">Žádné dříve použité</div>';
return items.map((h) => `
<div class="combo-opt" data-val="${escapeHtmlAttr(h.value)}">
<span class="combo-opt-text">${escapeHtml(h.value)}
<span class="combo-opt-count">${h.count}×</span></span>
<button class="combo-del" type="button" title="Smazat z historie">×</button>
</div>`).join("");
}
function historyField(key, label, wide) {
const v = state.order[key];
const cur = v == null ? "" : String(v);
const hasHist = ((state.orderHistory && state.orderHistory[key]) || []).length > 0;
return `
<div class="inv-field ${wide ? "inv-field-wide" : ""}">
<label class="inv-label">${escapeHtml(label)}</label>
<div class="combo-wrap" data-combo="${key}">
<input class="inv-input combo-input${cur === "" ? " is-empty" : ""}" data-order="${key}"
type="text" autocomplete="off" value="${escapeHtmlAttr(cur)}">
${hasHist ? '<button class="combo-toggle" type="button" tabindex="-1" aria-label="Dříve použité">⌄</button>' : ""}
<div class="combo-menu hidden">${comboMenuHtml(key)}</div>
</div>
</div>`;
}
// Dodací a platební podmínky — selectable presets (editable: type or pick).
const TERMS_FIELDS = [
["zpusob_platby", "Způsob platby", ["Bankovním převodem", "Hotově", "Kartou", "Zálohově", "Dobírka"]],
["forma_objednavky", "Forma objednávky", ["mailem", "telefonicky", "osobně", "písemně"]],
["zpusob_odberu", "Způsob odběru", ["-", "Osobní odběr", "Dodání na stavbu", "Dopravcem"]],
["termin_dodani", "Termín dodání", []],
["zpusob_dopravy", "Způsob dopravy", ["-", "Dodavatel", "Vlastní", "Dopravce"]],
["fakturujte", "Fakturujte", ["s DPH", "bez DPH"]],
];
function textField(key, label, wide) {
const v = state.order[key];
const isEmpty = v === null || v === undefined || v === "";
return `
<div class="inv-field ${wide ? "inv-field-wide" : ""}">
<label class="inv-label">${escapeHtml(label)}</label>
<input class="inv-input${isEmpty ? " is-empty" : ""}" data-order="${key}"
type="text" value="${escapeHtmlAttr(isEmpty ? "" : v)}">
</div>`;
}
function comboField(key, label, options) {
const v = state.order[key];
const cur = v == null ? "" : String(v);
// Termín dodání → flatpickr date picker (value stays DD.MM.YYYY).
if (key === "termin_dodani") {
return `
<div class="inv-field">
<label class="inv-label">${escapeHtml(label)}</label>
<input class="inv-input fp-date${cur === "" ? " is-empty" : ""}" data-order="${key}"
type="text" placeholder="dd.mm.rrrr" value="${escapeHtmlAttr(cur)}">
</div>`;
}
// Real dropdown: history (most-used first) + presets + any custom value.
const histVals = ((state.orderHistory && state.orderHistory[key]) || [])
.map((h) => h.value);
const vals = [];
for (const x of [...histVals, ...(options || []), cur]) {
if (x && !vals.includes(x)) vals.push(x);
}
const opts = ['<option value=""></option>']
.concat(vals.map((o) =>
`<option value="${escapeHtmlAttr(o)}"${o === cur ? " selected" : ""}>${escapeHtml(o)}</option>`))
.join("");
return `
<div class="inv-field">
<label class="inv-label">${escapeHtml(label)}</label>
<select class="inv-input" data-order="${key}">${opts}</select>
</div>`;
}
function projectOptions() {
const cur = state.order.kod_zakazky || "";
const opts = ['<option value="">— vyberte zakázku —</option>'];
for (const p of state.projects || []) {
const sel = p.kod === cur ? " selected" : "";
const label = p.nazev ? `${p.kod} — ${p.nazev}` : p.kod;
opts.push(`<option value="${escapeHtmlAttr(p.kod)}"${sel}>${escapeHtml(label)}</option>`);
}
return opts.join("");
}
function orderCardHtml() {
return `
<div class="inv-card">
<div class="inv-card-title">Objednávka (vyplní se do PDF)</div>
<p class="order-hint">Zakázku vyberte z nabídky (nebo přidejte přes
<strong>Spravovat zakázky</strong>). Pole jako <strong>Místo dodání</strong>,
kontaktní osoba či Vystavil se pamatují podle zakázky. Číslo objednávky
se předvyplní jako poslední&nbsp;+&nbsp;1. Cena celkem z položek:
${fmtCzk(sumTotals())}.</p>
<div class="inv-grid">
${textField("objednavka_number", "Číslo objednávky", false)}
<div class="inv-field">
<label class="inv-label">Kód zakázky / NS</label>
<div class="proj-row">
<select class="inv-input" id="proj-select">${projectOptions()}</select>
<button class="btn btn-secondary btn-sm" id="proj-manage" type="button">Spravovat</button>
</div>
</div>
${textField("nazev_zakazky", "Název zakázky", true)}
${ORDER_FIELDS.map(([k, l, w, h]) => fieldHtml(k, l, w, h)).join("")}
</div>
<div class="terms-subtitle">Dodací a platební podmínky</div>
<div class="inv-grid">
${TERMS_FIELDS.map(([k, l, o]) => comboField(k, l, o)).join("")}
</div>
<div class="proj-panel hidden" id="proj-panel"></div>
</div>`;
}
function fmtCzk(n) {
return (Number(n) || 0).toLocaleString("cs-CZ", {minimumFractionDigits: 2, maximumFractionDigits: 2}) + " Kč";
}
function applyOrderToInputs() {
document.querySelectorAll('[data-order]').forEach((inp) => {
const k = inp.dataset.order;
if (state.order[k] != null) {
inp.value = state.order[k];
inp.classList.toggle("is-empty", inp.value === "");
}
});
document.querySelectorAll('[data-order-date]').forEach((inp) => {
const k = inp.dataset.orderDate;
inp.value = toDateInput(state.order[k]);
});
}
async function loadProjectDefaults(code) {
if (!code) { state.orderHistory = {}; return; }
try {
const r = await fetch(`api/order-defaults?code=${encodeURIComponent(code)}`);
if (!r.ok) return;
const { defaults } = await r.json();
if (defaults && Object.keys(defaults).length) {
state.orderHistory = defaults._history || {};
delete defaults._history;
Object.assign(state.order, defaults);
// Re-render so history dropdowns reflect the selected project.
rerenderOrderCard();
}
} catch (_) {}
}
function renderProjPanel() {
const rows = (state.projects || []).map((p) => `
<div class="proj-item" data-kod="${escapeHtmlAttr(p.kod)}">
<span class="proj-kod">${escapeHtml(p.kod)}</span>
<input class="inv-input proj-nazev" value="${escapeHtmlAttr(p.nazev || "")}" placeholder="Název zakázky">
<button class="btn btn-secondary btn-sm proj-save" type="button">Uložit</button>
<button class="btn btn-secondary btn-sm proj-del" type="button">Smazat</button>
</div>`).join("");
return `
<div class="proj-add">
<input class="inv-input" id="proj-new-kod" placeholder="Číslo zakázky (kód/NS)">
<input class="inv-input" id="proj-new-nazev" placeholder="Název zakázky">
<button class="btn btn-primary btn-sm" id="proj-add-btn" type="button">Přidat</button>
</div>
<div class="proj-list">${rows || '<p class="order-hint">Zatím žádné zakázky.</p>'}</div>`;
}
async function refreshProjects(list) {
state.projects = list || state.projects;
const sel = $("proj-select");
if (sel) sel.innerHTML = projectOptions();
const panel = $("proj-panel");
if (panel && !panel.classList.contains("hidden")) panel.innerHTML = renderProjPanel();
}
function bindOrderHandlers() {
document.querySelectorAll('[data-order]').forEach((el) => {
const upd = () => {
state.order[el.dataset.order] = el.value;
el.classList.toggle("is-empty", el.value === "");
};
el.addEventListener("input", upd);
el.addEventListener("change", upd); // <select> fires change
});
// Date pickers store DD.MM.YYYY in the order.
document.querySelectorAll('[data-order-date]').forEach((el) => {
el.addEventListener("change", () => {
state.order[el.dataset.orderDate] = fromDateInput(el.value);
el.classList.toggle("is-empty", el.value === "");
});
});
// Combobox fields: open menu on focus/toggle, filter on type, pick or
// delete a previously-used value.
document.querySelectorAll(".combo-wrap").forEach((wrap) => {
const key = wrap.dataset.combo;
const input = wrap.querySelector(".combo-input");
const menu = wrap.querySelector(".combo-menu");
const toggle = wrap.querySelector(".combo-toggle");
const open = () => { menu.innerHTML = comboMenuHtml(key, input.value); menu.classList.remove("hidden"); };
const close = () => menu.classList.add("hidden");
if (toggle) toggle.addEventListener("click", (e) => {
e.stopPropagation();
menu.classList.contains("hidden") ? open() : close();
});
input.addEventListener("focus", open);
input.addEventListener("input", open); // value already synced by generic handler
menu.addEventListener("click", async (e) => {
const del = e.target.closest(".combo-del");
if (del) {
e.stopPropagation();
const val = del.closest(".combo-opt").dataset.val;
const code = state.order.kod_zakazky;
if (!code) { menu.querySelector(`[data-val="${CSS.escape(val)}"]`)?.remove(); return; }
try {
const r = await fetch("api/order-history/delete", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({ code, field: key, value: val }),
});
if (r.ok) {
state.orderHistory = (await r.json()).defaults._history || {};
menu.innerHTML = comboMenuHtml(key, input.value);
}
} catch (_) {}
return;
}
const opt = e.target.closest(".combo-opt");
if (opt) {
input.value = opt.dataset.val;
input.classList.remove("is-empty");
state.order[key] = opt.dataset.val;
close();
}
});
});
const sel = $("proj-select");
if (sel) sel.addEventListener("change", async () => {
const code = sel.value;
state.order.kod_zakazky = code;
const p = (state.projects || []).find((x) => x.kod === code);
state.order.nazev_zakazky = p ? (p.nazev || "") : "";
applyOrderToInputs();
await loadProjectDefaults(code);
});
const manage = $("proj-manage");
if (manage) manage.addEventListener("click", () => {
const panel = $("proj-panel");
panel.classList.toggle("hidden");
if (!panel.classList.contains("hidden")) {
panel.innerHTML = renderProjPanel();
panel.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
const panel = $("proj-panel");
if (panel) {
panel.addEventListener("click", async (e) => {
const addBtn = e.target.closest("#proj-add-btn");
if (addBtn) {
const kod = $("proj-new-kod").value.trim();
const nazev = $("proj-new-nazev").value.trim();
if (!kod) { alert("Zadejte číslo zakázky."); return; }
const r = await fetch("api/projects", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({kod, nazev})});
if (r.ok) { refreshProjects((await r.json()).projects); }
return;
}
const item = e.target.closest(".proj-item");
if (!item) return;
const kod = item.dataset.kod;
if (e.target.closest(".proj-save")) {
const nazev = item.querySelector(".proj-nazev").value.trim();
const r = await fetch("api/projects", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({kod, nazev})});
if (r.ok) refreshProjects((await r.json()).projects);
} else if (e.target.closest(".proj-del")) {
if (!confirm(`Smazat zakázku ${kod}?`)) return;
const r = await fetch(`api/projects/${encodeURIComponent(kod)}`, {method:"DELETE"});
if (r.ok) refreshProjects((await r.json()).projects);
}
});
}
}
$("order-pdf-btn").addEventListener("click", async () => {
if (!state.jobId) return;
const btn = $("order-pdf-btn");
const old = btn.textContent;
btn.disabled = true; btn.textContent = "Generuji…";
try {
// keep supplier in sync as dodavatel
state.order.dodavatel = state.data.supplier || {};
state.order.cena_celkem = sumTotals();
const r = await fetch(`api/order-pdf/${state.jobId}`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ order: state.order, items: state.data.line_items || [] }),
});
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const cd = r.headers.get("content-disposition") || "";
const m = cd.match(/filename="?([^"]+)"?/);
const blob = await r.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = m ? m[1] : "objednavka.pdf";
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 60000);
} catch (err) {
alert("Chyba: " + err.message);
} finally {
btn.disabled = false; btn.textContent = old;
}
});
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
function escapeHtmlAttr(s) { return escapeHtml(s); }
})();

View File

@@ -0,0 +1,325 @@
/* Invoice-extractor specific styles */
.processing-sub {
font-size: 13px;
color: var(--text-tertiary);
margin: 8px auto 0;
max-width: 400px;
}
.invoice-form,
.offer-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.order-hint {
font-size: 12.5px;
color: var(--text-tertiary);
margin: -2px 0 14px;
line-height: 1.5;
}
/* Combobox (type or pick previously-used value, with per-item delete) */
.combo-wrap { position: relative; }
/* Transparent click target over the chevron (drawn by the input background). */
.combo-toggle {
position: absolute; right: 0; top: 0; bottom: 0; width: 30px;
border: none; background: transparent; cursor: pointer; padding: 0;
font-size: 0; color: transparent;
}
.combo-menu {
position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: 60;
background: var(--bg-primary);
border: 1px solid var(--border-default);
border-radius: 6px;
box-shadow: var(--shadow-card, 0 8px 28px rgba(0,0,0,.18));
max-height: 220px; overflow-y: auto; padding: 4px;
}
.combo-opt {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 6px 8px; border-radius: 5px; cursor: pointer;
font-size: 13.5px; color: var(--text-primary);
}
.combo-opt:hover { background: var(--bg-secondary); }
.combo-opt-count { color: var(--text-tertiary); font-size: 12px; margin-left: 4px; }
.combo-del {
border: none; background: transparent; color: var(--text-tertiary);
cursor: pointer; font-size: 17px; line-height: 1; padding: 0 4px; border-radius: 4px;
flex-shrink: 0;
}
.combo-del:hover { color: var(--danger, #c0392b); background: var(--bg-secondary); }
.combo-empty { padding: 8px; color: var(--text-tertiary); font-size: 13px; }
/* ── flatpickr date picker — themed to the page ── */
.flatpickr-calendar {
background: var(--bg-primary);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card, 0 8px 28px rgba(0,0,0,.18));
font-family: inherit;
}
.flatpickr-calendar.arrowTop::before,
.flatpickr-calendar.arrowTop::after { border-bottom-color: var(--bg-primary); }
.flatpickr-calendar.arrowBottom::before,
.flatpickr-calendar.arrowBottom::after { border-top-color: var(--bg-primary); }
.flatpickr-months,
.flatpickr-month,
.flatpickr-current-month input.cur-year,
.flatpickr-weekdays,
span.flatpickr-weekday {
background: transparent;
color: var(--text-secondary);
fill: var(--text-secondary);
}
.flatpickr-current-month .flatpickr-monthDropdown-months { background: var(--bg-primary); }
.flatpickr-months .flatpickr-month { color: var(--text-primary); fill: var(--text-primary); }
.flatpickr-months .flatpickr-prev-month svg,
.flatpickr-months .flatpickr-next-month svg { fill: var(--text-secondary); }
.flatpickr-months .flatpickr-prev-month:hover svg,
.flatpickr-months .flatpickr-next-month:hover svg { fill: var(--primary); }
.flatpickr-day {
color: var(--text-primary);
border-radius: 6px;
}
.flatpickr-day:hover,
.flatpickr-day:focus { background: var(--bg-secondary); border-color: var(--bg-secondary); }
.flatpickr-day.today { border-color: var(--primary); }
.flatpickr-day.selected,
.flatpickr-day.selected:hover {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.prevMonthDay,
.flatpickr-day.nextMonthDay { color: var(--text-quaternary); }
.flatpickr-calendar .numInputWrapper span:hover { background: var(--bg-secondary); }
.terms-subtitle {
font-weight: 600;
font-size: 13.5px;
margin: 16px 0 10px;
padding-top: 12px;
border-top: 1px solid var(--border-subtle);
color: var(--text-secondary);
}
.proj-row { display: flex; gap: 6px; align-items: center; }
.proj-row select { flex: 1; }
.btn-sm { padding: 6px 10px; font-size: 13px; white-space: nowrap; }
.proj-panel {
margin-top: 14px;
padding: 12px;
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--bg-secondary);
}
.proj-add { display: flex; gap: 6px; margin-bottom: 10px; }
.proj-add input { flex: 1; }
.proj-item {
display: flex; gap: 6px; align-items: center; margin-bottom: 6px;
}
.proj-item .proj-kod { width: 130px; font-weight: 600; font-size: 13px; }
.proj-item .proj-nazev { flex: 1; }
.offer-warn {
background: #FEF3C7;
border: 1px solid #FCD34D;
color: #92400E;
border-radius: var(--radius-md);
padding: 12px 16px;
font-size: 14px;
line-height: 1.5;
}
:root.dark .offer-warn {
background: #422006;
border-color: #854D0E;
color: #FDE68A;
}
.inv-card {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 18px 20px;
box-shadow: var(--shadow-card);
}
.inv-card-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--primary);
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-default);
}
.inv-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
}
.inv-grid-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px 16px;
}
@media (max-width: 720px) {
.inv-grid, .inv-grid-3 { grid-template-columns: 1fr; }
}
.inv-field { display: flex; flex-direction: column; gap: 4px; }
.inv-field-wide { grid-column: 1 / -1; }
.inv-label {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary);
letter-spacing: 0.02em;
}
.inv-input {
width: 100%;
padding: 8px 10px;
background: var(--bg-secondary);
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
transition: border-color .15s, background .15s;
}
.inv-input:focus {
outline: none;
border-color: var(--primary);
background-color: var(--bg-primary); /* keep any chevron background-image */
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
/* Unified custom dropdown chevron for both <select> and combobox inputs,
so the preset-option fields and the history fields look identical. */
select.inv-input,
.combo-input {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 9px center;
background-size: 15px;
padding-right: 30px;
}
html.dark select.inv-input,
html.dark .combo-input {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
}
select.inv-input::-ms-expand { display: none; }
.inv-input.is-empty { color: var(--text-quaternary); font-style: italic; }
.inv-input.is-empty::placeholder { color: var(--text-quaternary); }
/* Tables (line items & vat) */
.inv-table-wrap { overflow-x: auto; }
.inv-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.inv-table th, .inv-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-default);
text-align: left;
}
.inv-table th {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary);
background: var(--bg-secondary);
}
.inv-table td input {
width: 100%;
background: transparent;
border: 1px solid transparent;
padding: 4px 6px;
border-radius: 4px;
font-size: 13px;
font-family: inherit;
color: var(--text-primary);
}
.inv-table td input:focus {
outline: none;
background: var(--bg-primary);
border-color: var(--primary);
}
.inv-table td.num input { text-align: right; }
.inv-row-actions { width: 36px; text-align: center; }
.inv-row-delete {
background: transparent;
border: none;
color: var(--text-quaternary);
cursor: pointer;
font-size: 16px;
padding: 2px 6px;
border-radius: 4px;
}
.inv-row-delete:hover { color: #dc2626; background: rgba(239,68,68,0.08); }
.inv-table-foot {
display: flex;
justify-content: flex-start;
margin-top: 10px;
}
.btn-add-row {
background: transparent;
border: 1px dashed var(--border-strong);
color: var(--text-tertiary);
font-size: 12px;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
}
.btn-add-row:hover { color: var(--primary); border-color: var(--primary); }
.totals-row {
display: flex;
justify-content: flex-end;
gap: 32px;
padding-top: 14px;
border-top: 2px solid var(--border-default);
}
.totals-row .total-cell {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.totals-row .total-cell-label {
font-size: 11px;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.totals-row .total-cell-input {
width: 140px;
text-align: right;
font-weight: 600;
}
.totals-row .total-cell.primary .total-cell-input {
font-size: 18px;
color: var(--primary);
}
/* Back link */
.back-link {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 12px 6px 10px; border-radius: 8px;
font-size: 13px; font-weight: 500; color: var(--text-tertiary);
text-decoration: none; border: 0.5px solid var(--border-default);
background: var(--bg-primary); flex-shrink: 0;
transition: color .15s, border-color .15s, background .15s;
}
.back-link:hover {
color: var(--primary); border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
}
.back-link svg { opacity: 0.8; }
@media (max-width: 640px) {
.back-link span { display: none; }
.back-link { padding: 6px; }
}

View File

@@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nabídka → objednávka | Colsys AI</title>
<!-- Relative base works both behind the portal (/apps/nabidky/) and
standalone at the root (/) on the production host. -->
<base href="./">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="static/vendor/flatpickr.min.css?v=10">
<link rel="stylesheet" href="static/styles.css?v=10">
<link rel="stylesheet" href="static/extra.css?v=10">
<script>
// Theme bootstrap — ?theme= URL → portal_theme cookie → app_theme localStorage → OS
(function () {
var t = null;
try {
var p = new URL(window.location.href).searchParams.get("theme");
if (p === "dark" || p === "light") t = p;
} catch (e) {}
if (!t) {
var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
if (m) t = decodeURIComponent(m[1]);
}
if (!t) { try { t = localStorage.getItem("app_theme"); } catch (e) {} }
if (!t && typeof matchMedia !== "undefined") {
try { t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } catch (e) {}
}
if (t === "dark" || t === "light") {
document.documentElement.classList.add(t);
try { localStorage.setItem("app_theme", t); } catch (e) {}
}
})();
function _resync() {
var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
if (!m) return;
var t = decodeURIComponent(m[1]);
if (t !== "dark" && t !== "light") return;
var cls = document.documentElement.classList;
if (cls.contains(t)) return;
cls.remove(t === "dark" ? "light" : "dark");
cls.add(t);
try { localStorage.setItem("app_theme", t); } catch (e) {}
}
document.addEventListener("visibilitychange", function () {
if (document.visibilityState === "visible") _resync();
});
window.addEventListener("pageshow", _resync);
</script>
</head>
<body>
<header class="header">
<div class="header-inner">
<a href="/" class="brand">
<span class="brand-icon">C</span>
<span class="brand-name">Colsys <span class="brand-ai">AI</span></span>
</a>
<span class="header-crumb">Nabídka → Excel</span>
<a href="/" class="back-link" title="Zpět na portál">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6"/>
</svg>
<span>Zpět na portál</span>
</a>
</div>
</header>
<main class="main">
<section id="s-upload">
<div class="section-intro">
<h1 class="section-title">Převod nabídky do Excelu</h1>
<p class="section-desc">
Nahrajte dodavatelskou nabídku (PDF nebo Excel). Aplikace z ní
vytáhne údaje o dodavateli a položky a připraví Excel v jednotném
tvaru. Před stažením můžete data zkontrolovat a upravit.
</p>
</div>
<div id="drop-zone" class="drop-zone">
<svg class="drop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 16.5V4.5m0 0-3.75 3.75M12 4.5l3.75 3.75M4.5 19.5h15"/>
</svg>
<p class="drop-text">Přetáhněte nabídku sem</p>
<p class="drop-or">nebo</p>
<button class="btn btn-secondary" id="browse-btn" type="button">Vybrat soubor</button>
<p class="drop-formats">PDF · XLSX</p>
<input type="file" id="file-input" accept=".pdf,.xlsx,.xlsm,.xls" style="display:none">
</div>
</section>
<section id="s-processing" class="hidden">
<div class="processing-card">
<div class="spinner"></div>
<h2 class="processing-title">Čtu nabídku…</h2>
<p class="processing-sub">AI extrahuje dodavatele a položky. Obvykle 10–40 sekund.</p>
</div>
</section>
<section id="s-result" class="hidden">
<div class="results-header">
<div>
<h2 class="results-title">Extrahovaná data</h2>
<p class="results-meta">Zkontrolujte hodnoty, případně upravte, a stáhněte Excel.</p>
</div>
<div class="results-actions">
<button class="btn btn-secondary" id="restart-btn" type="button">Nová nabídka</button>
<button class="btn btn-secondary" id="export-btn" type="button">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v12m0 0-3.75-3.75M12 16.5l3.75-3.75M4.5 19.5h15"/>
</svg>
Stáhnout Excel
</button>
<button class="btn btn-primary" id="order-pdf-btn" type="button">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v12m0 0-3.75-3.75M12 16.5l3.75-3.75M4.5 19.5h15"/>
</svg>
Objednávka (PDF)
</button>
</div>
</div>
<div class="offer-form" id="offer-form"></div>
</section>
</main>
<script src="static/vendor/flatpickr.min.js?v=10"></script>
<script src="static/vendor/flatpickr.cs.js?v=10"></script>
<script src="static/app.js?v=10"></script>
</body>
</html>

View File

@@ -0,0 +1,461 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f2f4f7;
--text-primary: #101828;
--text-secondary: #354052;
--text-tertiary: #676f83;
--text-quaternary: #98a2b2;
--border-default: rgb(16 24 40 / 0.08);
--border-strong: #d0d5dc;
--border-subtle: rgb(16 24 40 / 0.04);
--card: #ffffff;
--primary: #155aef;
--primary-hover: #004aeb;
--accent-indigo: #444ce7;
--shadow-card: 0 1px 2px rgb(16 24 40 / 0.05);
--radius-md: 8px;
--radius-lg: 12px;
}
/* Dark theme — applies when (a) user OS prefers dark and no .light override,
or (b) :root has explicit .dark class (set by portal_theme cookie). */
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--bg-primary: #14181f;
--bg-secondary: #1a1f29;
--bg-tertiary: #232936;
--text-primary: #f5f7fa;
--text-secondary: #c8ccd5;
--text-tertiary: #98a2b2;
--text-quaternary: #676f83;
--border-default: rgb(255 255 255 / 0.08);
--border-strong: #354052;
--border-subtle: rgb(255 255 255 / 0.04);
--card: #1a1f29;
}
}
:root.dark {
--bg-primary: #14181f;
--bg-secondary: #1a1f29;
--bg-tertiary: #232936;
--text-primary: #f5f7fa;
--text-secondary: #c8ccd5;
--text-tertiary: #98a2b2;
--text-quaternary: #676f83;
--border-default: rgb(255 255 255 / 0.08);
--border-strong: #354052;
--border-subtle: rgb(255 255 255 / 0.04);
--card: #1a1f29;
}
body {
font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-secondary);
color: var(--text-primary);
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Header ─────────────────────────────────────── */
.header {
position: sticky;
top: 0;
z-index: 30;
border-bottom: 0.5px solid var(--border-default);
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
backdrop-filter: blur(8px);
}
.header-inner {
max-width: 1280px; /* match portal max-w-7xl so brand doesn't shift */
margin: 0 auto;
height: 56px; /* portal uses h-14 */
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px; /* portal px-4 */
gap: 16px;
}
@media (min-width: 640px) {
.header-inner { padding: 0 32px; } /* portal sm:px-8 */
}
.brand {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
flex-shrink: 0;
}
.brand-icon {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%);
box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
letter-spacing: -0.04em;
color: white;
}
.brand-name {
font-size: 14px; /* tailwind text-sm */
font-weight: 600;
letter-spacing: -0.025em; /* tailwind tracking-tight */
color: var(--text-primary);
}
.brand-ai { color: var(--primary); }
.header-crumb {
font-size: 13px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Main ────────────────────────────────────────── */
.main {
max-width: 900px;
margin: 0 auto;
padding: 40px 24px 80px;
display: flex;
flex-direction: column;
gap: 32px;
}
@media (max-width: 640px) {
.header-inner { padding: 0 16px; }
.main { padding: 24px 16px 60px; }
}
/* ── Section intro ───────────────────────────────── */
.section-intro { margin-bottom: 24px; }
.section-title {
font-size: 22px;
font-weight: 600;
letter-spacing: -0.02em;
margin-bottom: 8px;
}
.section-desc {
color: var(--text-tertiary);
max-width: 580px;
font-size: 14px;
line-height: 1.6;
}
/* ── Examples panel ──────────────────────────────── */
.examples-panel {
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: var(--card);
box-shadow: var(--shadow-card);
padding: 16px;
margin-bottom: 16px;
}
.examples-header {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 12px;
}
.examples-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.examples-subtitle {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
}
.examples-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
margin-bottom: 10px;
}
.examples-list:empty::before {
content: "Zatím žádné vzory — přidejte alespoň jeden níže";
font-size: 12px;
color: var(--text-quaternary);
font-style: italic;
padding: 6px 0;
}
.example-chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
color: var(--text-primary);
padding: 4px 4px 4px 10px;
border-radius: 999px;
font-size: 12px;
font-family: ui-monospace, monospace;
}
.example-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-tertiary);
padding: 0;
transition: background 0.1s, color 0.1s;
}
.example-chip-remove:hover {
background: color-mix(in srgb, var(--primary) 18%, transparent);
color: var(--text-primary);
}
.example-chip-remove svg {
width: 12px;
height: 12px;
}
.examples-input-row {
display: flex;
gap: 8px;
}
.example-input {
flex: 1;
font-family: ui-monospace, monospace;
font-size: 13px;
padding: 8px 12px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--bg-primary);
color: var(--text-primary);
min-width: 0;
}
.example-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
.example-add-btn {
flex-shrink: 0;
}
/* ── Drop zone ───────────────────────────────────── */
.drop-zone {
border: 1.5px dashed var(--border-strong);
border-radius: var(--radius-lg);
background: var(--card);
padding: 56px 32px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
box-shadow: var(--shadow-card);
}
.drop-zone:hover, .drop-zone.drag-over {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, var(--card));
}
.drop-icon {
width: 44px;
height: 44px;
color: var(--text-quaternary);
margin: 0 auto 18px;
display: block;
transition: color 0.15s;
}
.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon {
color: var(--primary);
}
.drop-text {
font-size: 15px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
.drop-or {
font-size: 13px;
color: var(--text-quaternary);
margin-bottom: 14px;
}
.drop-formats {
font-size: 12px;
color: var(--text-quaternary);
margin-top: 14px;
}
/* ── Buttons ─────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.15s, box-shadow 0.15s;
line-height: 1;
}
.btn-primary {
background: var(--primary);
color: #fff;
box-shadow: 0 1px 2px rgb(21 90 239 / 0.25);
}
.btn-primary:hover { background: var(--primary-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: default; }
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-strong);
}
.btn-secondary:hover { background: var(--border-strong); }
.btn-icon { width: 15px; height: 15px; flex-shrink: 0; }
/* ── Processing card ─────────────────────────────── */
.processing-card {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 56px 32px;
text-align: center;
box-shadow: var(--shadow-card);
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--border-strong);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.75s linear infinite;
margin: 0 auto 22px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.processing-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
color: var(--text-primary);
}
.steps-list {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 320px;
margin: 0 auto;
text-align: left;
}
.step-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text-tertiary);
padding: 8px 12px;
border-radius: var(--radius-md);
background: var(--bg-tertiary);
}
.step-item.active {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
}
.step-item.done { color: #17b26a; }
.step-item.error { color: #d92d20; background: #fef3f2; }
.step-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
/* ── Results ─────────────────────────────────────── */
.results-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.results-title {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.015em;
margin-bottom: 4px;
}
.results-meta {
font-size: 13px;
color: var(--text-tertiary);
}
.results-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* ── Table ───────────────────────────────────────── */
.table-wrap {
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-card);
background: var(--card);
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
min-width: 500px;
}
thead {
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-default);
}
th {
padding: 10px 14px;
text-align: left;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary);
white-space: nowrap;
}
td {
padding: 8px 14px;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-primary);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:nth-child(even) td { background: var(--bg-secondary); }
td[contenteditable]:focus {
outline: none;
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
box-shadow: inset 0 0 0 1.5px var(--primary);
border-radius: 3px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 99px;
font-size: 11px;
font-weight: 500;
}
.badge-rule { background: #d1e0ff; color: #004aeb; }
.badge-llm { background: #d1fae5; color: #065f46; }
@media (prefers-color-scheme: dark) {
.badge-rule { background: #1e3a8a; color: #93c5fd; }
.badge-llm { background: #064e3b; color: #6ee7b7; }
}
.table-hint {
font-size: 12px;
color: var(--text-quaternary);
margin-top: 8px;
}
.hidden { display: none !important; }

View File

@@ -0,0 +1,75 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.cs = {}));
}(this, (function (exports) { 'use strict';
var fp = typeof window !== "undefined" && window.flatpickr !== undefined
? window.flatpickr
: {
l10ns: {},
};
var Czech = {
weekdays: {
shorthand: ["Ne", "Po", "Út", "St", "Čt", "Pá", "So"],
longhand: [
"Neděle",
"Pondělí",
"Úterý",
"Středa",
"Čtvrtek",
"Pátek",
"Sobota",
],
},
months: {
shorthand: [
"Led",
"Ún",
"Bře",
"Dub",
"Kvě",
"Čer",
"Čvc",
"Srp",
"Zář",
"Říj",
"Lis",
"Pro",
],
longhand: [
"Leden",
"Únor",
"Březen",
"Duben",
"Květen",
"Červen",
"Červenec",
"Srpen",
"Září",
"Říjen",
"Listopad",
"Prosinec",
],
},
firstDayOfWeek: 1,
ordinal: function () {
return ".";
},
rangeSeparator: " do ",
weekAbbreviation: "Týd.",
scrollTitle: "Rolujte pro změnu",
toggleTitle: "Přepnout dopoledne/odpoledne",
amPM: ["dop.", "odp."],
yearAriaLabel: "Rok",
time_24hr: true,
};
fp.l10ns.cs = Czech;
var cs = fp.l10ns;
exports.Czech = Czech;
exports.default = cs;
Object.defineProperty(exports, '__esModule', { value: true });
})));

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

181
offer-to-order/store.py Normal file
View File

@@ -0,0 +1,181 @@
"""Persistent state for the order form (one JSON file in WORK_DIR volume).
Per project (keyed by Kód zakázky) we keep:
- nazev: Název zakázky
- last: {field: last-used value} → used to prefill
- hist: {field: {value: count}} → all values ever used, with counts
(so the UI can offer them, most-used
on top)
Plus global meta: last order number (for the +1 suggestion).
"""
import json
import os
import re
import threading
from pathlib import Path
_STORE = Path(os.getenv("WORK_DIR", "/tmp/offer-to-order")) / "order_state.json"
_lock = threading.Lock()
# Fields remembered per project (Název is stored separately as "nazev").
STICKY = [
"misto_dodani", "kontaktni_osoba", "vystavil",
"splatnost", "zaruka", "zpusob_platby", "zpusob_odberu", "zpusob_dopravy",
"forma_objednavky", "fakturujte", "popis",
]
def _load() -> dict:
try:
d = json.loads(_STORE.read_text(encoding="utf-8"))
except Exception:
d = {}
d.setdefault("projects", {})
d.setdefault("meta", {})
return d
def _save(d: dict) -> None:
_STORE.parent.mkdir(parents=True, exist_ok=True)
_STORE.write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8")
def _normalize(rec: dict) -> dict:
"""Upgrade an old flat-style record to the {nazev,last,hist} shape."""
if "last" in rec or "hist" in rec:
rec.setdefault("last", {})
rec.setdefault("hist", {})
return rec
nazev = rec.get("nazev", "")
last, hist = {}, {}
for k in STICKY:
if rec.get(k) not in (None, ""):
last[k] = rec[k]
hist[k] = {rec[k]: 1}
return {"nazev": nazev, "last": last, "hist": hist}
# ── Projects ──
def list_projects() -> list[dict]:
projects = _load()["projects"]
return sorted(
[{"kod": k, "nazev": _normalize(v).get("nazev", "")}
for k, v in projects.items()],
key=lambda p: p["kod"].lower(),
)
def get_defaults(code: str) -> dict:
"""Prefill values (last used) + a _history map ranked most-used first."""
code = (code or "").strip()
if not code:
return {}
raw = _load()["projects"].get(code)
if not raw:
return {}
rec = _normalize(raw)
out = dict(rec["last"])
if rec.get("nazev"):
out["nazev_zakazky"] = rec["nazev"]
history = {}
for field, counts in rec.get("hist", {}).items():
ranked = sorted(counts.items(), key=lambda kv: -kv[1])
history[field] = [{"value": v, "count": c} for v, c in ranked]
out["_history"] = history
return out
def upsert_project(code: str, nazev: str) -> None:
code = (code or "").strip()
if not code:
return
with _lock:
d = _load()
rec = _normalize(d["projects"].get(code, {}))
rec["nazev"] = (nazev or "").strip()
d["projects"][code] = rec
_save(d)
def delete_project(code: str) -> None:
code = (code or "").strip()
with _lock:
d = _load()
if code in d["projects"]:
del d["projects"][code]
_save(d)
def delete_history_value(code: str, field: str, value: str) -> None:
"""Remove one remembered value from a project field's history."""
code = (code or "").strip()
if not code or not field:
return
with _lock:
d = _load()
raw = d["projects"].get(code)
if not raw:
return
rec = _normalize(raw)
counts = rec["hist"].get(field)
if counts and value in counts:
del counts[value]
if not counts:
rec["hist"].pop(field, None)
if rec["last"].get(field) == value:
remaining = rec["hist"].get(field)
if remaining:
rec["last"][field] = max(remaining, key=remaining.get)
else:
rec["last"].pop(field, None)
d["projects"][code] = rec
_save(d)
def save_defaults(code: str, order: dict) -> None:
"""Record sticky-field values for a project: update 'last' and bump the
frequency count in 'hist' for each non-empty value."""
code = (code or "").strip()
if not code:
return
with _lock:
d = _load()
rec = _normalize(d["projects"].get(code, {}))
if order.get("nazev_zakazky"):
rec["nazev"] = order["nazev_zakazky"]
for k in STICKY:
v = order.get(k)
if v in (None, ""):
continue
v = str(v)
rec["last"][k] = v
rec["hist"].setdefault(k, {})
rec["hist"][k][v] = rec["hist"][k].get(v, 0) + 1
d["projects"][code] = rec
_save(d)
# ── Order-number counter ──
def next_order_number() -> str:
return _increment(_load()["meta"].get("last_order_number") or "")
def _increment(s: str) -> str:
if not s:
return ""
m = re.search(r"(\d+)(\D*)$", s)
if not m:
return s
num = m.group(1)
return s[: m.start(1)] + str(int(num) + 1).zfill(len(num)) + m.group(2)
def remember_order_number(num: str) -> None:
num = (num or "").strip()
if not num:
return
with _lock:
d = _load()
d["meta"]["last_order_number"] = num
_save(d)