diff --git a/scripts/patch-reports-module.sql b/scripts/patch-reports-module.sql new file mode 100644 index 0000000..6b584ed --- /dev/null +++ b/scripts/patch-reports-module.sql @@ -0,0 +1,20 @@ +-- Reports module — RBAC for read-only report APIs + +INSERT INTO modules (code, name, sort_order, is_active) +VALUES ('REPORTS', 'Reports', 90, true) +ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name, sort_order = EXCLUDED.sort_order, is_active = true; + +INSERT INTO permissions (module_id, action, is_active) +SELECT m.id, a.action, true +FROM modules m +CROSS JOIN (VALUES ('view'), ('export')) AS a(action) +WHERE m.code = 'REPORTS' +ON CONFLICT (module_id, action) DO UPDATE SET is_active = true; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.action IN ('view', 'export') +JOIN modules m ON m.id = p.module_id AND m.code = 'REPORTS' +WHERE r.name = 'Super Admin' AND r.deleted_at IS NULL +ON CONFLICT (role_id, permission_id) DO NOTHING; diff --git a/src/docs/reports-routes.yaml b/src/docs/reports-routes.yaml new file mode 100644 index 0000000..d0359d5 --- /dev/null +++ b/src/docs/reports-routes.yaml @@ -0,0 +1,245 @@ +tags: + - name: Reports + +components: + schemas: + DepreciationSummary: + type: object + properties: + asset_count: { type: integer, example: 42 } + total_purchase_cost: { type: number, example: 1250000 } + total_accumulated_depreciation: { type: number, example: 320000 } + total_book_value: { type: number, example: 930000 } + total_annual_depreciation: { type: number, example: 85000 } + AssetDepreciationReportItem: + type: object + properties: + id: { type: string, example: "15" } + asset_code: { type: string, example: IT-LAP-00012 } + asset_name: { type: string, example: Dell Latitude 5540 } + status: { type: string, example: IN_USE } + purchase_date: { type: string, format: date, nullable: true } + purchase_cost: { type: number, example: 75000 } + salvage_value: { type: number, example: 5000 } + useful_life_years: { type: integer, nullable: true, example: 5 } + depreciation_method: { type: string, nullable: true, example: SLM } + depreciation_rate: { type: number, nullable: true, example: 17.3333 } + item_category: + type: object + nullable: true + properties: + id: { type: string } + code: { type: string } + name: { type: string } + item_subcategory: + type: object + nullable: true + properties: + id: { type: string } + code: { type: string } + name: { type: string } + plant: + type: object + nullable: true + properties: + id: { type: string } + code: { type: string } + name: { type: string } + department: + type: object + nullable: true + properties: + id: { type: string } + name: { type: string } + depreciation: + type: object + properties: + depreciation_method: { type: string, nullable: true } + depreciation_rate: { type: number, nullable: true } + annual_depreciation: { type: number } + accumulated_depreciation: { type: number } + book_value: { type: number } + years_elapsed: { type: number } + purchase_cost: { type: number } + salvage_value: { type: number } + useful_life_years: { type: integer, nullable: true } + DepreciationReportFilterOptions: + type: object + properties: + plants: + type: array + items: + type: object + properties: + id: { type: string } + code: { type: string } + name: { type: string } + departments: + type: array + items: + type: object + properties: + id: { type: string } + name: { type: string } + item_categories: + type: array + items: + type: object + properties: + id: { type: string } + code: { type: string } + name: { type: string } + item_subcategories: + type: array + items: + type: object + properties: + id: { type: string } + code: { type: string } + name: { type: string } + item_category_id: { type: string } + depreciation_methods: + type: array + items: + type: object + properties: + value: { type: string } + label: { type: string } + statuses: + type: array + items: + type: object + properties: + value: { type: string } + label: { type: string } + +paths: + /reports/assets/depreciation/filters: + get: + tags: [Reports] + summary: Filter dropdowns for asset depreciation report + responses: + "200": + description: Filter options fetched + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ApiResponse" + - type: object + properties: + data: { $ref: "#/components/schemas/DepreciationReportFilterOptions" } + /reports/assets/depreciation/export: + get: + tags: [Reports] + summary: Export asset depreciation report as CSV + parameters: + - in: query + name: search + schema: { type: string } + - in: query + name: status + schema: { type: string } + - in: query + name: depreciation_method + schema: { type: string, enum: [SLM, WDV, OTHER] } + - in: query + name: item_category_id + schema: { type: integer } + - in: query + name: item_subcategory_id + schema: { type: integer } + - in: query + name: plant_id + schema: { type: integer } + - in: query + name: department_id + schema: { type: integer } + - in: query + name: is_active + schema: { type: boolean } + - in: query + name: as_of_date + schema: { type: string, format: date } + description: Depreciation calculation date (defaults to today) + - in: query + name: purchase_date_from + schema: { type: string, format: date } + - in: query + name: purchase_date_to + schema: { type: string, format: date } + responses: + "200": + description: CSV export + content: + text/csv: + schema: { type: string } + /reports/assets/depreciation: + get: + tags: [Reports] + summary: Paginated asset depreciation report + description: | + Lists assets with computed depreciation (annual, accumulated, book value). + `meta.summary` aggregates totals across all rows matching the current filters. + parameters: + - in: query + name: page + schema: { type: integer, default: 1 } + - in: query + name: limit + schema: { type: integer, default: 20, maximum: 100 } + - in: query + name: search + schema: { type: string } + description: Search asset code, name, or serial number + - in: query + name: status + schema: { type: string } + - in: query + name: depreciation_method + schema: { type: string, enum: [SLM, WDV, OTHER] } + - in: query + name: item_category_id + schema: { type: integer } + - in: query + name: item_subcategory_id + schema: { type: integer } + - in: query + name: plant_id + schema: { type: integer } + - in: query + name: department_id + schema: { type: integer } + - in: query + name: is_active + schema: { type: boolean } + - in: query + name: as_of_date + schema: { type: string, format: date } + - in: query + name: purchase_date_from + schema: { type: string, format: date } + - in: query + name: purchase_date_to + schema: { type: string, format: date } + responses: + "200": + description: Asset depreciation report + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ApiResponse" + - type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/AssetDepreciationReportItem" } + meta: + type: object + properties: + page: { type: integer } + limit: { type: integer } + total: { type: integer } + as_of_date: { type: string, format: date-time } + summary: { $ref: "#/components/schemas/DepreciationSummary" } diff --git a/src/modules/reports/reports-assets-depreciation.service.js b/src/modules/reports/reports-assets-depreciation.service.js new file mode 100644 index 0000000..19ab2ea --- /dev/null +++ b/src/modules/reports/reports-assets-depreciation.service.js @@ -0,0 +1,256 @@ +const prisma = require('../../config/prisma'); +const { getPagination } = require('../../utils/pagination'); +const { calculateDepreciation, round4 } = require('../assets/assets.depreciation'); + +const depreciationInclude = { + item_categories: { select: { id: true, code: true, name: true } }, + item_subcategories: { select: { id: true, code: true, name: true } }, + plant: { select: { id: true, code: true, name: true } }, + departments: { select: { id: true, name: true } }, +}; + +const buildDepreciationWhere = (query) => { + const where = { + deleted_at: null, + ...(query.status ? { status: query.status } : {}), + ...(query.depreciation_method ? { depreciation_method: query.depreciation_method } : {}), + ...(query.item_category_id ? { item_category_id: BigInt(query.item_category_id) } : {}), + ...(query.item_subcategory_id + ? { item_subcategory_id: BigInt(query.item_subcategory_id) } + : {}), + ...(query.plant_id ? { plant_id: BigInt(query.plant_id) } : {}), + ...(query.department_id ? { department_id: BigInt(query.department_id) } : {}), + ...(query.is_active !== undefined ? { is_active: query.is_active } : {}), + ...(query.search + ? { + OR: [ + { asset_code: { contains: query.search, mode: 'insensitive' } }, + { asset_name: { contains: query.search, mode: 'insensitive' } }, + { serial_number: { contains: query.search, mode: 'insensitive' } }, + ], + } + : {}), + }; + + if (query.purchase_date_from || query.purchase_date_to) { + where.purchase_date = {}; + if (query.purchase_date_from) where.purchase_date.gte = new Date(query.purchase_date_from); + if (query.purchase_date_to) where.purchase_date.lte = new Date(query.purchase_date_to); + } + + return where; +}; + +const toNumber = (value) => + value !== null && value !== undefined ? Number(value) : value === null ? null : 0; + +const sanitizeDepreciationRow = (asset, asOfDate) => { + const purchaseCost = toNumber(asset.purchase_cost); + const salvageValue = toNumber(asset.salvage_value); + const depreciationRate = toNumber(asset.depreciation_rate); + + const depreciation = calculateDepreciation({ + depreciation_method: asset.depreciation_method, + depreciation_rate: depreciationRate, + purchase_cost: purchaseCost, + salvage_value: salvageValue, + useful_life_years: asset.useful_life_years, + purchase_date: asset.purchase_date, + as_of_date: asOfDate, + }); + + return { + id: asset.id, + asset_code: asset.asset_code, + asset_name: asset.asset_name, + status: asset.status, + purchase_date: asset.purchase_date, + purchase_cost: purchaseCost, + salvage_value: salvageValue, + useful_life_years: asset.useful_life_years, + depreciation_method: asset.depreciation_method, + depreciation_rate: depreciationRate, + item_category: asset.item_categories || null, + item_subcategory: asset.item_subcategories || null, + plant: asset.plant || null, + department: asset.departments || null, + depreciation, + }; +}; + +const computeSummary = (rows, asOfDate) => { + let totalPurchaseCost = 0; + let totalAccumulatedDepreciation = 0; + let totalBookValue = 0; + let totalAnnualDepreciation = 0; + + for (const asset of rows) { + const item = sanitizeDepreciationRow(asset, asOfDate); + totalPurchaseCost = round4(totalPurchaseCost + item.purchase_cost); + totalAccumulatedDepreciation = round4( + totalAccumulatedDepreciation + item.depreciation.accumulated_depreciation + ); + totalBookValue = round4(totalBookValue + item.depreciation.book_value); + totalAnnualDepreciation = round4( + totalAnnualDepreciation + item.depreciation.annual_depreciation + ); + } + + return { + asset_count: rows.length, + total_purchase_cost: totalPurchaseCost, + total_accumulated_depreciation: totalAccumulatedDepreciation, + total_book_value: totalBookValue, + total_annual_depreciation: totalAnnualDepreciation, + }; +}; + +const listAssetDepreciation = async (query) => { + const { page, limit, skip } = getPagination(query); + const asOfDate = query.as_of_date ? new Date(query.as_of_date) : new Date(); + const where = buildDepreciationWhere(query); + + const [rows, total, summaryRows] = await Promise.all([ + prisma.assets.findMany({ + where, + include: depreciationInclude, + orderBy: [{ asset_code: 'asc' }], + skip, + take: limit, + }), + prisma.assets.count({ where }), + prisma.assets.findMany({ + where, + include: depreciationInclude, + }), + ]); + + const summary = computeSummary(summaryRows, asOfDate); + + return { + data: rows.map((row) => sanitizeDepreciationRow(row, asOfDate)), + meta: { + page, + limit, + total, + as_of_date: asOfDate.toISOString(), + summary, + }, + }; +}; + +const getDepreciationFilterOptions = async () => { + const [plants, departments, categories, subcategories] = await Promise.all([ + prisma.locations.findMany({ + where: { deleted_at: null, is_active: true, type: 'plant' }, + select: { id: true, code: true, name: true }, + orderBy: { name: 'asc' }, + }), + prisma.departments.findMany({ + where: { deleted_at: null, is_active: true }, + select: { id: true, name: true }, + orderBy: { name: 'asc' }, + }), + prisma.item_categories.findMany({ + where: { deleted_at: null, is_active: true }, + select: { id: true, code: true, name: true }, + orderBy: { name: 'asc' }, + }), + prisma.item_subcategories.findMany({ + where: { deleted_at: null, is_active: true }, + select: { id: true, code: true, name: true, item_category_id: true }, + orderBy: { name: 'asc' }, + }), + ]); + + return { + plants, + departments, + item_categories: categories, + item_subcategories: subcategories, + depreciation_methods: [ + { value: 'SLM', label: 'Straight Line Method (SLM)' }, + { value: 'WDV', label: 'Written Down Value (WDV)' }, + { value: 'OTHER', label: 'Other' }, + ], + statuses: [ + { value: 'IN_USE', label: 'In Use' }, + { value: 'IDLE', label: 'Idle' }, + { value: 'UNDER_MAINTENANCE', label: 'Under Maintenance' }, + { value: 'DISPOSED', label: 'Disposed' }, + { value: 'SCRAPPED', label: 'Scrapped' }, + ], + }; +}; + +const escapeCsv = (value) => { + const text = String(value ?? ''); + if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`; + return text; +}; + +const exportAssetDepreciation = async (query) => { + const asOfDate = query.as_of_date ? new Date(query.as_of_date) : new Date(); + const where = buildDepreciationWhere(query); + + const rows = await prisma.assets.findMany({ + where, + include: depreciationInclude, + orderBy: [{ asset_code: 'asc' }], + }); + + const header = [ + 'Asset Code', + 'Asset Name', + 'Status', + 'Category', + 'Subcategory', + 'Plant', + 'Department', + 'Purchase Date', + 'Purchase Cost', + 'Salvage Value', + 'Useful Life (Years)', + 'Depreciation Method', + 'Depreciation Rate (%)', + 'Annual Depreciation', + 'Accumulated Depreciation', + 'Book Value', + 'Years Elapsed', + 'As Of Date', + ]; + + const lines = rows.map((row) => { + const item = sanitizeDepreciationRow(row, asOfDate); + return [ + item.asset_code, + item.asset_name, + item.status, + item.item_category?.name || '', + item.item_subcategory?.name || '', + item.plant?.name || '', + item.department?.name || '', + item.purchase_date ? new Date(item.purchase_date).toISOString().slice(0, 10) : '', + item.purchase_cost, + item.salvage_value, + item.useful_life_years ?? '', + item.depreciation_method || '', + item.depreciation_rate ?? '', + item.depreciation.annual_depreciation, + item.depreciation.accumulated_depreciation, + item.depreciation.book_value, + item.depreciation.years_elapsed, + asOfDate.toISOString().slice(0, 10), + ] + .map(escapeCsv) + .join(','); + }); + + return [header.join(','), ...lines].join('\n'); +}; + +module.exports = { + listAssetDepreciation, + getDepreciationFilterOptions, + exportAssetDepreciation, +}; diff --git a/src/modules/reports/reports.controller.js b/src/modules/reports/reports.controller.js new file mode 100644 index 0000000..a4d91ac --- /dev/null +++ b/src/modules/reports/reports.controller.js @@ -0,0 +1,29 @@ +const asyncHandler = require('../../utils/asyncHandler'); +const ApiResponse = require('../../utils/ApiResponse'); +const depreciationService = require('./reports-assets-depreciation.service'); + +const listAssetDepreciation = asyncHandler(async (req, res) => { + const result = await depreciationService.listAssetDepreciation(req.query); + res.json(new ApiResponse(200, result.data, 'Asset depreciation report fetched', result.meta)); +}); + +const depreciationFilters = asyncHandler(async (_req, res) => { + const data = await depreciationService.getDepreciationFilterOptions(); + res.json(new ApiResponse(200, data, 'Asset depreciation filter options fetched')); +}); + +const exportAssetDepreciation = asyncHandler(async (req, res) => { + const csv = await depreciationService.exportAssetDepreciation(req.query); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader( + 'Content-Disposition', + 'attachment; filename="asset-depreciation-report.csv"' + ); + res.send(csv); +}); + +module.exports = { + listAssetDepreciation, + depreciationFilters, + exportAssetDepreciation, +}; diff --git a/src/modules/reports/reports.routes.js b/src/modules/reports/reports.routes.js new file mode 100644 index 0000000..fabcf11 --- /dev/null +++ b/src/modules/reports/reports.routes.js @@ -0,0 +1,33 @@ +const express = require('express'); +const authenticate = require('../../middlewares/auth.middleware'); +const authorize = require('../../middlewares/rbac.middleware'); +const validate = require('../../middlewares/validate.middleware'); +const controller = require('./reports.controller'); +const { + depreciationReportQuerySchema, + exportDepreciationReportQuerySchema, +} = require('./reports.validation'); + +const router = express.Router(); + +router.use(authenticate); + +router.get( + '/assets/depreciation/filters', + authorize('REPORTS', 'view'), + controller.depreciationFilters +); +router.get( + '/assets/depreciation/export', + authorize('REPORTS', 'export'), + validate(exportDepreciationReportQuerySchema, 'query'), + controller.exportAssetDepreciation +); +router.get( + '/assets/depreciation', + authorize('REPORTS', 'view'), + validate(depreciationReportQuerySchema, 'query'), + controller.listAssetDepreciation +); + +module.exports = router; diff --git a/src/modules/reports/reports.validation.js b/src/modules/reports/reports.validation.js new file mode 100644 index 0000000..afbb5ad --- /dev/null +++ b/src/modules/reports/reports.validation.js @@ -0,0 +1,39 @@ +const Joi = require('joi'); +const { ASSET_STATUSES } = require('../assets/assets.constants'); + +const depreciationReportQuerySchema = Joi.object({ + page: Joi.number().integer().min(1).default(1), + limit: Joi.number().integer().min(1).max(100).default(20), + search: Joi.string().max(100).trim().allow('').optional(), + status: Joi.string() + .valid(...ASSET_STATUSES) + .optional(), + depreciation_method: Joi.string().valid('SLM', 'WDV', 'OTHER').optional(), + item_category_id: Joi.number().integer().positive().optional(), + item_subcategory_id: Joi.number().integer().positive().optional(), + plant_id: Joi.number().integer().positive().optional(), + department_id: Joi.number().integer().positive().optional(), + is_active: Joi.boolean().optional(), + as_of_date: Joi.date().iso().optional(), + purchase_date_from: Joi.date().iso().optional(), + purchase_date_to: Joi.date().iso().optional(), +}).custom((value, helpers) => { + if ( + value.purchase_date_from && + value.purchase_date_to && + new Date(value.purchase_date_from) > new Date(value.purchase_date_to) + ) { + return helpers.message('purchase_date_from must be before or equal to purchase_date_to'); + } + return value; +}); + +const exportDepreciationReportQuerySchema = depreciationReportQuerySchema.keys({ + page: Joi.strip(), + limit: Joi.strip(), +}); + +module.exports = { + depreciationReportQuerySchema, + exportDepreciationReportQuerySchema, +}; diff --git a/src/routes/v1/index.js b/src/routes/v1/index.js index 4f6947d..5387989 100644 --- a/src/routes/v1/index.js +++ b/src/routes/v1/index.js @@ -11,6 +11,7 @@ router.use('/grn', require('../../modules/grn/grn.routes')); router.use('/assets', require('../../modules/assets/assets.routes')); router.use('/settings', require('../../modules/settings/settings.routes')); router.use('/audit-logs', require('../../modules/audit-logs/audit-logs.routes')); +router.use('/reports', require('../../modules/reports/reports.routes')); router.use('/masters', require('../../modules/masters')); router.get('/healthz', (req, res) => {