GWM : Maintenance checklist json restructured
This commit is contained in:
parent
8de3afedb3
commit
ca461f3413
58
scripts/patch-maintenance-checklist-drop-key.sql
Normal file
58
scripts/patch-maintenance-checklist-drop-key.sql
Normal 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;
|
||||
@ -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 } }
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user