Implemented materials and export improvements.
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
This commit is contained in:
parent
acd4b375af
commit
4458a1d828
@ -9,7 +9,7 @@ const filamentSchema = new mongoose.Schema({
|
|||||||
barcode: { required: false, type: String },
|
barcode: { required: false, type: String },
|
||||||
url: { required: false, type: String },
|
url: { required: false, type: String },
|
||||||
image: { required: false, type: Buffer },
|
image: { required: false, type: Buffer },
|
||||||
type: { required: true, type: String },
|
material: { type: Schema.Types.ObjectId, ref: 'material', required: true },
|
||||||
diameter: { required: true, type: Number },
|
diameter: { required: true, type: Number },
|
||||||
density: { required: true, type: Number },
|
density: { required: true, type: Number },
|
||||||
emptySpoolWeight: { required: true, type: Number },
|
emptySpoolWeight: { required: true, type: Number },
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
|
|
||||||
const materialSchema = new mongoose.Schema({
|
const materialSchema = new mongoose.Schema(
|
||||||
_reference: { type: String, default: () => generateId()() },
|
{
|
||||||
name: { required: true, type: String },
|
_reference: { type: String, default: () => generateId()() },
|
||||||
url: { required: false, type: String },
|
name: { required: true, type: String },
|
||||||
image: { required: false, type: Buffer },
|
url: { required: false, type: String },
|
||||||
tags: [{ type: String }],
|
tags: [{ type: String }],
|
||||||
});
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
materialSchema.virtual('id').get(function () {
|
materialSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { partSkuModel } from './management/partsku.schema.js';
|
|||||||
import { productModel } from './management/product.schema.js';
|
import { productModel } from './management/product.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 { filamentStockModel } from './inventory/filamentstock.schema.js';
|
import { filamentStockModel } from './inventory/filamentstock.schema.js';
|
||||||
import { purchaseOrderModel } from './inventory/purchaseorder.schema.js';
|
import { purchaseOrderModel } from './inventory/purchaseorder.schema.js';
|
||||||
import { orderItemModel } from './inventory/orderitem.schema.js';
|
import { orderItemModel } from './inventory/orderitem.schema.js';
|
||||||
@ -104,6 +105,13 @@ export const models = {
|
|||||||
referenceField: '_reference',
|
referenceField: '_reference',
|
||||||
label: 'Vendor',
|
label: 'Vendor',
|
||||||
},
|
},
|
||||||
|
MAT: {
|
||||||
|
model: materialModel,
|
||||||
|
idField: '_id',
|
||||||
|
type: 'material',
|
||||||
|
referenceField: '_reference',
|
||||||
|
label: 'Material',
|
||||||
|
},
|
||||||
SJB: {
|
SJB: {
|
||||||
model: subJobModel,
|
model: subJobModel,
|
||||||
idField: '_id',
|
idField: '_id',
|
||||||
|
|||||||
@ -20,12 +20,10 @@ router.get('/', isAuthenticated, (req, res) => {
|
|||||||
|
|
||||||
const allowedFilters = [
|
const allowedFilters = [
|
||||||
'_id',
|
'_id',
|
||||||
'type',
|
'material',
|
||||||
'vendor.name',
|
'material._id',
|
||||||
'diameter',
|
'diameter',
|
||||||
'color',
|
|
||||||
'name',
|
'name',
|
||||||
'vendor._id',
|
|
||||||
'cost',
|
'cost',
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -44,7 +42,7 @@ 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 = ['diameter', 'type', 'vendor'];
|
const allowedFilters = ['diameter', 'material'];
|
||||||
const filter = getFilter(req.query, allowedFilters, false);
|
const filter = getFilter(req.query, allowedFilters, false);
|
||||||
listFilamentsByPropertiesRouteHandler(req, res, properties, filter);
|
listFilamentsByPropertiesRouteHandler(req, res, properties, filter);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { isAuthenticated } from '../../keycloak.js';
|
import { isAuthenticated } from '../../keycloak.js';
|
||||||
import { parseFilter } from '../../utils.js';
|
import { convertPropertiesString, getFilter, parseFilter } from '../../utils.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
import {
|
import {
|
||||||
listMaterialsRouteHandler,
|
listMaterialsRouteHandler,
|
||||||
|
listMaterialsByPropertiesRouteHandler,
|
||||||
getMaterialRouteHandler,
|
getMaterialRouteHandler,
|
||||||
editMaterialRouteHandler,
|
editMaterialRouteHandler,
|
||||||
newMaterialRouteHandler,
|
newMaterialRouteHandler,
|
||||||
@ -14,22 +15,26 @@ import {
|
|||||||
|
|
||||||
// list of materials
|
// list of materials
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
const { page, limit, property } = req.query;
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
|
|
||||||
const allowedFilters = ['type', 'brand', 'diameter', 'color'];
|
const allowedFilters = ['_id', 'name', 'tags'];
|
||||||
|
|
||||||
var filter = {};
|
var filter = {};
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(req.query)) {
|
for (const [key, value] of Object.entries(req.query)) {
|
||||||
for (var i = 0; i < allowedFilters.length; i++) {
|
if (allowedFilters.includes(key)) {
|
||||||
if (key == allowedFilters[i]) {
|
filter = { ...filter, ...parseFilter(key, value) };
|
||||||
const parsedFilter = parseFilter(key, value);
|
|
||||||
filter = { ...filter, ...parsedFilter };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
listMaterialsRouteHandler(req, res, page, limit, property, filter);
|
listMaterialsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
|
let properties = convertPropertiesString(req.query.properties);
|
||||||
|
const allowedFilters = ['name', 'tags'];
|
||||||
|
const filter = getFilter(req.query, allowedFilters, false);
|
||||||
|
listMaterialsByPropertiesRouteHandler(req, res, properties, filter);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', isAuthenticated, (req, res) => {
|
router.post('/', isAuthenticated, (req, res) => {
|
||||||
@ -50,7 +55,7 @@ router.get('/:id', isAuthenticated, (req, res) => {
|
|||||||
getMaterialRouteHandler(req, res);
|
getMaterialRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
// update printer info
|
// update material info
|
||||||
router.put('/:id', isAuthenticated, async (req, res) => {
|
router.put('/:id', isAuthenticated, async (req, res) => {
|
||||||
editMaterialRouteHandler(req, res);
|
editMaterialRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export const listFilamentsRouteHandler = async (
|
|||||||
search,
|
search,
|
||||||
sort,
|
sort,
|
||||||
order,
|
order,
|
||||||
populate: ['costTaxRate'],
|
populate: ['costTaxRate', 'material'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
@ -77,7 +77,7 @@ export const getFilamentRouteHandler = async (req, res) => {
|
|||||||
const result = await getObject({
|
const result = await getObject({
|
||||||
model: filamentModel,
|
model: filamentModel,
|
||||||
id,
|
id,
|
||||||
populate: ['costTaxRate'],
|
populate: ['costTaxRate', 'material'],
|
||||||
});
|
});
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
logger.warn(`Filament not found with supplied id.`);
|
logger.warn(`Filament not found with supplied id.`);
|
||||||
@ -99,7 +99,7 @@ export const editFilamentRouteHandler = async (req, res) => {
|
|||||||
barcode: req.body.barcode,
|
barcode: req.body.barcode,
|
||||||
url: req.body.url,
|
url: req.body.url,
|
||||||
image: req.body.image,
|
image: req.body.image,
|
||||||
type: req.body.type,
|
material: req.body.material?._id ?? req.body.material,
|
||||||
diameter: req.body.diameter,
|
diameter: req.body.diameter,
|
||||||
density: req.body.density,
|
density: req.body.density,
|
||||||
emptySpoolWeight: req.body.emptySpoolWeight,
|
emptySpoolWeight: req.body.emptySpoolWeight,
|
||||||
@ -132,7 +132,7 @@ export const editMultipleFilamentsRouteHandler = async (req, res) => {
|
|||||||
barcode: update.barcode,
|
barcode: update.barcode,
|
||||||
url: update.url,
|
url: update.url,
|
||||||
image: update.image,
|
image: update.image,
|
||||||
type: update.type,
|
material: update.material?._id ?? update.material,
|
||||||
diameter: update.diameter,
|
diameter: update.diameter,
|
||||||
density: update.density,
|
density: update.density,
|
||||||
emptySpoolWeight: update.emptySpoolWeight,
|
emptySpoolWeight: update.emptySpoolWeight,
|
||||||
@ -170,7 +170,7 @@ export const newFilamentRouteHandler = async (req, res) => {
|
|||||||
barcode: req.body.barcode,
|
barcode: req.body.barcode,
|
||||||
url: req.body.url,
|
url: req.body.url,
|
||||||
image: req.body.image,
|
image: req.body.image,
|
||||||
type: req.body.type,
|
material: req.body.material?._id ?? req.body.material,
|
||||||
diameter: req.body.diameter,
|
diameter: req.body.diameter,
|
||||||
density: req.body.density,
|
density: req.body.density,
|
||||||
emptySpoolWeight: req.body.emptySpoolWeight,
|
emptySpoolWeight: req.body.emptySpoolWeight,
|
||||||
|
|||||||
@ -2,7 +2,16 @@ import config from '../../config.js';
|
|||||||
import { materialModel } from '../../database/schemas/management/material.schema.js';
|
import { materialModel } from '../../database/schemas/management/material.schema.js';
|
||||||
import log4js from 'log4js';
|
import log4js from 'log4js';
|
||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { getModelStats, getModelHistory } from '../../database/database.js';
|
import {
|
||||||
|
getObject,
|
||||||
|
listObjects,
|
||||||
|
listObjectsByProperties,
|
||||||
|
editObject,
|
||||||
|
newObject,
|
||||||
|
getModelStats,
|
||||||
|
getModelHistory,
|
||||||
|
} from '../../database/database.js';
|
||||||
|
|
||||||
const logger = log4js.getLogger('Materials');
|
const logger = log4js.getLogger('Materials');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
@ -12,118 +21,121 @@ export const listMaterialsRouteHandler = async (
|
|||||||
page = 1,
|
page = 1,
|
||||||
limit = 25,
|
limit = 25,
|
||||||
property = '',
|
property = '',
|
||||||
|
filter = {},
|
||||||
|
search = '',
|
||||||
|
sort = '',
|
||||||
|
order = 'ascend'
|
||||||
|
) => {
|
||||||
|
const result = await listObjects({
|
||||||
|
model: materialModel,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
property,
|
||||||
|
filter,
|
||||||
|
search,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
populate: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error listing materials.');
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of materials (Page ${page}, Limit ${limit}). Count: ${result.length}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listMaterialsByPropertiesRouteHandler = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
properties = [],
|
||||||
filter = {}
|
filter = {}
|
||||||
) => {
|
) => {
|
||||||
try {
|
const result = await listObjectsByProperties({
|
||||||
// Calculate the skip value based on the page number and limit
|
model: materialModel,
|
||||||
const skip = (page - 1) * limit;
|
properties,
|
||||||
|
filter,
|
||||||
|
populate: [],
|
||||||
|
});
|
||||||
|
|
||||||
let material;
|
if (result?.error) {
|
||||||
let aggregateCommand = [];
|
logger.error('Error listing materials.');
|
||||||
|
res.status(result.code).send(result);
|
||||||
if (filter != {}) {
|
return;
|
||||||
// use filtering if present
|
|
||||||
aggregateCommand.push({ $match: filter });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (property != '') {
|
|
||||||
aggregateCommand.push({ $group: { _id: `$${property}` } }); // group all same properties
|
|
||||||
aggregateCommand.push({ $project: { _id: 0, [property]: '$_id' } }); // rename _id to the property name
|
|
||||||
} else {
|
|
||||||
aggregateCommand.push({ $project: { image: 0, url: 0 } });
|
|
||||||
}
|
|
||||||
|
|
||||||
aggregateCommand.push({ $skip: skip });
|
|
||||||
aggregateCommand.push({ $limit: Number(limit) });
|
|
||||||
|
|
||||||
material = await materialModel.aggregate(aggregateCommand);
|
|
||||||
|
|
||||||
logger.trace(
|
|
||||||
`List of materials (Page ${page}, Limit ${limit}, Property ${property}):`,
|
|
||||||
material
|
|
||||||
);
|
|
||||||
res.send(material);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error listing materials:', error);
|
|
||||||
res.status(500).send({ error: error });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of materials. Count: ${result.length}`);
|
||||||
|
res.send(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMaterialRouteHandler = async (req, res) => {
|
export const getMaterialRouteHandler = async (req, res) => {
|
||||||
try {
|
const id = req.params.id;
|
||||||
// Get ID from params
|
const result = await getObject({
|
||||||
const id = new mongoose.Types.ObjectId(req.params.id);
|
model: materialModel,
|
||||||
// Fetch the material with the given remote address
|
id,
|
||||||
const material = await materialModel.findOne({
|
populate: [],
|
||||||
_id: id,
|
});
|
||||||
});
|
if (result?.error) {
|
||||||
|
logger.warn(`Material not found with supplied id.`);
|
||||||
if (!material) {
|
return res.status(result.code).send(result);
|
||||||
logger.warn(`Material not found with supplied id.`);
|
|
||||||
return res.status(404).send({ error: 'Print job not found.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.trace(`Material with ID: ${id}:`, material);
|
|
||||||
res.send(material);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error fetching Material:', error);
|
|
||||||
res.status(500).send({ error: error.message });
|
|
||||||
}
|
}
|
||||||
|
logger.debug(`Retrieved material with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const editMaterialRouteHandler = async (req, res) => {
|
export const editMaterialRouteHandler = async (req, res) => {
|
||||||
try {
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
// Get ID from params
|
|
||||||
const id = new mongoose.Types.ObjectId(req.params.id);
|
|
||||||
// Fetch the material with the given remote address
|
|
||||||
const material = await materialModel.findOne({ _id: id });
|
|
||||||
|
|
||||||
if (!material) {
|
logger.trace(`Material with ID: ${id}`);
|
||||||
// Error handling
|
|
||||||
logger.warn(`Material not found with supplied id.`);
|
|
||||||
return res.status(404).send({ error: 'Print job not found.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.trace(`Material with ID: ${id}:`, material);
|
const updateData = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
name: req.body.name,
|
||||||
|
url: req.body.url,
|
||||||
|
tags: req.body.tags,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
const result = await editObject({
|
||||||
const updateData = req.body;
|
model: materialModel,
|
||||||
|
id,
|
||||||
|
updateData,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await materialModel.updateOne({ _id: id }, { $set: updateData });
|
if (result.error) {
|
||||||
if (result.nModified === 0) {
|
logger.error('Error editing material:', result.error);
|
||||||
logger.error('No Material updated.');
|
res.status(result.code).send(result);
|
||||||
res.status(500).send({ error: 'No materials updated.' });
|
return;
|
||||||
}
|
|
||||||
} catch (updateError) {
|
|
||||||
logger.error('Error updating material:', updateError);
|
|
||||||
res.status(500).send({ error: updateError.message });
|
|
||||||
}
|
|
||||||
res.send('OK');
|
|
||||||
} catch (fetchError) {
|
|
||||||
logger.error('Error fetching material:', fetchError);
|
|
||||||
res.status(500).send({ error: fetchError.message });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(`Edited material with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const newMaterialRouteHandler = async (req, res) => {
|
export const newMaterialRouteHandler = async (req, res) => {
|
||||||
try {
|
const newData = {
|
||||||
let { ...newMaterial } = req.body;
|
createdAt: new Date(),
|
||||||
newMaterial = {
|
updatedAt: new Date(),
|
||||||
...newMaterial,
|
name: req.body.name,
|
||||||
createdAt: new Date(),
|
url: req.body.url,
|
||||||
updatedAt: new Date(),
|
tags: req.body.tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await materialModel.create(newMaterial);
|
const result = await newObject({
|
||||||
if (result.nCreated === 0) {
|
model: materialModel,
|
||||||
logger.error('No material created.');
|
newData,
|
||||||
res.status(500).send({ error: 'No material created.' });
|
user: req.user,
|
||||||
}
|
});
|
||||||
res.status(200).send({ status: 'ok' });
|
if (result.error) {
|
||||||
} catch (updateError) {
|
logger.error('No material created:', result.error);
|
||||||
logger.error('Error updating material:', updateError);
|
return res.status(result.code).send(result);
|
||||||
res.status(500).send({ error: updateError.message });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(`New material with ID: ${result._id}`);
|
||||||
|
res.send(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMaterialStatsRouteHandler = async (req, res) => {
|
export const getMaterialStatsRouteHandler = async (req, res) => {
|
||||||
|
|||||||
@ -4,98 +4,11 @@ import { getModelByName } from './model.js';
|
|||||||
import { listObjectsOData } from '../../database/odata.js';
|
import { listObjectsOData } from '../../database/odata.js';
|
||||||
import { getFilter } from '../../utils.js';
|
import { getFilter } from '../../utils.js';
|
||||||
import { generateCsvTable } from '../../database/csv.js';
|
import { generateCsvTable } from '../../database/csv.js';
|
||||||
|
import { getModelFilterFields, parseOrderBy, rowToFlat } from './export.js';
|
||||||
|
|
||||||
const logger = log4js.getLogger('CSV');
|
const logger = log4js.getLogger('CSV');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
/**
|
|
||||||
* Flatten nested objects for CSV display.
|
|
||||||
* Objects become "key.subkey: value" or JSON string; arrays become comma-separated.
|
|
||||||
*/
|
|
||||||
function flattenForCsv(obj, prefix = '') {
|
|
||||||
if (obj === null || obj === undefined) return {};
|
|
||||||
if (typeof obj !== 'object') return { [prefix]: obj };
|
|
||||||
if (obj instanceof Date) return { [prefix]: obj };
|
|
||||||
if (Array.isArray(obj)) {
|
|
||||||
const str = obj
|
|
||||||
.map((v) => (v && typeof v === 'object' && !(v instanceof Date) ? JSON.stringify(v) : v))
|
|
||||||
.join(', ');
|
|
||||||
return { [prefix]: str };
|
|
||||||
}
|
|
||||||
const result = {};
|
|
||||||
for (const [k, v] of Object.entries(obj)) {
|
|
||||||
const key = prefix ? `${prefix}.${k}` : k;
|
|
||||||
if (v !== null && typeof v === 'object' && !(v instanceof Date) && !Array.isArray(v)) {
|
|
||||||
Object.assign(result, flattenForCsv(v, key));
|
|
||||||
} else {
|
|
||||||
result[key] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a row to flat key-value for CSV. Nested objects are flattened.
|
|
||||||
*/
|
|
||||||
function rowToFlat(row) {
|
|
||||||
const flat = {};
|
|
||||||
for (const [key, val] of Object.entries(row)) {
|
|
||||||
if (key.startsWith('@')) continue;
|
|
||||||
if (val !== null && typeof val === 'object' && !(val instanceof Date) && !Array.isArray(val)) {
|
|
||||||
Object.assign(flat, flattenForCsv(val, key));
|
|
||||||
} else {
|
|
||||||
flat[key] = val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flat;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get allowed filter fields for CSV export (reuse OData logic).
|
|
||||||
*/
|
|
||||||
function getModelFilterFields(objectType) {
|
|
||||||
const base = ['_id'];
|
|
||||||
const byType = {
|
|
||||||
note: ['parent._id', 'noteType', 'user'],
|
|
||||||
notification: ['user'],
|
|
||||||
userNotifier: ['user', 'object', 'objectType'],
|
|
||||||
printer: ['host'],
|
|
||||||
job: ['printer', 'gcodeFile'],
|
|
||||||
subJob: ['job'],
|
|
||||||
filamentStock: ['filamentSku'],
|
|
||||||
filamentSku: ['filament', 'vendor', 'costTaxRate'],
|
|
||||||
partStock: ['partSku'],
|
|
||||||
partSku: ['part', 'vendor', 'priceTaxRate', 'costTaxRate'],
|
|
||||||
productStock: ['productSku'],
|
|
||||||
productSku: ['product', 'vendor', 'priceTaxRate', 'costTaxRate'],
|
|
||||||
purchaseOrder: ['vendor'],
|
|
||||||
orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'sku._id', 'shipment._id'],
|
|
||||||
shipment: ['order._id', 'orderType', 'courierService._id'],
|
|
||||||
stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
|
|
||||||
stockAudit: ['filamentStock._id', 'partStock._id'],
|
|
||||||
documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'],
|
|
||||||
documentTemplate: ['parent._id', 'documentSize._id'],
|
|
||||||
salesOrder: ['client'],
|
|
||||||
invoice: ['to._id', 'from._id', 'order._id', 'orderType'],
|
|
||||||
auditLog: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
|
|
||||||
appPassword: ['name', 'user', 'active'],
|
|
||||||
};
|
|
||||||
const extra = byType[objectType] || [];
|
|
||||||
return [...base, ...extra];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOrderBy(orderby) {
|
|
||||||
if (!orderby || typeof orderby !== 'string') {
|
|
||||||
return { sort: 'createdAt', order: 'ascend' };
|
|
||||||
}
|
|
||||||
const trimmed = orderby.trim();
|
|
||||||
const parts = trimmed.split(/\s+/);
|
|
||||||
const sort = parts[0] || 'createdAt';
|
|
||||||
const dir = (parts[1] || 'asc').toLowerCase();
|
|
||||||
const order = dir === 'desc' ? 'descend' : 'ascend';
|
|
||||||
return { sort, order };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate CSV file for the given object type.
|
* Generate CSV file for the given object type.
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
|
|||||||
@ -10,98 +10,11 @@ import {
|
|||||||
incrementExcelTempRequestCount,
|
incrementExcelTempRequestCount,
|
||||||
deleteExcelTempToken,
|
deleteExcelTempToken,
|
||||||
} from '../../database/excel.js';
|
} from '../../database/excel.js';
|
||||||
|
import { getModelFilterFields, parseOrderBy, rowToFlat } from './export.js';
|
||||||
|
|
||||||
const logger = log4js.getLogger('Excel');
|
const logger = log4js.getLogger('Excel');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
/**
|
|
||||||
* Flatten nested objects for Excel display.
|
|
||||||
* Objects become "key.subkey: value" or JSON string; arrays become comma-separated.
|
|
||||||
*/
|
|
||||||
function flattenForExcel(obj, prefix = '') {
|
|
||||||
if (obj === null || obj === undefined) return {};
|
|
||||||
if (typeof obj !== 'object') return { [prefix]: obj };
|
|
||||||
if (obj instanceof Date) return { [prefix]: obj };
|
|
||||||
if (Array.isArray(obj)) {
|
|
||||||
const str = obj
|
|
||||||
.map((v) => (v && typeof v === 'object' && !(v instanceof Date) ? JSON.stringify(v) : v))
|
|
||||||
.join(', ');
|
|
||||||
return { [prefix]: str };
|
|
||||||
}
|
|
||||||
const result = {};
|
|
||||||
for (const [k, v] of Object.entries(obj)) {
|
|
||||||
const key = prefix ? `${prefix}.${k}` : k;
|
|
||||||
if (v !== null && typeof v === 'object' && !(v instanceof Date) && !Array.isArray(v)) {
|
|
||||||
Object.assign(result, flattenForExcel(v, key));
|
|
||||||
} else {
|
|
||||||
result[key] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a row to flat key-value for Excel. Nested objects are flattened.
|
|
||||||
*/
|
|
||||||
function rowToFlat(row) {
|
|
||||||
const flat = {};
|
|
||||||
for (const [key, val] of Object.entries(row)) {
|
|
||||||
if (key.startsWith('@')) continue;
|
|
||||||
if (val !== null && typeof val === 'object' && !(val instanceof Date) && !Array.isArray(val)) {
|
|
||||||
Object.assign(flat, flattenForExcel(val, key));
|
|
||||||
} else {
|
|
||||||
flat[key] = val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flat;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get allowed filter fields for Excel export (reuse OData logic).
|
|
||||||
*/
|
|
||||||
function getModelFilterFields(objectType) {
|
|
||||||
const base = ['_id'];
|
|
||||||
const byType = {
|
|
||||||
note: ['parent._id', 'noteType', 'user'],
|
|
||||||
notification: ['user'],
|
|
||||||
userNotifier: ['user', 'object', 'objectType'],
|
|
||||||
printer: ['host'],
|
|
||||||
job: ['printer', 'gcodeFile'],
|
|
||||||
subJob: ['job'],
|
|
||||||
filamentStock: ['filamentSku'],
|
|
||||||
filamentSku: ['filament', 'vendor', 'costTaxRate'],
|
|
||||||
partStock: ['partSku'],
|
|
||||||
partSku: ['part', 'vendor', 'priceTaxRate', 'costTaxRate'],
|
|
||||||
productStock: ['productSku'],
|
|
||||||
productSku: ['product', 'vendor', 'priceTaxRate', 'costTaxRate'],
|
|
||||||
purchaseOrder: ['vendor'],
|
|
||||||
orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'sku._id', 'shipment._id'],
|
|
||||||
shipment: ['order._id', 'orderType', 'courierService._id'],
|
|
||||||
stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
|
|
||||||
stockAudit: ['filamentStock._id', 'partStock._id'],
|
|
||||||
documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'],
|
|
||||||
documentTemplate: ['parent._id', 'documentSize._id'],
|
|
||||||
salesOrder: ['client'],
|
|
||||||
invoice: ['to._id', 'from._id', 'order._id', 'orderType'],
|
|
||||||
auditLog: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
|
|
||||||
appPassword: ['name', 'user', 'active'],
|
|
||||||
};
|
|
||||||
const extra = byType[objectType] || [];
|
|
||||||
return [...base, ...extra];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOrderBy(orderby) {
|
|
||||||
if (!orderby || typeof orderby !== 'string') {
|
|
||||||
return { sort: 'createdAt', order: 'ascend' };
|
|
||||||
}
|
|
||||||
const trimmed = orderby.trim();
|
|
||||||
const parts = trimmed.split(/\s+/);
|
|
||||||
const sort = parts[0] || 'createdAt';
|
|
||||||
const dir = (parts[1] || 'asc').toLowerCase();
|
|
||||||
const order = dir === 'desc' ? 'descend' : 'ascend';
|
|
||||||
return { sort, order };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate Excel file for the given object type.
|
* Generate Excel file for the given object type.
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
|
|||||||
110
src/services/misc/export.js
Normal file
110
src/services/misc/export.js
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Shared export utilities for OData, CSV, and Excel.
|
||||||
|
* Centralizes filter fields, order-by parsing, and row flattening.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Allowed filter fields per object type for OData/CSV/Excel exports */
|
||||||
|
export const EXPORT_FILTER_BY_TYPE = {
|
||||||
|
note: ['parent._id', 'noteType', 'user'],
|
||||||
|
notification: ['user'],
|
||||||
|
userNotifier: ['user', 'object', 'objectType'],
|
||||||
|
printer: ['host'],
|
||||||
|
job: ['printer', 'gcodeFile'],
|
||||||
|
subJob: ['job'],
|
||||||
|
filamentStock: ['filamentSku'],
|
||||||
|
filament: ['material', 'material._id', 'name', 'diameter', 'cost'],
|
||||||
|
filamentSku: ['filament', 'vendor', 'costTaxRate'],
|
||||||
|
material: ['name', 'tags'],
|
||||||
|
partStock: ['partSku'],
|
||||||
|
partSku: ['part', 'vendor', 'priceTaxRate', 'costTaxRate'],
|
||||||
|
productStock: ['productSku'],
|
||||||
|
productSku: ['product', 'vendor', 'priceTaxRate', 'costTaxRate'],
|
||||||
|
purchaseOrder: ['vendor'],
|
||||||
|
orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'sku._id', 'shipment._id'],
|
||||||
|
shipment: ['order._id', 'orderType', 'courierService._id'],
|
||||||
|
stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
|
||||||
|
stockAudit: ['filamentStock._id', 'partStock._id'],
|
||||||
|
documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'],
|
||||||
|
documentTemplate: ['parent._id', 'documentSize._id'],
|
||||||
|
salesOrder: ['client'],
|
||||||
|
invoice: ['to._id', 'from._id', 'order._id', 'orderType'],
|
||||||
|
auditLog: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
|
||||||
|
appPassword: ['name', 'user', 'active'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get allowed filter fields for a given object type.
|
||||||
|
* @param {string} objectType - Model type (e.g. 'filament', 'material')
|
||||||
|
* @returns {string[]} Allowed filter field names
|
||||||
|
*/
|
||||||
|
export function getModelFilterFields(objectType) {
|
||||||
|
const base = ['_id'];
|
||||||
|
const extra = EXPORT_FILTER_BY_TYPE[objectType] || [];
|
||||||
|
return [...base, ...extra];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse OData $orderby or orderby string into sort and order.
|
||||||
|
* Supports "field asc", "field desc", or just "field" (defaults asc).
|
||||||
|
* @param {string} [orderby] - Orderby string (e.g. "createdAt desc")
|
||||||
|
* @returns {{ sort: string, order: 'ascend'|'descend' }}
|
||||||
|
*/
|
||||||
|
export function parseOrderBy(orderby) {
|
||||||
|
if (!orderby || typeof orderby !== 'string') {
|
||||||
|
return { sort: 'createdAt', order: 'ascend' };
|
||||||
|
}
|
||||||
|
const trimmed = orderby.trim();
|
||||||
|
const parts = trimmed.split(/\s+/);
|
||||||
|
const sort = parts[0] || 'createdAt';
|
||||||
|
const dir = (parts[1] || 'asc').toLowerCase();
|
||||||
|
const order = dir === 'desc' ? 'descend' : 'ascend';
|
||||||
|
return { sort, order };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten nested objects for export display.
|
||||||
|
* Objects become "key.subkey: value"; arrays become comma-separated strings.
|
||||||
|
* @param {*} obj - Value to flatten
|
||||||
|
* @param {string} [prefix=''] - Key prefix for nested values
|
||||||
|
* @returns {Object} Flat key-value object
|
||||||
|
*/
|
||||||
|
export function flattenForExport(obj, prefix = '') {
|
||||||
|
if (obj === null || obj === undefined) return {};
|
||||||
|
if (typeof obj !== 'object') return { [prefix]: obj };
|
||||||
|
if (obj instanceof Date) return { [prefix]: obj };
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
const str = obj
|
||||||
|
.map((v) => (v && typeof v === 'object' && !(v instanceof Date) ? JSON.stringify(v) : v))
|
||||||
|
.join(', ');
|
||||||
|
return { [prefix]: str };
|
||||||
|
}
|
||||||
|
const result = {};
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
const key = prefix ? `${prefix}.${k}` : k;
|
||||||
|
if (v !== null && typeof v === 'object' && !(v instanceof Date) && !Array.isArray(v)) {
|
||||||
|
Object.assign(result, flattenForExport(v, key));
|
||||||
|
} else {
|
||||||
|
result[key] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a row (e.g. OData value item) to flat key-value for CSV/Excel.
|
||||||
|
* Nested objects are flattened; @odata.* keys are skipped.
|
||||||
|
* @param {Object} row - Row object
|
||||||
|
* @returns {Object} Flat key-value object
|
||||||
|
*/
|
||||||
|
export function rowToFlat(row) {
|
||||||
|
const flat = {};
|
||||||
|
for (const [key, val] of Object.entries(row)) {
|
||||||
|
if (key.startsWith('@')) continue;
|
||||||
|
if (val !== null && typeof val === 'object' && !(val instanceof Date) && !Array.isArray(val)) {
|
||||||
|
Object.assign(flat, flattenForExport(val, key));
|
||||||
|
} else {
|
||||||
|
flat[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flat;
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import mongoose from 'mongoose';
|
|||||||
import { getModelByName, getAllModels } from './model.js';
|
import { getModelByName, getAllModels } from './model.js';
|
||||||
import { listObjectsOData } from '../../database/odata.js';
|
import { listObjectsOData } from '../../database/odata.js';
|
||||||
import { getFilter } from '../../utils.js';
|
import { getFilter } from '../../utils.js';
|
||||||
|
import { getModelFilterFields, parseOrderBy } from './export.js';
|
||||||
|
|
||||||
const logger = log4js.getLogger('OData');
|
const logger = log4js.getLogger('OData');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
@ -253,22 +254,6 @@ export const metadataODataRouteHandler = (req, res) => {
|
|||||||
res.send(xml);
|
res.send(xml);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse OData $orderby into sort and order.
|
|
||||||
* Supports "field asc", "field desc", or just "field" (defaults asc).
|
|
||||||
*/
|
|
||||||
function parseOrderBy(orderby) {
|
|
||||||
if (!orderby || typeof orderby !== 'string') {
|
|
||||||
return { sort: 'createdAt', order: 'ascend' };
|
|
||||||
}
|
|
||||||
const trimmed = orderby.trim();
|
|
||||||
const parts = trimmed.split(/\s+/);
|
|
||||||
const sort = parts[0] || 'createdAt';
|
|
||||||
const dir = (parts[1] || 'asc').toLowerCase();
|
|
||||||
const order = dir === 'desc' ? 'descend' : 'ascend';
|
|
||||||
return { sort, order };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route handler for GET /odata/:objectType
|
* Route handler for GET /odata/:objectType
|
||||||
* Supports OData query options: $top, $skip, $orderby, $count, $select
|
* Supports OData query options: $top, $skip, $orderby, $count, $select
|
||||||
@ -329,37 +314,3 @@ export const listODataRouteHandler = async (req, res) => {
|
|||||||
res.send(result);
|
res.send(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Return allowed filter fields for a given object type.
|
|
||||||
* Extends a base set with type-specific fields.
|
|
||||||
*/
|
|
||||||
function getModelFilterFields(objectType) {
|
|
||||||
const base = ['_id'];
|
|
||||||
const byType = {
|
|
||||||
note: ['parent._id', 'noteType', 'user'],
|
|
||||||
notification: ['user'],
|
|
||||||
userNotifier: ['user', 'object', 'objectType'],
|
|
||||||
printer: ['host'],
|
|
||||||
job: ['printer', 'gcodeFile'],
|
|
||||||
subJob: ['job'],
|
|
||||||
filamentStock: ['filamentSku'],
|
|
||||||
filamentSku: ['filament', 'vendor', 'costTaxRate'],
|
|
||||||
partStock: ['partSku'],
|
|
||||||
partSku: ['part', 'vendor', 'priceTaxRate', 'costTaxRate'],
|
|
||||||
productStock: ['productSku'],
|
|
||||||
productSku: ['product', 'vendor', 'priceTaxRate', 'costTaxRate'],
|
|
||||||
purchaseOrder: ['vendor'],
|
|
||||||
orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'sku._id', 'shipment._id'],
|
|
||||||
shipment: ['order._id', 'orderType', 'courierService._id'],
|
|
||||||
stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
|
|
||||||
stockAudit: ['filamentStock._id', 'partStock._id'],
|
|
||||||
documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'],
|
|
||||||
documentTemplate: ['parent._id', 'documentSize._id'],
|
|
||||||
salesOrder: ['client'],
|
|
||||||
invoice: ['to._id', 'from._id', 'order._id', 'orderType'],
|
|
||||||
auditLog: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
|
|
||||||
appPassword: ['name', 'user', 'active'],
|
|
||||||
};
|
|
||||||
const extra = byType[objectType] || [];
|
|
||||||
return [...base, ...extra];
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user