GWM : Maintenance checklist json restructured

This commit is contained in:
Gowtham M 2026-07-21 10:07:37 +05:30
parent 8de3afedb3
commit ca461f3413
6 changed files with 145 additions and 63 deletions

View File

@ -0,0 +1,58 @@
-- Strip legacy `key` from maintenance checklist JSON (template + logs).
-- New shape: [{ "label": "...", "required": true }]
-- Idempotent: safe to re-run.
BEGIN;
-- Asset checklist templates
UPDATE assets
SET maintenance_checklist_json = sub.cleaned
FROM (
SELECT
a.id,
jsonb_agg(
jsonb_strip_nulls(
jsonb_build_object(
'label', COALESCE(NULLIF(trim(elem->>'label'), ''), NULLIF(trim(elem->>'key'), '')),
'required', COALESCE((elem->>'required')::boolean, true)
)
)
ORDER BY ord
) AS cleaned
FROM assets a
CROSS JOIN LATERAL jsonb_array_elements(a.maintenance_checklist_json) WITH ORDINALITY AS t(elem, ord)
WHERE a.maintenance_checklist_json IS NOT NULL
AND jsonb_typeof(a.maintenance_checklist_json) = 'array'
AND jsonb_array_length(a.maintenance_checklist_json) > 0
GROUP BY a.id
) sub
WHERE assets.id = sub.id
AND sub.cleaned IS NOT NULL;
-- Maintenance log results
UPDATE asset_maintenance_logs
SET checklist_json = sub.cleaned
FROM (
SELECT
l.id,
jsonb_agg(
jsonb_strip_nulls(
jsonb_build_object(
'label', COALESCE(NULLIF(trim(elem->>'label'), ''), NULLIF(trim(elem->>'key'), '')),
'status', elem->>'status',
'remarks', NULLIF(elem->>'remarks', '')
)
)
ORDER BY ord
) AS cleaned
FROM asset_maintenance_logs l
CROSS JOIN LATERAL jsonb_array_elements(l.checklist_json) WITH ORDINALITY AS t(elem, ord)
WHERE l.checklist_json IS NOT NULL
AND jsonb_typeof(l.checklist_json) = 'array'
AND jsonb_array_length(l.checklist_json) > 0
GROUP BY l.id
) sub
WHERE asset_maintenance_logs.id = sub.id
AND sub.cleaned IS NOT NULL;
COMMIT;

View File

@ -23,13 +23,12 @@ components:
maintenance_checklist_json:
type: array
nullable: true
description: 'Checklist TEMPLATE stored on the asset'
description: 'Checklist TEMPLATE stored on the asset. Unique by label (case-insensitive). No `key` field.'
items:
type: object
required: [key, label]
required: [label]
properties:
key: { type: string, example: 'oil_level' }
label: { type: string, example: 'Check oil level' }
label: { type: string, example: 'Check CO2 level' }
required: { type: boolean, example: true }
vendor_id: { type: integer, nullable: true }
po_id: { type: integer, nullable: true }
@ -73,9 +72,8 @@ components:
nullable: true
items:
type: object
required: [key, label]
required: [label]
properties:
key: { type: string }
label: { type: string }
required: { type: boolean }
vendor_id: { type: integer, nullable: true }
@ -122,10 +120,9 @@ components:
minItems: 1
items:
type: object
required: [key, status]
required: [label, status]
properties:
key: { type: string, example: 'oil_level' }
label: { type: string, example: 'Check oil level' }
label: { type: string, example: 'Check CO2 level' }
status: { type: string, enum: [OK, NOT_OK, NA] }
remarks: { type: string, nullable: true }
TransferAssetBody:
@ -612,7 +609,7 @@ paths:
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.
Required checklist labels 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 } }

View File

@ -2,8 +2,8 @@ const prisma = require('../../config/prisma');
const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog');
const { getPagination, isDropdownCall } = require('../../utils/pagination');
const { toDateOnly, assertReference, getAssetOrThrow, normalizeChecklistTemplate, presentChecklistResults } = require('./assets.helpers');
const { MAINTENANCE_CHECKLIST_STATUSES } = require('./assets.constants');
const { toDateOnly, getAssetOrThrow } = require('./assets.helpers');
const { sanitizeAsset } = require('./assets.service');
const logInclude = {
@ -43,6 +43,7 @@ const sanitizeLog = (row) => {
} = row;
return {
...rest,
checklist_json: presentChecklistResults(rest.checklist_json),
performed_by_user: users_asset_maintenance_logs_performed_byTousers || null,
created_by_user: users_asset_maintenance_logs_created_byTousers || null,
};
@ -61,59 +62,46 @@ const addDays = (value, 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 templateMap = new Map(
templateItems.map((item) => [String(item.label).trim().toLowerCase(), item])
);
const resultLabels = 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);
const label = String(row.label || '').trim();
if (!label) throw new ApiError(422, 'Each checklist result requires a label');
const labelKey = label.toLowerCase();
if (resultLabels.has(labelKey)) {
throw new ApiError(422, `Duplicate checklist result label: ${label}`);
}
resultLabels.add(labelKey);
if (!MAINTENANCE_CHECKLIST_STATUSES.includes(row.status)) {
throw new ApiError(422, `Invalid checklist status for ${key}`);
throw new ApiError(422, `Invalid checklist status for "${label}"`);
}
const templateItem = templateMap.get(labelKey);
if (!templateItem) {
throw new ApiError(422, `Unknown checklist item: ${label}`);
}
const templateItem = templateMap.get(key);
normalized.push({
key,
label: row.label || templateItem?.label || key,
label: templateItem.label,
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}`);
if (item.required !== false && !resultLabels.has(String(item.label).trim().toLowerCase())) {
throw new ApiError(422, `Required checklist item missing: ${item.label}`);
}
}

View File

@ -51,10 +51,61 @@ const deactivateOtherActive = async (tx, table, assetId, excludeId = null) => {
});
};
/**
* Checklist template on assets: [{ label, required }] no `key`.
* Strips legacy `key` if present; uniqueness is by label (case-insensitive).
*/
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 labels = new Set();
return template.map((item, index) => {
const label = String(item?.label || item?.key || '').trim();
if (!label) throw new ApiError(422, `Checklist item ${index + 1}: label is required`);
const labelKey = label.toLowerCase();
if (labels.has(labelKey)) {
throw new ApiError(422, `Duplicate checklist label: ${label}`);
}
labels.add(labelKey);
return {
label,
required: item.required !== false,
};
});
};
/**
* Soft-strip legacy `key` for API responses (no uniqueness validation).
*/
const presentChecklistTemplate = (template) => {
if (template === null || template === undefined) return null;
if (!Array.isArray(template)) return template;
return template
.map((item) => ({
label: String(item?.label || item?.key || '').trim(),
required: item?.required !== false,
}))
.filter((item) => item.label);
};
const presentChecklistResults = (results) => {
if (!Array.isArray(results)) return results ?? null;
return results.map((item) => ({
label: String(item?.label || item?.key || '').trim(),
status: item?.status,
remarks: item?.remarks ?? null,
}));
};
module.exports = {
toDateOnly,
assertReference,
getAssetOrThrow,
deactivateOtherActive,
normalizeChecklistTemplate,
presentChecklistTemplate,
presentChecklistResults,
SOFT_DELETE_TABLES,
};

View File

@ -12,7 +12,7 @@ const {
resolveDepreciationRate,
calculateDepreciation,
} = require('./assets.depreciation');
const { assertReference, toDateOnly } = require('./assets.helpers');
const { assertReference, toDateOnly, normalizeChecklistTemplate, presentChecklistTemplate } = require('./assets.helpers');
const { sanitizeAttachment } = require('./assets.attachments.service');
const assetInclude = {
@ -108,7 +108,7 @@ const buildMaintenanceSummary = (rest, logs) => {
return {
frequency_in_days: frequency,
checklist: rest.maintenance_checklist_json ?? null,
checklist: presentChecklistTemplate(rest.maintenance_checklist_json),
last_maintenance_date: lastMaintenanceDate,
next_due_date: nextDueDate,
is_due: isDue,
@ -150,6 +150,7 @@ const sanitizeAsset = (asset) => {
return {
...rest,
maintenance_checklist_json: presentChecklistTemplate(rest.maintenance_checklist_json),
purchase_cost: purchaseCost,
salvage_value: salvageValue,
depreciation_rate: depreciationRate,
@ -260,19 +261,8 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => {
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 };
});
checklistJson = normalizeChecklistTemplate(payload.maintenance_checklist_json);
}
}

View File

@ -16,19 +16,17 @@ const {
} = 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(),
label: Joi.string().trim().max(200).required(),
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(),