From e112ec43861206a4c7a7d89fb6f400ca1380685e Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 18 Jul 2026 15:19:11 +0100 Subject: [PATCH] Implemented job editing functionality by adding an editJobRouteHandler and associated logic to update draft jobs. Enhanced job schema with a recalculate method to manage subjob creation and updates based on job quantity and printer assignments. Updated tests to cover new editing capabilities and ensure proper state checks. --- src/database/schemas/production/job.schema.js | 99 ++++++++++++++++++- src/routes/production/jobs.js | 5 + .../production/__tests__/jobs.test.js | 54 ++++++++-- src/services/production/jobs.js | 72 +++++++++----- 4 files changed, 195 insertions(+), 35 deletions(-) diff --git a/src/database/schemas/production/job.schema.js b/src/database/schemas/production/job.schema.js index 07ea0d2..71a2f02 100644 --- a/src/database/schemas/production/job.schema.js +++ b/src/database/schemas/production/job.schema.js @@ -1,7 +1,15 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; const { Schema } = mongoose; -import { aggregateRollups, aggregateRollupsHistory } from '../../database.js'; +import { + aggregateRollups, + aggregateRollupsHistory, + listObjects, + editObject, + newObject, + deleteObject, +} from '../../database.js'; +import { subJobModel } from './subjob.schema.js'; const jobSchema = new mongoose.Schema( { @@ -84,6 +92,95 @@ jobSchema.statics.history = async function (from, to) { return results; }; +const normalizeId = (value) => value?._id ?? value; + +jobSchema.statics.recalculate = async function (job, user) { + const jobId = job._id || job; + if (!jobId || job?.state?.type !== 'draft') { + return; + } + + const quantity = job.quantity || 1; + const printers = (job.printers || []).map((printer) => normalizeId(printer)); + const gcodeFile = normalizeId(job.gcodeFile); + + const existingSubJobs = await listObjects({ + model: subJobModel, + filter: { job: jobId }, + sort: 'number', + order: 'ascend', + pagination: false, + }); + + if (existingSubJobs?.error) { + throw existingSubJobs; + } + + const existingSubJobsByNumber = new Map( + existingSubJobs.map((subJob) => [subJob.number, subJob]) + ); + + var printerCount = 0; + + for (let i = 0; i < quantity; i++) { + const subJobNumber = i + 1; + const printer = printers[printerCount]; + const subJobUpdateData = { + updatedAt: new Date(), + printer, + gcodeFile, + number: subJobNumber, + }; + + const existingSubJob = existingSubJobsByNumber.get(subJobNumber); + + if (existingSubJob) { + const subJobResult = await editObject({ + model: subJobModel, + id: existingSubJob._id, + updateData: subJobUpdateData, + user, + }); + if (subJobResult.error) { + throw subJobResult; + } + } else { + const subJobResult = await newObject({ + model: subJobModel, + newData: { + createdAt: new Date(), + ...subJobUpdateData, + job: jobId, + state: { type: 'draft' }, + }, + user, + }); + if (subJobResult.error) { + throw subJobResult; + } + } + + if (printerCount >= printers.length - 1) { + printerCount = 0; + } else { + printerCount += 1; + } + } + + for (const existingSubJob of existingSubJobs) { + if (existingSubJob.number > quantity) { + const deleteResult = await deleteObject({ + model: subJobModel, + id: existingSubJob._id, + user, + }); + if (deleteResult.error) { + throw deleteResult; + } + } + } +}; + jobSchema.virtual('id').get(function () { return this._id; }); diff --git a/src/routes/production/jobs.js b/src/routes/production/jobs.js index 9ebb216..1f0d1a4 100644 --- a/src/routes/production/jobs.js +++ b/src/routes/production/jobs.js @@ -8,6 +8,7 @@ import { getJobRouteHandler, newJobRouteHandler, deleteJobRouteHandler, + editJobRouteHandler, getJobStatsRouteHandler, getJobHistoryRouteHandler, searchJobsRouteHandler @@ -52,6 +53,10 @@ router.get('/:id', isAuthenticated, (req, res) => { getJobRouteHandler(req, res); }); +router.put('/:id', isAuthenticated, async (req, res) => { + editJobRouteHandler(req, res); +}); + router.delete('/:id', isAuthenticated, async (req, res) => { deleteJobRouteHandler(req, res); }); diff --git a/src/services/production/__tests__/jobs.test.js b/src/services/production/__tests__/jobs.test.js index c40f175..584fec2 100644 --- a/src/services/production/__tests__/jobs.test.js +++ b/src/services/production/__tests__/jobs.test.js @@ -6,10 +6,12 @@ jest.unstable_mockModule('../../../database/database.js', () => ({ listObjects: jest.fn(), getObject: jest.fn(), newObject: jest.fn(), + editObject: jest.fn(), deleteObject: jest.fn(), listObjectsByProperties: jest.fn(), getModelStats: jest.fn(), getModelHistory: jest.fn(), + checkStates: jest.fn(), })); jest.unstable_mockModule('../../../database/schemas/production/job.schema.js', () => ({ @@ -33,10 +35,15 @@ jest.unstable_mockModule('log4js', () => ({ })); // Import handlers after mocking -const { listJobsRouteHandler, getJobRouteHandler, newJobRouteHandler, deleteJobRouteHandler } = - await import('../jobs.js'); +const { + listJobsRouteHandler, + getJobRouteHandler, + newJobRouteHandler, + editJobRouteHandler, + deleteJobRouteHandler, +} = await import('../jobs.js'); -const { listObjects, getObject, newObject, deleteObject } = await import( +const { listObjects, getObject, newObject, editObject, deleteObject, checkStates } = await import( '../../../database/database.js' ); const { jobModel } = await import('../../../database/schemas/production/job.schema.js'); @@ -72,7 +79,7 @@ describe('Job Service Route Handlers', () => { }); describe('newJobRouteHandler', () => { - it('should create a new job and corresponding subjobs', async () => { + it('should create a new job', async () => { req.body = { quantity: 2, printers: ['p1', 'p2'], @@ -80,12 +87,11 @@ describe('Job Service Route Handlers', () => { }; const mockJob = { _id: 'job123' }; - newObject.mockResolvedValueOnce(mockJob); // For Job - newObject.mockResolvedValue({ _id: 'subjob' }); // For SubJobs + newObject.mockResolvedValueOnce(mockJob); await newJobRouteHandler(req, res); - expect(newObject).toHaveBeenCalledTimes(3); // 1 Job + 2 SubJobs + expect(newObject).toHaveBeenCalledTimes(1); expect(res.send).toHaveBeenCalledWith(mockJob); }); @@ -100,6 +106,40 @@ describe('Job Service Route Handlers', () => { }); }); + describe('editJobRouteHandler', () => { + it('should update a draft job', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + req.body = { + quantity: 2, + printers: ['p1', 'p2'], + gcodeFile: 'file123', + }; + + checkStates.mockResolvedValue(true); + editObject.mockResolvedValue({ _id: '507f1f77bcf86cd799439011', ...req.body }); + + await editJobRouteHandler(req, res); + + expect(checkStates).toHaveBeenCalledWith( + expect.objectContaining({ states: ['draft'] }) + ); + expect(editObject).toHaveBeenCalled(); + expect(res.send).toHaveBeenCalled(); + }); + + it('should fail if job is not in draft state', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + checkStates.mockResolvedValue(false); + + await editJobRouteHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ error: 'Job is not in draft state.' }) + ); + }); + }); + describe('deleteJobRouteHandler', () => { it('should delete a job', async () => { req.params.id = '507f1f77bcf86cd799439011'; diff --git a/src/services/production/jobs.js b/src/services/production/jobs.js index a0119e9..d43ba35 100644 --- a/src/services/production/jobs.js +++ b/src/services/production/jobs.js @@ -8,11 +8,12 @@ import { listObjects, listObjectsByProperties, newObject, + editObject, getModelStats, getModelHistory, - searchObjects + searchObjects, + checkStates } from '../../database/database.js'; -import { subJobModel } from '../../database/schemas/production/subjob.schema.js'; const logger = log4js.getLogger('Jobs'); logger.level = config.server.logLevel; @@ -117,34 +118,51 @@ export const newJobRouteHandler = async (req, res) => { logger.debug(`New job with ID: ${result._id}`); - var printerCount = 0; + res.send(result); +}; - for (let i = 0; i < newData.quantity; i++) { - const newSubJobData = { - createdAt: new Date(), - updatedAt: new Date(), - printer: newData.printers[printerCount], - gcodeFile: req.body.gcodeFile, - number: i + 1, - job: result._id, - state: { type: 'draft' }, - }; - const subJobResult = await newObject({ - model: subJobModel, - newData: newSubJobData, - user: req.user, - }); - if (subJobResult.error) { - logger.error('No sub job created:', result.error); - return res.status(result.code).send(result); - } - if (printerCount >= newData.printers.length - 1) { - printerCount = 0; - } else { - printerCount += 1; - } +export const editJobRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Job with ID: ${id}`); + + const checkStatesResult = await checkStates({ model: jobModel, id, states: ['draft'] }); + + if (checkStatesResult.error) { + logger.error('Error checking job states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; } + if (checkStatesResult === false) { + logger.error('Job is not in draft state.'); + res.status(400).send({ error: 'Job is not in draft state.', code: 400 }); + return; + } + + const updateData = { + updatedAt: new Date(), + quantity: req.body.quantity, + printers: req.body.printers, + gcodeFile: req.body.gcodeFile, + }; + + const result = await editObject({ + model: jobModel, + id, + updateData, + user: req.user, + populate: ['gcodeFile', 'printers'], + }); + + if (result.error) { + logger.error('Error editing job:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Edited job with ID: ${id}`); + res.send(result); };