Flatten budget-app contents into repo root

This commit is contained in:
jpmvaz
2026-10-29 20:41:01 +00:00
parent c9fd26f1c7
commit 93f2696764
95 changed files with 184 additions and 187 deletions
+196
View File
@@ -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