Asset module maintanance module emabled

This commit is contained in:
Gowtham M 2026-07-16 17:55:34 +05:30
parent 983505d6cf
commit 355f5a9f6d
21 changed files with 923 additions and 43 deletions

View File

@ -176,10 +176,14 @@ model assets {
department_id BigInt?
location_detail String? @db.VarChar(200)
assigned_to_user_id BigInt?
maintenance_incharge_user_id BigInt?
maintenance_frequency_in_days Int?
maintenance_checklist_json Json? @db.JsonB
vendor_id BigInt?
po_id BigInt?
grn_id BigInt?
grn_item_id BigInt?
commencement_date DateTime? @db.Date
purchase_date DateTime? @db.Date
purchase_cost Decimal @default(0) @db.Decimal(15, 4)
useful_life_years Int?
@ -203,11 +207,13 @@ model assets {
asset_amc_contracts asset_amc_contracts[]
asset_attachments asset_attachments[]
asset_insurance_policies asset_insurance_policies[]
asset_maintenance_logs asset_maintenance_logs[]
asset_service_visits asset_service_visits[]
asset_transfers asset_transfers[]
item_categories item_categories @relation(fields: [item_category_id], references: [id], onUpdate: NoAction)
item_subcategories item_subcategories? @relation(fields: [item_subcategory_id], references: [id], onUpdate: NoAction)
users_assets_assigned_to_user_idTousers users? @relation("assets_assigned_to_user_idTousers", fields: [assigned_to_user_id], references: [id], onUpdate: NoAction)
users_assets_maintenance_incharge_idTousers users? @relation("assets_maintenance_incharge_idTousers", fields: [maintenance_incharge_user_id], references: [id], onUpdate: NoAction)
users_assets_created_byTousers users? @relation("assets_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
departments departments? @relation(fields: [department_id], references: [id], onUpdate: NoAction)
grn grn? @relation(fields: [grn_id], references: [id], onUpdate: NoAction)
@ -221,6 +227,28 @@ model assets {
@@index([item_subcategory_id], map: "idx_assets_item_subcategory_id")
@@index([department_id], map: "idx_assets_dept_id")
@@index([location_id], map: "idx_assets_location_id")
@@index([maintenance_incharge_user_id], map: "idx_assets_maintenance_incharge")
}
model asset_maintenance_logs {
id BigInt @id @default(autoincrement())
asset_id BigInt
performed_by BigInt?
performed_date DateTime @db.Date
next_due_date DateTime? @db.Date
checklist_json Json? @db.JsonB
remarks String?
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
assets assets @relation(fields: [asset_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
users_asset_maintenance_logs_performed_byTousers users? @relation("asset_maintenance_logs_performed_byTousers", fields: [performed_by], references: [id], onUpdate: NoAction)
users_asset_maintenance_logs_created_byTousers users? @relation("asset_maintenance_logs_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
@@index([asset_id], map: "idx_asset_maintenance_logs_asset_id")
@@index([performed_by], map: "idx_asset_maintenance_logs_performed_by")
}
model audit_logs {
@ -466,6 +494,7 @@ model items {
is_asset_item Boolean @default(false)
description String?
specification String?
tags Json? @db.JsonB
min_order_qty Decimal? @db.Decimal(15, 4)
reorder_level Decimal? @db.Decimal(15, 4)
is_active Boolean @default(true)
@ -795,12 +824,15 @@ model users {
asset_amc_contracts_asset_amc_contracts_updated_byTousers asset_amc_contracts[] @relation("asset_amc_contracts_updated_byTousers")
asset_insurance_policies_asset_insurance_policies_created_byTousers asset_insurance_policies[] @relation("asset_insurance_policies_created_byTousers")
asset_insurance_policies_asset_insurance_policies_updated_byTousers asset_insurance_policies[] @relation("asset_insurance_policies_updated_byTousers")
asset_maintenance_logs_asset_maintenance_logs_performed_byTousers asset_maintenance_logs[] @relation("asset_maintenance_logs_performed_byTousers")
asset_maintenance_logs_asset_maintenance_logs_created_byTousers asset_maintenance_logs[] @relation("asset_maintenance_logs_created_byTousers")
asset_service_visits_asset_service_visits_created_byTousers asset_service_visits[] @relation("asset_service_visits_created_byTousers")
asset_service_visits_asset_service_visits_updated_byTousers asset_service_visits[] @relation("asset_service_visits_updated_byTousers")
asset_transfers_asset_transfers_from_user_idTousers asset_transfers[] @relation("asset_transfers_from_user_idTousers")
asset_transfers_asset_transfers_to_user_idTousers asset_transfers[] @relation("asset_transfers_to_user_idTousers")
asset_transfers_asset_transfers_transferred_byTousers asset_transfers[] @relation("asset_transfers_transferred_byTousers")
assets_assets_assigned_to_user_idTousers assets[] @relation("assets_assigned_to_user_idTousers")
assets_assets_maintenance_incharge_idTousers assets[] @relation("assets_maintenance_incharge_idTousers")
assets_assets_created_byTousers assets[] @relation("assets_created_byTousers")
assets_assets_updated_byTousers assets[] @relation("assets_updated_byTousers")
audit_logs audit_logs[]

View File

@ -0,0 +1,58 @@
-- Items tags + Assets maintenance / commencement fields.
-- Idempotent: safe to re-run.
BEGIN;
-- 1. items.tags (JSONB array of strings)
ALTER TABLE items ADD COLUMN IF NOT EXISTS tags JSONB;
-- 2. assets maintenance + commencement columns
ALTER TABLE assets ADD COLUMN IF NOT EXISTS maintenance_incharge_user_id BIGINT;
ALTER TABLE assets ADD COLUMN IF NOT EXISTS maintenance_frequency_in_days INTEGER;
ALTER TABLE assets ADD COLUMN IF NOT EXISTS maintenance_checklist_json JSONB;
ALTER TABLE assets ADD COLUMN IF NOT EXISTS commencement_date DATE;
-- Backfill commencement_date from purchase_date for existing assets
UPDATE assets
SET commencement_date = purchase_date
WHERE commencement_date IS NULL
AND purchase_date IS NOT NULL;
ALTER TABLE assets DROP CONSTRAINT IF EXISTS fk_assets_maintenance_incharge;
ALTER TABLE assets
ADD CONSTRAINT fk_assets_maintenance_incharge
FOREIGN KEY (maintenance_incharge_user_id) REFERENCES users(id);
CREATE INDEX IF NOT EXISTS idx_assets_maintenance_incharge
ON assets(maintenance_incharge_user_id);
-- 3. asset_maintenance_logs table
CREATE TABLE IF NOT EXISTS asset_maintenance_logs (
id BIGSERIAL PRIMARY KEY,
asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
performed_by BIGINT REFERENCES users(id),
performed_date DATE NOT NULL,
next_due_date DATE,
checklist_json JSONB,
remarks TEXT,
created_by BIGINT,
updated_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_asset_maintenance_logs_asset_id
ON asset_maintenance_logs(asset_id);
CREATE INDEX IF NOT EXISTS idx_asset_maintenance_logs_performed_by
ON asset_maintenance_logs(performed_by);
COMMIT;
-- Verify:
-- SELECT column_name FROM information_schema.columns
-- WHERE table_name='items' AND column_name='tags';
-- SELECT column_name FROM information_schema.columns
-- WHERE table_name='assets'
-- AND column_name IN ('maintenance_incharge_user_id','maintenance_frequency_in_days','maintenance_checklist_json','commencement_date');
-- SELECT to_regclass('public.asset_maintenance_logs');

View File

@ -0,0 +1,52 @@
/**
* Indian states / UTs shared by vendor source_of_supply and location.state.
* Keep values identical so GST intra/inter-state comparisons stay consistent.
*/
const INDIAN_STATE_OPTIONS = [
{ value: 'Andhra Pradesh', label: 'Andhra Pradesh' },
{ value: 'Arunachal Pradesh', label: 'Arunachal Pradesh' },
{ value: 'Assam', label: 'Assam' },
{ value: 'Bihar', label: 'Bihar' },
{ value: 'Chhattisgarh', label: 'Chhattisgarh' },
{ value: 'Goa', label: 'Goa' },
{ value: 'Gujarat', label: 'Gujarat' },
{ value: 'Haryana', label: 'Haryana' },
{ value: 'Himachal Pradesh', label: 'Himachal Pradesh' },
{ value: 'Jharkhand', label: 'Jharkhand' },
{ value: 'Karnataka', label: 'Karnataka' },
{ value: 'Kerala', label: 'Kerala' },
{ value: 'Madhya Pradesh', label: 'Madhya Pradesh' },
{ value: 'Maharashtra', label: 'Maharashtra' },
{ value: 'Manipur', label: 'Manipur' },
{ value: 'Meghalaya', label: 'Meghalaya' },
{ value: 'Mizoram', label: 'Mizoram' },
{ value: 'Nagaland', label: 'Nagaland' },
{ value: 'Odisha', label: 'Odisha' },
{ value: 'Punjab', label: 'Punjab' },
{ value: 'Rajasthan', label: 'Rajasthan' },
{ value: 'Sikkim', label: 'Sikkim' },
{ value: 'Tamil Nadu', label: 'Tamil Nadu' },
{ value: 'Telangana', label: 'Telangana' },
{ value: 'Tripura', label: 'Tripura' },
{ value: 'Uttar Pradesh', label: 'Uttar Pradesh' },
{ value: 'Uttarakhand', label: 'Uttarakhand' },
{ value: 'West Bengal', label: 'West Bengal' },
{ value: 'Andaman and Nicobar Islands', label: 'Andaman and Nicobar Islands' },
{ value: 'Chandigarh', label: 'Chandigarh' },
{
value: 'Dadra and Nagar Haveli and Daman and Diu',
label: 'Dadra and Nagar Haveli and Daman and Diu',
},
{ value: 'Delhi', label: 'Delhi' },
{ value: 'Jammu and Kashmir', label: 'Jammu and Kashmir' },
{ value: 'Ladakh', label: 'Ladakh' },
{ value: 'Lakshadweep', label: 'Lakshadweep' },
{ value: 'Puducherry', label: 'Puducherry' },
];
const INDIAN_STATE_VALUES = INDIAN_STATE_OPTIONS.map((item) => item.value);
module.exports = {
INDIAN_STATE_OPTIONS,
INDIAN_STATE_VALUES,
};

View File

@ -18,10 +18,24 @@ components:
department_id: { type: integer, nullable: true }
location_detail: { type: string }
assigned_to_user_id: { type: integer, nullable: true }
maintenance_incharge_user_id: { type: integer, nullable: true, description: 'User responsible for periodic maintenance checklist' }
maintenance_frequency_in_days: { type: integer, nullable: true, example: 30, description: 'Days between maintenance checklists' }
maintenance_checklist_json:
type: array
nullable: true
description: 'Checklist TEMPLATE stored on the asset'
items:
type: object
required: [key, label]
properties:
key: { type: string, example: 'oil_level' }
label: { type: string, example: 'Check oil level' }
required: { type: boolean, example: true }
vendor_id: { type: integer, nullable: true }
po_id: { type: integer, nullable: true }
grn_id: { type: integer, nullable: true }
grn_item_id: { type: integer, nullable: true }
commencement_date: { type: string, format: date, nullable: true, description: 'Asset usage start date — used for depreciation (falls back to purchase_date)' }
purchase_date: { type: string, format: date, nullable: true }
purchase_cost: { type: number, example: 850000 }
useful_life_years: { type: integer, example: 10 }
@ -52,10 +66,23 @@ components:
department_id: { type: integer, nullable: true }
location_detail: { type: string }
assigned_to_user_id: { type: integer, nullable: true }
maintenance_incharge_user_id: { type: integer, nullable: true }
maintenance_frequency_in_days: { type: integer, nullable: true }
maintenance_checklist_json:
type: array
nullable: true
items:
type: object
required: [key, label]
properties:
key: { type: string }
label: { type: string }
required: { type: boolean }
vendor_id: { type: integer, nullable: true }
po_id: { type: integer, nullable: true }
grn_id: { type: integer, nullable: true }
grn_item_id: { type: integer, nullable: true }
commencement_date: { type: string, format: date, nullable: true }
purchase_date: { type: string, format: date, nullable: true }
purchase_cost: { type: number }
useful_life_years: { type: integer }
@ -80,8 +107,27 @@ components:
purchase_cost: { type: number, example: 850000 }
salvage_value: { type: number, example: 50000 }
useful_life_years: { type: integer, example: 10 }
commencement_date: { type: string, format: date, description: 'Preferred depreciation start date' }
purchase_date: { type: string, format: date }
as_of_date: { type: string, format: date }
MaintenanceLogCreateBody:
type: object
required: [performed_date, checklist_json]
properties:
performed_date: { type: string, format: date }
next_due_date: { type: string, format: date, nullable: true, description: 'Optional override; defaults to performed_date + frequency' }
remarks: { type: string }
checklist_json:
type: array
minItems: 1
items:
type: object
required: [key, status]
properties:
key: { type: string, example: 'oil_level' }
label: { type: string, example: 'Check oil level' }
status: { type: string, enum: [OK, NOT_OK, NA] }
remarks: { type: string, nullable: true }
TransferAssetBody:
type: object
required: [transfer_date]
@ -420,6 +466,26 @@ paths:
'200':
description: Service alerts fetched
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/maintenance/my:
get:
tags: [Assets]
summary: List assets assigned to me as maintenance incharge
description: |
Returns assets where `maintenance_incharge_user_id` equals the logged-in user.
Use `due_only=true` to show only assets whose next checklist is due/overdue.
Each asset includes a `maintenance` summary (`is_due`, `next_due_date`, `checklist`, etc.).
parameters:
- { name: page, in: query, schema: { type: integer } }
- { name: limit, in: query, schema: { type: integer } }
- { name: search, in: query, schema: { type: string } }
- { name: due_only, in: query, schema: { type: boolean } }
- { name: status, in: query, schema: { type: string } }
- { name: location_id, in: query, schema: { type: integer } }
- { name: is_active, in: query, schema: { type: boolean } }
responses:
'200':
description: Maintenance assets fetched
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/export:
get:
tags: [Assets]
@ -435,6 +501,8 @@ paths:
- { name: item_subcategory_id, in: query, schema: { type: integer } }
- { name: location_id, in: query, schema: { type: integer } }
- { name: department_id, in: query, schema: { type: integer } }
- { name: maintenance_incharge_user_id, in: query, schema: { type: integer } }
- { name: due_only, in: query, schema: { type: boolean }, description: 'When true, only assets whose maintenance is due/overdue' }
- { name: is_active, in: query, schema: { type: boolean } }
responses:
'200':
@ -456,6 +524,8 @@ paths:
- { name: item_subcategory_id, in: query, schema: { type: integer } }
- { name: location_id, in: query, schema: { type: integer } }
- { name: department_id, in: query, schema: { type: integer } }
- { name: maintenance_incharge_user_id, in: query, schema: { type: integer } }
- { name: due_only, in: query, schema: { type: boolean } }
- { name: is_active, in: query, schema: { type: boolean } }
responses:
'200':
@ -527,6 +597,58 @@ paths:
'200':
description: Transfer history fetched
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/{id}/maintenance-logs:
get:
tags: [Assets]
summary: List maintenance checklist logs for an asset
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: page, in: query, schema: { type: integer } }
- { name: limit, in: query, schema: { type: integer } }
responses:
'200':
description: Maintenance logs fetched
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
post:
tags: [Assets]
summary: Submit a maintenance checklist (incharge only)
description: |
Only the asset's `maintenance_incharge_user_id` may submit.
Required checklist keys from `maintenance_checklist_json` must be present with status OK/NOT_OK/NA.
`next_due_date` defaults to performed_date + maintenance_frequency_in_days.
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/MaintenanceLogCreateBody' }
responses:
'201':
description: Checklist submitted
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
'403': { description: Not the maintenance incharge }
/assets/{id}/maintenance-logs/{logId}:
get:
tags: [Assets]
summary: Get a maintenance log by id
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: logId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Maintenance log fetched
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
delete:
tags: [Assets]
summary: Soft-delete a maintenance log (incharge only)
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: logId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Maintenance log deleted
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/{assetId}/attachments:
get:
tags: [Assets]

View File

@ -170,6 +170,11 @@ components:
hsn_code_id: { type: integer, nullable: true, example: 1 }
gst_rate_id: { type: integer, nullable: true, example: 4 }
is_asset_item: { type: boolean, example: false }
tags:
type: array
nullable: true
items: { type: string, example: 'critical' }
description: 'Free-form text tags (max 50 tags, each max 50 chars)'
description: { type: string, example: 'Raw material' }
specification: { type: string, example: '96% purity' }
min_order_qty: { type: number, nullable: true, example: 100.0 }
@ -186,6 +191,10 @@ components:
hsn_code_id: { type: integer, nullable: true, example: 1 }
gst_rate_id: { type: integer, nullable: true, example: 4 }
is_asset_item: { type: boolean, example: false }
tags:
type: array
nullable: true
items: { type: string, example: 'critical' }
description: { type: string, example: 'Raw material' }
specification: { type: string, example: '96% purity' }
min_order_qty: { type: number, nullable: true, example: 100.0 }
@ -1372,6 +1381,17 @@ paths:
description: Deleted
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
/masters/locations/states:
get:
tags: [Locations]
summary: List Indian state / UT options for location.state
description: |
Same values as `GET /vendors/source-of-supply`.
Use for plant/location state dropdowns so GST place-of-supply comparisons stay consistent.
responses:
"200":
description: State options fetched
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
/masters/locations:
get:
tags: [Locations]

View File

@ -0,0 +1,299 @@
const prisma = require('../../config/prisma');
const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog');
const { getPagination, isDropdownCall } = require('../../utils/pagination');
const { MAINTENANCE_CHECKLIST_STATUSES } = require('./assets.constants');
const { toDateOnly, getAssetOrThrow } = require('./assets.helpers');
const { sanitizeAsset } = require('./assets.service');
const logInclude = {
users_asset_maintenance_logs_performed_byTousers: {
select: { id: true, full_name: true, employee_code: true },
},
users_asset_maintenance_logs_created_byTousers: {
select: { id: true, full_name: true },
},
};
const assetListInclude = {
item_categories: { select: { id: true, code: true, name: true } },
item_subcategories: { select: { id: true, code: true, name: true } },
location: { select: { id: true, code: true, name: true, type: true } },
departments: { select: { id: true, name: true } },
users_assets_assigned_to_user_idTousers: {
select: { id: true, full_name: true, employee_code: true },
},
users_assets_maintenance_incharge_idTousers: {
select: { id: true, full_name: true, employee_code: true },
},
asset_maintenance_logs: {
where: { deleted_at: null },
orderBy: [{ performed_date: 'desc' }, { created_at: 'desc' }],
take: 1,
select: { id: true, performed_date: true, next_due_date: true },
},
};
const sanitizeLog = (row) => {
if (!row) return null;
const {
users_asset_maintenance_logs_performed_byTousers,
users_asset_maintenance_logs_created_byTousers,
...rest
} = row;
return {
...rest,
performed_by_user: users_asset_maintenance_logs_performed_byTousers || null,
created_by_user: users_asset_maintenance_logs_created_byTousers || null,
};
};
const startOfUtcDay = (value) => {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
};
const addDays = (value, days) => {
const base = startOfUtcDay(value);
if (!base || days === null || days === undefined) return null;
base.setUTCDate(base.getUTCDate() + Number(days));
return base;
};
const normalizeChecklistTemplate = (template) => {
if (template === null || template === undefined) return null;
if (!Array.isArray(template)) {
throw new ApiError(422, 'maintenance_checklist_json must be an array');
}
const keys = new Set();
return template.map((item, index) => {
const key = String(item.key || '').trim();
const label = String(item.label || '').trim();
if (!key) throw new ApiError(422, `Checklist item ${index + 1}: key is required`);
if (!label) throw new ApiError(422, `Checklist item ${index + 1}: label is required`);
if (keys.has(key)) throw new ApiError(422, `Duplicate checklist key: ${key}`);
keys.add(key);
return {
key,
label,
required: item.required !== false,
};
});
};
const validateChecklistResults = (template, results) => {
if (!Array.isArray(results) || !results.length) {
throw new ApiError(422, 'checklist_json is required and must contain at least one item');
}
const templateItems = Array.isArray(template) ? template : [];
const templateMap = new Map(templateItems.map((item) => [item.key, item]));
const resultKeys = new Set();
const normalized = [];
for (const row of results) {
const key = String(row.key || '').trim();
if (!key) throw new ApiError(422, 'Each checklist result requires a key');
if (resultKeys.has(key)) throw new ApiError(422, `Duplicate checklist result key: ${key}`);
resultKeys.add(key);
if (!MAINTENANCE_CHECKLIST_STATUSES.includes(row.status)) {
throw new ApiError(422, `Invalid checklist status for ${key}`);
}
const templateItem = templateMap.get(key);
normalized.push({
key,
label: row.label || templateItem?.label || key,
status: row.status,
remarks: row.remarks || null,
});
}
for (const item of templateItems) {
if (item.required !== false && !resultKeys.has(item.key)) {
throw new ApiError(422, `Required checklist item missing: ${item.key}`);
}
}
return normalized;
};
const assertInchargeOrThrow = (asset, userId) => {
if (!userId) throw new ApiError(403, 'Authentication required');
if (
!asset.maintenance_incharge_user_id ||
asset.maintenance_incharge_user_id.toString() !== BigInt(userId).toString()
) {
throw new ApiError(403, 'Only the maintenance incharge can perform this action');
}
};
/**
* Assets assigned to the logged-in user as maintenance incharge.
* Optional due_only=true returns only assets whose next maintenance is due/overdue.
*/
const listMyMaintenanceAssets = async (query, userId) => {
if (!userId) throw new ApiError(403, 'Authentication required');
const where = {
deleted_at: null,
maintenance_incharge_user_id: BigInt(userId),
...(query.status ? { status: query.status } : {}),
...(query.location_id ? { location_id: BigInt(query.location_id) } : {}),
...(query.is_active !== undefined ? { is_active: query.is_active } : { is_active: true }),
...(query.search
? {
OR: [
{ asset_code: { contains: query.search, mode: 'insensitive' } },
{ asset_name: { contains: query.search, mode: 'insensitive' } },
{ serial_number: { contains: query.search, mode: 'insensitive' } },
],
}
: {}),
};
const rows = await prisma.assets.findMany({
where,
include: assetListInclude,
orderBy: { asset_code: 'asc' },
});
let data = rows.map(sanitizeAsset);
if (query.due_only === true) {
data = data.filter((row) => row.maintenance?.is_due === true);
}
if (isDropdownCall(query)) {
return { data, meta: { page: 1, limit: data.length, total: data.length, dropdown: true } };
}
const { page, limit, skip } = getPagination(query);
const total = data.length;
const paged = data.slice(skip, skip + limit);
return { data: paged, meta: { page, limit, total } };
};
const listMaintenanceLogs = async (assetId, query) => {
await getAssetOrThrow(assetId);
const { page, limit, skip } = getPagination(query);
const where = { asset_id: BigInt(assetId), deleted_at: null };
const [rows, total] = await Promise.all([
prisma.asset_maintenance_logs.findMany({
where,
include: logInclude,
orderBy: [{ performed_date: 'desc' }, { created_at: 'desc' }],
skip,
take: limit,
}),
prisma.asset_maintenance_logs.count({ where }),
]);
return { data: rows.map(sanitizeLog), meta: { page, limit, total } };
};
const getMaintenanceLog = async (assetId, logId) => {
await getAssetOrThrow(assetId);
const row = await prisma.asset_maintenance_logs.findFirst({
where: {
id: BigInt(logId),
asset_id: BigInt(assetId),
deleted_at: null,
},
include: logInclude,
});
if (!row) throw new ApiError(404, 'Maintenance log not found');
return sanitizeLog(row);
};
const createMaintenanceLog = async (assetId, payload, userId, requestId) => {
const asset = await getAssetOrThrow(assetId);
assertInchargeOrThrow(asset, userId);
if (!asset.maintenance_frequency_in_days) {
throw new ApiError(422, 'Asset has no maintenance_frequency_in_days configured');
}
const template = normalizeChecklistTemplate(asset.maintenance_checklist_json);
if (!template || !template.length) {
throw new ApiError(422, 'Asset has no maintenance_checklist_json configured');
}
const checklist = validateChecklistResults(template, payload.checklist_json);
const performedDate = toDateOnly(payload.performed_date);
const nextDueDate = payload.next_due_date
? toDateOnly(payload.next_due_date)
: addDays(performedDate, asset.maintenance_frequency_in_days);
const created = await prisma.asset_maintenance_logs.create({
data: {
asset_id: BigInt(assetId),
performed_by: userId ? BigInt(userId) : null,
performed_date: performedDate,
next_due_date: nextDueDate,
checklist_json: checklist,
remarks: payload.remarks || null,
created_by: userId ? BigInt(userId) : null,
updated_by: userId ? BigInt(userId) : null,
},
include: logInclude,
});
const sanitized = sanitizeLog(created);
await auditLog({
tableName: 'asset_maintenance_logs',
recordId: created.id,
action: 'CREATE',
oldValue: null,
newValue: sanitized,
userId,
requestId,
});
return sanitized;
};
const deleteMaintenanceLog = async (assetId, logId, userId, requestId) => {
const asset = await getAssetOrThrow(assetId);
assertInchargeOrThrow(asset, userId);
const existing = await prisma.asset_maintenance_logs.findFirst({
where: {
id: BigInt(logId),
asset_id: BigInt(assetId),
deleted_at: null,
},
include: logInclude,
});
if (!existing) throw new ApiError(404, 'Maintenance log not found');
const deleted = await prisma.asset_maintenance_logs.update({
where: { id: existing.id },
data: {
deleted_at: new Date(),
updated_by: userId ? BigInt(userId) : null,
},
});
await auditLog({
tableName: 'asset_maintenance_logs',
recordId: logId,
action: 'DELETE',
oldValue: sanitizeLog(existing),
newValue: deleted,
userId,
requestId,
});
};
module.exports = {
listMyMaintenanceAssets,
listMaintenanceLogs,
getMaintenanceLog,
createMaintenanceLog,
deleteMaintenanceLog,
normalizeChecklistTemplate,
};

View File

@ -63,6 +63,12 @@ const VISIT_CONDITION_AFTER_OPTIONS = toOptions([
['NEEDS_REPLACEMENT', 'Needs Replacement'],
]);
const MAINTENANCE_CHECKLIST_STATUS_OPTIONS = toOptions([
['OK', 'OK'],
['NOT_OK', 'Not OK'],
['NA', 'Not Applicable'],
]);
const INSURANCE_POLICY_TYPE_OPTIONS = toOptions([
['FIRE_AND_ALLIED', 'Fire and Allied'],
['MACHINERY_BREAKDOWN', 'Machinery Breakdown'],
@ -94,6 +100,7 @@ const VISIT_TYPES = VISIT_TYPE_OPTIONS.map((o) => o.value);
const VISIT_STATUSES = VISIT_STATUS_OPTIONS.map((o) => o.value);
const VISIT_CONDITION_AFTER = VISIT_CONDITION_AFTER_OPTIONS.map((o) => o.value);
const INSURANCE_POLICY_TYPES = INSURANCE_POLICY_TYPE_OPTIONS.map((o) => o.value);
const MAINTENANCE_CHECKLIST_STATUSES = MAINTENANCE_CHECKLIST_STATUS_OPTIONS.map((o) => o.value);
const ALERT_TYPES = ['AMC', 'INSURANCE', 'WARRANTY'];
const ALERT_LEVELS = ['EXPIRED', 'CRITICAL', 'WARNING', 'INFO'];
@ -109,6 +116,7 @@ const getAssetDropdownOptions = () => ({
service_frequencies: SERVICE_FREQUENCY_OPTIONS,
visit_statuses: VISIT_STATUS_OPTIONS,
visit_conditions_after: VISIT_CONDITION_AFTER_OPTIONS,
maintenance_checklist_statuses: MAINTENANCE_CHECKLIST_STATUS_OPTIONS,
});
module.exports = {
@ -124,6 +132,7 @@ module.exports = {
VISIT_STATUSES,
VISIT_CONDITION_AFTER,
INSURANCE_POLICY_TYPES,
MAINTENANCE_CHECKLIST_STATUSES,
ALERT_TYPES,
ALERT_LEVELS,
SERVICE_ALERT_STATUSES,
@ -136,5 +145,6 @@ module.exports = {
VISIT_STATUS_OPTIONS,
VISIT_CONDITION_AFTER_OPTIONS,
INSURANCE_POLICY_TYPE_OPTIONS,
MAINTENANCE_CHECKLIST_STATUS_OPTIONS,
getAssetDropdownOptions,
};

View File

@ -5,6 +5,7 @@ const amcService = require('./assets-amc.service');
const visitsService = require('./assets-service-visits.service');
const insuranceService = require('./assets-insurance.service');
const alertsService = require('./assets-alerts.service');
const maintenanceService = require('./assets-maintenance.service');
const attachmentService = require('./assets.attachments.service');
const path = require('path');
@ -280,6 +281,41 @@ const removeAttachment = asyncHandler(async (req, res) => {
res.json(new ApiResponse(200, null, 'Asset attachment deleted successfully'));
});
const listMyMaintenance = asyncHandler(async (req, res) => {
const result = await maintenanceService.listMyMaintenanceAssets(req.query, req.user?.id);
res.json(new ApiResponse(200, result.data, 'Maintenance assets fetched', result.meta));
});
const listMaintenanceLogs = asyncHandler(async (req, res) => {
const result = await maintenanceService.listMaintenanceLogs(req.params.id, req.query);
res.json(new ApiResponse(200, result.data, 'Maintenance logs fetched', result.meta));
});
const getMaintenanceLog = asyncHandler(async (req, res) => {
const data = await maintenanceService.getMaintenanceLog(req.params.id, req.params.logId);
res.json(new ApiResponse(200, data, 'Maintenance log fetched'));
});
const createMaintenanceLog = asyncHandler(async (req, res) => {
const data = await maintenanceService.createMaintenanceLog(
req.params.id,
req.body,
req.user?.id,
req.id
);
res.status(201).json(new ApiResponse(201, data, 'Maintenance checklist submitted successfully'));
});
const removeMaintenanceLog = asyncHandler(async (req, res) => {
await maintenanceService.deleteMaintenanceLog(
req.params.id,
req.params.logId,
req.user?.id,
req.id
);
res.json(new ApiResponse(200, null, 'Maintenance log deleted successfully'));
});
module.exports = {
create,
list,
@ -323,4 +359,9 @@ module.exports = {
getAttachment,
downloadAttachment,
removeAttachment,
listMyMaintenance,
listMaintenanceLogs,
getMaintenanceLog,
createMaintenanceLog,
removeMaintenanceLog,
};

View File

@ -66,6 +66,7 @@ const calculateDepreciation = ({
purchase_cost: cost,
salvage_value: salvage,
useful_life_years: lifeYears,
commencement_date: commencementDate,
purchase_date: purchaseDate,
as_of_date: asOfDate,
} = {}) => {
@ -79,7 +80,10 @@ const calculateDepreciation = ({
useful_life_years: lifeYears,
});
const elapsed = yearsElapsed(purchaseDate, asOfDate || new Date());
// Depreciation starts from the asset's usage start date (commencement_date).
// Fall back to purchase_date for assets created before this field existed.
const depreciationStartDate = commencementDate || purchaseDate;
const elapsed = yearsElapsed(depreciationStartDate, asOfDate || new Date());
const cappedYears =
lifeYears && Number(lifeYears) > 0 ? Math.min(elapsed, Number(lifeYears)) : elapsed;
@ -129,6 +133,7 @@ const calculateDepreciation = ({
purchase_cost: purchaseCost,
salvage_value: salvageValue,
useful_life_years: lifeYears ?? null,
depreciation_start_date: depreciationStartDate || null,
};
};

View File

@ -23,6 +23,9 @@ const {
depreciationCalculateSchema,
expiryAlertsQuerySchema,
serviceAlertsQuerySchema,
myMaintenanceAssetsQuerySchema,
createMaintenanceLogSchema,
listMaintenanceLogsQuerySchema,
} = require('./assets.validation');
const router = express.Router();
@ -35,6 +38,12 @@ router.get(
validate(exportAssetsQuerySchema, 'query'),
controller.exportCsv
);
router.get(
'/maintenance/my',
authorize('ASSET', 'view'),
validate(myMaintenanceAssetsQuerySchema, 'query'),
controller.listMyMaintenance
);
router.get(
'/',
authorize('ASSET', 'view'),
@ -182,6 +191,29 @@ router.post(
controller.transfer
);
router.get(
'/:id/maintenance-logs',
authorize('ASSET', 'view'),
validate(listMaintenanceLogsQuerySchema, 'query'),
controller.listMaintenanceLogs
);
router.post(
'/:id/maintenance-logs',
authorize('ASSET', 'edit'),
validate(createMaintenanceLogSchema),
controller.createMaintenanceLog
);
router.get(
'/:id/maintenance-logs/:logId',
authorize('ASSET', 'view'),
controller.getMaintenanceLog
);
router.delete(
'/:id/maintenance-logs/:logId',
authorize('ASSET', 'edit'),
controller.removeMaintenanceLog
);
router.get('/:id', authorize('ASSET', 'view'), controller.getOne);
router.put('/:id', authorize('ASSET', 'edit'), validate(updateAssetSchema), controller.update);
router.delete('/:id', authorize('ASSET', 'delete'), controller.remove);

View File

@ -32,6 +32,15 @@ const assetInclude = {
users_assets_assigned_to_user_idTousers: {
select: { id: true, full_name: true, employee_code: true },
},
users_assets_maintenance_incharge_idTousers: {
select: { id: true, full_name: true, employee_code: true },
},
asset_maintenance_logs: {
where: { deleted_at: null },
orderBy: [{ performed_date: 'desc' }, { created_at: 'desc' }],
take: 1,
select: { id: true, performed_date: true, next_due_date: true },
},
vendors_assets_vendor_idTovendors: { select: { id: true, vendor_code: true, vendor_name: true } },
purchase_orders: { select: { id: true, po_number: true } },
grn: { select: { id: true, grn_number: true } },
@ -61,6 +70,52 @@ const transferInclude = {
const assetSeriesCode = (categoryCode) => `ASSET_${categoryCode}`;
const startOfUtcDay = (value) => {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
};
const addDays = (value, days) => {
const base = startOfUtcDay(value);
if (!base || days === null || days === undefined) return null;
base.setUTCDate(base.getUTCDate() + Number(days));
return base;
};
// Derive maintenance due state from the configured frequency, the latest log and
// the asset's usage start date (commencement_date, falling back to purchase_date).
const buildMaintenanceSummary = (rest, logs) => {
const frequency =
rest.maintenance_frequency_in_days !== null && rest.maintenance_frequency_in_days !== undefined
? Number(rest.maintenance_frequency_in_days)
: null;
const lastLog = Array.isArray(logs) && logs.length ? logs[0] : null;
const lastMaintenanceDate = lastLog?.performed_date ?? null;
const baseline = lastMaintenanceDate || rest.commencement_date || rest.purchase_date || null;
let nextDueDate = lastLog?.next_due_date ?? null;
if (!nextDueDate && frequency && baseline) {
nextDueDate = addDays(baseline, frequency);
}
const today = startOfUtcDay(new Date());
const isDue = Boolean(frequency && nextDueDate && new Date(nextDueDate) <= today);
const daysUntilDue =
nextDueDate && today
? Math.round((new Date(nextDueDate).getTime() - today.getTime()) / 86400000)
: null;
return {
frequency_in_days: frequency,
checklist: rest.maintenance_checklist_json ?? null,
last_maintenance_date: lastMaintenanceDate,
next_due_date: nextDueDate,
is_due: isDue,
days_until_due: daysUntilDue,
};
};
const sanitizeAsset = (asset) => {
if (!asset) return null;
const {
@ -69,6 +124,8 @@ const sanitizeAsset = (asset) => {
location,
departments,
users_assets_assigned_to_user_idTousers,
users_assets_maintenance_incharge_idTousers,
asset_maintenance_logs,
vendors_assets_vendor_idTovendors,
purchase_orders,
grn,
@ -101,6 +158,8 @@ const sanitizeAsset = (asset) => {
location: location || null,
department: departments || null,
assigned_to_user: users_assets_assigned_to_user_idTousers || null,
maintenance_incharge_user: users_assets_maintenance_incharge_idTousers || null,
maintenance: buildMaintenanceSummary(rest, asset_maintenance_logs),
vendor: vendors_assets_vendor_idTovendors || null,
purchase_order: purchase_orders || null,
grn: grn || null,
@ -113,12 +172,15 @@ const sanitizeAsset = (asset) => {
purchase_cost: purchaseCost,
salvage_value: salvageValue,
useful_life_years: rest.useful_life_years,
commencement_date: rest.commencement_date,
purchase_date: rest.purchase_date,
}),
item_categories: undefined,
item_subcategories: undefined,
departments: undefined,
users_assets_assigned_to_user_idTousers: undefined,
users_assets_maintenance_incharge_idTousers: undefined,
asset_maintenance_logs: undefined,
vendors_assets_vendor_idTovendors: undefined,
purchase_orders: undefined,
users_assets_created_byTousers: undefined,
@ -187,6 +249,33 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => {
await assertReference('departments', payload.department_id, 'department_id');
if (payload.assigned_to_user_id)
await assertReference('users', payload.assigned_to_user_id, 'assigned_to_user_id');
if (payload.maintenance_incharge_user_id)
await assertReference(
'users',
payload.maintenance_incharge_user_id,
'maintenance_incharge_user_id'
);
let checklistJson = null;
if (payload.maintenance_checklist_json !== undefined) {
if (payload.maintenance_checklist_json === null) {
checklistJson = null;
} else if (!Array.isArray(payload.maintenance_checklist_json)) {
throw new ApiError(422, 'maintenance_checklist_json must be an array');
} else {
const keys = new Set();
checklistJson = payload.maintenance_checklist_json.map((item, index) => {
const key = String(item.key || '').trim();
const label = String(item.label || '').trim();
if (!key) throw new ApiError(422, `Checklist item ${index + 1}: key is required`);
if (!label) throw new ApiError(422, `Checklist item ${index + 1}: label is required`);
if (keys.has(key)) throw new ApiError(422, `Duplicate checklist key: ${key}`);
keys.add(key);
return { key, label, required: item.required !== false };
});
}
}
if (payload.vendor_id) await assertReference('vendors', payload.vendor_id, 'vendor_id');
if (payload.po_id) await assertReference('purchase_orders', payload.po_id, 'po_id');
if (payload.grn_id) await assertReference('grn', payload.grn_id, 'grn_id');
@ -234,10 +323,22 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => {
department_id: payload.department_id ? BigInt(payload.department_id) : null,
location_detail: payload.location_detail || null,
assigned_to_user_id: payload.assigned_to_user_id ? BigInt(payload.assigned_to_user_id) : null,
maintenance_incharge_user_id: payload.maintenance_incharge_user_id
? BigInt(payload.maintenance_incharge_user_id)
: null,
maintenance_frequency_in_days:
payload.maintenance_frequency_in_days !== undefined &&
payload.maintenance_frequency_in_days !== null &&
payload.maintenance_frequency_in_days !== ''
? Number(payload.maintenance_frequency_in_days)
: null,
maintenance_checklist_json:
payload.maintenance_checklist_json !== undefined ? checklistJson : null,
vendor_id: payload.vendor_id ? BigInt(payload.vendor_id) : null,
po_id: payload.po_id ? BigInt(payload.po_id) : null,
grn_id: payload.grn_id ? BigInt(payload.grn_id) : null,
grn_item_id: payload.grn_item_id ? BigInt(payload.grn_item_id) : null,
commencement_date: payload.commencement_date ? toDateOnly(payload.commencement_date) : null,
purchase_date: payload.purchase_date ? toDateOnly(payload.purchase_date) : null,
purchase_cost: purchaseCost,
useful_life_years: usefulLifeYears,
@ -307,6 +408,9 @@ const buildAssetsWhere = (query) => ({
: {}),
...(query.location_id ? { location_id: BigInt(query.location_id) } : {}),
...(query.department_id ? { department_id: BigInt(query.department_id) } : {}),
...(query.maintenance_incharge_user_id
? { maintenance_incharge_user_id: BigInt(query.maintenance_incharge_user_id) }
: {}),
...(query.is_active !== undefined ? { is_active: query.is_active } : {}),
...(query.search
? {
@ -330,10 +434,27 @@ const listAssets = async (query) => {
include: assetInclude,
orderBy: { created_at: 'desc' },
});
const data = rows.map(sanitizeAsset);
let data = rows.map(sanitizeAsset);
if (query.due_only === true) {
data = data.filter((row) => row.maintenance?.is_due === true);
}
return { data, meta: { page: 1, limit: data.length, total: data.length, dropdown: true } };
}
if (query.due_only === true) {
const rows = await prisma.assets.findMany({
where,
include: assetInclude,
orderBy: { created_at: 'desc' },
});
const due = rows.map(sanitizeAsset).filter((row) => row.maintenance?.is_due === true);
const { page, limit, skip } = getPagination(query);
return {
data: due.slice(skip, skip + limit),
meta: { page, limit, total: due.length },
};
}
const { page, limit, skip } = getPagination(query);
const [rows, total] = await Promise.all([
@ -372,9 +493,16 @@ const exportAssets = async (query) => {
{ key: (row) => row.vendor?.vendor_name || '', header: 'Vendor' },
{ key: (row) => row.purchase_order?.po_number || '', header: 'PO Number' },
{ key: (row) => row.grn?.grn_number || '', header: 'GRN Number' },
{ key: 'commencement_date', header: 'Commencement Date', type: 'date' },
{ key: 'purchase_date', header: 'Purchase Date', type: 'date' },
{ key: 'purchase_cost', header: 'Purchase Cost' },
{ key: 'warranty_expiry_date', header: 'Warranty Expiry', type: 'date' },
{
key: (row) => row.maintenance_incharge_user?.full_name || '',
header: 'Maintenance Incharge',
},
{ key: (row) => row.maintenance?.frequency_in_days ?? '', header: 'Maintenance Freq (days)' },
{ key: (row) => row.maintenance?.next_due_date || '', header: 'Maintenance Next Due', type: 'date' },
{ key: 'is_active', header: 'Active' },
{ key: 'created_at', header: 'Created At', type: 'datetime' },
],
@ -407,10 +535,26 @@ const updateAsset = async (id, payload, userId, requestId) => {
payload.assigned_to_user_id !== undefined
? payload.assigned_to_user_id
: existing.assigned_to_user_id,
maintenance_incharge_user_id:
payload.maintenance_incharge_user_id !== undefined
? payload.maintenance_incharge_user_id
: existing.maintenance_incharge_user_id,
maintenance_frequency_in_days:
payload.maintenance_frequency_in_days !== undefined
? payload.maintenance_frequency_in_days
: existing.maintenance_frequency_in_days,
maintenance_checklist_json:
payload.maintenance_checklist_json !== undefined
? payload.maintenance_checklist_json
: existing.maintenance_checklist_json,
vendor_id: payload.vendor_id !== undefined ? payload.vendor_id : existing.vendor_id,
po_id: payload.po_id !== undefined ? payload.po_id : existing.po_id,
grn_id: payload.grn_id !== undefined ? payload.grn_id : existing.grn_id,
grn_item_id: payload.grn_item_id !== undefined ? payload.grn_item_id : existing.grn_item_id,
commencement_date:
payload.commencement_date !== undefined
? payload.commencement_date
: existing.commencement_date,
purchase_date:
payload.purchase_date !== undefined ? payload.purchase_date : existing.purchase_date,
purchase_cost: payload.purchase_cost ?? Number(existing.purchase_cost),
@ -613,4 +757,5 @@ module.exports = {
listVisitConditionsAfter,
listAssetOptions,
previewDepreciation,
sanitizeAsset,
};

View File

@ -10,10 +10,26 @@ const {
VISIT_STATUSES,
VISIT_CONDITION_AFTER,
INSURANCE_POLICY_TYPES,
MAINTENANCE_CHECKLIST_STATUSES,
ALERT_TYPES,
SERVICE_ALERT_STATUSES,
} = require('./assets.constants');
const checklistTemplateItemSchema = Joi.object({
key: Joi.string().trim().max(50).required(),
label: Joi.string().trim().max(200).required(),
required: Joi.boolean().default(true),
}).unknown(true);
const checklistResultItemSchema = Joi.object({
key: Joi.string().trim().max(50).required(),
label: Joi.string().trim().max(200).optional(),
status: Joi.string()
.valid(...MAINTENANCE_CHECKLIST_STATUSES)
.required(),
remarks: Joi.string().allow(null, '').max(500).optional(),
}).unknown(true);
const assetFields = {
asset_name: Joi.string().max(200).required(),
item_category_id: Joi.number().integer().positive().required(),
@ -26,10 +42,18 @@ const assetFields = {
department_id: Joi.number().integer().positive().allow(null).optional(),
location_detail: Joi.string().max(200).allow(null, '').optional(),
assigned_to_user_id: Joi.number().integer().positive().allow(null).optional(),
maintenance_incharge_user_id: Joi.number().integer().positive().allow(null).optional(),
maintenance_frequency_in_days: Joi.number().integer().min(1).max(3650).allow(null).optional(),
maintenance_checklist_json: Joi.array()
.items(checklistTemplateItemSchema)
.max(100)
.allow(null)
.optional(),
vendor_id: Joi.number().integer().positive().allow(null).optional(),
po_id: Joi.number().integer().positive().allow(null).optional(),
grn_id: Joi.number().integer().positive().allow(null).optional(),
grn_item_id: Joi.number().integer().positive().allow(null).optional(),
commencement_date: Joi.date().iso().allow(null).optional(),
purchase_date: Joi.date().iso().allow(null).optional(),
purchase_cost: Joi.number().min(0).default(0),
useful_life_years: Joi.number().integer().min(0).allow(null).optional(),
@ -68,10 +92,14 @@ const updateAssetSchema = Joi.object({
department_id: assetFields.department_id,
location_detail: assetFields.location_detail,
assigned_to_user_id: assetFields.assigned_to_user_id,
maintenance_incharge_user_id: assetFields.maintenance_incharge_user_id,
maintenance_frequency_in_days: assetFields.maintenance_frequency_in_days,
maintenance_checklist_json: assetFields.maintenance_checklist_json,
vendor_id: assetFields.vendor_id,
po_id: assetFields.po_id,
grn_id: assetFields.grn_id,
grn_item_id: assetFields.grn_item_id,
commencement_date: assetFields.commencement_date,
purchase_date: assetFields.purchase_date,
purchase_cost: assetFields.purchase_cost.optional(),
useful_life_years: assetFields.useful_life_years,
@ -103,6 +131,8 @@ const listAssetsQuerySchema = Joi.object({
item_subcategory_id: Joi.number().integer().positive().optional(),
location_id: Joi.number().integer().positive().optional(),
department_id: Joi.number().integer().positive().optional(),
maintenance_incharge_user_id: Joi.number().integer().positive().optional(),
due_only: Joi.boolean().optional(),
is_active: Joi.boolean().optional(),
});
@ -282,10 +312,35 @@ const depreciationCalculateSchema = Joi.object({
purchase_cost: Joi.number().min(0).default(0),
salvage_value: Joi.number().min(0).default(0),
useful_life_years: Joi.number().integer().min(0).allow(null).optional(),
commencement_date: Joi.date().iso().allow(null).optional(),
purchase_date: Joi.date().iso().allow(null).optional(),
as_of_date: Joi.date().iso().allow(null).optional(),
});
const myMaintenanceAssetsQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
due_only: Joi.boolean().optional(),
status: Joi.string()
.valid(...ASSET_STATUSES)
.optional(),
location_id: Joi.number().integer().positive().optional(),
is_active: Joi.boolean().optional(),
});
const createMaintenanceLogSchema = Joi.object({
performed_date: Joi.date().iso().required(),
checklist_json: Joi.array().items(checklistResultItemSchema).min(1).required(),
remarks: Joi.string().allow(null, '').optional(),
next_due_date: Joi.date().iso().allow(null).optional(),
});
const listMaintenanceLogsQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
});
const expiryAlertsQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
@ -320,6 +375,9 @@ module.exports = {
updateInsurancePolicySchema,
renewInsurancePolicySchema,
depreciationCalculateSchema,
myMaintenanceAssetsQuerySchema,
createMaintenanceLogSchema,
listMaintenanceLogsQuerySchema,
expiryAlertsQuerySchema,
serviceAlertsQuerySchema,
};

View File

@ -90,6 +90,17 @@ const normalizePayload = (payload, { isCreate = false } = {}) => {
}
}
if (Object.prototype.hasOwnProperty.call(data, 'tags')) {
if (Array.isArray(data.tags)) {
const cleaned = data.tags
.map((tag) => (tag === null || tag === undefined ? '' : String(tag).trim()))
.filter((tag) => tag !== '');
data.tags = Array.from(new Set(cleaned));
} else if (data.tags === null) {
data.tags = null;
}
}
if (data.min_order_qty !== undefined && data.min_order_qty !== null) {
data.min_order_qty = Number(data.min_order_qty);
}
@ -253,6 +264,7 @@ const exportItems = async (query) => {
{ key: (row) => row.uom?.code || '', header: 'UOM' },
{ key: (row) => row.hsn_code?.code || '', header: 'HSN Code' },
{ key: (row) => (row.gst_rate ? row.gst_rate.rate_pct : ''), header: 'GST %' },
{ key: (row) => (Array.isArray(row.tags) ? row.tags.join(', ') : ''), header: 'Tags' },
{ key: 'is_asset_item', header: 'Is Asset Item' },
{ key: 'is_active', header: 'Is Active' },
{ key: 'created_at', header: 'Created At', type: 'datetime' },

View File

@ -11,6 +11,7 @@ const createSchema = Joi.object({
is_asset_item: Joi.boolean().default(false),
description: Joi.string().allow(null, '').optional(),
specification: Joi.string().allow(null, '').optional(),
tags: Joi.array().items(Joi.string().trim().max(50)).max(50).allow(null).optional(),
min_order_qty: Joi.number().precision(4).allow(null).optional(),
reorder_level: Joi.number().precision(4).allow(null).optional(),
is_active: Joi.boolean().optional(),
@ -26,6 +27,7 @@ const updateSchema = Joi.object({
is_asset_item: Joi.boolean().optional(),
description: Joi.string().allow(null, '').optional(),
specification: Joi.string().allow(null, '').optional(),
tags: Joi.array().items(Joi.string().trim().max(50)).max(50).allow(null).optional(),
min_order_qty: Joi.number().precision(4).allow(null).optional(),
reorder_level: Joi.number().precision(4).allow(null).optional(),
is_active: Joi.boolean().optional(),

View File

@ -0,0 +1,12 @@
const {
INDIAN_STATE_OPTIONS,
INDIAN_STATE_VALUES,
} = require('../../../constants/indianStates');
module.exports = {
INDIAN_STATE_OPTIONS,
INDIAN_STATE_VALUES,
/** Alias used by location state dropdown API */
LOCATION_STATE_OPTIONS: INDIAN_STATE_OPTIONS,
LOCATION_STATE_VALUES: INDIAN_STATE_VALUES,
};

View File

@ -34,5 +34,10 @@ const exportCsv = asyncHandler(async (req, res) => {
res.send(csv);
});
module.exports = { create, list, exportCsv, getOne, update, remove };
const listStateOptions = asyncHandler(async (_req, res) => {
const data = service.listStateOptions();
res.json(new ApiResponse(200, data, 'Location state options fetched'));
});
module.exports = { create, list, exportCsv, getOne, update, remove, listStateOptions };

View File

@ -13,6 +13,7 @@ const {
const router = express.Router();
router.use(authenticate);
router.get('/states', authorize('MASTERS', 'view'), controller.listStateOptions);
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
router.get(
'/export',

View File

@ -11,6 +11,7 @@ const {
toWarehouseResponse,
toLocationResponse,
} = require('../../../utils/locations');
const { LOCATION_STATE_OPTIONS } = require('./locations.constants');
const TABLE_NAME = 'locations';
@ -347,4 +348,5 @@ module.exports = {
getLocationById: (id) => getLocationById(id),
updateLocationById,
deleteLocationById,
listStateOptions: () => LOCATION_STATE_OPTIONS,
};

View File

@ -1,11 +1,15 @@
const Joi = require('joi');
const { masterCode, masterName, listQuerySchema, toExportQuerySchema } = require('../_shared/masters.validation');
const { LOCATION_STATE_VALUES } = require('./locations.constants');
const locationFields = {
gstin: Joi.string().max(15).allow(null, '').optional(),
address: Joi.string().allow(null, '').optional(),
city: Joi.string().max(100).allow(null, '').optional(),
state: Joi.string().max(100).allow(null, '').optional(),
state: Joi.string()
.valid(...LOCATION_STATE_VALUES)
.allow(null, '')
.optional(),
pincode: Joi.string().max(10).allow(null, '').optional(),
phone: Joi.string().max(15).allow(null, '').optional(),
location: Joi.string().max(200).allow(null, '').optional(),

View File

@ -56,6 +56,7 @@ const sanitizeDepreciationRow = (asset, asOfDate) => {
purchase_cost: purchaseCost,
salvage_value: salvageValue,
useful_life_years: asset.useful_life_years,
commencement_date: asset.commencement_date,
purchase_date: asset.purchase_date,
as_of_date: asOfDate,
});
@ -65,6 +66,7 @@ const sanitizeDepreciationRow = (asset, asOfDate) => {
asset_code: asset.asset_code,
asset_name: asset.asset_name,
status: asset.status,
commencement_date: asset.commencement_date,
purchase_date: asset.purchase_date,
purchase_cost: purchaseCost,
salvage_value: salvageValue,
@ -203,6 +205,7 @@ const exportAssetDepreciation = async (query) => {
{ key: (row) => row.item_subcategory?.name || '', header: 'Subcategory' },
{ key: (row) => row.location?.name || '', header: 'Location' },
{ key: (row) => row.department?.name || '', header: 'Department' },
{ key: 'commencement_date', header: 'Commencement Date', type: 'date' },
{ key: 'purchase_date', header: 'Purchase Date', type: 'date' },
{ key: 'purchase_cost', header: 'Purchase Cost' },
{ key: 'salvage_value', header: 'Salvage Value' },

View File

@ -9,47 +9,12 @@ const GST_TREATMENTS = [
{ value: 'OTHER', label: 'Other' },
];
const SOURCE_OF_SUPPLY_OPTIONS = [
{ value: 'Andhra Pradesh', label: 'Andhra Pradesh' },
{ value: 'Arunachal Pradesh', label: 'Arunachal Pradesh' },
{ value: 'Assam', label: 'Assam' },
{ value: 'Bihar', label: 'Bihar' },
{ value: 'Chhattisgarh', label: 'Chhattisgarh' },
{ value: 'Goa', label: 'Goa' },
{ value: 'Gujarat', label: 'Gujarat' },
{ value: 'Haryana', label: 'Haryana' },
{ value: 'Himachal Pradesh', label: 'Himachal Pradesh' },
{ value: 'Jharkhand', label: 'Jharkhand' },
{ value: 'Karnataka', label: 'Karnataka' },
{ value: 'Kerala', label: 'Kerala' },
{ value: 'Madhya Pradesh', label: 'Madhya Pradesh' },
{ value: 'Maharashtra', label: 'Maharashtra' },
{ value: 'Manipur', label: 'Manipur' },
{ value: 'Meghalaya', label: 'Meghalaya' },
{ value: 'Mizoram', label: 'Mizoram' },
{ value: 'Nagaland', label: 'Nagaland' },
{ value: 'Odisha', label: 'Odisha' },
{ value: 'Punjab', label: 'Punjab' },
{ value: 'Rajasthan', label: 'Rajasthan' },
{ value: 'Sikkim', label: 'Sikkim' },
{ value: 'Tamil Nadu', label: 'Tamil Nadu' },
{ value: 'Telangana', label: 'Telangana' },
{ value: 'Tripura', label: 'Tripura' },
{ value: 'Uttar Pradesh', label: 'Uttar Pradesh' },
{ value: 'Uttarakhand', label: 'Uttarakhand' },
{ value: 'West Bengal', label: 'West Bengal' },
{ value: 'Andaman and Nicobar Islands', label: 'Andaman and Nicobar Islands' },
{ value: 'Chandigarh', label: 'Chandigarh' },
{ value: 'Dadra and Nagar Haveli and Daman and Diu', label: 'Dadra and Nagar Haveli and Daman and Diu' },
{ value: 'Delhi', label: 'Delhi' },
{ value: 'Jammu and Kashmir', label: 'Jammu and Kashmir' },
{ value: 'Ladakh', label: 'Ladakh' },
{ value: 'Lakshadweep', label: 'Lakshadweep' },
{ value: 'Puducherry', label: 'Puducherry' },
];
const {
INDIAN_STATE_OPTIONS: SOURCE_OF_SUPPLY_OPTIONS,
INDIAN_STATE_VALUES: SOURCE_OF_SUPPLY_VALUES,
} = require('../../constants/indianStates');
const GST_TREATMENT_VALUES = GST_TREATMENTS.map((item) => item.value);
const SOURCE_OF_SUPPLY_VALUES = SOURCE_OF_SUPPLY_OPTIONS.map((item) => item.value);
module.exports = {
GST_TREATMENTS,