diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cb1dc4c..a2692e1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] diff --git a/scripts/patch-items-tags-assets-maintenance.sql b/scripts/patch-items-tags-assets-maintenance.sql new file mode 100644 index 0000000..f7941a9 --- /dev/null +++ b/scripts/patch-items-tags-assets-maintenance.sql @@ -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'); diff --git a/src/constants/indianStates.js b/src/constants/indianStates.js new file mode 100644 index 0000000..97900e7 --- /dev/null +++ b/src/constants/indianStates.js @@ -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, +}; diff --git a/src/docs/assets-routes.yaml b/src/docs/assets-routes.yaml index 22c9a05..95a2072 100644 --- a/src/docs/assets-routes.yaml +++ b/src/docs/assets-routes.yaml @@ -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] diff --git a/src/docs/completed-routes.yaml b/src/docs/completed-routes.yaml index 322f63d..6e294d6 100644 --- a/src/docs/completed-routes.yaml +++ b/src/docs/completed-routes.yaml @@ -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] diff --git a/src/modules/assets/assets-maintenance.service.js b/src/modules/assets/assets-maintenance.service.js new file mode 100644 index 0000000..3ecdbcc --- /dev/null +++ b/src/modules/assets/assets-maintenance.service.js @@ -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, +}; diff --git a/src/modules/assets/assets.constants.js b/src/modules/assets/assets.constants.js index 7853c39..9c48714 100644 --- a/src/modules/assets/assets.constants.js +++ b/src/modules/assets/assets.constants.js @@ -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, }; diff --git a/src/modules/assets/assets.controller.js b/src/modules/assets/assets.controller.js index 12a5f9c..a8c5e0d 100644 --- a/src/modules/assets/assets.controller.js +++ b/src/modules/assets/assets.controller.js @@ -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, }; diff --git a/src/modules/assets/assets.depreciation.js b/src/modules/assets/assets.depreciation.js index 1dcadc9..ab08bfa 100644 --- a/src/modules/assets/assets.depreciation.js +++ b/src/modules/assets/assets.depreciation.js @@ -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, }; }; diff --git a/src/modules/assets/assets.routes.js b/src/modules/assets/assets.routes.js index c4d5496..2e19eac 100644 --- a/src/modules/assets/assets.routes.js +++ b/src/modules/assets/assets.routes.js @@ -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); diff --git a/src/modules/assets/assets.service.js b/src/modules/assets/assets.service.js index 6ca806a..b1633cd 100644 --- a/src/modules/assets/assets.service.js +++ b/src/modules/assets/assets.service.js @@ -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, }; diff --git a/src/modules/assets/assets.validation.js b/src/modules/assets/assets.validation.js index f1377d5..741326d 100644 --- a/src/modules/assets/assets.validation.js +++ b/src/modules/assets/assets.validation.js @@ -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, }; diff --git a/src/modules/masters/items/items.service.js b/src/modules/masters/items/items.service.js index ee24d0c..8f4234d 100644 --- a/src/modules/masters/items/items.service.js +++ b/src/modules/masters/items/items.service.js @@ -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' }, diff --git a/src/modules/masters/items/items.validation.js b/src/modules/masters/items/items.validation.js index 9c79bfe..f5efd79 100644 --- a/src/modules/masters/items/items.validation.js +++ b/src/modules/masters/items/items.validation.js @@ -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(), diff --git a/src/modules/masters/locations/locations.constants.js b/src/modules/masters/locations/locations.constants.js new file mode 100644 index 0000000..c505452 --- /dev/null +++ b/src/modules/masters/locations/locations.constants.js @@ -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, +}; diff --git a/src/modules/masters/locations/locations.controller.js b/src/modules/masters/locations/locations.controller.js index 427d3ee..e4a7087 100644 --- a/src/modules/masters/locations/locations.controller.js +++ b/src/modules/masters/locations/locations.controller.js @@ -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 }; diff --git a/src/modules/masters/locations/locations.routes.js b/src/modules/masters/locations/locations.routes.js index 05a6d53..177cfb7 100644 --- a/src/modules/masters/locations/locations.routes.js +++ b/src/modules/masters/locations/locations.routes.js @@ -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', diff --git a/src/modules/masters/locations/locations.service.js b/src/modules/masters/locations/locations.service.js index 31ab03f..3965b91 100644 --- a/src/modules/masters/locations/locations.service.js +++ b/src/modules/masters/locations/locations.service.js @@ -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, }; diff --git a/src/modules/masters/locations/locations.validation.js b/src/modules/masters/locations/locations.validation.js index 09a2385..83f75ca 100644 --- a/src/modules/masters/locations/locations.validation.js +++ b/src/modules/masters/locations/locations.validation.js @@ -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(), diff --git a/src/modules/reports/reports-assets-depreciation.service.js b/src/modules/reports/reports-assets-depreciation.service.js index 3c21671..18d3878 100644 --- a/src/modules/reports/reports-assets-depreciation.service.js +++ b/src/modules/reports/reports-assets-depreciation.service.js @@ -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' }, diff --git a/src/modules/vendors/vendors.constants.js b/src/modules/vendors/vendors.constants.js index d7fae07..d263bc9 100644 --- a/src/modules/vendors/vendors.constants.js +++ b/src/modules/vendors/vendors.constants.js @@ -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,