From 4458a1d82866eaf6a3f9147bdef233fe2465c85c Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 8 Mar 2026 01:28:21 +0000 Subject: [PATCH] Implemented materials and export improvements. --- .../schemas/management/filament.schema.js | 2 +- .../schemas/management/material.schema.js | 16 +- src/database/schemas/models.js | 8 + src/routes/management/filaments.js | 8 +- src/routes/management/materials.js | 25 ++- src/services/management/filaments.js | 10 +- src/services/management/materials.js | 196 ++++++++++-------- src/services/misc/csv.js | 89 +------- src/services/misc/excel.js | 89 +------- src/services/misc/export.js | 110 ++++++++++ src/services/misc/odata.js | 51 +---- 11 files changed, 258 insertions(+), 346 deletions(-) create mode 100644 src/services/misc/export.js diff --git a/src/database/schemas/management/filament.schema.js b/src/database/schemas/management/filament.schema.js index 9295a8d..1570a87 100644 --- a/src/database/schemas/management/filament.schema.js +++ b/src/database/schemas/management/filament.schema.js @@ -9,7 +9,7 @@ const filamentSchema = new mongoose.Schema({ barcode: { required: false, type: String }, url: { required: false, type: String }, image: { required: false, type: Buffer }, - type: { required: true, type: String }, + material: { type: Schema.Types.ObjectId, ref: 'material', required: true }, diameter: { required: true, type: Number }, density: { required: true, type: Number }, emptySpoolWeight: { required: true, type: Number }, diff --git a/src/database/schemas/management/material.schema.js b/src/database/schemas/management/material.schema.js index 1f28cf4..1cd6b3a 100644 --- a/src/database/schemas/management/material.schema.js +++ b/src/database/schemas/management/material.schema.js @@ -1,13 +1,15 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; -const materialSchema = new mongoose.Schema({ - _reference: { type: String, default: () => generateId()() }, - name: { required: true, type: String }, - url: { required: false, type: String }, - image: { required: false, type: Buffer }, - tags: [{ type: String }], -}); +const materialSchema = new mongoose.Schema( + { + _reference: { type: String, default: () => generateId()() }, + name: { required: true, type: String }, + url: { required: false, type: String }, + tags: [{ type: String }], + }, + { timestamps: true } +); materialSchema.virtual('id').get(function () { return this._id; diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index 968f7b3..1d7c3ed 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -9,6 +9,7 @@ import { partSkuModel } from './management/partsku.schema.js'; import { productModel } from './management/product.schema.js'; import { productSkuModel } from './management/productsku.schema.js'; import { vendorModel } from './management/vendor.schema.js'; +import { materialModel } from './management/material.schema.js'; import { filamentStockModel } from './inventory/filamentstock.schema.js'; import { purchaseOrderModel } from './inventory/purchaseorder.schema.js'; import { orderItemModel } from './inventory/orderitem.schema.js'; @@ -104,6 +105,13 @@ export const models = { referenceField: '_reference', label: 'Vendor', }, + MAT: { + model: materialModel, + idField: '_id', + type: 'material', + referenceField: '_reference', + label: 'Material', + }, SJB: { model: subJobModel, idField: '_id', diff --git a/src/routes/management/filaments.js b/src/routes/management/filaments.js index 46a7f97..bbe83cf 100644 --- a/src/routes/management/filaments.js +++ b/src/routes/management/filaments.js @@ -20,12 +20,10 @@ router.get('/', isAuthenticated, (req, res) => { const allowedFilters = [ '_id', - 'type', - 'vendor.name', + 'material', + 'material._id', 'diameter', - 'color', 'name', - 'vendor._id', 'cost', ]; @@ -44,7 +42,7 @@ router.get('/', isAuthenticated, (req, res) => { router.get('/properties', isAuthenticated, (req, res) => { let properties = convertPropertiesString(req.query.properties); - const allowedFilters = ['diameter', 'type', 'vendor']; + const allowedFilters = ['diameter', 'material']; const filter = getFilter(req.query, allowedFilters, false); listFilamentsByPropertiesRouteHandler(req, res, properties, filter); }); diff --git a/src/routes/management/materials.js b/src/routes/management/materials.js index c0c45d1..e5e1c94 100644 --- a/src/routes/management/materials.js +++ b/src/routes/management/materials.js @@ -1,10 +1,11 @@ import express from 'express'; import { isAuthenticated } from '../../keycloak.js'; -import { parseFilter } from '../../utils.js'; +import { convertPropertiesString, getFilter, parseFilter } from '../../utils.js'; const router = express.Router(); import { listMaterialsRouteHandler, + listMaterialsByPropertiesRouteHandler, getMaterialRouteHandler, editMaterialRouteHandler, newMaterialRouteHandler, @@ -14,22 +15,26 @@ import { // list of materials 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 = {}; for (const [key, value] of Object.entries(req.query)) { - for (var i = 0; i < allowedFilters.length; i++) { - if (key == allowedFilters[i]) { - const parsedFilter = parseFilter(key, value); - filter = { ...filter, ...parsedFilter }; - } + if (allowedFilters.includes(key)) { + filter = { ...filter, ...parseFilter(key, value) }; } } - 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) => { @@ -50,7 +55,7 @@ router.get('/:id', isAuthenticated, (req, res) => { getMaterialRouteHandler(req, res); }); -// update printer info +// update material info router.put('/:id', isAuthenticated, async (req, res) => { editMaterialRouteHandler(req, res); }); diff --git a/src/services/management/filaments.js b/src/services/management/filaments.js index 247a03d..c15009f 100644 --- a/src/services/management/filaments.js +++ b/src/services/management/filaments.js @@ -36,7 +36,7 @@ export const listFilamentsRouteHandler = async ( search, sort, order, - populate: ['costTaxRate'], + populate: ['costTaxRate', 'material'], }); if (result?.error) { @@ -77,7 +77,7 @@ export const getFilamentRouteHandler = async (req, res) => { const result = await getObject({ model: filamentModel, id, - populate: ['costTaxRate'], + populate: ['costTaxRate', 'material'], }); if (result?.error) { logger.warn(`Filament not found with supplied id.`); @@ -99,7 +99,7 @@ export const editFilamentRouteHandler = async (req, res) => { barcode: req.body.barcode, url: req.body.url, image: req.body.image, - type: req.body.type, + material: req.body.material?._id ?? req.body.material, diameter: req.body.diameter, density: req.body.density, emptySpoolWeight: req.body.emptySpoolWeight, @@ -132,7 +132,7 @@ export const editMultipleFilamentsRouteHandler = async (req, res) => { barcode: update.barcode, url: update.url, image: update.image, - type: update.type, + material: update.material?._id ?? update.material, diameter: update.diameter, density: update.density, emptySpoolWeight: update.emptySpoolWeight, @@ -170,7 +170,7 @@ export const newFilamentRouteHandler = async (req, res) => { barcode: req.body.barcode, url: req.body.url, image: req.body.image, - type: req.body.type, + material: req.body.material?._id ?? req.body.material, diameter: req.body.diameter, density: req.body.density, emptySpoolWeight: req.body.emptySpoolWeight, diff --git a/src/services/management/materials.js b/src/services/management/materials.js index 2609dd2..277c92b 100644 --- a/src/services/management/materials.js +++ b/src/services/management/materials.js @@ -2,7 +2,16 @@ import config from '../../config.js'; import { materialModel } from '../../database/schemas/management/material.schema.js'; import log4js from 'log4js'; 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'); logger.level = config.server.logLevel; @@ -12,118 +21,121 @@ export const listMaterialsRouteHandler = async ( page = 1, limit = 25, 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 = {} ) => { - try { - // Calculate the skip value based on the page number and limit - const skip = (page - 1) * limit; + const result = await listObjectsByProperties({ + model: materialModel, + properties, + filter, + populate: [], + }); - let material; - let aggregateCommand = []; - - if (filter != {}) { - // 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 }); + if (result?.error) { + logger.error('Error listing materials.'); + res.status(result.code).send(result); + return; } + + logger.debug(`List of materials. Count: ${result.length}`); + res.send(result); }; export const getMaterialRouteHandler = async (req, res) => { - try { - // 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.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 }); + const id = req.params.id; + const result = await getObject({ + model: materialModel, + id, + populate: [], + }); + if (result?.error) { + logger.warn(`Material not found with supplied id.`); + return res.status(result.code).send(result); } + logger.debug(`Retrieved material with ID: ${id}`); + res.send(result); }; export const editMaterialRouteHandler = async (req, res) => { - try { - // 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 }); + const id = new mongoose.Types.ObjectId(req.params.id); - if (!material) { - // 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}`); - 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 updateData = req.body; + const result = await editObject({ + model: materialModel, + id, + updateData, + user: req.user, + }); - const result = await materialModel.updateOne({ _id: id }, { $set: updateData }); - if (result.nModified === 0) { - logger.error('No Material updated.'); - res.status(500).send({ error: 'No materials updated.' }); - } - } 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 }); + if (result.error) { + logger.error('Error editing material:', result.error); + res.status(result.code).send(result); + return; } + + logger.debug(`Edited material with ID: ${id}`); + res.send(result); }; export const newMaterialRouteHandler = async (req, res) => { - try { - let { ...newMaterial } = req.body; - newMaterial = { - ...newMaterial, - createdAt: new Date(), - updatedAt: new Date(), - }; + const newData = { + createdAt: new Date(), + updatedAt: new Date(), + name: req.body.name, + url: req.body.url, + tags: req.body.tags, + }; - const result = await materialModel.create(newMaterial); - if (result.nCreated === 0) { - logger.error('No material created.'); - res.status(500).send({ error: 'No material created.' }); - } - res.status(200).send({ status: 'ok' }); - } catch (updateError) { - logger.error('Error updating material:', updateError); - res.status(500).send({ error: updateError.message }); + const result = await newObject({ + model: materialModel, + newData, + user: req.user, + }); + if (result.error) { + logger.error('No material created:', result.error); + return res.status(result.code).send(result); } + + logger.debug(`New material with ID: ${result._id}`); + res.send(result); }; export const getMaterialStatsRouteHandler = async (req, res) => { diff --git a/src/services/misc/csv.js b/src/services/misc/csv.js index 168e58b..2255e46 100644 --- a/src/services/misc/csv.js +++ b/src/services/misc/csv.js @@ -4,98 +4,11 @@ import { getModelByName } from './model.js'; import { listObjectsOData } from '../../database/odata.js'; import { getFilter } from '../../utils.js'; import { generateCsvTable } from '../../database/csv.js'; +import { getModelFilterFields, parseOrderBy, rowToFlat } from './export.js'; const logger = log4js.getLogger('CSV'); 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. * @param {Object} options diff --git a/src/services/misc/excel.js b/src/services/misc/excel.js index e7f91ce..070d082 100644 --- a/src/services/misc/excel.js +++ b/src/services/misc/excel.js @@ -10,98 +10,11 @@ import { incrementExcelTempRequestCount, deleteExcelTempToken, } from '../../database/excel.js'; +import { getModelFilterFields, parseOrderBy, rowToFlat } from './export.js'; const logger = log4js.getLogger('Excel'); 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. * @param {Object} options diff --git a/src/services/misc/export.js b/src/services/misc/export.js new file mode 100644 index 0000000..0440ae5 --- /dev/null +++ b/src/services/misc/export.js @@ -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; +} diff --git a/src/services/misc/odata.js b/src/services/misc/odata.js index 2e1ca09..2d49d22 100644 --- a/src/services/misc/odata.js +++ b/src/services/misc/odata.js @@ -4,6 +4,7 @@ import mongoose from 'mongoose'; import { getModelByName, getAllModels } from './model.js'; import { listObjectsOData } from '../../database/odata.js'; import { getFilter } from '../../utils.js'; +import { getModelFilterFields, parseOrderBy } from './export.js'; const logger = log4js.getLogger('OData'); logger.level = config.server.logLevel; @@ -253,22 +254,6 @@ export const metadataODataRouteHandler = (req, res) => { 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 * Supports OData query options: $top, $skip, $orderby, $count, $select @@ -329,37 +314,3 @@ export const listODataRouteHandler = async (req, res) => { 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]; -}