Files
Budget_Tool_Martinhal/backend/app/template.py
T

317 lines
15 KiB
Python
Raw Normal View History

2026-10-29 20:40:02 +00:00
"""Blank (or pre-filled) Excel template for a department's budget, and the import of a filled one."""
from datetime import datetime
from decimal import Decimal
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.datavalidation import DataValidation
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .audit import record
from .calc import ExpressionError, evaluate
from .models import Category, Department, Site, User
TEMPLATE_SHEETS = {"Lines", "Linhas"}
HEADER_ROW = 8
TEXT = {
"en": {
"sheet": "Lines", "lists": "Lists", "help": "Instructions",
"title": "Budget template · {dept}",
"year": "Year", "rate": "Year rate (%)", "dept": "Department",
"cols": ["Category", "Line", "Description", "Site", "Prior-year actual", "Base (without adjustment)",
"Adjustment", "Adjustment value", "Reason for adjustment", "Remarks"],
"months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
"split": "Split over months",
"adj": {"year_rate": "Year rate", "percent": "Percent", "factor": "Factor", "none": "None"},
"group": "Group",
"instructions": [
"How to fill in this template",
"1. On the 'Lines' sheet, set the year and the year rate (for example 5.5 for 5.5%).",
"2. Add one row per cost line, starting on row 9. Category and Line are required.",
"3. Category and Site have drop-down lists. Leave Site empty for costs shared by the group.",
"4. Prior-year actual and Base accept numbers or calculations such as =12*839.67.",
"5. Adjustment: 'Year rate' applies the year rate to the base; 'Percent' uses the Adjustment value as a %;",
" 'Factor' multiplies the base by the Adjustment value (e.g. 1.15); 'None' keeps the base.",
"6. Fill in the months (Jan to Dec) only for lines with uneven monthly amounts; the year total is then",
" the sum of the months and Base and Adjustment are ignored. Otherwise the year is split over 12.",
" To split the adjusted amount equally over some months only, list them in the last column",
" 'Split over months', for example 1-4 or Jan, Feb, Mar, Apr (leave Jan to Dec empty).",
"7. In the app, go to Budgets > Import Excel, choose the department and upload this file.",
],
},
"pt": {
"sheet": "Linhas", "lists": "Listas", "help": "Instruções",
"title": "Modelo de orçamento · {dept}",
"year": "Ano", "rate": "Taxa do ano (%)", "dept": "Departamento",
"cols": ["Categoria", "Linha", "Descrição", "Unidade", "Real do ano anterior", "Base (sem ajuste)",
"Ajuste", "Valor do ajuste", "Motivo do ajuste", "Observações"],
"months": ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"],
"split": "Dividir pelos meses",
"adj": {"year_rate": "Taxa do ano", "percent": "Percentagem", "factor": "Fator", "none": "Sem ajuste"},
"group": "Grupo",
"instructions": [
"Como preencher este modelo",
"1. Na folha 'Linhas', indique o ano e a taxa do ano (por exemplo 5,5 para 5,5%).",
"2. Acrescente uma linha por custo, a partir da linha 9. Categoria e Linha são obrigatórias.",
"3. Categoria e Unidade têm listas. Deixe a Unidade vazia para custos do grupo.",
"4. Real do ano anterior e Base aceitam números ou cálculos como =12*839,67.",
"5. Ajuste: 'Taxa do ano' aplica a taxa do ano à base; 'Percentagem' usa o Valor do ajuste como %;",
" 'Fator' multiplica a base pelo Valor do ajuste (ex.: 1,15); 'Sem ajuste' mantém a base.",
"6. Preencha os meses (Jan a Dez) só para linhas com valores mensais diferentes; o total do ano passa",
" a ser a soma dos meses e a Base e o Ajuste são ignorados. Caso contrário o ano é dividido por 12.",
" Para dividir o valor com ajuste em partes iguais só por alguns meses, indique-os na última coluna",
" 'Dividir pelos meses', por exemplo 1-4 ou Jan, Fev, Mar, Abr (deixe Jan a Dez vazios).",
"7. Na aplicação, vá a Orçamentos > Importar Excel, escolha o departamento e carregue este ficheiro.",
],
},
}
MONTH_NAMES = {name.lower(): i for t in TEXT.values() for i, name in enumerate(t["months"], start=1)}
MONTH_NAMES.update({"feb": 2, "fev": 2, "apr": 4, "abr": 4, "may": 5, "mai": 5, "aug": 8, "ago": 8,
"sep": 9, "set": 9, "oct": 10, "out": 10, "dec": 12, "dez": 12})
def _month(token: str) -> int | None:
token = token.strip().lower().rstrip(".")
if token.isdigit():
return int(token) if 1 <= int(token) <= 12 else None
return MONTH_NAMES.get(token[:3])
def parse_months(value) -> list[int]:
"""'1-4', '1,2,3,4', 'Jan-Abr', 'jan; fev; mar' -> [1, 2, 3, 4]. Unreadable text gives []."""
if isinstance(value, (int, float)):
value = str(int(value))
out = set()
for part in str(value).replace(";", ",").replace("/", ",").replace(" e ", ",").replace(" and ", ",").split(","):
part = part.strip()
if not part:
continue
if "-" in part or "–" in part:
a, _, b = part.replace("–", "-").partition("-")
start, end = _month(a), _month(b)
if start is None or end is None or end < start:
return []
out.update(range(start, end + 1))
else:
m = _month(part)
if m is None:
return []
out.add(m)
return sorted(out)
def months_text(months: list[int]) -> str:
"""[1, 2, 3, 4, 7] -> '1-4, 7'."""
months, runs = sorted(months), []
for m in months:
if runs and m == runs[-1][1] + 1:
runs[-1][1] = m
else:
runs.append([m, m])
return ", ".join(str(a) if a == b else f"{a}-{b}" for a, b in runs)
HEAD_FILL = PatternFill("solid", fgColor="1F3B63")
INPUT_FILL = PatternFill("solid", fgColor="FFF7E0")
def build_template(db: Session, department: Department, lang: str, year: int, rate: Decimal,
lines: list | None = None) -> bytes:
tx = TEXT.get(lang, TEXT["pt"])
cats = db.scalars(select(Category).where(Category.department_id == department.id, Category.active)
.order_by(Category.sort_order, Category.id)).all()
sites = db.scalars(select(Site).where(Site.active).order_by(Site.name)).all()
cat_name = (lambda c: c.name_pt or c.name_en) if lang == "pt" else (lambda c: c.name_en)
wb = Workbook()
ws = wb.active
ws.title = tx["sheet"]
ws["A1"] = tx["title"].format(dept=department.name)
ws["A1"].font = Font(bold=True, size=14)
for row, (label, value) in enumerate(((tx["dept"], department.name), (tx["year"], year),
(tx["rate"], float(rate * 100))), start=3):
ws.cell(row, 1, label).font = Font(bold=True)
c = ws.cell(row, 2, value)
if row > 3:
c.fill = INPUT_FILL
ws["B5"].number_format = "0.00"
headers = tx["cols"] + tx["months"] + [tx["split"]]
for col, label in enumerate(headers, start=1):
c = ws.cell(HEADER_ROW, col, label)
c.font, c.fill = Font(bold=True, color="FFFFFF"), HEAD_FILL
c.alignment = Alignment(wrap_text=True, vertical="center")
ws.row_dimensions[HEADER_ROW].height = 32
lists = wb.create_sheet(tx["lists"])
for i, c in enumerate(cats, start=1):
lists.cell(i, 1, cat_name(c))
for i, s in enumerate(sites, start=1):
lists.cell(i, 2, s.name)
for i, label in enumerate(tx["adj"].values(), start=1):
lists.cell(i, 3, label)
lists.sheet_state = "hidden"
last = HEADER_ROW + max(500, len(lines or []) + 200)
rng = lambda col: f"{get_column_letter(col)}{HEADER_ROW + 1}:{get_column_letter(col)}{last}"
for col, src, n in ((1, "A", len(cats)), (4, "B", len(sites)), (7, "C", 4)):
if n:
dv = DataValidation(type="list", formula1=f"'{tx['lists']}'!${src}$1:${src}${n}", allow_blank=True)
dv.error, dv.showErrorMessage = "", col != 1 # unknown categories are allowed and created on import
ws.add_data_validation(dv)
dv.add(rng(col))
row = HEADER_ROW + 1
for line in lines or []:
f = line.figures
ln = line.line
values = [cat_name(ln.category), ln.name, ln.description, ln.site.name if ln.site else "",
float(f.prior), float(f.base) if ln.monthly_kind != "manual" else None,
tx["adj"][ln.adj_kind] if ln.monthly_kind != "manual" else None,
float(ln.adj_value) if ln.adj_kind in ("percent", "factor") else None,
ln.adj_reason, ln.remarks]
if ln.monthly_kind == "manual":
values += [float(m) for m in f.months]
elif ln.monthly_kind == "selected" and ln.monthly_months:
values += [None] * 12 + [months_text(ln.monthly_months)]
for col, v in enumerate(values, start=1):
if v not in (None, ""):
ws.cell(row, col, v)
row += 1
for col in range(5, 7):
for r in range(HEADER_ROW + 1, last + 1):
ws.cell(r, col).number_format = "#,##0.00"
for col, width in zip(range(1, 11), (34, 40, 30, 14, 18, 20, 16, 14, 30, 36)):
ws.column_dimensions[get_column_letter(col)].width = width
for col in range(11, 23):
ws.column_dimensions[get_column_letter(col)].width = 11
ws.column_dimensions[get_column_letter(23)].width = 22
ws.freeze_panes = ws.cell(HEADER_ROW + 1, 3)
helpws = wb.create_sheet(tx["help"], 0)
for i, text in enumerate(tx["instructions"], start=1):
helpws.cell(i, 1, text).font = Font(bold=(i == 1), size=13 if i == 1 else 11)
helpws.cell(len(tx["instructions"]) + 2, 1, f"{tx['dept']}: {department.name} · {datetime.now():%Y-%m-%d}").font = Font(color="888888")
helpws.column_dimensions["A"].width = 110
wb.active = 1
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
def _norm(value) -> str:
return str(value or "").strip().lower()
def _amount(value, warnings, where) -> tuple[str, Decimal]:
if value is None or value == "":
return "0", Decimal(0)
if isinstance(value, (int, float)):
return str(value), Decimal(str(value))
try:
return str(value).strip().lstrip("="), evaluate(str(value))
except ExpressionError:
warnings.append(f"{where}: '{value}' is not a number, used 0")
return "0", Decimal(0)
def import_template(db: Session, wb, wb_formulas, user: User | None, department_id: int, year: int | None,
replace: bool, filename: str = "", check_only: bool = False):
from .importer import ImportErrorWithDetail, ImportResult, _category_index, prepare_year
from .models import CostLine
ws = next(w for w in wb.worksheets if w.title in TEMPLATE_SHEETS)
wsf = wb_formulas[ws.title]
def cell(r: int, c: int):
# Excel stores the computed value of =4*300; files written by other tools may only hold the formula.
v = ws.cell(r, c).value
return wsf.cell(r, c).value if v is None else v
labels = {**{_norm(t["year"]): "year" for t in TEXT.values()}, **{_norm(t["rate"]): "rate" for t in TEXT.values()}}
header_row, meta = None, {}
for r in range(1, 30):
a = _norm(ws.cell(r, 1).value)
if a in labels:
meta[labels[a]] = ws.cell(r, 2).value
if a in (_norm(t["cols"][0]) for t in TEXT.values()):
header_row = r
break
if header_row is None:
raise ImportErrorWithDetail("template_header_missing")
target_year = year or (int(meta["year"]) if isinstance(meta.get("year"), (int, float)) else None)
if not target_year:
raise ImportErrorWithDetail("year_not_found")
rate_value = meta.get("rate")
try:
rate = evaluate(str(rate_value)) / 100 if rate_value not in (None, "") else Decimal(0)
except ExpressionError:
rate = Decimal(0)
result = ImportResult(year=target_year)
if check_only:
return result
by = prepare_year(db, department_id, target_year, rate, replace, user, filename)
cats = _category_index(db, department_id)
sites = {_norm(s.name): s for s in db.scalars(select(Site))}
adj_kinds = {_norm(label): kind for t in TEXT.values() for kind, label in t["adj"].items()}
adj_kinds.update({"%": "percent", "factor ×": "factor", "fator ×": "factor"})
group_words = {_norm(t["group"]) for t in TEXT.values()}
next_order = (db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == department_id)) or 0) + 10
order = 0
for r in range(header_row + 1, ws.max_row + 1):
row = [cell(r, c) for c in range(1, 24)]
cat_label, name = str(row[0] or "").strip(), str(row[1] or "").strip()
if not cat_label and not name:
continue
where = f"{ws.title} row {r}"
if not cat_label or not name:
result.warnings.append(f"{where}: category and line are required, row skipped")
continue
cat = cats.get(cat_label.lower())
if cat is None:
cat = Category(department_id=department_id, name_en=cat_label, name_pt=cat_label, section="cost",
sort_order=next_order)
next_order += 10
db.add(cat)
db.flush()
cats[cat_label.lower()] = cat
result.categories_created.append(cat_label)
site = None
if row[3] not in (None, "") and _norm(row[3]) not in group_words:
site = sites.get(_norm(row[3]))
if site is None:
result.warnings.append(f"{where}: unknown site '{row[3]}', saved as group")
prior_expr, prior = _amount(row[4], result.warnings, where)
base_expr, base = _amount(row[5], result.warnings, where)
kind = adj_kinds.get(_norm(row[6]), "year_rate") if row[6] not in (None, "") else "year_rate"
_, adj_value = _amount(row[7], result.warnings, where)
months = row[10:22]
manual = any(m not in (None, "") for m in months)
month_values = [float(_amount(m, result.warnings, where)[1]) for m in months] if manual else None
selected = None
if not manual and row[22] not in (None, ""):
selected = parse_months(row[22])
if not selected:
result.warnings.append(f"{where}: could not read the months '{row[22]}', split over 12")
order += 10
db.add(CostLine(
year_id=by.id, category_id=cat.id, site_id=site.id if site else None, name=name,
description=str(row[2] or ""), remarks=str(row[9] or ""), prior_expr=prior_expr, prior_actual=prior,
base_expr=base_expr, base_amount=base, adj_kind="none" if manual else kind, adj_value=adj_value,
adj_reason=str(row[8] or ""), monthly_values=month_values, monthly_months=selected,
monthly_kind="manual" if manual else "selected" if selected else "even",
sort_order=order))
result.lines += 1
record(db, user, "year", by.id, "import", f"Imported {result.lines} lines from template into {target_year}",
year_id=by.id)
db.commit()
return result