Implemented product stocks and minor improvements.
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit

This commit is contained in:
Tom Butcher 2026-03-07 13:38:05 +00:00
parent cf01c3aa38
commit 7c44f36590
12 changed files with 413 additions and 166 deletions

View File

@ -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) {

View File

@ -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);

View File

@ -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',

View File

@ -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;
}

View File

@ -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);

View File

@ -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,

View File

@ -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;

View File

@ -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);
};

View File

@ -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 });
}
};

View File

@ -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'],

View File

@ -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'],

View File

@ -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'],