Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions openspec/changes/budget-feat-313-excel-export/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 7 additions & 7 deletions openspec/changes/budget-feat-313-excel-export/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions services/budget/app/crud/excel_export_crud.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading