diff --git a/tests/unit/handlers/spare.parts.handlers.test.js b/tests/unit/handlers/spare.parts.handlers.test.js index 66da474..15399d3 100644 --- a/tests/unit/handlers/spare.parts.handlers.test.js +++ b/tests/unit/handlers/spare.parts.handlers.test.js @@ -12,6 +12,12 @@ const PART = { rack: PART_RACK, info: { location: 'Lab', status: 'active' } } +const REPLACED_PART = { + id: 'p-out', + code: 'CB-AM-CB_1111-02', + rack: PART_RACK, + info: { location: 'miner.room', status: 'faulty' } +} const OPEN_WO = { id: 'wo-1', code: 'IVI-2-0001', @@ -32,12 +38,13 @@ const userMeta = (email = 'op@test') => ({ _info: { authToken: 'tok', user: { metadata: { email } } } }) -function buildCtx ({ wo = OPEN_WO, part = PART, pushResults = {} } = {}) { +function buildCtx ({ wo = OPEN_WO, part = PART, replaced = REPLACED_PART, pushResults = {} } = {}) { const pushed = [] const handler = async (_key, method, params) => { if (method === 'listThings') { if (params.query?.type === 'inventory-work_order') return wo ? [wo] : [] if (params.query?.id === PART.id) return part ? [part] : [] + if (params.query?.code) return replaced && params.query.code === replaced.code ? [replaced] : [] return [] } if (method === 'pushAction') { @@ -153,6 +160,83 @@ test('handlers: updateSparePart skips WO checks when only non-move fields change t.absent(pushed[0].params[0].info.workOrderId, 'no WO injected for non-move') }) +test('handlers: updateSparePart records top-level remarks on the move entry and keeps it off the part', async (t) => { + const { ctx, pushed } = buildCtx() + await handlers.updateSparePart(ctx, { + ...userMeta(), + params: { id: PART.id }, + body: { rackId: PART_RACK, workOrderId: 'wo-1', remarks: 'Not reachable', info: { location: 'miner.room' } } + }) + const woAction = pushed.find(p => p.params[0].rackId === WO_RACK) + const move = woAction.params[0].info.partsMoves[0] + t.is(move.remarks, 'Not reachable') + t.is(move.role, 'original') + const partAction = pushed.find(p => p.params[0].rackId === PART_RACK) + t.absent(partAction.params[0].info.remarks, 'remarks are move metadata, not written to the part record') +}) + +test('handlers: updateSparePart falls back to info.reason for remarks', async (t) => { + const { ctx, pushed } = buildCtx() + await handlers.updateSparePart(ctx, { + ...userMeta(), + params: { id: PART.id }, + body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'miner.room', reason: 'hardware_error' } } + }) + const woAction = pushed.find(p => p.params[0].rackId === WO_RACK) + t.is(woAction.params[0].info.partsMoves[0].remarks, 'hardware_error') +}) + +test('handlers: updateSparePart marks a replacement and links the replaced part', async (t) => { + const { ctx, pushed } = buildCtx() + const out = await handlers.updateSparePart(ctx, { + ...userMeta(), + params: { id: PART.id }, + body: { rackId: PART_RACK, workOrderId: 'wo-1', replacesPartCode: REPLACED_PART.code, info: { location: 'site.warehouse' } } + }) + const woAction = pushed.find(p => p.params[0].rackId === WO_RACK) + const move = woAction.params[0].info.partsMoves[0] + t.is(move.role, 'replacement') + t.is(move.replacesPartCode, REPLACED_PART.code) + t.is(move.replacesPartId, REPLACED_PART.id, 'server resolves the replaced part id from its code') + t.is(out.move.role, 'replacement') +}) + +test('handlers: updateSparePart 400s when replacesPartCode does not resolve to a part', async (t) => { + const { ctx } = buildCtx({ replaced: null }) + await t.exception( + () => handlers.updateSparePart(ctx, { + ...userMeta(), + params: { id: PART.id }, + body: { rackId: PART_RACK, workOrderId: 'wo-1', replacesPartCode: 'NOPE-0001', info: { location: 'site.warehouse' } } + }), + /ERR_REPLACES_PART_NOT_FOUND/ + ) +}) + +test('handlers: updateSparePart rejects a part replacing itself', async (t) => { + const { ctx } = buildCtx() + await t.exception( + () => handlers.updateSparePart(ctx, { + ...userMeta(), + params: { id: PART.id }, + body: { rackId: PART_RACK, workOrderId: 'wo-1', replacesPartCode: PART.code, info: { location: 'site.warehouse' } } + }), + /ERR_REPLACES_PART_SELF/ + ) +}) + +test('handlers: updateSparePart treats a replacement as a move and requires a workOrderId', async (t) => { + const { ctx } = buildCtx() + await t.exception( + () => handlers.updateSparePart(ctx, { + ...userMeta(), + params: { id: PART.id }, + body: { rackId: PART_RACK, replacesPartCode: REPLACED_PART.code, info: {} } + }), + /ERR_PART_MOVE_REQUIRES_WO/ + ) +}) + function buildRegisterCtx ({ pushResult } = {}) { const pushed = [] const handler = async (_key, method, params) => { diff --git a/workers/lib/server/handlers/spare.parts.handlers.js b/workers/lib/server/handlers/spare.parts.handlers.js index 2817f56..f94d7e0 100644 --- a/workers/lib/server/handlers/spare.parts.handlers.js +++ b/workers/lib/server/handlers/spare.parts.handlers.js @@ -29,11 +29,19 @@ async function _loadSparePart (ctx, partId) { return flattenRpcResults(results).find(t => t?.type !== WORK_ORDER_THING_TYPE) || null } +async function _loadSparePartByCode (ctx, code) { + const results = await ctx.dataProxy.requestData('listThings', { + query: { code } + }) + return flattenRpcResults(results).find(t => t?.type !== WORK_ORDER_THING_TYPE) || null +} + async function updateSparePart (ctx, req) { const { id } = req.params - const { rackId, workOrderId, info } = req.body + const { rackId, workOrderId, info, remarks, replacesPartCode } = req.body - const movesPart = info.location !== undefined || info.status !== undefined + const isReplacement = typeof replacesPartCode === 'string' && replacesPartCode.length > 0 + const movesPart = info.location !== undefined || info.status !== undefined || isReplacement if (movesPart && !workOrderId) { const err = new Error('ERR_PART_MOVE_REQUIRES_WO') @@ -43,10 +51,12 @@ async function updateSparePart (ctx, req) { let workOrderCode let part + let replacedPart if (movesPart) { - const [wo, p] = await Promise.all([ + const [wo, p, rp] = await Promise.all([ _loadWorkOrder(ctx, workOrderId), - _loadSparePart(ctx, id) + _loadSparePart(ctx, id), + isReplacement ? _loadSparePartByCode(ctx, replacesPartCode) : Promise.resolve(null) ]) if (!wo) { const err = new Error('ERR_WORK_ORDER_NOT_FOUND') @@ -63,8 +73,21 @@ async function updateSparePart (ctx, req) { err.statusCode = 404 throw err } + if (isReplacement) { + if (replacesPartCode === p.code) { + const err = new Error('ERR_REPLACES_PART_SELF') + err.statusCode = 400 + throw err + } + if (!rp) { + const err = new Error('ERR_REPLACES_PART_NOT_FOUND') + err.statusCode = 400 + throw err + } + } workOrderCode = wo.code part = p + replacedPart = rp } const { permissions } = await ctx.authLib.getTokenPerms(req._info.authToken) @@ -97,6 +120,7 @@ async function updateSparePart (ctx, req) { throw err } + const moveRemarks = remarks ?? info.reason const moveEntry = { partId: id, partCode: part.code, @@ -106,10 +130,15 @@ async function updateSparePart (ctx, req) { toLocation: info.location ?? part.info?.location ?? null, fromStatus: part.info?.status ?? null, toStatus: info.status ?? part.info?.status ?? null, - role: 'original', + role: isReplacement ? 'replacement' : 'original', ts: Date.now(), user: voter } + if (moveRemarks !== undefined && moveRemarks !== null) moveEntry.remarks = moveRemarks + if (isReplacement) { + moveEntry.replacesPartCode = replacesPartCode + moveEntry.replacesPartId = replacedPart.id + } const woRackId = await getWorkOrderRackId(ctx) const wo = await _loadWorkOrder(ctx, workOrderId) diff --git a/workers/lib/server/schemas/spare.parts.schemas.js b/workers/lib/server/schemas/spare.parts.schemas.js index b5c59f1..3e51204 100644 --- a/workers/lib/server/schemas/spare.parts.schemas.js +++ b/workers/lib/server/schemas/spare.parts.schemas.js @@ -54,6 +54,8 @@ const update = { properties: { rackId: { type: 'string', minLength: 1 }, workOrderId: { type: 'string', minLength: 1 }, + remarks: { type: 'string', maxLength: 4000 }, + replacesPartCode: { type: 'string', minLength: 1, maxLength: 200 }, info: { type: 'object', additionalProperties: true,