Flatten budget-app contents into repo root
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""Inline SVG charts, so the same markup renders in the browser and in WeasyPrint."""
|
||||
import math
|
||||
from html import escape
|
||||
|
||||
BLUE = "#2f6fd6"
|
||||
ORANGE = "#e8632b"
|
||||
INK = "#1d1d1f"
|
||||
MUTED = "#6b6b70"
|
||||
GRID = "#e4e4e8"
|
||||
|
||||
|
||||
def nice_ticks(lo: float, hi: float, count: int = 8) -> list[float]:
|
||||
if hi <= lo:
|
||||
hi = lo + 1
|
||||
raw = (hi - lo) / count
|
||||
mag = 10 ** math.floor(math.log10(raw))
|
||||
step = next(m * mag for m in (1, 2, 2.5, 5, 10) if m * mag >= raw)
|
||||
start = math.floor(lo / step) * step
|
||||
ticks, v = [], start
|
||||
while v <= hi + step * 0.001:
|
||||
ticks.append(round(v, 10))
|
||||
v += step
|
||||
if ticks[-1] < hi:
|
||||
ticks.append(ticks[-1] + step)
|
||||
return ticks
|
||||
|
||||
|
||||
def _wrap(text: str, width: int = 30, lines: int = 2) -> list[str]:
|
||||
words, out, cur = text.split(), [], ""
|
||||
for w in words:
|
||||
if len(cur) + len(w) + (1 if cur else 0) <= width:
|
||||
cur = f"{cur} {w}".strip()
|
||||
else:
|
||||
out.append(cur)
|
||||
cur = w
|
||||
out.append(cur)
|
||||
out = [o for o in out if o]
|
||||
if len(out) > lines:
|
||||
out = out[:lines]
|
||||
out[-1] = out[-1][: width - 1].rstrip() + "…"
|
||||
return [o if len(o) <= width else o[: width - 1] + "…" for o in out] or [""]
|
||||
|
||||
|
||||
def grouped_bars(rows: list[tuple[str, float, float]], fmt_tick, fmt_value=None, width: int = 900,
|
||||
height: int = 490) -> str:
|
||||
"""Horizontal bars, two per row (prior in blue, adjusted in orange), sized to fill width x height."""
|
||||
if not rows:
|
||||
return ""
|
||||
label_w = 250
|
||||
top, bottom = 8, 30
|
||||
row_h = min(130.0, (height - top - bottom) / len(rows))
|
||||
bar_h = min(row_h * 0.34, 36)
|
||||
plot_w = width - label_w - (80 if fmt_value else 24)
|
||||
height = round(top + bottom + row_h * len(rows))
|
||||
hi = max(max(p, a) for _, p, a in rows) or 1
|
||||
ticks = nice_ticks(0, hi, 8)
|
||||
x = lambda v: label_w + plot_w * v / ticks[-1]
|
||||
parts = [f'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" class="chart">']
|
||||
for t in ticks:
|
||||
parts.append(f'<line x1="{x(t):.1f}" x2="{x(t):.1f}" y1="{top}" y2="{height - bottom}" stroke="{GRID}"/>')
|
||||
parts.append(f'<text x="{x(t):.1f}" y="{height - bottom + 18}" font-size="12" text-anchor="middle" fill="{MUTED}">{escape(fmt_tick(t))}</text>')
|
||||
for i, (label, prior, adjusted) in enumerate(rows):
|
||||
y0 = top + i * row_h
|
||||
cy = y0 + row_h / 2
|
||||
fs = 13 if row_h >= 22 else 11.5
|
||||
wrapped = _wrap(label, 34, 2 if row_h >= 30 else 1)
|
||||
for j, chunk in enumerate(wrapped):
|
||||
dy = (j - (len(wrapped) - 1) / 2) * (fs + 2) + fs / 3
|
||||
parts.append(f'<text x="{label_w - 10}" y="{cy + dy:.1f}" font-size="{fs}" text-anchor="end" fill="{INK}">{escape(chunk)}</text>')
|
||||
for k, (value, colour) in enumerate(((prior, BLUE), (adjusted, ORANGE))):
|
||||
by = cy - bar_h - 1 + k * (bar_h + 2)
|
||||
w = max(x(max(value, 0)) - label_w, 0)
|
||||
parts.append(f'<rect x="{label_w}" y="{by:.1f}" width="{w:.1f}" height="{bar_h:.1f}" fill="{colour}"/>')
|
||||
if fmt_value and row_h >= 26:
|
||||
vfs = min(12, bar_h + 1)
|
||||
parts.append(f'<text x="{label_w + w + 5:.1f}" y="{by + bar_h / 2 + vfs / 3:.1f}" font-size="{vfs:.1f}" fill="{MUTED}">{escape(fmt_value(value))}</text>')
|
||||
parts.append(f'<line x1="{label_w}" x2="{label_w}" y1="{top}" y2="{height - bottom}" stroke="{MUTED}"/>')
|
||||
parts.append("</svg>")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def change_bars(rows: list[tuple[str, float]], fmt_value, width: int = 900, height: int = 520) -> str:
|
||||
"""Ranked % change per category; increases orange, decreases blue."""
|
||||
if not rows:
|
||||
return ""
|
||||
label_w, top, bottom = 270, 8, 30
|
||||
row_h = min(60.0, (height - top - bottom) / len(rows))
|
||||
bar = min(row_h * 0.6, 28)
|
||||
plot_w = width - label_w - 90
|
||||
height = round(top + bottom + row_h * len(rows))
|
||||
lo = min(0.0, min(v for _, v in rows))
|
||||
hi = max(0.0, max(v for _, v in rows)) or 1
|
||||
ticks = nice_ticks(lo, hi, 8)
|
||||
span = ticks[-1] - ticks[0]
|
||||
x = lambda v: label_w + plot_w * (v - ticks[0]) / span
|
||||
parts = [f'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" class="chart">']
|
||||
for t in ticks:
|
||||
parts.append(f'<line x1="{x(t):.1f}" x2="{x(t):.1f}" y1="{top}" y2="{height - bottom}" stroke="{GRID}"/>')
|
||||
parts.append(f'<text x="{x(t):.1f}" y="{height - bottom + 18}" font-size="12" text-anchor="middle" fill="{MUTED}">{"+" if t > 0 else ""}{t:g}%</text>')
|
||||
for i, (label, value) in enumerate(rows):
|
||||
cy = top + i * row_h + row_h / 2
|
||||
x0, x1 = sorted((x(0), x(value)))
|
||||
colour = ORANGE if value > 0 else BLUE
|
||||
parts.append(f'<text x="{label_w - 10}" y="{cy + 4.5:.1f}" font-size="13" text-anchor="end" fill="{INK}">{escape(_wrap(label, 38, 1)[0])}</text>')
|
||||
parts.append(f'<rect x="{x0:.1f}" y="{cy - bar / 2:.1f}" width="{max(x1 - x0, 1):.1f}" height="{bar:.1f}" fill="{colour}"/>')
|
||||
tx, anchor = (x1 + 6, "start") if value >= 0 else (x0 - 6, "end")
|
||||
parts.append(f'<text x="{tx:.1f}" y="{cy + 4.5:.1f}" font-size="12.5" font-weight="bold" text-anchor="{anchor}" fill="{INK}">{escape(fmt_value(value))}</text>')
|
||||
parts.append(f'<line x1="{x(0):.1f}" x2="{x(0):.1f}" y1="{top}" y2="{height - bottom}" stroke="{MUTED}"/>')
|
||||
parts.append("</svg>")
|
||||
return "".join(parts)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Excel exports with live formulas, so totals keep working when people edit the file."""
|
||||
import re
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
from ..summary import YearView
|
||||
from .i18n import T
|
||||
|
||||
MONEY = '#,##0.00;[Red]-#,##0.00'
|
||||
PCT = '0.00%;[Red]-0.00%'
|
||||
HEAD_FILL = PatternFill("solid", fgColor="1F3B63")
|
||||
CAT_FILL = PatternFill("solid", fgColor="EEF2F8")
|
||||
WHITE_BOLD = Font(bold=True, color="FFFFFF")
|
||||
BOLD = Font(bold=True)
|
||||
THIN_TOP = Border(top=Side(style="thin", color="999999"))
|
||||
|
||||
|
||||
def _safe_sheet_name(name: str, used: set[str]) -> str:
|
||||
base = re.sub(r"[\[\]:*?/\\]", "-", name)[:31] or "Sheet"
|
||||
candidate, n = base, 2
|
||||
while candidate.lower() in used:
|
||||
suffix = f" ({n})"
|
||||
candidate, n = base[: 31 - len(suffix)] + suffix, n + 1
|
||||
used.add(candidate.lower())
|
||||
return candidate
|
||||
|
||||
|
||||
def _header(ws, row: int, labels: list[str]) -> None:
|
||||
for i, label in enumerate(labels, start=1):
|
||||
c = ws.cell(row, i, label)
|
||||
c.font, c.fill = WHITE_BOLD, HEAD_FILL
|
||||
c.alignment = Alignment(horizontal="left" if i <= 2 else "right", vertical="center")
|
||||
|
||||
|
||||
def _title(ws, title: str, sub: str) -> None:
|
||||
ws["A1"] = title
|
||||
ws["A1"].font = Font(bold=True, size=14)
|
||||
ws["A2"] = sub
|
||||
ws["A2"].font = Font(color="666666")
|
||||
|
||||
|
||||
def _out(wb: Workbook) -> bytes:
|
||||
buf = BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _meta(view: YearView, t: T, site: str | None) -> str:
|
||||
bits = [f"{t('status')}: {t(view.year.status)}", f"{t('exported')}: {datetime.now():%Y-%m-%d %H:%M}"]
|
||||
if site:
|
||||
bits.insert(0, t("site_filter", site=site))
|
||||
return " · ".join(bits)
|
||||
|
||||
|
||||
def budget_xlsx(view: YearView, lang: str, site: str | None = None, include_empty: bool = True) -> bytes:
|
||||
t = T(lang, view.year.year, view.year.department.name)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = _safe_sheet_name(t("budget_title"), set())
|
||||
_title(ws, t("budget_title"), t("budget_sub") + " · " + _meta(view, t, site))
|
||||
months = t("months")
|
||||
_header(ws, 4, [t("line"), t("site"), *months, t("total")])
|
||||
row = 5
|
||||
cost_subtotals = []
|
||||
cats = sorted([c for c in view.categories if c.lines or include_empty],
|
||||
key=lambda c: 0 if c.category.section == "revenue" else 1)
|
||||
for cv in cats:
|
||||
ws.cell(row, 1, cv.name(lang)).font = BOLD
|
||||
for col in range(1, 16):
|
||||
ws.cell(row, col).fill = CAT_FILL
|
||||
row += 1
|
||||
first = row
|
||||
for lv in cv.lines:
|
||||
ws.cell(row, 1, lv.line.name)
|
||||
ws.cell(row, 2, lv.line.site.name if lv.line.site else t("group"))
|
||||
for m, value in enumerate(lv.figures.months):
|
||||
ws.cell(row, 3 + m, float(round(value, 2))).number_format = MONEY
|
||||
ws.cell(row, 15, f"=SUM(C{row}:N{row})").number_format = MONEY
|
||||
row += 1
|
||||
label = t("revenue_total") if cv.category.section == "revenue" else t("sub_total", name=cv.name(lang))
|
||||
ws.cell(row, 1, label).font = BOLD
|
||||
for col in range(3, 16):
|
||||
letter = get_column_letter(col)
|
||||
formula = f"=SUM({letter}{first}:{letter}{row - 1})" if cv.lines else 0
|
||||
c = ws.cell(row, col, formula)
|
||||
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
|
||||
if cv.category.section != "revenue":
|
||||
cost_subtotals.append(row)
|
||||
row += 2
|
||||
ws.cell(row, 1, t("total_costs")).font = WHITE_BOLD
|
||||
for col in range(1, 16):
|
||||
ws.cell(row, col).fill = HEAD_FILL
|
||||
for col in range(3, 16):
|
||||
letter = get_column_letter(col)
|
||||
formula = "=" + "+".join(f"{letter}{r}" for r in cost_subtotals) if cost_subtotals else 0
|
||||
c = ws.cell(row, col, formula)
|
||||
c.number_format, c.font = MONEY, WHITE_BOLD
|
||||
ws.column_dimensions["A"].width = 52
|
||||
ws.column_dimensions["B"].width = 12
|
||||
for col in range(3, 16):
|
||||
ws.column_dimensions[get_column_letter(col)].width = 13
|
||||
ws.freeze_panes = "C5"
|
||||
_inputs_sheet(wb.create_sheet(_safe_sheet_name(t("inputs"), {ws.title.lower()})), view, t, site)
|
||||
return _out(wb)
|
||||
|
||||
|
||||
def _inputs_sheet(ws, view: YearView, t: T, site: str | None) -> None:
|
||||
"""The Sheet1 layout: actual, base, factor, adjusted, change, remarks."""
|
||||
_title(ws, t("inputs") + f" {view.year.year}", _meta(view, t, site))
|
||||
_header(ws, 4, [t("category"), t("line"), t("site"), t("prior_actual"), t("base"), t("factor"),
|
||||
t("adjusted"), t("change_eur"), t("change_pct"), t("reason"), t("remarks")])
|
||||
row = 5
|
||||
for cv in view.with_lines():
|
||||
for lv in cv.lines:
|
||||
f, line = lv.figures, lv.line
|
||||
ws.cell(row, 1, cv.name(t.lang))
|
||||
ws.cell(row, 2, line.name)
|
||||
ws.cell(row, 3, line.site.name if line.site else t("group"))
|
||||
ws.cell(row, 4, float(f.prior)).number_format = MONEY
|
||||
ws.cell(row, 5, float(f.base)).number_format = MONEY
|
||||
ws.cell(row, 6, float(f.factor)).number_format = "0.0000"
|
||||
ws.cell(row, 7, f"=ROUND(E{row}*F{row},2)").number_format = MONEY
|
||||
ws.cell(row, 8, f"=G{row}-D{row}").number_format = MONEY
|
||||
ws.cell(row, 9, f'=IF(D{row}=0,"",H{row}/D{row})').number_format = PCT
|
||||
ws.cell(row, 10, line.adj_reason)
|
||||
ws.cell(row, 11, line.remarks)
|
||||
row += 1
|
||||
ws.cell(row, 1, t("total")).font = BOLD
|
||||
for col, letter in ((4, "D"), (5, "E"), (7, "G"), (8, "H")):
|
||||
c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})")
|
||||
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
|
||||
c = ws.cell(row, 9, f'=IF(D{row}=0,"",H{row}/D{row})')
|
||||
c.number_format, c.font = PCT, BOLD
|
||||
for letter, width in zip("ABCDEFGHIJK", (34, 46, 12, 16, 16, 9, 18, 15, 12, 30, 40)):
|
||||
ws.column_dimensions[letter].width = width
|
||||
ws.freeze_panes = "C5"
|
||||
|
||||
|
||||
def cost_report_xlsx(view: YearView, lang: str, site: str | None = None) -> bytes:
|
||||
t = T(lang, view.year.year, view.year.department.name)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
used: set[str] = set()
|
||||
ws.title = _safe_sheet_name(t("summary"), used)
|
||||
_title(ws, t("cost_report_title"), t("summary_sub") + " · " + _meta(view, t, site))
|
||||
_header(ws, 4, [t("category"), t("prior_actual"), t("adjusted"), t("change_eur"), t("change_pct")])
|
||||
cats = view.with_lines("cost")
|
||||
row = 5
|
||||
for cv in cats:
|
||||
sheet = wb.create_sheet(_safe_sheet_name(cv.name(lang), used))
|
||||
total_row = _category_sheet(sheet, cv, t)
|
||||
ref = f"'{sheet.title}'"
|
||||
ws.cell(row, 1, cv.name(lang))
|
||||
ws.cell(row, 2, f"={ref}!B{total_row}").number_format = MONEY
|
||||
ws.cell(row, 3, f"={ref}!C{total_row}").number_format = MONEY
|
||||
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
|
||||
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
|
||||
row += 1
|
||||
ws.cell(row, 1, t("total")).font = BOLD
|
||||
for col, letter in ((2, "B"), (3, "C")):
|
||||
c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})")
|
||||
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
|
||||
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
|
||||
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
|
||||
ws.cell(row, 4).font = ws.cell(row, 5).font = BOLD
|
||||
for letter, width in zip("ABCDE", (40, 20, 22, 18, 14)):
|
||||
ws.column_dimensions[letter].width = width
|
||||
return _out(wb)
|
||||
|
||||
|
||||
def _category_sheet(ws, cv, t: T) -> int:
|
||||
_title(ws, cv.name(t.lang), t("cost_lines", n=len(cv.lines)))
|
||||
_header(ws, 4, [t("line"), t("prior_actual"), t("adjusted"), t("change_eur"), t("change_pct"), t("site"), t("remarks")])
|
||||
row = 5
|
||||
for lv in cv.lines:
|
||||
ws.cell(row, 1, lv.line.name)
|
||||
ws.cell(row, 2, float(lv.figures.prior)).number_format = MONEY
|
||||
ws.cell(row, 3, float(lv.figures.adjusted)).number_format = MONEY
|
||||
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
|
||||
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
|
||||
ws.cell(row, 6, lv.line.site.name if lv.line.site else t("group"))
|
||||
ws.cell(row, 7, lv.line.remarks)
|
||||
row += 1
|
||||
ws.cell(row, 1, t("total")).font = BOLD
|
||||
for col, letter in ((2, "B"), (3, "C")):
|
||||
c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})")
|
||||
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
|
||||
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
|
||||
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
|
||||
for letter, width in zip("ABCDEFG", (46, 18, 20, 16, 12, 12, 40)):
|
||||
ws.column_dimensions[letter].width = width
|
||||
return row
|
||||
@@ -0,0 +1,136 @@
|
||||
from decimal import Decimal
|
||||
|
||||
LABELS = {
|
||||
"en": {
|
||||
"cost_report_title": "Capex and Costs {year}",
|
||||
"cost_report_subtitle": "{dept} · cost budget: {prior} actual vs. {year} with adjustment",
|
||||
"total_across": "Total across {n} categories",
|
||||
"by_category": "Budget by category",
|
||||
"by_category_sub": "Total cost per category, {prior} vs. {year}",
|
||||
"change_by_category": "Change by category",
|
||||
"change_by_category_sub": "Percentage change from {prior} actual to {year} with adjustment",
|
||||
"change_legend": "Orange = cost increase · Blue = cost reduction",
|
||||
"prior_actual": "{prior} actual",
|
||||
"adjusted": "{year} with adjustment",
|
||||
"base": "{year} without adjustment",
|
||||
"change_eur": "Change (€)",
|
||||
"change_pct": "Change (%)",
|
||||
"cost_lines": "{n} cost lines",
|
||||
"cost_line": "1 cost line",
|
||||
"summary": "Summary",
|
||||
"summary_sub": "All categories, {prior} actual vs. {year} with adjustment",
|
||||
"category": "Category",
|
||||
"line": "Line",
|
||||
"site": "Site",
|
||||
"group": "Group",
|
||||
"total": "Total",
|
||||
"factor": "Factor",
|
||||
"reason": "Reason",
|
||||
"remarks": "Remarks",
|
||||
"budget_title": "Budget {year} · {dept}",
|
||||
"budget_sub": "Monthly budget by category",
|
||||
"revenue_total": "Sub total revenue",
|
||||
"sub_total": "Sub total {name}",
|
||||
"total_costs": "Total costs",
|
||||
"status": "Status",
|
||||
"exported": "Exported",
|
||||
"draft": "Draft",
|
||||
"in_review": "In review",
|
||||
"approved": "Approved",
|
||||
"inputs": "Inputs",
|
||||
"months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||
"no_data": "No cost lines yet.",
|
||||
"site_filter": "Site: {site}",
|
||||
},
|
||||
"pt": {
|
||||
"cost_report_title": "Capex e Custos para {year}",
|
||||
"cost_report_subtitle": "{dept} · orçamento de custos: real {prior} vs. {year} com ajuste",
|
||||
"total_across": "Total de {n} categorias",
|
||||
"by_category": "Orçamento por categoria",
|
||||
"by_category_sub": "Custo total por categoria, {prior} vs. {year}",
|
||||
"change_by_category": "Variação por categoria",
|
||||
"change_by_category_sub": "Variação percentual do real {prior} para {year} com ajuste",
|
||||
"change_legend": "Laranja = aumento de custo · Azul = redução de custo",
|
||||
"prior_actual": "{prior} real",
|
||||
"adjusted": "{year} com ajuste",
|
||||
"base": "{year} sem ajuste",
|
||||
"change_eur": "Variação (€)",
|
||||
"change_pct": "Variação (%)",
|
||||
"cost_lines": "{n} linhas de custo",
|
||||
"cost_line": "1 linha de custo",
|
||||
"summary": "Resumo",
|
||||
"summary_sub": "Todas as categorias, real {prior} vs. {year} com ajuste",
|
||||
"category": "Categoria",
|
||||
"line": "Linha",
|
||||
"site": "Unidade",
|
||||
"group": "Grupo",
|
||||
"total": "Total",
|
||||
"factor": "Fator",
|
||||
"reason": "Motivo",
|
||||
"remarks": "Observações",
|
||||
"budget_title": "Orçamento {year} · {dept}",
|
||||
"budget_sub": "Orçamento mensal por categoria",
|
||||
"revenue_total": "Subtotal receitas",
|
||||
"sub_total": "Subtotal {name}",
|
||||
"total_costs": "Total de custos",
|
||||
"status": "Estado",
|
||||
"exported": "Exportado",
|
||||
"draft": "Rascunho",
|
||||
"in_review": "Em revisão",
|
||||
"approved": "Aprovado",
|
||||
"inputs": "Dados",
|
||||
"months": ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"],
|
||||
"no_data": "Ainda não há linhas de custo.",
|
||||
"site_filter": "Unidade: {site}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class T:
|
||||
def __init__(self, lang: str, year: int, dept: str = ""):
|
||||
self.lang = lang if lang in LABELS else "pt"
|
||||
self.labels = LABELS[self.lang]
|
||||
self.ctx = {"year": year, "prior": year - 1, "dept": dept}
|
||||
|
||||
def __call__(self, key: str, **kw):
|
||||
value = self.labels[key]
|
||||
return value.format(**{**self.ctx, **kw}) if isinstance(value, str) else value
|
||||
|
||||
def _group(self, value: Decimal, decimals: int) -> str:
|
||||
q = Decimal(1).scaleb(-decimals) if decimals else Decimal(1)
|
||||
text = f"{abs(Decimal(value)).quantize(q):,.{decimals}f}"
|
||||
whole, _, frac = text.partition(".")
|
||||
whole = whole.replace(",", " ")
|
||||
if not frac:
|
||||
return whole
|
||||
return whole + ("," if self.lang == "pt" else ".") + frac
|
||||
|
||||
def eur(self, value, decimals: int = 2, signed: bool = False) -> str:
|
||||
value = Decimal(value or 0)
|
||||
sign = ("+" if value > 0 else "−" if value < 0 else "") if signed else ("−" if value < 0 else "")
|
||||
body = self._group(value, decimals)
|
||||
return f"{sign}{body} €" if self.lang == "pt" else f"{sign}€{body}"
|
||||
|
||||
def num(self, value, decimals: int = 2) -> str:
|
||||
value = Decimal(value or 0)
|
||||
return ("\u2212" if value < 0 else "") + self._group(value, decimals)
|
||||
|
||||
def pct(self, value, signed: bool = True) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
value = Decimal(value)
|
||||
sign = ("+" if value > 0 else "−" if value < 0 else "") if signed else ""
|
||||
return f"{sign}{self._group(value, 2)}%"
|
||||
|
||||
def short_eur(self, value: float) -> str:
|
||||
v = abs(value)
|
||||
sign = "−" if value < 0 else ""
|
||||
if v >= 1_000_000:
|
||||
body = f"{v / 1_000_000:g}M"
|
||||
elif v >= 1000:
|
||||
body = f"{v / 1000:g}k"
|
||||
else:
|
||||
body = f"{v:g}"
|
||||
if self.lang == "pt":
|
||||
return f"{sign}{body.replace('.', ',')} €"
|
||||
return f"{sign}€{body}"
|
||||
@@ -0,0 +1,52 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from ..summary import YearView
|
||||
from .charts import change_bars, grouped_bars
|
||||
from .i18n import T
|
||||
|
||||
TEMPLATES = Path(__file__).parent / "templates"
|
||||
env = Environment(loader=FileSystemLoader(TEMPLATES), autoescape=select_autoescape(["html"]))
|
||||
CSS = (TEMPLATES / "report.css").read_text()
|
||||
|
||||
|
||||
def _exported() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def cost_report_html(view: YearView, lang: str, site: str | None = None, screen: bool = True) -> str:
|
||||
t = T(lang, view.year.year, view.year.department.name)
|
||||
cost_cats = view.with_lines("cost")
|
||||
cats = []
|
||||
for cv in cost_cats:
|
||||
rows = [(lv.line.name, float(lv.figures.prior), float(lv.figures.adjusted)) for lv in cv.lines]
|
||||
cats.append(SimpleNamespace(
|
||||
name=cv.name(lang), count=len(cv.lines), totals=cv.totals,
|
||||
chart=grouped_bars(rows, t.short_eur, lambda v: t.eur(v, 0))))
|
||||
overview = grouped_bars([(c.name, float(c.totals.prior), float(c.totals.adjusted)) for c in cats], t.short_eur)
|
||||
ranked = sorted([c for c in cats if c.totals.change_pct is not None],
|
||||
key=lambda c: c.totals.change_pct, reverse=True)
|
||||
change = change_bars([(c.name, float(c.totals.change_pct)) for c in ranked], lambda v: t.pct(v))
|
||||
return env.get_template("cost_report.html").render(
|
||||
t=t, title=t("cost_report_title"), year=view.year, site=site, cats=cats, costs=view.costs,
|
||||
overview_chart=overview, change_chart=change, exported=_exported(), css=CSS, screen=screen)
|
||||
|
||||
|
||||
def budget_html(view: YearView, lang: str, site: str | None = None, include_empty: bool = False) -> str:
|
||||
t = T(lang, view.year.year, view.year.department.name)
|
||||
cats = [SimpleNamespace(name=cv.name(lang), category=cv.category, lines=cv.lines, totals=cv.totals)
|
||||
for cv in view.categories if cv.lines or include_empty]
|
||||
# revenue first, as in the Budget IT sheet
|
||||
cats.sort(key=lambda c: 0 if c.category.section == "revenue" else 1)
|
||||
return env.get_template("budget.html").render(
|
||||
t=t, title=t("budget_title"), year=view.year, site=site, cats=cats, costs=view.costs,
|
||||
exported=_exported(), css=CSS)
|
||||
|
||||
|
||||
def to_pdf(html: str) -> bytes:
|
||||
from weasyprint import HTML # imported lazily: it loads system libraries
|
||||
|
||||
return HTML(string=html).write_pdf()
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ t.lang }}">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ title }}</title><style>{{ css|safe }}</style></head>
|
||||
<body>
|
||||
<section class="page budget">
|
||||
<h1>{{ title }}</h1>
|
||||
<div class="sub">{{ t('budget_sub') }}{% if site %} · {{ t('site_filter', site=site) }}{% endif %} · {{ t('status') }}: {{ t(year.status) }} · {{ t('exported') }}: {{ exported }}</div>
|
||||
<table class="grid">
|
||||
<thead><tr><th>{{ t('line') }}</th><th>{{ t('site') }}</th>{% for m in t('months') %}<th>{{ m }}</th>{% endfor %}<th>{{ t("total") }} (€)</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in cats %}
|
||||
<tr class="cat"><td colspan="15">{{ c.name }}</td></tr>
|
||||
{% for l in c.lines %}
|
||||
<tr><td class="name">{{ l.line.name }}</td><td>{{ l.line.site.name if l.line.site else t('group') }}</td>
|
||||
{% for m in l.figures.months %}<td class="num">{{ t.num(m) }}</td>{% endfor %}<td class="num">{{ t.num(l.figures.adjusted) }}</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="subtotal"><td colspan="2">{{ t('revenue_total') if c.category.section == 'revenue' else t('sub_total', name=c.name) }}</td>
|
||||
{% for m in c.totals.months %}<td class="num">{{ t.num(m) }}</td>{% endfor %}<td class="num">{{ t.num(c.totals.adjusted) }}</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="grand"><td colspan="2">{{ t('total_costs') }}</td>{% for m in costs.months %}<td class="num">{{ t.num(m) }}</td>{% endfor %}<td class="num">{{ t.num(costs.adjusted) }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</body></html>
|
||||
@@ -0,0 +1,75 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ t.lang }}">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ title }}</title><style>{{ css|safe }}</style>
|
||||
{% if screen %}<script>
|
||||
function fit(){var w=document.documentElement.clientWidth-32;var z=Math.min(1,w/1280);document.documentElement.style.setProperty('--zoom',z);}
|
||||
window.addEventListener('resize',fit);document.addEventListener('DOMContentLoaded',fit);
|
||||
</script>{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
{% set total_pages = 3 + cats|length %}
|
||||
<section class="page cover">
|
||||
<h1>{{ title }}</h1>
|
||||
<p class="sub">{{ t('cost_report_subtitle') }}{% if site %} · {{ t('site_filter', site=site) }}{% endif %}</p>
|
||||
<div class="kicker">{{ t('total_across', n=cats|length) }}</div>
|
||||
<div class="big">{{ t.eur(costs.prior, 0) }} → {{ t.eur(costs.adjusted, 0) }}</div>
|
||||
<div class="delta {{ 'up' if costs.change > 0 else 'down' }}">{{ t.pct(costs.change_pct) }} ({{ t.eur(costs.change, 0, true) }})</div>
|
||||
<div class="meta">{{ t('status') }}: {{ t(year.status) }} · {{ t('exported') }}: {{ exported }}</div>
|
||||
</section>
|
||||
|
||||
<section class="page">
|
||||
<h1>{{ t('by_category') }}</h1>
|
||||
<p class="sub">{{ t('by_category_sub') }}</p>
|
||||
<div class="rule"></div>
|
||||
<div class="legend"><span><i style="background:#2f6fd6"></i>{{ t('prior_actual') }}</span><span><i style="background:#e8632b"></i>{{ t('adjusted') }}</span></div>
|
||||
<div class="chartbox full">{{ overview_chart|safe }}</div>
|
||||
<div class="footer">{{ title }}<span class="n">2 / {{ total_pages }}</span></div>
|
||||
</section>
|
||||
|
||||
<section class="page">
|
||||
<h1>{{ t('change_by_category') }}</h1>
|
||||
<p class="sub">{{ t('change_by_category_sub') }}</p>
|
||||
<div class="rule"></div>
|
||||
<div class="legend" style="text-align:center">{{ t('change_legend') }}</div>
|
||||
<div class="chartbox full">{{ change_chart|safe }}</div>
|
||||
<div class="footer">{{ title }}<span class="n">3 / {{ total_pages }}</span></div>
|
||||
</section>
|
||||
|
||||
{% for c in cats %}
|
||||
<section class="page">
|
||||
<h1>{{ c.name }}</h1>
|
||||
<p class="sub">{{ t('cost_line') if c.count == 1 else t('cost_lines', n=c.count) }}</p>
|
||||
<div class="rule"></div>
|
||||
<div class="legend"><span><i style="background:#2f6fd6"></i>{{ t('prior_actual') }}</span><span><i style="background:#e8632b"></i>{{ t('adjusted') }}</span></div>
|
||||
<div class="body">
|
||||
<div class="chartbox">{{ c.chart|safe }}</div>
|
||||
<div class="kpis">
|
||||
<div class="kpi"><div class="k">{{ t('prior_actual') }}</div><div class="v">{{ t.eur(c.totals.prior) }}</div></div>
|
||||
<div class="kpi"><div class="k">{{ t('adjusted') }}</div><div class="v">{{ t.eur(c.totals.adjusted) }}</div></div>
|
||||
<div class="kpi"><div class="k">{{ t('change_eur') }}</div><div class="v {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.eur(c.totals.change, 2, true) }}</div></div>
|
||||
<div class="kpi"><div class="k">{{ t('change_pct') }}</div><div class="v {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.pct(c.totals.change_pct) }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">{{ title }}<span class="n">{{ 3 + loop.index }} / {{ total_pages }}</span></div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<section class="page">
|
||||
<h1>{{ t('summary') }}</h1>
|
||||
<p class="sub">{{ t('summary_sub') }}</p>
|
||||
<div class="rule"></div>
|
||||
<table class="summary">
|
||||
<thead><tr><th>{{ t('category') }}</th><th class="num">{{ t('prior_actual') }}</th><th class="num">{{ t('adjusted') }}</th><th class="num">{{ t('change_eur') }}</th><th class="num">{{ t('change_pct') }}</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in cats %}
|
||||
<tr><td>{{ c.name }}</td><td class="num">{{ t.eur(c.totals.prior) }}</td><td class="num">{{ t.eur(c.totals.adjusted) }}</td>
|
||||
<td class="num {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.eur(c.totals.change, 2, true) }}</td><td class="num {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.pct(c.totals.change_pct) }}</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="total"><td>{{ t('total') }}</td><td class="num">{{ t.eur(costs.prior) }}</td><td class="num">{{ t.eur(costs.adjusted) }}</td>
|
||||
<td class="num {{ 'up' if costs.change > 0 else 'down' }}">{{ t.eur(costs.change, 2, true) }}</td><td class="num {{ 'up' if costs.change > 0 else 'down' }}">{{ t.pct(costs.change_pct) }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer">{{ title }}<span class="n">{{ total_pages }} / {{ total_pages }}</span></div>
|
||||
</section>
|
||||
</body></html>
|
||||
@@ -0,0 +1,62 @@
|
||||
@page { size: 338.67mm 190.5mm; margin: 0; }
|
||||
@page budget { size: A3 landscape; margin: 12mm 10mm 14mm 10mm;
|
||||
@bottom-left { content: string(doc-title); font: 8pt Helvetica, Arial, sans-serif; color: #888; }
|
||||
@bottom-right { content: counter(page) " / " counter(pages); font: 8pt Helvetica, Arial, sans-serif; color: #888; } }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body { font-family: "DejaVu Sans", "Liberation Sans", Arial, sans-serif; color: #1d1d1f; background: #ececee; }
|
||||
.page { position: relative; width: 338.67mm; height: 190.5mm; padding: 13mm 16mm 12mm; background: #fbfbf9;
|
||||
overflow: hidden; page-break-after: always; break-after: page; }
|
||||
.page:last-child { page-break-after: auto; break-after: auto; }
|
||||
.page h1 { font-size: 22pt; margin: 0 0 1.5mm; }
|
||||
.page .sub { font-size: 10pt; color: #444; margin: 0 0 3mm; }
|
||||
.rule { border-top: 1px solid #ddd; margin: 2mm 0 3mm; }
|
||||
.legend { font-size: 8.5pt; color: #333; margin-bottom: 2mm; }
|
||||
.legend span { display: inline-block; margin-right: 18mm; }
|
||||
.legend i { display: inline-block; width: 3mm; height: 3mm; margin-right: 2mm; vertical-align: -0.4mm; }
|
||||
.footer { position: absolute; left: 16mm; right: 16mm; bottom: 6mm; font-size: 7pt; color: #888; }
|
||||
.footer .n { float: right; }
|
||||
.cover { padding-left: 22mm; }
|
||||
.cover::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4mm; background: #2f6fd6; }
|
||||
.cover h1 { font-size: 34pt; margin-top: 48mm; }
|
||||
.cover .sub { font-size: 13pt; }
|
||||
.cover .kicker { margin-top: 16mm; font-size: 10pt; color: #444; }
|
||||
.cover .big { font-size: 22pt; font-weight: bold; margin: 2mm 0; }
|
||||
.cover .delta { font-size: 13pt; font-weight: bold; }
|
||||
.up { color: #c2410c; } .down { color: #15803d; }
|
||||
.body { display: flex; gap: 8mm; }
|
||||
.chartbox { flex: 1 1 auto; height: 128mm; }
|
||||
.chartbox.full { height: 136mm; }
|
||||
.chartbox svg { width: 100%; height: 100%; }
|
||||
.kpis { flex: 0 0 72mm; padding-top: 2mm; }
|
||||
.kpi { margin-bottom: 7mm; }
|
||||
.kpi .k { font-size: 9pt; color: #333; }
|
||||
.kpi .v { font-size: 18pt; font-weight: bold; margin-top: 1.5mm; }
|
||||
table.summary { width: 100%; border-collapse: collapse; font-size: 9pt; margin-top: 3mm; }
|
||||
table.summary th { text-align: left; font-weight: bold; border-bottom: 1px solid #bbb; padding: 2mm; }
|
||||
table.summary td { padding: 1.6mm 2mm; border-bottom: 1px solid #e4e4e4; }
|
||||
table.summary .num { text-align: right; white-space: nowrap; }
|
||||
table.summary tr.total td { font-weight: bold; border-top: 1px solid #999; }
|
||||
.meta { font-size: 8pt; color: #777; margin-top: 2mm; }
|
||||
|
||||
/* annual budget */
|
||||
.budget { page: budget; background: #fff; padding: 0; width: auto; height: auto; overflow: visible; }
|
||||
.budget h1 { font-size: 16pt; margin: 0 0 1mm; string-set: doc-title content(); }
|
||||
.budget .sub { font-size: 9pt; color: #555; margin-bottom: 4mm; }
|
||||
table.grid { width: 100%; border-collapse: collapse; font-size: 7.2pt; }
|
||||
table.grid th, table.grid td { padding: 1.1mm 1.2mm; border-bottom: 1px solid #eee; }
|
||||
table.grid thead th { background: #1f3b63; color: #fff; text-align: right; font-weight: bold; }
|
||||
table.grid thead th:first-child, table.grid thead th:nth-child(2) { text-align: left; }
|
||||
table.grid thead { display: table-header-group; }
|
||||
table.grid td.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
table.grid tr.cat td { font-weight: bold; background: #eef2f8; padding-top: 2mm; }
|
||||
table.grid tr.subtotal td { font-weight: bold; border-top: 1px solid #9aa; border-bottom: 1px solid #9aa; }
|
||||
table.grid tr.grand td { font-weight: bold; background: #1f3b63; color: #fff; }
|
||||
table.grid tr { page-break-inside: avoid; }
|
||||
table.grid td.name { max-width: 70mm; }
|
||||
|
||||
@media screen {
|
||||
body { padding: 16px 0; }
|
||||
.page { margin: 0 auto 16px; box-shadow: 0 1px 4px rgba(0,0,0,.15); zoom: var(--zoom, 1); }
|
||||
.budget { width: auto; max-width: 1600px; height: auto; padding: 16px 20px; margin: 0 auto; overflow: visible; }
|
||||
}
|
||||
Reference in New Issue
Block a user