GWM : depreciation report

This commit is contained in:
Gowtham M 2026-07-13 09:51:37 +05:30
parent 0a52e1202c
commit 44c920d0c5
7 changed files with 623 additions and 0 deletions

View File

@ -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;

View File

@ -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" }

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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;

View File

@ -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,
};

View File

@ -11,6 +11,7 @@ router.use('/grn', require('../../modules/grn/grn.routes'));
router.use('/assets', require('../../modules/assets/assets.routes')); router.use('/assets', require('../../modules/assets/assets.routes'));
router.use('/settings', require('../../modules/settings/settings.routes')); router.use('/settings', require('../../modules/settings/settings.routes'));
router.use('/audit-logs', require('../../modules/audit-logs/audit-logs.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.use('/masters', require('../../modules/masters'));
router.get('/healthz', (req, res) => { router.get('/healthz', (req, res) => {