317 lines
15 KiB
Python
317 lines
15 KiB
Python
"""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
|