feat(projects): budget-vs-actual + time rollup + tests (PMS-51) #126

Merged
longjacksonle merged 1 commit from feat/pms-51-project-actuals-and-tests into main 2026-06-07 03:51:32 +02:00

What

Completes the Projects and tasks story (PMS-51). All CRUD (projects, phases, per-tenant task statuses, tasks, dependencies with cycle detection) was already wired and returning real data - the story's "routes return 501" note was stale. This PR closes the three remaining story-level gaps: budget-vs-actual (AC1), the time-entry rollup (AC5), and integration tests (AC6). It also fixes a latent 500 in filtered project listing.

Closes PMS-51.

Budget vs actual, computed on read (AC1)

ProjectResponse gains actual_hours and actual_amount, each a correlated SUM over time_entries with approval_status = 'approved' scoped to the project. Computed on read, so there is no denormalized counter to drift and no coupling to the approval path. Pair with budget_hours / budget_amount for budget-vs-actual.

Time -> actual_hours rollup (AC5)

  • task_id added to CreateTimeEntryRequest / UpdateTimeEntryRequest (the time_entries.task_id column already existed) and bound in create/update, so time can link to a task.
  • TaskResponse.actual_hours is derived from approved time linked to the task. Since it is now derived, the manual actual_hours setter was removed from UpdateTaskRequest / update_task so the field has a single source of truth.

Bug fix: filtered list_projects 500

list_projects shared one WHERE clause between the data query (filters numbered from $4, after limit/offset) and the count query (no limit/offset, so filters must start at $2), so a filtered list bound 3 params against a statement requiring 5 -> 500. Split into parallel data/count clauses with independent numbering - the same fix PMS-145 applied to the billing lists. The path had no test before, which is why it went unnoticed.

Tests - tests/projects.rs

  • Flow (AC1-4, AC6): project -> phase (ordered by sort_order) -> task -> subtask -> dependency, with budget present and actuals zero on a fresh project, and filtered listing.
  • Dependencies (AC4): idempotent re-add leaves exactly one row (uniqueness), a cycle-closing edge is 409, a self-dependency is 400.
  • Rollup (AC5): pending time does not count; after approving 2h at $50, the task shows actual_hours 2.0 and the project shows 2.0 hours / $100 amount.
  • Wiring (AC6): project/task routes 401 unauthenticated and never 501.

Verification

  • cargo test --test projects -> 3 passed; cargo test --lib -> 110 passed; cargo test --test time_tracking and --test billing still pass (the time-tracking service was touched).
  • cargo fmt --check clean; cargo clippy --all-targets -- -D warnings clean.

Known limitation

tasks.checklist (JSONB) is in the schema but not surfaced in TaskResponse / the task requests; AC3's checklist is therefore not exercised. Left for a follow-up since no create/update path exposes it today.

🤖 Generated with Claude Code

## What Completes the Projects and tasks story (PMS-51). All CRUD (projects, phases, per-tenant task statuses, tasks, dependencies with cycle detection) was already wired and returning real data - the story's "routes return 501" note was stale. This PR closes the three remaining story-level gaps: budget-vs-actual (AC1), the time-entry rollup (AC5), and integration tests (AC6). It also fixes a latent 500 in filtered project listing. Closes PMS-51. ## Budget vs actual, computed on read (AC1) `ProjectResponse` gains `actual_hours` and `actual_amount`, each a correlated `SUM` over `time_entries` with `approval_status = 'approved'` scoped to the project. Computed on read, so there is no denormalized counter to drift and no coupling to the approval path. Pair with `budget_hours` / `budget_amount` for budget-vs-actual. ## Time -> actual_hours rollup (AC5) - `task_id` added to `CreateTimeEntryRequest` / `UpdateTimeEntryRequest` (the `time_entries.task_id` column already existed) and bound in create/update, so time can link to a task. - `TaskResponse.actual_hours` is derived from approved time linked to the task. Since it is now derived, the manual `actual_hours` setter was removed from `UpdateTaskRequest` / `update_task` so the field has a single source of truth. ## Bug fix: filtered `list_projects` 500 `list_projects` shared one WHERE clause between the data query (filters numbered from `$4`, after limit/offset) and the count query (no limit/offset, so filters must start at `$2`), so a filtered list bound 3 params against a statement requiring 5 -> 500. Split into parallel data/count clauses with independent numbering - the same fix PMS-145 applied to the billing lists. The path had no test before, which is why it went unnoticed. ## Tests - `tests/projects.rs` - **Flow (AC1-4, AC6):** project -> phase (ordered by sort_order) -> task -> subtask -> dependency, with budget present and actuals zero on a fresh project, and filtered listing. - **Dependencies (AC4):** idempotent re-add leaves exactly one row (uniqueness), a cycle-closing edge is `409`, a self-dependency is `400`. - **Rollup (AC5):** pending time does not count; after approving 2h at $50, the task shows `actual_hours` 2.0 and the project shows 2.0 hours / $100 amount. - **Wiring (AC6):** project/task routes `401` unauthenticated and never `501`. ## Verification - `cargo test --test projects` -> 3 passed; `cargo test --lib` -> 110 passed; `cargo test --test time_tracking` and `--test billing` still pass (the time-tracking service was touched). - `cargo fmt --check` clean; `cargo clippy --all-targets -- -D warnings` clean. ## Known limitation `tasks.checklist` (JSONB) is in the schema but not surfaced in `TaskResponse` / the task requests; AC3's checklist is therefore not exercised. Left for a follow-up since no create/update path exposes it today. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(projects): compute budget-vs-actual, roll approved time into actuals, add tests (PMS-51)
All checks were successful
E2E (staging) / Playwright against staging (pull_request) Successful in 25s
Check / fmt + clippy + compile + tests (pull_request) Successful in 59s
Create release / Create release from merged PR (pull_request) Has been skipped
Build OCI container / Build and push mokosh-api image (push) Successful in 3m18s
5dc8acd110
The projects + tasks module had full CRUD for projects, phases, per-tenant task statuses, tasks, and dependencies (with cycle detection) already wired and returning real data (the story's "routes return 501" note was stale). This closes the remaining story-level gaps: budget-vs-actual computation (AC1), the time-entry -> actual_hours rollup (AC5), and integration tests (AC6).

Actuals are computed on read from approved time entries, so there is no denormalized counter to drift and no coupling to the timesheet-approval path. ProjectResponse gains `actual_hours` and `actual_amount`, each a correlated SUM over `time_entries` with `approval_status = 'approved'` scoped to the project; TaskResponse's `actual_hours` is likewise derived from approved time linked to the task. Because actual hours are now derived, the manual `actual_hours` setter was removed from UpdateTaskRequest and update_task so the field has a single source of truth.

Time entries can now link to a task: `task_id` is added to CreateTimeEntryRequest / UpdateTimeEntryRequest (the column already existed) and bound in create/update, so logging time against a task feeds both the task and project rollups once approved.

Also fixes a latent 500 in list_projects: it shared one WHERE clause between the data query (filters numbered from $4, after limit/offset) and the count query (which has no limit/offset, so filters must start at $2), so a filtered list bound 3 params against a statement needing 5. Split into parallel data/count clauses with independent numbering, the same fix PMS-145 applied to the billing lists. This path had no test before, which is why it went unnoticed.

Adds tests/projects.rs: a project -> phase (ordered) -> task -> subtask -> dependency happy path with budget-vs-actual on a fresh project; dependency uniqueness (idempotent re-add, one row) plus cycle (409) and self-dependency (400) rejection; an approved-time rollup test proving pending time does not count and approved 2h at $50 surfaces as 2.0 task/project actual hours and $100 project actual amount; and an auth/never-501 sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/pms-51-project-actuals-and-tests 2026-06-07 03:51:32 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!126
No description provided.