Added product category support, including new routes, database schemas, and service updates. Enhanced existing product functionalities to incorporate product categories in filtering and population.
This commit is contained in:
parent
92e07c97d7
commit
af0934b163
@ -12,6 +12,7 @@
|
|||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
"canonical-json": "^0.2.0",
|
"canonical-json": "^0.2.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"diff": "^9.0.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -32,6 +32,9 @@ importers:
|
|||||||
cors:
|
cors:
|
||||||
specifier: ^2.8.5
|
specifier: ^2.8.5
|
||||||
version: 2.8.6
|
version: 2.8.6
|
||||||
|
diff:
|
||||||
|
specifier: ^9.0.0
|
||||||
|
version: 9.0.0
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.2.3
|
specifier: ^17.2.3
|
||||||
version: 17.2.3
|
version: 17.2.3
|
||||||
@ -2265,6 +2268,10 @@ packages:
|
|||||||
dezalgo@1.0.4:
|
dezalgo@1.0.4:
|
||||||
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
||||||
|
|
||||||
|
diff@9.0.0:
|
||||||
|
resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
|
||||||
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
doctrine@2.1.0:
|
doctrine@2.1.0:
|
||||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -7723,6 +7730,8 @@ snapshots:
|
|||||||
asap: 2.0.6
|
asap: 2.0.6
|
||||||
wrappy: 1.0.2
|
wrappy: 1.0.2
|
||||||
|
|
||||||
|
diff@9.0.0: {}
|
||||||
|
|
||||||
doctrine@2.1.0:
|
doctrine@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
esutils: 2.0.3
|
esutils: 2.0.3
|
||||||
|
|||||||
@ -1,7 +1,26 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||||
|
import { stockEventModel } from './stockevent.schema.js';
|
||||||
|
|
||||||
|
const getStockEventTotal = async (stock, parentType) => {
|
||||||
|
const stockId = stock?._id;
|
||||||
|
if (!stockId) return null;
|
||||||
|
|
||||||
|
const parentId =
|
||||||
|
stockId instanceof mongoose.Types.ObjectId ? stockId : new mongoose.Types.ObjectId(stockId);
|
||||||
|
|
||||||
|
const [result] = await stockEventModel.aggregate([
|
||||||
|
{ $match: { parent: parentId, parentType } },
|
||||||
|
{ $group: { _id: null, total: { $sum: '$value' }, count: { $sum: 1 } } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: result?.total ?? 0,
|
||||||
|
count: result?.count ?? 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Define the main filamentStock schema
|
// Define the main filamentStock schema
|
||||||
const filamentStockSchema = new Schema(
|
const filamentStockSchema = new Schema(
|
||||||
@ -32,7 +51,11 @@ const filamentStockSchema = new Schema(
|
|||||||
|
|
||||||
filamentStockSchema.pre('validate', async function () {
|
filamentStockSchema.pre('validate', async function () {
|
||||||
if (!this.filament && this.filamentSku) {
|
if (!this.filament && this.filamentSku) {
|
||||||
const sku = await mongoose.model('filamentSku').findById(this.filamentSku).select('filament').lean();
|
const sku = await mongoose
|
||||||
|
.model('filamentSku')
|
||||||
|
.findById(this.filamentSku)
|
||||||
|
.select('filament')
|
||||||
|
.lean();
|
||||||
if (sku?.filament) this.filament = sku.filament;
|
if (sku?.filament) this.filament = sku.filament;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -66,6 +89,33 @@ filamentStockSchema.statics.history = async function (from, to) {
|
|||||||
return results;
|
return results;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
filamentStockSchema.statics.recalculate = async function (filamentStock, user) {
|
||||||
|
const events = await getStockEventTotal(filamentStock, this.modelName);
|
||||||
|
if (!events?.count) return;
|
||||||
|
|
||||||
|
const net = events.total;
|
||||||
|
const startingNet = filamentStock.startingWeight?.net ?? 0;
|
||||||
|
const startingGross = filamentStock.startingWeight?.gross ?? 0;
|
||||||
|
const gross = startingNet > 0 ? (startingGross * net) / startingNet : net;
|
||||||
|
|
||||||
|
console.log('Recalculating filament stock');
|
||||||
|
console.log('events', events);
|
||||||
|
console.log('filamentStock', filamentStock);
|
||||||
|
|
||||||
|
await editObject({
|
||||||
|
model: this,
|
||||||
|
id: filamentStock._id,
|
||||||
|
updateData: {
|
||||||
|
currentWeight: {
|
||||||
|
net,
|
||||||
|
gross,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
recalculate: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
filamentStockSchema.virtual('id').get(function () {
|
filamentStockSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -1,7 +1,26 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||||
|
import { stockEventModel } from './stockevent.schema.js';
|
||||||
|
|
||||||
|
const getStockEventTotal = async (stock, parentType) => {
|
||||||
|
const stockId = stock?._id;
|
||||||
|
if (!stockId) return null;
|
||||||
|
|
||||||
|
const parentId =
|
||||||
|
stockId instanceof mongoose.Types.ObjectId ? stockId : new mongoose.Types.ObjectId(stockId);
|
||||||
|
|
||||||
|
const [result] = await stockEventModel.aggregate([
|
||||||
|
{ $match: { parent: parentId, parentType } },
|
||||||
|
{ $group: { _id: null, total: { $sum: '$value' }, count: { $sum: 1 } } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: result?.total ?? 0,
|
||||||
|
count: result?.count ?? 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Define the main partStock schema
|
// Define the main partStock schema
|
||||||
const partStockSchema = new Schema(
|
const partStockSchema = new Schema(
|
||||||
@ -53,6 +72,21 @@ partStockSchema.statics.history = async function (from, to) {
|
|||||||
return results;
|
return results;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
partStockSchema.statics.recalculate = async function (partStock, user) {
|
||||||
|
const events = await getStockEventTotal(partStock, this.modelName);
|
||||||
|
if (!events?.count) return;
|
||||||
|
|
||||||
|
await editObject({
|
||||||
|
model: this,
|
||||||
|
id: partStock._id,
|
||||||
|
updateData: {
|
||||||
|
currentQuantity: events.total,
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
recalculate: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
partStockSchema.virtual('id').get(function () {
|
partStockSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -1,7 +1,26 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||||
|
import { stockEventModel } from './stockevent.schema.js';
|
||||||
|
|
||||||
|
const getStockEventTotal = async (stock, parentType) => {
|
||||||
|
const stockId = stock?._id;
|
||||||
|
if (!stockId) return null;
|
||||||
|
|
||||||
|
const parentId =
|
||||||
|
stockId instanceof mongoose.Types.ObjectId ? stockId : new mongoose.Types.ObjectId(stockId);
|
||||||
|
|
||||||
|
const [result] = await stockEventModel.aggregate([
|
||||||
|
{ $match: { parent: parentId, parentType } },
|
||||||
|
{ $group: { _id: null, total: { $sum: '$value' }, count: { $sum: 1 } } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: result?.total ?? 0,
|
||||||
|
count: result?.count ?? 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const partStockUsageSchema = new Schema({
|
const partStockUsageSchema = new Schema({
|
||||||
partStock: { type: Schema.Types.ObjectId, ref: 'partStock', required: false },
|
partStock: { type: Schema.Types.ObjectId, ref: 'partStock', required: false },
|
||||||
@ -68,6 +87,21 @@ productStockSchema.statics.history = async function (from, to) {
|
|||||||
return results;
|
return results;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
productStockSchema.statics.recalculate = async function (productStock, user) {
|
||||||
|
const events = await getStockEventTotal(productStock, this.modelName);
|
||||||
|
if (!events?.count) return;
|
||||||
|
|
||||||
|
await editObject({
|
||||||
|
model: this,
|
||||||
|
id: productStock._id,
|
||||||
|
updateData: {
|
||||||
|
currentQuantity: events.total,
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
recalculate: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
productStockSchema.virtual('id').get(function () {
|
productStockSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -11,7 +11,7 @@ const stockTransferLineSchema = new Schema(
|
|||||||
},
|
},
|
||||||
fromStock: {
|
fromStock: {
|
||||||
type: Schema.Types.ObjectId,
|
type: Schema.Types.ObjectId,
|
||||||
refPath: 'fromStockType',
|
refPath: 'lines.fromStockType',
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
quantity: { type: Number, required: true },
|
quantity: { type: Number, required: true },
|
||||||
@ -27,7 +27,7 @@ const stockTransferLineSchema = new Schema(
|
|||||||
},
|
},
|
||||||
toStock: {
|
toStock: {
|
||||||
type: Schema.Types.ObjectId,
|
type: Schema.Types.ObjectId,
|
||||||
refPath: 'toStockType',
|
refPath: 'lines.toStockType',
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -37,6 +37,7 @@ const stockTransferLineSchema = new Schema(
|
|||||||
const stockTransferSchema = new Schema(
|
const stockTransferSchema = new Schema(
|
||||||
{
|
{
|
||||||
_reference: { type: String, default: () => generateId()() },
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: { type: String, required: true },
|
||||||
state: {
|
state: {
|
||||||
type: { type: String, required: true, default: 'draft' },
|
type: { type: String, required: true, default: 'draft' },
|
||||||
progress: { type: Number, required: false },
|
progress: { type: Number, required: false },
|
||||||
|
|||||||
@ -7,6 +7,7 @@ const productSchema = new Schema(
|
|||||||
{
|
{
|
||||||
_reference: { type: String, default: () => generateId()() },
|
_reference: { type: String, default: () => generateId()() },
|
||||||
name: { type: String, required: true },
|
name: { type: String, required: true },
|
||||||
|
productCategory: { type: Schema.Types.ObjectId, ref: 'productCategory', required: true },
|
||||||
tags: [{ type: String }],
|
tags: [{ type: String }],
|
||||||
version: { type: String },
|
version: { type: String },
|
||||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
||||||
|
|||||||
18
src/database/schemas/management/productcategory.schema.js
Normal file
18
src/database/schemas/management/productcategory.schema.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
|
||||||
|
const productCategorySchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: { required: true, type: String },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
productCategorySchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
productCategorySchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const productCategoryModel = mongoose.model('productCategory', productCategorySchema);
|
||||||
@ -7,6 +7,7 @@ import { gcodeFileModel } from './production/gcodefile.schema.js';
|
|||||||
import { partModel } from './management/part.schema.js';
|
import { partModel } from './management/part.schema.js';
|
||||||
import { partSkuModel } from './management/partsku.schema.js';
|
import { partSkuModel } from './management/partsku.schema.js';
|
||||||
import { productModel } from './management/product.schema.js';
|
import { productModel } from './management/product.schema.js';
|
||||||
|
import { productCategoryModel } from './management/productcategory.schema.js';
|
||||||
import { productSkuModel } from './management/productsku.schema.js';
|
import { productSkuModel } from './management/productsku.schema.js';
|
||||||
import { vendorModel } from './management/vendor.schema.js';
|
import { vendorModel } from './management/vendor.schema.js';
|
||||||
import { materialModel } from './management/material.schema.js';
|
import { materialModel } from './management/material.schema.js';
|
||||||
@ -43,6 +44,7 @@ import { salesOrderModel } from './sales/salesorder.schema.js';
|
|||||||
import { marketplaceModel } from './sales/marketplace.schema.js';
|
import { marketplaceModel } from './sales/marketplace.schema.js';
|
||||||
import { listingModel } from './sales/listing.schema.js';
|
import { listingModel } from './sales/listing.schema.js';
|
||||||
import { listingVarientModel } from './sales/listingvarient.schema.js';
|
import { listingVarientModel } from './sales/listingvarient.schema.js';
|
||||||
|
import { paymentModel } from './finance/payment.schema.js';
|
||||||
|
|
||||||
// Map prefixes to models and id fields
|
// Map prefixes to models and id fields
|
||||||
export const models = {
|
export const models = {
|
||||||
@ -96,6 +98,13 @@ export const models = {
|
|||||||
referenceField: '_reference',
|
referenceField: '_reference',
|
||||||
label: 'Product',
|
label: 'Product',
|
||||||
},
|
},
|
||||||
|
PCG: {
|
||||||
|
model: productCategoryModel,
|
||||||
|
idField: '_id',
|
||||||
|
type: 'productCategory',
|
||||||
|
referenceField: '_reference',
|
||||||
|
label: 'Product Category',
|
||||||
|
},
|
||||||
SKU: {
|
SKU: {
|
||||||
model: productSkuModel,
|
model: productSkuModel,
|
||||||
idField: '_id',
|
idField: '_id',
|
||||||
@ -355,4 +364,11 @@ export const models = {
|
|||||||
label: 'Listing Varient',
|
label: 'Listing Varient',
|
||||||
referenceField: '_reference',
|
referenceField: '_reference',
|
||||||
},
|
},
|
||||||
|
PAY: {
|
||||||
|
model: paymentModel,
|
||||||
|
idField: '_id',
|
||||||
|
type: 'payment',
|
||||||
|
label: 'Payment',
|
||||||
|
referenceField: '_reference',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
partRoutes,
|
partRoutes,
|
||||||
partSkuRoutes,
|
partSkuRoutes,
|
||||||
productRoutes,
|
productRoutes,
|
||||||
|
productCategoryRoutes,
|
||||||
productSkuRoutes,
|
productSkuRoutes,
|
||||||
vendorRoutes,
|
vendorRoutes,
|
||||||
materialRoutes,
|
materialRoutes,
|
||||||
@ -143,6 +144,7 @@ app.use('/filamentskus', filamentSkuRoutes);
|
|||||||
app.use('/parts', partRoutes);
|
app.use('/parts', partRoutes);
|
||||||
app.use('/partskus', partSkuRoutes);
|
app.use('/partskus', partSkuRoutes);
|
||||||
app.use('/products', productRoutes);
|
app.use('/products', productRoutes);
|
||||||
|
app.use('/productcategories', productCategoryRoutes);
|
||||||
app.use('/productskus', productSkuRoutes);
|
app.use('/productskus', productSkuRoutes);
|
||||||
app.use('/vendors', vendorRoutes);
|
app.use('/vendors', vendorRoutes);
|
||||||
app.use('/materials', materialRoutes);
|
app.use('/materials', materialRoutes);
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import spotlightRoutes from './misc/spotlight.js';
|
|||||||
import partRoutes from './management/parts.js';
|
import partRoutes from './management/parts.js';
|
||||||
import partSkuRoutes from './management/partskus.js';
|
import partSkuRoutes from './management/partskus.js';
|
||||||
import productRoutes from './management/products.js';
|
import productRoutes from './management/products.js';
|
||||||
|
import productCategoryRoutes from './management/productcategories.js';
|
||||||
import productSkuRoutes from './management/productskus.js';
|
import productSkuRoutes from './management/productskus.js';
|
||||||
import vendorRoutes from './management/vendors.js';
|
import vendorRoutes from './management/vendors.js';
|
||||||
import materialRoutes from './management/materials.js';
|
import materialRoutes from './management/materials.js';
|
||||||
@ -66,6 +67,7 @@ export {
|
|||||||
partRoutes,
|
partRoutes,
|
||||||
partSkuRoutes,
|
partSkuRoutes,
|
||||||
productRoutes,
|
productRoutes,
|
||||||
|
productCategoryRoutes,
|
||||||
productSkuRoutes,
|
productSkuRoutes,
|
||||||
vendorRoutes,
|
vendorRoutes,
|
||||||
materialRoutes,
|
materialRoutes,
|
||||||
|
|||||||
@ -35,7 +35,13 @@ router.get('/', isAuthenticated, (req, res) => {
|
|||||||
|
|
||||||
router.get('/properties', isAuthenticated, (req, res) => {
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
let properties = convertPropertiesString(req.query.properties);
|
let properties = convertPropertiesString(req.query.properties);
|
||||||
const allowedFilters = ['filament', 'filament._id', 'filamentSku', 'state.type'];
|
const allowedFilters = [
|
||||||
|
'filament',
|
||||||
|
'filament._id',
|
||||||
|
'filamentSku',
|
||||||
|
'state.type',
|
||||||
|
'filamentSku._id',
|
||||||
|
];
|
||||||
const filter = getFilter(req.query, allowedFilters, false);
|
const filter = getFilter(req.query, allowedFilters, false);
|
||||||
var masterFilter = {};
|
var masterFilter = {};
|
||||||
if (req.query.masterFilter) {
|
if (req.query.masterFilter) {
|
||||||
|
|||||||
@ -21,14 +21,36 @@ import {
|
|||||||
// list of purchase orders
|
// list of purchase orders
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
const { page, limit, property, search, sort, order } = req.query;
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
const allowedFilters = ['vendor', 'state', 'value', 'vendor._id'];
|
const allowedFilters = [
|
||||||
|
'vendor',
|
||||||
|
'state',
|
||||||
|
'value',
|
||||||
|
'vendor._id',
|
||||||
|
'totalAmount',
|
||||||
|
'totalAmountWithTax',
|
||||||
|
'totalTaxAmount',
|
||||||
|
'shippingAmount',
|
||||||
|
'shippingAmountWithTax',
|
||||||
|
'grandTotalAmount',
|
||||||
|
];
|
||||||
const filter = getFilter(req.query, allowedFilters);
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
listPurchaseOrdersRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
listPurchaseOrdersRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/properties', isAuthenticated, (req, res) => {
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
let properties = convertPropertiesString(req.query.properties);
|
let properties = convertPropertiesString(req.query.properties);
|
||||||
const allowedFilters = ['vendor', 'state.type', 'value', 'vendor._id'];
|
const allowedFilters = [
|
||||||
|
'vendor',
|
||||||
|
'state.type',
|
||||||
|
'value',
|
||||||
|
'vendor._id',
|
||||||
|
'totalAmount',
|
||||||
|
'totalAmountWithTax',
|
||||||
|
'totalTaxAmount',
|
||||||
|
'shippingAmount',
|
||||||
|
'shippingAmountWithTax',
|
||||||
|
'grandTotalAmount',
|
||||||
|
];
|
||||||
const filter = getFilter(req.query, allowedFilters, false);
|
const filter = getFilter(req.query, allowedFilters, false);
|
||||||
var masterFilter = {};
|
var masterFilter = {};
|
||||||
if (req.query.masterFilter) {
|
if (req.query.masterFilter) {
|
||||||
|
|||||||
55
src/routes/management/productcategories.js
Normal file
55
src/routes/management/productcategories.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { isAuthenticated } from '../../keycloak.js';
|
||||||
|
import { convertPropertiesString, getFilter } from '../../utils.js';
|
||||||
|
import {
|
||||||
|
deleteProductCategoryRouteHandler,
|
||||||
|
editProductCategoryRouteHandler,
|
||||||
|
getProductCategoryHistoryRouteHandler,
|
||||||
|
getProductCategoryRouteHandler,
|
||||||
|
getProductCategoryStatsRouteHandler,
|
||||||
|
listProductCategoriesByPropertiesRouteHandler,
|
||||||
|
listProductCategoriesRouteHandler,
|
||||||
|
newProductCategoryRouteHandler,
|
||||||
|
} from '../../services/management/productcategories.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
|
const allowedFilters = ['_id', 'name'];
|
||||||
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
|
listProductCategoriesRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
|
const properties = convertPropertiesString(req.query.properties);
|
||||||
|
const allowedFilters = ['name'];
|
||||||
|
const filter = getFilter(req.query, allowedFilters, false);
|
||||||
|
listProductCategoriesByPropertiesRouteHandler(req, res, properties, filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', isAuthenticated, (req, res) => {
|
||||||
|
newProductCategoryRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/stats', isAuthenticated, (req, res) => {
|
||||||
|
getProductCategoryStatsRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/history', isAuthenticated, (req, res) => {
|
||||||
|
getProductCategoryHistoryRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', isAuthenticated, (req, res) => {
|
||||||
|
getProductCategoryRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', isAuthenticated, async (req, res) => {
|
||||||
|
editProductCategoryRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', isAuthenticated, async (req, res) => {
|
||||||
|
deleteProductCategoryRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@ -17,7 +17,7 @@ import {
|
|||||||
// list of products
|
// list of products
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
const { page, limit, property, search, sort, order } = req.query;
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
const allowedFilters = ['_id', 'name', 'globalPrice'];
|
const allowedFilters = ['_id', 'name', 'globalPrice', 'productCategory', 'productCategory._id'];
|
||||||
const filter = getFilter(req.query, allowedFilters);
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
listProductsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
listProductsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import { convertPropertiesString, getFilter } from '../../utils.js';
|
|||||||
// list of jobs
|
// list of jobs
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
const { page, limit, property, search, sort, order } = req.query;
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
const allowedFilters = ['state'];
|
const allowedFilters = ['state', 'gcodeFile._id', 'quantity'];
|
||||||
const filter = getFilter(req.query, allowedFilters);
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
listJobsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
listJobsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -81,11 +81,7 @@ export const listPaymentsByPropertiesRouteHandler = async (
|
|||||||
|
|
||||||
export const getPaymentRouteHandler = async (req, res) => {
|
export const getPaymentRouteHandler = async (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const populateFields = [
|
const populateFields = [{ path: 'vendor' }, { path: 'client' }, { path: 'invoice' }];
|
||||||
{ path: 'vendor' },
|
|
||||||
{ path: 'client' },
|
|
||||||
{ path: 'invoice' },
|
|
||||||
];
|
|
||||||
const result = await getObject({
|
const result = await getObject({
|
||||||
model: paymentModel,
|
model: paymentModel,
|
||||||
id,
|
id,
|
||||||
@ -181,8 +177,11 @@ export const newPaymentRouteHandler = async (req, res) => {
|
|||||||
// Get invoice to populate vendor/client
|
// Get invoice to populate vendor/client
|
||||||
const invoice = await getObject({
|
const invoice = await getObject({
|
||||||
model: invoiceModel,
|
model: invoiceModel,
|
||||||
id: req.body.invoice,
|
id: req.body.invoice?._id || req.body.invoice,
|
||||||
populate: [{ path: 'vendor' }, { path: 'client' }],
|
populate: [
|
||||||
|
{ path: 'vendor', strictPopulate: false },
|
||||||
|
{ path: 'client', strictPopulate: false },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (invoice.error) {
|
if (invoice.error) {
|
||||||
@ -370,4 +369,3 @@ export const cancelPaymentRouteHandler = async (req, res) => {
|
|||||||
logger.debug(`Cancelled payment with ID: ${id}`);
|
logger.debug(`Cancelled payment with ID: ${id}`);
|
||||||
res.send(result);
|
res.send(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -64,7 +64,7 @@ export const listFilamentStocksByPropertiesRouteHandler = async (
|
|||||||
model: filamentStockModel,
|
model: filamentStockModel,
|
||||||
properties,
|
properties,
|
||||||
filter,
|
filter,
|
||||||
populate: ['filament', { path: 'filamentSku', populate: 'filament' }, 'stockLocation'],
|
populate: ['filament', 'filamentSku', 'stockLocation'],
|
||||||
masterFilter,
|
masterFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -30,9 +30,37 @@ const normalizeLineInput = (l) => ({
|
|||||||
toStockLocation: l.toStockLocation?._id ?? l.toStockLocation,
|
toStockLocation: l.toStockLocation?._id ?? l.toStockLocation,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createStockEventsForLine({ transferId, fromId, fromType, toId, toType, qty, unit }) {
|
const stockModelByType = {
|
||||||
|
filamentStock: filamentStockModel,
|
||||||
|
partStock: partStockModel,
|
||||||
|
productStock: productStockModel,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 recalculateStock(stockType, stock, user) {
|
||||||
|
const model = stockModelByType[stockType];
|
||||||
|
if (!model?.recalculate) return;
|
||||||
|
|
||||||
|
await model.recalculate(stock, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createStockEventsForLine({ transferId, fromId, fromType, toId, toType, qty, unit, user }) {
|
||||||
const ts = new Date();
|
const ts = new Date();
|
||||||
await stockEventModel.insertMany([
|
await Promise.all([
|
||||||
|
createStockEvent(
|
||||||
{
|
{
|
||||||
value: -Math.abs(qty),
|
value: -Math.abs(qty),
|
||||||
unit,
|
unit,
|
||||||
@ -42,6 +70,9 @@ async function createStockEventsForLine({ transferId, fromId, fromType, toId, to
|
|||||||
ownerType: 'stockTransfer',
|
ownerType: 'stockTransfer',
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
},
|
},
|
||||||
|
user
|
||||||
|
),
|
||||||
|
createStockEvent(
|
||||||
{
|
{
|
||||||
value: Math.abs(qty),
|
value: Math.abs(qty),
|
||||||
unit,
|
unit,
|
||||||
@ -51,10 +82,26 @@ async function createStockEventsForLine({ transferId, fromId, fromType, toId, to
|
|||||||
ownerType: 'stockTransfer',
|
ownerType: 'stockTransfer',
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
},
|
},
|
||||||
|
user
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executePostedLine(transferId, line) {
|
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 executePostedLine(transferId, line, user) {
|
||||||
const toLocId = line.toStockLocation;
|
const toLocId = line.toStockLocation;
|
||||||
const loc = await stockLocationModel.findById(toLocId).lean();
|
const loc = await stockLocationModel.findById(toLocId).lean();
|
||||||
if (!loc) {
|
if (!loc) {
|
||||||
@ -73,25 +120,23 @@ async function executePostedLine(transferId, line) {
|
|||||||
throw new Error('Filament transfer quantity exceeds available net weight');
|
throw new Error('Filament transfer quantity exceeds available net weight');
|
||||||
}
|
}
|
||||||
const tareBefore = Math.max(0, (src.currentWeight?.gross ?? 0) - (src.currentWeight?.net ?? 0));
|
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 = {
|
const destWeight = {
|
||||||
net: line.quantity,
|
net: line.quantity,
|
||||||
gross: line.quantity + tareBefore,
|
gross: line.quantity + tareBefore,
|
||||||
};
|
};
|
||||||
const dest = await filamentStockModel.create({
|
const dest = await createStock(
|
||||||
|
filamentStockModel,
|
||||||
|
{
|
||||||
state: src.state,
|
state: src.state,
|
||||||
startingWeight: destWeight,
|
startingWeight: destWeight,
|
||||||
currentWeight: destWeight,
|
currentWeight: destWeight,
|
||||||
filament: src.filament,
|
filament: src.filament,
|
||||||
filamentSku: src.filamentSku,
|
filamentSku: src.filamentSku,
|
||||||
stockLocation: toLocId,
|
stockLocation: toLocId,
|
||||||
});
|
},
|
||||||
|
user
|
||||||
|
);
|
||||||
|
|
||||||
await createStockEventsForLine({
|
await createStockEventsForLine({
|
||||||
transferId,
|
transferId,
|
||||||
@ -101,31 +146,37 @@ async function executePostedLine(transferId, line) {
|
|||||||
toType: 'filamentStock',
|
toType: 'filamentStock',
|
||||||
qty: line.quantity,
|
qty: line.quantity,
|
||||||
unit: 'g',
|
unit: 'g',
|
||||||
|
user,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
recalculateStock('filamentStock', src, user),
|
||||||
|
recalculateStock('filamentStock', dest, user),
|
||||||
|
]);
|
||||||
|
|
||||||
return { toStockType: 'filamentStock', toStock: dest._id };
|
return { toStockType: 'filamentStock', toStock: dest._id };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (line.fromStockType === 'partStock') {
|
if (line.fromStockType === 'partStock') {
|
||||||
const src = await partStockModel.findById(line.fromStock);
|
const src = await partStockModel.findById(line.fromStock);
|
||||||
console.log(src);
|
|
||||||
if (!src) throw new Error('From part stock not found');
|
if (!src) throw new Error('From part stock not found');
|
||||||
const currentQuantity = src.state.type === 'new' ? src.startingQuantity : src.currentQuantity;
|
const currentQuantity = src.currentQuantity;
|
||||||
if (line.quantity > currentQuantity) {
|
if (line.quantity > currentQuantity) {
|
||||||
throw new Error('Part transfer quantity exceeds current quantity');
|
throw new Error('Part transfer quantity exceeds current quantity');
|
||||||
}
|
}
|
||||||
await partStockModel.findByIdAndUpdate(src._id, {
|
|
||||||
$inc: { currentQuantity: -line.quantity },
|
|
||||||
});
|
|
||||||
|
|
||||||
const dest = await partStockModel.create({
|
const dest = await createStock(
|
||||||
|
partStockModel,
|
||||||
|
{
|
||||||
partSku: src.partSku,
|
partSku: src.partSku,
|
||||||
currentQuantity: line.quantity,
|
currentQuantity: line.quantity,
|
||||||
state: { type: 'new' },
|
state: { type: 'new' },
|
||||||
sourceType: 'stockTransfer',
|
sourceType: 'stockTransfer',
|
||||||
source: transferId,
|
source: transferId,
|
||||||
stockLocation: toLocId,
|
stockLocation: toLocId,
|
||||||
});
|
},
|
||||||
|
user
|
||||||
|
);
|
||||||
|
|
||||||
await createStockEventsForLine({
|
await createStockEventsForLine({
|
||||||
transferId,
|
transferId,
|
||||||
@ -135,8 +186,14 @@ async function executePostedLine(transferId, line) {
|
|||||||
toType: 'partStock',
|
toType: 'partStock',
|
||||||
qty: line.quantity,
|
qty: line.quantity,
|
||||||
unit: 'each',
|
unit: 'each',
|
||||||
|
user,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
recalculateStock('partStock', src, user),
|
||||||
|
recalculateStock('partStock', dest, user),
|
||||||
|
]);
|
||||||
|
|
||||||
return { toStockType: 'partStock', toStock: dest._id };
|
return { toStockType: 'partStock', toStock: dest._id };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,18 +203,19 @@ async function executePostedLine(transferId, line) {
|
|||||||
if (line.quantity > src.currentQuantity) {
|
if (line.quantity > src.currentQuantity) {
|
||||||
throw new Error('Product transfer quantity exceeds current quantity');
|
throw new Error('Product transfer quantity exceeds current quantity');
|
||||||
}
|
}
|
||||||
await productStockModel.findByIdAndUpdate(src._id, {
|
|
||||||
$inc: { currentQuantity: -line.quantity },
|
|
||||||
});
|
|
||||||
|
|
||||||
const dest = await productStockModel.create({
|
const dest = await createStock(
|
||||||
|
productStockModel,
|
||||||
|
{
|
||||||
productSku: src.productSku,
|
productSku: src.productSku,
|
||||||
currentQuantity: line.quantity,
|
currentQuantity: line.quantity,
|
||||||
state: { type: 'posted' },
|
state: { type: 'posted' },
|
||||||
postedAt: new Date(),
|
postedAt: new Date(),
|
||||||
partStocks: [],
|
partStocks: [],
|
||||||
stockLocation: toLocId,
|
stockLocation: toLocId,
|
||||||
});
|
},
|
||||||
|
user
|
||||||
|
);
|
||||||
|
|
||||||
await createStockEventsForLine({
|
await createStockEventsForLine({
|
||||||
transferId,
|
transferId,
|
||||||
@ -167,8 +225,14 @@ async function executePostedLine(transferId, line) {
|
|||||||
toType: 'productStock',
|
toType: 'productStock',
|
||||||
qty: line.quantity,
|
qty: line.quantity,
|
||||||
unit: 'each',
|
unit: 'each',
|
||||||
|
user,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
recalculateStock('productStock', src, user),
|
||||||
|
recalculateStock('productStock', dest, user),
|
||||||
|
]);
|
||||||
|
|
||||||
return { toStockType: 'productStock', toStock: dest._id };
|
return { toStockType: 'productStock', toStock: dest._id };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -280,6 +344,9 @@ export const editStockTransferRouteHandler = async (req, res) => {
|
|||||||
const updateData = {
|
const updateData = {
|
||||||
lines: (req.body.lines || []).map((l) => normalizeLineInput(l)),
|
lines: (req.body.lines || []).map((l) => normalizeLineInput(l)),
|
||||||
};
|
};
|
||||||
|
if (req.body.name !== undefined) {
|
||||||
|
updateData.name = req.body.name;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await editObject({
|
const result = await editObject({
|
||||||
model: stockTransferModel,
|
model: stockTransferModel,
|
||||||
@ -325,6 +392,7 @@ export const editMultipleStockTransfersRouteHandler = async (req, res) => {
|
|||||||
|
|
||||||
export const newStockTransferRouteHandler = async (req, res) => {
|
export const newStockTransferRouteHandler = async (req, res) => {
|
||||||
const newData = {
|
const newData = {
|
||||||
|
name: req.body.name,
|
||||||
state: req.body.state ?? { type: 'draft' },
|
state: req.body.state ?? { type: 'draft' },
|
||||||
lines: (req.body.lines || []).map((l) => normalizeLineInput(l)),
|
lines: (req.body.lines || []).map((l) => normalizeLineInput(l)),
|
||||||
};
|
};
|
||||||
@ -404,7 +472,7 @@ export const postStockTransferRouteHandler = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
for (const line of doc.lines) {
|
for (const line of doc.lines) {
|
||||||
const plain = line.toObject();
|
const plain = line.toObject();
|
||||||
const { toStockType, toStock } = await executePostedLine(doc._id, plain);
|
const { toStockType, toStock } = await executePostedLine(doc._id, plain, req.user);
|
||||||
updatedLines.push({
|
updatedLines.push({
|
||||||
...plain,
|
...plain,
|
||||||
toStockType,
|
toStockType,
|
||||||
@ -412,24 +480,34 @@ export const postStockTransferRouteHandler = async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const posted = await stockTransferModel
|
const postedResult = await editObject({
|
||||||
.findByIdAndUpdate(
|
model: stockTransferModel,
|
||||||
id,
|
id,
|
||||||
{
|
updateData: {
|
||||||
$set: {
|
|
||||||
state: { type: 'posted' },
|
state: { type: 'posted' },
|
||||||
postedAt: new Date(),
|
postedAt: new Date(),
|
||||||
lines: updatedLines,
|
lines: updatedLines,
|
||||||
},
|
},
|
||||||
},
|
user: req.user,
|
||||||
{ new: true }
|
});
|
||||||
)
|
|
||||||
.populate([
|
if (postedResult?.error) {
|
||||||
|
throw new Error(postedResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const posted = await getObject({
|
||||||
|
model: stockTransferModel,
|
||||||
|
id,
|
||||||
|
populate: [
|
||||||
{ path: 'lines.fromStock' },
|
{ path: 'lines.fromStock' },
|
||||||
{ path: 'lines.toStockLocation' },
|
{ path: 'lines.toStockLocation' },
|
||||||
{ path: 'lines.toStock' },
|
{ path: 'lines.toStock' },
|
||||||
])
|
],
|
||||||
.lean();
|
});
|
||||||
|
|
||||||
|
if (posted?.error) {
|
||||||
|
throw new Error(posted.error);
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(`Posted stock transfer with ID: ${id}`);
|
logger.debug(`Posted stock transfer with ID: ${id}`);
|
||||||
res.send(posted);
|
res.send(posted);
|
||||||
|
|||||||
@ -42,7 +42,10 @@ export const listAuditLogsRouteHandler = async (
|
|||||||
.sort({ [sort]: sortOrder })
|
.sort({ [sort]: sortOrder })
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(Number(limit))
|
.limit(Number(limit))
|
||||||
.populate('owner', 'name _id');
|
.populate([
|
||||||
|
{ path: 'owner', select: 'name _id color' },
|
||||||
|
{ path: 'parent', select: '_id name' },
|
||||||
|
]);
|
||||||
|
|
||||||
const auditLogs = await query;
|
const auditLogs = await query;
|
||||||
logger.trace(
|
logger.trace(
|
||||||
@ -51,7 +54,7 @@ export const listAuditLogsRouteHandler = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const expandedIdAuditLogs = auditLogs.map((auditLog) => {
|
const expandedIdAuditLogs = auditLogs.map((auditLog) => {
|
||||||
const expendedAuditLog = { ...auditLog._doc, parent: { _id: auditLog.parent } };
|
const expendedAuditLog = { ...auditLog._doc };
|
||||||
return expendedAuditLog;
|
return expendedAuditLog;
|
||||||
});
|
});
|
||||||
res.send(expandedIdAuditLogs);
|
res.send(expandedIdAuditLogs);
|
||||||
|
|||||||
177
src/services/management/productcategories.js
Normal file
177
src/services/management/productcategories.js
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
import config from '../../config.js';
|
||||||
|
import { productCategoryModel } from '../../database/schemas/management/productcategory.schema.js';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import {
|
||||||
|
deleteObject,
|
||||||
|
editObject,
|
||||||
|
getModelHistory,
|
||||||
|
getModelStats,
|
||||||
|
getObject,
|
||||||
|
listObjects,
|
||||||
|
listObjectsByProperties,
|
||||||
|
newObject,
|
||||||
|
} from '../../database/database.js';
|
||||||
|
|
||||||
|
const logger = log4js.getLogger('ProductCategories');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
export const listProductCategoriesRouteHandler = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
page = 1,
|
||||||
|
limit = 25,
|
||||||
|
property = '',
|
||||||
|
filter = {},
|
||||||
|
search = '',
|
||||||
|
sort = '',
|
||||||
|
order = 'ascend'
|
||||||
|
) => {
|
||||||
|
const result = await listObjects({
|
||||||
|
model: productCategoryModel,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
property,
|
||||||
|
filter,
|
||||||
|
search,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
populate: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error listing product categories.');
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of product categories (Page ${page}, Limit ${limit}). Count: ${result.length}.`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listProductCategoriesByPropertiesRouteHandler = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
properties = [],
|
||||||
|
filter = {}
|
||||||
|
) => {
|
||||||
|
const result = await listObjectsByProperties({
|
||||||
|
model: productCategoryModel,
|
||||||
|
properties,
|
||||||
|
filter,
|
||||||
|
populate: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error listing product categories.');
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of product categories. Count: ${result.length}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProductCategoryRouteHandler = async (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const result = await getObject({
|
||||||
|
model: productCategoryModel,
|
||||||
|
id,
|
||||||
|
populate: [],
|
||||||
|
});
|
||||||
|
if (result?.error) {
|
||||||
|
logger.warn(`Product category not found with supplied id.`);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
logger.debug(`Retrieved product category with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editProductCategoryRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
|
||||||
|
logger.trace(`Product category with ID: ${id}`);
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
name: req.body.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await editObject({
|
||||||
|
model: productCategoryModel,
|
||||||
|
id,
|
||||||
|
updateData,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('Error editing product category:', result.error);
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Edited product category with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const newProductCategoryRouteHandler = async (req, res) => {
|
||||||
|
const newData = {
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
name: req.body.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await newObject({
|
||||||
|
model: productCategoryModel,
|
||||||
|
newData,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('No product category created:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`New product category with ID: ${result._id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteProductCategoryRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
|
||||||
|
logger.trace(`Product category with ID: ${id}`);
|
||||||
|
|
||||||
|
const result = await deleteObject({
|
||||||
|
model: productCategoryModel,
|
||||||
|
id,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('No product category deleted:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Deleted product category with ID: ${result._id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProductCategoryStatsRouteHandler = async (req, res) => {
|
||||||
|
const result = await getModelStats({ model: productCategoryModel });
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error fetching product category stats:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
logger.trace('Product category stats:', result);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProductCategoryHistoryRouteHandler = async (req, res) => {
|
||||||
|
const from = req.query.from;
|
||||||
|
const to = req.query.to;
|
||||||
|
const result = await getModelHistory({ model: productCategoryModel, from, to });
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error fetching product category history:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
logger.trace('Product category history:', result);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
@ -35,7 +35,7 @@ export const listProductsRouteHandler = async (
|
|||||||
search,
|
search,
|
||||||
sort,
|
sort,
|
||||||
order,
|
order,
|
||||||
populate: ['vendor', 'costTaxRate', 'priceTaxRate'],
|
populate: ['productCategory', 'vendor', 'costTaxRate', 'priceTaxRate'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
@ -58,7 +58,7 @@ export const listProductsByPropertiesRouteHandler = async (
|
|||||||
model: productModel,
|
model: productModel,
|
||||||
properties,
|
properties,
|
||||||
filter,
|
filter,
|
||||||
populate: ['vendor'],
|
populate: ['productCategory', 'vendor'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
@ -76,7 +76,7 @@ export const getProductRouteHandler = async (req, res) => {
|
|||||||
const result = await getObject({
|
const result = await getObject({
|
||||||
model: productModel,
|
model: productModel,
|
||||||
id,
|
id,
|
||||||
populate: ['vendor', 'costTaxRate', 'priceTaxRate'],
|
populate: ['productCategory', 'vendor', 'costTaxRate', 'priceTaxRate'],
|
||||||
});
|
});
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
logger.warn(`Product not found with supplied id.`);
|
logger.warn(`Product not found with supplied id.`);
|
||||||
@ -95,6 +95,7 @@ export const editProductRouteHandler = async (req, res) => {
|
|||||||
const updateData = {
|
const updateData = {
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
name: req.body?.name,
|
name: req.body?.name,
|
||||||
|
productCategory: req.body?.productCategory,
|
||||||
tags: req.body?.tags,
|
tags: req.body?.tags,
|
||||||
version: req.body?.version,
|
version: req.body?.version,
|
||||||
vendor: req.body.vendor,
|
vendor: req.body.vendor,
|
||||||
@ -130,6 +131,7 @@ export const newProductRouteHandler = async (req, res) => {
|
|||||||
const newData = {
|
const newData = {
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
name: req.body?.name,
|
name: req.body?.name,
|
||||||
|
productCategory: req.body?.productCategory,
|
||||||
tags: req.body?.tags,
|
tags: req.body?.tags,
|
||||||
version: req.body?.version,
|
version: req.body?.version,
|
||||||
vendor: req.body.vendor,
|
vendor: req.body.vendor,
|
||||||
|
|||||||
@ -107,6 +107,7 @@ export const editUserRouteHandler = async (req, res) => {
|
|||||||
id,
|
id,
|
||||||
updateData,
|
updateData,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
|
populate: ['profileImage'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
|||||||
@ -18,14 +18,24 @@ export const EXPORT_FILTER_BY_TYPE = {
|
|||||||
material: ['name', 'tags'],
|
material: ['name', 'tags'],
|
||||||
partStock: ['partSku'],
|
partStock: ['partSku'],
|
||||||
partSku: ['part', 'vendor', 'priceTaxRate', 'costTaxRate'],
|
partSku: ['part', 'vendor', 'priceTaxRate', 'costTaxRate'],
|
||||||
|
productCategory: ['name'],
|
||||||
|
product: ['productCategory', 'productCategory._id', 'vendor', 'priceTaxRate', 'costTaxRate'],
|
||||||
productStock: ['productSku'],
|
productStock: ['productSku'],
|
||||||
productSku: ['product', 'vendor', 'priceTaxRate', 'costTaxRate'],
|
productSku: ['product', 'vendor', 'priceTaxRate', 'costTaxRate'],
|
||||||
purchaseOrder: ['vendor'],
|
purchaseOrder: [
|
||||||
|
'vendor',
|
||||||
|
'totalAmount',
|
||||||
|
'totalAmountWithTax',
|
||||||
|
'totalTaxAmount',
|
||||||
|
'shippingAmount',
|
||||||
|
'shippingAmountWithTax',
|
||||||
|
'grandTotalAmount',
|
||||||
|
],
|
||||||
orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'sku._id', 'shipment._id'],
|
orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'sku._id', 'shipment._id'],
|
||||||
shipment: ['order._id', 'orderType', 'courierService._id'],
|
shipment: ['order._id', 'orderType', 'courierService._id'],
|
||||||
stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
|
stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
|
||||||
stockLocation: ['name', 'address'],
|
stockLocation: ['name', 'address'],
|
||||||
stockTransfer: ['state.type', 'postedAt'],
|
stockTransfer: ['name', 'state.type', 'postedAt'],
|
||||||
stockAudit: ['filamentStock._id', 'partStock._id'],
|
stockAudit: ['filamentStock._id', 'partStock._id'],
|
||||||
documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'],
|
documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'],
|
||||||
documentTemplate: ['parent._id', 'documentSize._id'],
|
documentTemplate: ['parent._id', 'documentSize._id'],
|
||||||
|
|||||||
@ -40,6 +40,7 @@ const buildSearchFilter = (params) => {
|
|||||||
const trimSpotlightObject = (object, objectType) => {
|
const trimSpotlightObject = (object, objectType) => {
|
||||||
return {
|
return {
|
||||||
_id: object._id,
|
_id: object._id,
|
||||||
|
_reference: object._reference || undefined,
|
||||||
name: object.name || undefined,
|
name: object.name || undefined,
|
||||||
state: object.state && object?.state.type ? { type: object.state.type } : undefined,
|
state: object.state && object?.state.type ? { type: object.state.type } : undefined,
|
||||||
tags: object.tags || undefined,
|
tags: object.tags || undefined,
|
||||||
@ -48,6 +49,7 @@ const trimSpotlightObject = (object, objectType) => {
|
|||||||
updatedAt: object.updatedAt || undefined,
|
updatedAt: object.updatedAt || undefined,
|
||||||
objectType: objectType || undefined,
|
objectType: objectType || undefined,
|
||||||
online: object.online || undefined,
|
online: object.online || undefined,
|
||||||
|
amount: object.amount || undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
490
src/utils.js
490
src/utils.js
@ -14,21 +14,309 @@ import { createEmailRenderAuthCode } from './services/misc/emailRenderAuth.js';
|
|||||||
import { Worker } from 'worker_threads';
|
import { Worker } from 'worker_threads';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import { diffJson } from 'diff';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const logger = log4js.getLogger('Utils');
|
const logger = log4js.getLogger('Utils');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Filter expression parsing (Microsoft Dynamics NAV / Business Central style)
|
||||||
|
//
|
||||||
|
// Supported syntax (all compiled down to MongoDB query operators):
|
||||||
|
// = equal to 377 -> { field: 377 }
|
||||||
|
// <> not equal to <>0 -> { field: { $ne: 0 } }
|
||||||
|
// > greater than >1200 -> { field: { $gt: 1200 } }
|
||||||
|
// >= greater than or equal >=1200 -> { field: { $gte: 1200 } }
|
||||||
|
// < less than <1200 -> { field: { $lt: 1200 } }
|
||||||
|
// <= less than or equal <=1200 -> { field: { $lte: 1200 } }
|
||||||
|
// .. interval 1100..2100 -> { field: { $gte: 1100, $lte: 2100 } }
|
||||||
|
// open-ended intervals ..2500 / 23..
|
||||||
|
// | either / or 1200|1300 -> { field: { $in: [1200, 1300] } }
|
||||||
|
// & and <2000&>1000 -> { field: { $gt: 1000, $lt: 2000 } }
|
||||||
|
// ( ) grouping / precedence 30|(>=10&<=20)
|
||||||
|
// * any number of characters Co* / *Co / *Co*
|
||||||
|
// ? a single character Hans?n
|
||||||
|
// @ ignore case @location (text matching is case-insensitive)
|
||||||
|
//
|
||||||
|
// Date-typed fields (detected by name, e.g. *At / *Date / *Time) interpret bare
|
||||||
|
// values as calendar days/datetimes, e.g. "22" => the whole of day 22 of the
|
||||||
|
// current month/year, "22..23" => start of 22 through end of 23.
|
||||||
|
//
|
||||||
|
// Note: BC's space-delimited date shorthand is locale dependent; here numeric
|
||||||
|
// date operands are read day-first ("D M Y H Min S") with missing components
|
||||||
|
// filled from the current date and the interval boundary.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function buildWildcardRegexPattern(input) {
|
function buildWildcardRegexPattern(input) {
|
||||||
// Escape all regex special chars except * (which we treat as a wildcard)
|
// Escape all regex special chars except * and ? (which we treat as wildcards)
|
||||||
const escaped = input.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
const escaped = String(input).replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||||
// Convert * to "match anything"
|
// * matches any run of characters, ? matches exactly one character
|
||||||
const withWildcards = escaped.replace(/\*/g, '.*');
|
const withWildcards = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
// Anchor so that, without *, this is an exact match
|
// Anchor so that, without wildcards, this is an exact match
|
||||||
return `^${withWildcards}$`;
|
return `^${withWildcards}$`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeDateField(property) {
|
||||||
|
const last = String(property).split('.').pop();
|
||||||
|
return /[a-z](?:At|Date|Time)$/.test(last) || /^(?:date|time|datetime)$/i.test(last);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObjectIdString(value) {
|
||||||
|
return /^[a-f\d]{24}$/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNumeric(value) {
|
||||||
|
return value.trim() !== '' && !isNaN(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfDay(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(23, 59, 59, 999);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeYear(year) {
|
||||||
|
if (year >= 100) return year;
|
||||||
|
return year < 70 ? 2000 + year : 1900 + year;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses a single date operand to a Date, filling missing parts from the
|
||||||
|
// current date and the supplied interval boundary ('start' | 'end').
|
||||||
|
function parseDateOperand(value, boundary = 'start') {
|
||||||
|
const text = String(value).trim();
|
||||||
|
if (!text) return null;
|
||||||
|
const end = boundary === 'end';
|
||||||
|
|
||||||
|
// Values with explicit separators (ISO and similar) are parsed directly.
|
||||||
|
if (/[-/T]/.test(text) || /\d:\d/.test(text)) {
|
||||||
|
const parsed = new Date(text);
|
||||||
|
if (isNaN(parsed.getTime())) return null;
|
||||||
|
if (!/[T:]/.test(text)) {
|
||||||
|
return end ? endOfDay(parsed) : startOfDay(parsed);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric, day-first form: "D [M] [Y] [H] [Min] [S]".
|
||||||
|
const parts = text.split(/\s+/);
|
||||||
|
if (!parts.every((part) => /^\d+$/.test(part))) return null;
|
||||||
|
const nums = parts.map(Number);
|
||||||
|
const now = new Date();
|
||||||
|
const day = nums[0];
|
||||||
|
const month = nums.length >= 2 ? nums[1] : now.getMonth() + 1;
|
||||||
|
const year = nums.length >= 3 ? normalizeYear(nums[2]) : now.getFullYear();
|
||||||
|
const hour = nums.length >= 4 ? nums[3] : end ? 23 : 0;
|
||||||
|
const minute = nums.length >= 5 ? nums[4] : end ? 59 : 0;
|
||||||
|
const second = nums.length >= 6 ? nums[5] : end ? 59 : 0;
|
||||||
|
const ms = end ? 999 : 0;
|
||||||
|
const date = new Date(year, month - 1, day, hour, minute, second, ms);
|
||||||
|
return isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coerces a literal to its strongest matching scalar type.
|
||||||
|
function coerceScalar(value) {
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
if (lower === 'true') return true;
|
||||||
|
if (lower === 'false') return false;
|
||||||
|
if (isObjectIdString(value)) return new mongoose.Types.ObjectId(value);
|
||||||
|
if (isNumeric(value)) return Number(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coerces an interval/comparison boundary, preferring a date for date fields.
|
||||||
|
function coerceBoundary(value, isDateField, boundary) {
|
||||||
|
if (isDateField) {
|
||||||
|
const date = parseDateOperand(value, boundary);
|
||||||
|
if (date) return date;
|
||||||
|
}
|
||||||
|
return coerceScalar(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splits on a separator while respecting parentheses depth.
|
||||||
|
function splitTopLevel(str, separator) {
|
||||||
|
const parts = [];
|
||||||
|
let depth = 0;
|
||||||
|
let current = '';
|
||||||
|
for (const ch of str) {
|
||||||
|
if (ch === '(') depth++;
|
||||||
|
else if (ch === ')') depth = Math.max(0, depth - 1);
|
||||||
|
|
||||||
|
if (ch === separator && depth === 0) {
|
||||||
|
parts.push(current);
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(current);
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when a single outer pair of parentheses wraps the whole token.
|
||||||
|
function isWrappedInParens(str) {
|
||||||
|
if (!str.startsWith('(') || !str.endsWith(')')) return false;
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
if (str[i] === '(') depth++;
|
||||||
|
else if (str[i] === ')') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0 && i < str.length - 1) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return depth === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses a filter expression into an AST of or / and / leaf nodes.
|
||||||
|
function parseExpression(str) {
|
||||||
|
const orParts = splitTopLevel(str, '|');
|
||||||
|
if (orParts.length > 1) {
|
||||||
|
return { type: 'or', items: orParts.map(parseExpression) };
|
||||||
|
}
|
||||||
|
const andParts = splitTopLevel(str, '&');
|
||||||
|
if (andParts.length > 1) {
|
||||||
|
return { type: 'and', items: andParts.map(parseExpression) };
|
||||||
|
}
|
||||||
|
const trimmed = str.trim();
|
||||||
|
if (isWrappedInParens(trimmed)) {
|
||||||
|
return parseExpression(trimmed.slice(1, -1));
|
||||||
|
}
|
||||||
|
return { type: 'leaf', token: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strips a leading @ (ignore-case marker); text matching is already case-insensitive.
|
||||||
|
function stripIgnoreCase(str) {
|
||||||
|
return str.startsWith('@') ? str.slice(1) : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds an equality condition (value, date range, or wildcard regex).
|
||||||
|
function buildEquality(value, isDateField) {
|
||||||
|
if (isDateField) {
|
||||||
|
const start = parseDateOperand(value, 'start');
|
||||||
|
const end = parseDateOperand(value, 'end');
|
||||||
|
if (start && end) return { op: { $gte: start, $lte: end } };
|
||||||
|
}
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
if (lower === 'true') return { value: true };
|
||||||
|
if (lower === 'false') return { value: false };
|
||||||
|
if (isObjectIdString(value)) return { value: new mongoose.Types.ObjectId(value) };
|
||||||
|
if (isNumeric(value)) return { value: Number(value) };
|
||||||
|
return { op: { $regex: buildWildcardRegexPattern(value), $options: 'i' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a comparison condition for a single operator.
|
||||||
|
function buildComparison(name, value, isDateField) {
|
||||||
|
if (name === 'eq') return buildEquality(value, isDateField);
|
||||||
|
|
||||||
|
if (name === 'ne') {
|
||||||
|
if (isDateField) {
|
||||||
|
const start = parseDateOperand(value, 'start');
|
||||||
|
const end = parseDateOperand(value, 'end');
|
||||||
|
if (start && end) return { op: { $not: { $gte: start, $lte: end } } };
|
||||||
|
}
|
||||||
|
if (/[*?]/.test(value)) {
|
||||||
|
return { op: { $not: { $regex: buildWildcardRegexPattern(value), $options: 'i' } } };
|
||||||
|
}
|
||||||
|
return { op: { $ne: coerceScalar(value) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For dates, < and >= align to the start of the day, > and <= to the end.
|
||||||
|
const boundary = name === 'gt' || name === 'lte' ? 'end' : 'start';
|
||||||
|
return { op: { [`$${name}`]: coerceBoundary(value, isDateField, boundary) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses a leaf token (range, comparison, or plain value) into a condition.
|
||||||
|
function parseLeafCondition(rawToken, isDateField) {
|
||||||
|
const token = rawToken.trim();
|
||||||
|
if (token === '') {
|
||||||
|
return { op: { $regex: '^$', $options: 'i' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interval: a..b, ..b, a..
|
||||||
|
const rangeIdx = token.indexOf('..');
|
||||||
|
if (rangeIdx !== -1) {
|
||||||
|
const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim());
|
||||||
|
const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim());
|
||||||
|
const op = {};
|
||||||
|
if (lo !== '') op.$gte = coerceBoundary(lo, isDateField, 'start');
|
||||||
|
if (hi !== '') op.$lte = coerceBoundary(hi, isDateField, 'end');
|
||||||
|
// A bare ".." places no constraint on the field.
|
||||||
|
if (Object.keys(op).length === 0) return { query: {} };
|
||||||
|
return { op };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparison operators, longest symbols first.
|
||||||
|
const operators = [
|
||||||
|
['<>', 'ne'],
|
||||||
|
['>=', 'gte'],
|
||||||
|
['<=', 'lte'],
|
||||||
|
['>', 'gt'],
|
||||||
|
['<', 'lt'],
|
||||||
|
['=', 'eq'],
|
||||||
|
];
|
||||||
|
for (const [symbol, name] of operators) {
|
||||||
|
if (token.startsWith(symbol)) {
|
||||||
|
return buildComparison(name, stripIgnoreCase(token.slice(symbol.length).trim()), isDateField);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildEquality(stripIgnoreCase(token), isDateField);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts a condition descriptor into a MongoDB query object for `property`.
|
||||||
|
function conditionToQuery(cond, property) {
|
||||||
|
if (cond.query) return cond.query;
|
||||||
|
if (cond.op) return { [property]: cond.op };
|
||||||
|
return { [property]: cond.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOnlyRegex(op) {
|
||||||
|
return Object.keys(op).every((key) => key === '$regex' || key === '$options');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combines AND-ed conditions, merging operator objects on the same field where possible.
|
||||||
|
function combineAnd(children, property) {
|
||||||
|
if (children.some((child) => child.query)) {
|
||||||
|
return { query: { $and: children.map((child) => conditionToQuery(child, property)) } };
|
||||||
|
}
|
||||||
|
const merged = {};
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.value !== undefined) {
|
||||||
|
merged.$eq = child.value;
|
||||||
|
} else if (child.op) {
|
||||||
|
Object.assign(merged, child.op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { op: merged };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combines OR-ed conditions, collapsing to $in or a single regex when possible.
|
||||||
|
function combineOr(children, property) {
|
||||||
|
if (children.every((child) => child.value !== undefined)) {
|
||||||
|
return { op: { $in: children.map((child) => child.value) } };
|
||||||
|
}
|
||||||
|
if (children.every((child) => child.op && isOnlyRegex(child.op))) {
|
||||||
|
const pattern = children.map((child) => child.op.$regex).join('|');
|
||||||
|
return { op: { $regex: pattern, $options: 'i' } };
|
||||||
|
}
|
||||||
|
return { query: { $or: children.map((child) => conditionToQuery(child, property)) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCondition(node, property, isDateField) {
|
||||||
|
if (node.type === 'leaf') {
|
||||||
|
return parseLeafCondition(node.token, isDateField);
|
||||||
|
}
|
||||||
|
const children = node.items.map((item) => buildCondition(item, property, isDateField));
|
||||||
|
return node.type === 'and' ? combineAnd(children, property) : combineOr(children, property);
|
||||||
|
}
|
||||||
|
|
||||||
function parseFilter(property, value) {
|
function parseFilter(property, value) {
|
||||||
// Normalize state filter to state.type for schemas with state: { type }
|
// Normalize state filter to state.type for schemas with state: { type }
|
||||||
if (property === 'state') {
|
if (property === 'state') {
|
||||||
@ -38,40 +326,22 @@ function parseFilter(property, value) {
|
|||||||
if (value?._id !== undefined && value?._id !== null) {
|
if (value?._id !== undefined && value?._id !== null) {
|
||||||
return { [property]: { _id: new mongoose.Types.ObjectId(value._id) } };
|
return { [property]: { _id: new mongoose.Types.ObjectId(value._id) } };
|
||||||
}
|
}
|
||||||
if (typeof value === 'string') {
|
|
||||||
var trimmed = value.trim();
|
|
||||||
if (trimmed.charAt(3) == ':') {
|
|
||||||
trimmed = value.split(':')[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle booleans
|
// Non-string values (actual booleans, numbers, objects, etc.) pass through.
|
||||||
if (trimmed.toLowerCase() === 'true') return { [property]: true };
|
if (typeof value !== 'string') {
|
||||||
if (trimmed.toLowerCase() === 'false') return { [property]: false };
|
|
||||||
|
|
||||||
// Handle ObjectId (24-char hex)
|
|
||||||
|
|
||||||
if (/^[a-f\d]{24}$/i.test(trimmed) && trimmed.length >= 24) {
|
|
||||||
return { [property]: new mongoose.Types.ObjectId(trimmed) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle numbers
|
|
||||||
if (!isNaN(trimmed)) {
|
|
||||||
return { [property]: parseFloat(trimmed) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to case-insensitive regex for non-numeric strings.
|
|
||||||
// Supports * as a wildcard (e.g. "filament*" matches "filament stock").
|
|
||||||
const pattern = buildWildcardRegexPattern(trimmed);
|
|
||||||
return {
|
|
||||||
[property]: {
|
|
||||||
$regex: pattern,
|
|
||||||
$options: 'i',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle actual booleans, numbers, objects, etc.
|
|
||||||
return { [property]: value };
|
return { [property]: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if (trimmed.charAt(3) === ':') {
|
||||||
|
const afterColon = value.split(':')[1];
|
||||||
|
trimmed = afterColon != null ? afterColon.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDateField = looksLikeDateField(property);
|
||||||
|
const tree = parseExpression(trimmed);
|
||||||
|
const condition = buildCondition(tree, property, isDateField);
|
||||||
|
return conditionToQuery(condition, property);
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertToCamelCase(obj) {
|
function convertToCamelCase(obj) {
|
||||||
@ -303,6 +573,30 @@ function extractGCodeConfigBlock(fileContent, useCamelCase = true) {
|
|||||||
return useCamelCase ? convertToCamelCase(configObject) : configObject;
|
return useCamelCase ? convertToCamelCase(configObject) : configObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUDIT_IGNORED_KEYS = ['createdAt', 'updatedAt', '_id'];
|
||||||
|
|
||||||
|
function isDiffableObject(value) {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value) && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNumericValue(value) {
|
||||||
|
return (
|
||||||
|
typeof value === 'number' ||
|
||||||
|
(value !== null && value !== undefined && !isNaN(Number(value)) && value !== '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDiffValue(value) {
|
||||||
|
return isNumericValue(value) ? Number(value) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valuesDiffer(oldVal, newVal) {
|
||||||
|
return diffJson(
|
||||||
|
{ value: normalizeDiffValue(oldVal) },
|
||||||
|
{ value: normalizeDiffValue(newVal) }
|
||||||
|
).some((part) => part.added || part.removed);
|
||||||
|
}
|
||||||
|
|
||||||
function getChangedValues(oldObj, newObj, old = false) {
|
function getChangedValues(oldObj, newObj, old = false) {
|
||||||
const changes = {};
|
const changes = {};
|
||||||
|
|
||||||
@ -311,24 +605,15 @@ function getChangedValues(oldObj, newObj, old = false) {
|
|||||||
// Check all keys in the new object
|
// Check all keys in the new object
|
||||||
for (const key in combinedObj) {
|
for (const key in combinedObj) {
|
||||||
// Skip if the key is _id or timestamps
|
// Skip if the key is _id or timestamps
|
||||||
if (key === 'createdAt' || key === 'updatedAt' || key === '_id') continue;
|
if (AUDIT_IGNORED_KEYS.includes(key)) continue;
|
||||||
|
|
||||||
const oldVal = oldObj ? oldObj[key] : undefined;
|
const oldVal = oldObj ? oldObj[key] : undefined;
|
||||||
const newVal = newObj ? newObj[key] : undefined;
|
const newVal = newObj ? newObj[key] : undefined;
|
||||||
|
|
||||||
// If both values are objects (but not arrays or null), recurse
|
// If both values are objects (but not arrays or null), recurse
|
||||||
if (
|
if (isDiffableObject(oldVal) && isDiffableObject(newVal)) {
|
||||||
oldVal &&
|
|
||||||
newVal &&
|
|
||||||
typeof oldVal === 'object' &&
|
|
||||||
typeof newVal === 'object' &&
|
|
||||||
!Array.isArray(oldVal) &&
|
|
||||||
!Array.isArray(newVal) &&
|
|
||||||
oldVal !== null &&
|
|
||||||
newVal !== null
|
|
||||||
) {
|
|
||||||
if (oldVal?._id || newVal?._id) {
|
if (oldVal?._id || newVal?._id) {
|
||||||
if (JSON.stringify(oldVal?._id) !== JSON.stringify(newVal?._id)) {
|
if (valuesDiffer(oldVal?._id, newVal?._id)) {
|
||||||
changes[key] = old ? oldVal : newVal;
|
changes[key] = old ? oldVal : newVal;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -338,24 +623,7 @@ function getChangedValues(oldObj, newObj, old = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Check if both values are numbers (or can be converted to numbers)
|
if (valuesDiffer(oldVal, newVal)) {
|
||||||
const oldIsNumber =
|
|
||||||
typeof oldVal === 'number' ||
|
|
||||||
(oldVal !== null && oldVal !== undefined && !isNaN(Number(oldVal)) && oldVal !== '');
|
|
||||||
const newIsNumber =
|
|
||||||
typeof newVal === 'number' ||
|
|
||||||
(newVal !== null && newVal !== undefined && !isNaN(Number(newVal)) && newVal !== '');
|
|
||||||
|
|
||||||
let valuesDiffer;
|
|
||||||
if (oldIsNumber && newIsNumber) {
|
|
||||||
// Compare numbers directly (this normalizes 7.50 to 7.5)
|
|
||||||
valuesDiffer = Number(oldVal) !== Number(newVal);
|
|
||||||
} else {
|
|
||||||
// Use JSON.stringify for non-number comparisons
|
|
||||||
valuesDiffer = JSON.stringify(oldVal) !== JSON.stringify(newVal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (valuesDiffer) {
|
|
||||||
// If the old value is different from the new value, include it
|
// If the old value is different from the new value, include it
|
||||||
changes[key] = old ? oldVal : newVal;
|
changes[key] = old ? oldVal : newVal;
|
||||||
}
|
}
|
||||||
@ -426,6 +694,8 @@ async function editAuditLog(oldValue, newValue, parentId, parentType, user) {
|
|||||||
operation: 'edit',
|
operation: 'edit',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('auditLog', oldValue);
|
||||||
|
|
||||||
await auditLog.save();
|
await auditLog.save();
|
||||||
|
|
||||||
await distributeNew(auditLog._id, 'auditLog');
|
await distributeNew(auditLog._id, 'auditLog');
|
||||||
@ -544,29 +814,52 @@ async function distributeDelete(value, type) {
|
|||||||
await natsServer.publish(`${type}s.delete`, value);
|
await natsServer.publish(`${type}s.delete`, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getReferenceId(value) {
|
||||||
|
if (value instanceof mongoose.Types.ObjectId) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object' && value._id) {
|
||||||
|
return getReferenceId(value._id);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReferenceIdString(value) {
|
||||||
|
const id = getReferenceId(value);
|
||||||
|
return id == null ? null : id.toString();
|
||||||
|
}
|
||||||
|
|
||||||
async function distributeChildUpdate(oldValue, newValue, id, model) {
|
async function distributeChildUpdate(oldValue, newValue, id, model) {
|
||||||
const oldPopulatedObjects = populateObjects(oldValue, model) || [];
|
const oldPopulatedObjects = populateObjects(oldValue, model) || [];
|
||||||
const oldPopulatedObjectIds = oldPopulatedObjects.map((populate) => populate._id.toString());
|
const oldPopulatedObjectIds = oldPopulatedObjects.map((populate) =>
|
||||||
|
getReferenceIdString(populate._id)
|
||||||
|
);
|
||||||
const newPopulatedObjects = populateObjects(newValue, model) || [];
|
const newPopulatedObjects = populateObjects(newValue, model) || [];
|
||||||
const newPopulatedObjectIds = newPopulatedObjects.map((populate) => populate._id.toString());
|
const newPopulatedObjectIds = newPopulatedObjects.map((populate) =>
|
||||||
|
getReferenceIdString(populate._id)
|
||||||
|
);
|
||||||
|
|
||||||
for (const populated of oldPopulatedObjects) {
|
for (const populated of oldPopulatedObjects) {
|
||||||
if (!newPopulatedObjectIds.includes(populated._id.toString())) {
|
const populatedId = getReferenceIdString(populated._id);
|
||||||
|
if (!populatedId) continue;
|
||||||
|
if (!newPopulatedObjectIds.includes(populatedId)) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Distributing child update for ${populated.ref}s.${populated._id}.events.childUpdate`
|
`Distributing child update for ${populated.ref}s.${populatedId}.events.childUpdate`
|
||||||
);
|
);
|
||||||
await natsServer.publish(`${populated.ref}s.${populated._id}.events.childUpdate`, {
|
await natsServer.publish(`${populated.ref}s.${populatedId}.events.childUpdate`, {
|
||||||
type: 'childUpdate',
|
type: 'childUpdate',
|
||||||
data: { parentId: id, parentType: model.modelName },
|
data: { parentId: id, parentType: model.modelName },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const populated of newPopulatedObjects) {
|
for (const populated of newPopulatedObjects) {
|
||||||
if (!oldPopulatedObjectIds.includes(populated._id.toString())) {
|
const populatedId = getReferenceIdString(populated._id);
|
||||||
|
if (!populatedId) continue;
|
||||||
|
if (!oldPopulatedObjectIds.includes(populatedId)) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Distributing child update for ${populated.ref}s.${populated._id}.events.childUpdate`
|
`Distributing child update for ${populated.ref}s.${populatedId}.events.childUpdate`
|
||||||
);
|
);
|
||||||
await natsServer.publish(`${populated.ref}s.${populated._id}.events.childUpdate`, {
|
await natsServer.publish(`${populated.ref}s.${populatedId}.events.childUpdate`, {
|
||||||
type: 'childUpdate',
|
type: 'childUpdate',
|
||||||
data: { parentId: id, parentType: model.modelName },
|
data: { parentId: id, parentType: model.modelName },
|
||||||
});
|
});
|
||||||
@ -577,10 +870,12 @@ async function distributeChildUpdate(oldValue, newValue, id, model) {
|
|||||||
async function distributeChildDelete(value, id, model) {
|
async function distributeChildDelete(value, id, model) {
|
||||||
const populatedObjects = populateObjects(value, model) || [];
|
const populatedObjects = populateObjects(value, model) || [];
|
||||||
for (const populated of populatedObjects) {
|
for (const populated of populatedObjects) {
|
||||||
|
const populatedId = getReferenceIdString(populated._id);
|
||||||
|
if (!populatedId) continue;
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Distributing child delete for ${populated.ref}s.${populated._id}.events.childDelete`
|
`Distributing child delete for ${populated.ref}s.${populatedId}.events.childDelete`
|
||||||
);
|
);
|
||||||
await natsServer.publish(`${populated.ref}s.${populated._id}.events.childDelete`, {
|
await natsServer.publish(`${populated.ref}s.${populatedId}.events.childDelete`, {
|
||||||
type: 'childDelete',
|
type: 'childDelete',
|
||||||
data: { parentId: id, parentType: model.modelName },
|
data: { parentId: id, parentType: model.modelName },
|
||||||
});
|
});
|
||||||
@ -590,8 +885,10 @@ async function distributeChildDelete(value, id, model) {
|
|||||||
async function distributeChildNew(value, id, model) {
|
async function distributeChildNew(value, id, model) {
|
||||||
const populatedObjects = populateObjects(value, model) || [];
|
const populatedObjects = populateObjects(value, model) || [];
|
||||||
for (const populated of populatedObjects) {
|
for (const populated of populatedObjects) {
|
||||||
logger.debug(`Distributing child new for ${populated.ref}s.${populated._id}.events.childNew`);
|
const populatedId = getReferenceIdString(populated._id);
|
||||||
await natsServer.publish(`${populated.ref}s.${populated._id}.events.childNew`, {
|
if (!populatedId) continue;
|
||||||
|
logger.debug(`Distributing child new for ${populated.ref}s.${populatedId}.events.childNew`);
|
||||||
|
await natsServer.publish(`${populated.ref}s.${populatedId}.events.childNew`, {
|
||||||
type: 'childNew',
|
type: 'childNew',
|
||||||
data: { parentId: id, parentType: model.modelName },
|
data: { parentId: id, parentType: model.modelName },
|
||||||
});
|
});
|
||||||
@ -760,16 +1057,36 @@ function expandObjectIds(input) {
|
|||||||
return expand(input);
|
return expand(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merges per-field filter clauses, falling back to $and when clauses use
|
||||||
|
// logical operators or target the same field (so nothing is silently lost).
|
||||||
|
function mergeFilterClauses(clauses) {
|
||||||
|
const valid = clauses.filter((clause) => clause && Object.keys(clause).length > 0);
|
||||||
|
if (valid.length === 0) return {};
|
||||||
|
if (valid.length === 1) return valid[0];
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
let needsAnd = false;
|
||||||
|
for (const clause of valid) {
|
||||||
|
for (const key of Object.keys(clause)) {
|
||||||
|
if (key === '$or' || key === '$and' || seen.has(key)) {
|
||||||
|
needsAnd = true;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return needsAnd ? { $and: valid } : Object.assign({}, ...valid);
|
||||||
|
}
|
||||||
|
|
||||||
// Returns a filter object based on allowed filters and req.query
|
// Returns a filter object based on allowed filters and req.query
|
||||||
function getFilter(query, allowedFilters, parse = true) {
|
function getFilter(query, allowedFilters, parse = true) {
|
||||||
let filter = {};
|
const clauses = [];
|
||||||
for (const [key, value] of Object.entries(query)) {
|
for (const [key, value] of Object.entries(query)) {
|
||||||
if (allowedFilters.includes(key)) {
|
if (allowedFilters.includes(key)) {
|
||||||
const parsedFilter = parse ? parseFilter(key, value) : { [key]: value };
|
clauses.push(parse ? parseFilter(key, value) : { [key]: value });
|
||||||
filter = { ...filter, ...parsedFilter };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return filter;
|
return mergeFilterClauses(clauses);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Converts a properties argument (string or array) to an array of strings
|
// Converts a properties argument (string or array) to an array of strings
|
||||||
@ -869,15 +1186,16 @@ function buildDeepPopulateSpec(object, model, populated = new Set()) {
|
|||||||
const refModel = model.db.model(refName);
|
const refModel = model.db.model(refName);
|
||||||
const childPopulate = buildDeepPopulateSpec(object, refModel, populated);
|
const childPopulate = buildDeepPopulateSpec(object, refModel, populated);
|
||||||
|
|
||||||
const id = object[pathname]?._id || object[pathname];
|
const values = Array.isArray(object[pathname]) ? object[pathname] : [object[pathname]];
|
||||||
|
const ids = values.map(getReferenceId).filter(Boolean);
|
||||||
if (id == null || !id) return;
|
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
if (childPopulate.length > 0) {
|
if (childPopulate.length > 0) {
|
||||||
populateSpec.push({ path: pathname, populate: childPopulate, ref: refName, _id: id });
|
populateSpec.push({ path: pathname, populate: childPopulate, ref: refName, _id: id });
|
||||||
} else {
|
} else {
|
||||||
populateSpec.push({ path: pathname, ref: refName, _id: id });
|
populateSpec.push({ path: pathname, ref: refName, _id: id });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return populateSpec;
|
return populateSpec;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user