Initial portal commit: landing + 9 AI-powered apps

Apps:
- dwg-rooms: extract room numbers from DWG/DXF
- dwg-counting: count symbols in PDF drawings (OpenCV template matching)
- contract-check: review PDF contracts against a checklist (Claude vision + Tesseract OCR fallback)
- email-drafter: bullet notes → polished Czech/English business emails
- invoice-extractor: PDF/image invoice → structured data → Excel
- translator: Czech-first translator across 19 languages with tone control
- vv-check: find inconsistent unit prices across VV sheets in one workbook
- vv-compare: diff original vs new VV files (changes / added / removed)
- feature-request: portal users submit ideas + sample files

Infrastructure:
- LiteLLM gateway with per-app virtual keys + budgets
- Langfuse observability
- Geist font, shared theme, cross-subdomain back link + theme sync via cookie/URL
- Caddy reverse proxy on *.klas.chat
This commit is contained in:
Ondřej Glaser
2026-05-13 15:25:04 +02:00
commit 48cef99257
139 changed files with 20171 additions and 0 deletions

8
vv-compare/Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /tmp/vv-compare
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,18 @@
services:
vv-compare:
build: .
container_name: vv-compare
restart: unless-stopped
ports:
- "127.0.0.1:3034:8000"
networks:
- localai
volumes:
- vv-compare-data:/tmp/vv-compare
volumes:
vv-compare-data:
networks:
localai:
external: true

90
vv-compare/main.py Normal file
View File

@@ -0,0 +1,90 @@
"""FastAPI: two Excel VVs (original + new) → comparison report Excel."""
import logging
import os
import uuid
from pathlib import Path
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from vv_compare import compare, write_compare_report
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="VV Compare")
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_methods=["*"], allow_headers=["*"])
WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/vv-compare"))
WORK_DIR.mkdir(parents=True, exist_ok=True)
jobs: dict[str, dict] = {}
@app.get("/")
async def root():
return FileResponse("static/index.html")
@app.post("/api/compare")
async def do_compare(
original: UploadFile = File(...),
new: UploadFile = File(...),
):
for f in (original, new):
suffix = Path(f.filename or "").suffix.lower()
if suffix not in (".xlsx", ".xlsm"):
raise HTTPException(400, "Podporované formáty: .xlsx, .xlsm")
job_id = str(uuid.uuid4())
job_dir = WORK_DIR / job_id
job_dir.mkdir()
orig_path = job_dir / f"original{Path(original.filename).suffix}"
new_path = job_dir / f"new{Path(new.filename).suffix}"
orig_path.write_bytes(await original.read())
new_path.write_bytes(await new.read())
logger.info("Job %s: original=%s, new=%s", job_id, original.filename, new.filename)
try:
result = compare(orig_path, new_path)
except Exception as exc:
logger.exception("Compare failed")
raise HTTPException(500, f"Porovnání selhalo: {exc}")
jobs[job_id] = {
"original_filename": original.filename,
"new_filename": new.filename,
"job_dir": str(job_dir),
"result": result,
}
return {"job_id": job_id, "result": result}
@app.get("/api/report/{job_id}")
async def report(job_id: str):
if job_id not in jobs:
raise HTTPException(404, "Úloha nenalezena")
job = jobs[job_id]
out_path = Path(job["job_dir"]) / "report.xlsx"
write_compare_report(
job["result"],
job.get("original_filename") or "puvodni.xlsx",
job.get("new_filename") or "novy.xlsx",
out_path,
)
return FileResponse(
str(out_path),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename="Porovnani_VV_puvodni_vs_novy.xlsx",
)
@app.get("/health")
async def health():
return {"status": "ok"}
app.mount("/static", StaticFiles(directory="static"), name="static")

View File

@@ -0,0 +1,4 @@
fastapi>=0.115
uvicorn[standard]>=0.30
openpyxl>=3.1
python-multipart>=0.0.9

130
vv-compare/static/app.js Normal file
View File

@@ -0,0 +1,130 @@
// VV compare frontend
(() => {
const $ = (id) => document.getElementById(id);
const sections = {
upload: $("s-upload"),
processing: $("s-processing"),
result: $("s-result"),
};
const show = (n) => {
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== n);
};
let state = { jobId: null, result: null, origFile: null, newFile: null };
function attachDrop(zoneId, inputId, textId, which) {
const zone = $(zoneId);
const input = $(inputId);
const text = $(textId);
zone.addEventListener("click", () => input.click());
input.addEventListener("change", (e) => {
if (e.target.files[0]) setFile(which, e.target.files[0], zone, text);
});
["dragenter", "dragover"].forEach((ev) =>
zone.addEventListener(ev, (e) => { e.preventDefault(); zone.classList.add("drag-over"); }));
["dragleave", "drop"].forEach((ev) =>
zone.addEventListener(ev, (e) => { e.preventDefault(); zone.classList.remove("drag-over"); }));
zone.addEventListener("drop", (e) => {
if (e.dataTransfer.files[0]) setFile(which, e.dataTransfer.files[0], zone, text);
});
}
function setFile(which, file, zone, textEl) {
if (which === "orig") state.origFile = file;
else state.newFile = file;
zone.classList.add("has-file");
const size = file.size > 1024 * 1024
? `${(file.size / 1024 / 1024).toFixed(1)} MB`
: `${(file.size / 1024).toFixed(0)} kB`;
textEl.innerHTML = `<strong>${escapeHtml(file.name)}</strong><br><span style="color:var(--text-tertiary);font-size:12px">${size}</span>`;
updateRunBtn();
}
attachDrop("drop-original", "orig-input", "orig-text", "orig");
attachDrop("drop-new", "new-input", "new-text", "new");
function updateRunBtn() {
const btn = $("compare-btn");
const hint = $("compare-hint");
if (!state.origFile && !state.newFile) {
btn.disabled = true; hint.textContent = "Nahrajte oba soubory.";
} else if (!state.origFile) {
btn.disabled = true; hint.textContent = "Chybí původní VV.";
} else if (!state.newFile) {
btn.disabled = true; hint.textContent = "Chybí nový VV.";
} else {
btn.disabled = false; hint.textContent = "Připraveno k porovnání.";
}
}
$("compare-btn").addEventListener("click", async () => {
if (!state.origFile || !state.newFile) return;
show("processing");
try {
const fd = new FormData();
fd.append("original", state.origFile);
fd.append("new", state.newFile);
const r = await fetch("/api/compare", { 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.result = json.result;
renderResult();
show("result");
} catch (err) {
alert("Chyba: " + err.message);
show("upload");
}
});
function renderResult() {
const r = state.result;
const c = r.changes.length, a = r.added.length, d = r.removed.length;
$("cnt-changed").textContent = c;
$("cnt-added").textContent = a;
$("cnt-removed").textContent = d;
$("results-meta").textContent =
`Porovnání ${r.per_sheet.length} listů — ${c + a + d} celkových rozdílů.`;
const psHtml = r.per_sheet.map((ps) => `
<tr>
<td>${escapeHtml(ps.sheet)}</td>
<td>${escapeHtml(ps.hala || "")}</td>
<td class="num">${ps.orig_count}</td>
<td class="num">${ps.new_count}</td>
<td class="num"><span class="pill ${ps.changes ? "changed" : "zero"}">${ps.changes}</span></td>
<td class="num"><span class="pill ${ps.added ? "added" : "zero"}">${ps.added}</span></td>
<td class="num"><span class="pill ${ps.removed ? "removed" : "zero"}">${ps.removed}</span></td>
</tr>`).join("");
$("per-sheet").innerHTML = r.per_sheet.length ? `
<table>
<thead>
<tr>
<th>List</th><th>Hala</th>
<th>Pol. původní</th><th>Pol. nový</th>
<th>Změněné</th><th>Přidané</th><th>Odebrané</th>
</tr>
</thead>
<tbody>${psHtml}</tbody>
</table>` : `<p style="color:var(--text-tertiary)">Nepodařilo se najít listy VV v žádném ze souborů.</p>`;
}
$("download-btn").addEventListener("click", () => {
if (state.jobId) window.location.href = `/api/report/${state.jobId}`;
});
$("restart-btn").addEventListener("click", () => {
state = { jobId: null, result: null, origFile: null, newFile: null };
$("orig-input").value = ""; $("new-input").value = "";
$("drop-original").classList.remove("has-file");
$("drop-new").classList.remove("has-file");
$("orig-text").textContent = "Přetáhněte soubor sem nebo klikněte";
$("new-text").textContent = "Přetáhněte soubor sem nebo klikněte";
updateRunBtn();
show("upload");
});
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
})();

149
vv-compare/static/extra.css Normal file
View File

@@ -0,0 +1,149 @@
/* VV compare specific */
.processing-sub {
font-size: 13px;
color: var(--text-tertiary);
margin: 8px auto 0;
max-width: 400px;
}
.dual-drop {
display: flex;
align-items: stretch;
gap: 14px;
margin-bottom: 18px;
}
.drop-half { flex: 1; display: flex; flex-direction: column; gap: 6px; }
.drop-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-tertiary);
padding-left: 4px;
}
.drop-zone-mini {
flex: 1;
border: 1.5px dashed var(--border-strong);
border-radius: var(--radius-lg);
background: var(--bg-secondary);
padding: 32px 18px;
text-align: center;
cursor: pointer;
transition: border-color .15s, background .15s;
min-height: 140px;
display: flex;
align-items: center;
justify-content: center;
}
.drop-zone-mini:hover { border-color: var(--primary); }
.drop-zone-mini.drag-over { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 5%, var(--bg-secondary)); }
.drop-zone-mini.has-file {
border-style: solid;
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, var(--bg-secondary));
}
.drop-arrow {
align-self: center;
font-size: 22px;
color: var(--text-quaternary);
font-weight: 200;
padding: 0 4px;
}
@media (max-width: 640px) {
.dual-drop { flex-direction: column; }
.drop-arrow { transform: rotate(90deg); padding: 4px 0; }
}
.run-row {
display: flex; align-items: center; gap: 14px;
padding-top: 16px; border-top: 1px solid var(--border-default);
flex-wrap: wrap;
}
.btn-lg { padding: 12px 26px; font-size: 14px; font-weight: 600; }
.run-hint { font-size: 13px; color: var(--text-tertiary); }
.recap-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin: 16px 0 22px;
}
.recap-card {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 18px 22px;
border-left: 4px solid #94a3b8;
}
.recap-card.recap-changed { border-left-color: #f59e0b; }
.recap-card.recap-added { border-left-color: #22c55e; }
.recap-card.recap-removed { border-left-color: #ef4444; }
.recap-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.recap-value {
font-size: 32px;
font-weight: 600;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.per-sheet {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 16px 18px;
overflow-x: auto;
}
.per-sheet table { width: 100%; border-collapse: collapse; font-size: 13px; }
.per-sheet th, .per-sheet td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-default);
text-align: left;
}
.per-sheet th {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
background: var(--bg-secondary);
}
.per-sheet td.num { text-align: right; font-variant-numeric: tabular-nums; }
.per-sheet .pill {
display: inline-block;
padding: 1px 7px;
border-radius: 999px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.per-sheet .pill.changed { background: rgba(245,158,11,0.15); color: #b45309; }
.per-sheet .pill.added { background: rgba(34,197,94,0.15); color: #15803d; }
.per-sheet .pill.removed { background: rgba(239,68,68,0.15); color: #b91c1c; }
.per-sheet .pill.zero { background: var(--bg-tertiary); color: var(--text-quaternary); }
/* 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,131 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Porovnání VV | Colsys AI</title>
<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/styles.css">
<link rel="stylesheet" href="/static/extra.css">
<script>
(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) { try { t = localStorage.getItem("app_theme"); } catch (e) {} }
if (!t) { var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
if (m) t = decodeURIComponent(m[1]); }
if (t === "dark" || t === "light") {
document.documentElement.classList.add(t);
try { localStorage.setItem("app_theme", t); } catch (e) {}
}
})();
</script>
</head>
<body>
<header class="header">
<div class="header-inner">
<a href="https://ai.klas.chat" 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">Porovnání VV</span>
<a href="https://ai.klas.chat" 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">
<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">Porovnání výkazu výměr (původní vs nový)</h1>
<p class="section-desc">
Nahrajte dva soubory MaR VV — původní a nový. Aplikace najde
změny ve výměrách, MJ, přidané a odebrané položky a vytvoří
souhrnný Excel report.
</p>
</div>
<div class="dual-drop">
<div class="drop-half">
<div class="drop-label">PŮVODNÍ VV</div>
<div class="drop-zone-mini" id="drop-original">
<p class="drop-text" id="orig-text">Přetáhněte soubor sem nebo klikněte</p>
<input type="file" id="orig-input" accept=".xlsx,.xlsm" style="display:none">
</div>
</div>
<div class="drop-arrow"></div>
<div class="drop-half">
<div class="drop-label">NOVÝ VV</div>
<div class="drop-zone-mini" id="drop-new">
<p class="drop-text" id="new-text">Přetáhněte soubor sem nebo klikněte</p>
<input type="file" id="new-input" accept=".xlsx,.xlsm" style="display:none">
</div>
</div>
</div>
<div class="run-row">
<button class="btn btn-primary btn-lg" id="compare-btn" type="button" disabled>
Porovnat
</button>
<span class="run-hint" id="compare-hint">Nahrajte oba soubory.</span>
</div>
</section>
<section id="s-processing" class="hidden">
<div class="processing-card">
<div class="spinner"></div>
<h2 class="processing-title">Porovnávám soubory…</h2>
<p class="processing-sub">Načítám oba sešity, párkuji listy a hledám změněné, přidané a odebrané položky. Při rozsáhlejších výkazech to může trvat půl minuty i déle — vyčkejte, nezavírejte stránku.</p>
</div>
</section>
<section id="s-result" class="hidden">
<div class="results-header">
<div>
<h2 class="results-title">Výsledek porovnání</h2>
<p class="results-meta" id="results-meta"></p>
</div>
<div class="results-actions">
<button class="btn btn-secondary" id="restart-btn" type="button">Nové porovnání</button>
<button class="btn btn-primary" id="download-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 report Excel
</button>
</div>
</div>
<div class="recap-cards">
<div class="recap-card recap-changed">
<div class="recap-label">Změněné</div>
<div class="recap-value" id="cnt-changed">0</div>
</div>
<div class="recap-card recap-added">
<div class="recap-label">Přidané</div>
<div class="recap-value" id="cnt-added">0</div>
</div>
<div class="recap-card recap-removed">
<div class="recap-label">Odebrané</div>
<div class="recap-value" id="cnt-removed">0</div>
</div>
</div>
<div class="per-sheet" id="per-sheet"></div>
</section>
</main>
<script src="/static/app.js"></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; }

507
vv-compare/vv_compare.py Normal file
View File

@@ -0,0 +1,507 @@
"""Compare original vs new VV (Výkaz Výměr) Excel files.
Produces a 4-sheet report:
- Souhrn — overview table with per-sheet counts + grand totals
- Změny — items present in both but with different quantity / MJ
- Přidané — items in new but not in original
- Odebrané — items in original but not in new
Following Jirka's spec exactly: section rows are skipped, change = qty/MJ
diff only (NOT price), heavy use of colour-coding.
"""
import logging
import re
from collections import defaultdict
from pathlib import Path
import openpyxl
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from openpyxl.workbook import Workbook
logger = logging.getLogger(__name__)
HEADER_HINTS = {
"por": ["poř.", "por.", "pořadí", "č.", "č"],
"kod": ["kód", "kod"],
"popis": ["popis", "název", "název položky"],
"mj": ["mj", "j.j.", "jednotka"],
"vymera": ["výměra", "vymera", "množství", "mnozstvi"],
"cena_jed": ["jednotková cena", "jednotkova cena", "j. cena", "j.cena",
"jed. cena", "cena/jed", "cena j.", "cena za jednotku"],
"cena_tot": ["cena celkem", "celkem", "cena"],
}
# Colors per spec
BLUE = "1F4E78"
WHITE = "FFFFFF"
GRAY = "F2F2F2"
YELLOW = "FFF2CC" # changes
GREEN_BG = "D9EAD3" # added
RED_BG = "F4CCCC" # removed
GREEN_DIFF = "E4F0DC" # positive qty diff
RED_DIFF = "FCE4E4" # negative qty diff
DARK_GREEN = "006100"
DARK_RED = "C00000"
GRAY_TEXT = "595959"
BORDER_GRAY = "BFBFBF"
THIN = Side(style="thin", color=BORDER_GRAY)
BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
def normalise(text) -> str:
if text is None:
return ""
return re.sub(r"\s+", " ", str(text).strip())
def _to_float(v):
if v is None or v == "":
return None
try:
return float(v)
except (ValueError, TypeError):
return None
def find_header(ws) -> dict | None:
"""Return header column mapping or None if no VV-like header found."""
for row_idx in range(1, min(13, ws.max_row + 1)):
matched = {}
for col_idx in range(1, min(15, ws.max_column + 1)):
val = normalise(ws.cell(row=row_idx, column=col_idx).value).lower()
for role, hints in HEADER_HINTS.items():
if role in matched:
continue
if any(val == h or val.startswith(h) for h in hints):
matched[role] = col_idx
break
if "popis" in matched and "vymera" in matched and "mj" in matched:
matched["_header_row"] = row_idx
return matched
return None
def extract_items(ws, header: dict) -> tuple[list[dict], str | None]:
"""Return (items, hala_name)."""
header_row = header["_header_row"]
# Hall/object name: row 4 col C per spec, but be flexible
hala = None
for r in range(1, header_row):
for c in range(1, min(8, ws.max_column + 1)):
v = ws.cell(row=r, column=c).value
if v and isinstance(v, str) and 5 < len(v) < 200 \
and "vykaz" not in v.lower() and "výkaz" not in v.lower():
hala = v.strip()
break
if hala:
break
items = []
current_section = None
popis_col = header["popis"]
mj_col = header["mj"]
vymera_col = header["vymera"]
cena_jed_col = header.get("cena_jed")
cena_tot_col = header.get("cena_tot")
kod_col = header.get("kod")
for r in range(header_row + 1, ws.max_row + 1):
popis = ws.cell(row=r, column=popis_col).value
if popis is None or not str(popis).strip():
continue
popis_text = str(popis).strip()
mj_val = ws.cell(row=r, column=mj_col).value
# Detect section row: empty MJ + description contains ":"
if (not mj_val or not str(mj_val).strip()) and ":" in popis_text \
and len(popis_text) < 100:
current_section = popis_text
continue
vymera = _to_float(ws.cell(row=r, column=vymera_col).value)
# Skip rows without quantity (probably subtotals)
if vymera is None:
continue
items.append({
"row": r,
"section": current_section,
"kod": ws.cell(row=r, column=kod_col).value if kod_col else None,
"description": popis_text,
"description_norm": normalise(popis_text),
"mj": str(mj_val).strip() if mj_val else "",
"vymera": vymera,
"cena_jed": _to_float(ws.cell(row=r, column=cena_jed_col).value) if cena_jed_col else None,
"cena_tot": _to_float(ws.cell(row=r, column=cena_tot_col).value) if cena_tot_col else None,
})
return items, hala
def analyse_workbook(path: Path) -> dict:
"""Return {sheet_name: {items, hala, header_found}}."""
wb = openpyxl.load_workbook(path, data_only=True)
out = {}
for ws in wb.worksheets:
header = find_header(ws)
if not header:
continue
items, hala = extract_items(ws, header)
if not items:
continue
out[ws.title] = {"items": items, "hala": hala, "header": header}
wb.close()
return out
def compare(orig_path: Path, new_path: Path) -> dict:
orig_sheets = analyse_workbook(orig_path)
new_sheets = analyse_workbook(new_path)
# Match sheets by exact name first; fall back to position-based match for unmatched.
matched_pairs: list[tuple[str | None, str | None]] = []
orig_used: set[str] = set()
new_used: set[str] = set()
for name in orig_sheets:
if name in new_sheets:
matched_pairs.append((name, name))
orig_used.add(name)
new_used.add(name)
remaining_orig = [s for s in orig_sheets if s not in orig_used]
remaining_new = [s for s in new_sheets if s not in new_used]
# Position match by order of declaration
for o, n in zip(remaining_orig, remaining_new):
matched_pairs.append((o, n))
# Orphans (only orig)
for o in remaining_orig[len(remaining_new):]:
matched_pairs.append((o, None))
for n in remaining_new[len(remaining_orig):]:
matched_pairs.append((None, n))
per_sheet = []
all_changes = []
all_added = []
all_removed = []
for orig_name, new_name in matched_pairs:
orig_items = orig_sheets.get(orig_name, {}).get("items", []) if orig_name else []
new_items = new_sheets.get(new_name, {}).get("items", []) if new_name else []
hala = (new_sheets.get(new_name, {}).get("hala") if new_name else None) \
or (orig_sheets.get(orig_name, {}).get("hala") if orig_name else None) \
or ""
sheet_label = new_name or orig_name or ""
# Pair items by (section, description_norm)
orig_index = defaultdict(list)
for it in orig_items:
orig_index[(it["section"] or "", it["description_norm"])].append(it)
new_index = defaultdict(list)
for it in new_items:
new_index[(it["section"] or "", it["description_norm"])].append(it)
changes = []
added = []
removed = []
# Items present in both
for key, new_list in new_index.items():
orig_list = orig_index.get(key, [])
if not orig_list:
for it in new_list:
added.append({
"sheet": sheet_label, "hala": hala,
"section": key[0], "description": it["description"],
"mj": it["mj"], "vymera": it["vymera"],
"cena_jed": it["cena_jed"], "cena_tot": it["cena_tot"],
})
continue
# Compare in pairs (one-to-one by index)
for idx, new_it in enumerate(new_list):
if idx >= len(orig_list):
# Duplicate added entry
added.append({
"sheet": sheet_label, "hala": hala,
"section": key[0], "description": new_it["description"],
"mj": new_it["mj"], "vymera": new_it["vymera"],
"cena_jed": new_it["cena_jed"], "cena_tot": new_it["cena_tot"],
})
continue
orig_it = orig_list[idx]
qty_diff = (new_it["vymera"] or 0) - (orig_it["vymera"] or 0)
mj_diff = orig_it["mj"] != new_it["mj"]
if abs(qty_diff) > 1e-9 or mj_diff:
changes.append({
"sheet": sheet_label, "hala": hala,
"section": key[0], "description": new_it["description"],
"mj_orig": orig_it["mj"], "mj_new": new_it["mj"],
"vymera_orig": orig_it["vymera"], "vymera_new": new_it["vymera"],
"vymera_diff": qty_diff,
"cena_jed_orig": orig_it["cena_jed"],
"cena_orig": orig_it["cena_tot"],
"cena_new": new_it["cena_tot"],
})
# Items only in original
for key, orig_list in orig_index.items():
if key in new_index:
# Handle leftover originals (orig had more duplicates than new)
used = min(len(new_index[key]), len(orig_list))
for it in orig_list[used:]:
removed.append({
"sheet": sheet_label, "hala": hala,
"section": key[0], "description": it["description"],
"mj": it["mj"], "vymera": it["vymera"],
"cena_jed": it["cena_jed"], "cena_tot": it["cena_tot"],
})
continue
for it in orig_list:
removed.append({
"sheet": sheet_label, "hala": hala,
"section": key[0], "description": it["description"],
"mj": it["mj"], "vymera": it["vymera"],
"cena_jed": it["cena_jed"], "cena_tot": it["cena_tot"],
})
per_sheet.append({
"sheet": sheet_label,
"hala": hala,
"orig_count": len(orig_items),
"new_count": len(new_items),
"changes": len(changes),
"added": len(added),
"removed": len(removed),
})
all_changes.extend(changes)
all_added.extend(added)
all_removed.extend(removed)
return {
"per_sheet": per_sheet,
"changes": all_changes,
"added": all_added,
"removed": all_removed,
}
# ── Excel report writer (4 sheets per spec) ───────────────────
NUM_QTY = '#,##0.##;[Red]-#,##0.##;"-"'
NUM_CZK = '#,##0.00 "";[Red]-#,##0.00 "";"-"'
def _title(ws, text: str, subtitle: str = ""):
cell = ws.cell(row=1, column=1, value=text)
cell.font = Font(name="Arial", bold=True, size=14, color=BLUE)
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=12)
if subtitle:
sub = ws.cell(row=2, column=1, value=subtitle)
sub.font = Font(name="Arial", italic=True, size=10, color=GRAY_TEXT)
ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=12)
def _hdr_cell(cell):
cell.font = Font(name="Arial", bold=True, size=11, color=WHITE)
cell.fill = PatternFill("solid", fgColor=BLUE)
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
cell.border = BORDER
def _body_cell(cell, num_format: str | None = None, fill: str | None = None,
bold: bool = False, color: str | None = None,
horizontal: str | None = None):
cell.font = Font(name="Arial", size=10, bold=bold,
color=color or "000000")
cell.border = BORDER
if fill:
cell.fill = PatternFill("solid", fgColor=fill)
if num_format:
cell.number_format = num_format
cell.alignment = Alignment(horizontal=horizontal or "left",
vertical="top",
wrap_text=True)
def write_compare_report(result: dict, orig_filename: str, new_filename: str,
out_path: Path) -> Path:
wb = Workbook()
# ── 1. Souhrn ─────────────────────────────────────────
ws = wb.active
ws.title = "Souhrn"
_title(ws, "Porovnání výkazu výměr",
f"Původní: {orig_filename} · Nový: {new_filename}")
headers = ["List", "Hala / objekt", "Počet pol. (původní)", "Počet pol. (nový)",
"Změněné položky", "Přidané položky", "Odebrané položky"]
for c, h in enumerate(headers, 1):
_hdr_cell(ws.cell(row=4, column=c, value=h))
r = 5
for ps in result["per_sheet"]:
ws.cell(row=r, column=1, value=ps["sheet"])
ws.cell(row=r, column=2, value=ps["hala"])
ws.cell(row=r, column=3, value=ps["orig_count"])
ws.cell(row=r, column=4, value=ps["new_count"])
ws.cell(row=r, column=5, value=ps["changes"])
ws.cell(row=r, column=6, value=ps["added"])
ws.cell(row=r, column=7, value=ps["removed"])
for c in range(1, 8):
_body_cell(ws.cell(row=r, column=c))
# Colored highlights when > 0
if ps["changes"]:
_body_cell(ws.cell(row=r, column=5), fill=YELLOW, bold=True,
horizontal="right")
if ps["added"]:
_body_cell(ws.cell(row=r, column=6), fill=GREEN_BG, bold=True,
color=DARK_GREEN, horizontal="right")
if ps["removed"]:
_body_cell(ws.cell(row=r, column=7), fill=RED_BG, bold=True,
color=DARK_RED, horizontal="right")
r += 1
# CELKEM row
if result["per_sheet"]:
first_data_row = 5
last_data_row = r - 1
ws.cell(row=r, column=1, value="CELKEM")
for c in range(3, 8):
col_letter = get_column_letter(c)
ws.cell(row=r, column=c,
value=f"=SUM({col_letter}{first_data_row}:{col_letter}{last_data_row})")
for c in range(1, 8):
_body_cell(ws.cell(row=r, column=c),
fill=GRAY, bold=True,
horizontal=("right" if c >= 3 else "left"))
r += 1
# Recap box
r += 1
ws.cell(row=r, column=1, value="Rekapitulace změn (celkem)").font = \
Font(name="Arial", bold=True, size=12, color=BLUE)
r += 1
for label, cnt, fill, color in [
("Změněné", len(result["changes"]), YELLOW, None),
("Přidané", len(result["added"]), GREEN_BG, DARK_GREEN),
("Odebrané", len(result["removed"]), RED_BG, DARK_RED),
]:
_body_cell(ws.cell(row=r, column=1, value=label), bold=True)
_body_cell(ws.cell(row=r, column=2, value=cnt),
fill=fill, bold=True, color=color, horizontal="right")
r += 1
r += 1
note = ws.cell(row=r, column=1,
value=("Za „změnu\" je považován pouze rozdíl ve výměře nebo MJ. "
"Cenové rozdíly se ignorují, protože nový VV obvykle ceny "
"neobsahuje."))
note.font = Font(name="Arial", italic=True, size=9, color=GRAY_TEXT)
note.alignment = Alignment(wrap_text=True)
ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=7)
for c, w in {1: 22, 2: 30, 3: 18, 4: 18, 5: 18, 6: 18, 7: 18}.items():
ws.column_dimensions[get_column_letter(c)].width = w
ws.row_dimensions[4].height = 32
# ── 2. Změny ─────────────────────────────────────────
ws = wb.create_sheet("Změny")
_title(ws, "Změněné položky (rozdíl ve výměře nebo MJ)")
cols = ["List", "Hala", "Sekce", "Popis položky",
"MJ orig.", "MJ nová", "Výměra orig.", "Výměra nová", "Rozdíl výměra",
"Jed. cena orig.", "Cena orig.", "Cena nová"]
for c, h in enumerate(cols, 1):
_hdr_cell(ws.cell(row=4, column=c, value=h))
r = 5
for ch in result["changes"]:
ws.cell(row=r, column=1, value=ch["sheet"])
ws.cell(row=r, column=2, value=ch["hala"])
ws.cell(row=r, column=3, value=ch["section"])
ws.cell(row=r, column=4, value=ch["description"])
ws.cell(row=r, column=5, value=ch["mj_orig"])
ws.cell(row=r, column=6, value=ch["mj_new"])
ws.cell(row=r, column=7, value=ch["vymera_orig"])
ws.cell(row=r, column=8, value=ch["vymera_new"])
ws.cell(row=r, column=9, value=ch["vymera_diff"])
ws.cell(row=r, column=10, value=ch["cena_jed_orig"])
ws.cell(row=r, column=11, value=ch["cena_orig"])
ws.cell(row=r, column=12, value=ch["cena_new"])
for c in range(1, 13):
_body_cell(ws.cell(row=r, column=c))
# Number formats
for c in (7, 8, 9):
ws.cell(row=r, column=c).number_format = NUM_QTY
ws.cell(row=r, column=c).alignment = Alignment(horizontal="right", vertical="top")
for c in (10, 11, 12):
ws.cell(row=r, column=c).number_format = NUM_CZK
ws.cell(row=r, column=c).alignment = Alignment(horizontal="right", vertical="top")
# Highlight diff cell
diff_fill = GREEN_DIFF if ch["vymera_diff"] > 0 else RED_DIFF
_body_cell(ws.cell(row=r, column=9), fill=diff_fill, bold=True,
num_format=NUM_QTY, horizontal="right")
r += 1
widths = {1: 14, 2: 22, 3: 22, 4: 50, 5: 10, 6: 10, 7: 14, 8: 14, 9: 14, 10: 14, 11: 14, 12: 14}
for c, w in widths.items():
ws.column_dimensions[get_column_letter(c)].width = w
ws.freeze_panes = "A5"
ws.auto_filter.ref = f"A4:L{max(5, r - 1)}"
ws.row_dimensions[4].height = 32
# ── 3. Přidané ─────────────────────────────────────────
ws = wb.create_sheet("Přidané")
_title(ws, "Přidané položky (jsou v novém VV, nebyly v původním)")
add_cols = ["List", "Hala", "Sekce", "Popis položky", "MJ", "Výměra",
"Jed. cena", "Cena"]
for c, h in enumerate(add_cols, 1):
_hdr_cell(ws.cell(row=4, column=c, value=h))
r = 5
for it in result["added"]:
vals = [it["sheet"], it["hala"], it["section"], it["description"],
it["mj"], it["vymera"], it["cena_jed"], it["cena_tot"]]
for c, v in enumerate(vals, 1):
_body_cell(ws.cell(row=r, column=c, value=v),
fill=GREEN_BG, color=DARK_GREEN)
ws.cell(row=r, column=6).number_format = NUM_QTY
ws.cell(row=r, column=6).alignment = Alignment(horizontal="right", vertical="top")
ws.cell(row=r, column=7).number_format = NUM_CZK
ws.cell(row=r, column=7).alignment = Alignment(horizontal="right", vertical="top")
ws.cell(row=r, column=8).number_format = NUM_CZK
ws.cell(row=r, column=8).alignment = Alignment(horizontal="right", vertical="top")
r += 1
for c, w in {1: 14, 2: 22, 3: 22, 4: 50, 5: 10, 6: 14, 7: 14, 8: 14}.items():
ws.column_dimensions[get_column_letter(c)].width = w
ws.freeze_panes = "A5"
ws.auto_filter.ref = f"A4:H{max(5, r - 1)}"
ws.row_dimensions[4].height = 32
# ── 4. Odebrané ─────────────────────────────────────────
ws = wb.create_sheet("Odebrané")
_title(ws, "Odebrané položky (byly v původním VV, v novém nejsou)")
for c, h in enumerate(add_cols, 1):
_hdr_cell(ws.cell(row=4, column=c, value=h))
r = 5
first_data_row = r
for it in result["removed"]:
vals = [it["sheet"], it["hala"], it["section"], it["description"],
it["mj"], it["vymera"], it["cena_jed"], it["cena_tot"]]
for c, v in enumerate(vals, 1):
_body_cell(ws.cell(row=r, column=c, value=v),
fill=RED_BG, color=DARK_RED)
ws.cell(row=r, column=6).number_format = NUM_QTY
ws.cell(row=r, column=6).alignment = Alignment(horizontal="right", vertical="top")
ws.cell(row=r, column=7).number_format = NUM_CZK
ws.cell(row=r, column=7).alignment = Alignment(horizontal="right", vertical="top")
ws.cell(row=r, column=8).number_format = NUM_CZK
ws.cell(row=r, column=8).alignment = Alignment(horizontal="right", vertical="top")
r += 1
last_data_row = r - 1
if last_data_row >= first_data_row:
# Sum cena_tot column
ws.cell(row=r, column=1, value="Součet")
ws.cell(row=r, column=8,
value=f"=SUM(H{first_data_row}:H{last_data_row})")
for c in range(1, 9):
_body_cell(ws.cell(row=r, column=c), fill=GRAY, bold=True,
horizontal=("right" if c == 8 else "left"))
ws.cell(row=r, column=8).number_format = NUM_CZK
for c, w in {1: 14, 2: 22, 3: 22, 4: 50, 5: 10, 6: 14, 7: 14, 8: 14}.items():
ws.column_dimensions[get_column_letter(c)].width = w
ws.freeze_panes = "A5"
ws.row_dimensions[4].height = 32
wb.save(str(out_path))
return out_path