Aditya-fix: Restore Cost Breakdown by Category API for Financials donut chart#2036
Open
Aditya-gam wants to merge 8 commits intodevelopmentfrom
Open
Aditya-fix: Restore Cost Breakdown by Category API for Financials donut chart#2036Aditya-gam wants to merge 8 commits intodevelopmentfrom
Aditya-gam wants to merge 8 commits intodevelopmentfrom
Conversation
- Add costDate, projectName, projectType for filtering and display - Add calculatedAt, lastUpdated, source for audit trail - Change projectId to ObjectId ref buildingProject - Add COST_CATEGORIES enum and DEFAULT_HOURLY_RATE - Add compound indexes: unique (projectId, category, costDate), and query indexes for breakdown by project and date range Co-authored-by: Cursor <cursoragent@cursor.com>
- runCostAggregation(projectIds): compute Labor, Materials, Equipment from BuildingProject, BuildingMaterial, BuildingTool; upsert Cost docs - Reuse logic aligned with bmFinancialController (members.hours * rate, approved purchaseRecord sum by date) - triggerProjectAggregation(projectId): debounced (30s) for event-driven sync - Use standalone BuildingMaterial/BuildingTool models (buildingMaterials and buildingTools collections) for consistency with financial API Co-authored-by: Cursor <cursoragent@cursor.com>
- Use BuildingProject for validation and project name (not Project)
- Filter by costDate instead of createdAt; validate date range
- Add categoryDetail query: projectBreakdown per category for slice click
- Invalidate cache with clearByPrefix('cost_breakdown:') on writes
- Admin-only for add/update/delete/refresh via requestor.role check
- Add refreshCosts: POST /refresh calls runCostAggregation
- getCostsByProject: validate against BuildingProject, sort by costDate
- Normalize breakdown categories to COST_CATEGORIES; serialize projectId in detail
Co-authored-by: Cursor <cursoragent@cursor.com>
- Add POST /refresh for manual cost aggregation (admin) - Use GET /project/:projectId for list-by-project to avoid conflict with :costId - Static routes (/breakdown, /refresh) before parameterized routes - Controller no longer takes model arg; invoke as costsController() Co-authored-by: Cursor <cursoragent@cursor.com>
- getCostBreakdown: validation, cache hit, project label, categoryDetail, errors - addCostEntry/updateCostEntry/deleteCostEntry: admin check, validation, CRUD, cache invalidation - getCostsByProject: validation, cache, pagination, category filter - refreshCosts: admin check, projectIds validation, runCostAggregation - Mocks: Cost, BuildingProject, nodeCache, logger, costAggregationService Co-authored-by: Cursor <cursoragent@cursor.com>
- runCostAggregation: no projects, projectIds filter, labor/materials/tools, bulkWrite, per-project and top-level errors - triggerProjectAggregation: debounce timing, single run after delay - Internal logic: calculateProjectLaborCost, aggregateApprovedPurchasesByDate, buildUpsertOps - Mocks: Cost, BuildingProject, BuildingMaterial, BuildingTool, logger Co-authored-by: Cursor <cursoragent@cursor.com>
- GET /breakdown, POST /refresh, POST /, PUT /:costId, DELETE /:costId, GET /project/:projectId - Verify each route invokes correct controller method - Param passing: costId and projectId in req.params - Unknown route returns 404 Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Restores and completes the Cost Breakdown by Category backend so the donut chart (Dashboard → Reports → Total Construction Summary → Financials) can render. The costs API existed (PR #1300), but the router was never registered in
routes.js, so endpoints were unreachable. This PR mounts the router, refactors around a materialized view and cost aggregation service, fixes route ordering, and adds unit tests./api/costsinsrc/startup/routes.js.costDate,projectName,projectType, indexes; unique key(projectId, category, costDate).BuildingProjectfor validation;getCostBreakdownsupportsprojectId,startDate,endDate,categoryDetail=true;getCostsByProjectatGET /project/:projectId(paginated, optionalcategory); admin-only CRUD +POST /refresh; cache withclearByPrefixon write/refresh.costAggregationService.js—runCostAggregation(projectIds?)builds costs from BuildingProject (labor), BuildingMaterial, BuildingTool (approved purchases); bulk upsert intocosts.costsController.spec.js,costAggregationService.spec.js,costsRouter.test.js.Related PRs
Main changes explained
Created/Updated Files
src/startup/routes.jsapp.use('/api/costs', costsRouter);so the costs router is mounted and all cost endpoints are reachable at/api/costs/*. Previously, the router was required but never registered.src/models/costs.jsprojectIdas ObjectId refbuildingProject; addedcostDate(required, indexed),projectName,projectType(enum: commercial/residential/private),calculatedAt,lastUpdated,source(aggregation/manual/correction). Category enum:Total Cost of Labor,Total Cost of Materials,Total Cost of Equipment.{ projectId, category, costDate }for aggregation upserts;{ projectId, costDate },{ costDate }for breakdown and date-range queries.Cost,COST_CATEGORIES,DEFAULT_HOURLY_RATE(25).src/controllers/costsController.jsBuildingProject(bmdashboard) for project validation and names;costAggregationService.runCostAggregationfor refresh;nodeCachewithclearByPrefix('cost_breakdown:')andclearByPrefix('costs_project:')for invalidation.projectId(ObjectId, must exist in BuildingProject),startDate/endDate(valid dates, start ≤ end). Cache key:cost_breakdown:${projectId||'all'}:${startDate}:${endDate}:${categoryDetail}. Returns{ project, totalCost, breakdown }; withcategoryDetail=true, each breakdown item includesprojectBreakdown: [{ projectId, projectName, amount, percentage }].req.body.requestor.rolein['Administrator','Owner']). Validate category, amount ≥ 0, projectId/costId; invalidate cache on success.projectId(params), optionalpage,limit(capped at 100),category. Returns{ costs, pagination }with totalCosts, totalPages, currentPage, hasNextPage, hasPreviousPage. Cached per project/category/page/limit.projectIdsoptional array; callsrunCostAggregation(projectIds || null), then invalidates cost caches; returns{ message, updated, errors }.src/routes/costsRouter.js/breakdownand/refreshbeing matched as IDs:GET /breakdown→ getCostBreakdown;POST /refresh→ refreshCosts;POST /→ addCostEntry;PUT /:costId,DELETE /:costId→ update/delete;GET /project/:projectId→ getCostsByProject. EnsuresGET /api/costs/project/:projectIdis used for project cost list (notGET /api/costs/:projectId).src/services/bmdashboard/costAggregationService.jsprojectIds); for each project: computes labor frommembers[].hours * DEFAULT_HOURLY_RATE, materials fromBuildingMaterialapprovedpurchaseRecord(quantity × unitPrice by date), equipment fromBuildingToolapprovedpurchaseRecord; builds bulk upsert ops by (projectId, category, costDate); runsCost.bulkWrite(upsert). Returns{ updated, errors }; per-project failures are caught and pushed toerrors, and aggregation continues.calculateProjectLaborCost(project, hourlyRate),aggregateApprovedPurchasesByDate(inventoryDocs),buildUpsertOps(projectInfo, category, costsByDate).src/controllers/costsController.spec.jssrc/services/bmdashboard/costAggregationService.spec.jsrunCostAggregation(no projects, with projectIds, labor/materials/tools calculation, bulkWrite, per-project and top-level errors) and for helpers (calculateProjectLaborCost,aggregateApprovedPurchasesByDate,buildUpsertOps); optional tests fortriggerProjectAggregationdebounce behavior.src/routes/costsRouter.test.js/breakdown, POST/refresh, POST/, PUT/:costId, DELETE/:costId, GET/project/:projectIdcall the correct controller methods (e.g. via mocked controller or integration-style request).Key implementation details
costscollection is the single source for chart and project-cost reads. It is populated byrunCostAggregation, which derives data frombuildingProjects(member hours → labor),buildingMaterials(approved purchase records → materials), andbuildingTools(approved purchase records → equipment). One document per (projectId, category, costDate); upsert key prevents duplicates.BuildingProject.name.cost_breakdown:${projectId||'all'}:${startDate}:${endDate}:${categoryDetail}. Project costs:costs_project:${projectId}:${category||'all'}:${page}:${limit}. Invalidation usescache.clearByPrefix('cost_breakdown:')andcache.clearByPrefix('costs_project:')on add/update/delete/refresh.categoryDetail=true, aggregation groups by category and projectId, then attachesprojectBreakdownwith per-project amount and percentage per category for frontend slice-click drill-down.req.body.requestor.rolein['Administrator', 'Owner']; no separate middleware file change in this PR.How to test
Aditya-feature/cost-breakdown-donut-chart-wiprm -rf node_modules package-lock.json && npm cache clean --forcenpm installto install dependencies, then start the backend locally (npm run dev)GET http://localhost:4500/api/costs/breakdownGET http://localhost:4500/api/costs/breakdown?projectId=654946c8bc5772e8caf7e963GET http://localhost:4500/api/costs/breakdown?categoryDetail=trueGET http://localhost:4500/api/costs/breakdown?startDate=2024-01-01&endDate=2025-12-31GET http://localhost:4500/api/costs/breakdown?projectId=invalid→400"Invalid project ID format"GET http://localhost:4500/api/costs/breakdown?projectId=507f1f77bcf86cd799439099(valid ObjectId but non-existent project) →400"Project not found"GET http://localhost:4500/api/costs/project/654946c8bc5772e8caf7e963GET http://localhost:4500/api/costs/project/654946c8bc5772e8caf7e963?page=1&limit=10GET /api/costs/breakdownagain; response should reflect updated data (or empty breakdown if no costs).npx jest src/controllers/costsController.spec.js src/services/bmdashboard/costAggregationService.spec.js src/routes/costsRouter.test.js --coverage --collectCoverageFrom='src/controllers/costsController.js' --collectCoverageFrom='src/services/bmdashboard/costAggregationService.js' --collectCoverageFrom='src/routes/costsRouter.js' --collectCoverageFrom='src/models/costs.js'Screenshots or videos of changes
TestVideo.mov
Note
GET /breakdownandGET /project/:projectIdis backward-compatible with the contract from PR Zhifan - Create a Donut Chart for Cost Breakdown by category backend #1300; the path for project costs is explicitlyGET /project/:projectId(notGET /:projectId), so any client using the old path must use the new one./api/costs/refresh) or migration if present. New deployments can run a refresh to backfill.GET /api/costs/breakdownandGET /api/costs/project/:projectIdonce this PR is merged.