Add stock audit level management functionality and integrate with existing inventory system
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit

This commit introduces a new `stockAuditLevel` schema and corresponding routes for managing stock audit levels, enhancing the inventory management capabilities. It includes CRUD operations for stock audit levels, allowing for the creation, retrieval, updating, and deletion of audit levels. The integration with existing stock audit functionalities is established, enabling the association of audit levels with stock audits. Additionally, utility functions for handling stock audit levels are implemented, improving the overall structure and maintainability of the inventory system.
This commit is contained in:
Tom Butcher 2026-09-01 15:41:32 +01:00
parent ec5783acd8
commit bce40ce30d
15 changed files with 1728 additions and 218 deletions

View File

@ -2,6 +2,13 @@ import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
import { updateDraftStockAuditCurrents } from './stockaudit.schema.js';
const toId = (value) => {
if (value == null) return null;
if (typeof value === 'object' && value._id) return String(value._id);
return String(value);
};
// Define the main filamentStock schema
const filamentStockSchema = new Schema(
@ -96,6 +103,19 @@ filamentStockSchema.statics.history = async function (from, to) {
return results;
};
filamentStockSchema.statics.recalculate = async function (filamentStock, user) {
const itemSkuId = toId(filamentStock?.filamentSku);
const stockLocationId = toId(filamentStock?.stockLocation);
if (!itemSkuId || !stockLocationId) return;
await updateDraftStockAuditCurrents({
itemType: 'filament',
itemSkuId,
stockLocationId,
user,
});
};
// Add virtual id getter
filamentStockSchema.virtual('id').get(function () {
return this._id;

View File

@ -1,6 +1,13 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
import { updateDraftStockAuditCurrents } from './stockaudit.schema.js';
const toId = (value) => {
if (value == null) return null;
if (typeof value === 'object' && value._id) return String(value._id);
return String(value);
};
// Define the main partStock schema
const partStockSchema = new mongoose.Schema(
@ -89,6 +96,18 @@ partStockSchema.statics.history = async function (from, to) {
partStockSchema.statics.recalculate = async function (partStock, user) {
if (!partStock?._id) return;
const itemSkuId = toId(partStock.partSku);
const stockLocationId = toId(partStock.stockLocation);
if (itemSkuId && stockLocationId) {
await updateDraftStockAuditCurrents({
itemType: 'part',
itemSkuId,
stockLocationId,
user,
});
}
if (partStock.state?.type === 'draft' || partStock.state?.type === 'consumed') return;
if ((Number(partStock.currentQuantity) || 0) > 0) return;

View File

@ -2,6 +2,7 @@ import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
import { updateDraftStockAuditCurrents } from './stockaudit.schema.js';
const partStockListItemSchema = new Schema({
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
@ -122,6 +123,17 @@ productStockSchema.statics.history = async function (from, to) {
};
productStockSchema.statics.recalculate = async function (productStock, user) {
const productSkuId = toId(productStock?.productSku);
const stockLocationId = toId(productStock?.stockLocation);
if (productSkuId && stockLocationId) {
await updateDraftStockAuditCurrents({
itemType: 'product',
itemSkuId: productSkuId,
stockLocationId,
user,
});
}
if (
productStock?._id &&
productStock.state?.type !== 'draft' &&
@ -139,8 +151,6 @@ productStockSchema.statics.recalculate = async function (productStock, user) {
});
}
const productSkuId = toId(productStock?.productSku);
const stockLocationId = toId(productStock?.stockLocation);
if (!productSkuId || !stockLocationId) {
return;
}

View File

@ -1,42 +1,311 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
import { editObject, getObject } from '../../database.js';
import { stockAuditLevelModel, normalizeAuditLevelLine } from '../management/stockauditlevel.schema.js';
import { filamentModel } from '../management/filament.schema.js';
import { filamentSkuModel } from '../management/filamentsku.schema.js';
import { partModel } from '../management/part.schema.js';
import { partSkuModel } from '../management/partsku.schema.js';
import { productModel } from '../management/product.schema.js';
import { productSkuModel } from '../management/productsku.schema.js';
const { Schema } = mongoose;
const stockAuditItemSchema = new Schema({
type: { type: String, enum: ['filament', 'part'], required: true },
stock: { type: Schema.Types.ObjectId, required: true },
expectedQuantity: { type: Number, required: true },
actualQuantity: { type: Number, required: true },
notes: { type: String },
});
const itemModelsByType = {
filament: filamentModel,
part: partModel,
product: productModel,
};
const skuModelsByType = {
filament: filamentSkuModel,
part: partSkuModel,
product: productSkuModel,
};
const parentFieldByType = {
filament: 'filament',
part: 'part',
product: 'product',
};
const toId = (value) => {
if (value == null) return null;
if (typeof value === 'object' && value._id != null) return String(value._id);
return String(value);
};
const stockAuditLineSchema = new Schema(
{
itemType: {
type: String,
enum: ['filament', 'part', 'product'],
required: true,
},
item: { type: Schema.Types.ObjectId, refPath: 'auditLines.itemType', required: true },
itemSku: {
type: Schema.Types.ObjectId,
ref: function () {
return ['filament', 'part', 'product'].includes(this.itemType)
? this.itemType + 'Sku'
: null;
},
required: true,
},
current: { type: Number, required: true, default: 0 },
actual: { type: Number, required: true, default: 0 },
new: { type: Number, required: true, default: 0 },
},
{ _id: true }
);
const stockAuditSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
type: { type: String, required: true },
status: {
type: String,
enum: ['pending', 'in_progress', 'completed', 'cancelled'],
default: 'pending',
state: {
type: { type: String, required: true, default: 'draft' },
progress: { type: Number, required: false },
},
auditLevel: {
type: Schema.Types.ObjectId,
ref: 'stockAuditLevel',
required: true,
},
notes: { type: String },
items: [stockAuditItemSchema],
createdBy: { type: Schema.Types.ObjectId, ref: 'user', required: true },
completedAt: { type: Date },
stockLocation: {
type: Schema.Types.ObjectId,
ref: 'stockLocation',
required: true,
},
postedAt: { type: Date, required: false },
auditLines: { type: [stockAuditLineSchema], default: [] },
},
{ timestamps: true }
);
stockAuditSchema.index({ type: 'text', status: 'text', notes: 'text' });
stockAuditSchema.index({ 'state.type': 'text' });
stockAuditSchema.statics.stats = async function () {
const [draft, complete] = await Promise.all([
this.countDocuments({ 'state.type': 'draft' }),
this.countDocuments({ 'state.type': 'complete' }),
]);
return {
draft: { count: draft },
complete: { count: complete },
};
};
stockAuditSchema.statics.history = async function () {
return [];
};
async function fetchItemsForLevelLine(levelLine) {
const itemType = levelLine.itemType;
const itemModel = itemModelsByType[itemType];
if (!itemModel) return [];
if (levelLine.allItems) {
return itemModel.find().select('_id').lean();
}
const itemId = toId(levelLine.item);
if (!itemId) return [];
return [{ _id: itemId }];
}
async function fetchSkusForLevelLine(levelLine, itemId) {
const itemType = levelLine.itemType;
const skuModel = skuModelsByType[itemType];
const parentField = parentFieldByType[itemType];
if (!skuModel || !parentField) return [];
if (levelLine.allSkus) {
return skuModel.find({ [parentField]: itemId }).select('_id').lean();
}
const skuId = toId(levelLine.itemSku);
if (!skuId) return [];
return [{ _id: skuId }];
}
export async function getCurrentQuantityAtLocation(itemType, itemSkuId, stockLocationId) {
const locationId = toId(stockLocationId);
const skuId = toId(itemSkuId);
if (!locationId || !skuId) return 0;
if (itemType === 'filament') {
const stocks = await mongoose
.model('filamentStock')
.find({ filamentSku: skuId, stockLocation: locationId })
.select('currentWeight.net')
.lean();
return stocks.reduce((sum, stock) => sum + (Number(stock.currentWeight?.net) || 0), 0);
}
if (itemType === 'part') {
const stocks = await mongoose
.model('partStock')
.find({ partSku: skuId, stockLocation: locationId })
.select('currentQuantity')
.lean();
return stocks.reduce((sum, stock) => sum + (Number(stock.currentQuantity) || 0), 0);
}
if (itemType === 'product') {
const stocks = await mongoose
.model('productStock')
.find({ productSku: skuId, stockLocation: locationId })
.select('currentQuantity')
.lean();
return stocks.reduce((sum, stock) => sum + (Number(stock.currentQuantity) || 0), 0);
}
return 0;
}
function buildAuditLineQuantities(current, actual) {
const currentVal = Number(current) || 0;
const actualVal = Number(actual) || 0;
return {
current: currentVal,
actual: actualVal,
new: actualVal,
};
}
function getExistingActual(line) {
if (line?.actual != null) return Number(line.actual);
if (line?.actualQuantity != null) return Number(line.actualQuantity);
return null;
}
function buildAuditLineKey(itemType, itemId, itemSkuId) {
return `${itemType}:${toId(itemId)}:${toId(itemSkuId)}`;
}
async function expandLevelLinesToAuditLines(levelLines, stockLocationId, existingLines = []) {
const existingByKey = new Map();
for (const line of existingLines) {
existingByKey.set(
buildAuditLineKey(line.itemType, line.item, line.itemSku),
line
);
}
const auditLines = [];
for (const levelLine of levelLines || []) {
const items = await fetchItemsForLevelLine(levelLine);
for (const item of items) {
const itemId = toId(item._id);
const skus = await fetchSkusForLevelLine(levelLine, itemId);
for (const sku of skus) {
const itemSkuId = toId(sku._id);
const current = await getCurrentQuantityAtLocation(
levelLine.itemType,
itemSkuId,
stockLocationId
);
const key = buildAuditLineKey(levelLine.itemType, itemId, itemSkuId);
const existing = existingByKey.get(key);
const existingActual = getExistingActual(existing);
const actual = existingActual != null ? existingActual : current;
auditLines.push({
itemType: levelLine.itemType,
item: itemId,
itemSku: itemSkuId,
...buildAuditLineQuantities(current, actual),
});
}
}
}
return auditLines;
}
stockAuditSchema.statics.recalculate = async function (stockAudit, user) {
if (stockAudit?.state?.type !== 'draft') return;
const auditLevelId = toId(stockAudit.auditLevel?._id ?? stockAudit.auditLevel);
const stockLocationId = toId(stockAudit.stockLocation?._id ?? stockAudit.stockLocation);
if (!auditLevelId || !stockLocationId) return;
const auditLevel = await getObject({
model: stockAuditLevelModel,
id: auditLevelId,
populate: [
{ path: 'auditLines.item' },
{ path: 'auditLines.itemSku' },
],
});
if (!auditLevel || auditLevel.error) return;
const auditLines = await expandLevelLinesToAuditLines(
(auditLevel.auditLines || []).map((line) => normalizeAuditLevelLine(line)),
stockLocationId,
stockAudit.auditLines
);
await editObject({
model: this,
id: stockAudit._id,
updateData: { auditLines },
user,
recalculate: false,
});
};
// Add virtual id getter
stockAuditSchema.virtual('id').get(function () {
return this._id;
});
// Configure JSON serialization to include virtuals
stockAuditSchema.set('toJSON', { virtuals: true });
// Create and export the model
export const stockAuditModel = mongoose.model('stockAudit', stockAuditSchema);
export async function updateDraftStockAuditCurrents({
itemType,
itemSkuId,
stockLocationId,
user,
}) {
const skuId = toId(itemSkuId);
const locationId = toId(stockLocationId);
if (!itemType || !skuId || !locationId) return;
const current = await getCurrentQuantityAtLocation(itemType, skuId, locationId);
const draftAudits = await stockAuditModel
.find({
'state.type': 'draft',
stockLocation: locationId,
})
.lean();
for (const audit of draftAudits) {
let changed = false;
const auditLines = (audit.auditLines || []).map((line) => {
if (line.itemType !== itemType || toId(line.itemSku) !== skuId) {
return line;
}
const lineCurrent = Number(line.current) || 0;
if (lineCurrent === current) {
return line;
}
changed = true;
return { ...line, current };
});
if (!changed) continue;
await editObject({
model: stockAuditModel,
id: audit._id,
updateData: { auditLines },
user,
recalculate: false,
});
}
}

View File

@ -0,0 +1,108 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
import { editObject } from '../../database.js';
const { Schema } = mongoose;
const toId = (value) => {
if (value == null) return null;
if (typeof value === 'object' && value._id != null) return String(value._id);
return String(value);
};
export function normalizeAuditLevelLine(line) {
const allItems = Boolean(line?.allItems);
const allSkus = allItems ? true : Boolean(line?.allSkus);
const item = allItems ? null : line?.item?._id ?? line?.item ?? null;
const itemSku = allItems || allSkus ? null : line?.itemSku?._id ?? line?.itemSku ?? null;
return {
_id: line?._id,
itemType: line?.itemType,
allItems,
allSkus,
item,
itemSku,
};
}
function auditLevelLineChanged(before, after) {
return (
Boolean(before?.allItems) !== Boolean(after.allItems) ||
Boolean(before?.allSkus) !== Boolean(after.allSkus) ||
toId(before?.item) !== toId(after.item) ||
toId(before?.itemSku) !== toId(after.itemSku)
);
}
const stockAuditLevelLineSchema = new Schema(
{
itemType: {
type: String,
enum: ['filament', 'part', 'product'],
required: true,
},
allItems: { type: Boolean, default: false },
item: { type: Schema.Types.ObjectId, refPath: 'auditLines.itemType', required: false },
allSkus: { type: Boolean, default: false },
itemSku: {
type: Schema.Types.ObjectId,
ref: function () {
return ['filament', 'part', 'product'].includes(this.itemType)
? this.itemType + 'Sku'
: null;
},
required: false,
},
},
{ _id: true }
);
const stockAuditLevelSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { type: String, required: true },
tags: [{ type: String }],
auditLines: { type: [stockAuditLevelLineSchema], default: [] },
},
{ timestamps: true }
);
stockAuditLevelSchema.index({ name: 'text', tags: 'text' });
stockAuditLevelSchema.statics.stats = async function () {
const count = await this.countDocuments();
return { total: { count } };
};
stockAuditLevelSchema.statics.history = async function () {
return [];
};
stockAuditLevelSchema.statics.recalculate = async function (stockAuditLevel, user) {
if (!stockAuditLevel?._id) return;
const auditLines = stockAuditLevel.auditLines || [];
const normalizedLines = auditLines.map((line) => normalizeAuditLevelLine(line));
const changed = auditLines.some((line, index) =>
auditLevelLineChanged(line, normalizedLines[index])
);
if (!changed) return;
await editObject({
model: this,
id: stockAuditLevel._id,
updateData: { auditLines: normalizedLines },
user,
recalculate: false,
});
};
stockAuditLevelSchema.virtual('id').get(function () {
return this._id;
});
stockAuditLevelSchema.set('toJSON', { virtuals: true });
export const stockAuditLevelModel = mongoose.model('stockAuditLevel', stockAuditLevelSchema);

View File

@ -18,6 +18,7 @@ import { purchaseOrderModel } from './inventory/purchaseorder.schema.js';
import { orderItemModel } from './inventory/orderitem.schema.js';
import { stockEventModel } from './inventory/stockevent.schema.js';
import { stockAuditModel } from './inventory/stockaudit.schema.js';
import { stockAuditLevelModel } from './management/stockauditlevel.schema.js';
import { partStockModel } from './inventory/partstock.schema.js';
import { productStockModel } from './inventory/productstock.schema.js';
import { stockLocationModel } from './inventory/stocklocation.schema.js';
@ -88,6 +89,7 @@ export const models = {
FLS: modelEntry(() => filamentStockModel, 'filamentStock', 'Filament Stock'),
SEV: modelEntry(() => stockEventModel, 'stockEvent', 'Stock Event'),
SAU: modelEntry(() => stockAuditModel, 'stockAudit', 'Stock Audit'),
SAL: modelEntry(() => stockAuditLevelModel, 'stockAuditLevel', 'Stock Audit Level'),
PTS: modelEntry(() => partStockModel, 'partStock', 'Part Stock'),
PDS: modelEntry(() => productStockModel, 'productStock', 'Product Stock'),
SLN: modelEntry(() => stockLocationModel, 'stockLocation', 'Stock Location'),

View File

@ -34,6 +34,7 @@ import {
orderItemRoutes,
shipmentRoutes,
stockAuditRoutes,
stockAuditLevelRoutes,
stockLocationRoutes,
stockTransferRoutes,
stockEventRoutes,
@ -204,6 +205,7 @@ app.use('/orderitems', orderItemRoutes);
app.use('/shipments', shipmentRoutes);
app.use('/stockevents', stockEventRoutes);
app.use('/stockaudits', stockAuditRoutes);
app.use('/stockauditlevels', stockAuditLevelRoutes);
app.use('/stocklocations', stockLocationRoutes);
app.use('/stocktransfers', stockTransferRoutes);
app.use('/auditlogs', auditLogRoutes);

View File

@ -29,6 +29,7 @@ import orderItemRoutes from './inventory/orderitems.js';
import shipmentRoutes from './inventory/shipments.js';
import stockEventRoutes from './inventory/stockevents.js';
import stockAuditRoutes from './inventory/stockaudits.js';
import stockAuditLevelRoutes from './management/stockauditlevels.js';
import stockLocationRoutes from './inventory/stocklocations.js';
import stockTransferRoutes from './inventory/stocktransfers.js';
import auditLogRoutes from './management/auditlogs.js';
@ -95,6 +96,7 @@ export {
shipmentRoutes,
stockEventRoutes,
stockAuditRoutes,
stockAuditLevelRoutes,
stockLocationRoutes,
stockTransferRoutes,
auditLogRoutes,

View File

@ -1,9 +1,23 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { checkPermissions } from '../../database/permissions.js';
import { parseFilter, getFilter, getSort } from '../../utils.js';
import { getFilter, convertPropertiesString, getSort } from '../../utils.js';
const router = express.Router();
const listAllowedFilters = [
'state',
'state.type',
'auditLevel',
'stockLocation',
'postedAt',
'createdAt',
'updatedAt',
'_reference',
];
const listAllowedSorters = ['createdAt', 'updatedAt', 'postedAt', 'state'];
const propertiesAllowedFilters = ['state.type'];
import {
listStockAuditsRouteHandler,
getStockAuditRouteHandler,
@ -13,54 +27,66 @@ import {
getStockAuditStatsRouteHandler,
getStockAuditHistoryRouteHandler,
searchStockAuditsRouteHandler,
listStockAuditsByPropertiesRouteHandler,
getStockAuditPropertyValuesRouteHandler,
getStockAuditNeighborsRouteHandler,
postStockAuditRouteHandler,
} from '../../services/inventory/stockaudits.js';
const listAllowedFilters = [
'status',
'type',
'createdBy',
'state',
'createdAt',
'updatedAt',
'_reference',
];
const listAllowedSorters = ['createdAt', 'updatedAt', 'state'];
// List stock audits
router.get('/', isAuthenticated, checkPermissions('stockAudit', 'list'), async (req, res) => {
const { page, limit, property } = req.query;
var filter = {};
for (const [key, value] of Object.entries(req.query)) {
for (var i = 0; i < listAllowedFilters.length; i++) {
if (key == listAllowedFilters[i]) {
const parsedFilter = await parseFilter(key, value);
filter = { ...filter, ...parsedFilter };
}
}
}
listStockAuditsRouteHandler(req, res, page, limit, property, filter);
const { page, limit, property, search, sortProperty, sortOrder } = req.query;
const filter = await getFilter(req.query, listAllowedFilters);
listStockAuditsRouteHandler(
req,
res,
page,
limit,
property,
filter,
search,
getSort(sortProperty, listAllowedSorters),
sortOrder
);
});
// Create new stock audit
router.post('/', isAuthenticated, checkPermissions('stockAudit', 'new'), async (req, res) => {
newStockAuditRouteHandler(req, res);
router.get(
'/properties',
checkPermissions('stockAudit', 'list'),
isAuthenticated,
async (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const filter = await getFilter(req.query, propertiesAllowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = JSON.parse(req.query.masterFilter);
}
listStockAuditsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
}
);
router.get('/values', checkPermissions('stockAudit', 'list'), isAuthenticated, async (req, res) => {
const { property } = req.query;
const filter = await getFilter(req.query, listAllowedFilters, true);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = await getFilter(JSON.parse(req.query.masterFilter), listAllowedFilters, true);
}
getStockAuditPropertyValuesRouteHandler(req, res, property, filter, masterFilter);
});
// get stock audit stats
router.get('/search', checkPermissions('stockAudit', 'list'), isAuthenticated, async (req, res) => {
const { search } = req.query;
searchStockAuditsRouteHandler(req, res, search);
});
router.post('/', isAuthenticated, checkPermissions('stockAudit', 'new'), async (req, res) => {
newStockAuditRouteHandler(req, res);
});
router.get('/stats', isAuthenticated, async (req, res) => {
getStockAuditStatsRouteHandler(req, res);
});
// get stock audit history
router.get('/history', isAuthenticated, async (req, res) => {
getStockAuditHistoryRouteHandler(req, res);
});
@ -68,22 +94,37 @@ router.get('/history', isAuthenticated, async (req, res) => {
router.get('/neighbors', isAuthenticated, async (req, res) => {
const { property, search, sortProperty, sortOrder, id } = req.query;
const filter = await getFilter(req.query, listAllowedFilters);
getStockAuditNeighborsRouteHandler(req, res, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder, id);
getStockAuditNeighborsRouteHandler(
req,
res,
property,
filter,
search,
getSort(sortProperty, listAllowedSorters),
sortOrder,
id
);
});
// Get specific stock audit
router.get('/:id', isAuthenticated, checkPermissions('stockAudit', 'info'), async (req, res) => {
getStockAuditRouteHandler(req, res);
});
// Update stock audit
router.put('/:id', isAuthenticated, checkPermissions('stockAudit', 'edit'), async (req, res) => {
updateStockAuditRouteHandler(req, res);
});
// Delete stock audit
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteStockAuditRouteHandler(req, res);
});
router.post(
'/:id/post',
isAuthenticated,
checkPermissions('stockAudit', 'post'),
async (req, res) => {
postStockAuditRouteHandler(req, res);
}
);
export default router;

View File

@ -0,0 +1,131 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { checkPermissions } from '../../database/permissions.js';
import { getFilter, convertPropertiesString, getSort } from '../../utils.js';
const router = express.Router();
const listAllowedFilters = ['name', 'createdAt', 'updatedAt', '_reference'];
const listAllowedSorters = ['name', 'createdAt', '_id', 'updatedAt'];
const propertiesAllowedFilters = ['tags'];
import {
listStockAuditLevelsRouteHandler,
getStockAuditLevelRouteHandler,
editStockAuditLevelRouteHandler,
newStockAuditLevelRouteHandler,
deleteStockAuditLevelRouteHandler,
listStockAuditLevelsByPropertiesRouteHandler,
getStockAuditLevelStatsRouteHandler,
getStockAuditLevelHistoryRouteHandler,
searchStockAuditLevelsRouteHandler,
getStockAuditLevelPropertyValuesRouteHandler,
getStockAuditLevelNeighborsRouteHandler,
} from '../../services/management/stockauditlevels.js';
router.get('/', isAuthenticated, checkPermissions('stockAuditLevel', 'list'), async (req, res) => {
const { page, limit, property, search, sortProperty, sortOrder } = req.query;
const filter = await getFilter(req.query, listAllowedFilters);
listStockAuditLevelsRouteHandler(
req,
res,
page,
limit,
property,
filter,
search,
getSort(sortProperty, listAllowedSorters),
sortOrder
);
});
router.get(
'/properties',
checkPermissions('stockAuditLevel', 'list'),
isAuthenticated,
async (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const filter = await getFilter(req.query, propertiesAllowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = JSON.parse(req.query.masterFilter);
}
listStockAuditLevelsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
}
);
router.get(
'/values',
checkPermissions('stockAuditLevel', 'list'),
isAuthenticated,
async (req, res) => {
const { property } = req.query;
const filter = await getFilter(req.query, listAllowedFilters, true);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = await getFilter(JSON.parse(req.query.masterFilter), listAllowedFilters, true);
}
getStockAuditLevelPropertyValuesRouteHandler(req, res, property, filter, masterFilter);
}
);
router.get(
'/search',
checkPermissions('stockAuditLevel', 'list'),
isAuthenticated,
async (req, res) => {
const { search } = req.query;
searchStockAuditLevelsRouteHandler(req, res, search);
}
);
router.post('/', isAuthenticated, checkPermissions('stockAuditLevel', 'new'), async (req, res) => {
newStockAuditLevelRouteHandler(req, res);
});
router.get('/stats', isAuthenticated, async (req, res) => {
getStockAuditLevelStatsRouteHandler(req, res);
});
router.get('/history', isAuthenticated, async (req, res) => {
getStockAuditLevelHistoryRouteHandler(req, res);
});
router.get('/neighbors', isAuthenticated, async (req, res) => {
const { property, search, sortProperty, sortOrder, id } = req.query;
const filter = await getFilter(req.query, listAllowedFilters);
getStockAuditLevelNeighborsRouteHandler(
req,
res,
property,
filter,
search,
getSort(sortProperty, listAllowedSorters),
sortOrder,
id
);
});
router.get(
'/:id',
isAuthenticated,
checkPermissions('stockAuditLevel', 'info'),
async (req, res) => {
getStockAuditLevelRouteHandler(req, res);
}
);
router.put(
'/:id',
isAuthenticated,
checkPermissions('stockAuditLevel', 'edit'),
async (req, res) => {
editStockAuditLevelRouteHandler(req, res);
}
);
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteStockAuditLevelRouteHandler(req, res);
});
export default router;

View File

@ -1,24 +1,49 @@
import { jest } from '@jest/globals';
jest.unstable_mockModule('../../../utils.js', () => ({
getAuditLogs: jest.fn(),
}));
jest.unstable_mockModule('../../../database/database.js', () => ({
searchObjects: jest.fn(),
getPropertyValues: jest.fn(),
listObjects: jest.fn(),
getObject: jest.fn(),
editObject: jest.fn(),
newObject: jest.fn(),
deleteObject: jest.fn(),
listObjectsByProperties: jest.fn(),
getModelStats: jest.fn(),
getModelHistory: jest.fn(),
getObjectNeighbors: jest.fn(),
checkStates: jest.fn(),
deleteObjectCache: jest.fn(),
}));
jest.unstable_mockModule('../../../database/schemas/inventory/stockaudit.schema.js', () => ({
stockAuditModel: {
modelName: 'StockAudit',
aggregate: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
findById: jest.fn(),
},
getCurrentQuantityAtLocation: jest.fn(),
}));
jest.unstable_mockModule('../../../database/schemas/inventory/filamentstock.schema.js', () => ({
filamentStockModel: {
find: jest.fn(),
},
}));
jest.unstable_mockModule('../../../database/schemas/inventory/partstock.schema.js', () => ({
partStockModel: {
find: jest.fn(),
},
}));
jest.unstable_mockModule('../../../database/schemas/inventory/productstock.schema.js', () => ({
productStockModel: {
find: jest.fn(),
},
}));
jest.unstable_mockModule('../../../database/schemas/inventory/stockevent.schema.js', () => ({
stockEventModel: { modelName: 'StockEvent' },
}));
jest.unstable_mockModule('log4js', () => ({
@ -37,10 +62,18 @@ const {
listStockAuditsRouteHandler,
getStockAuditRouteHandler,
newStockAuditRouteHandler,
postStockAuditRouteHandler,
} = await import('../stockaudits.js');
const { getAuditLogs } = await import('../../../utils.js');
const { stockAuditModel } = await import('../../../database/schemas/inventory/stockaudit.schema.js');
const { listObjects, getObject, newObject, editObject, checkStates } = await import(
'../../../database/database.js'
);
const { stockAuditModel, getCurrentQuantityAtLocation } = await import(
'../../../database/schemas/inventory/stockaudit.schema.js'
);
const { partStockModel } = await import(
'../../../database/schemas/inventory/partstock.schema.js'
);
describe('Stock Audit Service Route Handlers', () => {
let req, res;
@ -61,34 +94,173 @@ describe('Stock Audit Service Route Handlers', () => {
describe('listStockAuditsRouteHandler', () => {
it('should list stock audits', async () => {
const mockResult = [{ _id: '1', type: 'full' }];
stockAuditModel.aggregate.mockResolvedValue(mockResult);
const mockResult = [{ _id: '1', state: { type: 'draft' } }];
listObjects.mockResolvedValue(mockResult);
await listStockAuditsRouteHandler(req, res);
expect(stockAuditModel.aggregate).toHaveBeenCalled();
expect(listObjects).toHaveBeenCalledWith(
expect.objectContaining({ model: stockAuditModel })
);
expect(res.send).toHaveBeenCalledWith(mockResult);
});
});
describe('getStockAuditRouteHandler', () => {
it('should get a stock audit by ID with audit logs', async () => {
it('should get a stock audit by ID', async () => {
req.params.id = '507f1f77bcf86cd799439011';
const mockAudit = { _id: '507f1f77bcf86cd799439011', type: 'full', _doc: {} };
stockAuditModel.findOne.mockReturnValue({
populate: jest.fn().mockReturnValue({
populate: jest.fn().mockReturnValue({
populate: jest.fn().mockResolvedValue(mockAudit),
}),
}),
});
getAuditLogs.mockResolvedValue([]);
const mockAudit = { _id: '507f1f77bcf86cd799439011', state: { type: 'draft' } };
getObject.mockResolvedValue(mockAudit);
await getStockAuditRouteHandler(req, res);
expect(getAuditLogs).toHaveBeenCalled();
expect(res.send).toHaveBeenCalled();
expect(getObject).toHaveBeenCalledWith(
expect.objectContaining({ model: stockAuditModel, id: req.params.id })
);
expect(res.send).toHaveBeenCalledWith(mockAudit);
});
});
describe('newStockAuditRouteHandler', () => {
it('should create a stock audit with draft state', async () => {
req.body = {
auditLevel: 'level-1',
stockLocation: 'loc-1',
};
const mockResult = { _id: 'audit-1', state: { type: 'draft' } };
newObject.mockResolvedValue(mockResult);
await newStockAuditRouteHandler(req, res);
expect(newObject).toHaveBeenCalledWith(
expect.objectContaining({
model: stockAuditModel,
newData: expect.objectContaining({
state: { type: 'draft' },
auditLevel: 'level-1',
stockLocation: 'loc-1',
auditLines: [],
}),
})
);
expect(res.send).toHaveBeenCalledWith(mockResult);
});
});
describe('postStockAuditRouteHandler', () => {
const auditId = '507f1f77bcf86cd799439011';
const stockLocationId = '507f1f77bcf86cd799439012';
beforeEach(() => {
req.params.id = auditId;
});
it('should reject when stock audit is not in draft state', async () => {
checkStates.mockResolvedValue(false);
await postStockAuditRouteHandler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.send).toHaveBeenCalledWith({
error: 'Stock audit is not in draft state.',
code: 400,
});
});
it('should reject when live current differs from line current', async () => {
checkStates.mockResolvedValue(true);
stockAuditModel.findById.mockResolvedValue({
_id: auditId,
stockLocation: stockLocationId,
auditLines: [
{
toObject: () => ({
itemType: 'part',
item: 'part-1',
itemSku: 'sku-1',
current: 10,
actual: 8,
}),
},
],
});
getCurrentQuantityAtLocation.mockResolvedValue(12);
await postStockAuditRouteHandler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.send).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringContaining('Current quantity for SKU has changed'),
code: 400,
})
);
});
it('should create stock events and mark audit complete on success', async () => {
checkStates.mockResolvedValue(true);
stockAuditModel.findById.mockResolvedValue({
_id: auditId,
stockLocation: stockLocationId,
auditLines: [
{
toObject: () => ({
itemType: 'part',
item: 'part-1',
itemSku: 'sku-1',
current: 10,
actual: 8,
}),
},
],
});
getCurrentQuantityAtLocation.mockResolvedValue(10);
const mockStock = {
_id: 'stock-1',
currentQuantity: 10,
};
partStockModel.find.mockReturnValue({
sort: jest.fn().mockResolvedValue([mockStock]),
});
const completeAudit = {
_id: auditId,
state: { type: 'complete' },
};
editObject.mockResolvedValue(completeAudit);
getObject.mockResolvedValue(completeAudit);
newObject.mockResolvedValue({ _id: 'event-1' });
await postStockAuditRouteHandler(req, res);
expect(editObject).toHaveBeenCalledWith(
expect.objectContaining({
model: stockAuditModel,
id: expect.anything(),
updateData: {
state: { type: 'complete' },
postedAt: expect.any(Date),
},
})
);
expect(newObject).toHaveBeenCalledWith(
expect.objectContaining({
model: expect.anything(),
newData: expect.objectContaining({
value: -2,
unit: 'each',
parent: 'stock-1',
parentType: 'partStock',
owner: auditId,
ownerType: 'stockAudit',
}),
})
);
expect(editObject.mock.invocationCallOrder[0]).toBeLessThan(
newObject.mock.invocationCallOrder[0]
);
expect(res.send).toHaveBeenCalledWith(completeAudit);
});
});
});

View File

@ -1,183 +1,480 @@
import config from '../../config.js';
import { stockAuditModel } from '../../database/schemas/inventory/stockaudit.schema.js';
import {
stockAuditModel,
getCurrentQuantityAtLocation,
} from '../../database/schemas/inventory/stockaudit.schema.js';
import { filamentStockModel } from '../../database/schemas/inventory/filamentstock.schema.js';
import { partStockModel } from '../../database/schemas/inventory/partstock.schema.js';
import { productStockModel } from '../../database/schemas/inventory/productstock.schema.js';
import { stockEventModel } from '../../database/schemas/inventory/stockevent.schema.js';
import log4js from 'log4js';
import mongoose from 'mongoose';
import { getAuditLogs } from '../../utils.js';
import {
getModelStats, getModelHistory,
deleteObject,
listObjects,
getObject,
editObject,
newObject,
listObjectsByProperties,
getModelStats,
getModelHistory,
checkStates,
searchObjects,
getPropertyValues,
getObjectNeighbors,
} from '../../database/database.js';
const logger = log4js.getLogger('Stock Audits');
logger.level = config.server.logLevel;
const STOCK_AUDIT_POPULATE = [
{ path: 'auditLevel' },
{ path: 'stockLocation' },
{ path: 'auditLines.item' },
{ path: 'auditLines.itemSku' },
];
const normalizeAuditLineInput = (line) => {
const current = Number(line.current ?? line.currentQuantity) || 0;
const actual = Number(line.actual ?? line.actualQuantity) || 0;
return {
itemType: line.itemType,
item: line.item?._id ?? line.item,
itemSku: line.itemSku?._id ?? line.itemSku,
current,
actual,
new: actual,
};
};
const toId = (value) => {
if (value == null) return null;
if (typeof value === 'object' && value._id != null) return value._id;
return value;
};
const stockConfigByItemType = {
filament: {
stockModel: filamentStockModel,
parentType: 'filamentStock',
skuField: 'filamentSku',
itemField: 'filament',
unit: 'g',
getAvailable: (stock) => Number(stock.currentWeight?.net) || 0,
},
part: {
stockModel: partStockModel,
parentType: 'partStock',
skuField: 'partSku',
itemField: 'part',
unit: 'each',
getAvailable: (stock) => Number(stock.currentQuantity) || 0,
},
product: {
stockModel: productStockModel,
parentType: 'productStock',
skuField: 'productSku',
itemField: 'product',
unit: 'each',
getAvailable: (stock) => Number(stock.currentQuantity) || 0,
},
};
async function createStockEvent(newData, user) {
const result = await newObject({
model: stockEventModel,
newData,
user,
});
if (result?.error) {
throw new Error(result.error);
}
return result;
}
async function createStock(model, newData, user) {
const result = await newObject({
model,
newData,
user,
});
if (result?.error) {
throw new Error(result.error);
}
return result;
}
async function applyPositiveVariance(auditId, line, stockLocationId, variance, config, user) {
const itemId = toId(line.item);
const itemSkuId = toId(line.itemSku);
const ts = new Date();
if (line.itemType === 'filament') {
const weight = { net: variance, gross: variance };
const stock = await createStock(
config.stockModel,
{
state: { type: 'unconsumed' },
startingWeight: weight,
currentWeight: weight,
filament: itemId,
filamentSku: itemSkuId,
stockLocation: stockLocationId,
},
user
);
await createStockEvent(
{
value: variance,
unit: config.unit,
parent: stock._id,
parentType: config.parentType,
owner: auditId,
ownerType: 'stockAudit',
timestamp: ts,
},
user
);
return;
}
if (line.itemType === 'part') {
const stock = await createStock(
config.stockModel,
{
part: itemId,
partSku: itemSkuId,
currentQuantity: variance,
state: { type: 'new' },
postedAt: ts,
stockLocation: stockLocationId,
},
user
);
await createStockEvent(
{
value: variance,
unit: config.unit,
parent: stock._id,
parentType: config.parentType,
owner: auditId,
ownerType: 'stockAudit',
timestamp: ts,
},
user
);
return;
}
if (line.itemType === 'product') {
const stock = await createStock(
config.stockModel,
{
product: itemId,
productSku: itemSkuId,
currentQuantity: variance,
state: { type: 'new' },
postedAt: ts,
partStockList: [],
stockLocation: stockLocationId,
},
user
);
await createStockEvent(
{
value: variance,
unit: config.unit,
parent: stock._id,
parentType: config.parentType,
owner: auditId,
ownerType: 'stockAudit',
timestamp: ts,
},
user
);
}
}
async function applyNegativeVariance(auditId, line, stockLocationId, variance, config, user) {
const itemSkuId = toId(line.itemSku);
const amountToRemove = Math.abs(variance);
const ts = new Date();
const stocks = await config.stockModel
.find({
[config.skuField]: itemSkuId,
stockLocation: stockLocationId,
'state.type': { $ne: 'draft' },
})
.sort({ createdAt: 1 });
let remaining = amountToRemove;
for (const stock of stocks) {
if (remaining <= 0) break;
const available = config.getAvailable(stock);
if (available <= 0) continue;
const deduction = Math.min(remaining, available);
await createStockEvent(
{
value: -deduction,
unit: config.unit,
parent: stock._id,
parentType: config.parentType,
owner: auditId,
ownerType: 'stockAudit',
timestamp: ts,
},
user
);
remaining -= deduction;
}
if (remaining > 0) {
throw new Error(
`Insufficient stock to apply audit variance of ${variance} for SKU ${itemSkuId}`
);
}
}
async function executePostedAuditLine(auditId, line, stockLocationId, user) {
const current = Number(line.current) || 0;
const actual = Number(line.actual) || 0;
const variance = actual - current;
if (variance === 0) return;
const itemSkuId = toId(line.itemSku);
const liveCurrent = await getCurrentQuantityAtLocation(
line.itemType,
itemSkuId,
stockLocationId
);
if (liveCurrent !== current) {
throw new Error(
`Current quantity for SKU has changed (expected ${current}, found ${liveCurrent}). Recalculate the audit before posting.`
);
}
const config = stockConfigByItemType[line.itemType];
if (!config) {
throw new Error(`Unsupported item type: ${line.itemType}`);
}
if (variance > 0) {
await applyPositiveVariance(auditId, line, stockLocationId, variance, config, user);
} else {
await applyNegativeVariance(auditId, line, stockLocationId, variance, config, user);
}
}
export const listStockAuditsRouteHandler = async (
req,
res,
page = 1,
limit = 25,
property = '',
filter = {}
filter = {},
search = '',
sort = '',
order = 'ascend'
) => {
try {
const skip = (page - 1) * limit;
let stockAudits;
let aggregateCommand = [];
// Lookup createdBy user
aggregateCommand.push({
$lookup: {
from: 'users',
localField: 'createdBy',
foreignField: '_id',
as: 'createdBy',
},
const result = await listObjects({
model: stockAuditModel,
page,
limit,
property,
filter,
search,
sort,
order,
populate: STOCK_AUDIT_POPULATE,
});
aggregateCommand.push({ $unwind: '$createdBy' });
if (filter != {}) {
aggregateCommand.push({ $match: filter });
if (result?.error) {
logger.error('Error listing stock audits.');
res.status(result.code).send(result);
return;
}
if (property != '') {
aggregateCommand.push({ $group: { _id: `$${property}` } });
aggregateCommand.push({ $project: { _id: 0, [property]: '$_id' } });
logger.debug(`List of stock audits (Page ${page}, Limit ${limit}). Count: ${result.length}`);
res.send(result);
};
export const listStockAuditsByPropertiesRouteHandler = async (
req,
res,
properties = '',
filter = {},
masterFilter = {}
) => {
const result = await listObjectsByProperties({
model: stockAuditModel,
properties,
filter,
masterFilter,
populate: STOCK_AUDIT_POPULATE,
});
if (result?.error) {
logger.error('Error listing stock audits.');
res.status(result.code).send(result);
return;
}
aggregateCommand.push({ $skip: skip });
aggregateCommand.push({ $limit: Number(limit) });
logger.debug(`List of stock audits. Count: ${result.length}`);
res.send(result);
};
stockAudits = await stockAuditModel.aggregate(aggregateCommand);
logger.trace(
`List of stock audits (Page ${page}, Limit ${limit}, Property ${property}):`,
stockAudits
);
res.send(stockAudits);
} catch (error) {
logger.error('Error listing stock audits:', error);
res.status(500).send({ error: error });
}
export const getStockAuditPropertyValuesRouteHandler = async (
req,
res,
property,
filter,
masterFilter
) => {
const result = await getPropertyValues({
model: stockAuditModel,
property,
filter: { ...filter, ...masterFilter },
});
res.send(result);
};
export const searchStockAuditsRouteHandler = async (req, res, search) => {
const result = await searchObjects({
model: stockAuditModel,
search,
populate: STOCK_AUDIT_POPULATE,
});
res.send(result);
};
export const getStockAuditRouteHandler = async (req, res) => {
try {
const id = new mongoose.Types.ObjectId(req.params.id);
const stockAudit = await stockAuditModel
.findOne({
_id: id,
})
.populate('createdBy')
.populate('items.filamentStock')
.populate('items.partStock');
if (!stockAudit) {
const id = req.params.id;
const result = await getObject({
model: stockAuditModel,
id,
populate: STOCK_AUDIT_POPULATE,
});
if (result?.error) {
logger.warn(`Stock audit not found with supplied id.`);
return res.status(404).send({ error: 'Stock audit not found.' });
}
logger.trace(`Stock audit with ID: ${id}:`, stockAudit);
const auditLogs = await getAuditLogs(id);
res.send({ ...stockAudit._doc, auditLogs: auditLogs });
} catch (error) {
logger.error('Error fetching stock audit:', error);
res.status(500).send({ error: error.message });
}
};
export const newStockAuditRouteHandler = async (req, res) => {
try {
const newStockAudit = {
type: req.body.type,
status: req.body.status || 'pending',
notes: req.body.notes,
items: req.body.items.map((item) => ({
type: item.type,
stock:
item.type === 'filament'
? new mongoose.Types.ObjectId(item.filamentStock)
: new mongoose.Types.ObjectId(item.partStock),
expectedQuantity: item.expectedQuantity,
actualQuantity: item.actualQuantity,
notes: item.notes,
})),
createdBy: new mongoose.Types.ObjectId(req.body.createdBy),
completedAt: req.body.status === 'completed' ? new Date() : null,
};
const result = await stockAuditModel.create(newStockAudit);
if (!result) {
logger.error('No stock audit created.');
return res.status(500).send({ error: 'No stock audit created.' });
}
return res.send({ status: 'ok', id: result._id });
} catch (error) {
logger.error('Error adding stock audit:', error);
return res.status(500).send({ error: error.message });
return res.status(result.code).send(result);
}
logger.debug(`Retrieved stock audit with ID: ${id}`);
res.send(result);
};
export const updateStockAuditRouteHandler = async (req, res) => {
try {
const id = new mongoose.Types.ObjectId(req.params.id);
const updateData = {
...req.body,
items: req.body.items?.map((item) => ({
type: item.type,
stock:
item.type === 'filament'
? new mongoose.Types.ObjectId(item.filamentStock)
: new mongoose.Types.ObjectId(item.partStock),
expectedQuantity: item.expectedQuantity,
actualQuantity: item.actualQuantity,
notes: item.notes,
})),
completedAt: req.body.status === 'completed' ? new Date() : null,
const checkStatesResult = await checkStates({ model: stockAuditModel, id, states: ['draft'] });
if (checkStatesResult?.error) {
logger.error('Error checking stock audit state:', checkStatesResult.error);
res.status(checkStatesResult.code).send(checkStatesResult);
return;
}
if (checkStatesResult === false) {
logger.error('Stock audit is not in draft state.');
res.status(400).send({ error: 'Stock audit is not in draft state.', code: 400 });
return;
}
const updateData = {};
if (req.body.state !== undefined) {
updateData.state = req.body.state;
}
if (req.body.auditLevel !== undefined) {
updateData.auditLevel = req.body.auditLevel?._id ?? req.body.auditLevel;
}
if (req.body.stockLocation !== undefined) {
updateData.stockLocation = req.body.stockLocation?._id ?? req.body.stockLocation;
}
if (req.body.auditLines !== undefined) {
updateData.auditLines = (req.body.auditLines || []).map((line) => normalizeAuditLineInput(line));
}
const result = await editObject({
model: stockAuditModel,
id,
updateData,
user: req.user,
populate: STOCK_AUDIT_POPULATE,
});
if (result.error) {
logger.error('Error updating stock audit:', result.error);
res.status(result.code).send(result);
return;
}
logger.debug(`Updated stock audit with ID: ${id}`);
res.send(result);
};
export const newStockAuditRouteHandler = async (req, res) => {
const newData = {
state: req.body.state ?? { type: 'draft' },
auditLevel: req.body.auditLevel?._id ?? req.body.auditLevel,
stockLocation: req.body.stockLocation?._id ?? req.body.stockLocation,
auditLines: (req.body.auditLines || []).map((line) => normalizeAuditLineInput(line)),
};
const result = await stockAuditModel.findByIdAndUpdate(id, { $set: updateData }, { new: true });
const result = await newObject({
model: stockAuditModel,
newData,
user: req.user,
});
if (!result) {
logger.warn(`Stock audit not found with supplied id.`);
return res.status(404).send({ error: 'Stock audit not found.' });
if (result.error) {
logger.error('No stock audit created:', result.error);
return res.status(result.code).send(result);
}
logger.trace(`Updated stock audit with ID: ${id}:`, result);
logger.debug(`New stock audit with ID: ${result._id}`);
res.send(result);
} catch (error) {
logger.error('Error updating stock audit:', error);
res.status(500).send({ error: error.message });
}
};
export const deleteStockAuditRouteHandler = async (req, res) => {
try {
const id = new mongoose.Types.ObjectId(req.params.id);
const result = await stockAuditModel.findByIdAndDelete(id);
if (!result) {
logger.warn(`Stock audit not found with supplied id.`);
return res.status(404).send({ error: 'Stock audit not found.' });
const checkStatesResult = await checkStates({ model: stockAuditModel, id, states: ['draft'] });
if (checkStatesResult?.error) {
logger.error('Error checking stock audit state:', checkStatesResult.error);
res.status(checkStatesResult.code).send(checkStatesResult);
return;
}
logger.trace(`Deleted stock audit with ID: ${id}`);
res.send({ status: 'ok' });
} catch (error) {
logger.error('Error deleting stock audit:', error);
res.status(500).send({ error: error.message });
if (checkStatesResult === false) {
logger.error('Stock audit is not in draft state.');
res.status(400).send({ error: 'Stock audit is not in draft state.', code: 400 });
return;
}
const result = await deleteObject({
model: stockAuditModel,
id,
user: req.user,
});
if (result.error) {
logger.error('No stock audit deleted:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`Deleted stock audit with ID: ${result._id}`);
res.send(result);
};
export const getStockAuditStatsRouteHandler = async (req, res) => {
@ -233,3 +530,68 @@ export const getStockAuditNeighborsRouteHandler = async (
logger.debug(`Retrieved stock audit neighbors for ID: ${id}`);
res.send(result);
};
export const postStockAuditRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const checkStatesResult = await checkStates({ model: stockAuditModel, id, states: ['draft'] });
if (checkStatesResult?.error) {
logger.error('Error checking stock audit state:', checkStatesResult.error);
res.status(checkStatesResult.code).send(checkStatesResult);
return;
}
if (checkStatesResult === false) {
logger.error('Stock audit is not in draft state.');
res.status(400).send({ error: 'Stock audit is not in draft state.', code: 400 });
return;
}
const doc = await stockAuditModel.findById(id);
if (!doc) {
return res.status(404).send({ error: 'Stock audit not found.', code: 404 });
}
if (!doc.auditLines?.length) {
return res.status(400).send({ error: 'Stock audit has no audit lines.', code: 400 });
}
const stockLocationId = doc.stockLocation;
try {
const completeResult = await editObject({
model: stockAuditModel,
id,
updateData: {
state: { type: 'complete' },
postedAt: new Date(),
},
user: req.user,
});
if (completeResult?.error) {
throw new Error(completeResult.error);
}
for (const line of doc.auditLines) {
await executePostedAuditLine(doc._id, line.toObject(), stockLocationId, req.user);
}
const complete = await getObject({
model: stockAuditModel,
id,
populate: STOCK_AUDIT_POPULATE,
});
if (complete?.error) {
throw new Error(complete.error);
}
logger.debug(`Posted stock audit with ID: ${id}`);
res.send(complete);
} catch (err) {
logger.error('Error posting stock audit:', err);
res.status(400).send({ error: err.message || 'Failed to post stock audit', code: 400 });
}
};

View File

@ -0,0 +1,130 @@
import { jest } from '@jest/globals';
jest.unstable_mockModule('../../../database/database.js', () => ({
searchObjects: jest.fn(),
getPropertyValues: jest.fn(),
listObjects: jest.fn(),
getObject: jest.fn(),
editObject: jest.fn(),
newObject: jest.fn(),
deleteObject: jest.fn(),
listObjectsByProperties: jest.fn(),
getModelStats: jest.fn(),
getModelHistory: jest.fn(),
getObjectNeighbors: jest.fn(),
deleteObjectCache: jest.fn(),
}));
jest.unstable_mockModule('../../../database/schemas/management/stockauditlevel.schema.js', () => ({
stockAuditLevelModel: { modelName: 'StockAuditLevel' },
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
trace: jest.fn(),
}),
},
}));
const {
listStockAuditLevelsRouteHandler,
getStockAuditLevelRouteHandler,
newStockAuditLevelRouteHandler,
editStockAuditLevelRouteHandler,
} = await import('../stockauditlevels.js');
const { listObjects, getObject, editObject, newObject } = await import(
'../../../database/database.js'
);
const { stockAuditLevelModel } = await import(
'../../../database/schemas/management/stockauditlevel.schema.js'
);
describe('Stock Audit Level Service Route Handlers', () => {
let req, res;
beforeEach(() => {
req = {
params: {},
query: {},
body: {},
user: { id: 'test-user-id' },
};
res = {
send: jest.fn(),
status: jest.fn().mockReturnThis(),
};
jest.clearAllMocks();
});
describe('listStockAuditLevelsRouteHandler', () => {
it('should list stock audit levels', async () => {
const mockResult = [{ _id: '1', name: 'Full audit' }];
listObjects.mockResolvedValue(mockResult);
await listStockAuditLevelsRouteHandler(req, res);
expect(listObjects).toHaveBeenCalledWith(
expect.objectContaining({ model: stockAuditLevelModel })
);
expect(res.send).toHaveBeenCalledWith(mockResult);
});
});
describe('getStockAuditLevelRouteHandler', () => {
it('should get a stock audit level', async () => {
req.params.id = 'level-1';
const mockResult = { _id: 'level-1', name: 'Full audit' };
getObject.mockResolvedValue(mockResult);
await getStockAuditLevelRouteHandler(req, res);
expect(getObject).toHaveBeenCalledWith(
expect.objectContaining({ model: stockAuditLevelModel, id: 'level-1' })
);
expect(res.send).toHaveBeenCalledWith(mockResult);
});
});
describe('newStockAuditLevelRouteHandler', () => {
it('should create a stock audit level', async () => {
req.body = { name: 'Full audit', auditLines: [] };
const mockResult = { _id: 'level-1', name: 'Full audit' };
newObject.mockResolvedValue(mockResult);
await newStockAuditLevelRouteHandler(req, res);
expect(newObject).toHaveBeenCalledWith(
expect.objectContaining({
model: stockAuditLevelModel,
newData: expect.objectContaining({ name: 'Full audit', auditLines: [] }),
})
);
expect(res.send).toHaveBeenCalledWith(mockResult);
});
});
describe('editStockAuditLevelRouteHandler', () => {
it('should edit a stock audit level', async () => {
req.params.id = '507f1f77bcf86cd799439011';
req.body = { name: 'Updated audit', auditLines: [] };
const mockResult = { _id: req.params.id, name: 'Updated audit' };
editObject.mockResolvedValue(mockResult);
await editStockAuditLevelRouteHandler(req, res);
expect(editObject).toHaveBeenCalledWith(
expect.objectContaining({
model: stockAuditLevelModel,
updateData: expect.objectContaining({ name: 'Updated audit' }),
})
);
expect(res.send).toHaveBeenCalledWith(mockResult);
});
});
});

View File

@ -0,0 +1,241 @@
import config from '../../config.js';
import { stockAuditLevelModel } from '../../database/schemas/management/stockauditlevel.schema.js';
import log4js from 'log4js';
import mongoose from 'mongoose';
import {
deleteObject,
listObjects,
getObject,
editObject,
newObject,
listObjectsByProperties,
getModelStats,
getModelHistory,
searchObjects,
getPropertyValues,
getObjectNeighbors,
} from '../../database/database.js';
const logger = log4js.getLogger('Stock Audit Levels');
logger.level = config.server.logLevel;
const STOCK_AUDIT_LEVEL_POPULATE = [{ path: 'auditLines.item' }, { path: 'auditLines.itemSku' }];
export const listStockAuditLevelsRouteHandler = async (
req,
res,
page = 1,
limit = 25,
property = '',
filter = {},
search = '',
sort = '',
order = 'ascend'
) => {
const result = await listObjects({
model: stockAuditLevelModel,
page,
limit,
property,
filter,
search,
sort,
order,
populate: STOCK_AUDIT_LEVEL_POPULATE,
});
if (result?.error) {
logger.error('Error listing stock audit levels.');
res.status(result.code).send(result);
return;
}
logger.debug(
`List of stock audit levels (Page ${page}, Limit ${limit}). Count: ${result.length}.`
);
res.send(result);
};
export const listStockAuditLevelsByPropertiesRouteHandler = async (
req,
res,
properties = '',
filter = {},
masterFilter = {}
) => {
const result = await listObjectsByProperties({
model: stockAuditLevelModel,
properties,
filter,
masterFilter,
populate: STOCK_AUDIT_LEVEL_POPULATE,
});
if (result?.error) {
logger.error('Error listing stock audit levels.');
res.status(result.code).send(result);
return;
}
logger.debug(`List of stock audit levels. Count: ${result.length}`);
res.send(result);
};
export const getStockAuditLevelPropertyValuesRouteHandler = async (
req,
res,
property,
filter,
masterFilter
) => {
const result = await getPropertyValues({
model: stockAuditLevelModel,
property,
filter: { ...filter, ...masterFilter },
});
res.send(result);
};
export const searchStockAuditLevelsRouteHandler = async (req, res, search) => {
const result = await searchObjects({
model: stockAuditLevelModel,
search,
populate: STOCK_AUDIT_LEVEL_POPULATE,
});
res.send(result);
};
export const getStockAuditLevelRouteHandler = async (req, res) => {
const id = req.params.id;
const result = await getObject({
model: stockAuditLevelModel,
id,
populate: STOCK_AUDIT_LEVEL_POPULATE,
});
if (result?.error) {
logger.warn(`Stock audit level not found with supplied id.`);
return res.status(result.code).send(result);
}
logger.debug(`Retrieved stock audit level with ID: ${id}`);
res.send(result);
};
export const editStockAuditLevelRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const updateData = {
name: req.body.name,
tags: req.body.tags,
auditLines: req.body.auditLines,
};
const result = await editObject({
model: stockAuditLevelModel,
id,
updateData,
user: req.user,
populate: STOCK_AUDIT_LEVEL_POPULATE,
});
if (result.error) {
logger.error('Error editing stock audit level:', result.error);
res.status(result.code).send(result);
return;
}
logger.debug(`Edited stock audit level with ID: ${id}`);
res.send(result);
};
export const newStockAuditLevelRouteHandler = async (req, res) => {
const newData = {
name: req.body.name,
tags: req.body.tags,
};
const result = await newObject({
model: stockAuditLevelModel,
newData,
user: req.user,
});
if (result.error) {
logger.error('No stock audit level created:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`New stock audit level with ID: ${result._id}`);
res.send(result);
};
export const deleteStockAuditLevelRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const result = await deleteObject({
model: stockAuditLevelModel,
id,
user: req.user,
});
if (result.error) {
logger.error('No stock audit level deleted:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`Deleted stock audit level with ID: ${result._id}`);
res.send(result);
};
export const getStockAuditLevelStatsRouteHandler = async (req, res) => {
const result = await getModelStats({ model: stockAuditLevelModel });
if (result?.error) {
logger.error('Error fetching stock audit level stats:', result.error);
return res.status(result.code).send(result);
}
logger.trace('Stock audit level stats:', result);
res.send(result);
};
export const getStockAuditLevelHistoryRouteHandler = async (req, res) => {
const from = req.query.from;
const to = req.query.to;
const result = await getModelHistory({ model: stockAuditLevelModel, from, to });
if (result?.error) {
logger.error('Error fetching stock audit level history:', result.error);
return res.status(result.code).send(result);
}
logger.trace('Stock audit level history:', result);
res.send(result);
};
export const getStockAuditLevelNeighborsRouteHandler = async (
req,
res,
property = '',
filter = {},
search = '',
sort = '',
order = 'ascend',
id
) => {
if (!id) {
return res.status(400).send({ error: 'Missing id parameter', code: 400 });
}
const result = await getObjectNeighbors({
model: stockAuditLevelModel,
id,
filter,
search,
sort,
order,
});
if (result?.error) {
logger.error('Error fetching stock audit level neighbors.');
return res.status(result.code).send(result);
}
logger.debug(`Retrieved stock audit level neighbors for ID: ${id}`);
res.send(result);
};

View File

@ -36,7 +36,8 @@ export const EXPORT_FILTER_BY_TYPE = {
stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
stockLocation: ['name', 'address'],
stockTransfer: ['name', 'state.type', 'postedAt'],
stockAudit: ['filamentStock._id', 'partStock._id'],
stockAudit: ['auditLevel._id', 'stockLocation._id', 'state.type', 'postedAt'],
stockAuditLevel: ['name', 'tags'],
documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'],
documentTemplate: ['parent._id', 'documentSize._id'],
salesOrder: ['client'],