From 7c44f365903f3daf26850c8da3e722bd0ed26aff Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 7 Mar 2026 13:38:05 +0000 Subject: [PATCH] Implemented product stocks and minor improvements. --- src/database/database.js | 14 +- .../schemas/inventory/productstock.schema.js | 64 +++++ src/database/schemas/models.js | 5 +- src/database/utils.js | 43 ++++ src/index.js | 2 + src/routes/index.js | 2 + src/routes/inventory/productstocks.js | 64 +++++ src/services/inventory/productstocks.js | 221 ++++++++++++++++++ src/services/management/spotlight.js | 161 ------------- src/services/misc/csv.js | 1 + src/services/misc/excel.js | 1 + src/services/misc/odata.js | 1 + 12 files changed, 413 insertions(+), 166 deletions(-) create mode 100644 src/database/schemas/inventory/productstock.schema.js create mode 100644 src/routes/inventory/productstocks.js create mode 100644 src/services/inventory/productstocks.js delete mode 100644 src/services/management/spotlight.js diff --git a/src/database/database.js b/src/database/database.js index 0735f00..89221f6 100644 --- a/src/database/database.js +++ b/src/database/database.js @@ -23,6 +23,7 @@ import { import { getAllModels } from '../services/misc/model.js'; import { redisServer } from './redis.js'; import { auditLogModel } from './schemas/management/auditlog.schema.js'; +import { convertObjectIdStringsInFilter } from './utils.js'; const logger = log4js.getLogger('Database'); logger.level = config.server.logLevel; @@ -570,8 +571,12 @@ export const listObjectsByProperties = async ({ } } - if (masterFilter != {}) { - pipeline.push({ $match: { ...masterFilter } }); + logger.debug('Master filter:', masterFilter); + + if (Object.keys(masterFilter).length > 0) { + const convertedFilter = convertObjectIdStringsInFilter(masterFilter); + logger.debug('Converted filter:', convertedFilter); + pipeline.push({ $match: convertedFilter }); } if (propertiesPresent) { @@ -593,10 +598,13 @@ export const listObjectsByProperties = async ({ } else { // If no properties specified, just return all objects without grouping // Ensure pipeline is not empty by adding a $match stage if needed - if (pipeline.length === 0 && masterFilter == {}) { + if (pipeline.length === 0 && Object.keys(masterFilter).length === 0) { + console.log('Adding empty match stage'); pipeline.push({ $match: {} }); } + console.log('Running pipeline:', pipeline); const results = await model.aggregate(pipeline); + console.log('Results:', results); return results; } } catch (error) { diff --git a/src/database/schemas/inventory/productstock.schema.js b/src/database/schemas/inventory/productstock.schema.js new file mode 100644 index 0000000..ce3d2e5 --- /dev/null +++ b/src/database/schemas/inventory/productstock.schema.js @@ -0,0 +1,64 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +const { Schema } = mongoose; +import { aggregateRollups, aggregateRollupsHistory } from '../../database.js'; + +const partStockUsageSchema = new Schema({ + partStock: { type: Schema.Types.ObjectId, ref: 'partStock', required: false }, + part: { type: Schema.Types.ObjectId, ref: 'part', required: true }, + quantity: { type: Number, required: true }, +}); + +// Define the main productStock schema - tracks assembled products consisting of part stocks +const productStockSchema = new Schema( + { + _reference: { type: String, default: () => generateId()() }, + state: { + type: { type: String, required: true }, + progress: { type: Number, required: false }, + }, + product: { type: mongoose.Schema.Types.ObjectId, ref: 'product', required: true }, + currentQuantity: { type: Number, required: true }, + partStocks: [partStockUsageSchema], + }, + { timestamps: true } +); + +const rollupConfigs = [ + { + name: 'totalCurrentQuantity', + filter: {}, + rollups: [{ name: 'totalCurrentQuantity', property: 'currentQuantity', operation: 'sum' }], + }, +]; + +productStockSchema.statics.stats = async function () { + const results = await aggregateRollups({ + model: this, + rollupConfigs: rollupConfigs, + }); + + return results; +}; + +productStockSchema.statics.history = async function (from, to) { + const results = await aggregateRollupsHistory({ + model: this, + startDate: from, + endDate: to, + rollupConfigs: rollupConfigs, + }); + + return results; +}; + +// Add virtual id getter +productStockSchema.virtual('id').get(function () { + return this._id; +}); + +// Configure JSON serialization to include virtuals +productStockSchema.set('toJSON', { virtuals: true }); + +// Create and export the model +export const productStockModel = mongoose.model('productStock', productStockSchema); diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index c647630..deae175 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -12,6 +12,7 @@ import { orderItemModel } from './inventory/orderitem.schema.js'; import { stockEventModel } from './inventory/stockevent.schema.js'; import { stockAuditModel } from './inventory/stockaudit.schema.js'; import { partStockModel } from './inventory/partstock.schema.js'; +import { productStockModel } from './inventory/productstock.schema.js'; import { auditLogModel } from './management/auditlog.schema.js'; import { userModel } from './management/user.schema.js'; import { appPasswordModel } from './management/apppassword.schema.js'; @@ -115,12 +116,12 @@ export const models = { label: 'Part Stock', }, PDS: { - model: null, + model: productStockModel, idField: '_id', type: 'productStock', referenceField: '_reference', label: 'Product Stock', - }, // No productStockModel found + }, ADL: { model: auditLogModel, idField: '_id', diff --git a/src/database/utils.js b/src/database/utils.js index b295f4a..6c9fb95 100644 --- a/src/database/utils.js +++ b/src/database/utils.js @@ -1,7 +1,50 @@ import { customAlphabet } from 'nanoid'; +import mongoose from 'mongoose'; const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; export const generateId = () => { // 10 characters return customAlphabet(ALPHABET, 12); }; + +/** Check if a value is a string that looks like a MongoDB ObjectId (24 hex chars). */ +export function isObjectIdString(value) { + return typeof value === 'string' && /^[a-f\d]{24}$/i.test(value); +} + +/** Convert a value to ObjectId if it's a valid ObjectId string; otherwise return as-is. */ +export function toObjectIdIfValid(value) { + if (isObjectIdString(value)) { + return new mongoose.Types.ObjectId(value); + } + return value; +} + +/** Recursively convert ObjectId strings to ObjectId in a filter object for MongoDB $match. */ +export function convertObjectIdStringsInFilter(filter) { + if (!filter || typeof filter !== 'object') return filter; + + const result = {}; + for (const [key, value] of Object.entries(filter)) { + if (key.startsWith('$')) { + if ((key === '$in' || key === '$nin') && Array.isArray(value)) { + result[key] = value.map((v) => (isObjectIdString(v) ? new mongoose.Types.ObjectId(v) : v)); + } else if (value && typeof value === 'object' && !Array.isArray(value)) { + result[key] = convertObjectIdStringsInFilter(value); + } else { + result[key] = toObjectIdIfValid(value); + } + } else if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof mongoose.Types.ObjectId) && + !(value instanceof Date) + ) { + result[key] = convertObjectIdStringsInFilter(value); + } else { + result[key] = toObjectIdIfValid(value); + } + } + return result; +} diff --git a/src/index.js b/src/index.js index 0ccc684..2dfad4e 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,7 @@ import { vendorRoutes, materialRoutes, partStockRoutes, + productStockRoutes, filamentStockRoutes, purchaseOrderRoutes, orderItemRoutes, @@ -135,6 +136,7 @@ app.use('/products', productRoutes); app.use('/vendors', vendorRoutes); app.use('/materials', materialRoutes); app.use('/partstocks', partStockRoutes); +app.use('/productstocks', productStockRoutes); app.use('/filamentstocks', filamentStockRoutes); app.use('/purchaseorders', purchaseOrderRoutes); app.use('/orderitems', orderItemRoutes); diff --git a/src/routes/index.js b/src/routes/index.js index 65091b4..7be4722 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -14,6 +14,7 @@ import productRoutes from './management/products.js'; import vendorRoutes from './management/vendors.js'; import materialRoutes from './management/materials.js'; import partStockRoutes from './inventory/partstocks.js'; +import productStockRoutes from './inventory/productstocks.js'; import filamentStockRoutes from './inventory/filamentstocks.js'; import purchaseOrderRoutes from './inventory/purchaseorders.js'; import orderItemRoutes from './inventory/orderitems.js'; @@ -58,6 +59,7 @@ export { vendorRoutes, materialRoutes, partStockRoutes, + productStockRoutes, filamentStockRoutes, purchaseOrderRoutes, orderItemRoutes, diff --git a/src/routes/inventory/productstocks.js b/src/routes/inventory/productstocks.js new file mode 100644 index 0000000..967a549 --- /dev/null +++ b/src/routes/inventory/productstocks.js @@ -0,0 +1,64 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { getFilter, convertPropertiesString } from '../../utils.js'; + +const router = express.Router(); +import { + listProductStocksRouteHandler, + getProductStockRouteHandler, + editProductStockRouteHandler, + editMultipleProductStocksRouteHandler, + newProductStockRouteHandler, + deleteProductStockRouteHandler, + listProductStocksByPropertiesRouteHandler, + getProductStockStatsRouteHandler, + getProductStockHistoryRouteHandler, +} from '../../services/inventory/productstocks.js'; + +router.get('/', isAuthenticated, (req, res) => { + const { page, limit, property, search, sort, order } = req.query; + const allowedFilters = ['product', 'state', 'currentQuantity', 'product._id']; + const filter = getFilter(req.query, allowedFilters); + listProductStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order); +}); + +router.get('/properties', isAuthenticated, (req, res) => { + let properties = convertPropertiesString(req.query.properties); + const allowedFilters = ['product', 'state.type']; + const filter = getFilter(req.query, allowedFilters, false); + var masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = JSON.parse(req.query.masterFilter); + } + listProductStocksByPropertiesRouteHandler(req, res, properties, filter, masterFilter); +}); + +router.post('/', isAuthenticated, (req, res) => { + newProductStockRouteHandler(req, res); +}); + +router.get('/stats', isAuthenticated, (req, res) => { + getProductStockStatsRouteHandler(req, res); +}); + +router.get('/history', isAuthenticated, (req, res) => { + getProductStockHistoryRouteHandler(req, res); +}); + +router.get('/:id', isAuthenticated, (req, res) => { + getProductStockRouteHandler(req, res); +}); + +router.put('/', isAuthenticated, async (req, res) => { + editMultipleProductStocksRouteHandler(req, res); +}); + +router.put('/:id', isAuthenticated, async (req, res) => { + editProductStockRouteHandler(req, res); +}); + +router.delete('/:id', isAuthenticated, async (req, res) => { + deleteProductStockRouteHandler(req, res); +}); + +export default router; diff --git a/src/services/inventory/productstocks.js b/src/services/inventory/productstocks.js new file mode 100644 index 0000000..2e309a3 --- /dev/null +++ b/src/services/inventory/productstocks.js @@ -0,0 +1,221 @@ +import config from '../../config.js'; +import { productStockModel } from '../../database/schemas/inventory/productstock.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { + deleteObject, + listObjects, + getObject, + editObject, + editObjects, + newObject, + listObjectsByProperties, + getModelStats, + getModelHistory, +} from '../../database/database.js'; +import { productModel } from '../../database/schemas/management/product.schema.js'; +const logger = log4js.getLogger('Product Stocks'); +logger.level = config.server.logLevel; + +export const listProductStocksRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const result = await listObjects({ + model: productStockModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate: [{ path: 'product' }, { path: 'partStocks.partStock' }], + }); + + if (result?.error) { + logger.error('Error listing product stocks.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of product stocks (Page ${page}, Limit ${limit}). Count: ${result.length}`); + res.send(result); +}; + +export const listProductStocksByPropertiesRouteHandler = async ( + req, + res, + properties = '', + filter = {}, + masterFilter = {} +) => { + const result = await listObjectsByProperties({ + model: productStockModel, + properties, + filter, + populate: ['product', 'partStocks.partStock'], + masterFilter, + }); + + if (result?.error) { + logger.error('Error listing product stocks.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of product stocks. Count: ${result.length}`); + res.send(result); +}; + +export const getProductStockRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: productStockModel, + id, + populate: [{ path: 'partStocks.part' }, { path: 'partStocks.partStock' }, { path: 'product' }], + }); + if (result?.error) { + logger.warn(`Product Stock not found with supplied id.`); + return res.status(result.code).send(result); + } + logger.debug(`Retrieved product stock with ID: ${id}`); + res.send(result); +}; + +export const editProductStockRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Product Stock with ID: ${id}`); + + const updateData = { + partStocks: req.body?.partStocks?.map((partStock) => ({ + quantity: partStock.quantity, + partStock: partStock.partStock, + })), + }; + + const result = await editObject({ + model: productStockModel, + id, + updateData, + user: req.user, + }); + + if (result.error) { + logger.error('Error editing product stock:', result.error); + res.status(result).send(result); + return; + } + + logger.debug(`Edited product stock with ID: ${id}`); + + res.send(result); +}; + +export const editMultipleProductStocksRouteHandler = async (req, res) => { + const updates = req.body.map((update) => ({ + _id: update._id, + })); + + if (!Array.isArray(updates)) { + return res.status(400).send({ error: 'Body must be an array of updates.', code: 400 }); + } + + const result = await editObjects({ + model: productStockModel, + updates, + user: req.user, + }); + + if (result.error) { + logger.error('Error editing product stocks:', result.error); + res.status(result.code || 500).send(result); + return; + } + + logger.debug(`Edited ${updates.length} product stocks`); + + res.send(result); +}; + +export const newProductStockRouteHandler = async (req, res) => { + const productId = new mongoose.Types.ObjectId(req.body.product?._id); + const product = await getObject({ + model: productModel, + id: productId, + }); + const newData = { + updatedAt: new Date(), + currentQuantity: req.body.currentQuantity, + product: req.body.product, + state: req.body.state, + partStocks: product.parts.map((part) => ({ + part: part.part, + quantity: part.quantity, + partStock: undefined, + })), + }; + const result = await newObject({ + model: productStockModel, + newData, + user: req.user, + }); + if (result.error) { + logger.error('No product stock created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New product stock with ID: ${result._id}`); + + res.send(result); +}; + +export const deleteProductStockRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Product Stock with ID: ${id}`); + + const result = await deleteObject({ + model: productStockModel, + id, + user: req.user, + }); + if (result.error) { + logger.error('No product stock deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted product stock with ID: ${result._id}`); + + res.send(result); +}; + +export const getProductStockStatsRouteHandler = async (req, res) => { + const result = await getModelStats({ model: productStockModel }); + if (result?.error) { + logger.error('Error fetching product stock stats:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Product stock stats:', result); + res.send(result); +}; + +export const getProductStockHistoryRouteHandler = async (req, res) => { + const from = req.query.from; + const to = req.query.to; + const result = await getModelHistory({ model: productStockModel, from, to }); + if (result?.error) { + logger.error('Error fetching product stock history:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Product stock history:', result); + res.send(result); +}; diff --git a/src/services/management/spotlight.js b/src/services/management/spotlight.js deleted file mode 100644 index 7e77dd9..0000000 --- a/src/services/management/spotlight.js +++ /dev/null @@ -1,161 +0,0 @@ -import config from '../../config.js'; -import { jobModel } from '../../database/schemas/production/job.schema.js'; -import { subJobModel } from '../../database/schemas/production/subjob.schema.js'; -import log4js from 'log4js'; -import { printerModel } from '../../database/schemas/production/printer.schema.js'; -import { filamentModel } from '../../database/schemas/management/filament.schema.js'; -import { gcodeFileModel } from '../../database/schemas/production/gcodefile.schema.js'; -import { partModel } from '../../database/schemas/management/part.schema.js'; -import { productModel } from '../../database/schemas/management/product.schema.js'; -import { vendorModel } from '../../database/schemas/management/vendor.schema.js'; -import { filamentStockModel } from '../../database/schemas/inventory/filamentstock.schema.js'; -import { stockEventModel } from '../../database/schemas/inventory/stockevent.schema.js'; -import { stockAuditModel } from '../../database/schemas/inventory/stockaudit.schema.js'; -import { partStockModel } from '../../database/schemas/inventory/partstock.schema.js'; -import { auditLogModel } from '../../database/schemas/management/auditlog.schema.js'; -import { userModel } from '../../database/schemas/management/user.schema.js'; -import { noteTypeModel } from '../../database/schemas/management/notetype.schema.js'; -import { noteModel } from '../../database/schemas/misc/note.schema.js'; -import mongoose from 'mongoose'; - -const logger = log4js.getLogger('Jobs'); -logger.level = config.server.logLevel; - -// Map prefixes to models and id fields -const PREFIX_MODEL_MAP = { - PRN: { model: printerModel, idField: '_id', type: 'printer' }, - FIL: { model: filamentModel, idField: '_id', type: 'filament' }, - SPL: { model: null, idField: '_id', type: 'spool' }, // No spool model found - GCF: { model: gcodeFileModel, idField: '_id', type: 'gcodefile' }, - JOB: { model: jobModel, idField: '_id', type: 'job' }, - PRT: { model: partModel, idField: '_id', type: 'part' }, - PRD: { model: productModel, idField: '_id', type: 'product' }, - VEN: { model: vendorModel, idField: '_id', type: 'vendor' }, - SJB: { model: subJobModel, idField: '_id', type: 'subjob' }, - FLS: { model: filamentStockModel, idField: '_id', type: 'filamentstock' }, - SEV: { model: stockEventModel, idField: '_id', type: 'stockevent' }, - SAU: { model: stockAuditModel, idField: '_id', type: 'stockaudit' }, - PTS: { model: partStockModel, idField: '_id', type: 'partstock' }, - PDS: { model: null, idField: '_id', type: 'productstock' }, // No productStockModel found - ADL: { model: auditLogModel, idField: '_id', type: 'auditlog' }, - USR: { model: userModel, idField: '_id', type: 'user' }, - NTY: { model: noteTypeModel, idField: '_id', type: 'notetype' }, - NTE: { model: noteModel, idField: '_id', type: 'note' }, -}; - -// Helper function to build search filter from query parameters -const buildSearchFilter = (params) => { - const filter = {}; - - for (const [key, value] of Object.entries(params)) { - // Skip pagination and limit parameters as they're not search filters - if (key === 'limit' || key === 'page') continue; - - // Handle different field types - if (key === 'name') { - filter.name = { $regex: value, $options: 'i' }; // Case-insensitive search - } else if (key === 'id' || key === '_id') { - if (mongoose.Types.ObjectId.isValid(value)) { - filter._id = value; - } - } else if (key === 'tags') { - filter.tags = { $in: [new RegExp(value, 'i')] }; - } else if (key === 'state') { - filter['state.type'] = value; - } else if (key.includes('.')) { - // Handle nested fields like 'state.type', 'address.city', etc. - filter[key] = { $regex: value, $options: 'i' }; - } else { - // For all other fields, do a case-insensitive search - filter[key] = { $regex: value, $options: 'i' }; - } - } - - return filter; -}; - -const trimSpotlightObject = (object) => { - return { - _id: object._id, - name: object.name || undefined, - state: object.state && object?.state.type ? { type: object.state.type } : undefined, - tags: object.tags || undefined, - email: object.email || undefined, - color: object.color || undefined, - updatedAt: object.updatedAt || undefined, - }; -}; - -export const getSpotlightRouteHandler = async (req, res) => { - try { - const query = req.params.query; - const queryParams = req.query; - if (query.length < 3) { - res.status(200).send([]); - return; - } - const prefix = query.substring(0, 3); - const delimiter = query.substring(3, 4); - const suffix = query.substring(4); - - if (delimiter == ':') { - const prefixEntry = PREFIX_MODEL_MAP[prefix]; - if (!prefixEntry || !prefixEntry.model) { - res.status(400).send({ error: 'Invalid or unsupported prefix' }); - return; - } - const { model, idField } = prefixEntry; - - // Validate ObjectId if the idField is '_id' - if (idField === '_id' && !mongoose.Types.ObjectId.isValid(suffix)) { - res.status(404).send({ error: `${prefix} not found` }); - return; - } - - // Find the object by the correct field - const queryObj = {}; - queryObj[idField] = suffix.toLowerCase(); - let doc = await model.findOne(queryObj).lean(); - if (!doc) { - res.status(404).send({ error: `${prefix} not found` }); - return; - } - // Build the response with only the required fields - const response = trimSpotlightObject(doc); - res.status(200).send(response); - return; - } - - if (Object.keys(queryParams).length > 0) { - const prefixEntry = PREFIX_MODEL_MAP[prefix]; - if (!prefixEntry || !prefixEntry.model) { - res.status(400).send({ error: 'Invalid or unsupported prefix' }); - return; - } - const { model } = prefixEntry; - - // Use req.query for search parameters - - if (Object.keys(queryParams).length === 0) { - res.status(400).send({ error: 'No search parameters provided' }); - return; - } - - // Build search filter - const searchFilter = buildSearchFilter(queryParams); - - // Perform search with limit - const limit = parseInt(req.query.limit) || 10; - const docs = await model.find(searchFilter).limit(limit).sort({ updatedAt: -1 }).lean(); - - // Format response - const response = docs.map((doc) => trimSpotlightObject(doc)); - - res.status(200).send(response); - return; - } - } catch (error) { - logger.error('Error in spotlight lookup:', error); - res.status(500).send({ error: error }); - } -}; diff --git a/src/services/misc/csv.js b/src/services/misc/csv.js index 6ee1186..1bcee02 100644 --- a/src/services/misc/csv.js +++ b/src/services/misc/csv.js @@ -64,6 +64,7 @@ function getModelFilterFields(objectType) { subJob: ['job'], filamentStock: ['filament'], partStock: ['part'], + productStock: ['product'], purchaseOrder: ['vendor'], orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'shipment._id'], shipment: ['order._id', 'orderType', 'courierService._id'], diff --git a/src/services/misc/excel.js b/src/services/misc/excel.js index 9f0eff3..d8afa65 100644 --- a/src/services/misc/excel.js +++ b/src/services/misc/excel.js @@ -70,6 +70,7 @@ function getModelFilterFields(objectType) { subJob: ['job'], filamentStock: ['filament'], partStock: ['part'], + productStock: ['product'], purchaseOrder: ['vendor'], orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'shipment._id'], shipment: ['order._id', 'orderType', 'courierService._id'], diff --git a/src/services/misc/odata.js b/src/services/misc/odata.js index 3717516..901f627 100644 --- a/src/services/misc/odata.js +++ b/src/services/misc/odata.js @@ -344,6 +344,7 @@ function getModelFilterFields(objectType) { subJob: ['job'], filamentStock: ['filament'], partStock: ['part'], + productStock: ['product'], purchaseOrder: ['vendor'], orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'shipment._id'], shipment: ['order._id', 'orderType', 'courierService._id'],