diff --git a/src/database/schemas/inventory/filamentstock.schema.js b/src/database/schemas/inventory/filamentstock.schema.js index 21ba3f4..1c42010 100644 --- a/src/database/schemas/inventory/filamentstock.schema.js +++ b/src/database/schemas/inventory/filamentstock.schema.js @@ -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; diff --git a/src/database/schemas/inventory/partstock.schema.js b/src/database/schemas/inventory/partstock.schema.js index dab4fef..7e32b10 100644 --- a/src/database/schemas/inventory/partstock.schema.js +++ b/src/database/schemas/inventory/partstock.schema.js @@ -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; diff --git a/src/database/schemas/inventory/productstock.schema.js b/src/database/schemas/inventory/productstock.schema.js index aa21f56..e3fead2 100644 --- a/src/database/schemas/inventory/productstock.schema.js +++ b/src/database/schemas/inventory/productstock.schema.js @@ -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; } diff --git a/src/database/schemas/inventory/stockaudit.schema.js b/src/database/schemas/inventory/stockaudit.schema.js index 24cba7b..753833e 100644 --- a/src/database/schemas/inventory/stockaudit.schema.js +++ b/src/database/schemas/inventory/stockaudit.schema.js @@ -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, + }); + } +} diff --git a/src/database/schemas/inventory/stockevent.schema copy.js b/src/database/schemas/inventory/stockevent.schema copy.js deleted file mode 100644 index ca11179..0000000 --- a/src/database/schemas/inventory/stockevent.schema copy.js +++ /dev/null @@ -1,44 +0,0 @@ -import mongoose from 'mongoose'; -import { generateId } from '../../utils.js'; -const { Schema } = mongoose; - -const stockEventSchema = new Schema( - { - _reference: { type: String, default: () => generateId()() }, - value: { type: Number, required: true }, - unit: { type: String, required: true }, - parent: { - type: Schema.Types.ObjectId, - refPath: 'parentType', - required: true, - }, - parentType: { - type: String, - required: true, - enum: ['filamentStock', 'partStock', 'productStock'], // Add other models as needed - }, - owner: { - type: Schema.Types.ObjectId, - refPath: 'ownerType', - required: true, - }, - ownerType: { - type: String, - required: true, - enum: ['user', 'subJob', 'stockAudit'], - }, - timestamp: { type: Date, default: Date.now }, - }, - { timestamps: true } -); - -// Add virtual id getter -stockEventSchema.virtual('id').get(function () { - return this._id; -}); - -// Configure JSON serialization to include virtuals -stockEventSchema.set('toJSON', { virtuals: true }); - -// Create and export the model -export const stockEventModel = mongoose.model('stockEvent', stockEventSchema); diff --git a/src/database/schemas/management/stockauditlevel.schema.js b/src/database/schemas/management/stockauditlevel.schema.js new file mode 100644 index 0000000..5fe3c06 --- /dev/null +++ b/src/database/schemas/management/stockauditlevel.schema.js @@ -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); diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index 138a752..2cb8d25 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -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'),