Unzip budget-app v7 into repo root

This commit is contained in:
jpmvaz
2026-10-29 20:40:02 +00:00
parent 2554e68315
commit c9fd26f1c7
94 changed files with 12098 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
"""Imports a workbook laid out like 'Budget para 2027.xlsx'.
Sheet1 holds one row per cost line (prior actual, base, adjusted, remarks); the 'Budget IT'
sheet says which category each row belongs to (column P = '=Sheet1!D<row>') and holds the
revenue lines with typed monthly values.
"""
import os
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal
from io import BytesIO
from zoneinfo import ZoneInfo
import openpyxl
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .audit import record
from .calc import ExpressionError, evaluate
from .models import BudgetYear, Category, CostLine, Site, User
REF_RE = re.compile(r"^=\s*'?Sheet1'?!\$?D\$?(\d+)\s*$", re.I)
FACTOR_RE = re.compile(r"^=\s*\$?C\$?\d+\s*\*\s*([\d.]+)\s*$", re.I)
ARITH_RE = re.compile(r"^=?[\d\s.+\-*/()]+$")
HEADER_RE = re.compile(r"^(\d{4})\s+(ATUAL|ACTUAL|SEM AJUSTE|COM AJUSTE)", re.I)
# Reasons the budget owner gave for factors that differ from the year's rate.
KNOWN_REASONS = {
"hardware": "Aumento de preço do hardware / Hardware price increase",
}
class ImportErrorWithDetail(ValueError):
pass
@dataclass
class ImportResult:
year: int
lines: int = 0
categories_created: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def _sheet(wb, *names):
for ws in wb.worksheets:
if ws.title.strip().lower() in [n.lower() for n in names]:
return ws
return None
def _amount(formula_cell, value_cell, warnings: list[str], where: str) -> tuple[str, Decimal]:
"""Keep a pure-arithmetic formula as the typed expression; otherwise take Excel's cached value."""
raw = formula_cell.value
if raw is None or raw == "":
return "0", Decimal("0")
if isinstance(raw, (int, float)):
return str(raw), Decimal(str(raw))
text = str(raw).strip()
if ARITH_RE.match(text):
try:
return text.lstrip("="), evaluate(text)
except ExpressionError:
pass
cached = value_cell.value
if isinstance(cached, (int, float)):
return str(round(cached, 6)), Decimal(str(cached))
warnings.append(f"{where}: could not read '{text}', used 0")
return "0", Decimal("0")
def _category_index(db: Session, department_id: int) -> dict[str, Category]:
cats = db.scalars(select(Category).where(Category.department_id == department_id))
index = {}
for c in cats:
index[c.name_en.strip().lower()] = c
if c.name_pt:
index.setdefault(c.name_pt.strip().lower(), c)
return index
def current_year(db: Session, department_id: int, year: int) -> BudgetYear | None:
return db.scalar(select(BudgetYear).where(BudgetYear.department_id == department_id, BudgetYear.year == year,
BudgetYear.archived.is_(False)))
def archived_name(text: str, when: datetime) -> str:
"""'Orcamento_IT_2027.xlsx' -> 'Orcamento_IT_2027_2026-10-28_2250.xlsx'; titles get ' (2026-10-28 22:50)'."""
if "." in text and text.rsplit(".", 1)[1].lower() in ("xlsx", "xlsm", "xls"):
stem, ext = text.rsplit(".", 1)
return f"{stem}_{when:%Y-%m-%d_%H%M}.{ext}"
return f"{text} ({when:%Y-%m-%d %H:%M})"
def archive_year(db: Session, existing: BudgetYear, user: User | None) -> None:
"""Keep a replaced budget, renamed with the current date and time and locked against changes."""
when = datetime.now(ZoneInfo(os.getenv("TZ") or "Europe/Lisbon"))
old_title, old_file = existing.title, existing.source_file
existing.title = archived_name(existing.title or f"Budget {existing.year}", when)[:200]
if existing.source_file:
existing.source_file = archived_name(existing.source_file, when)[:255]
existing.archived, existing.archived_at = True, datetime.now(timezone.utc)
existing.archived_by_id = user.id if user else None
db.flush()
record(db, user, "year", existing.id, "archive",
f"{existing.department.name} {existing.year}: '{old_title}' → '{existing.title}'"
+ (f" ({old_file} → {existing.source_file})" if old_file else ""), year_id=existing.id)
def prepare_year(db: Session, department_id: int, year: int, rate: Decimal, replace: bool,
user: User | None = None, source_file: str = "") -> BudgetYear:
existing = current_year(db, department_id, year)
if existing and not replace:
raise ImportErrorWithDetail("year_exists")
if existing and existing.status != "draft":
raise ImportErrorWithDetail("year_not_draft")
if existing:
archive_year(db, existing, user)
by = BudgetYear(department_id=department_id, year=year, default_rate=rate, source_file=source_file[:255],
title=existing_title(existing, year), notes=existing.notes if existing else "")
db.add(by)
db.flush()
return by
def existing_title(existing: BudgetYear | None, year: int) -> str:
if existing is None:
return f"Budget {year}"
# the replacement keeps the original title, without the archive date
title = existing.title
return title[: title.rfind(" (")] if title.endswith(")") and " (" in title else title
def import_workbook(db: Session, data: bytes, user: User | None, department_id: int, year: int | None = None,
replace: bool = False, filename: str = "", check_only: bool = False) -> ImportResult:
"""Imports the file into a new budget. With check_only, only reads which year it is for."""
try:
wb_f = openpyxl.load_workbook(BytesIO(data))
wb_v = openpyxl.load_workbook(BytesIO(data), data_only=True)
except Exception as exc: # openpyxl raises several unrelated types for a bad file
raise ImportErrorWithDetail("not_an_xlsx") from exc
from .template import TEMPLATE_SHEETS, import_template
if any(ws.title in TEMPLATE_SHEETS for ws in wb_v.worksheets):
return import_template(db, wb_v, wb_f, user, department_id, year, replace, filename, check_only)
inputs_f, inputs_v = _sheet(wb_f, "Sheet1", "Input", "Inputs"), _sheet(wb_v, "Sheet1", "Input", "Inputs")
budget_f = _sheet(wb_f, "Budget IT", "Budget")
if inputs_f is None or budget_f is None:
raise ImportErrorWithDetail("missing_sheets")
budget_v = wb_v[budget_f.title]
# The target year comes from the header row ("2026 ATUAL | 2027 SEM AJUSTE | 2027 COM AJUSTE").
detected = None
for row in inputs_f.iter_rows(min_row=1, max_row=10):
for cell in row:
m = HEADER_RE.match(str(cell.value or ""))
if m and "AJUSTE" in m.group(2).upper():
detected = int(m.group(1))
target_year = year or detected
if not target_year:
raise ImportErrorWithDetail("year_not_found")
rate = inputs_v["B1"].value if isinstance(inputs_v["B1"].value, (int, float)) else 0
rate = Decimal(str(rate))
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)
next_order = (db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == department_id)) or 0) + 10
sites = list(db.scalars(select(Site)))
def category(name: str) -> Category:
nonlocal next_order
key = name.strip().lower()
if key not in cats:
cat = Category(department_id=department_id,
name_en=name.strip().title() if name.isupper() else name.strip(),
section="revenue" if "revenue" in key else "cost", sort_order=next_order)
next_order += 10
db.add(cat)
db.flush()
cats[key] = cat
result.categories_created.append(cat.name_en)
return cats[key]
def site_for(name: str):
for s in sites:
if re.search(rf"\b{re.escape(s.name)}\b", name, re.I):
return s
return None
# Walk 'Budget IT': headings, then lines that point at Sheet1 or hold typed monthly values.
current = None
order = 0
for r in range(1, budget_f.max_row + 1):
label = budget_f.cell(r, 1).value
p = budget_f.cell(r, 16).value
months_f = [budget_f.cell(r, c).value for c in range(3, 15)]
if not isinstance(label, str) or not label.strip() or label.strip().startswith("…"):
continue
label = label.strip()
if label.lower().startswith("sub total"):
current = None
continue
if p is None and all(v is None for v in months_f):
if r > 9:
current = category(label)
continue
if current is None:
continue
order += 10
m = REF_RE.match(str(p or ""))
if m:
sr = int(m.group(1))
short = inputs_f.cell(sr, 1).value
name = str(short).strip() if short else label
where = f"Sheet1 row {sr}"
prior_expr, prior = _amount(inputs_f.cell(sr, 2), inputs_v.cell(sr, 2), result.warnings, where)
base_expr, base = _amount(inputs_f.cell(sr, 3), inputs_v.cell(sr, 3), result.warnings, where)
d_formula = str(inputs_f.cell(sr, 4).value or "")
fm = FACTOR_RE.match(d_formula)
if fm:
factor = Decimal(fm.group(1))
else:
d_val = inputs_v.cell(sr, 4).value
factor = (Decimal(str(d_val)) / base) if base and isinstance(d_val, (int, float)) else Decimal(1)
if factor == 1 + rate:
adj_kind, adj_value, reason = "year_rate", Decimal(0), ""
else:
adj_kind, adj_value = "factor", factor
reason = KNOWN_REASONS.get(current.name_en.lower(), "") if factor >= 2 else ""
line = CostLine(
year_id=by.id, category_id=current.id, site_id=(s.id if (s := site_for(label + " " + name)) else None),
name=name, description=label if label != name else "",
remarks=str(inputs_f.cell(sr, 5).value or ""),
prior_expr=prior_expr, prior_actual=prior, base_expr=base_expr, base_amount=base,
adj_kind=adj_kind, adj_value=adj_value, adj_reason=reason, sort_order=order)
else:
values = []
for c in range(3, 15):
v = budget_v.cell(r, c).value
values.append(float(v) if isinstance(v, (int, float)) else 0.0)
line = CostLine(year_id=by.id, category_id=current.id, name=label, monthly_kind="manual",
monthly_values=values, adj_kind="none", sort_order=order)
db.add(line)
result.lines += 1
record(db, user, "year", by.id, "import", f"Imported {result.lines} lines into {target_year}", year_id=by.id)
db.commit()
return result