From 6287f271891bc5c852ba0601aa78ee804cff8b62 Mon Sep 17 00:00:00 2001 From: Norair Arutshyan Date: Wed, 23 Sep 2026 17:11:17 +0100 Subject: [PATCH] feat(export): implement Sheet 2 (Budget vs. Report Dashboard) with donor-currency-first layout Refactors Sheet 1 into an OriginalBudgetSheet(_SheetWriter) subclass and adds DashboardSheet with a funding ledger, report summary, and per-line detail section blending real per-allocation conversion rates with the estimated rate for any unsatisfied remainder (flagged with estimate styling). Co-Authored-By: Claude Sonnet 5 --- .../budget-feat-313-excel-export/design.md | 5 + .../budget-feat-313-excel-export/tasks.md | 14 +- services/budget/app/crud/excel_export_crud.py | 80 ++ .../app/services/excel_export_service.py | 1134 ++++++++++++----- .../budget/tests/test_excel_export_crud.py | 141 ++ .../budget/tests/test_excel_export_service.py | 325 ++++- 6 files changed, 1374 insertions(+), 325 deletions(-) create mode 100644 services/budget/app/crud/excel_export_crud.py create mode 100644 services/budget/tests/test_excel_export_crud.py diff --git a/openspec/changes/budget-feat-313-excel-export/design.md b/openspec/changes/budget-feat-313-excel-export/design.md index 6cf0d48..f15f515 100644 --- a/openspec/changes/budget-feat-313-excel-export/design.md +++ b/openspec/changes/budget-feat-313-excel-export/design.md @@ -71,6 +71,11 @@ Group 1's Sheet 1 implementation threads the same handful of values (`ws`, `plan *Alternative considered*: one class per donor-selectable template (each a bespoke layout), inheriting a shared parent. Rejected — same trap as Decision 9's declarative-engine alternative: unbounded, hard to review, and duplicates the single-rendering-path guarantee Decision 10 exists to give. Templates still only vary bounded options over a fixed, closed set of sheet renderers. *Timing*: introduced in group 2 (converting Sheet 1's free functions into `OriginalBudgetSheet` alongside building `DashboardSheet`), not group 1. A base class guessed from one caller is unproven; group 2 gives two real sheets to validate what's actually shared before group 3 adds a third. +**13. Sheet 2 is donor-currency-first throughout, not local-currency-first: revised against a hand-edited reference file (`uploads/budget/Demo Budget 10 (3).xlsx`) after group 2's initial pass.** +Donors don't reason in local currency (per user feedback), so every comparison figure — "Original", "Total Expenses", "Deviation" — is in `actual_currency`, not `local_currency`; the local-currency columns ("Planned", "Expenses") are shown alongside but are not what deviation is computed against. Concretely, Sheet 2 now has: the same header block as Sheet 1 (rows 1-6); an Approved/Balance summary (Approved Total — Sheet 1's total expenditures in donor currency, computed the same way as Sheet 1's own footer, not a live cross-sheet formula; Approved on — `budget.confirmed_at`; Received/Converted Totals and their percentages; Current Balance in both currencies — Received − Converted (donor), and converted-to-local-but-unspent (local)); a **Funding Ledger** merging `FundingReceiptModel` and `CurrencyConversionModel` rows into one date-ordered table (a receipt sorts before a conversion sharing its date), replacing group 2's original conversions-only "INCOME" section; a **Report Summary** section mirroring Sheet 1's Budget-Summary-vs-Detailed-Budget split — one row per category referencing its own detail-subtotal row, plus a TOTAL row — followed by the per-line/category detail (columns: Original (donor) / Planned (local) / Expenses (local) / Total Expenses (donor) / Deviation (donor), the last a formula `=Original−TotalExpenses`); and a footer (Refund to donor = Received Total − Report Summary's Total Expenses grand total; a "Place, date:" line; side-by-side Authorised Signatory / Project Contact Person signature lines; the audit line). The estimated-portion cell style (Decision 4) still applies, now to the Total Expenses column. +*Deferred*: the reference file's "Avg rate" idea (a single blended rate to normalize the Funding Ledger and replace its per-row literals with formulas) is explicitly punted to group 3 (List of Expenses), per the user: "leave it for now, we will come back to this, whenever we build list of expenses page." +*Verification*: the implementation was diff-checked cell-by-cell against the reference file (matching except for the user's own placeholder/typo cells and float-precision artifacts from hand-typing) and round-tripped through `soffice --headless` to confirm the formulas compute, not just parse — see Decision 5's same verification approach. + ## Risks / Trade-offs - [A budget with many report lines/allocations could make generation slow] → Out of scope for a "simple export" of one budget; single-budget expense volume in practice is small (tens to low hundreds of lines), revisit only if real usage shows otherwise. diff --git a/openspec/changes/budget-feat-313-excel-export/tasks.md b/openspec/changes/budget-feat-313-excel-export/tasks.md index 2237d8d..b292cb6 100644 --- a/openspec/changes/budget-feat-313-excel-export/tasks.md +++ b/openspec/changes/budget-feat-313-excel-export/tasks.md @@ -9,15 +9,15 @@ Workflow rule: one task group = one GitHub sub-issue (of this change's parent is - [x] 1.2 Implement Sheet 1 generation: header block (org/donor name via `customer_client`, project name/period, estimated currency/rate), a Budget Summary section (its title row doubles as the column-header row; one row per category referencing its Detailed Budget subtotal cell, plus a `SUM`-formula TOTAL row), a Detailed Budget section (category header row, lines, then a `SUM`-formula "Subtotal" row — categories with zero lines still shown, at 0), and a footer (a "Total expenditures" row summing each category's subtotal cell, signature/contact-person lines); per-line local amount is the only literal value, every other figure is a formula, blank when `estimated_exchange_rate` is unset; column widths/number formats matched to a user-supplied reference file; verify with unit tests covering 2 categories, no `estimated_exchange_rate`, and an empty category, plus a LibreOffice headless round-trip confirming the formulas actually compute (not just parse) - [x] 1.3 Add `GET /budgets/{budget_id}/export.xlsx` to `services/budget/app/api/budget_routes.py`, authorized the same way as `GET /budgets/{budget_id}` (owner or funder), returning a `StreamingResponse` (mirroring `attachment_routes.py`'s pattern) with the correct `Content-Type`/`Content-Disposition`; verify with an integration test that owner and funder both get 200 and a non-owner/non-funder gets rejected - [x] 1.4 Wire the new route into `nginx-dev.conf`, `nginx.conf`, and `Caddyfile`; verify by confirming the route pattern matches the existing `/budgets/{budget_id}/...` entries in all three files (no changes needed — all three already proxy the whole `/api/v1/budgets/` prefix as a catch-all block, so `export.xlsx` is already covered) -- [ ] 1.5 Run backend lint/tests clean for `services/budget`; PR merged (`Closes` this group's sub-issue) +- [x] 1.5 Run backend lint/tests clean for `services/budget`; PR merged (`Closes` this group's sub-issue) -## 2. Sheet 2 — Budget vs. Report Dashboard — depends on 1 +## 2. Sheet 2 — Budget vs. Report Dashboard — depends on 1 — Issue #318 -- [ ] 2.1 Introduce a `_SheetWriter` base class in `excel_export_service.py` holding the shared cell-writing helpers (`_bold_row`, `_set_cell`, `_apply_box_border`, currency formatting) as methods, and refactor Sheet 1's existing free functions into an `OriginalBudgetSheet(_SheetWriter)` subclass with a `write()` entry point — pure refactor, no output change; verify group 1's existing Sheet 1 tests pass unchanged (see design.md Decision 12) -- [ ] 2.2 Add `services/budget/app/crud/excel_export_crud.py` with a per-budget-line rollup query (join `budget_lines` → `report_lines`, grouped by `budget_line_id`) returning each line's total local-currency expenses and its allocations' `(amount_allocated, conversion.donor_amount, conversion.local_amount)` tuples; verify with a unit test against a seeded budget with lines spanning fully-allocated, partially-allocated, and zero-expense cases -- [ ] 2.3 Implement the converted-expense calculation in `excel_export_service.py`: real per-allocation rate for allocated amounts plus `estimated_exchange_rate` for any unsatisfied remainder, flagging a line as "includes estimate" when a remainder exists; verify with a unit test covering fully-allocated (no flag), partially-allocated (flagged, blended figure), and fully-unallocated (fully estimated, flagged) cases -- [ ] 2.4 Implement Sheet 2 as a `DashboardSheet(_SheetWriter)` subclass: an income section (one row per `CurrencyConversion` for the budget — converted date, donor amount, local amount, implied rate — plus a total row); verify with a unit test using a budget with one funding receipt and multiple conversions, asserting one row per conversion -- [ ] 2.5 Add `DashboardSheet`'s per-budget-line and category-subtotal rows (expenses local, expenses converted, deviation), reusing Sheet 1's category grouping/order and applying the estimated-portion cell style (italic + fill, per design.md Decision 4) where flagged; verify with a unit test asserting column values and that flagged cells carry the style +- [x] 2.1 Introduce a `_SheetWriter` base class in `excel_export_service.py` holding the shared cell-writing helpers (`_bold_row`, `_set_cell`, `_apply_box_border`, currency formatting) as methods, and refactor Sheet 1's existing free functions into an `OriginalBudgetSheet(_SheetWriter)` subclass with a `write()` entry point — pure refactor, no output change; verify group 1's existing Sheet 1 tests pass unchanged (see design.md Decision 12) +- [x] 2.2 Add `services/budget/app/crud/excel_export_crud.py` with a per-budget-line rollup query (join `budget_lines` → `report_lines`, grouped by `budget_line_id`) returning each line's total local-currency expenses and its allocations' `(amount_allocated, conversion.donor_amount, conversion.local_amount)` tuples; verify with a unit test against a seeded budget with lines spanning fully-allocated, partially-allocated, and zero-expense cases +- [x] 2.3 Implement the converted-expense calculation in `excel_export_service.py`: real per-allocation rate for allocated amounts plus `estimated_exchange_rate` for any unsatisfied remainder, flagging a line as "includes estimate" when a remainder exists; verify with a unit test covering fully-allocated (no flag), partially-allocated (flagged, blended figure), and fully-unallocated (fully estimated, flagged) cases +- [x] 2.4 Implement Sheet 2 as a `DashboardSheet(_SheetWriter)` subclass: an income section (one row per `CurrencyConversion` for the budget — converted date, donor amount, local amount, implied rate — plus a total row); verify with a unit test using a budget with one funding receipt and multiple conversions, asserting one row per conversion — **superseded**: revised into the Funding Ledger/Report Summary/donor-currency-first design in Decision 13 after a user-supplied reference file; the income-section shape described here is not what shipped +- [x] 2.5 Add `DashboardSheet`'s per-budget-line and category-subtotal rows (expenses local, expenses converted, deviation), reusing Sheet 1's category grouping/order and applying the estimated-portion cell style (italic + fill, per design.md Decision 4) where flagged; verify with a unit test asserting column values and that flagged cells carry the style — **superseded**: see Decision 13; deviation is donor-currency (Original − Total Expenses), not local-currency - [ ] 2.6 Run backend lint/tests clean for `services/budget`; PR merged (`Closes` this group's sub-issue) ## 3. Sheet 3 — List of Expenses with per-allocation sublines — depends on 1 diff --git a/services/budget/app/crud/excel_export_crud.py b/services/budget/app/crud/excel_export_crud.py new file mode 100644 index 0000000..18e3b5b --- /dev/null +++ b/services/budget/app/crud/excel_export_crud.py @@ -0,0 +1,80 @@ +from dataclasses import dataclass, field +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.budget import BudgetLineModel +from app.models.currency_ledger import CurrencyConversionModel, ReportLineConversionAllocationModel +from app.models.report import ReportLineModel + + +@dataclass +class AllocationRollup: + """One report line's expense funded by one currency-conversion lot.""" + + amount_allocated: float + conversion_donor_amount: float + conversion_local_amount: float + + +@dataclass +class BudgetLineExpenseRollup: + """A budget line's total local-currency expenses plus funding allocations.""" + + budget_line_id: UUID + total_local_amount: float + allocations: list[AllocationRollup] = field(default_factory=list) + + +async def get_budget_line_expense_rollups( + session: AsyncSession, budget_id: UUID +) -> dict[UUID, BudgetLineExpenseRollup]: + """One rollup per budget line of `budget_id`, keyed by budget_line_id.""" + totals_sq = ( + select( + ReportLineModel.budget_line_id.label("budget_line_id"), + func.sum(ReportLineModel.amount).label("total_local_amount"), + ) + .group_by(ReportLineModel.budget_line_id) + .subquery() + ) + totals_result = await session.execute( + select(BudgetLineModel.id, func.coalesce(totals_sq.c.total_local_amount, 0.0)) + .outerjoin(totals_sq, totals_sq.c.budget_line_id == BudgetLineModel.id) + .where(BudgetLineModel.budget_id == budget_id) + ) + rollups = { + line_id: BudgetLineExpenseRollup(budget_line_id=line_id, total_local_amount=total) + for line_id, total in totals_result.all() + } + if not rollups: + return rollups + + allocations_result = await session.execute( + select( + ReportLineModel.budget_line_id, + ReportLineConversionAllocationModel.amount_allocated, + CurrencyConversionModel.donor_amount, + CurrencyConversionModel.local_amount, + ) + .join( + ReportLineModel, + ReportLineModel.id == ReportLineConversionAllocationModel.report_line_id, + ) + .join( + CurrencyConversionModel, + CurrencyConversionModel.id == ReportLineConversionAllocationModel.conversion_id, + ) + .where(ReportLineModel.budget_line_id.in_(rollups.keys())) + ) + for budget_line_id, amount_allocated, donor_amount, local_amount in allocations_result.all(): + rollups[budget_line_id].allocations.append( + AllocationRollup( + amount_allocated=amount_allocated, + conversion_donor_amount=donor_amount, + conversion_local_amount=local_amount, + ) + ) + + return rollups diff --git a/services/budget/app/services/excel_export_service.py b/services/budget/app/services/excel_export_service.py index 956e911..cae1e8c 100644 --- a/services/budget/app/services/excel_export_service.py +++ b/services/budget/app/services/excel_export_service.py @@ -1,4 +1,5 @@ import io +from dataclasses import dataclass from datetime import date, datetime, timezone from uuid import UUID @@ -8,16 +9,27 @@ from app.crud.budget_category_crud import list_budget_categories from app.crud.budget_line_crud import list_budget_lines +from app.crud.currency_conversion_crud import FLOAT_EPSILON, list_currency_conversions +from app.crud.excel_export_crud import ( + BudgetLineExpenseRollup, + get_budget_line_expense_rollups, +) +from app.crud.funding_receipt_crud import list_funding_receipts from app.models.budget import BudgetCategoryModel, BudgetLineModel, BudgetModel +from app.models.currency_ledger import CurrencyConversionModel, FundingReceiptModel from app.services.budget_services import _add_duration_months, get_viewable_budget_service from app.services.customer_client import CustomerServiceError, get_customer_cached from app.services.user_cache import get_users_by_ids_cached SHEET1_TITLE = "Original Budget" +SHEET2_TITLE = "Budget vs. Report Dashboard" _BOLD = Font(bold=True) _AUDIT_FONT = Font(italic=True, size=9, color="808080") _TOTAL_FILL = PatternFill(start_color="D9D9D9", end_color="D9D9D9", fill_type="solid") _TOP_BORDER = Border(top=Side(style="thin")) +_BOTTOM_BORDER = Border(bottom=Side(style="thin")) +_ESTIMATE_CELL_FONT = Font(italic=True) +_ESTIMATE_CELL_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") _DESCRIPTION_COL_WIDTH = 26.63 _EXTRA_COL_WIDTH = 20.0 _AMOUNT_COL_WIDTH = 17.77 @@ -25,6 +37,10 @@ _HEADER_ROW_COUNT = 6 _RATE_ROW = _HEADER_ROW_COUNT _DESCRIPTION_COL = 1 +_RATE_FORMAT = "0.0000" +_DATE_FORMAT = "yyyy-mm-dd" +_PERCENT_FORMAT = "0.0%" +_DASHBOARD_COL_WIDTHS = {1: 26.63, 2: 18.0, 3: 21.0, 4: 21.0, 5: 23.0, 6: 18.0} async def export_budget_workbook_service( @@ -35,6 +51,9 @@ async def export_budget_workbook_service( budget = await get_viewable_budget_service(budget_id, valid_user, db) categories = await list_budget_categories(db, budget_id=budget_id, limit=None) lines = await list_budget_lines(db, budget_id=budget_id, limit=None) + conversions = await list_currency_conversions(db, budget_id=budget_id) + receipts = await list_funding_receipts(db, budget_id=budget_id) + rollups = await get_budget_line_expense_rollups(db, budget_id=budget_id) organisation_name = None try: organisation_name = (await get_customer_cached(budget.owner_id)).get("name") @@ -59,6 +78,9 @@ async def export_budget_workbook_service( budget, categories, lines, + conversions=conversions, + receipts=receipts, + rollups=rollups, organisation_name=organisation_name, donor_name=donor_name, exported_by=exported_by, @@ -70,19 +92,37 @@ def generate_budget_export_workbook( budget: BudgetModel, categories: list[BudgetCategoryModel], lines: list[BudgetLineModel], + conversions: list[CurrencyConversionModel] | None = None, + receipts: list[FundingReceiptModel] | None = None, + rollups: dict[UUID, BudgetLineExpenseRollup] | None = None, organisation_name: str | None = None, donor_name: str | None = None, exported_by: str | None = None, exported_at: datetime | None = None, ) -> bytes: - """Builds the export workbook for one budget. Group 1 populates only - Sheet 1 (Original Budget); Sheets 2/3 land in later task groups.""" + """Builds the export workbook for one budget. Sheet 3 (List of Expenses) + lands in group 3; group 6 makes sheet selection template-driven.""" wb = Workbook() - ws = wb.active - ws.title = SHEET1_TITLE - _write_sheet1( - ws, budget, categories, lines, organisation_name, donor_name, exported_by, exported_at - ) + sheet1 = wb.active + sheet1.title = SHEET1_TITLE + OriginalBudgetSheet( + sheet1, budget, categories, lines, organisation_name, donor_name, exported_by, exported_at + ).write() + + sheet2 = wb.create_sheet(SHEET2_TITLE) + DashboardSheet( + sheet2, + budget, + organisation_name, + donor_name, + receipts or [], + conversions or [], + categories, + lines, + rollups or {}, + exported_by, + exported_at, + ).write() buf = io.BytesIO() wb.save(buf) @@ -98,120 +138,42 @@ def _period_label(start_date: date | None, duration_months: int | None) -> str | return f"{start_date.strftime('%m/%Y')}-{end_date.strftime('%m/%Y')}" -def _currency_format(currency: str | None) -> str: - return f'#,##0.00" {currency}"' if currency else "#,##0.00" - - -def _bold_row(ws, row: int) -> None: - for cell in ws[row]: - cell.font = _BOLD - - -def _apply_box_border(ws, start_row: int, end_row: int, end_col: int) -> None: - thin = Side(style="thin") - for row in range(start_row, end_row + 1): - for column in range(1, end_col + 1): - ws.cell(row=row, column=column).border = Border( - top=thin if row == start_row else None, - bottom=thin if row == end_row else None, - left=thin if column == 1 else None, - right=thin if column == end_col else None, +@dataclass +class LineExpenseConversion: + converted_donor_amount: float + is_estimated: bool + + +def _compute_converted_expense( + rollup: BudgetLineExpenseRollup, estimated_exchange_rate: float +) -> LineExpenseConversion: + """Real per-allocation rate for allocated amounts, `estimated_exchange_rate` + for any unsatisfied remainder (design.md Decision 2).""" + allocated_total = 0.0 + converted = 0.0 + for allocation in rollup.allocations: + allocated_total += allocation.amount_allocated + if allocation.conversion_local_amount: + converted += ( + allocation.amount_allocated + * allocation.conversion_donor_amount + / allocation.conversion_local_amount ) + remainder = rollup.total_local_amount - allocated_total + is_estimated = remainder > FLOAT_EPSILON + if is_estimated: + converted += remainder / estimated_exchange_rate + return LineExpenseConversion(converted_donor_amount=converted, is_estimated=is_estimated) -def _extra_field_keys(lines: list[BudgetLineModel]) -> list[str]: - """Distinct extra_fields keys across the budget, in first-seen order.""" - keys: dict = {} - for line in lines: - for key in line.extra_fields or {}: - keys[key] = None - return list(keys) - - -def _amount_columns(extra_keys: list[str]) -> dict: - """Amount/Estimate shift right by one column per distinct extra field key.""" - amount_col = _DESCRIPTION_COL + 1 + len(extra_keys) - estimate_col = amount_col + 1 - return { - "amount_col": amount_col, - "estimate_col": estimate_col, - "amount_letter": get_column_letter(amount_col), - "estimate_letter": get_column_letter(estimate_col), - } - - -def _plan_rows(ordered_category_ids: list, lines_by_category_id: dict) -> dict: - """Computes every row number up front — Budget Summary formulas reference - Detailed Budget's subtotal rows before those rows are written.""" - summary_header_row = _RATE_ROW + 3 - summary_rows = {} - row = summary_header_row - for category_id in ordered_category_ids: - row += 1 - summary_rows[category_id] = row - summary_total_row = row + 1 if ordered_category_ids else summary_header_row + 1 - - detail_title_row = summary_total_row + 2 - detail_header_rows: dict = {} - detail_line_rows: dict = {} - detail_subtotal_rows: dict = {} - row = detail_title_row - for category_id in ordered_category_ids: - row += 1 - detail_header_rows[category_id] = row - line_rows = [] - for _ in lines_by_category_id.get(category_id, []): - row += 1 - line_rows.append(row) - detail_line_rows[category_id] = line_rows - row += 1 - detail_subtotal_rows[category_id] = row - row += 1 # blank separator after the category block - - footer_total_row = row + 2 if ordered_category_ids else detail_title_row + 2 - signature_line_row = footer_total_row + 3 - contact_line_row = signature_line_row + 4 - audit_row = contact_line_row + 3 - - return { - "summary_header_row": summary_header_row, - "summary_rows": summary_rows, - "summary_total_row": summary_total_row, - "detail_title_row": detail_title_row, - "detail_header_rows": detail_header_rows, - "detail_line_rows": detail_line_rows, - "detail_subtotal_rows": detail_subtotal_rows, - "footer_total_row": footer_total_row, - "signature_line_row": signature_line_row, - "contact_line_row": contact_line_row, - "audit_row": audit_row, - } - - -def _write_sheet1( - ws, - budget: BudgetModel, - categories: list[BudgetCategoryModel], - lines: list[BudgetLineModel], - organisation_name: str | None, - donor_name: str | None, - exported_by: str | None, - exported_at: datetime | None, -) -> None: - has_rate = bool(budget.estimated_exchange_rate) - rate_cell = _write_header(ws, budget, organisation_name, donor_name) - - local_fmt = _currency_format(budget.local_currency) - estimate_fmt = _currency_format(budget.actual_currency) - amount_header = f"Amount ({budget.local_currency})" if budget.local_currency else "Amount" - estimate_header = ( - f"Estimate ({budget.actual_currency})" if budget.actual_currency else "Donor Estimate" - ) - +def _group_lines_by_category( + categories: list[BudgetCategoryModel], lines: list[BudgetLineModel] +) -> tuple[list[UUID | None], dict[UUID | None, str], dict[UUID | None, list[BudgetLineModel]]]: + """Ordered category ids, display names, and lines — shared by Sheet 1/2.""" categories_by_id: dict[UUID | None, BudgetCategoryModel] = { category.id: category for category in categories } - lines_by_category_id: dict = {} + lines_by_category_id: dict[UUID | None, list[BudgetLineModel]] = {} for line in lines: category = categories_by_id.get(line.category_id) lines_by_category_id.setdefault(category.id if category else None, []).append(line) @@ -226,14 +188,231 @@ def _write_sheet1( for cid in ordered_category_ids } category_lines = {cid: lines_by_category_id.get(cid, []) for cid in ordered_category_ids} - extra_keys = _extra_field_keys(lines) - cols = _amount_columns(extra_keys) - _set_column_widths(ws, extra_keys, cols) + return ordered_category_ids, category_names, category_lines + + +def _audit_line(exported_by: str | None, exported_at: datetime | None) -> str: + parts = ["Generated by OpenGrantFlow"] + if exported_by: + parts.append(exported_by) + if exported_at: + parts.append(exported_at.strftime("%Y-%m-%d %H:%M UTC")) + return " · ".join(parts) - plan = _plan_rows(ordered_category_ids, lines_by_category_id) - _write_budget_summary( +class _SheetWriter: + """Shared cell-writing helpers for one worksheet (see design.md Decision 12).""" + + def __init__( + self, + ws, + budget: BudgetModel, + organisation_name: str | None, + donor_name: str | None, + ) -> None: + self.ws = ws + self.budget = budget + self.organisation_name = organisation_name + self.donor_name = donor_name + + def _write_header(self) -> str: + """Writes the header block (rows 1-6) and returns the rate cell ref for formulas.""" + budget = self.budget + fields = ( + ("Organisation Name", self.organisation_name), + ("Donor Name", self.donor_name), + ("Project Name", budget.name), + ("Project Period", _period_label(budget.start_date, budget.duration_months)), + ("Budget Original Currency", budget.actual_currency), + ("Estimated Exchange Rate", budget.estimated_exchange_rate), + ) + for row, (label, value) in enumerate(fields, start=1): + self.ws.cell(row=row, column=1, value=label) + self.ws.cell(row=row, column=2, value=value) + self._bold_row(row) + + return f"$B${_RATE_ROW}" + + @staticmethod + def _currency_format(currency: str | None) -> str: + return f'#,##0.00" {currency}"' if currency else "#,##0.00" + + def _bold_row(self, row: int) -> None: + for cell in self.ws[row]: + cell.font = _BOLD + + def _apply_box_border(self, start_row: int, end_row: int, end_col: int) -> None: + thin = Side(style="thin") + for row in range(start_row, end_row + 1): + for column in range(1, end_col + 1): + self.ws.cell(row=row, column=column).border = Border( + top=thin if row == start_row else None, + bottom=thin if row == end_row else None, + left=thin if column == 1 else None, + right=thin if column == end_col else None, + ) + + def _set_cell(self, row: int, column: int, value, number_format: str): + cell = self.ws.cell(row=row, column=column, value=value) + cell.number_format = number_format + return cell + + +class OriginalBudgetSheet(_SheetWriter): + """Sheet 1 — Original Budget: header block, Budget Summary, Detailed Budget, footer.""" + + def __init__( + self, ws, + budget: BudgetModel, + categories: list[BudgetCategoryModel], + lines: list[BudgetLineModel], + organisation_name: str | None, + donor_name: str | None, + exported_by: str | None, + exported_at: datetime | None, + ) -> None: + super().__init__(ws, budget, organisation_name, donor_name) + self.categories = categories + self.lines = lines + self.exported_by = exported_by + self.exported_at = exported_at + + def write(self) -> None: + budget = self.budget + has_rate = bool(budget.estimated_exchange_rate) + rate_cell = self._write_header() + + local_fmt = self._currency_format(budget.local_currency) + estimate_fmt = self._currency_format(budget.actual_currency) + amount_header = f"Amount ({budget.local_currency})" if budget.local_currency else "Amount" + estimate_header = ( + f"Estimate ({budget.actual_currency})" if budget.actual_currency else "Donor Estimate" + ) + + ordered_category_ids, category_names, category_lines = _group_lines_by_category( + self.categories, self.lines + ) + extra_keys = self._extra_field_keys() + cols = self._amount_columns(extra_keys) + self._set_column_widths(extra_keys, cols) + + plan = self._plan_rows(ordered_category_ids, category_lines) + + self._write_budget_summary( + plan, + ordered_category_ids, + category_names, + amount_header, + estimate_header, + extra_keys, + cols, + local_fmt, + estimate_fmt, + has_rate, + ) + self._write_detailed_budget( + plan, + ordered_category_ids, + category_names, + category_lines, + extra_keys, + cols, + local_fmt, + estimate_fmt, + has_rate, + rate_cell, + ) + self._write_footer( + plan, + ordered_category_ids, + cols, + local_fmt, + estimate_fmt, + has_rate, + ) + + def _extra_field_keys(self) -> list[str]: + """Distinct extra_fields keys across the budget, in first-seen order.""" + keys: dict = {} + for line in self.lines: + for key in line.extra_fields or {}: + keys[key] = None + return list(keys) + + def _amount_columns(self, extra_keys: list[str]) -> dict: + """Amount/Estimate shift right by one column per distinct extra field key.""" + amount_col = _DESCRIPTION_COL + 1 + len(extra_keys) + estimate_col = amount_col + 1 + return { + "amount_col": amount_col, + "estimate_col": estimate_col, + "amount_letter": get_column_letter(amount_col), + "estimate_letter": get_column_letter(estimate_col), + } + + def _plan_rows(self, ordered_category_ids: list, lines_by_category_id: dict) -> dict: + """Computes every row number up front — Budget Summary formulas reference + Detailed Budget's subtotal rows before those rows are written.""" + summary_header_row = _RATE_ROW + 3 + summary_rows = {} + row = summary_header_row + for category_id in ordered_category_ids: + row += 1 + summary_rows[category_id] = row + summary_total_row = row + 1 if ordered_category_ids else summary_header_row + 1 + + detail_title_row = summary_total_row + 2 + detail_header_rows: dict = {} + detail_line_rows: dict = {} + detail_subtotal_rows: dict = {} + row = detail_title_row + for category_id in ordered_category_ids: + row += 1 + detail_header_rows[category_id] = row + line_rows = [] + for _ in lines_by_category_id.get(category_id, []): + row += 1 + line_rows.append(row) + detail_line_rows[category_id] = line_rows + row += 1 + detail_subtotal_rows[category_id] = row + row += 1 # blank separator after the category block + + footer_total_row = row + 2 if ordered_category_ids else detail_title_row + 2 + signature_line_row = footer_total_row + 3 + contact_line_row = signature_line_row + 4 + audit_row = contact_line_row + 3 + + return { + "summary_header_row": summary_header_row, + "summary_rows": summary_rows, + "summary_total_row": summary_total_row, + "detail_title_row": detail_title_row, + "detail_header_rows": detail_header_rows, + "detail_line_rows": detail_line_rows, + "detail_subtotal_rows": detail_subtotal_rows, + "footer_total_row": footer_total_row, + "signature_line_row": signature_line_row, + "contact_line_row": contact_line_row, + "audit_row": audit_row, + } + + def _set_column_widths(self, extra_keys: list[str], cols: dict) -> None: + self.ws.column_dimensions[get_column_letter(_DESCRIPTION_COL)].width = ( + _DESCRIPTION_COL_WIDTH + ) + for i in range(len(extra_keys)): + self.ws.column_dimensions[get_column_letter(_DESCRIPTION_COL + 1 + i)].width = ( + _EXTRA_COL_WIDTH + ) + self.ws.column_dimensions[get_column_letter(cols["amount_col"])].width = _AMOUNT_COL_WIDTH + self.ws.column_dimensions[get_column_letter(cols["estimate_col"])].width = ( + _ESTIMATE_COL_WIDTH + ) + + def _write_budget_summary( + self, plan, ordered_category_ids, category_names, @@ -244,9 +423,49 @@ def _write_sheet1( local_fmt, estimate_fmt, has_rate, - ) - _write_detailed_budget( - ws, + ) -> None: + ws = self.ws + amount_col, estimate_col = cols["amount_col"], cols["estimate_col"] + amount_letter, estimate_letter = cols["amount_letter"], cols["estimate_letter"] + + header_row = plan["summary_header_row"] + ws.cell(row=header_row, column=1, value="BUDGET SUMMARY") + for i, key in enumerate(extra_keys): + ws.cell(row=header_row, column=_DESCRIPTION_COL + 1 + i, value=key) + ws.cell(row=header_row, column=amount_col, value=amount_header) + ws.cell(row=header_row, column=estimate_col, value=estimate_header) + self._bold_row(header_row) + + for category_id in ordered_category_ids: + row = plan["summary_rows"][category_id] + detail_subtotal_row = plan["detail_subtotal_rows"][category_id] + ws.cell(row=row, column=1, value=category_names[category_id]) + self._set_cell(row, amount_col, f"={amount_letter}{detail_subtotal_row}", local_fmt) + if has_rate: + self._set_cell( + row, estimate_col, f"={estimate_letter}{detail_subtotal_row}", estimate_fmt + ) + if ordered_category_ids: + first_summary_row = plan["summary_rows"][ordered_category_ids[0]] + last_summary_row = plan["summary_rows"][ordered_category_ids[-1]] + self._apply_box_border(first_summary_row, last_summary_row, estimate_col) + + total_row = plan["summary_total_row"] + ws.cell(row=total_row, column=1, value="TOTAL") + if ordered_category_ids: + first_row = plan["summary_rows"][ordered_category_ids[0]] + last_row = plan["summary_rows"][ordered_category_ids[-1]] + amount_range = f"{amount_letter}{first_row}:{amount_letter}{last_row}" + self._set_cell(total_row, amount_col, f"=SUM({amount_range})", local_fmt) + if has_rate: + estimate_range = f"{estimate_letter}{first_row}:{estimate_letter}{last_row}" + self._set_cell(total_row, estimate_col, f"=SUM({estimate_range})", estimate_fmt) + else: + self._set_cell(total_row, amount_col, 0.0, local_fmt) + self._bold_row(total_row) + + def _write_detailed_budget( + self, plan, ordered_category_ids, category_names, @@ -257,206 +476,499 @@ def _write_sheet1( estimate_fmt, has_rate, rate_cell, - ) - _write_footer( - ws, + ) -> None: + ws = self.ws + amount_col, estimate_col = cols["amount_col"], cols["estimate_col"] + amount_letter, estimate_letter = cols["amount_letter"], cols["estimate_letter"] + + ws.cell(row=plan["detail_title_row"], column=1, value="DETAILED BUDGET") + self._bold_row(plan["detail_title_row"]) + + for category_id in ordered_category_ids: + header_row = plan["detail_header_rows"][category_id] + ws.cell(row=header_row, column=1, value=category_names[category_id]) + self._bold_row(header_row) + + line_rows = plan["detail_line_rows"][category_id] + for line, row in zip(category_lines[category_id], line_rows): + ws.cell(row=row, column=1, value=line.description) + for i, key in enumerate(extra_keys): + value = (line.extra_fields or {}).get(key) + ws.cell(row=row, column=_DESCRIPTION_COL + 1 + i, value=value) + self._set_cell(row, amount_col, line.amount or 0.0, local_fmt) + if has_rate: + self._set_cell( + row, estimate_col, f"={amount_letter}{row}/{rate_cell}", estimate_fmt + ) + if line_rows: + self._apply_box_border(line_rows[0], line_rows[-1], estimate_col) + + subtotal_row = plan["detail_subtotal_rows"][category_id] + ws.cell(row=subtotal_row, column=1, value="Subtotal") + if line_rows: + amount_range = f"{amount_letter}{line_rows[0]}:{amount_letter}{line_rows[-1]}" + self._set_cell(subtotal_row, amount_col, f"=SUM({amount_range})", local_fmt) + if has_rate: + estimate_range = ( + f"{estimate_letter}{line_rows[0]}:{estimate_letter}{line_rows[-1]}" + ) + self._set_cell( + subtotal_row, estimate_col, f"=SUM({estimate_range})", estimate_fmt + ) + else: + self._set_cell(subtotal_row, amount_col, 0.0, local_fmt) + if has_rate: + formula = f"={amount_letter}{subtotal_row}/{rate_cell}" + self._set_cell(subtotal_row, estimate_col, formula, estimate_fmt) + self._bold_row(subtotal_row) + + def _write_footer( + self, plan, ordered_category_ids, cols, local_fmt, estimate_fmt, has_rate, - exported_by, - exported_at, - ) + ) -> None: + ws = self.ws + amount_col, estimate_col = cols["amount_col"], cols["estimate_col"] + amount_letter, estimate_letter = cols["amount_letter"], cols["estimate_letter"] + + total_row = plan["footer_total_row"] + ws.cell(row=total_row, column=1, value="Total expenditures") + if ordered_category_ids: + subtotal_rows = [plan["detail_subtotal_rows"][cid] for cid in ordered_category_ids] + c_formula = "=" + "+".join(f"{amount_letter}{r}" for r in subtotal_rows) + self._set_cell(total_row, amount_col, c_formula, local_fmt) + if has_rate: + d_formula = "=" + "+".join(f"{estimate_letter}{r}" for r in subtotal_rows) + self._set_cell(total_row, estimate_col, d_formula, estimate_fmt) + else: + self._set_cell(total_row, amount_col, 0.0, local_fmt) + for cell in ws[total_row]: + cell.font = _BOLD + cell.fill = _TOTAL_FILL + signature_row = plan["signature_line_row"] + ws.cell(row=signature_row, column=1).border = _TOP_BORDER + ws.cell(row=signature_row + 1, column=1, value="Authorised Signatory") -def _set_column_widths(ws, extra_keys: list[str], cols: dict) -> None: - ws.column_dimensions[get_column_letter(_DESCRIPTION_COL)].width = _DESCRIPTION_COL_WIDTH - for i in range(len(extra_keys)): - ws.column_dimensions[get_column_letter(_DESCRIPTION_COL + 1 + i)].width = _EXTRA_COL_WIDTH - ws.column_dimensions[get_column_letter(cols["amount_col"])].width = _AMOUNT_COL_WIDTH - ws.column_dimensions[get_column_letter(cols["estimate_col"])].width = _ESTIMATE_COL_WIDTH - - -def _write_header( - ws, budget: BudgetModel, organisation_name: str | None, donor_name: str | None -) -> str: - """Writes the header block and returns the rate cell ref for formulas.""" - fields = ( - ("Organisation Name", organisation_name), - ("Donor Name", donor_name), - ("Project Name", budget.name), - ("Project Period", _period_label(budget.start_date, budget.duration_months)), - ("Estimated Currency", budget.actual_currency), - ("Estimated Exchange Rate", budget.estimated_exchange_rate), - ) - for row, (label, value) in enumerate(fields, start=1): - ws.cell(row=row, column=1, value=label) - ws.cell(row=row, column=2, value=value) - _bold_row(ws, row) - - return f"$B${_RATE_ROW}" - - -def _write_budget_summary( - ws, - plan, - ordered_category_ids, - category_names, - amount_header, - estimate_header, - extra_keys, - cols, - local_fmt, - estimate_fmt, - has_rate, -) -> None: - amount_col, estimate_col = cols["amount_col"], cols["estimate_col"] - amount_letter, estimate_letter = cols["amount_letter"], cols["estimate_letter"] - - header_row = plan["summary_header_row"] - ws.cell(row=header_row, column=1, value="BUDGET SUMMARY") - for i, key in enumerate(extra_keys): - ws.cell(row=header_row, column=_DESCRIPTION_COL + 1 + i, value=key) - ws.cell(row=header_row, column=amount_col, value=amount_header) - ws.cell(row=header_row, column=estimate_col, value=estimate_header) - _bold_row(ws, header_row) - - for category_id in ordered_category_ids: - row = plan["summary_rows"][category_id] - detail_subtotal_row = plan["detail_subtotal_rows"][category_id] - ws.cell(row=row, column=1, value=category_names[category_id]) - _set_cell(ws, row, amount_col, f"={amount_letter}{detail_subtotal_row}", local_fmt) - if has_rate: - _set_cell( - ws, row, estimate_col, f"={estimate_letter}{detail_subtotal_row}", estimate_fmt + contact_row = plan["contact_line_row"] + ws.cell(row=contact_row, column=1).border = _TOP_BORDER + ws.cell(row=contact_row + 1, column=1, value="Project contact person") + + audit_cell = ws.cell( + row=plan["audit_row"], column=1, value=_audit_line(self.exported_by, self.exported_at) + ) + audit_cell.font = _AUDIT_FONT + + +class DashboardSheet(_SheetWriter): + """Sheet 2 — Budget vs. Report Dashboard: donor-currency-first framing.""" + + def __init__( + self, + ws, + budget: BudgetModel, + organisation_name: str | None, + donor_name: str | None, + receipts: list[FundingReceiptModel], + conversions: list[CurrencyConversionModel], + categories: list[BudgetCategoryModel], + lines: list[BudgetLineModel], + rollups: dict[UUID, BudgetLineExpenseRollup], + exported_by: str | None, + exported_at: datetime | None, + ) -> None: + super().__init__(ws, budget, organisation_name, donor_name) + self.receipts = receipts + self.conversions = conversions + self.categories = categories + self.lines = lines + self.rollups = rollups + self.exported_by = exported_by + self.exported_at = exported_at + + def write(self) -> None: + self._set_column_widths() + self._write_header() + ordered_category_ids, category_names, category_lines = _group_lines_by_category( + self.categories, self.lines + ) + events = self._ledger_events() + plan = self._plan(ordered_category_ids, category_lines, len(events)) + estimated_exchange_rate = self.budget.estimated_exchange_rate + + approved_total = ( + sum(line.amount or 0.0 for line in self.lines) / estimated_exchange_rate + if estimated_exchange_rate + else None + ) + received_total = sum(receipt.amount for receipt in self.receipts) + converted_total = sum(conversion.donor_amount for conversion in self.conversions) + local_converted_total = sum(conversion.local_amount for conversion in self.conversions) + local_expenses_total = sum(self._rollup_for(line).total_local_amount for line in self.lines) + + self._write_approved_block(plan, approved_total) + self._write_balance_block( + plan, + approved_total, + received_total, + converted_total, + local_converted_total - local_expenses_total, + ) + self._write_ledger(plan, events) + self._write_report_summary(plan, ordered_category_ids, category_names) + self._write_detail( + plan, + ordered_category_ids, + category_names, + category_lines, + bool(estimated_exchange_rate), + ) + self._write_footer(plan) + + def _set_column_widths(self) -> None: + for column, width in _DASHBOARD_COL_WIDTHS.items(): + self.ws.column_dimensions[get_column_letter(column)].width = width + + def _rollup_for(self, line: BudgetLineModel) -> BudgetLineExpenseRollup: + return self.rollups.get( + line.id, BudgetLineExpenseRollup(budget_line_id=line.id, total_local_amount=0.0) + ) + + def _ledger_events( + self, + ) -> list[tuple[date, int, FundingReceiptModel | CurrencyConversionModel]]: + """Receipts and conversions merged into one date-ordered ledger; a + receipt sorts before a conversion sharing its date.""" + events: list[tuple[date, int, FundingReceiptModel | CurrencyConversionModel]] = [ + (receipt.received_at, 0, receipt) for receipt in self.receipts + ] + events += [(conversion.converted_at, 1, conversion) for conversion in self.conversions] + events.sort(key=lambda event: (event[0], event[1])) + return events + + def _plan(self, ordered_category_ids, category_lines, ledger_event_count: int) -> dict: + approved_total_row = _HEADER_ROW_COUNT + 2 + approved_on_row = approved_total_row + 1 + received_total_row = approved_total_row + 3 + converted_total_row = received_total_row + 1 + balance_header_row = received_total_row + 2 + balance_value_row = received_total_row + 3 + + ledger_title_row = balance_value_row + 2 + ledger_header_row = ledger_title_row + 1 + ledger_data_start_row = ledger_header_row + 1 + ledger_total_row = ( + ledger_data_start_row + ledger_event_count + if ledger_event_count + else ledger_header_row + 1 + ) + + report_summary_header_row = ledger_total_row + 3 + report_summary_rows: dict = {} + row = report_summary_header_row + for category_id in ordered_category_ids: + row += 1 + report_summary_rows[category_id] = row + report_summary_total_row = ( + row + 1 if ordered_category_ids else report_summary_header_row + 1 + ) + + detail_category_header_rows: dict = {} + detail_line_rows: dict = {} + detail_subtotal_rows: dict = {} + row = report_summary_total_row + 1 + for category_id in ordered_category_ids: + row += 1 + detail_category_header_rows[category_id] = row + line_rows = [] + for _ in category_lines[category_id]: + row += 1 + line_rows.append(row) + detail_line_rows[category_id] = line_rows + subtotal_row = row + 1 + detail_subtotal_rows[category_id] = subtotal_row + row = subtotal_row + 1 # blank separator row before the next category + + refund_row = row + 4 + place_date_row = refund_row + 4 + signature_line_row = place_date_row + 4 + signature_label_row = signature_line_row + 1 + audit_row = signature_label_row + 4 + + return { + "approved_total_row": approved_total_row, + "approved_on_row": approved_on_row, + "received_total_row": received_total_row, + "converted_total_row": converted_total_row, + "balance_header_row": balance_header_row, + "balance_value_row": balance_value_row, + "ledger_title_row": ledger_title_row, + "ledger_header_row": ledger_header_row, + "ledger_data_start_row": ledger_data_start_row, + "ledger_total_row": ledger_total_row, + "report_summary_header_row": report_summary_header_row, + "report_summary_rows": report_summary_rows, + "report_summary_total_row": report_summary_total_row, + "detail_category_header_rows": detail_category_header_rows, + "detail_line_rows": detail_line_rows, + "detail_subtotal_rows": detail_subtotal_rows, + "refund_row": refund_row, + "place_date_row": place_date_row, + "signature_line_row": signature_line_row, + "signature_label_row": signature_label_row, + "audit_row": audit_row, + } + + def _write_approved_block(self, plan: dict, approved_total: float | None) -> None: + ws = self.ws + donor_fmt = self._currency_format(self.budget.actual_currency) + + ws.cell(row=plan["approved_total_row"], column=1, value="Approved Total") + if approved_total is not None: + self._set_cell(plan["approved_total_row"], 2, approved_total, donor_fmt) + + ws.cell(row=plan["approved_on_row"], column=1, value="Approved on") + if self.budget.confirmed_at: + # openpyxl rejects tz-aware datetimes outright; Excel has no timezone concept. + confirmed_at = self.budget.confirmed_at.replace(tzinfo=None) + cell = ws.cell(row=plan["approved_on_row"], column=2, value=confirmed_at) + cell.number_format = _DATE_FORMAT + + def _write_balance_block( + self, + plan: dict, + approved_total: float | None, + received_total: float, + converted_total: float, + local_balance: float, + ) -> None: + ws = self.ws + donor_fmt = self._currency_format(self.budget.actual_currency) + local_fmt = self._currency_format(self.budget.local_currency) + + received_row = plan["received_total_row"] + ws.cell(row=received_row, column=1, value="Received Total") + self._set_cell(received_row, 2, received_total, donor_fmt) + if approved_total: + self._set_cell( + received_row, 3, f"=B{received_row}/B{plan['approved_total_row']}", _PERCENT_FORMAT ) - if ordered_category_ids: - first_summary_row = plan["summary_rows"][ordered_category_ids[0]] - last_summary_row = plan["summary_rows"][ordered_category_ids[-1]] - _apply_box_border(ws, first_summary_row, last_summary_row, estimate_col) - - total_row = plan["summary_total_row"] - ws.cell(row=total_row, column=1, value="TOTAL") - if ordered_category_ids: - first_row = plan["summary_rows"][ordered_category_ids[0]] - last_row = plan["summary_rows"][ordered_category_ids[-1]] - amount_range = f"{amount_letter}{first_row}:{amount_letter}{last_row}" - _set_cell(ws, total_row, amount_col, f"=SUM({amount_range})", local_fmt) - if has_rate: - estimate_range = f"{estimate_letter}{first_row}:{estimate_letter}{last_row}" - _set_cell(ws, total_row, estimate_col, f"=SUM({estimate_range})", estimate_fmt) - else: - _set_cell(ws, total_row, amount_col, 0.0, local_fmt) - _bold_row(ws, total_row) - - -def _write_detailed_budget( - ws, - plan, - ordered_category_ids, - category_names, - category_lines, - extra_keys, - cols, - local_fmt, - estimate_fmt, - has_rate, - rate_cell, -) -> None: - amount_col, estimate_col = cols["amount_col"], cols["estimate_col"] - amount_letter, estimate_letter = cols["amount_letter"], cols["estimate_letter"] - - ws.cell(row=plan["detail_title_row"], column=1, value="DETAILED BUDGET") - _bold_row(ws, plan["detail_title_row"]) - - for category_id in ordered_category_ids: - header_row = plan["detail_header_rows"][category_id] - ws.cell(row=header_row, column=1, value=category_names[category_id]) - _bold_row(ws, header_row) - - line_rows = plan["detail_line_rows"][category_id] - for line, row in zip(category_lines[category_id], line_rows): - ws.cell(row=row, column=1, value=line.description) - for i, key in enumerate(extra_keys): - value = (line.extra_fields or {}).get(key) - ws.cell(row=row, column=_DESCRIPTION_COL + 1 + i, value=value) - _set_cell(ws, row, amount_col, line.amount or 0.0, local_fmt) - if has_rate: - _set_cell(ws, row, estimate_col, f"={amount_letter}{row}/{rate_cell}", estimate_fmt) - if line_rows: - _apply_box_border(ws, line_rows[0], line_rows[-1], estimate_col) - - subtotal_row = plan["detail_subtotal_rows"][category_id] - ws.cell(row=subtotal_row, column=1, value="Subtotal") - if line_rows: - amount_range = f"{amount_letter}{line_rows[0]}:{amount_letter}{line_rows[-1]}" - _set_cell(ws, subtotal_row, amount_col, f"=SUM({amount_range})", local_fmt) - if has_rate: - estimate_range = f"{estimate_letter}{line_rows[0]}:{estimate_letter}{line_rows[-1]}" - _set_cell(ws, subtotal_row, estimate_col, f"=SUM({estimate_range})", estimate_fmt) - else: - _set_cell(ws, subtotal_row, amount_col, 0.0, local_fmt) - if has_rate: - formula = f"={amount_letter}{subtotal_row}/{rate_cell}" - _set_cell(ws, subtotal_row, estimate_col, formula, estimate_fmt) - _bold_row(ws, subtotal_row) + converted_row = plan["converted_total_row"] + ws.cell(row=converted_row, column=1, value="Converted Total") + self._set_cell(converted_row, 2, converted_total, donor_fmt) + if received_total: + self._set_cell(converted_row, 3, f"=B{converted_row}/B{received_row}", _PERCENT_FORMAT) + + header_row = plan["balance_header_row"] + ws.cell(row=header_row, column=1, value="Current Balance") + ws.cell(row=header_row, column=2, value=self.budget.actual_currency or "Donor") + ws.cell(row=header_row, column=3, value=self.budget.local_currency or "Local") + self._bold_row(header_row) + + value_row = plan["balance_value_row"] + self._set_cell(value_row, 2, f"=B{received_row}-B{converted_row}", donor_fmt) + self._set_cell(value_row, 3, local_balance, local_fmt) + + def _write_ledger( + self, + plan: dict, + events: list[tuple[date, int, FundingReceiptModel | CurrencyConversionModel]], + ) -> None: + ws = self.ws + budget = self.budget + donor_fmt = self._currency_format(budget.actual_currency) + local_fmt = self._currency_format(budget.local_currency) + donor_header = ( + f"Donor Amount ({budget.actual_currency})" if budget.actual_currency else "Donor Amount" + ) + local_header = ( + f"Local Amount ({budget.local_currency})" if budget.local_currency else "Local Amount" + ) -def _audit_line(exported_by: str | None, exported_at: datetime | None) -> str: - parts = ["Generated by OpenGrantFlow"] - if exported_by: - parts.append(exported_by) - if exported_at: - parts.append(exported_at.strftime("%Y-%m-%d %H:%M UTC")) - return " · ".join(parts) + ws.cell(row=plan["ledger_title_row"], column=1, value="Funding Ledger") + self._bold_row(plan["ledger_title_row"]) + header_row = plan["ledger_header_row"] + ws.cell(row=header_row, column=1, value="Date") + ws.cell(row=header_row, column=2, value="Received") + ws.cell(row=header_row, column=3, value=donor_header) + ws.cell(row=header_row, column=4, value=local_header) + ws.cell(row=header_row, column=5, value="Implied Rate") + self._bold_row(header_row) -def _write_footer( - ws, - plan, - ordered_category_ids, - cols, - local_fmt, - estimate_fmt, - has_rate, - exported_by, - exported_at, -) -> None: - amount_col, estimate_col = cols["amount_col"], cols["estimate_col"] - amount_letter, estimate_letter = cols["amount_letter"], cols["estimate_letter"] - - total_row = plan["footer_total_row"] - ws.cell(row=total_row, column=1, value="Total expenditures") - if ordered_category_ids: - subtotal_rows = [plan["detail_subtotal_rows"][cid] for cid in ordered_category_ids] - c_formula = "=" + "+".join(f"{amount_letter}{r}" for r in subtotal_rows) - _set_cell(ws, total_row, amount_col, c_formula, local_fmt) - if has_rate: - d_formula = "=" + "+".join(f"{estimate_letter}{r}" for r in subtotal_rows) - _set_cell(ws, total_row, estimate_col, d_formula, estimate_fmt) - else: - _set_cell(ws, total_row, amount_col, 0.0, local_fmt) - for cell in ws[total_row]: - cell.font = _BOLD - cell.fill = _TOTAL_FILL - - signature_row = plan["signature_line_row"] - ws.cell(row=signature_row, column=1).border = _TOP_BORDER - ws.cell(row=signature_row + 1, column=1, value="Authorised Signatory") - - contact_row = plan["contact_line_row"] - ws.cell(row=contact_row, column=1).border = _TOP_BORDER - ws.cell(row=contact_row + 1, column=1, value="Project contact person") - - audit_cell = ws.cell( - row=plan["audit_row"], column=1, value=_audit_line(exported_by, exported_at) - ) - audit_cell.font = _AUDIT_FONT + row = plan["ledger_data_start_row"] - 1 + for event_date, kind, obj in events: + row += 1 + ws.cell(row=row, column=1, value=event_date).number_format = _DATE_FORMAT + if isinstance(obj, FundingReceiptModel): + self._set_cell(row, 2, obj.amount, donor_fmt) + else: + self._set_cell(row, 3, obj.donor_amount, donor_fmt) + self._set_cell(row, 4, obj.local_amount, local_fmt) + if obj.donor_amount: + self._set_cell(row, 5, f"=D{row}/C{row}", _RATE_FORMAT) + + total_row = plan["ledger_total_row"] + ws.cell(row=total_row, column=1, value="TOTAL") + if events: + first_row, last_row = plan["ledger_data_start_row"], row + self._set_cell(total_row, 3, f"=SUM(C{first_row}:C{last_row})", donor_fmt) + self._set_cell(total_row, 4, f"=SUM(D{first_row}:D{last_row})", local_fmt) + else: + self._set_cell(total_row, 3, 0.0, donor_fmt) + self._set_cell(total_row, 4, 0.0, local_fmt) + self._bold_row(total_row) + + def _write_report_summary(self, plan: dict, ordered_category_ids, category_names) -> None: + ws = self.ws + budget = self.budget + donor_fmt = self._currency_format(budget.actual_currency) + local_fmt = self._currency_format(budget.local_currency) + + header_row = plan["report_summary_header_row"] + ws.cell(row=header_row, column=1, value="Report Summary") + ws.cell( + row=header_row, column=2, value=self._column_header("Original", budget.actual_currency) + ) + ws.cell( + row=header_row, column=3, value=self._column_header("Planned", budget.local_currency) + ) + ws.cell( + row=header_row, column=4, value=self._column_header("Expenses", budget.local_currency) + ) + ws.cell( + row=header_row, + column=5, + value=self._column_header("Total Expenses", budget.actual_currency), + ) + ws.cell( + row=header_row, column=6, value=self._column_header("Deviation", budget.actual_currency) + ) + self._bold_row(header_row) + + for category_id in ordered_category_ids: + row = plan["report_summary_rows"][category_id] + subtotal_row = plan["detail_subtotal_rows"][category_id] + ws.cell(row=row, column=1, value=category_names[category_id]) + self._set_cell(row, 2, f"=B{subtotal_row}", donor_fmt) + self._set_cell(row, 3, f"=C{subtotal_row}", local_fmt) + self._set_cell(row, 4, f"=D{subtotal_row}", local_fmt) + self._set_cell(row, 5, f"=E{subtotal_row}", donor_fmt) + self._set_cell(row, 6, f"=F{subtotal_row}", donor_fmt) + if ordered_category_ids: + first_summary_row = plan["report_summary_rows"][ordered_category_ids[0]] + last_summary_row = plan["report_summary_rows"][ordered_category_ids[-1]] + self._apply_box_border(first_summary_row, last_summary_row, 6) + + total_row = plan["report_summary_total_row"] + ws.cell(row=total_row, column=1, value="TOTAL") + column_formats = ( + (2, donor_fmt), + (3, local_fmt), + (4, local_fmt), + (5, donor_fmt), + (6, donor_fmt), + ) + if ordered_category_ids: + first_row = plan["report_summary_rows"][ordered_category_ids[0]] + last_row = plan["report_summary_rows"][ordered_category_ids[-1]] + for column, fmt in column_formats: + letter = get_column_letter(column) + self._set_cell( + total_row, column, f"=SUM({letter}{first_row}:{letter}{last_row})", fmt + ) + else: + for column, fmt in column_formats: + self._set_cell(total_row, column, 0.0, fmt) + self._bold_row(total_row) + self._apply_box_border(total_row, total_row, 6) + + def _write_detail( + self, plan: dict, ordered_category_ids, category_names, category_lines, has_rate: bool + ) -> None: + ws = self.ws + budget = self.budget + donor_fmt = self._currency_format(budget.actual_currency) + local_fmt = self._currency_format(budget.local_currency) + estimated_exchange_rate = budget.estimated_exchange_rate + + for category_id in ordered_category_ids: + header_row = plan["detail_category_header_rows"][category_id] + ws.cell(row=header_row, column=1, value=category_names[category_id]) + self._bold_row(header_row) + + line_rows = plan["detail_line_rows"][category_id] + for line, row in zip(category_lines[category_id], line_rows): + rollup = self._rollup_for(line) + planned = line.amount or 0.0 + ws.cell(row=row, column=1, value=line.description) + self._set_cell(row, 3, planned, local_fmt) + self._set_cell(row, 4, rollup.total_local_amount, local_fmt) + if has_rate and estimated_exchange_rate: + self._set_cell(row, 2, planned / estimated_exchange_rate, donor_fmt) + conversion = _compute_converted_expense(rollup, estimated_exchange_rate) + cell = self._set_cell(row, 5, conversion.converted_donor_amount, donor_fmt) + if conversion.is_estimated: + cell.font = _ESTIMATE_CELL_FONT + cell.fill = _ESTIMATE_CELL_FILL + self._set_cell(row, 6, f"=B{row}-E{row}", donor_fmt) + if line_rows: + self._apply_box_border(line_rows[0], line_rows[-1], 6) + + subtotal_row = plan["detail_subtotal_rows"][category_id] + ws.cell(row=subtotal_row, column=1, value="Subtotal") + if line_rows: + first_row, last_row = line_rows[0], line_rows[-1] + self._set_cell(subtotal_row, 3, f"=SUM(C{first_row}:C{last_row})", local_fmt) + self._set_cell(subtotal_row, 4, f"=SUM(D{first_row}:D{last_row})", local_fmt) + if has_rate: + self._set_cell(subtotal_row, 2, f"=SUM(B{first_row}:B{last_row})", donor_fmt) + self._set_cell(subtotal_row, 5, f"=SUM(E{first_row}:E{last_row})", donor_fmt) + self._set_cell(subtotal_row, 6, f"=SUM(F{first_row}:F{last_row})", donor_fmt) + else: + self._set_cell(subtotal_row, 3, 0.0, local_fmt) + self._set_cell(subtotal_row, 4, 0.0, local_fmt) + if has_rate: + self._set_cell(subtotal_row, 2, 0.0, donor_fmt) + self._set_cell(subtotal_row, 5, 0.0, donor_fmt) + self._set_cell(subtotal_row, 6, f"=B{subtotal_row}-E{subtotal_row}", donor_fmt) + self._bold_row(subtotal_row) + self._apply_box_border(subtotal_row, subtotal_row, 6) + + def _write_footer(self, plan: dict) -> None: + ws = self.ws + donor_fmt = self._currency_format(self.budget.actual_currency) + + ws.cell(row=plan["refund_row"], column=1, value="Refund to donor:") + refund_cell = self._set_cell( + plan["refund_row"], + 2, + f"=B{plan['received_total_row']}-E{plan['report_summary_total_row']}", + donor_fmt, + ) + refund_cell.font = _BOLD + + ws.cell(row=plan["place_date_row"], column=1, value="Place, date:") + ws.cell(row=plan["place_date_row"], column=2).border = _BOTTOM_BORDER + line_row = plan["signature_line_row"] + for column in (1, 2, 4, 5): + ws.cell(row=line_row, column=column).border = _BOTTOM_BORDER + + label_row = plan["signature_label_row"] + ws.cell(row=label_row, column=1, value="Authorised Signatory") + ws.cell(row=label_row, column=4, value="Project Contact Person") + + audit_cell = ws.cell( + row=plan["audit_row"], column=1, value=_audit_line(self.exported_by, self.exported_at) + ) + audit_cell.font = _AUDIT_FONT -def _set_cell(ws, row: int, column: int, value, number_format: str): - cell = ws.cell(row=row, column=column, value=value) - cell.number_format = number_format - return cell + @staticmethod + def _column_header(label: str, currency: str | None) -> str: + return f"{label} ({currency})" if currency else label diff --git a/services/budget/tests/test_excel_export_crud.py b/services/budget/tests/test_excel_export_crud.py new file mode 100644 index 0000000..2aa15a5 --- /dev/null +++ b/services/budget/tests/test_excel_export_crud.py @@ -0,0 +1,141 @@ +from datetime import date + +import pytest + +from app.crud.excel_export_crud import get_budget_line_expense_rollups +from app.models.budget import BudgetLineModel +from app.models.currency_ledger import ReportLineConversionAllocationModel +from tests.factories.budget import BudgetFactory +from tests.factories.currency_ledger import CurrencyConversionFactory +from tests.factories.report import ReportFactory, ReportLineFactory + + +async def _persist(db, instance): + db.add(instance) + await db.commit() + await db.refresh(instance) + return instance + + +async def _seed_budget(db): + return await _persist(db, BudgetFactory.build()) + + +async def _seed_budget_line(db, budget_id): + # Raw model, not BudgetLineFactory: its .budget/.category SubFactories null the + # FK at flush when persisted directly (same trap test_excel_export_service.py avoids). + line = BudgetLineModel(budget_id=budget_id, description="Salaries", amount=1000.0) + return await _persist(db, line) + + +async def _seed_report(db, budget_id): + return await _persist(db, ReportFactory.build(budget_id=budget_id)) + + +async def _seed_report_line(db, report_id, budget_line_id, amount): + return await _persist( + db, + ReportLineFactory.build( + report_id=report_id, + budget_line_id=budget_line_id, + amount=amount, + expense_date=date(2026, 2, 1), + ), + ) + + +async def _seed_conversion(db, budget_id, donor_amount, local_amount): + return await _persist( + db, + CurrencyConversionFactory.build( + budget_id=budget_id, donor_amount=donor_amount, local_amount=local_amount + ), + ) + + +async def _seed_allocation(db, report_line_id, conversion_id, amount_allocated): + allocation = ReportLineConversionAllocationModel( + report_line_id=report_line_id, + conversion_id=conversion_id, + amount_allocated=amount_allocated, + ) + return await _persist(db, allocation) + + +@pytest.mark.anyio +class TestGetBudgetLineExpenseRollups: + async def test_fully_allocated_line(self, db): + budget = await _seed_budget(db) + report = await _seed_report(db, budget.id) + line = await _seed_budget_line(db, budget.id) + report_line = await _seed_report_line(db, report.id, line.id, amount=100.0) + conversion = await _seed_conversion(db, budget.id, donor_amount=50.0, local_amount=100.0) + await _seed_allocation(db, report_line.id, conversion.id, amount_allocated=100.0) + + rollups = await get_budget_line_expense_rollups(db, budget.id) + + rollup = rollups[line.id] + assert rollup.total_local_amount == 100.0 + assert len(rollup.allocations) == 1 + assert rollup.allocations[0].amount_allocated == 100.0 + assert rollup.allocations[0].conversion_donor_amount == 50.0 + assert rollup.allocations[0].conversion_local_amount == 100.0 + + async def test_partially_allocated_line(self, db): + budget = await _seed_budget(db) + report = await _seed_report(db, budget.id) + line = await _seed_budget_line(db, budget.id) + report_line = await _seed_report_line(db, report.id, line.id, amount=100.0) + conversion = await _seed_conversion(db, budget.id, donor_amount=30.0, local_amount=60.0) + await _seed_allocation(db, report_line.id, conversion.id, amount_allocated=60.0) + + rollups = await get_budget_line_expense_rollups(db, budget.id) + + rollup = rollups[line.id] + assert rollup.total_local_amount == 100.0 + assert len(rollup.allocations) == 1 + assert rollup.allocations[0].amount_allocated == 60.0 + + async def test_zero_expense_line_has_no_allocations(self, db): + budget = await _seed_budget(db) + line = await _seed_budget_line(db, budget.id) + + rollups = await get_budget_line_expense_rollups(db, budget.id) + + rollup = rollups[line.id] + assert rollup.total_local_amount == 0.0 + assert rollup.allocations == [] + + async def test_no_budget_lines_returns_empty(self, db): + budget = await _seed_budget(db) + + rollups = await get_budget_line_expense_rollups(db, budget.id) + + assert rollups == {} + + async def test_multi_lot_allocations_sum_to_full_expense(self, db): + budget = await _seed_budget(db) + report = await _seed_report(db, budget.id) + line = await _seed_budget_line(db, budget.id) + report_line = await _seed_report_line(db, report.id, line.id, amount=150.0) + conversion1 = await _seed_conversion(db, budget.id, donor_amount=40.0, local_amount=100.0) + conversion2 = await _seed_conversion(db, budget.id, donor_amount=20.0, local_amount=50.0) + await _seed_allocation(db, report_line.id, conversion1.id, amount_allocated=100.0) + await _seed_allocation(db, report_line.id, conversion2.id, amount_allocated=50.0) + + rollups = await get_budget_line_expense_rollups(db, budget.id) + + rollup = rollups[line.id] + assert rollup.total_local_amount == 150.0 + assert len(rollup.allocations) == 2 + assert sum(a.amount_allocated for a in rollup.allocations) == 150.0 + + async def test_budget_with_unrelated_line_is_not_mixed_in(self, db): + budget = await _seed_budget(db) + other_budget = await _seed_budget(db) + line = await _seed_budget_line(db, budget.id) + await _seed_budget_line(db, other_budget.id) + + rollups = await get_budget_line_expense_rollups(db, budget.id) + + assert list(rollups.keys()) == [line.id] diff --git a/services/budget/tests/test_excel_export_service.py b/services/budget/tests/test_excel_export_service.py index a361531..48ff8b5 100644 --- a/services/budget/tests/test_excel_export_service.py +++ b/services/budget/tests/test_excel_export_service.py @@ -4,16 +4,20 @@ from uuid import uuid4 import pytest -from openpyxl import load_workbook +from openpyxl import Workbook, load_workbook +from app.crud.excel_export_crud import AllocationRollup, BudgetLineExpenseRollup from app.models.budget import BudgetCategoryModel, BudgetLineModel, BudgetModel from app.schemas.budget_schema import BudgetStatus from app.services.excel_export_service import ( + DashboardSheet, _audit_line, + _compute_converted_expense, _period_label, generate_budget_export_workbook, ) from tests.factories.budget import BudgetCategoryFactory, BudgetFactory, BudgetLineFactory +from tests.factories.currency_ledger import CurrencyConversionFactory, FundingReceiptFactory OWNER_ID = str(uuid4()) FUNDER_ID = str(uuid4()) @@ -47,6 +51,312 @@ def test_omits_missing_parts(self): assert _audit_line(None, None) == "Generated by OpenGrantFlow" +class TestComputeConvertedExpense: + def test_fully_allocated_line_uses_only_real_rate_no_flag(self): + rollup = BudgetLineExpenseRollup( + budget_line_id=uuid4(), + total_local_amount=100.0, + allocations=[ + AllocationRollup( + amount_allocated=100.0, + conversion_donor_amount=50.0, + conversion_local_amount=100.0, + ) + ], + ) + + result = _compute_converted_expense(rollup, estimated_exchange_rate=2.0) + + assert result.converted_donor_amount == 50.0 + assert result.is_estimated is False + + def test_partially_allocated_line_blends_real_and_estimated(self): + rollup = BudgetLineExpenseRollup( + budget_line_id=uuid4(), + total_local_amount=100.0, + allocations=[ + AllocationRollup( + amount_allocated=60.0, + conversion_donor_amount=30.0, + conversion_local_amount=60.0, + ) + ], + ) + + result = _compute_converted_expense(rollup, estimated_exchange_rate=2.0) + + # 60 local -> 30 donor (real rate); remainder 40 local -> 20 donor (estimated rate) + assert result.converted_donor_amount == 50.0 + assert result.is_estimated is True + + def test_fully_unallocated_line_is_fully_estimated(self): + rollup = BudgetLineExpenseRollup( + budget_line_id=uuid4(), total_local_amount=100.0, allocations=[] + ) + + result = _compute_converted_expense(rollup, estimated_exchange_rate=2.0) + + assert result.converted_donor_amount == 50.0 + assert result.is_estimated is True + + def test_zero_expense_line_is_not_flagged(self): + rollup = BudgetLineExpenseRollup( + budget_line_id=uuid4(), total_local_amount=0.0, allocations=[] + ) + + result = _compute_converted_expense(rollup, estimated_exchange_rate=2.0) + + assert result.converted_donor_amount == 0.0 + assert result.is_estimated is False + + +class TestDashboardSheet: + def _write( + self, + budget, + categories=(), + lines=(), + rollups=None, + receipts=(), + conversions=(), + exported_by=None, + exported_at=None, + ): + wb = Workbook() + ws = wb.active + DashboardSheet( + ws, + budget, + "Test Org", + "Test Donor", + list(receipts), + list(conversions), + list(categories), + list(lines), + rollups or {}, + exported_by, + exported_at, + ).write() + return ws + + def test_full_layout_with_rate(self): + confirmed_at = datetime(2026, 1, 10, tzinfo=timezone.utc) + budget = BudgetFactory.build( + local_currency="GBP", + actual_currency="USD", + estimated_exchange_rate=2.0, + confirmed_at=confirmed_at, + ) + category = BudgetCategoryFactory.build(budget=budget, budget_id=budget.id, name="Personnel") + line = BudgetLineFactory.build( + budget=budget, + budget_id=budget.id, + category=category, + category_id=category.id, + description="Salaries", + amount=1000.0, + ) + rollup = BudgetLineExpenseRollup( + budget_line_id=line.id, + total_local_amount=100.0, + allocations=[ + AllocationRollup( + amount_allocated=100.0, + conversion_donor_amount=50.0, + conversion_local_amount=100.0, + ) + ], + ) + receipt = FundingReceiptFactory.build(amount=600.0, received_at=date(2026, 1, 1)) + conversion = CurrencyConversionFactory.build( + donor_amount=300.0, local_amount=650.0, converted_at=date(2026, 1, 2) + ) + exported_at = datetime(2026, 9, 22, 14, 30, tzinfo=timezone.utc) + + ws = self._write( + budget, + categories=[category], + lines=[line], + rollups={line.id: rollup}, + receipts=[receipt], + conversions=[conversion], + exported_by="exporter@example.com", + exported_at=exported_at, + ) + rows = list(ws.iter_rows(values_only=True)) + + assert rows[0][:2] == ("Organisation Name", "Test Org") + assert rows[1][:2] == ("Donor Name", "Test Donor") + + # column widths wide enough for the longest header in each ("Total Expenses (USD)" etc) + assert ws.column_dimensions["A"].width == 26.63 + assert ws.column_dimensions["B"].width == 18.0 + assert ws.column_dimensions["C"].width == 21.0 + assert ws.column_dimensions["D"].width == 21.0 + assert ws.column_dimensions["E"].width == 23.0 + assert ws.column_dimensions["F"].width == 18.0 + + assert rows[7][:2] == ("Approved Total", 500.0) # 1000 GBP / rate 2.0 + assert ws.cell(row=9, column=1).value == "Approved on" + assert ws.cell(row=9, column=2).value == confirmed_at.replace(tzinfo=None) + + assert rows[10][:3] == ("Received Total", 600.0, "=B11/B8") + assert rows[11][:3] == ("Converted Total", 300.0, "=B12/B11") + assert rows[12][:3] == ("Current Balance", "USD", "GBP") + assert ws.cell(row=14, column=2).value == "=B11-B12" + assert ws.cell(row=14, column=3).value == 550.0 # 650 converted - 100 spent, local + + assert rows[15][:1] == ("Funding Ledger",) + assert rows[16][:5] == ( + "Date", + "Received", + "Donor Amount (USD)", + "Local Amount (GBP)", + "Implied Rate", + ) + assert rows[17][:5] == (date(2026, 1, 1), 600.0, None, None, None) + assert rows[18][:5] == (date(2026, 1, 2), None, 300.0, 650.0, "=D19/C19") + assert rows[19][:5] == ("TOTAL", None, "=SUM(C18:C19)", "=SUM(D18:D19)", None) + + assert rows[22][:6] == ( + "Report Summary", + "Original (USD)", + "Planned (GBP)", + "Expenses (GBP)", + "Total Expenses (USD)", + "Deviation (USD)", + ) + assert rows[23][:6] == ("Personnel", "=B29", "=C29", "=D29", "=E29", "=F29") + assert rows[24][:6] == ( + "TOTAL", + "=SUM(B24:B24)", + "=SUM(C24:C24)", + "=SUM(D24:D24)", + "=SUM(E24:E24)", + "=SUM(F24:F24)", + ) + + assert rows[26][0] == "Personnel" + assert rows[27][:6] == ("Salaries", 500.0, 1000.0, 100.0, 50.0, "=B28-E28") + assert rows[28][:6] == ( + "Subtotal", + "=SUM(B28:B28)", + "=SUM(C28:C28)", + "=SUM(D28:D28)", + "=SUM(E28:E28)", + "=SUM(F28:F28)", + ) + converted_cell = ws.cell(row=28, column=5) + assert converted_cell.font.italic is not True # fully allocated -> no estimate style + + assert rows[33][:2] == ("Refund to donor:", "=B11-E25") + assert ws.cell(row=38, column=2).border.bottom.style == "thin" # Place, date: line + assert ws.cell(row=42, column=1).border.bottom.style == "thin" + assert ws.cell(row=42, column=2).border.bottom.style == "thin" + assert ws.cell(row=42, column=4).border.bottom.style == "thin" + assert ws.cell(row=42, column=5).border.bottom.style == "thin" + assert rows[42][:1] == ("Authorised Signatory",) + assert rows[42][3] == "Project Contact Person" + assert rows[46][0] == ( + "Generated by OpenGrantFlow · exporter@example.com · 2026-09-22 14:30 UTC" + ) + + def test_partially_allocated_line_is_styled_as_estimate(self): + budget = BudgetFactory.build( + local_currency="GBP", actual_currency="USD", estimated_exchange_rate=2.0 + ) + category = BudgetCategoryFactory.build(budget=budget, budget_id=budget.id, name="Travel") + line = BudgetLineFactory.build( + budget=budget, + budget_id=budget.id, + category=category, + category_id=category.id, + description="Flights", + amount=500.0, + ) + rollup = BudgetLineExpenseRollup( + budget_line_id=line.id, + total_local_amount=100.0, + allocations=[ + AllocationRollup( + amount_allocated=60.0, + conversion_donor_amount=30.0, + conversion_local_amount=60.0, + ) + ], + ) + + ws = self._write(budget, categories=[category], lines=[line], rollups={line.id: rollup}) + + # real 60/2 rate=30 + estimated remainder 40/2=20 -> 50.0; row = header(23)+1+1+1(line)=26 + converted_cell = ws.cell(row=26, column=5) + assert converted_cell.value == 50.0 + assert converted_cell.font.italic is True + assert converted_cell.fill.fgColor.rgb == "00FFF2CC" + + def test_no_rate_leaves_donor_columns_blank(self): + budget = BudgetFactory.build( + local_currency="GBP", actual_currency="USD", estimated_exchange_rate=None + ) + category = BudgetCategoryFactory.build(budget=budget, budget_id=budget.id, name="Travel") + line = BudgetLineFactory.build( + budget=budget, + budget_id=budget.id, + category=category, + category_id=category.id, + description="Flights", + amount=500.0, + ) + + ws = self._write(budget, categories=[category], lines=[line]) + rows = list(ws.iter_rows(values_only=True)) + + assert rows[7][:2] == ("Approved Total", None) + assert rows[25][:6] == ("Flights", None, 500.0, 0.0, None, None) + + def test_no_categories_or_ledger_events_falls_back_to_header_plus_one(self): + budget = BudgetFactory.build(local_currency="GBP", actual_currency="USD") + + ws = self._write(budget) + rows = list(ws.iter_rows(values_only=True)) + + assert rows[16][:5] == ( + "Date", + "Received", + "Donor Amount (USD)", + "Local Amount (GBP)", + "Implied Rate", + ) + assert rows[17][:5] == ("TOTAL", None, 0.0, 0.0, None) # ledger_total_row = header+1 + assert rows[20][0] == "Report Summary" + assert rows[21][0] == "TOTAL" # report_summary_total_row = header+1, no categories + + def test_no_approved_total_skips_received_percent(self): + budget = BudgetFactory.build(local_currency="GBP", actual_currency="USD") + receipt = FundingReceiptFactory.build(amount=600.0, received_at=date(2026, 1, 1)) + + ws = self._write(budget, receipts=[receipt]) + + assert ws.cell(row=11, column=3).value is None + + def test_tz_aware_confirmed_at_does_not_break_save(self): + # openpyxl rejects tz-aware datetimes only at wb.save() time, not at cell-write + # time — a bare ws.cell() assertion would miss this; must round-trip through save. + budget = BudgetFactory.build( + local_currency="GBP", + actual_currency="USD", + confirmed_at=datetime(2026, 1, 10, tzinfo=timezone.utc), + ) + wb = Workbook() + ws = wb.active + DashboardSheet(ws, budget, "Org", "Donor", [], [], [], [], {}, None, None).write() + + buf = io.BytesIO() + wb.save(buf) + reloaded = load_workbook(buf)["Sheet"] + assert reloaded.cell(row=9, column=2).value == datetime(2026, 1, 10) + + class TestGenerateBudgetExportWorkbook: def test_returns_valid_xlsx_roundtrip(self): budget = BudgetFactory.build() @@ -54,7 +364,7 @@ def test_returns_valid_xlsx_roundtrip(self): data = generate_budget_export_workbook(budget, [], []) wb = load_workbook(io.BytesIO(data)) - assert wb.sheetnames == ["Original Budget"] + assert wb.sheetnames == ["Original Budget", "Budget vs. Report Dashboard"] def test_full_layout_with_rate(self): budget = BudgetFactory.build( @@ -103,7 +413,7 @@ def test_full_layout_with_rate(self): assert rows[0] == ("Organisation Name", "Test Org", None) assert rows[1] == ("Donor Name", "Test Donor", None) assert rows[2] == ("Project Name", budget.name, None) - assert rows[4] == ("Estimated Currency", "USD", None) + assert rows[4] == ("Budget Original Currency", "USD", None) assert rows[5] == ("Estimated Exchange Rate", 2.0, None) # Budget Summary: title doubles as the column header row; each category @@ -237,9 +547,7 @@ def test_multiple_extra_field_keys_become_separate_columns(self): ws = load_workbook(io.BytesIO(data))["Original Budget"] rows = list(ws.iter_rows(values_only=True)) assert ws.max_column == 5 - assert rows[8] == ( - "BUDGET SUMMARY", "Notes", "Vendor", "Amount (GBP)", "Donor Estimate" - ) + assert rows[8] == ("BUDGET SUMMARY", "Notes", "Vendor", "Amount (GBP)", "Donor Estimate") assert rows[14] == ("Coordinator", "Approved", "Acme", 500, None) def test_no_extra_fields_omits_extra_columns_entirely(self): @@ -288,6 +596,9 @@ async def _make_budget(db, owner_id=OWNER_ID, funding_customer_id=None): start_date=date(2026, 1, 1), duration_months=12, local_currency="GBP", + # tz-aware, matching the DateTime(timezone=True) column — catches openpyxl's + # rejection of tz-aware datetimes, which only surfaces on an actual wb.save(). + confirmed_at=datetime(2026, 1, 5, tzinfo=timezone.utc), ) db.add(budget) await db.commit() @@ -340,7 +651,7 @@ async def test_owner_can_export(self, db, make_client): "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ) wb = load_workbook(io.BytesIO(response.content)) - assert wb.sheetnames == ["Original Budget"] + assert wb.sheetnames == ["Original Budget", "Budget vs. Report Dashboard"] async def test_funder_can_export(self, db, make_client): budget = await _make_budget(db, funding_customer_id=FUNDER_ID)