Added stock location and stock transfer features, including new routes, database schemas, and service updates. Enhanced existing stock-related functionalities to support filtering and population of stock location data.

This commit is contained in:
Tom Butcher 2026-03-21 23:14:07 +00:00
parent 1b858d8814
commit d3c662a9ec
21 changed files with 989 additions and 29 deletions

View File

@ -20,6 +20,11 @@ const filamentStockSchema = new Schema(
gross: { type: Number, required: true },
},
filamentSku: { type: mongoose.Schema.Types.ObjectId, ref: 'filamentSku', required: true },
stockLocation: {
type: mongoose.Schema.Types.ObjectId,
ref: 'stockLocation',
required: false,
},
},
{ timestamps: true }
);

View File

@ -12,6 +12,11 @@ const partStockSchema = new Schema(
progress: { type: Number, required: false },
},
partSku: { type: mongoose.Schema.Types.ObjectId, ref: 'partSku', required: true },
stockLocation: {
type: mongoose.Schema.Types.ObjectId,
ref: 'stockLocation',
required: false,
},
currentQuantity: { type: Number, required: true },
sourceType: { type: String, required: true },
source: { type: Schema.Types.ObjectId, refPath: 'sourceType', required: true },

View File

@ -19,6 +19,11 @@ const productStockSchema = new Schema(
},
postedAt: { type: Date, required: false },
productSku: { type: mongoose.Schema.Types.ObjectId, ref: 'productSku', required: true },
stockLocation: {
type: mongoose.Schema.Types.ObjectId,
ref: 'stockLocation',
required: false,
},
currentQuantity: { type: Number, required: true },
partStocks: [partStockUsageSchema],
},

View File

@ -25,7 +25,7 @@ const stockEventSchema = new Schema(
ownerType: {
type: String,
required: true,
enum: ['user', 'subJob', 'stockAudit'],
enum: ['user', 'subJob', 'stockAudit', 'stockTransfer'],
},
timestamp: { type: Date, default: Date.now },
},

View File

@ -0,0 +1,29 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const stockLocationSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { type: String, required: true },
notes: { type: String, required: false },
},
{ timestamps: true }
);
stockLocationSchema.statics.stats = async function () {
const total = await this.countDocuments({});
return { total: { count: total } };
};
stockLocationSchema.statics.history = async function () {
return [];
};
stockLocationSchema.virtual('id').get(function () {
return this._id;
});
stockLocationSchema.set('toJSON', { virtuals: true });
export const stockLocationModel = mongoose.model('stockLocation', stockLocationSchema);

View File

@ -0,0 +1,71 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const stockTransferLineSchema = new Schema(
{
fromStockType: {
type: String,
required: true,
enum: ['filamentStock', 'partStock', 'productStock'],
},
fromStock: {
type: Schema.Types.ObjectId,
refPath: 'fromStockType',
required: true,
},
quantity: { type: Number, required: true },
toStockLocation: {
type: Schema.Types.ObjectId,
ref: 'stockLocation',
required: true,
},
toStockType: {
type: String,
required: false,
enum: ['filamentStock', 'partStock', 'productStock'],
},
toStock: {
type: Schema.Types.ObjectId,
refPath: 'toStockType',
required: false,
},
},
{ _id: true }
);
const stockTransferSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
state: {
type: { type: String, required: true, default: 'draft' },
progress: { type: Number, required: false },
},
postedAt: { type: Date, required: false },
lines: { type: [stockTransferLineSchema], default: [] },
},
{ timestamps: true }
);
stockTransferSchema.statics.stats = async function () {
const [draft, posted] = await Promise.all([
this.countDocuments({ 'state.type': 'draft' }),
this.countDocuments({ 'state.type': 'posted' }),
]);
return {
draft: { count: draft },
posted: { count: posted },
};
};
stockTransferSchema.statics.history = async function () {
return [];
};
stockTransferSchema.virtual('id').get(function () {
return this._id;
});
stockTransferSchema.set('toJSON', { virtuals: true });
export const stockTransferModel = mongoose.model('stockTransfer', stockTransferSchema);

View File

@ -31,19 +31,10 @@ partSchema.virtual('id').get(function () {
partSchema.set('toJSON', { virtuals: true });
partSchema.statics.recalculate = async function (part, user) {
const orderItemModel = mongoose.model('orderItem');
const itemId = part._id;
const draftOrderItems = await orderItemModel
.find({
'state.type': 'draft',
itemType: 'part',
item: itemId,
})
.populate('order')
.lean();
for (const orderItem of draftOrderItems) {
await orderItemModel.recalculate(orderItem, user);
const partSkuModel = mongoose.model('partSku');
const skus = await partSkuModel.find({ part: part._id }).select('_id').lean();
for (const sku of skus) {
await partSkuModel.recalculate(sku, user);
}
};

View File

@ -17,6 +17,8 @@ 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 { stockLocationModel } from './inventory/stocklocation.schema.js';
import { stockTransferModel } from './inventory/stocktransfer.schema.js';
import { auditLogModel } from './management/auditlog.schema.js';
import { userModel } from './management/user.schema.js';
import { appPasswordModel } from './management/apppassword.schema.js';
@ -157,6 +159,20 @@ export const models = {
referenceField: '_reference',
label: 'Product Stock',
},
SLN: {
model: stockLocationModel,
idField: '_id',
type: 'stockLocation',
referenceField: '_reference',
label: 'Stock Location',
},
STT: {
model: stockTransferModel,
idField: '_id',
type: 'stockTransfer',
referenceField: '_reference',
label: 'Stock Transfer',
},
ADL: {
model: auditLogModel,
idField: '_id',

View File

@ -29,6 +29,8 @@ import {
orderItemRoutes,
shipmentRoutes,
stockAuditRoutes,
stockLocationRoutes,
stockTransferRoutes,
stockEventRoutes,
auditLogRoutes,
noteTypeRoutes,
@ -152,6 +154,8 @@ app.use('/orderitems', orderItemRoutes);
app.use('/shipments', shipmentRoutes);
app.use('/stockevents', stockEventRoutes);
app.use('/stockaudits', stockAuditRoutes);
app.use('/stocklocations', stockLocationRoutes);
app.use('/stocktransfers', stockTransferRoutes);
app.use('/auditlogs', auditLogRoutes);
app.use('/notetypes', noteTypeRoutes);
app.use('/documentsizes', documentSizesRoutes);

View File

@ -24,6 +24,8 @@ 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 stockLocationRoutes from './inventory/stocklocations.js';
import stockTransferRoutes from './inventory/stocktransfers.js';
import auditLogRoutes from './management/auditlogs.js';
import noteTypeRoutes from './management/notetypes.js';
import documentSizesRoutes from './management/documentsizes.js';
@ -75,6 +77,8 @@ export {
shipmentRoutes,
stockEventRoutes,
stockAuditRoutes,
stockLocationRoutes,
stockTransferRoutes,
auditLogRoutes,
noteTypeRoutes,
noteRoutes,

View File

@ -18,7 +18,15 @@ import {
// list of filament stocks
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['filamentSku', 'state', 'startingWeight', 'currentWeight', 'filamentSku._id'];
const allowedFilters = [
'filamentSku',
'state',
'startingWeight',
'currentWeight',
'filamentSku._id',
'stockLocation',
'stockLocation._id',
];
const filter = getFilter(req.query, allowedFilters);
listFilamentStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});

View File

@ -18,7 +18,15 @@ import {
// list of part stocks
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['partSku', 'state', 'startingQuantity', 'currentQuantity', 'partSku._id'];
const allowedFilters = [
'partSku',
'state',
'startingQuantity',
'currentQuantity',
'partSku._id',
'stockLocation',
'stockLocation._id',
];
const filter = getFilter(req.query, allowedFilters);
listPartStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});

View File

@ -18,7 +18,14 @@ import {
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['productSku', 'state', 'currentQuantity', 'productSku._id'];
const allowedFilters = [
'productSku',
'state',
'currentQuantity',
'productSku._id',
'stockLocation',
'stockLocation._id',
];
const filter = getFilter(req.query, allowedFilters);
listProductStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});

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 {
listStockLocationsRouteHandler,
getStockLocationRouteHandler,
editStockLocationRouteHandler,
editMultipleStockLocationsRouteHandler,
newStockLocationRouteHandler,
deleteStockLocationRouteHandler,
listStockLocationsByPropertiesRouteHandler,
getStockLocationStatsRouteHandler,
getStockLocationHistoryRouteHandler,
} from '../../services/inventory/stocklocations.js';
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['name', 'notes'];
const filter = getFilter(req.query, allowedFilters);
listStockLocationsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['name'];
const filter = getFilter(req.query, allowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = JSON.parse(req.query.masterFilter);
}
listStockLocationsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
router.post('/', isAuthenticated, (req, res) => {
newStockLocationRouteHandler(req, res);
});
router.get('/stats', isAuthenticated, (req, res) => {
getStockLocationStatsRouteHandler(req, res);
});
router.get('/history', isAuthenticated, (req, res) => {
getStockLocationHistoryRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getStockLocationRouteHandler(req, res);
});
router.put('/', isAuthenticated, async (req, res) => {
editMultipleStockLocationsRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editStockLocationRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteStockLocationRouteHandler(req, res);
});
export default router;

View File

@ -0,0 +1,69 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { getFilter, convertPropertiesString } from '../../utils.js';
const router = express.Router();
import {
listStockTransfersRouteHandler,
getStockTransferRouteHandler,
editStockTransferRouteHandler,
editMultipleStockTransfersRouteHandler,
newStockTransferRouteHandler,
deleteStockTransferRouteHandler,
postStockTransferRouteHandler,
listStockTransfersByPropertiesRouteHandler,
getStockTransferStatsRouteHandler,
getStockTransferHistoryRouteHandler,
} from '../../services/inventory/stocktransfers.js';
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['state', 'state.type', 'postedAt'];
const filter = getFilter(req.query, allowedFilters);
listStockTransfersRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['state.type'];
const filter = getFilter(req.query, allowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = JSON.parse(req.query.masterFilter);
}
listStockTransfersByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
router.post('/', isAuthenticated, (req, res) => {
newStockTransferRouteHandler(req, res);
});
router.get('/stats', isAuthenticated, (req, res) => {
getStockTransferStatsRouteHandler(req, res);
});
router.get('/history', isAuthenticated, (req, res) => {
getStockTransferHistoryRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getStockTransferRouteHandler(req, res);
});
router.put('/', isAuthenticated, async (req, res) => {
editMultipleStockTransfersRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editStockTransferRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteStockTransferRouteHandler(req, res);
});
router.post('/:id/post', isAuthenticated, async (req, res) => {
postStockTransferRouteHandler(req, res);
});
export default router;

View File

@ -36,7 +36,7 @@ export const listFilamentStocksRouteHandler = async (
search,
sort,
order,
populate: [{ path: 'filamentSku' }],
populate: [{ path: 'filamentSku' }, { path: 'stockLocation' }],
});
if (result?.error) {
@ -60,7 +60,7 @@ export const listFilamentStocksByPropertiesRouteHandler = async (
model: filamentStockModel,
properties,
filter,
populate: ['filamentSku'],
populate: ['filamentSku', 'stockLocation'],
masterFilter,
});
@ -79,7 +79,7 @@ export const getFilamentStockRouteHandler = async (req, res) => {
const result = await getObject({
model: filamentStockModel,
id,
populate: [{ path: 'filamentSku' }],
populate: [{ path: 'filamentSku' }, { path: 'stockLocation' }],
});
if (result?.error) {
logger.warn(`Filament Stock not found with supplied id.`);
@ -95,8 +95,9 @@ export const editFilamentStockRouteHandler = async (req, res) => {
logger.trace(`Filament Stock with ID: ${id}`);
const updateData = {};
// Create audit log before updating
const updateData = {
stockLocation: req.body.stockLocation,
};
const result = await editObject({
model: filamentStockModel,
id,
@ -148,6 +149,7 @@ export const newFilamentStockRouteHandler = async (req, res) => {
currentWeight: req.body.currentWeight,
filamentSku: req.body.filamentSku,
state: req.body.state,
stockLocation: req.body.stockLocation,
};
const result = await newObject({
model: filamentStockModel,

View File

@ -36,7 +36,7 @@ export const listPartStocksRouteHandler = async (
search,
sort,
order,
populate: [{ path: 'partSku' }],
populate: [{ path: 'partSku' }, { path: 'stockLocation' }, { path: 'source' }],
});
if (result?.error) {
@ -60,7 +60,7 @@ export const listPartStocksByPropertiesRouteHandler = async (
model: partStockModel,
properties,
filter,
populate: ['partSku'],
populate: ['partSku', 'stockLocation', 'source'],
masterFilter,
});
@ -79,7 +79,7 @@ export const getPartStockRouteHandler = async (req, res) => {
const result = await getObject({
model: partStockModel,
id,
populate: [{ path: 'partSku' }],
populate: [{ path: 'partSku' }, { path: 'stockLocation' }, { path: 'source' }],
});
if (result?.error) {
logger.warn(`Part Stock not found with supplied id.`);
@ -96,7 +96,6 @@ export const editPartStockRouteHandler = async (req, res) => {
logger.trace(`Part Stock with ID: ${id}`);
const updateData = {};
// Create audit log before updating
const result = await editObject({
model: partStockModel,
id,
@ -148,6 +147,9 @@ export const newPartStockRouteHandler = async (req, res) => {
currentQuantity: req.body.currentQuantity,
partSku: req.body.partSku,
state: req.body.state,
sourceType: req.body.sourceType,
source: req.body.source,
stockLocation: req.body.stockLocation,
};
const result = await newObject({
model: partStockModel,

View File

@ -38,7 +38,11 @@ export const listProductStocksRouteHandler = async (
search,
sort,
order,
populate: [{ path: 'productSku' }, { path: 'partStocks.partStock' }],
populate: [
{ path: 'productSku' },
{ path: 'partStocks.partStock' },
{ path: 'stockLocation' },
],
});
if (result?.error) {
@ -62,7 +66,7 @@ export const listProductStocksByPropertiesRouteHandler = async (
model: productStockModel,
properties,
filter,
populate: ['productSku', 'partStocks.partStock'],
populate: ['productSku', 'partStocks.partStock', 'stockLocation'],
masterFilter,
});
@ -81,7 +85,12 @@ export const getProductStockRouteHandler = async (req, res) => {
const result = await getObject({
model: productStockModel,
id,
populate: [{ path: 'partStocks.partSku' }, { path: 'partStocks.partStock' }, { path: 'productSku' }],
populate: [
{ path: 'partStocks.partSku' },
{ path: 'partStocks.partStock' },
{ path: 'productSku' },
{ path: 'stockLocation' },
],
});
if (result?.error) {
logger.warn(`Product Stock not found with supplied id.`);
@ -111,6 +120,7 @@ export const editProductStockRouteHandler = async (req, res) => {
}
const updateData = {
stockLocation: req.body?.stockLocation,
partStocks: req.body?.partStocks?.map((partStock) => ({
quantity: partStock.quantity,
partStock: partStock.partStock,
@ -173,6 +183,7 @@ export const newProductStockRouteHandler = async (req, res) => {
currentQuantity: req.body.currentQuantity,
productSku: req.body.productSku,
state: req.body.state ?? { type: 'draft' },
stockLocation: req.body.stockLocation,
partStocks: (productSku.parts || []).map((part) => ({
partSku: part.partSku,
quantity: part.quantity,

View File

@ -0,0 +1,196 @@
import config from '../../config.js';
import { stockLocationModel } from '../../database/schemas/inventory/stocklocation.schema.js';
import log4js from 'log4js';
import mongoose from 'mongoose';
import {
deleteObject,
listObjects,
getObject,
editObject,
editObjects,
newObject,
listObjectsByProperties,
getModelStats,
getModelHistory,
} from '../../database/database.js';
const logger = log4js.getLogger('Stock Locations');
logger.level = config.server.logLevel;
export const listStockLocationsRouteHandler = async (
req,
res,
page = 1,
limit = 25,
property = '',
filter = {},
search = '',
sort = '',
order = 'ascend'
) => {
const result = await listObjects({
model: stockLocationModel,
page,
limit,
property,
filter,
search,
sort,
order,
});
if (result?.error) {
logger.error('Error listing stock locations.');
res.status(result.code).send(result);
return;
}
logger.debug(`List of stock locations (Page ${page}, Limit ${limit}). Count: ${result.length}`);
res.send(result);
};
export const listStockLocationsByPropertiesRouteHandler = async (
req,
res,
properties = '',
filter = {},
masterFilter = {}
) => {
const result = await listObjectsByProperties({
model: stockLocationModel,
properties,
filter,
masterFilter,
});
if (result?.error) {
logger.error('Error listing stock locations.');
res.status(result.code).send(result);
return;
}
logger.debug(`List of stock locations. Count: ${result.length}`);
res.send(result);
};
export const getStockLocationRouteHandler = async (req, res) => {
const id = req.params.id;
const result = await getObject({
model: stockLocationModel,
id,
});
if (result?.error) {
logger.warn(`Stock location not found with supplied id.`);
return res.status(result.code).send(result);
}
logger.debug(`Retrieved stock location with ID: ${id}`);
res.send(result);
};
export const editStockLocationRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const updateData = {
name: req.body.name,
notes: req.body.notes,
};
const result = await editObject({
model: stockLocationModel,
id,
updateData,
user: req.user,
});
if (result.error) {
logger.error('Error editing stock location:', result.error);
res.status(result.code).send(result);
return;
}
logger.debug(`Edited stock location with ID: ${id}`);
res.send(result);
};
export const editMultipleStockLocationsRouteHandler = 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: stockLocationModel,
updates,
user: req.user,
});
if (result.error) {
logger.error('Error editing stock locations:', result.error);
res.status(result.code || 500).send(result);
return;
}
logger.debug(`Edited ${updates.length} stock locations`);
res.send(result);
};
export const newStockLocationRouteHandler = async (req, res) => {
const newData = {
name: req.body.name,
notes: req.body.notes,
};
const result = await newObject({
model: stockLocationModel,
newData,
user: req.user,
});
if (result.error) {
logger.error('No stock location created:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`New stock location with ID: ${result._id}`);
res.send(result);
};
export const deleteStockLocationRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const result = await deleteObject({
model: stockLocationModel,
id,
user: req.user,
});
if (result.error) {
logger.error('No stock location deleted:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`Deleted stock location with ID: ${result._id}`);
res.send(result);
};
export const getStockLocationStatsRouteHandler = async (req, res) => {
const result = await getModelStats({ model: stockLocationModel });
if (result?.error) {
logger.error('Error fetching stock location stats:', result.error);
return res.status(result.code).send(result);
}
logger.trace('Stock location stats:', result);
res.send(result);
};
export const getStockLocationHistoryRouteHandler = async (req, res) => {
const from = req.query.from;
const to = req.query.to;
const result = await getModelHistory({ model: stockLocationModel, from, to });
if (result?.error) {
logger.error('Error fetching stock location history:', result.error);
return res.status(result.code).send(result);
}
logger.trace('Stock location history:', result);
res.send(result);
};

View File

@ -0,0 +1,461 @@
import config from '../../config.js';
import { stockTransferModel } from '../../database/schemas/inventory/stocktransfer.schema.js';
import { stockLocationModel } from '../../database/schemas/inventory/stocklocation.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 {
deleteObject,
listObjects,
getObject,
editObject,
editObjects,
newObject,
listObjectsByProperties,
getModelStats,
getModelHistory,
checkStates,
} from '../../database/database.js';
const logger = log4js.getLogger('Stock Transfers');
logger.level = config.server.logLevel;
const normalizeLineInput = (l) => ({
fromStockType: l.fromStockType,
fromStock: l.fromStock?._id ?? l.fromStock,
quantity: Number(l.quantity),
toStockLocation: l.toStockLocation?._id ?? l.toStockLocation,
});
async function createStockEventsForLine({ transferId, fromId, fromType, toId, toType, qty, unit }) {
const ts = new Date();
await stockEventModel.insertMany([
{
value: -Math.abs(qty),
unit,
parent: fromId,
parentType: fromType,
owner: transferId,
ownerType: 'stockTransfer',
timestamp: ts,
},
{
value: Math.abs(qty),
unit,
parent: toId,
parentType: toType,
owner: transferId,
ownerType: 'stockTransfer',
timestamp: ts,
},
]);
}
async function executePostedLine(transferId, line) {
const toLocId = line.toStockLocation;
const loc = await stockLocationModel.findById(toLocId).lean();
if (!loc) {
throw new Error(`Unknown stock location: ${toLocId}`);
}
if (!(line.quantity > 0)) {
throw new Error('Line quantity must be positive');
}
if (line.fromStockType === 'filamentStock') {
const src = await filamentStockModel.findById(line.fromStock);
if (!src) throw new Error('From filament stock not found');
const netAvail = src.currentWeight?.net ?? 0;
if (line.quantity > netAvail) {
throw new Error('Filament transfer quantity exceeds available net weight');
}
const tareBefore = Math.max(0, (src.currentWeight?.gross ?? 0) - (src.currentWeight?.net ?? 0));
const newNet = netAvail - line.quantity;
const ratio = netAvail > 0 ? newNet / netAvail : 0;
const newGross = (src.currentWeight?.gross ?? 0) * ratio;
await filamentStockModel.findByIdAndUpdate(src._id, {
$set: { 'currentWeight.net': newNet, 'currentWeight.gross': newGross },
});
const destWeight = {
net: line.quantity,
gross: line.quantity + tareBefore,
};
const dest = await filamentStockModel.create({
state: src.state,
startingWeight: destWeight,
currentWeight: destWeight,
filamentSku: src.filamentSku,
stockLocation: toLocId,
});
await createStockEventsForLine({
transferId,
fromId: src._id,
fromType: 'filamentStock',
toId: dest._id,
toType: 'filamentStock',
qty: line.quantity,
unit: 'g',
});
return { toStockType: 'filamentStock', toStock: dest._id };
}
if (line.fromStockType === 'partStock') {
const src = await partStockModel.findById(line.fromStock);
console.log(src);
if (!src) throw new Error('From part stock not found');
const currentQuantity = src.state.type === 'new' ? src.startingQuantity : src.currentQuantity;
if (line.quantity > currentQuantity) {
throw new Error('Part transfer quantity exceeds current quantity');
}
await partStockModel.findByIdAndUpdate(src._id, {
$inc: { currentQuantity: -line.quantity },
});
const dest = await partStockModel.create({
partSku: src.partSku,
currentQuantity: line.quantity,
state: { type: 'new' },
sourceType: 'stockTransfer',
source: transferId,
stockLocation: toLocId,
});
await createStockEventsForLine({
transferId,
fromId: src._id,
fromType: 'partStock',
toId: dest._id,
toType: 'partStock',
qty: line.quantity,
unit: 'each',
});
return { toStockType: 'partStock', toStock: dest._id };
}
if (line.fromStockType === 'productStock') {
const src = await productStockModel.findById(line.fromStock);
if (!src) throw new Error('From product stock not found');
if (line.quantity > src.currentQuantity) {
throw new Error('Product transfer quantity exceeds current quantity');
}
await productStockModel.findByIdAndUpdate(src._id, {
$inc: { currentQuantity: -line.quantity },
});
const dest = await productStockModel.create({
productSku: src.productSku,
currentQuantity: line.quantity,
state: { type: 'posted' },
postedAt: new Date(),
partStocks: [],
stockLocation: toLocId,
});
await createStockEventsForLine({
transferId,
fromId: src._id,
fromType: 'productStock',
toId: dest._id,
toType: 'productStock',
qty: line.quantity,
unit: 'each',
});
return { toStockType: 'productStock', toStock: dest._id };
}
throw new Error(`Unsupported from stock type: ${line.fromStockType}`);
}
export const listStockTransfersRouteHandler = async (
req,
res,
page = 1,
limit = 25,
property = '',
filter = {},
search = '',
sort = '',
order = 'ascend'
) => {
const result = await listObjects({
model: stockTransferModel,
page,
limit,
property,
filter,
search,
sort,
order,
populate: [
{ path: 'lines.fromStock' },
{ path: 'lines.toStockLocation' },
{ path: 'lines.toStock' },
],
});
if (result?.error) {
logger.error('Error listing stock transfers.');
res.status(result.code).send(result);
return;
}
logger.debug(`List of stock transfers (Page ${page}, Limit ${limit}). Count: ${result.length}`);
res.send(result);
};
export const listStockTransfersByPropertiesRouteHandler = async (
req,
res,
properties = '',
filter = {},
masterFilter = {}
) => {
const result = await listObjectsByProperties({
model: stockTransferModel,
properties,
filter,
populate: [
{ path: 'lines.fromStock' },
{ path: 'lines.toStockLocation' },
{ path: 'lines.toStock' },
],
masterFilter,
});
if (result?.error) {
logger.error('Error listing stock transfers.');
res.status(result.code).send(result);
return;
}
logger.debug(`List of stock transfers. Count: ${result.length}`);
res.send(result);
};
export const getStockTransferRouteHandler = async (req, res) => {
const id = req.params.id;
const result = await getObject({
model: stockTransferModel,
id,
populate: [
{ path: 'lines.fromStock' },
{ path: 'lines.toStockLocation' },
{ path: 'lines.toStock' },
],
});
if (result?.error) {
logger.warn(`Stock transfer not found with supplied id.`);
return res.status(result.code).send(result);
}
logger.debug(`Retrieved stock transfer with ID: ${id}`);
res.send(result);
};
export const editStockTransferRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const checkStatesResult = await checkStates({ model: stockTransferModel, id, states: ['draft'] });
if (checkStatesResult.error) {
logger.error('Error checking stock transfer state:', checkStatesResult.error);
res.status(checkStatesResult.code).send(checkStatesResult);
return;
}
if (checkStatesResult === false) {
logger.error('Stock transfer is not in draft state.');
res.status(400).send({ error: 'Stock transfer is not in draft state.', code: 400 });
return;
}
const updateData = {
lines: (req.body.lines || []).map((l) => normalizeLineInput(l)),
};
const result = await editObject({
model: stockTransferModel,
id,
updateData,
user: req.user,
});
if (result.error) {
logger.error('Error editing stock transfer:', result.error);
res.status(result.code).send(result);
return;
}
logger.debug(`Edited stock transfer with ID: ${id}`);
res.send(result);
};
export const editMultipleStockTransfersRouteHandler = 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: stockTransferModel,
updates,
user: req.user,
});
if (result.error) {
logger.error('Error editing stock transfers:', result.error);
res.status(result.code || 500).send(result);
return;
}
logger.debug(`Edited ${updates.length} stock transfers`);
res.send(result);
};
export const newStockTransferRouteHandler = async (req, res) => {
const newData = {
state: req.body.state ?? { type: 'draft' },
lines: (req.body.lines || []).map((l) => normalizeLineInput(l)),
};
const result = await newObject({
model: stockTransferModel,
newData,
user: req.user,
});
if (result.error) {
logger.error('No stock transfer created:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`New stock transfer with ID: ${result._id}`);
res.send(result);
};
export const deleteStockTransferRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const checkStatesResult = await checkStates({ model: stockTransferModel, id, states: ['draft'] });
if (checkStatesResult.error) {
logger.error('Error checking stock transfer state:', checkStatesResult.error);
res.status(checkStatesResult.code).send(checkStatesResult);
return;
}
if (checkStatesResult === false) {
logger.error('Stock transfer is not in draft state.');
res.status(400).send({ error: 'Stock transfer is not in draft state.', code: 400 });
return;
}
const result = await deleteObject({
model: stockTransferModel,
id,
user: req.user,
});
if (result.error) {
logger.error('No stock transfer deleted:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`Deleted stock transfer with ID: ${result._id}`);
res.send(result);
};
export const postStockTransferRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const checkStatesResult = await checkStates({ model: stockTransferModel, id, states: ['draft'] });
if (checkStatesResult.error) {
logger.error('Error checking stock transfer state:', checkStatesResult.error);
res.status(checkStatesResult.code).send(checkStatesResult);
return;
}
if (checkStatesResult === false) {
logger.error('Stock transfer is not in draft state.');
res.status(400).send({ error: 'Stock transfer is not in draft state.', code: 400 });
return;
}
const doc = await stockTransferModel.findById(id);
if (!doc) {
return res.status(404).send({ error: 'Stock transfer not found.', code: 404 });
}
if (!doc.lines?.length) {
return res.status(400).send({ error: 'Stock transfer has no lines.', code: 400 });
}
const updatedLines = [];
try {
for (const line of doc.lines) {
const plain = line.toObject();
const { toStockType, toStock } = await executePostedLine(doc._id, plain);
updatedLines.push({
...plain,
toStockType,
toStock,
});
}
const posted = await stockTransferModel
.findByIdAndUpdate(
id,
{
$set: {
state: { type: 'posted' },
postedAt: new Date(),
lines: updatedLines,
},
},
{ new: true }
)
.populate([
{ path: 'lines.fromStock' },
{ path: 'lines.toStockLocation' },
{ path: 'lines.toStock' },
])
.lean();
logger.debug(`Posted stock transfer with ID: ${id}`);
res.send(posted);
} catch (err) {
logger.error('Error posting stock transfer:', err);
res.status(400).send({ error: err.message || 'Failed to post stock transfer', code: 400 });
}
};
export const getStockTransferStatsRouteHandler = async (req, res) => {
const result = await getModelStats({ model: stockTransferModel });
if (result?.error) {
logger.error('Error fetching stock transfer stats:', result.error);
return res.status(result.code).send(result);
}
logger.trace('Stock transfer stats:', result);
res.send(result);
};
export const getStockTransferHistoryRouteHandler = async (req, res) => {
const from = req.query.from;
const to = req.query.to;
const result = await getModelHistory({ model: stockTransferModel, from, to });
if (result?.error) {
logger.error('Error fetching stock transfer history:', result.error);
return res.status(result.code).send(result);
}
logger.trace('Stock transfer history:', result);
res.send(result);
};

View File

@ -23,6 +23,8 @@ export const EXPORT_FILTER_BY_TYPE = {
orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'sku._id', 'shipment._id'],
shipment: ['order._id', 'orderType', 'courierService._id'],
stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
stockLocation: ['name'],
stockTransfer: ['state.type', 'postedAt'],
stockAudit: ['filamentStock._id', 'partStock._id'],
documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'],
documentTemplate: ['parent._id', 'documentSize._id'],