"""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