Unzip budget-app v7 into repo root
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user