Compare commits

...

4 Commits

Author SHA1 Message Date
850c721867 Refactor allowed filters and sorters across various routes to enhance data handling
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
This commit updates the allowed filters and sorters in multiple routes, including invoices, payment policies, inventory, management, and sales. It removes unnecessary filters, adds new ones for improved categorization, and ensures consistency in the data structure. These changes aim to streamline data retrieval and enhance the overall functionality of the application.
2026-09-01 19:54:44 +01:00
a7b613c88b Implement model prefix stripping and leading operator handling in utility functions
This commit introduces new functions to handle model prefix stripping and leading operator detection in the `utils.js` file. The `stripModelPrefixFromExpression` and `splitLeadingOperator` functions are added to enhance expression parsing. Additionally, existing functions are updated to utilize these new utilities, improving the handling of expressions in filters. Corresponding tests are added to ensure the correctness of the new functionality, particularly for equality and not-equal expressions.
2026-09-01 18:44:38 +01:00
1f679825df Add tax calculation helpers and integrate into existing schemas
This commit introduces a new `tax.js` module containing functions for tax rate resolution and amount calculation with tax. The `amountWithTax` and `resolveTaxRate` functions are integrated into various schemas, including `invoice`, `orderitem`, `shipment`, `courierservice`, `filament`, `part`, and `product`, enhancing their ability to compute amounts with applicable taxes. Additionally, corresponding tests are added to ensure the correctness of the new tax-related functionalities.
2026-09-01 17:39:12 +01:00
edb8282c41 Enhance filament stock management by adding default state and posting functionality
This commit updates the filament stock schema to set a default state of 'draft' and introduces a new route handler for posting filament stocks. The posting process includes validation to ensure stocks are in the draft state before transitioning to 'unconsumed', along with the creation of an initial stock event. Additionally, the service layer is updated to handle these changes, and tests are added to verify the new functionality.
2026-09-01 17:26:10 +01:00
36 changed files with 723 additions and 120 deletions

View File

@ -38,4 +38,16 @@ describe('parseFilter number fields', () => {
const result = await parseFilter('_reference', 'JOB*', jobModel);
expect(result._reference).toEqual({ $regex: '^JOB.*$', $options: 'i' });
});
it('strips model prefix from equality expressions', async () => {
await expect(parseFilter('_reference', 'GCF:EJX3EXUZYW2U', jobModel)).resolves.toEqual({
_reference: { $regex: '^EJX3EXUZYW2U$', $options: 'i' },
});
});
it('strips model prefix from not-equal expressions', async () => {
await expect(parseFilter('_reference', '<>GCF:EJX3EXUZYW2U', jobModel)).resolves.toEqual({
_reference: { $ne: 'EJX3EXUZYW2U' },
});
});
});

View File

@ -0,0 +1,37 @@
import { amountWithTax, effectiveMarginPrice } from '../tax.js';
describe('amountWithTax', () => {
it('returns 0 for zero amount', () => {
expect(amountWithTax(0, { rateType: 'percentage', rate: 20 })).toBe(0);
});
it('applies percentage tax', () => {
expect(amountWithTax(100, { rateType: 'percentage', rate: 20 })).toBe(120);
});
it('applies fixed amount tax', () => {
expect(amountWithTax(100, { rateType: 'amount', rate: 5 })).toBe(105);
});
it('applies fixed rate type from API schema', () => {
expect(amountWithTax(100, { rateType: 'fixed', rate: 5 })).toBe(105);
});
it('returns base amount when tax rate is missing', () => {
expect(amountWithTax(100, null)).toBe(100);
});
});
describe('effectiveMarginPrice', () => {
it('computes price from margin mode', () => {
expect(
effectiveMarginPrice({ priceMode: 'margin', price: 50, cost: 100, margin: 20 })
).toBe(120);
});
it('returns explicit price for amount mode', () => {
expect(
effectiveMarginPrice({ priceMode: 'amount', price: 150, cost: 100, margin: 20 })
).toBe(150);
});
});

View File

@ -1,7 +1,9 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
import { aggregateRollups, aggregateRollupsHistory, editObject, getObject } from '../../database.js';
import { taxRateModel } from '../management/taxrate.schema.js';
import { amountWithTax, resolveTaxRate } from '../../tax.js';
const invoiceOrderItemSchema = new Schema(
{
@ -140,23 +142,41 @@ invoiceSchema.statics.recalculate = async function (invoice, user) {
return;
}
const invoiceOrderItems = [];
for (const item of invoice.invoiceOrderItems || []) {
const taxRate = await resolveTaxRate(item.taxRate, getObject, taxRateModel);
invoiceOrderItems.push({
...item,
invoiceAmountWithTax: amountWithTax(item.invoiceAmount, taxRate),
});
}
const invoiceShipments = [];
for (const item of invoice.invoiceShipments || []) {
const taxRate = await resolveTaxRate(item.taxRate, getObject, taxRateModel);
invoiceShipments.push({
...item,
invoiceAmountWithTax: amountWithTax(item.invoiceAmount, taxRate),
});
}
// Calculate totals from invoiceOrderItems
let totalAmount = 0;
for (const item of invoice.invoiceOrderItems || []) {
for (const item of invoiceOrderItems) {
totalAmount += Number.parseFloat(item.invoiceAmount) || 0;
}
let totalAmountWithTax = 0;
for (const item of invoice.invoiceOrderItems || []) {
for (const item of invoiceOrderItems) {
totalAmountWithTax += Number.parseFloat(item.invoiceAmountWithTax) || 0;
}
// Calculate shipping totals from invoiceShipments
let shippingAmount = 0;
for (const item of invoice.invoiceShipments || []) {
for (const item of invoiceShipments) {
shippingAmount += Number.parseFloat(item.invoiceAmount) || 0;
}
let shippingAmountWithTax = 0;
for (const item of invoice.invoiceShipments || []) {
for (const item of invoiceShipments) {
shippingAmountWithTax += Number.parseFloat(item.invoiceAmountWithTax) || 0;
}
@ -168,6 +188,8 @@ invoiceSchema.statics.recalculate = async function (invoice, user) {
(parseFloat(shippingAmountWithTax) - parseFloat(shippingAmount));
const updateData = {
invoiceOrderItems,
invoiceShipments,
totalAmount: parseFloat(totalAmount).toFixed(2),
totalAmountWithTax: parseFloat(totalAmountWithTax).toFixed(2),
shippingAmount: parseFloat(shippingAmount).toFixed(2),

View File

@ -15,9 +15,10 @@ const filamentStockSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
state: {
type: { type: String, required: true },
type: { type: String, required: true, default: 'draft' },
progress: { type: Number, required: false },
},
postedAt: { type: Date, required: false },
startingWeight: {
net: { type: Number, required: true },
gross: { type: Number, required: true },
@ -65,6 +66,11 @@ const rollupConfigs = [
filter: {},
rollups: [{ name: 'totalCurrentWeight', property: 'currentWeight.net', operation: 'sum' }],
},
{
name: 'draft',
filter: { 'state.type': 'draft' },
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
},
{
name: 'unconsumed',
filter: { 'state.type': 'unconsumed' },
@ -114,6 +120,8 @@ filamentStockSchema.statics.recalculate = async function (filamentStock, user) {
stockLocationId,
user,
});
if (filamentStock.state?.type === 'draft' || filamentStock.state?.type === 'consumed') return;
};
// Add virtual id getter

View File

@ -15,6 +15,7 @@ import {
getObject,
} from '../../database.js';
import { generateId } from '../../utils.js';
import { amountWithTax, resolveTaxRate } from '../../tax.js';
const { Schema } = mongoose;
const skuModelsByItemType = {
@ -194,17 +195,10 @@ orderItemSchema.statics.recalculate = async function (orderItem, user) {
}
}
let taxRate = orderItem.taxRate;
if (orderItem.taxRate?._id && Object.keys(orderItem.taxRate).length === 1) {
taxRate = await getObject({
model: taxRateModel,
id: orderItem.taxRate._id,
cached: true,
});
}
const taxRate = await resolveTaxRate(orderItem.taxRate, getObject, taxRateModel);
const orderTotalAmount = effectiveItemAmount * orderItem.quantity;
const orderTotalAmountWithTax = orderTotalAmount * (1 + (taxRate?.rate || 0) / 100);
const orderTotalAmountWithTax = amountWithTax(orderTotalAmount, taxRate);
const orderItemUpdateData = {
totalAmount: orderTotalAmount,

View File

@ -10,6 +10,7 @@ import {
editObject,
getObject,
} from '../../database.js';
import { amountWithTax, resolveTaxRate } from '../../tax.js';
const shipmentSchema = new Schema(
{
@ -102,26 +103,17 @@ shipmentSchema.statics.recalculate = async function (shipment, user) {
return;
}
var taxRate = shipment.taxRate;
const taxRate = await resolveTaxRate(shipment.taxRate, getObject, taxRateModel);
if (shipment.taxRate?._id && Object.keys(shipment.taxRate).length == 1) {
taxRate = await getObject({
model: taxRateModel,
id: shipment.taxRate._id,
cached: true,
});
}
const amountWithTax = parseFloat(
(shipment.amount || 0) * (1 + (taxRate?.rate || 0) / 100)
).toFixed(2);
const amountWithTaxValue = amountWithTax(shipment.amount || 0, taxRate);
await editObject({
model: shipmentModel,
id: shipment._id,
updateData: {
amountWithTax: amountWithTax,
amountWithTax: amountWithTaxValue,
invoicedAmountRemaining: shipment.amount - (shipment.invoicedAmount || 0),
invoicedAmountWithTaxRemaining: amountWithTax - (shipment.invoicedAmountWithTax || 0),
invoicedAmountWithTaxRemaining:
amountWithTaxValue - (shipment.invoicedAmountWithTax || 0),
},
user,
recalculate: false,

View File

@ -1,5 +1,8 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
import { taxRateModel } from './taxrate.schema.js';
import { editObject, getObject } from '../../database.js';
import { amountWithTax, resolveTaxRate } from '../../tax.js';
const { Schema } = mongoose;
const marketplaceMappingSchema = new mongoose.Schema(
@ -39,4 +42,29 @@ courierServiceSchema.virtual('id').get(function () {
courierServiceSchema.set('toJSON', { virtuals: true });
courierServiceSchema.statics.recalculate = async function (courierService, user) {
const costTaxRate = await resolveTaxRate(courierService.costTaxRate, getObject, taxRateModel);
const updateData = {};
if (courierService.cost != null) {
updateData.costWithTax = amountWithTax(courierService.cost, costTaxRate);
}
if (courierService.additionalCost != null) {
updateData.additionalCostWithTax = amountWithTax(
courierService.additionalCost,
costTaxRate
);
}
if (Object.keys(updateData).length > 0) {
await editObject({
model: this,
id: courierService._id,
updateData,
user,
recalculate: false,
});
}
};
export const courierServiceModel = mongoose.model('courierService', courierServiceSchema);

View File

@ -1,5 +1,8 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
import { taxRateModel } from '../management/taxrate.schema.js';
import { editObject, getObject } from '../../database.js';
import { amountWithTax, resolveTaxRate } from '../../tax.js';
const { Schema } = mongoose;
// Filament base - cost and tax; color and cost override at FilamentSKU
@ -28,6 +31,24 @@ filamentSchema.virtual('id').get(function () {
filamentSchema.set('toJSON', { virtuals: true });
filamentSchema.statics.recalculate = async function (filament, user) {
const costTaxRate = await resolveTaxRate(filament.costTaxRate, getObject, taxRateModel);
const taxUpdateData = {};
if (filament.cost != null) {
taxUpdateData.costWithTax = amountWithTax(filament.cost, costTaxRate);
}
if (Object.keys(taxUpdateData).length > 0) {
await editObject({
model: this,
id: filament._id,
updateData: taxUpdateData,
user,
recalculate: false,
});
Object.assign(filament, taxUpdateData);
}
const filamentSkuModel = mongoose.model('filamentSku');
const skus = await filamentSkuModel.find({ filament: filament._id }).select('_id').lean();
for (const sku of skus) {

View File

@ -1,5 +1,9 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
import { filamentModel } from './filament.schema.js';
import { taxRateModel } from './taxrate.schema.js';
import { editObject, getObject } from '../../database.js';
import { amountWithTax, resolveTaxRate } from '../../tax.js';
const { Schema } = mongoose;
// Define the main filament SKU schema - color and cost live at SKU level
@ -30,6 +34,34 @@ filamentSkuSchema.virtual('id').get(function () {
filamentSkuSchema.set('toJSON', { virtuals: true });
filamentSkuSchema.statics.recalculate = async function (filamentSku, user) {
const parent = await getObject({
model: filamentModel,
id: filamentSku.filament?._id || filamentSku.filament,
cached: true,
});
const taxUpdateData = {};
if (filamentSku.overrideCost) {
const costTaxRate = await resolveTaxRate(filamentSku.costTaxRate, getObject, taxRateModel);
if (filamentSku.cost != null) {
taxUpdateData.costWithTax = amountWithTax(filamentSku.cost, costTaxRate);
}
} else if (parent?.costWithTax != null) {
taxUpdateData.costWithTax = parent.costWithTax;
}
if (Object.keys(taxUpdateData).length > 0) {
await editObject({
model: this,
id: filamentSku._id,
updateData: taxUpdateData,
user,
recalculate: false,
});
Object.assign(filamentSku, taxUpdateData);
}
const orderItemModel = mongoose.model('orderItem');
const skuId = filamentSku._id;
const draftOrderItems = await orderItemModel

View File

@ -1,5 +1,8 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
import { taxRateModel } from '../management/taxrate.schema.js';
import { editObject, getObject } from '../../database.js';
import { amountWithTax, resolveTaxRate } from '../../tax.js';
const { Schema } = mongoose;
// Define the main part schema - cost/price and tax; override at PartSku
@ -32,6 +35,28 @@ partSchema.virtual('id').get(function () {
partSchema.set('toJSON', { virtuals: true });
partSchema.statics.recalculate = async function (part, user) {
const costTaxRate = await resolveTaxRate(part.costTaxRate, getObject, taxRateModel);
const priceTaxRate = await resolveTaxRate(part.priceTaxRate, getObject, taxRateModel);
const taxUpdateData = {};
if (part.cost != null) {
taxUpdateData.costWithTax = amountWithTax(part.cost, costTaxRate);
}
if (part.price != null) {
taxUpdateData.priceWithTax = amountWithTax(part.price, priceTaxRate);
}
if (Object.keys(taxUpdateData).length > 0) {
await editObject({
model: this,
id: part._id,
updateData: taxUpdateData,
user,
recalculate: false,
});
Object.assign(part, taxUpdateData);
}
const partSkuModel = mongoose.model('partSku');
const skus = await partSkuModel.find({ part: part._id }).select('_id').lean();
for (const sku of skus) {

View File

@ -1,5 +1,13 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
import { partModel } from './part.schema.js';
import { taxRateModel } from './taxrate.schema.js';
import { editObject, getObject } from '../../database.js';
import {
amountWithTax,
effectiveMarginPrice,
resolveTaxRate,
} from '../../tax.js';
const { Schema } = mongoose;
// Define the main part SKU schema - pricing lives at SKU level
@ -36,6 +44,54 @@ partSkuSchema.virtual('id').get(function () {
partSkuSchema.set('toJSON', { virtuals: true });
partSkuSchema.statics.recalculate = async function (partSku, user) {
const parent = await getObject({
model: partModel,
id: partSku.part?._id || partSku.part,
cached: true,
});
const taxUpdateData = {};
if (partSku.overrideCost) {
const costTaxRate = await resolveTaxRate(partSku.costTaxRate, getObject, taxRateModel);
if (partSku.cost != null) {
taxUpdateData.costWithTax = amountWithTax(partSku.cost, costTaxRate);
}
} else if (parent?.costWithTax != null) {
taxUpdateData.costWithTax = parent.costWithTax;
}
if (partSku.overridePrice) {
const priceTaxRate = await resolveTaxRate(
partSku.priceTaxRate ?? parent?.priceTaxRate,
getObject,
taxRateModel
);
const cost = partSku.overrideCost ? partSku.cost : parent?.cost;
const price = effectiveMarginPrice({
priceMode: partSku.priceMode ?? parent?.priceMode,
price: partSku.price,
cost,
margin: partSku.margin ?? parent?.margin,
});
if (price != null) {
taxUpdateData.priceWithTax = amountWithTax(price, priceTaxRate);
}
} else if (parent?.priceWithTax != null) {
taxUpdateData.priceWithTax = parent.priceWithTax;
}
if (Object.keys(taxUpdateData).length > 0) {
await editObject({
model: this,
id: partSku._id,
updateData: taxUpdateData,
user,
recalculate: false,
});
Object.assign(partSku, taxUpdateData);
}
const orderItemModel = mongoose.model('orderItem');
const skuId = partSku._id;
const draftOrderItems = await orderItemModel

View File

@ -1,5 +1,8 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
import { taxRateModel } from '../management/taxrate.schema.js';
import { editObject, getObject } from '../../database.js';
import { amountWithTax, resolveTaxRate } from '../../tax.js';
const { Schema } = mongoose;
// Define the main product schema
@ -35,6 +38,34 @@ productSchema.virtual('id').get(function () {
productSchema.set('toJSON', { virtuals: true });
productSchema.statics.recalculate = async function (product, user) {
const costTaxRate = await resolveTaxRate(product.costTaxRate, getObject, taxRateModel);
const priceTaxRate = await resolveTaxRate(product.priceTaxRate, getObject, taxRateModel);
const taxUpdateData = {};
if (product.cost != null) {
taxUpdateData.costWithTax = amountWithTax(product.cost, costTaxRate);
}
if (product.price != null) {
taxUpdateData.priceWithTax = amountWithTax(product.price, priceTaxRate);
}
if (Object.keys(taxUpdateData).length > 0) {
await editObject({
model: this,
id: product._id,
updateData: taxUpdateData,
user,
recalculate: false,
});
Object.assign(product, taxUpdateData);
}
const productSkuModel = mongoose.model('productSku');
const skus = await productSkuModel.find({ product: product._id }).select('_id').lean();
for (const sku of skus) {
await productSkuModel.recalculate(sku, user);
}
const orderItemModel = mongoose.model('orderItem');
const itemId = product._id;
const draftOrderItems = await orderItemModel

View File

@ -1,5 +1,13 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
import { productModel } from './product.schema.js';
import { taxRateModel } from './taxrate.schema.js';
import { editObject, getObject } from '../../database.js';
import {
amountWithTax,
effectiveMarginPrice,
resolveTaxRate,
} from '../../tax.js';
const { Schema } = mongoose;
const partSkuUsageSchema = new Schema({
@ -43,6 +51,54 @@ productSkuSchema.virtual('id').get(function () {
productSkuSchema.set('toJSON', { virtuals: true });
productSkuSchema.statics.recalculate = async function (productSku, user) {
const parent = await getObject({
model: productModel,
id: productSku.product?._id || productSku.product,
cached: true,
});
const taxUpdateData = {};
if (productSku.overrideCost) {
const costTaxRate = await resolveTaxRate(productSku.costTaxRate, getObject, taxRateModel);
if (productSku.cost != null) {
taxUpdateData.costWithTax = amountWithTax(productSku.cost, costTaxRate);
}
} else if (parent?.costWithTax != null) {
taxUpdateData.costWithTax = parent.costWithTax;
}
if (productSku.overridePrice) {
const priceTaxRate = await resolveTaxRate(
productSku.priceTaxRate ?? parent?.priceTaxRate,
getObject,
taxRateModel
);
const cost = productSku.overrideCost ? productSku.cost : parent?.cost;
const price = effectiveMarginPrice({
priceMode: productSku.priceMode ?? parent?.priceMode,
price: productSku.price,
cost,
margin: productSku.margin ?? parent?.margin,
});
if (price != null) {
taxUpdateData.priceWithTax = amountWithTax(price, priceTaxRate);
}
} else if (parent?.priceWithTax != null) {
taxUpdateData.priceWithTax = parent.priceWithTax;
}
if (Object.keys(taxUpdateData).length > 0) {
await editObject({
model: this,
id: productSku._id,
updateData: taxUpdateData,
user,
recalculate: false,
});
Object.assign(productSku, taxUpdateData);
}
const orderItemModel = mongoose.model('orderItem');
const skuId = productSku._id;
const draftOrderItems = await orderItemModel

54
src/database/tax.js Normal file
View File

@ -0,0 +1,54 @@
/**
* Tax calculation helpers mirroring farmcontrol-ui model value functions.
*/
export function isPopulatedTaxRate(taxRate) {
return taxRate != null && taxRate.rateType != null;
}
export async function resolveTaxRate(taxRateRef, getObject, taxRateModel) {
if (!taxRateRef) {
return null;
}
if (isPopulatedTaxRate(taxRateRef)) {
return taxRateRef;
}
const id = taxRateRef._id ?? taxRateRef;
if (!id) {
return null;
}
if (
typeof taxRateRef === 'object' &&
taxRateRef._id &&
Object.keys(taxRateRef).length === 1
) {
return await getObject({ model: taxRateModel, id, cached: true });
}
return await getObject({ model: taxRateModel, id, cached: true });
}
export function amountWithTax(amount, taxRate) {
const base = Number.parseFloat(amount) || 0;
if (!base) {
return 0;
}
if (!taxRate) {
return Number.parseFloat(base.toFixed(2));
}
const rate = Number.parseFloat(taxRate.rate) || 0;
if (taxRate.rateType === 'percentage') {
return Number.parseFloat((base * (1 + rate / 100)).toFixed(2));
}
if (taxRate.rateType === 'amount' || taxRate.rateType === 'fixed') {
return Number.parseFloat((base + rate).toFixed(2));
}
return Number.parseFloat(base.toFixed(2));
}
export function effectiveMarginPrice({ priceMode, price, cost, margin }) {
if (priceMode === 'margin' && margin != null && cost != null) {
return cost * (1 + margin / 100);
}
return price;
}

View File

@ -12,10 +12,6 @@ const listAllowedFilters = [
'state',
'vendor._id',
'client._id',
'from',
'from._id',
'to',
'to._id',
'order',
'order._id',
'orderType',
@ -27,10 +23,6 @@ const listAllowedSorters = ['createdAt', 'state', 'updatedAt', 'invoiceDate', 'd
const propertiesAllowedFilters = [
'vendor',
'client',
'from',
'from._id',
'to',
'to._id',
'orderType',
'order',
'order._id',

View File

@ -21,13 +21,12 @@ const router = express.Router();
const listAllowedFilters = [
'name',
'immediatePay',
'marketplaces.marketplace',
'createdAt',
'updatedAt',
'_reference',
];
const listAllowedSorters = ['name', 'immediatePay', 'createdAt', '_id', 'updatedAt'];
const propertiesAllowedFilters = ['name', 'immediatePay', 'marketplaces.marketplace'];
const propertiesAllowedFilters = ['name', 'immediatePay'];
router.get('/', isAuthenticated, checkPermissions('paymentPolicy', 'list'), async (req, res) => {
const { page, limit, property, search, sortProperty, sortOrder } = req.query;

View File

@ -34,6 +34,7 @@ import {
editMultipleFilamentStocksRouteHandler,
newFilamentStockRouteHandler,
deleteFilamentStockRouteHandler,
postFilamentStockRouteHandler,
listFilamentStocksByPropertiesRouteHandler,
getFilamentStockStatsRouteHandler,
getFilamentStockHistoryRouteHandler,
@ -121,4 +122,8 @@ router.delete('/:id', isAuthenticated, async (req, res) => {
deleteFilamentStockRouteHandler(req, res);
});
router.post('/:id/post', isAuthenticated, checkPermissions('filamentStock', 'post'), async (req, res) => {
postFilamentStockRouteHandler(req, res);
});
export default router;

View File

@ -6,6 +6,8 @@ import { getFilter, convertPropertiesString, getSort } from '../../utils.js';
const router = express.Router();
const listAllowedFilters = [
'part',
'part._id',
'partSku',
'partSku._id',
'state',
@ -16,7 +18,14 @@ const listAllowedFilters = [
'updatedAt',
'_reference',
];
const listAllowedSorters = ['partSku', 'currentQuantity', 'state', 'createdAt', 'updatedAt'];
const listAllowedSorters = [
'part',
'partSku',
'currentQuantity',
'state',
'createdAt',
'updatedAt',
];
const propertiesAllowedFilters = ['part', 'state.type'];
import {
listPartStocksRouteHandler,

View File

@ -6,6 +6,8 @@ import { getFilter, convertPropertiesString, getSort } from '../../utils.js';
const router = express.Router();
const listAllowedFilters = [
'product',
'product._id',
'productSku',
'productSku._id',
'state',
@ -16,8 +18,16 @@ const listAllowedFilters = [
'updatedAt',
'_reference',
];
const listAllowedSorters = ['productSku', 'currentQuantity', 'state', 'createdAt', 'updatedAt'];
const propertiesAllowedFilters = ['productSku', 'state.type'];
const listAllowedSorters = [
'product',
'productSku',
'currentQuantity',
'state',
'createdAt',
'updatedAt',
'stockLocation',
];
const propertiesAllowedFilters = ['product', 'productSku', 'state.type'];
import {
listProductStocksRouteHandler,
getProductStockRouteHandler,

View File

@ -8,6 +8,8 @@ const router = express.Router();
const listAllowedFilters = [
'state',
'state.type',
'fromLocation',
'toLocation',
'postedAt',
'createdAt',
'updatedAt',

View File

@ -15,7 +15,8 @@ const listAllowedFilters = [
'deliveryTime',
'cost',
'costWithTax',
'marketplaces.marketplace',
'additionalCost',
'additionalCostWithTax',
'createdAt',
'updatedAt',
'_reference',
@ -25,8 +26,11 @@ const listAllowedSorters = [
'courier',
'active',
'tracked',
'deliveryTime',
'cost',
'costWithTax',
'additionalCost',
'additionalCostWithTax',
'estimatedDeliveryTime',
'createdAt',
'_id',
@ -42,7 +46,8 @@ const propertiesAllowedFilters = [
'deliveryTime',
'cost',
'costWithTax',
'marketplaces.marketplace',
'additionalCost',
'additionalCostWithTax',
];
import {
listCourierServicesRouteHandler,

View File

@ -11,11 +11,20 @@ const listAllowedFilters = [
'active',
'isGlobal',
'state',
'connection.port',
'createdAt',
'updatedAt',
'_reference',
];
const listAllowedSorters = ['name', 'documentSize', 'connectedAt', 'updatedAt', 'state', 'createdAt'];
const listAllowedSorters = [
'name',
'documentSize',
'connectedAt',
'connection.port',
'updatedAt',
'state',
'createdAt',
];
const propertiesAllowedFilters = ['tags'];
import {
listDocumentPrintersRouteHandler,

View File

@ -11,7 +11,10 @@ const listAllowedFilters = [
'material._id',
'diameter',
'name',
'density',
'emptySpoolWeight',
'cost',
'costWithTax',
'createdAt',
'updatedAt',
'_reference',
@ -21,6 +24,9 @@ const listAllowedSorters = [
'createdAt',
'vendor',
'material',
'diameter',
'density',
'emptySpoolWeight',
'cost',
'costWithTax',
'updatedAt',

View File

@ -5,13 +5,26 @@ import { getFilter, convertPropertiesString, getSort } from '../../utils.js';
const router = express.Router();
const listAllowedFilters = ['product._id', '_id', 'name', 'createdAt', 'updatedAt', '_reference'];
const listAllowedFilters = [
'product._id',
'_id',
'name',
'cost',
'costWithTax',
'price',
'margin',
'priceWithTax',
'createdAt',
'updatedAt',
'_reference',
];
const listAllowedSorters = [
'name',
'priceMode',
'cost',
'costWithTax',
'price',
'margin',
'priceWithTax',
'createdAt',
'updatedAt',
@ -59,24 +72,15 @@ router.get('/properties', checkPermissions('part', 'list'), isAuthenticated, asy
listPartsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
router.get(
'/values',
checkPermissions('part', 'list'),
isAuthenticated,
async (req, res) => {
const { property } = req.query;
const filter = await getFilter(req.query, listAllowedFilters, true);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = await getFilter(
JSON.parse(req.query.masterFilter),
listAllowedFilters,
true
);
}
getPartPropertyValuesRouteHandler(req, res, property, filter, masterFilter);
router.get('/values', checkPermissions('part', 'list'), isAuthenticated, async (req, res) => {
const { property } = req.query;
const filter = await getFilter(req.query, listAllowedFilters, true);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = await getFilter(JSON.parse(req.query.masterFilter), listAllowedFilters, true);
}
);
getPartPropertyValuesRouteHandler(req, res, property, filter, masterFilter);
});
router.get('/search', checkPermissions('part', 'list'), isAuthenticated, async (req, res) => {
const { search } = req.query;

View File

@ -12,7 +12,10 @@ const listAllowedFilters = [
'part._id',
'name',
'cost',
'costWithTax',
'price',
'priceWithTax',
'margin',
'createdAt',
'updatedAt',
'_reference',
@ -25,6 +28,7 @@ const listAllowedSorters = [
'costWithTax',
'price',
'priceWithTax',
'margin',
'createdAt',
'updatedAt',
];

View File

@ -11,6 +11,11 @@ const listAllowedFilters = [
'globalPrice',
'productCategory',
'productCategory._id',
'cost',
'costWithTax',
'price',
'margin',
'priceWithTax',
'createdAt',
'updatedAt',
'_reference',
@ -24,6 +29,7 @@ const listAllowedSorters = [
'cost',
'costWithTax',
'price',
'margin',
'priceWithTax',
'updatedAt',
];

View File

@ -12,7 +12,10 @@ const listAllowedFilters = [
'product._id',
'name',
'cost',
'costWithTax',
'price',
'priceWithTax',
'margin',
'createdAt',
'updatedAt',
'_reference',
@ -25,6 +28,7 @@ const listAllowedSorters = [
'costWithTax',
'price',
'priceWithTax',
'margin',
'createdAt',
'updatedAt',
];

View File

@ -6,7 +6,7 @@ import { getFilter, convertPropertiesString, getSort } from '../../utils.js';
const router = express.Router();
const listAllowedFilters = ['name', 'createdAt', 'updatedAt', '_reference', 'tags'];
const listAllowedSorters = ['name', 'createdAt', '_id', 'updatedAt', 'tags'];
const listAllowedSorters = ['name', 'createdAt', 'updatedAt'];
const propertiesAllowedFilters = ['tags'];
import {

View File

@ -12,7 +12,6 @@ const listAllowedFilters = [
'active',
'country',
'jurisdiction',
'marketplaces.marketplace',
'createdAt',
'updatedAt',
'_reference',

View File

@ -21,12 +21,8 @@ const router = express.Router();
const listAllowedFilters = [
'name',
'handlingTime',
'localPickup',
'globalShipping',
'freightShipping',
'pickupDropOff',
'courierServices',
'marketplaces.marketplace',
'localPickup',
'createdAt',
'updatedAt',
'_reference',
@ -35,9 +31,8 @@ const listAllowedSorters = ['name', 'handlingTime', 'createdAt', '_id', 'updated
const propertiesAllowedFilters = [
'name',
'handlingTime',
'localPickup',
'courierServices',
'marketplaces.marketplace',
'localPickup',
];
router.get('/', isAuthenticated, checkPermissions('fulfillmentPolicy', 'list'), async (req, res) => {

View File

@ -21,6 +21,7 @@ const listAllowedFilters = [
'returnPolicy',
'state',
'state.type',
'condition',
'createdAt',
'updatedAt',
'_reference',
@ -48,6 +49,7 @@ const propertiesAllowedFilters = [
'returnPolicy',
'state',
'state.type',
'condition',
'createdAt',
'updatedAt',
];

View File

@ -24,7 +24,6 @@ const listAllowedFilters = [
'returnPeriodDays',
'returnShippingCostPayer',
'refundMethod',
'marketplaces.marketplace',
'createdAt',
'updatedAt',
'_reference',
@ -42,7 +41,6 @@ const propertiesAllowedFilters = [
'returnsAccepted',
'returnShippingCostPayer',
'refundMethod',
'marketplaces.marketplace',
];
router.get('/', isAuthenticated, checkPermissions('returnPolicy', 'list'), async (req, res) => {

View File

@ -6,6 +6,7 @@ jest.unstable_mockModule('../../../database/database.js', () => ({
listObjects: jest.fn(),
getObject: jest.fn(),
editObject: jest.fn(),
editObject: jest.fn(),
editObjects: jest.fn(),
newObject: jest.fn(),
deleteObject: jest.fn(),
@ -13,6 +14,7 @@ jest.unstable_mockModule('../../../database/database.js', () => ({
getModelStats: jest.fn(),
getModelHistory: jest.fn(),
getObjectNeighbors: jest.fn(),
checkStates: jest.fn(),
aggregateRollups: jest.fn(),
aggregateRollupsHistory: jest.fn(),
}));
@ -41,9 +43,12 @@ const {
listFilamentStocksRouteHandler,
getFilamentStockRouteHandler,
newFilamentStockRouteHandler,
postFilamentStockRouteHandler,
} = await import('../filamentstocks.js');
const { listObjects, getObject, newObject } = await import('../../../database/database.js');
const { listObjects, getObject, newObject, checkStates, editObject } = await import(
'../../../database/database.js'
);
const { filamentStockModel } = await import(
'../../../database/schemas/inventory/filamentstock.schema.js'
);
@ -80,21 +85,79 @@ describe('Filament Stock Service Route Handlers', () => {
});
describe('newFilamentStockRouteHandler', () => {
it('should create a new filament stock', async () => {
it('should create a new draft filament stock without a stock event', async () => {
req.body = {
filament: 'filament123',
startingWeight: { net: 1000, gross: 1100 },
currentWeight: { net: 1000, gross: 1100 },
};
const mockStock = { _id: '456', ...req.body };
const mockStockEvent = { _id: '789' };
newObject.mockResolvedValueOnce(mockStock).mockResolvedValueOnce(mockStockEvent);
const mockStock = { _id: '456', ...req.body, state: { type: 'draft' } };
newObject.mockResolvedValueOnce(mockStock);
await newFilamentStockRouteHandler(req, res);
expect(newObject).toHaveBeenCalledTimes(2);
expect(newObject).toHaveBeenCalledTimes(1);
expect(newObject).toHaveBeenCalledWith(
expect.objectContaining({
newData: expect.objectContaining({ state: { type: 'draft' } }),
})
);
expect(res.send).toHaveBeenCalledWith(mockStock);
});
});
describe('postFilamentStockRouteHandler', () => {
it('should post a draft filament stock and create an initial stock event', async () => {
req.params.id = '507f1f77bcf86cd799439011';
req.user = { _id: 'test-user-id' };
checkStates.mockResolvedValue(true);
getObject.mockResolvedValue({
_id: '507f1f77bcf86cd799439011',
startingWeight: { net: 1000, gross: 1100 },
});
newObject.mockResolvedValue({ _id: '789' });
editObject.mockResolvedValue({
_id: '507f1f77bcf86cd799439011',
state: { type: 'unconsumed' },
postedAt: expect.any(Date),
});
await postFilamentStockRouteHandler(req, res);
expect(checkStates).toHaveBeenCalledWith(
expect.objectContaining({ states: ['draft'] })
);
expect(newObject).toHaveBeenCalledWith(
expect.objectContaining({
newData: expect.objectContaining({
value: 1000,
unit: 'g',
parentType: 'filamentStock',
}),
recalculate: true,
})
);
expect(editObject).toHaveBeenCalledWith(
expect.objectContaining({
updateData: expect.objectContaining({
state: { type: 'unconsumed' },
}),
})
);
expect(res.send).toHaveBeenCalled();
});
it('should fail if filament stock is not in draft state', async () => {
req.params.id = '507f1f77bcf86cd799439011';
checkStates.mockResolvedValue(false);
await postFilamentStockRouteHandler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.send).toHaveBeenCalledWith(
expect.objectContaining({ error: 'Filament stock is not in draft state.' })
);
});
});
});

View File

@ -12,6 +12,7 @@ import {
listObjectsByProperties,
getModelStats,
getModelHistory,
checkStates,
searchObjects,
getPropertyValues,
getObjectNeighbors,
@ -20,6 +21,12 @@ import { stockEventModel } from '../../database/schemas/inventory/stockevent.sch
const logger = log4js.getLogger('Filament Stocks');
logger.level = config.server.logLevel;
const FILAMENT_STOCK_POPULATE = [
{ path: 'filament' },
{ path: 'filamentSku', populate: 'filament' },
{ path: 'stockLocation' },
];
export const listFilamentStocksRouteHandler = async (
req,
res,
@ -130,19 +137,33 @@ export const editFilamentStockRouteHandler = async (req, res) => {
logger.trace(`Filament Stock with ID: ${id}`);
const checkStatesResult = await checkStates({ model: filamentStockModel, id, states: ['draft'] });
if (checkStatesResult.error) {
logger.error('Error checking filament stock states:', checkStatesResult.error);
res.status(checkStatesResult.code).send(checkStatesResult);
return;
}
if (checkStatesResult === false) {
logger.error('Filament stock is not in draft state.');
res.status(400).send({ error: 'Filament stock is not in draft state.', code: 400 });
return;
}
const updateData = {
stockLocation: req.body.stockLocation,
filament: req.body?.filament,
filamentSku: req.body?.filamentSku,
stockLocation: req.body?.stockLocation,
startingWeight: req.body?.startingWeight,
currentWeight: req.body?.currentWeight ?? req.body?.startingWeight,
};
const result = await editObject({
model: filamentStockModel,
id,
updateData,
user: req.user,
populate: [
{ path: 'filament' },
{ path: 'filamentSku', populate: 'filament' },
{ path: 'stockLocation' },
],
populate: FILAMENT_STOCK_POPULATE,
});
if (result.error) {
@ -183,13 +204,14 @@ export const editMultipleFilamentStocksRouteHandler = async (req, res) => {
};
export const newFilamentStockRouteHandler = async (req, res) => {
const startingWeight = req.body.startingWeight;
const newData = {
updatedAt: new Date(),
startingWeight: req.body.startingWeight,
currentWeight: req.body.currentWeight,
startingWeight,
currentWeight: req.body.currentWeight ?? startingWeight,
filament: req.body.filament,
filamentSku: req.body.filamentSku,
state: req.body.state,
state: req.body.state ?? { type: 'draft' },
stockLocation: req.body.stockLocation,
};
const result = await newObject({
@ -204,28 +226,6 @@ export const newFilamentStockRouteHandler = async (req, res) => {
logger.debug(`New filament stock with ID: ${result._id}`);
const netStockEventData = {
updatedAt: new Date(),
value: req.body.startingWeight.net,
owner: req.user,
ownerType: 'user',
parent: result._id,
parentType: 'filamentStock',
unit: 'g',
};
const stockEventResult = await newObject({
model: stockEventModel,
newData: netStockEventData,
user: req.user,
});
if (stockEventResult.error) {
logger.error('No stock event created:', stockEventResult.error);
return res.status(stockEventResult.code).send(stockEventResult);
}
logger.debug(`New stock event with ID: ${stockEventResult._id}`);
res.send(result);
};
@ -235,6 +235,20 @@ export const deleteFilamentStockRouteHandler = async (req, res) => {
logger.trace(`Filament Stock with ID: ${id}`);
const checkStatesResult = await checkStates({ model: filamentStockModel, id, states: ['draft'] });
if (checkStatesResult.error) {
logger.error('Error checking filament stock states:', checkStatesResult.error);
res.status(checkStatesResult.code).send(checkStatesResult);
return;
}
if (checkStatesResult === false) {
logger.error('Filament stock is not in draft state.');
res.status(400).send({ error: 'Filament stock is not in draft state.', code: 400 });
return;
}
const result = await deleteObject({
model: filamentStockModel,
id,
@ -250,6 +264,79 @@ export const deleteFilamentStockRouteHandler = async (req, res) => {
res.send(result);
};
export const postFilamentStockRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
logger.trace(`Filament Stock with ID: ${id}`);
const checkStatesResult = await checkStates({ model: filamentStockModel, id, states: ['draft'] });
if (checkStatesResult.error) {
logger.error('Error checking filament stock states:', checkStatesResult.error);
res.status(checkStatesResult.code).send(checkStatesResult);
return;
}
if (checkStatesResult === false) {
logger.error('Filament stock is not in draft state.');
res.status(400).send({ error: 'Filament stock is not in draft state.', code: 400 });
return;
}
const filamentStock = await getObject({
model: filamentStockModel,
id,
populate: FILAMENT_STOCK_POPULATE,
});
if (filamentStock?.error) {
logger.error('Error loading filament stock to post:', filamentStock.error);
res.status(filamentStock.code || 500).send(filamentStock);
return;
}
const initialStockEventResult = await newObject({
model: stockEventModel,
newData: {
value: filamentStock.startingWeight.net,
unit: 'g',
parent: { _id: id },
parentType: 'filamentStock',
owner: { _id: req.user._id },
ownerType: 'user',
},
recalculate: true,
user: req.user,
});
if (initialStockEventResult?.error) {
logger.error('Error creating initial stock event:', initialStockEventResult.error);
res.status(initialStockEventResult.code || 500).send(initialStockEventResult);
return;
}
const updateData = {
updatedAt: new Date(),
state: { type: 'unconsumed' },
postedAt: new Date(),
currentWeight: filamentStock.startingWeight,
};
const result = await editObject({
model: filamentStockModel,
id,
updateData,
user: req.user,
populate: FILAMENT_STOCK_POPULATE,
});
if (result.error) {
logger.error('Error posting filament stock:', result.error);
res.status(result.code).send(result);
return;
}
logger.debug(`Posted filament stock with ID: ${id}`);
res.send(result);
};
export const getFilamentStockStatsRouteHandler = async (req, res) => {
const result = await getModelStats({ model: filamentStockModel });
if (result?.error) {

View File

@ -51,6 +51,15 @@ const trimSpotlightObject = (object, objectType) => {
online: object.online || undefined,
amount: object.amount || undefined,
unit: object.unit || undefined,
currentWeight: object.currentWeight || undefined,
currentQuantity: object.currentQuantity || undefined,
grandTotalAmount: object.grandTotalAmount || undefined,
totalAmount: object.totalAmount || undefined,
totalAmountWithTax: object.totalAmountWithTax || undefined,
cost: object.cost || undefined,
costWithTax: object.costWithTax || undefined,
price: object.price || undefined,
priceWithTax: object.priceWithTax || undefined,
};
};

View File

@ -198,6 +198,26 @@ function parsePrefixedValue(value) {
return { prefix: null, suffix: trimmed, hadPrefix: false };
}
const FILTER_OPERATOR_PREFIXES = ['<>', '>=', '<=', '>', '<', '='];
function splitLeadingOperator(str) {
const text = String(str).trim();
for (const op of FILTER_OPERATOR_PREFIXES) {
if (text.startsWith(op)) {
return { operator: op, operand: text.slice(op.length).trim() };
}
}
return { operator: '', operand: text };
}
// Strips a model prefix (e.g. GCF:) from an expression, including after a leading operator.
function stripModelPrefixFromExpression(expression) {
const { operator, operand } = splitLeadingOperator(expression);
const { suffix, hadPrefix } = parsePrefixedValue(operand);
if (!hadPrefix) return expression;
return operator + suffix;
}
function buildRegexOp(pattern, useOptions = true) {
const op = { $regex: pattern };
if (useOptions) op.$options = 'i';
@ -242,12 +262,12 @@ function getRefModelEntryFromPrefix(prefix) {
}
function stripPrefixFromOperand(operand) {
const { suffix, hadPrefix } = parsePrefixedValue(String(operand).trim());
return hadPrefix ? suffix : String(operand).trim();
return stripModelPrefixFromExpression(operand);
}
function getRefModelEntryForToken(token, fallbackRefName) {
const { prefix, hadPrefix } = parsePrefixedValue(String(token).trim());
const { operand } = splitLeadingOperator(token);
const { prefix, hadPrefix } = parsePrefixedValue(operand);
if (hadPrefix) {
const entry = getRefModelEntryFromPrefix(prefix);
if (entry) return entry;
@ -741,9 +761,15 @@ function combineAndRefIds(children) {
}
async function resolveRefOperand(operand, refModelEntry, operator = 'eq') {
const lookupOperand = String(operand).trim();
const { prefix, suffix, hadPrefix } = parsePrefixedValue(lookupOperand);
if (hadPrefix) {
const prefixEntry = getRefModelEntryFromPrefix(prefix);
if (prefixEntry) refModelEntry = prefixEntry;
}
if (!refModelEntry?.model) return NO_MATCH_CONDITION;
const lookupOperand = String(operand).trim();
const objectId = extractObjectIdFromOperand(lookupOperand);
if (objectId) {
@ -752,14 +778,16 @@ async function resolveRefOperand(operand, refModelEntry, operator = 'eq') {
return NO_MATCH_CONDITION;
}
const expression = buildRefListExpression(operator, lookupOperand);
const ids = await listRefModelIds(refModelEntry, expression);
const expressionOperand = hadPrefix ? suffix : lookupOperand;
if (operator === 'ne') {
const ids = await listRefModelIds(refModelEntry, expressionOperand);
if (ids.length === 0) return { query: {} };
return { op: { $nin: ids } };
}
const expression = buildRefListExpression(operator, expressionOperand);
const ids = await listRefModelIds(refModelEntry, expression);
return idsToCondition(ids);
}
@ -850,9 +878,8 @@ async function parseFilter(property, value, model = null) {
}
let expression = trimmed;
if (fieldKind.kind === 'default' && expression.charAt(3) === ':') {
const afterColon = value.split(':')[1];
expression = afterColon != null ? afterColon.trim() : '';
if (fieldKind.kind === 'default') {
expression = stripModelPrefixFromExpression(expression);
}
const isDateField = looksLikeDateField(property);