Compare commits
No commits in common. "main" and "config-updates" have entirely different histories.
main
...
config-upd
11
.env.example
11
.env.example
@ -1,2 +1,9 @@
|
||||
SESSION_SECRET='SECRET'
|
||||
KEYCLOAK_CLIENT_SECRET='SECRET'
|
||||
DB_LINK="mongo-link-to-connect"
|
||||
|
||||
JWT_SECRET="token"
|
||||
|
||||
APP_URL_CLIENT=https://material-dashboard-react-node.creative-tim.com
|
||||
APP_URL_API=https://node-json-api-free.creative-tim.com/login
|
||||
|
||||
MAILTRAP_USER=
|
||||
MAILTRAP_PASSWORD=
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -139,9 +139,4 @@ dist
|
||||
gocdefile/*
|
||||
gcodefile
|
||||
gcodefiles/*
|
||||
gcodefiles
|
||||
|
||||
test-results.xml
|
||||
|
||||
# Jenkins generated build metadata
|
||||
src/buildInfo.json
|
||||
gcodefiles
|
||||
132
Jenkinsfile
vendored
132
Jenkinsfile
vendored
@ -1,132 +0,0 @@
|
||||
properties([
|
||||
buildDiscarder(logRotator(numToKeepStr: '25'))
|
||||
])
|
||||
|
||||
pipeline {
|
||||
agent {
|
||||
label 'ubuntu'
|
||||
}
|
||||
|
||||
environment {
|
||||
NODE_ENV = 'production'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
stage('Setup Node.js') {
|
||||
steps {
|
||||
nodejs(nodeJSInstallationName: 'Node23') {
|
||||
sh 'node -v'
|
||||
sh 'pnpm -v'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Write Build Metadata') {
|
||||
steps {
|
||||
nodejs(nodeJSInstallationName: 'Node23') {
|
||||
sh '''
|
||||
node -e "const fs = require('fs'); fs.writeFileSync('src/buildInfo.json', JSON.stringify({ buildNumber: process.env.BUILD_NUMBER || 'dev' }, null, 2) + '\\n');"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Install Dependencies') {
|
||||
steps {
|
||||
nodejs(nodeJSInstallationName: 'Node23') {
|
||||
sh 'pnpm install --frozen-lockfile --production=false'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Run Tests') {
|
||||
steps {
|
||||
nodejs(nodeJSInstallationName: 'Node23') {
|
||||
sh '''
|
||||
export NODE_ENV=test
|
||||
export SESSION_SECRET=test-session-secret-for-testing-only
|
||||
pnpm test
|
||||
'''
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit 'test-results.xml'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy via SSH') {
|
||||
steps {
|
||||
sshPublisher(publishers: [
|
||||
sshPublisherDesc(
|
||||
configName: 'farmcontrol.tombutcher.local',
|
||||
transfers: [
|
||||
sshTransfer(
|
||||
cleanRemote: false,
|
||||
excludes: '*.*',
|
||||
execCommand: '''
|
||||
if [ -e /home/farmcontrol/farmcontrol-api/src ]; then
|
||||
rm -rf /home/farmcontrol/farmcontrol-api/src
|
||||
fi
|
||||
if [ -e /home/farmcontrol/farmcontrol-api/package.json ]; then
|
||||
rm -rf /home/farmcontrol/farmcontrol-api/package.json
|
||||
fi
|
||||
if [ -e /home/farmcontrol/farmcontrol-api/package-lock.json ]; then
|
||||
rm -rf /home/farmcontrol/farmcontrol-api/package-lock.json
|
||||
fi
|
||||
if [ -e /home/farmcontrol/farmcontrol-api/node_modules ]; then
|
||||
rm -rf /home/farmcontrol/farmcontrol-api/node_modules
|
||||
fi
|
||||
''',
|
||||
execTimeout: 120000,
|
||||
flatten: true,
|
||||
makeEmptyDirs: true,
|
||||
noDefaultExcludes: false,
|
||||
patternSeparator: '[, ]+',
|
||||
remoteDirectory: 'farmcontrol-api',
|
||||
remoteDirectorySDF: false,
|
||||
removePrefix: '',
|
||||
sourceFiles: ''
|
||||
),
|
||||
sshTransfer(
|
||||
cleanRemote: false,
|
||||
excludes: 'node_modules/**',
|
||||
execCommand: '''
|
||||
cd /home/farmcontrol/farmcontrol-api
|
||||
pnpm install --production
|
||||
sudo systemctl restart farmcontrol-api
|
||||
''',
|
||||
execTimeout: 120000,
|
||||
flatten: false,
|
||||
makeEmptyDirs: true,
|
||||
noDefaultExcludes: false,
|
||||
patternSeparator: '[, ]+',
|
||||
remoteDirectory: 'farmcontrol-api',
|
||||
remoteDirectorySDF: false,
|
||||
removePrefix: '',
|
||||
sourceFiles: '**/*'
|
||||
)
|
||||
],
|
||||
usePromotionTimestamp: false,
|
||||
useWorkspaceInPromotion: false,
|
||||
verbose: true
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
cleanWs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
# FarmControl API
|
||||
|
||||
[](https://ci.tombutcher.work/job/farmcontrol/job/farmcontrol-api/job/main/)
|
||||
|
||||
A comprehensive REST API for managing 3D printing farms, inventory, and production workflows. Built with Node.js, Express, and MongoDB, featuring authentication with Keycloak and comprehensive logging.
|
||||
|
||||
## 🚀 Features
|
||||
@ -155,7 +153,7 @@ The application uses Keycloak for authentication. Configure your Keycloak server
|
||||
The application uses MongoDB. Ensure your MongoDB instance is running and accessible at the configured `DB_LINK`.
|
||||
|
||||
### CORS Configuration
|
||||
Allowed request origins are configured via `server.corsOrigins` in `config.json`. When omitted, the API falls back to `app.urlClient` and `app.urlElectronClient`.
|
||||
The application is configured to allow requests only from the specified `APP_URL_CLIENT`. Update this in your environment configuration.
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
|
||||
plugins: ['transform-import-meta'],
|
||||
};
|
||||
119
config.json
119
config.json
@ -2,31 +2,22 @@
|
||||
"development": {
|
||||
"server": {
|
||||
"port": 8787,
|
||||
"logLevel": "debug",
|
||||
"corsOrigins": [
|
||||
"https://web.farmcontrol.app",
|
||||
"https://dev.tombutcher.work",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5780"
|
||||
]
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"auth": {
|
||||
"enabled": true,
|
||||
"keycloak": {
|
||||
"url": "https://auth.tombutcher.work",
|
||||
"realm": "master",
|
||||
"clientId": "farmcontrol-dev"
|
||||
"clientId": "farmcontrol-client"
|
||||
},
|
||||
"requiredRoles": []
|
||||
},
|
||||
"app": {
|
||||
"urlClient": "https://dev.tombutcher.work",
|
||||
"urlElectronClient": "http://localhost:5780",
|
||||
"urlApi": "https://dev.tombutcher.work/api",
|
||||
"devAuthClient": "http://localhost:3500",
|
||||
"jenkinsProject": "https://ci.tombutcher.work/job/farmcontrol/job/farmcontrol-ui",
|
||||
"appUpdateStorage": "https://dist.farmcontrol.app/jenkins/farmcontrol/farmcontrol-ui"
|
||||
"urlClient": "http://localhost:3000",
|
||||
"urlElectronClient": "http://localhost:3000",
|
||||
"urlApi": "http://localhost:8787",
|
||||
"devAuthClient": "http://localhost:3500"
|
||||
},
|
||||
"database": {
|
||||
"mongo": {
|
||||
@ -55,84 +46,12 @@
|
||||
"filesBucket": "farmcontrol"
|
||||
}
|
||||
},
|
||||
"smtp": {
|
||||
"host": "mail.tombutcher.work",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"auth": {
|
||||
"user": "farmcontrol",
|
||||
"pass": "XwV5u3jWufuo5E5U4N9hBHfNfwk28D7fNdFN"
|
||||
},
|
||||
"from": "FarmControl <farmcontrol@tombutcher.work>"
|
||||
},
|
||||
"otpExpiryMins": 0.5
|
||||
},
|
||||
"test": {
|
||||
"server": {
|
||||
"port": 8788,
|
||||
"logLevel": "error"
|
||||
},
|
||||
"auth": {
|
||||
"enabled": false,
|
||||
"sessionSecret": "test-session-secret-for-testing-only",
|
||||
"keycloak": {
|
||||
"url": "http://localhost:8080",
|
||||
"realm": "test",
|
||||
"clientId": "test-client"
|
||||
},
|
||||
"requiredRoles": []
|
||||
},
|
||||
"app": {
|
||||
"urlClient": "http://localhost:3000",
|
||||
"urlElectronClient": "http://localhost:5780",
|
||||
"urlApi": "http://localhost:8788/api",
|
||||
"devAuthClient": "http://localhost:3500",
|
||||
"jenkinsProject": "https://ci.tombutcher.work/job/farmcontrol/job/farmcontrol-ui",
|
||||
"appUpdateStorage": "https://dist.farmcontrol.app/jenkins/farmcontrol/farmcontrol-ui"
|
||||
},
|
||||
"database": {
|
||||
"mongo": {
|
||||
"url": "mongodb://127.0.0.1:27017/farmcontrol-test",
|
||||
"link": "127.0.0.1:27017"
|
||||
},
|
||||
"redis": {
|
||||
"host": "localhost",
|
||||
"port": 6379,
|
||||
"password": "",
|
||||
"cacheTtl": 30
|
||||
},
|
||||
"nats": {
|
||||
"host": "localhost",
|
||||
"port": 4222
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"fileStorage": "./test-uploads",
|
||||
"ceph": {
|
||||
"accessKeyId": "minioadmin",
|
||||
"secretAccessKey": "minioadmin123",
|
||||
"endpoint": "http://127.0.0.1:9000",
|
||||
"region": "us-east-1",
|
||||
"filesBucket": "farmcontrol-test"
|
||||
}
|
||||
},
|
||||
"smtp": {
|
||||
"host": "localhost",
|
||||
"port": 587,
|
||||
"secure": false,
|
||||
"auth": {
|
||||
"user": "",
|
||||
"pass": ""
|
||||
},
|
||||
"from": "FarmControl <farmcontrol@tombutcher.work>"
|
||||
},
|
||||
"otpExpiryMins": 0.5
|
||||
},
|
||||
"production": {
|
||||
"server": {
|
||||
"port": 8080,
|
||||
"logLevel": "info",
|
||||
"corsOrigins": ["https://web.farmcontrol.app", "views://mainview"]
|
||||
"logLevel": "info"
|
||||
},
|
||||
"auth": {
|
||||
"enabled": true,
|
||||
@ -144,17 +63,15 @@
|
||||
"requiredRoles": []
|
||||
},
|
||||
"app": {
|
||||
"urlClient": "https://web.farmcontrol.app",
|
||||
"urlClient": "http://localhost:3000",
|
||||
"urlElectronClient": "http://localhost:3000",
|
||||
"urlApi": "https://api.farmcontrol.app",
|
||||
"devAuthClient": "http://localhost:3500",
|
||||
"jenkinsProject": "https://ci.tombutcher.work/job/farmcontrol/job/farmcontrol-ui",
|
||||
"appUpdateStorage": "https://dist.farmcontrol.app/jenkins/farmcontrol/farmcontrol-ui"
|
||||
"urlApi": "http://localhost:8080",
|
||||
"devAuthClient": "http://localhost:3500"
|
||||
},
|
||||
"database": {
|
||||
"mongo": {
|
||||
"url": "mongodb://192.168.68.38:27017/farmcontrol",
|
||||
"link": "192.168.68.38:27017"
|
||||
"url": "mongodb://localhost:27017/farmcontrol",
|
||||
"link": "localhost:27017"
|
||||
},
|
||||
"redis": {
|
||||
"url": "",
|
||||
@ -173,20 +90,10 @@
|
||||
"ceph": {
|
||||
"accessKeyId": "minioadmin",
|
||||
"secretAccessKey": "minioadmin123",
|
||||
"endpoint": "http://192.168.68.39:9000",
|
||||
"endpoint": "http://127.0.0.1:9000",
|
||||
"region": "us-east-1",
|
||||
"filesBucket": "farmcontrol"
|
||||
}
|
||||
},
|
||||
"smtp": {
|
||||
"host": "mail.tombutcher.work",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"auth": {
|
||||
"user": "farmcontrol",
|
||||
"pass": "XwV5u3jWufuo5E5U4N9hBHfNfwk28D7fNdFN"
|
||||
},
|
||||
"from": "FarmControl <noreply@farmcontrol.app>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
export default {
|
||||
apps: [
|
||||
{
|
||||
name: 'farmcontrol-api',
|
||||
script: 'src/index.js',
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
env_production: {
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
71
fcdev.js
71
fcdev.js
@ -1,4 +1,3 @@
|
||||
import { diffLines } from 'diff';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@ -12,60 +11,24 @@ async function syncModelsWithWS() {
|
||||
|
||||
console.log(`Syncing schemas from ${sourceDir} to ${targetDir}...`);
|
||||
|
||||
const stats = { copied: 0, added: 0, skipped: 0 };
|
||||
|
||||
try {
|
||||
await syncDirectory(sourceDir, targetDir, sourceDir, stats);
|
||||
console.log(
|
||||
`✅ Schema sync completed successfully! (${stats.added} added, ${stats.copied} updated, ${stats.skipped} unchanged)`
|
||||
);
|
||||
await syncDirectory(sourceDir, targetDir, sourceDir);
|
||||
console.log('✅ Schema sync completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('❌ Error syncing schemas:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncModelsWithScheduler() {
|
||||
const sourceDir = path.resolve(__dirname, 'src/database/schemas');
|
||||
const targetDir = path.resolve(__dirname, '../farmcontrol-scheduler/src/database/schemas');
|
||||
|
||||
console.log(`Syncing schemas from ${sourceDir} to ${targetDir}...`);
|
||||
|
||||
const stats = { copied: 0, added: 0, skipped: 0 };
|
||||
|
||||
try {
|
||||
await syncDirectory(sourceDir, targetDir, sourceDir, stats);
|
||||
console.log(
|
||||
`✅ Schema sync completed successfully! (${stats.added} added, ${stats.copied} updated, ${stats.skipped} unchanged)`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('❌ Error syncing schemas:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function fileHasChanges(oldContent, newContent) {
|
||||
return diffLines(oldContent, newContent).some((part) => part.added || part.removed);
|
||||
}
|
||||
|
||||
async function readFileIfExists(filePath) {
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDirectory(source, target, rootSource, stats) {
|
||||
async function syncDirectory(source, target, rootSource) {
|
||||
// Create target directory if it doesn't exist
|
||||
try {
|
||||
await fs.access(target);
|
||||
} catch {
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
}
|
||||
|
||||
// Read all items in source directory
|
||||
const items = await fs.readdir(source, { withFileTypes: true });
|
||||
|
||||
for (const item of items) {
|
||||
@ -73,29 +36,17 @@ async function syncDirectory(source, target, rootSource, stats) {
|
||||
const targetPath = path.join(target, item.name);
|
||||
|
||||
if (item.isDirectory()) {
|
||||
await syncDirectory(sourcePath, targetPath, rootSource, stats);
|
||||
// Recursively sync subdirectories
|
||||
await syncDirectory(sourcePath, targetPath, rootSource);
|
||||
} else if (item.isFile()) {
|
||||
const sourceContent = await fs.readFile(sourcePath, 'utf8');
|
||||
const targetContent = await readFileIfExists(targetPath);
|
||||
const relativePath = path.relative(rootSource, sourcePath);
|
||||
|
||||
if (targetContent === null) {
|
||||
await fs.writeFile(targetPath, sourceContent);
|
||||
stats.added += 1;
|
||||
console.log(` + Added: ${relativePath}`);
|
||||
} else if (fileHasChanges(targetContent, sourceContent)) {
|
||||
await fs.writeFile(targetPath, sourceContent);
|
||||
stats.copied += 1;
|
||||
console.log(` ✓ Updated: ${relativePath}`);
|
||||
} else {
|
||||
stats.skipped += 1;
|
||||
}
|
||||
// Copy file from source to target
|
||||
await fs.copyFile(sourcePath, targetPath);
|
||||
console.log(` ✓ Copied: ${path.relative(rootSource, sourcePath)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run the sync function when executed directly
|
||||
syncModelsWithWS();
|
||||
syncModelsWithScheduler();
|
||||
|
||||
export { syncModelsWithWS, syncModelsWithScheduler };
|
||||
export { syncModelsWithWS };
|
||||
|
||||
BIN
images/admin.jpg
Normal file
BIN
images/admin.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
images/creator.jpg
Normal file
BIN
images/creator.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
BIN
images/member.jpg
Normal file
BIN
images/member.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@ -1,22 +0,0 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
transform: {},
|
||||
moduleFileExtensions: ['js', 'json', 'jsx', 'node'],
|
||||
testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'],
|
||||
verbose: true,
|
||||
reporters: [
|
||||
'default',
|
||||
[
|
||||
'jest-junit',
|
||||
{
|
||||
outputDirectory: '.',
|
||||
outputName: 'test-results.xml',
|
||||
suiteName: 'farmcontrol-api-tests',
|
||||
classNameTemplate: '{classname}',
|
||||
titleTemplate: '{title}',
|
||||
ancestorSeparator: ' › ',
|
||||
usePathForSuiteName: 'true',
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
12211
package-lock.json
generated
Normal file
12211
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
37
package.json
@ -9,41 +9,28 @@
|
||||
"@nats-io/transport-node": "^3.2.0",
|
||||
"axios": "^1.13.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"body-parser": "^2.2.0",
|
||||
"canonical-json": "^0.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"dayjs": "^1.11.23",
|
||||
"diff": "^9.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"ejs": "^3.1.10",
|
||||
"exceljs": "^4.4.0",
|
||||
"exifr": "^7.1.3",
|
||||
"express": "^5.1.0",
|
||||
"express-session": "^1.18.2",
|
||||
"i": "^0.3.7",
|
||||
"ioredis": "^6.0.0",
|
||||
"js-beautify": "^2.0.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"keycloak-connect": "^26.1.1",
|
||||
"lodash": "^4.17.23",
|
||||
"log4js": "^6.9.1",
|
||||
"mongodb": "^6.21.0",
|
||||
"mongoose": "^9.9.1",
|
||||
"mongoose": "^8.19.4",
|
||||
"multer": "^2.0.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "*",
|
||||
"nodemon": "^3.1.11",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-to-img": "^6.2.0",
|
||||
"pg": "^8.16.3",
|
||||
"posthtml": "^0.16.7",
|
||||
"puppeteer": "^24.37.5",
|
||||
"sequelize": "^6.37.7",
|
||||
"sharp": "^0.34.3",
|
||||
"speedgoose": "^2.5.11"
|
||||
"redis": "^5.10.0",
|
||||
"sequelize": "^6.37.7"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
@ -54,31 +41,23 @@
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.20.7",
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
"@babel/register": "^7.28.3",
|
||||
"@jest/globals": "^30.2.0",
|
||||
"babel-jest": "^30.2.0",
|
||||
"babel-plugin-transform-import-meta": "^2.3.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"jest": "^30.2.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"sequelize-cli": "^6.6.3",
|
||||
"standard": "^17.1.2",
|
||||
"supertest": "^7.1.4"
|
||||
"standard": "^17.1.2"
|
||||
},
|
||||
"scripts": {
|
||||
"syncModelsWithWS": "node fcdev.js",
|
||||
"watch:schemas": "nodemon --config nodemon.schemas.json",
|
||||
"dev": "concurrently --kill-others --names \"API,SCHEMAS\" --prefix-colors \"cyan,yellow\" \"nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js\" \"nodemon --config nodemon.schemas.json\"",
|
||||
"dev": "concurrently --names \"API,SCHEMAS\" --prefix-colors \"cyan,yellow\" \"nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js\" \"nodemon --config nodemon.schemas.json\"",
|
||||
"dev:api": "nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js",
|
||||
"test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"seed": "node src/mongo/seedData.js",
|
||||
"clear": "node src/mongo/clearDbs.js",
|
||||
"start": "node src/index.js"
|
||||
"clear": "node src/mongo/clearDbs.js"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.28.0"
|
||||
"license": "ISC"
|
||||
}
|
||||
|
||||
11813
pnpm-lock.yaml
generated
11813
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,9 +0,0 @@
|
||||
minimumReleaseAge: 0
|
||||
|
||||
allowBuilds:
|
||||
bcrypt: true
|
||||
chromedriver: true
|
||||
core-js: true
|
||||
puppeteer: true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
@ -1,16 +0,0 @@
|
||||
import { actorDisplayName, resolveAuditOwner } from '../auditOwner.js';
|
||||
|
||||
describe('audit owner resolution', () => {
|
||||
it('represents system actions without an owner', () => {
|
||||
expect(resolveAuditOwner('system')).toEqual({ system: true });
|
||||
expect(actorDisplayName('system')).toBe('system');
|
||||
});
|
||||
|
||||
it('preserves object actors as owners', () => {
|
||||
expect(resolveAuditOwner({ _id: 'user-1' })).toEqual({
|
||||
system: false,
|
||||
owner: 'user-1',
|
||||
ownerType: 'user',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,138 +0,0 @@
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
const userQueryMock = {
|
||||
lean: jest.fn(),
|
||||
};
|
||||
const userModelMock = {
|
||||
findOne: jest.fn(() => userQueryMock),
|
||||
};
|
||||
const appPasswordQueryMock = {
|
||||
select: jest.fn(),
|
||||
lean: jest.fn(),
|
||||
};
|
||||
const appPasswordModelMock = {
|
||||
find: jest.fn(() => appPasswordQueryMock),
|
||||
};
|
||||
const bcryptCompareMock = jest.fn();
|
||||
|
||||
jest.unstable_mockModule('../config.js', () => ({
|
||||
default: { server: { logLevel: 'info' } },
|
||||
getEnvironment: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('log4js', () => ({
|
||||
default: {
|
||||
getLogger: () => ({
|
||||
level: 'info',
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('node-cache', () => ({
|
||||
default: class {
|
||||
get() {}
|
||||
set() {}
|
||||
on() {}
|
||||
flushAll() {}
|
||||
getStats() {}
|
||||
del() {}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('bcrypt', () => ({
|
||||
default: { compare: bcryptCompareMock },
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../database/schemas/management/user.schema.js', () => ({
|
||||
userModel: userModelMock,
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../database/schemas/management/apppassword.schema.js', () => ({
|
||||
appPasswordModel: appPasswordModelMock,
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../database/database.js', () => ({
|
||||
getPropertyValues: jest.fn(),
|
||||
getObject: jest.fn(),
|
||||
getObjectNeighbors: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../database/schemas/management/host.schema.js', () => ({
|
||||
hostModel: {},
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../services/misc/auth.js', () => ({
|
||||
getSession: jest.fn(),
|
||||
lookupUserByToken: jest.fn(),
|
||||
}));
|
||||
|
||||
const { isAppAuthenticated } = await import('../keycloak.js');
|
||||
|
||||
describe('app-password authentication', () => {
|
||||
let req;
|
||||
let res;
|
||||
let next;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
appPasswordQueryMock.select.mockReturnValue(appPasswordQueryMock);
|
||||
req = {
|
||||
headers: {
|
||||
authorization: `Basic ${Buffer.from('slicer-user:app-secret').toString('base64')}`,
|
||||
},
|
||||
params: { username: 'slicer-user' },
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
};
|
||||
next = jest.fn();
|
||||
});
|
||||
|
||||
it('accepts a valid username and app password', async () => {
|
||||
const user = { _id: 'user-1', username: 'slicer-user' };
|
||||
userQueryMock.lean.mockResolvedValue(user);
|
||||
appPasswordQueryMock.lean.mockResolvedValue([{ secret: 'stored-hash' }]);
|
||||
bcryptCompareMock.mockResolvedValue(true);
|
||||
|
||||
await isAppAuthenticated(req, res, next);
|
||||
|
||||
expect(req.user).toBe(user);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts the app password from the password route parameter', async () => {
|
||||
const user = { _id: 'user-1', username: 'slicer-user' };
|
||||
req.headers = {};
|
||||
req.params.password = 'app-secret';
|
||||
userQueryMock.lean.mockResolvedValue(user);
|
||||
appPasswordQueryMock.lean.mockResolvedValue([{ secret: 'stored-hash' }]);
|
||||
bcryptCompareMock.mockResolvedValue(true);
|
||||
|
||||
await isAppAuthenticated(req, res, next);
|
||||
|
||||
expect(bcryptCompareMock).toHaveBeenCalledWith('app-secret', 'stored-hash');
|
||||
expect(req.user).toBe(user);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an invalid app password', async () => {
|
||||
userQueryMock.lean.mockResolvedValue({ _id: 'user-1', username: 'slicer-user' });
|
||||
appPasswordQueryMock.lean.mockResolvedValue([{ secret: 'stored-hash' }]);
|
||||
bcryptCompareMock.mockResolvedValue(false);
|
||||
|
||||
await isAppAuthenticated(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Not Authenticated',
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,86 +0,0 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import request from 'supertest';
|
||||
|
||||
// Mock Keycloak and Auth
|
||||
jest.unstable_mockModule('../keycloak.js', () => ({
|
||||
keycloak: {
|
||||
middleware: () => (req, res, next) => next(),
|
||||
protect: () => (req, res, next) => next(),
|
||||
},
|
||||
isAuthenticated: (req, res, next) => next(),
|
||||
isAppAuthenticated: (req, res, next) => next(),
|
||||
isAppQueryAuthenticated: (req, res, next) => next(),
|
||||
expressSession: (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../database/permissions.js', () => ({
|
||||
checkPermissions: () => (req, res, next) => next(),
|
||||
hasPermission: jest.fn(async () => true),
|
||||
getUserPermissionsCacheKey: jest.fn((userId) => `permissions:${userId}`),
|
||||
saveUserPermissionsToRedis: jest.fn(),
|
||||
applyPermissionSettingsList: jest.fn(() => ({})),
|
||||
getPermissionSettingsId: jest.fn(),
|
||||
excludeIdFromList: jest.fn((items = []) => items),
|
||||
resolveReferencedDocs: jest.fn(async () => []),
|
||||
resolvePermissionSettings: jest.fn(async () => []),
|
||||
}));
|
||||
|
||||
// Mock database connections and initializations in index.js
|
||||
jest.unstable_mockModule('../database/mongo.js', () => ({
|
||||
dbConnect: jest.fn(),
|
||||
}));
|
||||
jest.unstable_mockModule('../database/nats.js', () => ({
|
||||
natsServer: { connect: jest.fn() },
|
||||
}));
|
||||
jest.unstable_mockModule('../database/ceph.js', () => ({
|
||||
initializeBuckets: jest.fn(),
|
||||
uploadFile: jest.fn(),
|
||||
downloadFile: jest.fn(),
|
||||
deleteFile: jest.fn(),
|
||||
fileExists: jest.fn(),
|
||||
listFiles: jest.fn(),
|
||||
getFileMetadata: jest.fn(),
|
||||
getPresignedUrl: jest.fn(),
|
||||
BUCKETS: { FILES: 'test-bucket' },
|
||||
}));
|
||||
|
||||
// Mock the service handlers to avoid database calls
|
||||
jest.unstable_mockModule('../services/management/users.js', () => ({
|
||||
listUsersRouteHandler: jest.fn((req, res) => res.send([{ id: '1', name: 'Mock User' }])),
|
||||
listUsersByPropertiesRouteHandler: jest.fn(),
|
||||
searchUsersRouteHandler: jest.fn(),
|
||||
getUserRouteHandler: jest.fn((req, res) => res.send({ id: req.params.id, name: 'Mock User' })),
|
||||
editUserRouteHandler: jest.fn(),
|
||||
getUserStatsRouteHandler: jest.fn(),
|
||||
getUserHistoryRouteHandler: jest.fn(),
|
||||
setAppPasswordRouteHandler: jest.fn(),
|
||||
getUserPropertyValuesRouteHandler: jest.fn(),
|
||||
getUserNeighborsRouteHandler: jest.fn(),
|
||||
}));
|
||||
|
||||
const { default: app } = await import('../index.js');
|
||||
const { listUsersRouteHandler, getUserRouteHandler } = await import(
|
||||
'../services/management/users.js'
|
||||
);
|
||||
|
||||
describe('Users API Endpoints', () => {
|
||||
describe('GET /users', () => {
|
||||
it('should return a list of users', async () => {
|
||||
const response = await request(app).get('/users');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([{ id: '1', name: 'Mock User' }]);
|
||||
expect(listUsersRouteHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/:id', () => {
|
||||
it('should return a single user', async () => {
|
||||
const response = await request(app).get('/users/123');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ id: '123', name: 'Mock User' });
|
||||
expect(getUserRouteHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,45 +0,0 @@
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
import { mongoose } from 'mongoose';
|
||||
import { ObjectId as BsonObjectId } from 'mongodb';
|
||||
import { expandObjectIds } from '../utils.js';
|
||||
|
||||
const HEX = '692f07c4cb1de2995c4d543a';
|
||||
const BYTES = [105, 47, 7, 196, 203, 29, 226, 153, 92, 77, 84, 58];
|
||||
|
||||
describe('expandObjectIds', () => {
|
||||
it('stores mongoose ObjectIds as hex text', () => {
|
||||
const id = new mongoose.Types.ObjectId(HEX);
|
||||
const result = expandObjectIds({ location: id, _id: id });
|
||||
|
||||
expect(result.location).toEqual({ _id: HEX });
|
||||
expect(result._id).toBe(HEX);
|
||||
expect(result.location._id).not.toHaveProperty('buffer');
|
||||
});
|
||||
|
||||
it('stores BSON ObjectIds as hex text even when they are not mongoose instances', () => {
|
||||
const id = new BsonObjectId(HEX);
|
||||
expect(id instanceof mongoose.Types.ObjectId).toBe(false);
|
||||
|
||||
const result = expandObjectIds({ product: id });
|
||||
|
||||
expect(result.product).toEqual({ _id: HEX });
|
||||
expect(typeof result.product._id).toBe('string');
|
||||
});
|
||||
|
||||
it('converts serialized 12-byte buffer objects to hex text', () => {
|
||||
const serialized = {
|
||||
buffer: Object.fromEntries(BYTES.map((byte, index) => [String(index), byte])),
|
||||
};
|
||||
|
||||
const result = expandObjectIds({ sku: serialized });
|
||||
|
||||
expect(result.sku).toEqual({ _id: HEX });
|
||||
});
|
||||
|
||||
it('converts 12-byte Buffers to hex text instead of expanding index keys', () => {
|
||||
const result = expandObjectIds({ sku: Buffer.from(HEX, 'hex') });
|
||||
|
||||
expect(result.sku).toEqual({ _id: HEX });
|
||||
expect(result.sku).not.toHaveProperty('0');
|
||||
});
|
||||
});
|
||||
@ -1,42 +0,0 @@
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
import mongoose from 'mongoose';
|
||||
import {
|
||||
collectIdsAtPath,
|
||||
getFieldsByRef,
|
||||
modelHasRef,
|
||||
} from '../utils.js';
|
||||
import { emailMessageModel } from '../database/schemas/management/emailmessage.schema.js';
|
||||
|
||||
describe('getFieldsByRef', () => {
|
||||
it('finds nested array file refs on email message recipients', () => {
|
||||
expect(modelHasRef(emailMessageModel, 'file')).toBe(true);
|
||||
expect(getFieldsByRef(emailMessageModel, 'file')).toEqual(['recipients.attachments']);
|
||||
});
|
||||
|
||||
it('finds top-level array refs using embeddedSchemaType', () => {
|
||||
const schema = new mongoose.Schema({
|
||||
files: [{ type: mongoose.Schema.Types.ObjectId, ref: 'file' }],
|
||||
});
|
||||
const model = { schema };
|
||||
|
||||
expect(modelHasRef(model, 'file')).toBe(true);
|
||||
expect(getFieldsByRef(model, 'file')).toEqual(['files']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectIdsAtPath', () => {
|
||||
it('collects file ids from nested recipient attachment lists', () => {
|
||||
const object = {
|
||||
recipients: [
|
||||
{ attachments: [{ _id: 'file-1' }, 'file-2'] },
|
||||
{ attachments: ['file-3'] },
|
||||
],
|
||||
};
|
||||
|
||||
expect(collectIdsAtPath(object, 'recipients.attachments')).toEqual([
|
||||
'file-1',
|
||||
'file-2',
|
||||
'file-3',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -1,268 +0,0 @@
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
import { getFilter, parseFilter } from '../utils.js';
|
||||
import { jobModel } from '../database/schemas/production/job.schema.js';
|
||||
|
||||
const startOfDay = (date) => {
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
const endOfDay = (date) => {
|
||||
const d = new Date(date);
|
||||
d.setHours(23, 59, 59, 999);
|
||||
return d;
|
||||
};
|
||||
|
||||
const startOfMonth = (date) => new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
|
||||
|
||||
const endOfMonth = (date) =>
|
||||
new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||
|
||||
const startOfWeek = (date) => {
|
||||
const d = startOfDay(date);
|
||||
const day = d.getDay();
|
||||
const mondayOffset = day === 0 ? -6 : 1 - day;
|
||||
d.setDate(d.getDate() + mondayOffset);
|
||||
return d;
|
||||
};
|
||||
|
||||
const endOfWeek = (date) => {
|
||||
const start = startOfWeek(date);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 6);
|
||||
return endOfDay(end);
|
||||
};
|
||||
|
||||
const startOfYear = (date) => new Date(date.getFullYear(), 0, 1, 0, 0, 0, 0);
|
||||
|
||||
const endOfYear = (date) => new Date(date.getFullYear(), 11, 31, 23, 59, 59, 999);
|
||||
|
||||
const startOfHour = (date) => {
|
||||
const d = new Date(date);
|
||||
d.setMinutes(0, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
const endOfHour = (date) => {
|
||||
const d = new Date(date);
|
||||
d.setMinutes(59, 59, 999);
|
||||
return d;
|
||||
};
|
||||
|
||||
const startOfMinute = (date) => {
|
||||
const d = new Date(date);
|
||||
d.setSeconds(0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
const endOfMinute = (date) => {
|
||||
const d = new Date(date);
|
||||
d.setSeconds(59, 999);
|
||||
return d;
|
||||
};
|
||||
|
||||
describe('parseFilter number fields', () => {
|
||||
it('matches exact numeric quantity without regex', async () => {
|
||||
await expect(parseFilter('quantity', '12', jobModel)).resolves.toEqual({ quantity: 12 });
|
||||
});
|
||||
|
||||
it('matches multiple quantities with $in', async () => {
|
||||
await expect(parseFilter('quantity', '1|2|3', jobModel)).resolves.toEqual({
|
||||
quantity: { $in: [1, 2, 3] },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not put $regex or $options on quantity for wildcards', async () => {
|
||||
const result = await parseFilter('quantity', '1*', jobModel);
|
||||
expect(result.quantity).toBeUndefined();
|
||||
expect(JSON.stringify(result)).not.toMatch(/\$options/);
|
||||
expect(result).toEqual({
|
||||
$expr: {
|
||||
$regexMatch: {
|
||||
input: { $toString: '$quantity' },
|
||||
regex: '^1.*$',
|
||||
options: 'i',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('treats non-numeric quantity text as no match', async () => {
|
||||
await expect(parseFilter('quantity', 'abc', jobModel)).resolves.toEqual({
|
||||
quantity: { $in: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('still uses $regex on string fields', async () => {
|
||||
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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFilter empty values', () => {
|
||||
it("treats '' as null or missing", async () => {
|
||||
await expect(parseFilter('_reference', "''", jobModel)).resolves.toEqual({
|
||||
_reference: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats ='' as equal to null or missing", async () => {
|
||||
await expect(parseFilter('_reference', "=''", jobModel)).resolves.toEqual({
|
||||
_reference: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats <>'' as present and not null", async () => {
|
||||
await expect(parseFilter('_reference', "<>''", jobModel)).resolves.toEqual({
|
||||
_reference: { $ne: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats '' as empty on number fields", async () => {
|
||||
await expect(parseFilter('quantity', "''", jobModel)).resolves.toEqual({
|
||||
quantity: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats <>'' as not empty on date fields", async () => {
|
||||
await expect(parseFilter('createdAt', "<>''", jobModel)).resolves.toEqual({
|
||||
createdAt: { $ne: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats '' as empty on object references", async () => {
|
||||
await expect(parseFilter('gcodeFile', "''", jobModel)).resolves.toEqual({
|
||||
gcodeFile: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats <>'' as not empty on object references", async () => {
|
||||
await expect(parseFilter('gcodeFile', "<>''", jobModel)).resolves.toEqual({
|
||||
gcodeFile: { $ne: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFilter date fields', () => {
|
||||
it('matches TODAY as the current calendar day', async () => {
|
||||
const now = new Date();
|
||||
await expect(parseFilter('createdAt', 'TODAY', jobModel)).resolves.toEqual({
|
||||
createdAt: { $gte: startOfDay(now), $lte: endOfDay(now) },
|
||||
});
|
||||
});
|
||||
|
||||
it('matches TODAY.. as from today onwards', async () => {
|
||||
const now = new Date();
|
||||
await expect(parseFilter('createdAt', 'TODAY..', jobModel)).resolves.toEqual({
|
||||
createdAt: { $gte: startOfDay(now) },
|
||||
});
|
||||
});
|
||||
|
||||
it('matches -7D.. as the last 7 days', async () => {
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - 7);
|
||||
await expect(parseFilter('createdAt', '-7D..', jobModel)).resolves.toEqual({
|
||||
createdAt: { $gte: startOfDay(from) },
|
||||
});
|
||||
});
|
||||
|
||||
it('matches -30D.. as the last 30 days', async () => {
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - 30);
|
||||
await expect(parseFilter('createdAt', '-30D..', jobModel)).resolves.toEqual({
|
||||
createdAt: { $gte: startOfDay(from) },
|
||||
});
|
||||
});
|
||||
|
||||
it('matches -1M..-CM as the previous calendar month', async () => {
|
||||
const now = new Date();
|
||||
const previous = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
await expect(parseFilter('createdAt', '-1M..-CM', jobModel)).resolves.toEqual({
|
||||
createdAt: { $gte: startOfMonth(previous), $lte: endOfMonth(previous) },
|
||||
});
|
||||
});
|
||||
|
||||
it('matches -1W as the previous calendar week', async () => {
|
||||
const previous = new Date();
|
||||
previous.setDate(previous.getDate() - 7);
|
||||
await expect(parseFilter('createdAt', '-1W', jobModel)).resolves.toEqual({
|
||||
createdAt: { $gte: startOfWeek(previous), $lte: endOfWeek(previous) },
|
||||
});
|
||||
});
|
||||
|
||||
it('matches -1Y..-CY as the previous calendar year', async () => {
|
||||
const previous = new Date(new Date().getFullYear() - 1, 0, 1);
|
||||
await expect(parseFilter('createdAt', '-1Y..-CY', jobModel)).resolves.toEqual({
|
||||
createdAt: { $gte: startOfYear(previous), $lte: endOfYear(previous) },
|
||||
});
|
||||
});
|
||||
|
||||
it('matches -2H as that hour two hours ago', async () => {
|
||||
const hour = new Date();
|
||||
hour.setHours(hour.getHours() - 2);
|
||||
await expect(parseFilter('createdAt', '-2H', jobModel)).resolves.toEqual({
|
||||
createdAt: { $gte: startOfHour(hour), $lte: endOfHour(hour) },
|
||||
});
|
||||
});
|
||||
|
||||
it('matches -15m.. as the last 15 minutes', async () => {
|
||||
const from = new Date();
|
||||
from.setMinutes(from.getMinutes() - 15);
|
||||
await expect(parseFilter('createdAt', '-15m..', jobModel)).resolves.toEqual({
|
||||
createdAt: { $gte: startOfMinute(from) },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat lowercase m as a month', async () => {
|
||||
const month = await parseFilter('createdAt', '-1M', jobModel);
|
||||
const minute = await parseFilter('createdAt', '-1m', jobModel);
|
||||
expect(month).not.toEqual(minute);
|
||||
const from = new Date();
|
||||
from.setMinutes(from.getMinutes() - 1);
|
||||
expect(minute).toEqual({
|
||||
createdAt: { $gte: startOfMinute(from), $lte: endOfMinute(from) },
|
||||
});
|
||||
});
|
||||
|
||||
it('omits invalid date text instead of sending it to Mongo', async () => {
|
||||
await expect(parseFilter('createdAt', 'not-a-date', jobModel)).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('omits expressions that mix dates with non-date operands', async () => {
|
||||
await expect(parseFilter('createdAt', 'TODAY|foo', jobModel)).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('rejects impossible calendar days', async () => {
|
||||
await expect(parseFilter('createdAt', '32', jobModel)).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('getFilter drops invalid date fields and keeps valid ones', async () => {
|
||||
const now = new Date();
|
||||
const filter = await getFilter(
|
||||
{ createdAt: 'yesterday', quantity: '12' },
|
||||
['createdAt', 'quantity'],
|
||||
true,
|
||||
jobModel
|
||||
);
|
||||
expect(filter).toEqual({ quantity: 12 });
|
||||
expect(filter.createdAt).toBeUndefined();
|
||||
|
||||
const today = await getFilter({ createdAt: 'TODAY' }, ['createdAt'], true, jobModel);
|
||||
expect(today).toEqual({
|
||||
createdAt: { $gte: startOfDay(now), $lte: endOfDay(now) },
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,28 +0,0 @@
|
||||
const AUDIT_OWNER_TYPES = ['user', 'printer', 'host', 'marketplace'];
|
||||
|
||||
export function resolveAuditOwner(actor) {
|
||||
if (actor === 'system') {
|
||||
return {
|
||||
system: true,
|
||||
};
|
||||
}
|
||||
|
||||
const ownerType = AUDIT_OWNER_TYPES.includes(actor?._objectType) ? actor._objectType : 'user';
|
||||
return {
|
||||
system: false,
|
||||
owner: actor?._id,
|
||||
ownerType,
|
||||
};
|
||||
}
|
||||
|
||||
export function actorDisplayName(actor) {
|
||||
if (actor === 'system') return 'system';
|
||||
if (actor?._objectType === 'marketplace' || actor?._objectType === 'host') {
|
||||
return actor.name || actor._objectType;
|
||||
}
|
||||
const firstName = actor?.firstName ?? 'unknown';
|
||||
const lastName = actor?.lastName ?? '';
|
||||
return `${firstName} ${lastName}`.trim();
|
||||
}
|
||||
|
||||
export { AUDIT_OWNER_TYPES };
|
||||
@ -2,10 +2,6 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables from .env file
|
||||
dotenv.config();
|
||||
|
||||
// Configure paths relative to this file
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@ -29,47 +25,7 @@ function loadConfig() {
|
||||
throw new Error(`Configuration for environment '${NODE_ENV}' not found in config.json`);
|
||||
}
|
||||
|
||||
const envConfig = config[NODE_ENV];
|
||||
|
||||
// Ensure auth config exists
|
||||
if (!envConfig.auth) {
|
||||
envConfig.auth = {};
|
||||
}
|
||||
if (!envConfig.auth.keycloak) {
|
||||
envConfig.auth.keycloak = {};
|
||||
}
|
||||
|
||||
// Override secrets with environment variables if available
|
||||
if (process.env.KEYCLOAK_CLIENT_SECRET) {
|
||||
envConfig.auth.keycloak.clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
|
||||
}
|
||||
|
||||
// Session secret must be set - use env var or throw error
|
||||
if (process.env.SESSION_SECRET) {
|
||||
envConfig.auth.sessionSecret = process.env.SESSION_SECRET;
|
||||
} else if (!envConfig.auth.sessionSecret) {
|
||||
throw new Error(
|
||||
'SESSION_SECRET environment variable is required. Please set SESSION_SECRET in your environment.'
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure smtp config exists and override with env vars if available
|
||||
if (!envConfig.smtp) {
|
||||
envConfig.smtp = {};
|
||||
}
|
||||
if (process.env.SMTP_HOST) envConfig.smtp.host = process.env.SMTP_HOST;
|
||||
if (process.env.SMTP_PORT) envConfig.smtp.port = parseInt(process.env.SMTP_PORT, 10);
|
||||
if (process.env.SMTP_SECURE) envConfig.smtp.secure = process.env.SMTP_SECURE === 'true';
|
||||
if (process.env.SMTP_USER || process.env.SMTP_PASS) {
|
||||
envConfig.smtp.auth = {
|
||||
...(envConfig.smtp.auth || {}),
|
||||
...(process.env.SMTP_USER && { user: process.env.SMTP_USER }),
|
||||
...(process.env.SMTP_PASS && { pass: process.env.SMTP_PASS }),
|
||||
};
|
||||
}
|
||||
if (process.env.SMTP_FROM) envConfig.smtp.from = process.env.SMTP_FROM;
|
||||
|
||||
return envConfig;
|
||||
return config[NODE_ENV];
|
||||
} catch (err) {
|
||||
console.error('Error loading config:', err);
|
||||
throw err;
|
||||
|
||||
@ -1,218 +0,0 @@
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Mock src/database/utils.js (where generateId and convertObjectIdStringsInFilter live)
|
||||
jest.unstable_mockModule('../utils.js', () => ({
|
||||
generateId: jest.fn(() => () => 'test-id'),
|
||||
convertObjectIdStringsInFilter: jest.fn((filter) => filter),
|
||||
}));
|
||||
|
||||
// Mock src/utils.js (where most database.js helpers live)
|
||||
jest.unstable_mockModule('../../utils.js', () => ({
|
||||
deleteAuditLog: jest.fn(),
|
||||
deleteNotification: jest.fn(),
|
||||
distributeDelete: jest.fn(),
|
||||
editAuditLog: jest.fn(),
|
||||
editNotification: jest.fn(),
|
||||
expandObjectIds: jest.fn((obj) => obj),
|
||||
flatternObjectIds: jest.fn((object) => {
|
||||
if (!object || typeof object !== 'object') {
|
||||
return object;
|
||||
}
|
||||
|
||||
const result = {};
|
||||
|
||||
for (const [key, value] of Object.entries(object)) {
|
||||
if (value && typeof value === 'object' && value._id) {
|
||||
result[key] = value._id;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
getFieldsByRef: jest.fn(() => []),
|
||||
collectIdsAtPath: jest.fn(() => []),
|
||||
getQueryToCacheKey: jest.fn(({ model, id }) => `${model}:${id}`),
|
||||
modelHasRef: jest.fn(() => false),
|
||||
newAuditLog: jest.fn(),
|
||||
distributeNew: jest.fn(),
|
||||
distributeUpdate: jest.fn(),
|
||||
distributeChildUpdate: jest.fn(),
|
||||
distributeChildDelete: jest.fn(),
|
||||
distributeChildNew: jest.fn(),
|
||||
distributeStats: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../redis.js', () => ({
|
||||
redisServer: {
|
||||
getKey: jest.fn(),
|
||||
setKey: jest.fn(),
|
||||
deleteKey: jest.fn(),
|
||||
getKeysByPattern: jest.fn(() => []), // Return empty array to avoid iterable error
|
||||
},
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../services/misc/model.js', () => ({
|
||||
getAllModels: jest.fn(() => []),
|
||||
getModelByName: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../schemas/management/auditlog.schema.js', () => ({
|
||||
auditLogModel: {
|
||||
find: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fileModel specifically as it's imported by database.js
|
||||
jest.unstable_mockModule('../schemas/management/file.schema.js', () => ({
|
||||
fileModel: {
|
||||
findById: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Now import the database utilities
|
||||
const { listObjects, getObject, newObject, editObject, deleteObject } =
|
||||
await import('../database.js');
|
||||
const { editAuditLog, editNotification } = await import('../../utils.js');
|
||||
|
||||
describe('Database Utilities (CRUD)', () => {
|
||||
let mockModel;
|
||||
|
||||
beforeEach(() => {
|
||||
mockModel = {
|
||||
modelName: 'TestModel',
|
||||
find: jest.fn().mockReturnThis(),
|
||||
findById: jest.fn().mockReturnThis(),
|
||||
findByIdAndUpdate: jest.fn().mockReturnThis(),
|
||||
findByIdAndDelete: jest.fn().mockReturnThis(),
|
||||
create: jest.fn(),
|
||||
sort: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
populate: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
lean: jest.fn().mockReturnThis(),
|
||||
exec: jest.fn(),
|
||||
};
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('listObjects', () => {
|
||||
it('should return a list of objects', async () => {
|
||||
const mockData = [{ _id: '1', name: 'Test' }];
|
||||
mockModel.lean.mockResolvedValue(mockData);
|
||||
|
||||
const result = await listObjects({ model: mockModel });
|
||||
|
||||
expect(mockModel.find).toHaveBeenCalled();
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
|
||||
it('should handle pagination', async () => {
|
||||
await listObjects({ model: mockModel, page: 2, limit: 10 });
|
||||
expect(mockModel.skip).toHaveBeenCalledWith(10);
|
||||
expect(mockModel.limit).toHaveBeenCalledWith(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getObject', () => {
|
||||
it('should return a single object by ID', async () => {
|
||||
const mockData = { _id: '123', name: 'Test' };
|
||||
mockModel.lean.mockResolvedValue(mockData);
|
||||
|
||||
const result = await getObject({ model: mockModel, id: '123' });
|
||||
|
||||
expect(mockModel.findById).toHaveBeenCalledWith('123');
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
|
||||
it('should return 404 if object not found', async () => {
|
||||
mockModel.lean.mockResolvedValue(null);
|
||||
|
||||
const result = await getObject({ model: mockModel, id: '123' });
|
||||
|
||||
expect(result).toEqual({ error: 'Object not found.', code: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('newObject', () => {
|
||||
it('should create a new object', async () => {
|
||||
const newData = { name: 'New' };
|
||||
const createdData = { _id: '456', ...newData };
|
||||
mockModel.create.mockResolvedValue({
|
||||
toObject: () => createdData,
|
||||
_id: '456',
|
||||
});
|
||||
|
||||
const result = await newObject({ model: mockModel, newData });
|
||||
|
||||
expect(mockModel.create).toHaveBeenCalledWith(newData);
|
||||
expect(result).toEqual(createdData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editObject', () => {
|
||||
it('should update an existing object', async () => {
|
||||
const id = '123';
|
||||
const updateData = { name: 'Updated' };
|
||||
const previousData = { _id: id, name: 'Old' };
|
||||
|
||||
mockModel.lean.mockResolvedValue(previousData);
|
||||
|
||||
const result = await editObject({ model: mockModel, id, updateData });
|
||||
|
||||
expect(mockModel.findByIdAndUpdate).toHaveBeenCalledWith(id, updateData);
|
||||
expect(result).toEqual({ ...previousData, ...updateData });
|
||||
});
|
||||
|
||||
it('should flatten object id references before updating', async () => {
|
||||
const id = '123';
|
||||
const fileId = '507f1f77bcf86cd799439012';
|
||||
const updateData = { file: { _id: fileId, name: 'part.gcode' } };
|
||||
const previousData = { _id: id, name: 'Old' };
|
||||
|
||||
mockModel.lean.mockResolvedValue(previousData);
|
||||
|
||||
await editObject({ model: mockModel, id, updateData });
|
||||
|
||||
expect(mockModel.findByIdAndUpdate).toHaveBeenCalledWith(id, { file: fileId });
|
||||
});
|
||||
|
||||
it('creates an audit log but no user notification for system edits', async () => {
|
||||
mockModel.lean.mockResolvedValue({ _id: '123', name: 'Old' });
|
||||
|
||||
await editObject({
|
||||
model: mockModel,
|
||||
id: '123',
|
||||
updateData: { name: 'Updated' },
|
||||
user: 'system',
|
||||
});
|
||||
|
||||
expect(editAuditLog).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
'123',
|
||||
'TestModel',
|
||||
'system'
|
||||
);
|
||||
expect(editNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteObject', () => {
|
||||
it('should delete an object', async () => {
|
||||
const id = '123';
|
||||
const mockData = { _id: id, name: 'To be deleted' };
|
||||
mockModel.findByIdAndDelete.mockResolvedValue({
|
||||
toObject: () => mockData,
|
||||
});
|
||||
|
||||
const result = await deleteObject({ model: mockModel, id });
|
||||
|
||||
expect(mockModel.findByIdAndDelete).toHaveBeenCalledWith(id);
|
||||
expect(result).toEqual({ deleted: true, object: mockData });
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,176 +0,0 @@
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
const redisMock = {
|
||||
getKey: jest.fn(),
|
||||
setKey: jest.fn(),
|
||||
};
|
||||
|
||||
const lean = jest.fn();
|
||||
const select = jest.fn(() => ({ lean }));
|
||||
const findById = jest.fn(() => ({ select }));
|
||||
const userModel = { findById };
|
||||
|
||||
jest.unstable_mockModule('../redis.js', () => ({
|
||||
redisServer: redisMock,
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('mongoose', () => ({
|
||||
default: {
|
||||
model: jest.fn(() => userModel),
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
applyPermissionSettingsList,
|
||||
checkPermissions,
|
||||
hasPermission,
|
||||
getUserPermissionsCacheKey,
|
||||
saveUserPermissionsToRedis,
|
||||
} = await import('../permissions.js');
|
||||
|
||||
describe('applyPermissionSettingsList', () => {
|
||||
it('applies permission settings in order and inherits intermediate values', () => {
|
||||
const result = applyPermissionSettingsList([
|
||||
{
|
||||
permissions: {
|
||||
user: { info: true, edit: false, delete: true },
|
||||
printer: { info: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
permissions: {
|
||||
user: { edit: true, delete: null },
|
||||
printer: { info: null, delete: false },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
user: { info: true, edit: true, delete: true },
|
||||
printer: { info: true, delete: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty object when nothing is explicitly set', () => {
|
||||
expect(applyPermissionSettingsList([{ permissions: { user: { info: null } } }])).toEqual({});
|
||||
});
|
||||
|
||||
it('applies group permissions first, then user permission settings', () => {
|
||||
const result = applyPermissionSettingsList([
|
||||
{
|
||||
permissions: {
|
||||
user: { info: true, edit: false },
|
||||
printer: { info: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
permissions: {
|
||||
user: { delete: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
permissions: {
|
||||
user: { edit: true },
|
||||
printer: { info: false },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
user: { info: true, edit: true, delete: true },
|
||||
printer: { info: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveUserPermissionsToRedis', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('saves permissions under permissions:userID', async () => {
|
||||
const permissions = { user: { info: true } };
|
||||
|
||||
await saveUserPermissionsToRedis('user-1', permissions);
|
||||
|
||||
expect(redisMock.setKey).toHaveBeenCalledWith('permissions:user-1', permissions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasPermission', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
redisMock.getKey.mockResolvedValue(null);
|
||||
redisMock.setKey.mockResolvedValue(undefined);
|
||||
lean.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('returns true when the cached permission is allowed', async () => {
|
||||
redisMock.getKey.mockResolvedValue({ printer: { edit: true } });
|
||||
|
||||
await expect(hasPermission({ _id: 'user-1' }, 'printer', 'edit')).resolves.toBe(true);
|
||||
expect(redisMock.getKey).toHaveBeenCalledWith(getUserPermissionsCacheKey('user-1'));
|
||||
expect(findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when the cached permission is denied or missing', async () => {
|
||||
redisMock.getKey.mockResolvedValue({ printer: { edit: false } });
|
||||
|
||||
await expect(hasPermission('user-1', 'printer', 'edit')).resolves.toBe(false);
|
||||
await expect(hasPermission('user-1', 'printer', 'delete')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('loads permissions from mongodb when redis is empty and caches them', async () => {
|
||||
lean.mockResolvedValue({
|
||||
permissions: { job: { info: true, edit: false } },
|
||||
});
|
||||
|
||||
await expect(hasPermission('user-2', 'job', 'info')).resolves.toBe(true);
|
||||
expect(findById).toHaveBeenCalledWith('user-2');
|
||||
expect(select).toHaveBeenCalledWith('permissions');
|
||||
expect(redisMock.setKey).toHaveBeenCalledWith('permissions:user-2', {
|
||||
job: { info: true, edit: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns false when the user or action cannot be resolved', async () => {
|
||||
await expect(hasPermission(null, 'job', 'info')).resolves.toBe(false);
|
||||
await expect(hasPermission('user-1', '', 'info')).resolves.toBe(false);
|
||||
expect(redisMock.getKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPermissions middleware', () => {
|
||||
const json = jest.fn();
|
||||
const status = jest.fn(() => ({ json }));
|
||||
const next = jest.fn();
|
||||
const res = { status };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
redisMock.getKey.mockResolvedValue(null);
|
||||
redisMock.setKey.mockResolvedValue(undefined);
|
||||
lean.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('calls next when the user is allowed', async () => {
|
||||
redisMock.getKey.mockResolvedValue({ part: { edit: true } });
|
||||
const req = { user: { _id: 'user-1' } };
|
||||
|
||||
await checkPermissions('part', 'edit')(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 403 when the user is not allowed', async () => {
|
||||
redisMock.getKey.mockResolvedValue({ part: { edit: false } });
|
||||
const req = { user: { _id: 'user-1' } };
|
||||
|
||||
await checkPermissions('part', 'edit')(req, res, next);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(403);
|
||||
expect(json).toHaveBeenCalledWith({ error: 'Forbidden', code: 'FORBIDDEN' });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,37 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
import mongoose from 'mongoose';
|
||||
import { convertObjectIdStringsInFilter } from '../utils.js';
|
||||
|
||||
describe('convertObjectIdStringsInFilter', () => {
|
||||
it('preserves Date bounds on comparison operators', () => {
|
||||
const start = new Date('2026-08-04T23:00:00.000Z');
|
||||
const end = new Date('2026-09-04T22:59:59.999Z');
|
||||
|
||||
expect(
|
||||
convertObjectIdStringsInFilter({
|
||||
createdAt: { $gte: start, $lte: end },
|
||||
})
|
||||
).toEqual({
|
||||
createdAt: { $gte: start, $lte: end },
|
||||
});
|
||||
});
|
||||
|
||||
it('converts ObjectId strings while leaving Date values intact', () => {
|
||||
const start = new Date('2026-08-01T00:00:00.000Z');
|
||||
const id = '691a537b1e8acd0b40114206';
|
||||
|
||||
const result = convertObjectIdStringsInFilter({
|
||||
owner: id,
|
||||
createdAt: { $gte: start },
|
||||
});
|
||||
|
||||
expect(result.owner).toEqual(new mongoose.Types.ObjectId(id));
|
||||
expect(result.createdAt.$gte).toBe(start);
|
||||
});
|
||||
});
|
||||
@ -1,10 +1,11 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { userModel } from '../schemas/user.schema.js';
|
||||
import { dbConnect } from '../mongo/index.js';
|
||||
import mongoose from "mongoose";
|
||||
import { userModel } from "../schemas/user.schema.js";
|
||||
import { dbConnect } from "../mongo/index.js";
|
||||
|
||||
async function clear() {
|
||||
dbConnect();
|
||||
await userModel.deleteMany({});
|
||||
console.log("DB cleared");
|
||||
}
|
||||
|
||||
clear().then(() => {
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Convert a value to a CSV cell-friendly format.
|
||||
* Primitives pass through; objects/arrays are stringified; dates are formatted.
|
||||
*/
|
||||
function toCsvValue(val) {
|
||||
if (val === null || val === undefined) return '';
|
||||
if (val instanceof Date) return val.toISOString();
|
||||
if (typeof val === 'number' || typeof val === 'boolean') return String(val);
|
||||
if (typeof val === 'string') return val;
|
||||
if (typeof val === 'object') {
|
||||
if (Array.isArray(val)) return val.map(toCsvValue).join(', ');
|
||||
return JSON.stringify(val);
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a CSV field per RFC 4180: wrap in double quotes, escape internal quotes by doubling.
|
||||
*/
|
||||
function escapeCsvField(str) {
|
||||
if (str == null) return '""';
|
||||
const s = String(str);
|
||||
if (s.includes('"') || s.includes(',') || s.includes('\n') || s.includes('\r')) {
|
||||
return '"' + s.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a CSV buffer from tabular data.
|
||||
* @param {Array<Object>} data - Array of row objects (keys = column headers)
|
||||
* @param {Object} options - Options
|
||||
* @param {string[]} [options.columnOrder] - Optional column order (uses Object.keys of first row if not provided)
|
||||
* @returns {Buffer} CSV file as buffer
|
||||
*/
|
||||
export function generateCsvTable(data, options = {}) {
|
||||
const { columnOrder } = options;
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return Buffer.from('', 'utf8');
|
||||
}
|
||||
|
||||
const keys = columnOrder || Object.keys(data[0]).filter((k) => !k.startsWith('@'));
|
||||
const headerRow = keys.map((k) => escapeCsvField(k)).join(',');
|
||||
const lines = [headerRow];
|
||||
|
||||
for (const row of data) {
|
||||
const values = keys.map((key) => escapeCsvField(toCsvValue(row[key])));
|
||||
lines.push(values.join(','));
|
||||
}
|
||||
|
||||
const csv = lines.join('\n');
|
||||
return Buffer.from(csv, 'utf8');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,160 +0,0 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import log4js from 'log4js';
|
||||
import ExcelJS from 'exceljs';
|
||||
import config from '../config.js';
|
||||
import { redisServer } from './redis.js';
|
||||
|
||||
const logger = log4js.getLogger('Excel');
|
||||
const EXCEL_TEMP_KEY_PREFIX = 'excel:temp:';
|
||||
const EXCEL_TEMP_TTL_SECONDS = 15; // 15 seconds
|
||||
|
||||
const excelNanoid = customAlphabet(
|
||||
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
|
||||
12
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a temp token and store export params in Redis.
|
||||
* @param {Object} params - { objectType, filter, sort, order }
|
||||
* @returns {Promise<{ token: string, url: string }>}
|
||||
*/
|
||||
export async function createExcelTempToken(params) {
|
||||
const baseUrl = config.app?.urlApi?.replace(/\/$/, '') || '';
|
||||
if (!baseUrl) {
|
||||
throw new Error('config.app.urlApi is not set; required for Excel temp URLs');
|
||||
}
|
||||
const objectType = params.objectType || 'Export';
|
||||
const now = new Date();
|
||||
const datetime =
|
||||
now.getFullYear() +
|
||||
String(now.getMonth() + 1).padStart(2, '0') +
|
||||
String(now.getDate()).padStart(2, '0') +
|
||||
String(now.getHours()).padStart(2, '0') +
|
||||
String(now.getMinutes()).padStart(2, '0');
|
||||
const token = `${objectType}s-${datetime}-${excelNanoid()}`;
|
||||
const key = EXCEL_TEMP_KEY_PREFIX + token;
|
||||
const stored = { ...params, requestCount: 0 };
|
||||
await redisServer.setKey(key, stored, EXCEL_TEMP_TTL_SECONDS);
|
||||
logger.debug('Stored excel temp token in Redis:', key);
|
||||
const url = `${baseUrl}/excel/temp/${token}.xlsx`;
|
||||
return { token, url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get export params for a temp token (supports up to 2 requests; requestCount stored in Redis).
|
||||
* @param {string} token
|
||||
* @returns {Promise<Object|null>} { objectType, filter, sort, order } or null
|
||||
*/
|
||||
export async function getExcelTempParams(token) {
|
||||
if (!token) return null;
|
||||
const key = EXCEL_TEMP_KEY_PREFIX + token;
|
||||
const params = await redisServer.getKey(key);
|
||||
if (!params) {
|
||||
logger.debug('Excel temp token not found in Redis:', key);
|
||||
return null;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value to an Excel cell-friendly format.
|
||||
* Primitives pass through; objects/arrays are stringified; dates are preserved.
|
||||
*/
|
||||
function toExcelValue(val) {
|
||||
if (val === null || val === undefined) return null;
|
||||
if (val instanceof Date) return val;
|
||||
if (typeof val === 'number' || typeof val === 'boolean') return val;
|
||||
if (typeof val === 'string') return val;
|
||||
if (typeof val === 'object') {
|
||||
if (Array.isArray(val)) return val.map(toExcelValue).join(', ');
|
||||
return JSON.stringify(val);
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an Excel workbook from tabular data.
|
||||
* @param {Array<Object>} data - Array of row objects (keys = column headers)
|
||||
* @param {Object} options - Options
|
||||
* @param {string} [options.sheetName='Export'] - Worksheet name
|
||||
* @param {string[]} [options.columnOrder] - Optional column order (uses Object.keys of first row if not provided)
|
||||
* @returns {Promise<Buffer>} Excel file as buffer
|
||||
*/
|
||||
export async function generateExcelTable(data, options = {}) {
|
||||
const { sheetName = 'Export', columnOrder } = options;
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet(sheetName, {
|
||||
views: [{ state: 'frozen', ySplit: 1 }],
|
||||
});
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
const keys = columnOrder || Object.keys(data[0]).filter((k) => !k.startsWith('@'));
|
||||
const colCount = keys.length;
|
||||
const rowCount = data.length + 1;
|
||||
|
||||
const toColLetter = (n) => {
|
||||
let s = '';
|
||||
while (n >= 0) {
|
||||
s = String.fromCharCode((n % 26) + 65) + s;
|
||||
n = Math.floor(n / 26) - 1;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const endCol = toColLetter(colCount - 1);
|
||||
|
||||
const tableRows = data.map((row) => keys.map((key) => toExcelValue(row[key])));
|
||||
worksheet.addTable({
|
||||
name: 'DataTable',
|
||||
ref: `A1:${endCol}${rowCount}`,
|
||||
headerRow: true,
|
||||
style: {
|
||||
theme: 'TableStyleLight1',
|
||||
showRowStripes: true,
|
||||
},
|
||||
columns: keys.map((key) => ({ name: key, filterButton: true })),
|
||||
rows: tableRows,
|
||||
});
|
||||
|
||||
// Auto-fit columns (approximate)
|
||||
worksheet.columns.forEach((col, i) => {
|
||||
let maxLen = keys[i]?.length || 10;
|
||||
worksheet.eachRow({ includeEmpty: false }, (row) => {
|
||||
const cell = row.getCell(i + 1);
|
||||
const val = cell.value;
|
||||
const len = val != null ? String(val).length : 0;
|
||||
maxLen = Math.min(Math.max(maxLen, len), 50);
|
||||
});
|
||||
col.width = maxLen + 2;
|
||||
});
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment request count for a temp token. Returns new count or null if token not found.
|
||||
* @param {string} token
|
||||
* @returns {Promise<number|null>} New requestCount or null
|
||||
*/
|
||||
export async function incrementExcelTempRequestCount(token) {
|
||||
if (!token) return null;
|
||||
const key = EXCEL_TEMP_KEY_PREFIX + token;
|
||||
const data = await redisServer.getKey(key);
|
||||
if (!data) return null;
|
||||
const requestCount = (data.requestCount ?? 0) + 1;
|
||||
const updated = { ...data, requestCount };
|
||||
await redisServer.setKey(key, updated, EXCEL_TEMP_TTL_SECONDS);
|
||||
logger.debug('Incremented excel temp request count:', key, requestCount);
|
||||
return requestCount;
|
||||
}
|
||||
|
||||
export async function deleteExcelTempToken(token) {
|
||||
const key = EXCEL_TEMP_KEY_PREFIX + token;
|
||||
await redisServer.deleteKey(key);
|
||||
logger.debug('Deleted excel temp token from Redis:', key);
|
||||
}
|
||||
@ -1,164 +0,0 @@
|
||||
import log4js from 'log4js';
|
||||
import mongoose from 'mongoose';
|
||||
import config from '../config.js';
|
||||
import { listObjects } from './database.js';
|
||||
|
||||
const logger = log4js.getLogger('DatabaseOData');
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
const EXCLUDED_PATHS = ['appPasswordHash', 'secret', '__v'];
|
||||
|
||||
/** Check if path is an embedded object (has nested schema, not ObjectId ref). Excludes arrays. */
|
||||
function isEmbeddedObject(path) {
|
||||
if (!path) return false;
|
||||
if (path.options?.type === mongoose.Schema.Types.ObjectId) return false;
|
||||
if (path.instance === 'Array') return false;
|
||||
return path.schema != null || path.caster?.schema != null;
|
||||
}
|
||||
|
||||
/** Get all top-level property keys declared in OData metadata for a schema. */
|
||||
function getDeclaredPropertyKeys(schema) {
|
||||
if (!schema?.paths) return [];
|
||||
return Object.keys(schema.paths)
|
||||
.filter((k) => !k.includes('.'))
|
||||
.filter((k) => !EXCLUDED_PATHS.includes(k))
|
||||
.filter((k) => schema.paths[k]?.options?.select !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all declared properties are present in each document. OData clients (e.g. Excel)
|
||||
* expect every metadata property to exist; omit none. Use null for missing/invalid values.
|
||||
*/
|
||||
function normalizeODataResponse(data, schema) {
|
||||
const declaredKeys = getDeclaredPropertyKeys(schema);
|
||||
if (declaredKeys.length === 0) return data;
|
||||
|
||||
function normalize(doc) {
|
||||
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return doc;
|
||||
const result = { ...doc };
|
||||
for (const key of declaredKeys) {
|
||||
const val = result[key];
|
||||
if (isEmbeddedObject(schema.paths[key])) {
|
||||
// ComplexType: object or null only
|
||||
const isValid = val != null && typeof val === 'object' && !Array.isArray(val);
|
||||
result[key] = isValid ? val : null;
|
||||
} else {
|
||||
// Primitive, array, etc.: value or null (never omit)
|
||||
result[key] = val !== undefined && val !== null ? val : null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return Array.isArray(data) ? data.map(normalize) : normalize(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert expanded ObjectId refs ({ _id: x }) back to primitive strings for OData.
|
||||
* OData metadata declares refs as Edm.String; clients expect primitives, not objects.
|
||||
*/
|
||||
function compactObjectIdsForOData(data) {
|
||||
function isExpandedRef(val) {
|
||||
return val && typeof val === 'object' && !Array.isArray(val) && Object.keys(val).length === 1 && '_id' in val;
|
||||
}
|
||||
function compact(val) {
|
||||
if (Array.isArray(val)) return val.map(compact);
|
||||
if (isExpandedRef(val)) return val._id?.toString?.() ?? val._id;
|
||||
if (val instanceof Date || val instanceof Buffer) return val; // pass through primitives
|
||||
if (val && typeof val === 'object') {
|
||||
const result = {};
|
||||
for (const [k, v] of Object.entries(val)) result[k] = compact(v);
|
||||
return result;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
return Array.isArray(data) ? data.map(compact) : compact(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate filter keys ending with ._id for Mongoose compatibility
|
||||
* @param {Object} filter - Filter object (will be mutated)
|
||||
*/
|
||||
function translateFilterKeys(filter) {
|
||||
Object.keys(filter).forEach((key) => {
|
||||
if (key.endsWith('._id')) {
|
||||
const baseKey = key.slice(0, -4);
|
||||
filter[baseKey] = filter[key];
|
||||
delete filter[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List objects with OData-compatible response format.
|
||||
* Works like listObjects but returns { @odata.context, @odata.count?, value }.
|
||||
*
|
||||
* @param {Object} options - Same options as listObjects, plus:
|
||||
* @param {boolean} [options.count=false] - When true, include @odata.count (total before $skip/$top)
|
||||
* @returns {Promise<Object>} OData-formatted response or { error, code } on failure
|
||||
*/
|
||||
export const listObjectsOData = async ({
|
||||
model,
|
||||
populate = [],
|
||||
page = 1,
|
||||
limit = 25,
|
||||
filter = {},
|
||||
sort = '',
|
||||
order = 'ascend',
|
||||
pagination = true,
|
||||
project,
|
||||
count = false,
|
||||
}) => {
|
||||
try {
|
||||
const filterCopy = JSON.parse(JSON.stringify(filter));
|
||||
translateFilterKeys(filterCopy);
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
listObjects({
|
||||
model,
|
||||
populate,
|
||||
page,
|
||||
limit,
|
||||
filter: filterCopy,
|
||||
sort,
|
||||
order,
|
||||
pagination,
|
||||
project,
|
||||
}),
|
||||
count ? model.countDocuments(filterCopy) : Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
if (data?.error) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Strip __v from each document (Mongoose version key - causes OData client issues)
|
||||
const sanitizedData = Array.isArray(data)
|
||||
? data.map(({ __v, ...doc }) => doc)
|
||||
: data;
|
||||
|
||||
// Convert expanded ObjectId refs ({ _id: x }) to primitive strings - OData clients expect Edm.String
|
||||
let odataData = compactObjectIdsForOData(sanitizedData);
|
||||
|
||||
// Ensure all declared properties are present; use null for missing values (Excel requires this)
|
||||
odataData = normalizeODataResponse(odataData, model.schema);
|
||||
|
||||
const baseUrl = config.app?.urlApi || '';
|
||||
const modelName = model.modelName;
|
||||
const context = `${baseUrl}/odata/$metadata#${modelName}`;
|
||||
|
||||
const response = {
|
||||
'@odata.context': context,
|
||||
value: odataData,
|
||||
};
|
||||
|
||||
if (count && total !== undefined) {
|
||||
response['@odata.count'] = total;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
logger.error('OData list error:', error);
|
||||
return { error, code: 500 };
|
||||
}
|
||||
};
|
||||
@ -1,153 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { redisServer } from './redis.js';
|
||||
|
||||
import config from '../config.js';
|
||||
import log4js from 'log4js';
|
||||
|
||||
const logger = log4js.getLogger('Permissions');
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
export const getUserPermissionsCacheKey = (userId) => `permissions:${userId}`;
|
||||
|
||||
export const saveUserPermissionsToRedis = async (userId, permissions = {}) => {
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await redisServer.setKey(getUserPermissionsCacheKey(userId), permissions || {});
|
||||
};
|
||||
|
||||
const getUserId = (user) => {
|
||||
if (!user) return null;
|
||||
if (typeof user === 'string') return user;
|
||||
if (user._id) return user._id._id || user._id;
|
||||
return user;
|
||||
};
|
||||
|
||||
const loadPermissionsFromMongo = async (userId) => {
|
||||
const userDoc = await mongoose.model('user').findById(userId).select('permissions').lean();
|
||||
|
||||
return userDoc?.permissions || {};
|
||||
};
|
||||
|
||||
const countTruePermissions = (obj) => {
|
||||
if (obj == null || typeof obj !== 'object') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Object.values(obj).reduce((count, value) => {
|
||||
if (value === true) {
|
||||
return count + 1;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return count + countTruePermissions(value);
|
||||
}
|
||||
return count;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
export const hasPermission = async (user, objectType, action) => {
|
||||
const userId = getUserId(user);
|
||||
if (!userId || !objectType || !action) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.debug(`Checking permission: ${userId}, ${objectType}, ${action}`);
|
||||
|
||||
const cacheKey = getUserPermissionsCacheKey(userId);
|
||||
let permissions = await redisServer.getKey(cacheKey);
|
||||
|
||||
if (permissions == null) {
|
||||
permissions = await loadPermissionsFromMongo(userId);
|
||||
await saveUserPermissionsToRedis(userId, permissions);
|
||||
}
|
||||
|
||||
const allowed = permissions?.[objectType]?.[action] === true;
|
||||
|
||||
logger.debug(
|
||||
`Retrieved permissions for user: ${userId}, Allowed: ${allowed ? 'Yes' : 'No'}, Allowed Count: ${countTruePermissions(permissions)}`
|
||||
);
|
||||
|
||||
return allowed;
|
||||
};
|
||||
|
||||
export const checkPermissions = (objectType, action) => async (req, res, next) => {
|
||||
try {
|
||||
if (req.user?._objectType == 'host') {
|
||||
if (
|
||||
(objectType === 'documentTemplate' && action === 'design') ||
|
||||
(objectType === 'file' && action === 'download')
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
const allowed = await hasPermission(req.user, objectType, action);
|
||||
if (!allowed) {
|
||||
return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
|
||||
}
|
||||
return next();
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
|
||||
export const applyPermissionSettingsList = (settingsList = []) => {
|
||||
const permissions = {};
|
||||
|
||||
for (const setting of settingsList) {
|
||||
const matrix = setting?.permissions;
|
||||
if (!matrix || typeof matrix !== 'object' || Array.isArray(matrix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [modelName, actions] of Object.entries(matrix)) {
|
||||
if (!actions || typeof actions !== 'object' || Array.isArray(actions)) {
|
||||
continue;
|
||||
}
|
||||
if (!permissions[modelName]) {
|
||||
permissions[modelName] = {};
|
||||
}
|
||||
for (const [actionName, value] of Object.entries(actions)) {
|
||||
if (value === true || value === false) {
|
||||
permissions[modelName][actionName] = value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(permissions[modelName]).length === 0) {
|
||||
delete permissions[modelName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return permissions;
|
||||
};
|
||||
|
||||
export const getPermissionSettingsId = (value) => {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'string') return value;
|
||||
if (value._id) {
|
||||
return value._id._id || value._id;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const excludeIdFromList = (items = [], id) =>
|
||||
(items || []).filter((item) => String(getPermissionSettingsId(item)) !== String(id));
|
||||
|
||||
export const resolveReferencedDocs = async (modelName, items = []) => {
|
||||
const ids = (items || []).map(getPermissionSettingsId).filter(Boolean);
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const docs = await mongoose
|
||||
.model(modelName)
|
||||
.find({ _id: { $in: ids } })
|
||||
.lean();
|
||||
const docsById = new Map(docs.map((doc) => [String(doc._id), doc]));
|
||||
|
||||
return ids.map((id) => docsById.get(String(id))).filter(Boolean);
|
||||
};
|
||||
|
||||
export const resolvePermissionSettings = async (owner) => {
|
||||
return resolveReferencedDocs('permissionSetting', owner?.permissionSettings);
|
||||
};
|
||||
@ -1,4 +1,4 @@
|
||||
import Redis from 'ioredis';
|
||||
import { createClient } from 'redis';
|
||||
import log4js from 'log4js';
|
||||
import config from '../config.js';
|
||||
|
||||
@ -7,14 +7,11 @@ logger.level = config.server.logLevel;
|
||||
|
||||
class RedisServer {
|
||||
constructor() {
|
||||
const url =
|
||||
config.database.redis.url ||
|
||||
`redis://${config.database.redis.host}:${config.database.redis.port}`;
|
||||
const password = config.database.redis.password || undefined;
|
||||
const url = config.database.redis.url || `redis://${config.database.redis.host}:${config.database.redis.port}`;
|
||||
|
||||
this.client = new Redis(url, {
|
||||
password,
|
||||
lazyConnect: true,
|
||||
this.client = createClient({
|
||||
url,
|
||||
password: config.database.redis.password || undefined,
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
@ -36,7 +33,7 @@ class RedisServer {
|
||||
await this.connect();
|
||||
const payload = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
if (ttlSeconds) {
|
||||
await this.client.set(key, payload, 'EX', ttlSeconds);
|
||||
await this.client.set(key, payload, { EX: ttlSeconds });
|
||||
} else {
|
||||
await this.client.set(key, payload);
|
||||
}
|
||||
@ -58,41 +55,17 @@ class RedisServer {
|
||||
await this.client.del(key);
|
||||
}
|
||||
|
||||
async getAndDeleteKey(key) {
|
||||
await this.connect();
|
||||
const value = await this.client.getdel(key);
|
||||
if (value == null) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
async eval(script, keys = [], args = []) {
|
||||
await this.connect();
|
||||
return this.client.eval(
|
||||
script,
|
||||
keys.length,
|
||||
...keys,
|
||||
...args.map((arg) => String(arg))
|
||||
);
|
||||
}
|
||||
|
||||
async getKeysByPattern(pattern) {
|
||||
await this.connect();
|
||||
const keys = [];
|
||||
let cursor = '0';
|
||||
do {
|
||||
const [nextCursor, foundKeys] = await this.client.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
pattern,
|
||||
'COUNT',
|
||||
100
|
||||
);
|
||||
cursor = nextCursor;
|
||||
keys.push(...foundKeys);
|
||||
const result = await this.client.scan(cursor, {
|
||||
MATCH: pattern,
|
||||
COUNT: 100,
|
||||
});
|
||||
cursor = result.cursor;
|
||||
keys.push(...result.keys);
|
||||
} while (cursor !== '0');
|
||||
return keys;
|
||||
}
|
||||
|
||||
@ -1,416 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
jest.unstable_mockModule('../../database.js', () => ({
|
||||
searchObjects: jest.fn(),
|
||||
getPropertyValues: jest.fn(),
|
||||
listObjects: jest.fn(),
|
||||
getObject: jest.fn(),
|
||||
editObject: jest.fn(),
|
||||
editObjects: jest.fn(),
|
||||
newObject: jest.fn(),
|
||||
deleteObject: jest.fn(),
|
||||
listObjectsByProperties: jest.fn(),
|
||||
getModelStats: jest.fn(),
|
||||
getModelHistory: jest.fn(),
|
||||
aggregateRollups: jest.fn(),
|
||||
aggregateRollupsHistory: jest.fn(),
|
||||
checkStates: jest.fn(),
|
||||
getObjectNeighbors: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../utils.js', () => ({
|
||||
generateId: jest.fn(() => () => 'test-id'),
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../inventory/stockaudit.schema.js', () => ({
|
||||
updateDraftStockAuditCurrents: jest.fn().mockResolvedValue(),
|
||||
}));
|
||||
|
||||
const { aggregateRollups, editObject, newObject, deleteObject } = await import('../../database.js');
|
||||
const { listingModel } = await import('../sales/listing.schema.js');
|
||||
const { listingVarientModel } = await import('../sales/listingvarient.schema.js');
|
||||
const { productSkuModel } = await import('../management/productsku.schema.js');
|
||||
const { productStockModel } = await import('../inventory/productstock.schema.js');
|
||||
|
||||
const listingId = new mongoose.Types.ObjectId();
|
||||
const productId = new mongoose.Types.ObjectId();
|
||||
const productSkuId = new mongoose.Types.ObjectId();
|
||||
const stockLocationId = new mongoose.Types.ObjectId();
|
||||
const varientId = new mongoose.Types.ObjectId();
|
||||
|
||||
const mockFind = (docs) => ({
|
||||
sort: () => ({ lean: async () => docs }),
|
||||
});
|
||||
|
||||
describe('listing.recalculate', () => {
|
||||
beforeEach(() => {
|
||||
editObject.mockReset();
|
||||
newObject.mockReset();
|
||||
deleteObject.mockReset();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('calls recalculate on each listing varient', async () => {
|
||||
const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
|
||||
jest.spyOn(listingVarientModel, 'find').mockReturnValue(
|
||||
mockFind([{ _id: varientId, listing: listingId }])
|
||||
);
|
||||
|
||||
const listing = { _id: listingId, stockLocation: stockLocationId };
|
||||
await listingModel.recalculate(listing, 'user-1');
|
||||
|
||||
expect(listingVarientModel.find).toHaveBeenCalledWith({ listing: listingId });
|
||||
expect(recalculate).toHaveBeenCalledWith({ _id: varientId, listing: listingId }, 'user-1');
|
||||
expect(newObject).not.toHaveBeenCalled();
|
||||
expect(deleteObject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates listing varients from product skus when none exist', async () => {
|
||||
const skuBId = new mongoose.Types.ObjectId();
|
||||
const createdVarientId = new mongoose.Types.ObjectId();
|
||||
const productSkus = [{ _id: productSkuId }, { _id: skuBId }];
|
||||
const syncedVarients = [
|
||||
{ _id: varientId, listing: listingId, product: productId, productSku: productSkuId },
|
||||
{ _id: createdVarientId, listing: listingId, product: productId, productSku: skuBId },
|
||||
];
|
||||
|
||||
const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
|
||||
jest
|
||||
.spyOn(listingVarientModel, 'find')
|
||||
.mockReturnValueOnce(mockFind([]))
|
||||
.mockReturnValueOnce(mockFind(syncedVarients));
|
||||
jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind(productSkus));
|
||||
newObject.mockResolvedValue({ _id: createdVarientId });
|
||||
|
||||
await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1');
|
||||
|
||||
expect(productSkuModel.find).toHaveBeenCalledWith({ product: productId });
|
||||
expect(editObject).not.toHaveBeenCalled();
|
||||
expect(deleteObject).not.toHaveBeenCalled();
|
||||
expect(newObject).toHaveBeenCalledTimes(2);
|
||||
expect(newObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
newData: expect.objectContaining({
|
||||
listing: listingId,
|
||||
product: productId,
|
||||
productSku: productSkuId,
|
||||
state: { type: 'draft' },
|
||||
}),
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
expect(recalculate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('creates missing listing varients when one already matches a sku', async () => {
|
||||
const skuBId = new mongoose.Types.ObjectId();
|
||||
const skuCId = new mongoose.Types.ObjectId();
|
||||
const existingVarient = {
|
||||
_id: varientId,
|
||||
listing: listingId,
|
||||
product: productId,
|
||||
productSku: productSkuId,
|
||||
};
|
||||
|
||||
const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
|
||||
jest
|
||||
.spyOn(listingVarientModel, 'find')
|
||||
.mockReturnValueOnce(mockFind([existingVarient]))
|
||||
.mockReturnValueOnce(
|
||||
mockFind([
|
||||
existingVarient,
|
||||
{ _id: new mongoose.Types.ObjectId(), listing: listingId, product: productId, productSku: skuBId },
|
||||
{ _id: new mongoose.Types.ObjectId(), listing: listingId, product: productId, productSku: skuCId },
|
||||
])
|
||||
);
|
||||
jest
|
||||
.spyOn(productSkuModel, 'find')
|
||||
.mockReturnValue(mockFind([{ _id: productSkuId }, { _id: skuBId }, { _id: skuCId }]));
|
||||
newObject.mockResolvedValue({ _id: new mongoose.Types.ObjectId() });
|
||||
|
||||
await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1');
|
||||
|
||||
expect(editObject).not.toHaveBeenCalled();
|
||||
expect(newObject).toHaveBeenCalledTimes(2);
|
||||
expect(newObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
newData: expect.objectContaining({
|
||||
listing: listingId,
|
||||
product: productId,
|
||||
productSku: skuBId,
|
||||
state: { type: 'draft' },
|
||||
}),
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
expect(recalculate).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rebuilds listing varients when an existing varient is missing a product sku', async () => {
|
||||
const skuBId = new mongoose.Types.ObjectId();
|
||||
jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
|
||||
jest
|
||||
.spyOn(listingVarientModel, 'find')
|
||||
.mockReturnValueOnce(
|
||||
mockFind([{ _id: varientId, listing: listingId, product: productId }])
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
mockFind([
|
||||
{ _id: varientId, listing: listingId, product: productId, productSku: productSkuId },
|
||||
{
|
||||
_id: new mongoose.Types.ObjectId(),
|
||||
listing: listingId,
|
||||
product: productId,
|
||||
productSku: skuBId,
|
||||
},
|
||||
])
|
||||
);
|
||||
jest
|
||||
.spyOn(productSkuModel, 'find')
|
||||
.mockReturnValue(mockFind([{ _id: productSkuId }, { _id: skuBId }]));
|
||||
editObject.mockResolvedValue({});
|
||||
newObject.mockResolvedValue({ _id: new mongoose.Types.ObjectId() });
|
||||
|
||||
await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1');
|
||||
|
||||
expect(editObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
id: varientId,
|
||||
updateData: expect.objectContaining({
|
||||
product: productId,
|
||||
productSku: productSkuId,
|
||||
}),
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
expect(newObject).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not rebuild varients when they already match the product skus', async () => {
|
||||
const existingVarient = {
|
||||
_id: varientId,
|
||||
listing: listingId,
|
||||
product: productId,
|
||||
productSku: productSkuId,
|
||||
};
|
||||
const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
|
||||
jest.spyOn(listingVarientModel, 'find').mockReturnValue(mockFind([existingVarient]));
|
||||
jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind([{ _id: productSkuId }]));
|
||||
|
||||
await listingModel.recalculate(
|
||||
{ _id: listingId, product: productId, stockLocation: stockLocationId },
|
||||
'user-1'
|
||||
);
|
||||
|
||||
expect(newObject).not.toHaveBeenCalled();
|
||||
expect(deleteObject).not.toHaveBeenCalled();
|
||||
expect(editObject).not.toHaveBeenCalled();
|
||||
expect(recalculate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates and updates listing varients to match product skus when a product differs', async () => {
|
||||
const otherProductId = new mongoose.Types.ObjectId();
|
||||
const skuBId = new mongoose.Types.ObjectId();
|
||||
const skuCId = new mongoose.Types.ObjectId();
|
||||
const createdVarientId = new mongoose.Types.ObjectId();
|
||||
const existingVarients = [{ _id: varientId, listing: listingId, product: otherProductId }];
|
||||
const productSkus = [{ _id: productSkuId }, { _id: skuBId }, { _id: skuCId }];
|
||||
const syncedVarients = [
|
||||
{ _id: varientId, listing: listingId, product: productId, productSku: productSkuId },
|
||||
{ _id: createdVarientId, listing: listingId, product: productId, productSku: skuBId },
|
||||
{ _id: new mongoose.Types.ObjectId(), listing: listingId, product: productId, productSku: skuCId },
|
||||
];
|
||||
|
||||
const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
|
||||
jest
|
||||
.spyOn(listingVarientModel, 'find')
|
||||
.mockReturnValueOnce(mockFind(existingVarients))
|
||||
.mockReturnValueOnce(mockFind(syncedVarients));
|
||||
jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind(productSkus));
|
||||
editObject.mockResolvedValue({});
|
||||
newObject.mockResolvedValue({ _id: createdVarientId });
|
||||
|
||||
await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1');
|
||||
|
||||
expect(editObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
id: varientId,
|
||||
updateData: expect.objectContaining({
|
||||
product: productId,
|
||||
productSku: productSkuId,
|
||||
}),
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
expect(newObject).toHaveBeenCalledTimes(2);
|
||||
expect(newObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
newData: expect.objectContaining({
|
||||
listing: listingId,
|
||||
product: productId,
|
||||
productSku: skuBId,
|
||||
state: { type: 'draft' },
|
||||
}),
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
expect(deleteObject).not.toHaveBeenCalled();
|
||||
expect(recalculate).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('deletes extra listing varients when the product has fewer skus', async () => {
|
||||
const otherProductId = new mongoose.Types.ObjectId();
|
||||
const extraVarientId = new mongoose.Types.ObjectId();
|
||||
const existingVarients = [
|
||||
{ _id: varientId, listing: listingId, product: otherProductId },
|
||||
{ _id: extraVarientId, listing: listingId, product: otherProductId },
|
||||
];
|
||||
|
||||
jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
|
||||
jest
|
||||
.spyOn(listingVarientModel, 'find')
|
||||
.mockReturnValueOnce(mockFind(existingVarients))
|
||||
.mockReturnValueOnce(
|
||||
mockFind([{ _id: varientId, listing: listingId, product: productId, productSku: productSkuId }])
|
||||
);
|
||||
jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind([{ _id: productSkuId }]));
|
||||
editObject.mockResolvedValue({});
|
||||
deleteObject.mockResolvedValue({});
|
||||
|
||||
await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1');
|
||||
|
||||
expect(editObject).toHaveBeenCalledTimes(1);
|
||||
expect(newObject).not.toHaveBeenCalled();
|
||||
expect(deleteObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
id: extraVarientId,
|
||||
user: 'user-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listingVarient.recalculate', () => {
|
||||
beforeEach(() => {
|
||||
aggregateRollups.mockReset();
|
||||
editObject.mockReset();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('sums sibling listing varient stock quantities onto the listing', async () => {
|
||||
aggregateRollups.mockResolvedValue({ stockQuantity: { sum: 12 } });
|
||||
editObject.mockResolvedValue({});
|
||||
|
||||
await listingVarientModel.recalculate({ listing: listingId, stockQuantity: 4 }, 'user-1');
|
||||
|
||||
expect(aggregateRollups).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: listingVarientModel,
|
||||
baseFilter: { listing: listingId },
|
||||
})
|
||||
);
|
||||
expect(editObject).toHaveBeenCalledWith({
|
||||
model: listingModel,
|
||||
id: listingId,
|
||||
updateData: { stockQuantity: 12 },
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes the product sku stock total onto the listing varient before rolling up', async () => {
|
||||
jest.spyOn(listingVarientModel, 'exists').mockResolvedValue({ _id: varientId });
|
||||
aggregateRollups.mockImplementation(async ({ model }) => {
|
||||
if (model === productStockModel) {
|
||||
return { stockQuantity: { sum: 9 } };
|
||||
}
|
||||
return { stockQuantity: { sum: 12 } };
|
||||
});
|
||||
editObject.mockResolvedValue({});
|
||||
|
||||
await listingVarientModel.recalculate(
|
||||
{
|
||||
_id: varientId,
|
||||
listing: { _id: listingId, stockLocation: stockLocationId, product: productId },
|
||||
product: productId,
|
||||
productSku: productSkuId,
|
||||
stockQuantity: 0,
|
||||
},
|
||||
'user-1'
|
||||
);
|
||||
|
||||
expect(aggregateRollups).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: productStockModel,
|
||||
baseFilter: {
|
||||
productSku: productSkuId,
|
||||
stockLocation: stockLocationId,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(editObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
id: varientId,
|
||||
updateData: { stockQuantity: 9 },
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
expect(editObject).toHaveBeenCalledWith({
|
||||
model: listingModel,
|
||||
id: listingId,
|
||||
updateData: { stockQuantity: 12 },
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('productStock.recalculate', () => {
|
||||
beforeEach(() => {
|
||||
aggregateRollups.mockReset();
|
||||
editObject.mockReset();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('writes the sku/location total onto matching listing varients', async () => {
|
||||
aggregateRollups.mockResolvedValue({ stockQuantity: { sum: 9 } });
|
||||
editObject.mockResolvedValue({});
|
||||
jest.spyOn(productSkuModel, 'findById').mockReturnValue({
|
||||
select: () => ({ lean: async () => ({ product: productId }) }),
|
||||
});
|
||||
jest.spyOn(listingVarientModel, 'find').mockReturnValue({
|
||||
populate: () => ({
|
||||
lean: async () => [
|
||||
{
|
||||
_id: varientId,
|
||||
product: productId,
|
||||
productSku: productSkuId,
|
||||
stockQuantity: 0,
|
||||
listing: { product: productId, stockLocation: stockLocationId },
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await productStockModel.recalculate(
|
||||
{ productSku: productSkuId, stockLocation: stockLocationId, currentQuantity: 9 },
|
||||
'user-1'
|
||||
);
|
||||
|
||||
expect(aggregateRollups).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: productStockModel,
|
||||
baseFilter: {
|
||||
productSku: productSkuId,
|
||||
stockLocation: stockLocationId,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(editObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
id: varientId,
|
||||
updateData: { stockQuantity: 9 },
|
||||
user: 'user-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,251 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
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(
|
||||
{
|
||||
orderItem: { type: Schema.Types.ObjectId, ref: 'orderItem', required: true },
|
||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
invoiceAmountWithTax: { type: Number, required: true, default: 0 },
|
||||
invoiceAmount: { type: Number, required: true, default: 0 },
|
||||
invoiceQuantity: { type: Number, required: true, default: 0 },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
const invoiceShipmentSchema = new Schema(
|
||||
{
|
||||
shipment: { type: Schema.Types.ObjectId, ref: 'shipment', required: true },
|
||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
invoiceAmountWithTax: { type: Number, required: true, default: 0 },
|
||||
invoiceAmount: { type: Number, required: true, default: 0 },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
const invoiceSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
totalAmount: { type: Number, required: true, default: 0 },
|
||||
totalAmountWithTax: { type: Number, required: true, default: 0 },
|
||||
shippingAmount: { type: Number, required: true, default: 0 },
|
||||
shippingAmountWithTax: { type: Number, required: true, default: 0 },
|
||||
grandTotalAmount: { type: Number, required: true, default: 0 },
|
||||
totalTaxAmount: { type: Number, required: true, default: 0 },
|
||||
from: { type: Schema.Types.ObjectId, refPath: 'fromType', required: false },
|
||||
fromType: { type: String, required: false },
|
||||
to: { type: Schema.Types.ObjectId, refPath: 'toType', required: false },
|
||||
toType: { type: String, required: false },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'draft' },
|
||||
},
|
||||
orderType: { type: String, required: true },
|
||||
order: { type: Schema.Types.ObjectId, refPath: 'orderType', required: true },
|
||||
issuedAt: { type: Date, required: false },
|
||||
dueAt: { type: Date, required: false },
|
||||
postedAt: { type: Date, required: false },
|
||||
acknowledgedAt: { type: Date, required: false },
|
||||
paidAt: { type: Date, required: false },
|
||||
cancelledAt: { type: Date, required: false },
|
||||
invoiceOrderItems: [invoiceOrderItemSchema],
|
||||
invoiceShipments: [invoiceShipmentSchema],
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
invoiceSchema.index({ orderType: 'text', fromType: 'text', toType: 'text' });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'draft',
|
||||
filter: { 'state.type': 'draft' },
|
||||
rollups: [
|
||||
{ name: 'draftCount', property: 'state.type', operation: 'count' },
|
||||
{ name: 'draftGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sent',
|
||||
filter: { 'state.type': 'sent' },
|
||||
rollups: [
|
||||
{ name: 'sentCount', property: 'state.type', operation: 'count' },
|
||||
{ name: 'sentGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'acknowledged',
|
||||
filter: { 'state.type': 'acknowledged' },
|
||||
rollups: [
|
||||
{ name: 'acknowledgedCount', property: 'state.type', operation: 'count' },
|
||||
{ name: 'acknowledgedGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'partiallyPaid',
|
||||
filter: { 'state.type': 'partiallyPaid' },
|
||||
rollups: [
|
||||
{ name: 'partiallyPaidCount', property: 'state.type', operation: 'count' },
|
||||
{ name: 'partiallyPaidGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'paid',
|
||||
filter: { 'state.type': 'paid' },
|
||||
rollups: [
|
||||
{ name: 'paidCount', property: 'state.type', operation: 'count' },
|
||||
{ name: 'paidGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'overdue',
|
||||
filter: { 'state.type': 'overdue' },
|
||||
rollups: [
|
||||
{ name: 'overdueCount', property: 'state.type', operation: 'count' },
|
||||
{ name: 'overdueGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'cancelled',
|
||||
filter: { 'state.type': 'cancelled' },
|
||||
rollups: [{ name: 'cancelledCount', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
invoiceSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
// Transform the results to match the expected format
|
||||
return results;
|
||||
};
|
||||
|
||||
invoiceSchema.statics.history = async function (from, to) {
|
||||
const results = await aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
// Return time-series data array
|
||||
return results;
|
||||
};
|
||||
|
||||
invoiceSchema.statics.scheduledProperties = [
|
||||
{
|
||||
name: 'dueAt',
|
||||
filter: { state: 'due|sent|acknowledged' },
|
||||
type: 'dateTime',
|
||||
},
|
||||
];
|
||||
|
||||
invoiceSchema.statics.onSchedulerEvent = async function (invoice, property) {
|
||||
const invoiceId = invoice._id || invoice;
|
||||
if (!invoiceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (property === 'dueAt' && invoice.dueAt && invoice.dueAt < new Date()) {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: invoiceId,
|
||||
updateData: {
|
||||
state: { type: 'overdue' },
|
||||
},
|
||||
user: 'system',
|
||||
recalculate: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
invoiceSchema.statics.recalculate = async function (invoice, user) {
|
||||
const invoiceId = invoice._id || invoice;
|
||||
if (!invoiceId) {
|
||||
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 invoiceOrderItems) {
|
||||
totalAmount += Number.parseFloat(item.invoiceAmount) || 0;
|
||||
}
|
||||
let totalAmountWithTax = 0;
|
||||
for (const item of invoiceOrderItems) {
|
||||
totalAmountWithTax += Number.parseFloat(item.invoiceAmountWithTax) || 0;
|
||||
}
|
||||
|
||||
// Calculate shipping totals from invoiceShipments
|
||||
let shippingAmount = 0;
|
||||
for (const item of invoiceShipments) {
|
||||
shippingAmount += Number.parseFloat(item.invoiceAmount) || 0;
|
||||
}
|
||||
let shippingAmountWithTax = 0;
|
||||
for (const item of invoiceShipments) {
|
||||
shippingAmountWithTax += Number.parseFloat(item.invoiceAmountWithTax) || 0;
|
||||
}
|
||||
|
||||
// Calculate grand total and tax amount
|
||||
const grandTotalAmount = parseFloat(totalAmountWithTax) + parseFloat(shippingAmountWithTax);
|
||||
const totalTaxAmount =
|
||||
parseFloat(totalAmountWithTax) -
|
||||
parseFloat(totalAmount) +
|
||||
(parseFloat(shippingAmountWithTax) - parseFloat(shippingAmount));
|
||||
|
||||
const updateData = {
|
||||
invoiceOrderItems,
|
||||
invoiceShipments,
|
||||
totalAmount: parseFloat(totalAmount).toFixed(2),
|
||||
totalAmountWithTax: parseFloat(totalAmountWithTax).toFixed(2),
|
||||
shippingAmount: parseFloat(shippingAmount).toFixed(2),
|
||||
shippingAmountWithTax: parseFloat(shippingAmountWithTax).toFixed(2),
|
||||
grandTotalAmount: parseFloat(grandTotalAmount).toFixed(2),
|
||||
totalTaxAmount: parseFloat(totalTaxAmount).toFixed(2),
|
||||
};
|
||||
|
||||
await editObject({
|
||||
model: this,
|
||||
id: invoiceId,
|
||||
updateData,
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
invoiceSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
invoiceSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
// Create and export the model
|
||||
export const invoiceModel = mongoose.model('invoice', invoiceSchema);
|
||||
@ -1,124 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||
|
||||
const paymentSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
amount: { type: Number, required: true, default: 0 },
|
||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: false },
|
||||
client: { type: Schema.Types.ObjectId, ref: 'client', required: false },
|
||||
invoice: { type: Schema.Types.ObjectId, ref: 'invoice', required: true },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'draft' },
|
||||
},
|
||||
postedAt: { type: Date, required: false },
|
||||
authorisedAt: { type: Date, required: false },
|
||||
declinedAt: { type: Date, required: false },
|
||||
cancelledAt: { type: Date, required: false },
|
||||
paymentMethod: { type: String, required: false },
|
||||
notes: { type: String, required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
paymentSchema.index({ paymentMethod: 'text', notes: 'text' });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'draft',
|
||||
filter: { 'state.type': 'draft' },
|
||||
rollups: [
|
||||
{ name: 'draftCount', property: 'state.type', operation: 'count' },
|
||||
{ name: 'draftAmount', property: 'amount', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'posted',
|
||||
filter: { 'state.type': 'posted' },
|
||||
rollups: [
|
||||
{ name: 'postedCount', property: 'state.type', operation: 'count' },
|
||||
{ name: 'postedAmount', property: 'amount', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'authorised',
|
||||
filter: { 'state.type': 'authorised' },
|
||||
rollups: [
|
||||
{ name: 'authorisedCount', property: 'state.type', operation: 'count' },
|
||||
{ name: 'authorisedAmount', property: 'amount', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'declined',
|
||||
filter: { 'state.type': 'declined' },
|
||||
rollups: [
|
||||
{ name: 'declinedCount', property: 'state.type', operation: 'count' },
|
||||
{ name: 'declinedAmount', property: 'amount', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'cancelled',
|
||||
filter: { 'state.type': 'cancelled' },
|
||||
rollups: [
|
||||
{ name: 'cancelledCount', property: 'state.type', operation: 'count' },
|
||||
{ name: 'cancelledAmount', property: 'amount', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
paymentSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
// Transform the results to match the expected format
|
||||
return results;
|
||||
};
|
||||
|
||||
paymentSchema.statics.history = async function (from, to) {
|
||||
const results = await aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
// Return time-series data array
|
||||
return results;
|
||||
};
|
||||
|
||||
paymentSchema.statics.recalculate = async function (payment, user) {
|
||||
const paymentId = payment._id || payment;
|
||||
if (!paymentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For payments, the amount is set directly
|
||||
const amount = payment.amount || 0;
|
||||
|
||||
const updateData = {
|
||||
amount: parseFloat(amount).toFixed(2),
|
||||
};
|
||||
|
||||
await editObject({
|
||||
model: this,
|
||||
id: paymentId,
|
||||
updateData,
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
paymentSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
paymentSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
// Create and export the model
|
||||
export const paymentModel = mongoose.model('payment', paymentSchema);
|
||||
@ -1,25 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { marketplaceSyncMappingSchema } from '../sales/marketplaceMapping.schema.js';
|
||||
|
||||
const paymentPolicySchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
description: { type: String, required: false },
|
||||
immediatePay: { type: Boolean, required: false, default: true },
|
||||
paymentInstructions: { type: String, required: false },
|
||||
marketplaces: { type: [marketplaceSyncMappingSchema()], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
paymentPolicySchema.index({ name: 'text', description: 'text', paymentInstructions: 'text' });
|
||||
|
||||
paymentPolicySchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
paymentPolicySchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const paymentPolicyModel = mongoose.model('paymentPolicy', paymentPolicySchema);
|
||||
@ -1,69 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const taxRecordSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: true },
|
||||
transactionType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['purchaseOrder', 'salesOrder', 'other'],
|
||||
},
|
||||
transaction: { type: Schema.Types.ObjectId, refPath: 'transactionType', required: true },
|
||||
amount: { type: Number, required: true },
|
||||
taxAmount: { type: Number, required: true },
|
||||
transactionDate: { required: true, type: Date, default: Date.now },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
taxRecordSchema.index({ transactionType: 'text' });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'total',
|
||||
filter: {},
|
||||
rollups: [
|
||||
{ name: 'count', property: 'amount', operation: 'count' },
|
||||
{ name: 'amount', property: 'amount', operation: 'sum' },
|
||||
{ name: 'taxAmount', property: 'taxAmount', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'salesOrder',
|
||||
filter: { transactionType: 'salesOrder' },
|
||||
rollups: [{ name: 'taxAmount', property: 'taxAmount', operation: 'sum' }],
|
||||
},
|
||||
{
|
||||
name: 'purchaseOrder',
|
||||
filter: { transactionType: 'purchaseOrder' },
|
||||
rollups: [{ name: 'taxAmount', property: 'taxAmount', operation: 'sum' }],
|
||||
},
|
||||
];
|
||||
|
||||
taxRecordSchema.statics.stats = async function () {
|
||||
return aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs,
|
||||
});
|
||||
};
|
||||
|
||||
taxRecordSchema.statics.history = async function (from, to) {
|
||||
return aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs,
|
||||
});
|
||||
};
|
||||
|
||||
taxRecordSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
taxRecordSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const taxRecordModel = mongoose.model('taxRecord', taxRecordSchema);
|
||||
@ -2,23 +2,15 @@ import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
||||
import { updateDraftStockAuditCurrents } from './stockaudit.schema.js';
|
||||
|
||||
const toId = (value) => {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'object' && value._id) return String(value._id);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// Define the main filamentStock schema
|
||||
const filamentStockSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'draft' },
|
||||
type: { type: String, required: true },
|
||||
progress: { type: Number, required: false },
|
||||
},
|
||||
postedAt: { type: Date, required: false },
|
||||
startingWeight: {
|
||||
net: { type: Number, required: true },
|
||||
gross: { type: Number, required: true },
|
||||
@ -27,65 +19,17 @@ const filamentStockSchema = new Schema(
|
||||
net: { type: Number, required: true },
|
||||
gross: { type: Number, required: true },
|
||||
},
|
||||
history: [
|
||||
{
|
||||
currentWeight: {
|
||||
net: { type: Number, required: true },
|
||||
gross: { type: Number, required: true },
|
||||
},
|
||||
timestamp: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
filament: { type: mongoose.Schema.Types.ObjectId, ref: 'filament', required: true },
|
||||
filamentSku: { type: mongoose.Schema.Types.ObjectId, ref: 'filamentSku', required: true },
|
||||
stockLocation: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'stockLocation',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
filamentStockSchema.index({ 'state.type': 'text' });
|
||||
|
||||
filamentStockSchema.pre('validate', async function () {
|
||||
if (!this.filament && this.filamentSku) {
|
||||
const sku = await mongoose
|
||||
.model('filamentSku')
|
||||
.findById(this.filamentSku)
|
||||
.select('filament')
|
||||
.lean();
|
||||
if (sku?.filament) this.filament = sku.filament;
|
||||
}
|
||||
});
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'totalCurrentWeight',
|
||||
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' },
|
||||
rollups: [{ name: 'unconsumed', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'used',
|
||||
filter: { 'state.type': 'used' },
|
||||
rollups: [{ name: 'used', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'consumed',
|
||||
filter: { 'state.type': 'consumed' },
|
||||
rollups: [{ name: 'consumed', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
filamentStockSchema.statics.stats = async function () {
|
||||
@ -109,21 +53,6 @@ filamentStockSchema.statics.history = async function (from, to) {
|
||||
return results;
|
||||
};
|
||||
|
||||
filamentStockSchema.statics.recalculate = async function (filamentStock, user) {
|
||||
const itemSkuId = toId(filamentStock?.filamentSku);
|
||||
const stockLocationId = toId(filamentStock?.stockLocation);
|
||||
if (!itemSkuId || !stockLocationId) return;
|
||||
|
||||
await updateDraftStockAuditCurrents({
|
||||
itemType: 'filament',
|
||||
itemSkuId,
|
||||
stockLocationId,
|
||||
user,
|
||||
});
|
||||
|
||||
if (filamentStock.state?.type === 'draft' || filamentStock.state?.type === 'consumed') return;
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
filamentStockSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -1,139 +1,30 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { purchaseOrderModel } from './purchaseorder.schema.js';
|
||||
import { salesOrderModel } from '../sales/salesorder.schema.js';
|
||||
import { taxRateModel } from '../management/taxrate.schema.js';
|
||||
import { filamentModel } from '../management/filament.schema.js';
|
||||
import { filamentSkuModel } from '../management/filamentsku.schema.js';
|
||||
import { partModel } from '../management/part.schema.js';
|
||||
import { partSkuModel } from '../management/partsku.schema.js';
|
||||
import { productModel } from '../management/product.schema.js';
|
||||
import { productSkuModel } from '../management/productsku.schema.js';
|
||||
import {
|
||||
aggregateRollups,
|
||||
aggregateRollupsHistory,
|
||||
editObject,
|
||||
getObject,
|
||||
} from '../../database.js';
|
||||
import { aggregateRollups, editObject } from '../../database.js';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { amountWithTax, resolveTaxRate } from '../../tax.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const skuModelsByItemType = {
|
||||
filament: filamentSkuModel,
|
||||
part: partSkuModel,
|
||||
product: productSkuModel,
|
||||
};
|
||||
|
||||
const parentModelsByItemType = {
|
||||
filament: filamentModel,
|
||||
part: partModel,
|
||||
product: productModel,
|
||||
};
|
||||
|
||||
const orderItemSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
orderType: { type: String, required: true },
|
||||
name: { type: String, required: true },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'draft' },
|
||||
},
|
||||
order: { type: Schema.Types.ObjectId, refPath: 'orderType', required: true },
|
||||
itemType: { type: String, required: true },
|
||||
item: { type: Schema.Types.ObjectId, refPath: 'itemType', required: false },
|
||||
sku: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: function () {
|
||||
return ['filament', 'part', 'product'].includes(this.itemType)
|
||||
? this.itemType + 'Sku'
|
||||
: null;
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
syncAmount: { type: String, required: false, default: null },
|
||||
item: { type: Schema.Types.ObjectId, refPath: 'itemType', required: true },
|
||||
syncAmount: { type: String, required: true, default: null },
|
||||
itemAmount: { type: Number, required: true },
|
||||
quantity: { type: Number, required: true },
|
||||
totalAmount: { type: Number, required: true },
|
||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
totalAmountWithTax: { type: Number, required: true },
|
||||
invoicedAmountWithTax: { type: Number, required: false, default: 0 },
|
||||
invoicedAmount: { type: Number, required: false, default: 0 },
|
||||
invoicedQuantity: { type: Number, required: false, default: 0 },
|
||||
invoicedAmountRemaining: { type: Number, required: false, default: 0 },
|
||||
invoicedAmountWithTaxRemaining: { type: Number, required: false, default: 0 },
|
||||
invoicedQuantityRemaining: { type: Number, required: false, default: 0 },
|
||||
timestamp: { type: Date, default: Date.now },
|
||||
shipment: { type: Schema.Types.ObjectId, ref: 'shipment', required: false },
|
||||
listing: { type: Schema.Types.ObjectId, ref: 'listing', required: false },
|
||||
listingVarient: { type: Schema.Types.ObjectId, ref: 'listingVarient', required: false },
|
||||
externalReference: { type: String, required: false },
|
||||
orderedAt: { type: Date, required: false },
|
||||
receivedAt: { type: Date, required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
orderItemSchema.index({ name: 'text', itemType: 'text', orderType: 'text' });
|
||||
orderItemSchema.index({ order: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'draft',
|
||||
filter: { 'state.type': 'draft' },
|
||||
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'ordered',
|
||||
filter: { 'state.type': 'ordered' },
|
||||
rollups: [{ name: 'ordered', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'shipped',
|
||||
filter: { 'state.type': 'shipped' },
|
||||
rollups: [{ name: 'shipped', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'received',
|
||||
filter: { 'state.type': 'received' },
|
||||
rollups: [{ name: 'received', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'shippedValue',
|
||||
filter: { 'state.type': 'shipped' },
|
||||
rollups: [{ name: 'totalAmountWithTax', property: 'totalAmountWithTax', operation: 'sum' }],
|
||||
},
|
||||
{
|
||||
name: 'receivedValue',
|
||||
filter: { 'state.type': 'received' },
|
||||
rollups: [{ name: 'totalAmountWithTax', property: 'totalAmountWithTax', operation: 'sum' }],
|
||||
},
|
||||
];
|
||||
|
||||
orderItemSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
baseFilter: {},
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
// Transform the results to match the expected format
|
||||
return results;
|
||||
};
|
||||
|
||||
orderItemSchema.statics.history = async function (from, to) {
|
||||
const results = await aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
// Return time-series data array
|
||||
return results;
|
||||
};
|
||||
|
||||
orderItemSchema.statics.recalculate = async function (orderItem, user) {
|
||||
if (orderItem.orderType !== 'purchaseOrder' && orderItem.orderType !== 'salesOrder') {
|
||||
// Only purchase orders are supported for now
|
||||
if (orderItem.orderType !== 'purchaseOrder') {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -142,84 +33,6 @@ orderItemSchema.statics.recalculate = async function (orderItem, user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If SKU present and syncAmount is set, check if override is on for the price mode and use that price instead
|
||||
let effectiveItemAmount = orderItem.itemAmount;
|
||||
const syncAmount = orderItem.syncAmount;
|
||||
const skuId = orderItem.sku?._id || orderItem.sku;
|
||||
const itemType = orderItem.itemType;
|
||||
if (syncAmount && skuId && itemType && ['filament', 'part', 'product'].includes(itemType)) {
|
||||
const skuModel = skuModelsByItemType[itemType];
|
||||
const parentModel = parentModelsByItemType[itemType];
|
||||
if (skuModel && parentModel) {
|
||||
const sku = await getObject({
|
||||
model: skuModel,
|
||||
id: skuId,
|
||||
cached: true,
|
||||
});
|
||||
if (sku) {
|
||||
const parentId =
|
||||
sku.part?._id ||
|
||||
sku.part ||
|
||||
sku.product?._id ||
|
||||
sku.product ||
|
||||
sku.filament?._id ||
|
||||
sku.filament;
|
||||
if (syncAmount === 'itemCost') {
|
||||
if (sku.overrideCost && sku.cost != null) {
|
||||
effectiveItemAmount = sku.cost;
|
||||
} else if (parentId) {
|
||||
const parent = await getObject({
|
||||
model: parentModel,
|
||||
id: parentId,
|
||||
cached: true,
|
||||
});
|
||||
if (parent && parent.cost != null) {
|
||||
effectiveItemAmount = parent.cost;
|
||||
}
|
||||
}
|
||||
} else if (syncAmount === 'itemPrice' && itemType !== 'filament') {
|
||||
if (sku.overridePrice && sku.price != null) {
|
||||
effectiveItemAmount = sku.price;
|
||||
} else if (parentId) {
|
||||
const parent = await getObject({
|
||||
model: parentModel,
|
||||
id: parentId,
|
||||
cached: true,
|
||||
});
|
||||
if (parent && parent.price != null) {
|
||||
effectiveItemAmount = parent.price;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const taxRate = await resolveTaxRate(orderItem.taxRate, getObject, taxRateModel);
|
||||
|
||||
const orderTotalAmount = effectiveItemAmount * orderItem.quantity;
|
||||
const orderTotalAmountWithTax = amountWithTax(orderTotalAmount, taxRate);
|
||||
|
||||
const orderItemUpdateData = {
|
||||
totalAmount: orderTotalAmount,
|
||||
totalAmountWithTax: orderTotalAmountWithTax,
|
||||
invoicedAmountRemaining: orderTotalAmount - orderItem.invoicedAmount,
|
||||
invoicedAmountWithTaxRemaining: orderTotalAmountWithTax - orderItem.invoicedAmountWithTax,
|
||||
invoicedQuantityRemaining: orderItem.quantity - orderItem.invoicedQuantity,
|
||||
};
|
||||
if (effectiveItemAmount !== orderItem.itemAmount) {
|
||||
orderItemUpdateData.itemAmount = effectiveItemAmount;
|
||||
orderItem.itemAmount = effectiveItemAmount;
|
||||
}
|
||||
|
||||
await editObject({
|
||||
model: this,
|
||||
id: orderItem._id,
|
||||
updateData: orderItemUpdateData,
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
|
||||
const rollupResults = await aggregateRollups({
|
||||
model: this,
|
||||
baseFilter: {
|
||||
@ -238,11 +51,6 @@ orderItemSchema.statics.recalculate = async function (orderItem, user) {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'overallCount',
|
||||
rollups: [{ name: 'overallCount', property: '_id', operation: 'count' }],
|
||||
},
|
||||
...rollupConfigs,
|
||||
],
|
||||
});
|
||||
|
||||
@ -250,59 +58,14 @@ orderItemSchema.statics.recalculate = async function (orderItem, user) {
|
||||
const totalAmount = totals.totalAmount.sum?.toFixed(2) || 0;
|
||||
const totalAmountWithTax = totals.totalAmountWithTax.sum?.toFixed(2) || 0;
|
||||
|
||||
const orderModel = orderItem.orderType === 'purchaseOrder' ? purchaseOrderModel : salesOrderModel;
|
||||
const order = await getObject({
|
||||
model: orderModel,
|
||||
id: orderId,
|
||||
cached: true,
|
||||
});
|
||||
|
||||
const grandTotalAmount =
|
||||
parseFloat(totalAmountWithTax || 0) + parseFloat(order.shippingAmountWithTax || 0);
|
||||
|
||||
var updateData = {
|
||||
totalAmount: parseFloat(totalAmount).toFixed(2),
|
||||
totalAmountWithTax: parseFloat(totalAmountWithTax).toFixed(2),
|
||||
totalTaxAmount: parseFloat((totalAmountWithTax - totalAmount).toFixed(2)),
|
||||
grandTotalAmount: parseFloat(grandTotalAmount).toFixed(2),
|
||||
};
|
||||
|
||||
const overallCount = rollupResults.overallCount.count || 0;
|
||||
const shippedCount = rollupResults.shipped.count || 0;
|
||||
const receivedCount = rollupResults.received.count || 0;
|
||||
|
||||
if (orderItem.orderType === 'purchaseOrder') {
|
||||
if (shippedCount > 0 && shippedCount < overallCount) {
|
||||
updateData = { ...updateData, state: { type: 'partiallyShipped' } };
|
||||
}
|
||||
if (shippedCount > 0 && shippedCount == overallCount) {
|
||||
updateData = { ...updateData, state: { type: 'shipped' } };
|
||||
}
|
||||
if (receivedCount > 0 && receivedCount < overallCount) {
|
||||
updateData = { ...updateData, state: { type: 'partiallyReceived' } };
|
||||
}
|
||||
if (receivedCount > 0 && receivedCount == overallCount) {
|
||||
updateData = { ...updateData, state: { type: 'received' } };
|
||||
}
|
||||
} else {
|
||||
if (shippedCount > 0 && shippedCount < overallCount) {
|
||||
updateData = { ...updateData, state: { type: 'partiallyShipped' } };
|
||||
}
|
||||
if (shippedCount > 0 && shippedCount == overallCount) {
|
||||
updateData = { ...updateData, state: { type: 'shipped' } };
|
||||
}
|
||||
if (receivedCount > 0 && receivedCount < overallCount) {
|
||||
updateData = { ...updateData, state: { type: 'partiallyDelivered' } };
|
||||
}
|
||||
if (receivedCount > 0 && receivedCount == overallCount) {
|
||||
updateData = { ...updateData, state: { type: 'delivered' } };
|
||||
}
|
||||
}
|
||||
|
||||
await editObject({
|
||||
model: orderModel,
|
||||
model: purchaseOrderModel,
|
||||
id: orderId,
|
||||
updateData: updateData,
|
||||
updateData: {
|
||||
totalAmount: parseFloat(totalAmount),
|
||||
totalAmountWithTax: parseFloat(totalAmountWithTax),
|
||||
totalTaxAmount: parseFloat(totalAmountWithTax - totalAmount),
|
||||
},
|
||||
user,
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,76 +1,30 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||
import { updateDraftStockAuditCurrents } from './stockaudit.schema.js';
|
||||
|
||||
const toId = (value) => {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'object' && value._id) return String(value._id);
|
||||
return String(value);
|
||||
};
|
||||
const { Schema } = mongoose;
|
||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
||||
|
||||
// Define the main partStock schema
|
||||
const partStockSchema = new mongoose.Schema(
|
||||
const partStockSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'draft' },
|
||||
type: { type: String, required: true },
|
||||
progress: { type: Number, required: false },
|
||||
},
|
||||
postedAt: { type: Date, required: false },
|
||||
part: { type: mongoose.Schema.Types.ObjectId, ref: 'part', required: true },
|
||||
partSku: { type: mongoose.Schema.Types.ObjectId, ref: 'partSku', required: true },
|
||||
stockLocation: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'stockLocation',
|
||||
required: false,
|
||||
},
|
||||
currentQuantity: { type: Number, required: true },
|
||||
history: [
|
||||
{
|
||||
currentQuantity: { type: Number, required: true },
|
||||
timestamp: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
sourceType: { type: String, required: true },
|
||||
source: { type: Schema.Types.ObjectId, refPath: 'sourceType', required: true },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
partStockSchema.index({ 'state.type': 'text' });
|
||||
|
||||
partStockSchema.pre('validate', async function () {
|
||||
if (!this.part && this.partSku) {
|
||||
const sku = await mongoose.model('partSku').findById(this.partSku).select('part').lean();
|
||||
if (sku?.part) this.part = sku.part;
|
||||
}
|
||||
});
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'totalCurrentQuantity',
|
||||
filter: {},
|
||||
rollups: [{ name: 'totalCurrentQuantity', property: 'currentQuantity', operation: 'sum' }],
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
filter: { 'state.type': 'draft' },
|
||||
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
filter: { 'state.type': 'new' },
|
||||
rollups: [{ name: 'new', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'used',
|
||||
filter: { 'state.type': 'used' },
|
||||
rollups: [{ name: 'used', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'consumed',
|
||||
filter: { 'state.type': 'consumed' },
|
||||
rollups: [{ name: 'consumed', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
partStockSchema.statics.stats = async function () {
|
||||
@ -94,34 +48,6 @@ partStockSchema.statics.history = async function (from, to) {
|
||||
return results;
|
||||
};
|
||||
|
||||
partStockSchema.statics.recalculate = async function (partStock, user) {
|
||||
if (!partStock?._id) return;
|
||||
|
||||
const itemSkuId = toId(partStock.partSku);
|
||||
const stockLocationId = toId(partStock.stockLocation);
|
||||
if (itemSkuId && stockLocationId) {
|
||||
await updateDraftStockAuditCurrents({
|
||||
itemType: 'part',
|
||||
itemSkuId,
|
||||
stockLocationId,
|
||||
user,
|
||||
});
|
||||
}
|
||||
|
||||
if (partStock.state?.type === 'draft' || partStock.state?.type === 'consumed') return;
|
||||
if ((Number(partStock.currentQuantity) || 0) > 0) return;
|
||||
|
||||
await editObject({
|
||||
model: this,
|
||||
id: partStock._id,
|
||||
updateData: {
|
||||
state: { ...(partStock.state || {}), type: 'consumed', progress: 0 },
|
||||
},
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
partStockSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -1,218 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||
import { updateDraftStockAuditCurrents } from './stockaudit.schema.js';
|
||||
|
||||
const partStockListItemSchema = new Schema({
|
||||
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
|
||||
partSku: { type: Schema.Types.ObjectId, ref: 'partSku', required: true },
|
||||
partStocks: [{ type: Schema.Types.ObjectId, ref: 'partStock', required: false }],
|
||||
requiredQuantity: { type: Number, required: true },
|
||||
});
|
||||
|
||||
partStockListItemSchema.virtual('remainingQuantity').get(function () {
|
||||
const required = this.requiredQuantity || 0;
|
||||
const stocks = Array.isArray(this.partStocks) ? this.partStocks : [];
|
||||
const available = stocks.reduce(
|
||||
(sum, stock) => sum + (Number(stock?.currentQuantity) || 0),
|
||||
0
|
||||
);
|
||||
return required - available;
|
||||
});
|
||||
|
||||
partStockListItemSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
const toId = (value) => {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'object' && value._id) return String(value._id);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// Define the main productStock schema - tracks assembled products consisting of part stocks
|
||||
const productStockSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'draft' },
|
||||
progress: { type: Number, required: false },
|
||||
},
|
||||
postedAt: { type: Date, required: false },
|
||||
product: { type: mongoose.Schema.Types.ObjectId, ref: 'product', required: true },
|
||||
productSku: { type: mongoose.Schema.Types.ObjectId, ref: 'productSku', required: true },
|
||||
stockLocation: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'stockLocation',
|
||||
required: false,
|
||||
},
|
||||
currentQuantity: { type: Number, required: true },
|
||||
history: [
|
||||
{
|
||||
currentQuantity: { type: Number, required: true },
|
||||
timestamp: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
partStockList: [partStockListItemSchema],
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
productStockSchema.index({ 'state.type': 'text' });
|
||||
|
||||
productStockSchema.pre('validate', async function () {
|
||||
if (!this.product && this.productSku) {
|
||||
const sku = await mongoose.model('productSku').findById(this.productSku).select('product').lean();
|
||||
if (sku?.product) this.product = sku.product;
|
||||
}
|
||||
if (this.partStockList?.length) {
|
||||
for (const item of this.partStockList) {
|
||||
if (!item.part && item.partSku) {
|
||||
const sku = await mongoose.model('partSku').findById(item.partSku).select('part').lean();
|
||||
if (sku?.part) item.part = sku.part;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'totalCurrentQuantity',
|
||||
filter: {},
|
||||
rollups: [{ name: 'totalCurrentQuantity', property: 'currentQuantity', operation: 'sum' }],
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
filter: { 'state.type': 'draft' },
|
||||
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
filter: { 'state.type': 'new' },
|
||||
rollups: [{ name: 'new', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'used',
|
||||
filter: { 'state.type': 'used' },
|
||||
rollups: [{ name: 'used', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'consumed',
|
||||
filter: { 'state.type': 'consumed' },
|
||||
rollups: [{ name: 'consumed', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
productStockSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
productStockSchema.statics.history = async function (from, to) {
|
||||
const results = await aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
productStockSchema.statics.recalculate = async function (productStock, user) {
|
||||
const productSkuId = toId(productStock?.productSku);
|
||||
const stockLocationId = toId(productStock?.stockLocation);
|
||||
if (productSkuId && stockLocationId) {
|
||||
await updateDraftStockAuditCurrents({
|
||||
itemType: 'product',
|
||||
itemSkuId: productSkuId,
|
||||
stockLocationId,
|
||||
user,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
productStock?._id &&
|
||||
productStock.state?.type !== 'draft' &&
|
||||
productStock.state?.type !== 'consumed' &&
|
||||
(Number(productStock.currentQuantity) || 0) <= 0
|
||||
) {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: productStock._id,
|
||||
updateData: {
|
||||
state: { ...(productStock.state || {}), type: 'consumed', progress: 0 },
|
||||
},
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!productSkuId || !stockLocationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let productId = toId(productStock?.product) || toId(productStock?.productSku?.product);
|
||||
if (!productId) {
|
||||
const productSku = await mongoose.model('productSku').findById(productSkuId).select('product').lean();
|
||||
productId = toId(productSku?.product);
|
||||
}
|
||||
if (!productId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rollupResults = await aggregateRollups({
|
||||
model: this,
|
||||
baseFilter: {
|
||||
productSku: new mongoose.Types.ObjectId(productSkuId),
|
||||
stockLocation: new mongoose.Types.ObjectId(stockLocationId),
|
||||
},
|
||||
rollupConfigs: [
|
||||
{
|
||||
name: 'stockQuantity',
|
||||
rollups: [{ name: 'stockQuantity', property: 'currentQuantity', operation: 'sum' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const stockQuantity = rollupResults.stockQuantity?.sum || 0;
|
||||
|
||||
const listingVarientModel = mongoose.model('listingVarient');
|
||||
const varients = await listingVarientModel
|
||||
.find({ productSku: productSkuId })
|
||||
.populate('listing')
|
||||
.lean();
|
||||
|
||||
for (const varient of varients) {
|
||||
const varientProductId =
|
||||
toId(varient.product) || toId(varient.listing?.product);
|
||||
const varientLocationId = toId(varient.listing?.stockLocation);
|
||||
if (varientProductId !== productId || varientLocationId !== stockLocationId) {
|
||||
continue;
|
||||
}
|
||||
if (varient.stockQuantity === stockQuantity) {
|
||||
await listingVarientModel.recalculate(varient, user);
|
||||
continue;
|
||||
}
|
||||
|
||||
await editObject({
|
||||
model: listingVarientModel,
|
||||
id: varient._id,
|
||||
updateData: { stockQuantity },
|
||||
user,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
productStockSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
productStockSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
// Create and export the model
|
||||
export const productStockModel = mongoose.model('productStock', productStockSchema);
|
||||
@ -1,116 +1,22 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
||||
|
||||
const purchaseOrderSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
totalAmount: { type: Number, required: true, default: 0 },
|
||||
totalAmountWithTax: { type: Number, required: true, default: 0 },
|
||||
shippingAmount: { type: Number, required: true, default: 0 },
|
||||
shippingAmountWithTax: { type: Number, required: true, default: 0 },
|
||||
grandTotalAmount: { type: Number, required: true, default: 0 },
|
||||
totalTaxAmount: { type: Number, required: true, default: 0 },
|
||||
totalAmount: { type: Number, required: true },
|
||||
totalAmountWithTax: { type: Number, required: true },
|
||||
totalTaxAmount: { type: Number, required: true },
|
||||
timestamp: { type: Date, default: Date.now },
|
||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'draft' },
|
||||
},
|
||||
postedAt: { type: Date, required: false },
|
||||
acknowledgedAt: { type: Date, required: false },
|
||||
cancelledAt: { type: Date, required: false },
|
||||
completedAt: { type: Date, required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
purchaseOrderSchema.index({ 'state.type': 'text' });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'draft',
|
||||
filter: { 'state.type': 'draft' },
|
||||
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'sent',
|
||||
filter: { 'state.type': 'sent' },
|
||||
rollups: [{ name: 'sent', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'acknowledged',
|
||||
filter: { 'state.type': 'acknowledged' },
|
||||
rollups: [{ name: 'acknowledged', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'partiallyShipped',
|
||||
filter: { 'state.type': 'partiallyShipped' },
|
||||
rollups: [{ name: 'partiallyShipped', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'shipped',
|
||||
filter: { 'state.type': 'shipped' },
|
||||
rollups: [{ name: 'shipped', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'partiallyReceived',
|
||||
filter: { 'state.type': 'partiallyReceived' },
|
||||
rollups: [{ name: 'partiallyReceived', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'received',
|
||||
filter: { 'state.type': 'received' },
|
||||
rollups: [{ name: 'received', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'cancelled',
|
||||
filter: { 'state.type': 'cancelled' },
|
||||
rollups: [{ name: 'cancelled', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'completed',
|
||||
filter: { 'state.type': 'completed' },
|
||||
rollups: [{ name: 'completed', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'awaitingReceiptValue',
|
||||
filter: {
|
||||
'state.type': {
|
||||
$in: ['sent', 'acknowledged', 'partiallyShipped', 'shipped', 'partiallyReceived'],
|
||||
},
|
||||
},
|
||||
rollups: [{ name: 'grandTotalAmount', property: 'grandTotalAmount', operation: 'sum' }],
|
||||
},
|
||||
{
|
||||
name: 'receivedValue',
|
||||
filter: { 'state.type': { $in: ['received', 'completed'] } },
|
||||
rollups: [{ name: 'grandTotalAmount', property: 'grandTotalAmount', operation: 'sum' }],
|
||||
},
|
||||
];
|
||||
|
||||
purchaseOrderSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
// Transform the results to match the expected format
|
||||
return results;
|
||||
};
|
||||
|
||||
purchaseOrderSchema.statics.history = async function (from, to) {
|
||||
const results = await aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
// Return time-series data array
|
||||
return results;
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
purchaseOrderSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -1,167 +1,43 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import { purchaseOrderModel } from './purchaseorder.schema.js';
|
||||
import { salesOrderModel } from '../sales/salesorder.schema.js';
|
||||
import { taxRateModel } from '../management/taxrate.schema.js';
|
||||
import {
|
||||
aggregateRollups,
|
||||
aggregateRollupsHistory,
|
||||
editObject,
|
||||
getObject,
|
||||
} from '../../database.js';
|
||||
import { amountWithTax, resolveTaxRate } from '../../tax.js';
|
||||
|
||||
const shipmentItemSchema = new Schema({
|
||||
itemType: { type: String, required: true },
|
||||
item: { type: Schema.Types.ObjectId, refPath: 'itemType', required: true },
|
||||
quantity: { type: Number, required: true },
|
||||
itemCost: { type: Number, required: true },
|
||||
totalCost: { type: Number, required: true },
|
||||
totalCostWithTax: { type: Number, required: true },
|
||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
});
|
||||
|
||||
const shipmentSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
orderType: { type: String, required: true },
|
||||
order: { type: Schema.Types.ObjectId, refPath: 'orderType', required: true },
|
||||
purchaseOrder: { type: Schema.Types.ObjectId, ref: 'purchaseOrder', required: true },
|
||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
||||
courierService: { type: Schema.Types.ObjectId, ref: 'courierService', required: false },
|
||||
trackingNumber: { type: String, required: false },
|
||||
externalReference: { type: String, required: false },
|
||||
amount: { type: Number, required: true },
|
||||
amountWithTax: { type: Number, required: true },
|
||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
invoicedAmount: { type: Number, required: false, default: 0 },
|
||||
invoicedAmountWithTax: { type: Number, required: false, default: 0 },
|
||||
invoicedAmountRemaining: { type: Number, required: false, default: 0 },
|
||||
invoicedAmountWithTaxRemaining: { type: Number, required: false, default: 0 },
|
||||
shippedAt: { type: Date, required: false },
|
||||
expectedAt: { type: Date, required: false },
|
||||
deliveredAt: { type: Date, required: false },
|
||||
cancelledAt: { type: Date, required: false },
|
||||
items: [shipmentItemSchema],
|
||||
cost: { net: { type: Number, required: true }, gross: { type: Number, required: true } },
|
||||
shippedDate: { type: Date, required: false },
|
||||
expectedDeliveryDate: { type: Date, required: false },
|
||||
actualDeliveryDate: { type: Date, required: false },
|
||||
state: {
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: 'pending',
|
||||
enum: ['pending', 'shipped', 'in_transit', 'delivered', 'cancelled'],
|
||||
},
|
||||
},
|
||||
notes: { type: String },
|
||||
timestamp: { type: Date, default: Date.now },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
shipmentSchema.index({ trackingNumber: 'text', orderType: 'text' });
|
||||
shipmentSchema.index({ order: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'draft',
|
||||
filter: { 'state.type': 'draft' },
|
||||
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'planned',
|
||||
filter: { 'state.type': 'planned' },
|
||||
rollups: [{ name: 'planned', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'shipped',
|
||||
filter: { 'state.type': 'shipped' },
|
||||
rollups: [{ name: 'shipped', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'delivered',
|
||||
filter: { 'state.type': 'delivered' },
|
||||
rollups: [{ name: 'delivered', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'cancelled',
|
||||
filter: { 'state.type': 'cancelled' },
|
||||
rollups: [{ name: 'cancelled', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'inTransitValue',
|
||||
filter: { 'state.type': 'shipped' },
|
||||
rollups: [{ name: 'amountWithTax', property: 'amountWithTax', operation: 'sum' }],
|
||||
},
|
||||
];
|
||||
|
||||
shipmentSchema.statics.stats = async function () {
|
||||
return aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs,
|
||||
});
|
||||
};
|
||||
|
||||
shipmentSchema.statics.history = async function (from, to) {
|
||||
return aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs,
|
||||
});
|
||||
};
|
||||
|
||||
shipmentSchema.statics.recalculate = async function (shipment, user) {
|
||||
if (shipment.orderType !== 'purchaseOrder' && shipment.orderType !== 'salesOrder') {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderId = shipment.order?._id || shipment.order;
|
||||
if (!orderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taxRate = await resolveTaxRate(shipment.taxRate, getObject, taxRateModel);
|
||||
|
||||
const amountWithTaxValue = amountWithTax(shipment.amount || 0, taxRate);
|
||||
await editObject({
|
||||
model: shipmentModel,
|
||||
id: shipment._id,
|
||||
updateData: {
|
||||
amountWithTax: amountWithTaxValue,
|
||||
invoicedAmountRemaining: shipment.amount - (shipment.invoicedAmount || 0),
|
||||
invoicedAmountWithTaxRemaining:
|
||||
amountWithTaxValue - (shipment.invoicedAmountWithTax || 0),
|
||||
},
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
|
||||
const rollupResults = await aggregateRollups({
|
||||
model: this,
|
||||
baseFilter: {
|
||||
order: new mongoose.Types.ObjectId(orderId),
|
||||
orderType: shipment.orderType,
|
||||
},
|
||||
rollupConfigs: [
|
||||
{
|
||||
name: 'shipmentTotals',
|
||||
rollups: [
|
||||
{ name: 'amount', property: 'amount', operation: 'sum' },
|
||||
{ name: 'amountWithTax', property: 'amountWithTax', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const totals = rollupResults.shipmentTotals || {};
|
||||
const totalShippingAmount = totals.amount.sum?.toFixed(2) || 0;
|
||||
const totalShippingAmountWithTax = totals.amountWithTax.sum?.toFixed(2) || 0;
|
||||
|
||||
const orderModel = shipment.orderType === 'purchaseOrder' ? purchaseOrderModel : salesOrderModel;
|
||||
const order = await getObject({
|
||||
model: orderModel,
|
||||
id: orderId,
|
||||
cached: true,
|
||||
});
|
||||
|
||||
const grandTotalAmount =
|
||||
parseFloat(order.totalAmountWithTax || 0) + parseFloat(totalShippingAmountWithTax || 0);
|
||||
await editObject({
|
||||
model: orderModel,
|
||||
id: orderId,
|
||||
updateData: {
|
||||
shippingAmount: parseFloat(totalShippingAmount).toFixed(2),
|
||||
shippingAmountWithTax: parseFloat(totalShippingAmountWithTax).toFixed(2),
|
||||
grandTotalAmount: parseFloat(grandTotalAmount).toFixed(2),
|
||||
},
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
shipmentSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -1,311 +1,40 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { editObject, getObject } from '../../database.js';
|
||||
import { stockAuditLevelModel, normalizeAuditLevelLine } from '../management/stockauditlevel.schema.js';
|
||||
import { filamentModel } from '../management/filament.schema.js';
|
||||
import { filamentSkuModel } from '../management/filamentsku.schema.js';
|
||||
import { partModel } from '../management/part.schema.js';
|
||||
import { partSkuModel } from '../management/partsku.schema.js';
|
||||
import { productModel } from '../management/product.schema.js';
|
||||
import { productSkuModel } from '../management/productsku.schema.js';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const itemModelsByType = {
|
||||
filament: filamentModel,
|
||||
part: partModel,
|
||||
product: productModel,
|
||||
};
|
||||
|
||||
const skuModelsByType = {
|
||||
filament: filamentSkuModel,
|
||||
part: partSkuModel,
|
||||
product: productSkuModel,
|
||||
};
|
||||
|
||||
const parentFieldByType = {
|
||||
filament: 'filament',
|
||||
part: 'part',
|
||||
product: 'product',
|
||||
};
|
||||
|
||||
const toId = (value) => {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'object' && value._id != null) return String(value._id);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const stockAuditLineSchema = new Schema(
|
||||
{
|
||||
itemType: {
|
||||
type: String,
|
||||
enum: ['filament', 'part', 'product'],
|
||||
required: true,
|
||||
},
|
||||
item: { type: Schema.Types.ObjectId, refPath: 'auditLines.itemType', required: true },
|
||||
itemSku: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: function () {
|
||||
return ['filament', 'part', 'product'].includes(this.itemType)
|
||||
? this.itemType + 'Sku'
|
||||
: null;
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
current: { type: Number, required: true, default: 0 },
|
||||
actual: { type: Number, required: true, default: 0 },
|
||||
new: { type: Number, required: true, default: 0 },
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
const stockAuditItemSchema = new Schema({
|
||||
type: { type: String, enum: ['filament', 'part'], required: true },
|
||||
stock: { type: Schema.Types.ObjectId, required: true },
|
||||
expectedQuantity: { type: Number, required: true },
|
||||
actualQuantity: { type: Number, required: true },
|
||||
notes: { type: String },
|
||||
});
|
||||
|
||||
const stockAuditSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'draft' },
|
||||
progress: { type: Number, required: false },
|
||||
},
|
||||
auditLevel: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'stockAuditLevel',
|
||||
type: { type: String, required: true },
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'in_progress', 'completed', 'cancelled'],
|
||||
default: 'pending',
|
||||
required: true,
|
||||
},
|
||||
stockLocation: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'stockLocation',
|
||||
required: true,
|
||||
},
|
||||
postedAt: { type: Date, required: false },
|
||||
auditLines: { type: [stockAuditLineSchema], default: [] },
|
||||
notes: { type: String },
|
||||
items: [stockAuditItemSchema],
|
||||
createdBy: { type: Schema.Types.ObjectId, ref: 'user', required: true },
|
||||
completedAt: { type: Date },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
stockAuditSchema.index({ 'state.type': 'text' });
|
||||
|
||||
stockAuditSchema.statics.stats = async function () {
|
||||
const [draft, complete] = await Promise.all([
|
||||
this.countDocuments({ 'state.type': 'draft' }),
|
||||
this.countDocuments({ 'state.type': 'complete' }),
|
||||
]);
|
||||
return {
|
||||
draft: { count: draft },
|
||||
complete: { count: complete },
|
||||
};
|
||||
};
|
||||
|
||||
stockAuditSchema.statics.history = async function () {
|
||||
return [];
|
||||
};
|
||||
|
||||
async function fetchItemsForLevelLine(levelLine) {
|
||||
const itemType = levelLine.itemType;
|
||||
const itemModel = itemModelsByType[itemType];
|
||||
if (!itemModel) return [];
|
||||
|
||||
if (levelLine.allItems) {
|
||||
return itemModel.find().select('_id').lean();
|
||||
}
|
||||
|
||||
const itemId = toId(levelLine.item);
|
||||
if (!itemId) return [];
|
||||
return [{ _id: itemId }];
|
||||
}
|
||||
|
||||
async function fetchSkusForLevelLine(levelLine, itemId) {
|
||||
const itemType = levelLine.itemType;
|
||||
const skuModel = skuModelsByType[itemType];
|
||||
const parentField = parentFieldByType[itemType];
|
||||
if (!skuModel || !parentField) return [];
|
||||
|
||||
if (levelLine.allSkus) {
|
||||
return skuModel.find({ [parentField]: itemId }).select('_id').lean();
|
||||
}
|
||||
|
||||
const skuId = toId(levelLine.itemSku);
|
||||
if (!skuId) return [];
|
||||
return [{ _id: skuId }];
|
||||
}
|
||||
|
||||
export async function getCurrentQuantityAtLocation(itemType, itemSkuId, stockLocationId) {
|
||||
const locationId = toId(stockLocationId);
|
||||
const skuId = toId(itemSkuId);
|
||||
if (!locationId || !skuId) return 0;
|
||||
|
||||
if (itemType === 'filament') {
|
||||
const stocks = await mongoose
|
||||
.model('filamentStock')
|
||||
.find({ filamentSku: skuId, stockLocation: locationId })
|
||||
.select('currentWeight.net')
|
||||
.lean();
|
||||
return stocks.reduce((sum, stock) => sum + (Number(stock.currentWeight?.net) || 0), 0);
|
||||
}
|
||||
|
||||
if (itemType === 'part') {
|
||||
const stocks = await mongoose
|
||||
.model('partStock')
|
||||
.find({ partSku: skuId, stockLocation: locationId })
|
||||
.select('currentQuantity')
|
||||
.lean();
|
||||
return stocks.reduce((sum, stock) => sum + (Number(stock.currentQuantity) || 0), 0);
|
||||
}
|
||||
|
||||
if (itemType === 'product') {
|
||||
const stocks = await mongoose
|
||||
.model('productStock')
|
||||
.find({ productSku: skuId, stockLocation: locationId })
|
||||
.select('currentQuantity')
|
||||
.lean();
|
||||
return stocks.reduce((sum, stock) => sum + (Number(stock.currentQuantity) || 0), 0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildAuditLineQuantities(current, actual) {
|
||||
const currentVal = Number(current) || 0;
|
||||
const actualVal = Number(actual) || 0;
|
||||
return {
|
||||
current: currentVal,
|
||||
actual: actualVal,
|
||||
new: actualVal,
|
||||
};
|
||||
}
|
||||
|
||||
function getExistingActual(line) {
|
||||
if (line?.actual != null) return Number(line.actual);
|
||||
if (line?.actualQuantity != null) return Number(line.actualQuantity);
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildAuditLineKey(itemType, itemId, itemSkuId) {
|
||||
return `${itemType}:${toId(itemId)}:${toId(itemSkuId)}`;
|
||||
}
|
||||
|
||||
async function expandLevelLinesToAuditLines(levelLines, stockLocationId, existingLines = []) {
|
||||
const existingByKey = new Map();
|
||||
for (const line of existingLines) {
|
||||
existingByKey.set(
|
||||
buildAuditLineKey(line.itemType, line.item, line.itemSku),
|
||||
line
|
||||
);
|
||||
}
|
||||
|
||||
const auditLines = [];
|
||||
|
||||
for (const levelLine of levelLines || []) {
|
||||
const items = await fetchItemsForLevelLine(levelLine);
|
||||
for (const item of items) {
|
||||
const itemId = toId(item._id);
|
||||
const skus = await fetchSkusForLevelLine(levelLine, itemId);
|
||||
for (const sku of skus) {
|
||||
const itemSkuId = toId(sku._id);
|
||||
const current = await getCurrentQuantityAtLocation(
|
||||
levelLine.itemType,
|
||||
itemSkuId,
|
||||
stockLocationId
|
||||
);
|
||||
const key = buildAuditLineKey(levelLine.itemType, itemId, itemSkuId);
|
||||
const existing = existingByKey.get(key);
|
||||
const existingActual = getExistingActual(existing);
|
||||
const actual = existingActual != null ? existingActual : current;
|
||||
|
||||
auditLines.push({
|
||||
itemType: levelLine.itemType,
|
||||
item: itemId,
|
||||
itemSku: itemSkuId,
|
||||
...buildAuditLineQuantities(current, actual),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return auditLines;
|
||||
}
|
||||
|
||||
stockAuditSchema.statics.recalculate = async function (stockAudit, user) {
|
||||
if (stockAudit?.state?.type !== 'draft') return;
|
||||
|
||||
const auditLevelId = toId(stockAudit.auditLevel?._id ?? stockAudit.auditLevel);
|
||||
const stockLocationId = toId(stockAudit.stockLocation?._id ?? stockAudit.stockLocation);
|
||||
if (!auditLevelId || !stockLocationId) return;
|
||||
|
||||
const auditLevel = await getObject({
|
||||
model: stockAuditLevelModel,
|
||||
id: auditLevelId,
|
||||
populate: [
|
||||
{ path: 'auditLines.item' },
|
||||
{ path: 'auditLines.itemSku' },
|
||||
],
|
||||
});
|
||||
|
||||
if (!auditLevel || auditLevel.error) return;
|
||||
|
||||
const auditLines = await expandLevelLinesToAuditLines(
|
||||
(auditLevel.auditLines || []).map((line) => normalizeAuditLevelLine(line)),
|
||||
stockLocationId,
|
||||
stockAudit.auditLines
|
||||
);
|
||||
|
||||
await editObject({
|
||||
model: this,
|
||||
id: stockAudit._id,
|
||||
updateData: { auditLines },
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
stockAuditSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
stockAuditSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
// Create and export the model
|
||||
export const stockAuditModel = mongoose.model('stockAudit', stockAuditSchema);
|
||||
|
||||
export async function updateDraftStockAuditCurrents({
|
||||
itemType,
|
||||
itemSkuId,
|
||||
stockLocationId,
|
||||
user,
|
||||
}) {
|
||||
const skuId = toId(itemSkuId);
|
||||
const locationId = toId(stockLocationId);
|
||||
if (!itemType || !skuId || !locationId) return;
|
||||
|
||||
const current = await getCurrentQuantityAtLocation(itemType, skuId, locationId);
|
||||
|
||||
const draftAudits = await stockAuditModel
|
||||
.find({
|
||||
'state.type': 'draft',
|
||||
stockLocation: locationId,
|
||||
})
|
||||
.lean();
|
||||
|
||||
for (const audit of draftAudits) {
|
||||
let changed = false;
|
||||
const auditLines = (audit.auditLines || []).map((line) => {
|
||||
if (line.itemType !== itemType || toId(line.itemSku) !== skuId) {
|
||||
return line;
|
||||
}
|
||||
const lineCurrent = Number(line.current) || 0;
|
||||
if (lineCurrent === current) {
|
||||
return line;
|
||||
}
|
||||
changed = true;
|
||||
return { ...line, current };
|
||||
});
|
||||
|
||||
if (!changed) continue;
|
||||
|
||||
await editObject({
|
||||
model: stockAuditModel,
|
||||
id: audit._id,
|
||||
updateData: { auditLines },
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,209 +1,7 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import {
|
||||
getObject,
|
||||
editObject,
|
||||
aggregateRollups,
|
||||
aggregateRollupsHistory,
|
||||
} from '../../database.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const parentStockModelNames = {
|
||||
filamentStock: 'filamentStock',
|
||||
partStock: 'partStock',
|
||||
productStock: 'productStock',
|
||||
};
|
||||
|
||||
const initialStockStates = {
|
||||
filamentStock: 'unconsumed',
|
||||
partStock: 'new',
|
||||
productStock: 'new',
|
||||
};
|
||||
|
||||
const getStartingAmount = (parentType, parentStock) => {
|
||||
if (parentType === 'filamentStock') {
|
||||
return parentStock.startingWeight?.net ?? 0;
|
||||
}
|
||||
|
||||
return parentStock.startingQuantity ?? 0;
|
||||
};
|
||||
|
||||
const buildParentState = (parentType, parentStock, currentAmount, startingAmount) => {
|
||||
if (parentStock.state?.type === 'draft') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fullState = initialStockStates[parentType];
|
||||
if (!fullState) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (currentAmount <= 0) {
|
||||
return { ...parentStock.state, type: 'consumed', progress: 0 };
|
||||
}
|
||||
|
||||
if (startingAmount <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const progress = currentAmount / startingAmount;
|
||||
|
||||
if (currentAmount === startingAmount) {
|
||||
return { ...parentStock.state, type: fullState, progress: 1 };
|
||||
}
|
||||
|
||||
if (currentAmount < startingAmount) {
|
||||
return { ...parentStock.state, type: 'used', progress };
|
||||
}
|
||||
|
||||
return { ...parentStock.state, type: fullState, progress: 1 };
|
||||
};
|
||||
|
||||
const getStockEventTotal = async (parentId, parentType) => {
|
||||
if (!parentId) return null;
|
||||
|
||||
const objectId =
|
||||
parentId instanceof mongoose.Types.ObjectId ? parentId : new mongoose.Types.ObjectId(parentId);
|
||||
|
||||
const [result] = await mongoose
|
||||
.model('stockEvent')
|
||||
.aggregate([
|
||||
{ $match: { parent: objectId, parentType } },
|
||||
{ $group: { _id: null, total: { $sum: '$value' }, count: { $sum: 1 } } },
|
||||
]);
|
||||
|
||||
return {
|
||||
total: result?.total ?? 0,
|
||||
count: result?.count ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
const buildParentUpdateData = (parentType, parentStock, events, stockEvent) => {
|
||||
const updateData = {};
|
||||
let currentAmount;
|
||||
|
||||
if (parentType === 'filamentStock') {
|
||||
const net = events.total;
|
||||
const startingNet = parentStock.startingWeight?.net ?? 0;
|
||||
const startingGross = parentStock.startingWeight?.gross ?? 0;
|
||||
const gross = startingNet > 0 ? (startingGross * net) / startingNet : net;
|
||||
updateData.currentWeight = { net, gross };
|
||||
currentAmount = net;
|
||||
} else {
|
||||
const eventValue = Number(stockEvent?.value);
|
||||
if (Number.isFinite(eventValue) && eventValue < 0) {
|
||||
updateData.currentQuantity = Math.max(
|
||||
0,
|
||||
(Number(parentStock.currentQuantity) || 0) + eventValue
|
||||
);
|
||||
} else {
|
||||
updateData.currentQuantity = events.total;
|
||||
}
|
||||
currentAmount = updateData.currentQuantity;
|
||||
}
|
||||
|
||||
const state = buildParentState(
|
||||
parentType,
|
||||
parentStock,
|
||||
currentAmount,
|
||||
getStartingAmount(parentType, parentStock)
|
||||
);
|
||||
if (state) {
|
||||
updateData.state = state;
|
||||
}
|
||||
|
||||
return updateData;
|
||||
};
|
||||
|
||||
const HISTORY_RATE_LIMIT_MS = 3000;
|
||||
|
||||
const isWithinHistoryRateLimit = (lastEntry, timestamp = new Date()) => {
|
||||
if (!lastEntry?.timestamp) return false;
|
||||
const elapsed = new Date(timestamp).getTime() - new Date(lastEntry.timestamp).getTime();
|
||||
return elapsed < HISTORY_RATE_LIMIT_MS;
|
||||
};
|
||||
|
||||
const getLastParentHistoryValue = (parentType, history = []) => {
|
||||
const lastEntry = history.at(-1);
|
||||
if (!lastEntry) return undefined;
|
||||
|
||||
return parentType === 'filamentStock' ? lastEntry.currentWeight : lastEntry.currentQuantity;
|
||||
};
|
||||
|
||||
const parentValuesEqual = (parentType, a, b) => {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return false;
|
||||
|
||||
if (parentType === 'filamentStock') {
|
||||
return a.net === b.net && a.gross === b.gross;
|
||||
}
|
||||
|
||||
return a === b;
|
||||
};
|
||||
|
||||
const buildParentHistoryEntry = (parentType, currentValue, timestamp) => {
|
||||
if (parentType === 'filamentStock') {
|
||||
return { currentWeight: currentValue, timestamp };
|
||||
}
|
||||
|
||||
return { currentQuantity: currentValue, timestamp };
|
||||
};
|
||||
|
||||
const appendParentHistoryIfChanged = (
|
||||
parentType,
|
||||
parentStock,
|
||||
updateData,
|
||||
timestamp = new Date()
|
||||
) => {
|
||||
const history = parentStock.history || [];
|
||||
const lastEntry = history.at(-1);
|
||||
const currentValue =
|
||||
parentType === 'filamentStock' ? updateData.currentWeight : updateData.currentQuantity;
|
||||
const lastHistoryValue = getLastParentHistoryValue(parentType, history);
|
||||
|
||||
if (parentValuesEqual(parentType, currentValue, lastHistoryValue)) {
|
||||
return updateData;
|
||||
}
|
||||
|
||||
if (isWithinHistoryRateLimit(lastEntry, timestamp)) {
|
||||
return updateData;
|
||||
}
|
||||
|
||||
return {
|
||||
...updateData,
|
||||
history: [...history, buildParentHistoryEntry(parentType, currentValue, timestamp)],
|
||||
};
|
||||
};
|
||||
|
||||
const recalculateParentStock = async (parentType, parentId, user, stockEvent) => {
|
||||
if (!parentType || !parentId) return;
|
||||
|
||||
const modelName = parentStockModelNames[parentType];
|
||||
if (!modelName) return;
|
||||
|
||||
const parentModel = mongoose.model(modelName);
|
||||
const parentStock = await getObject({
|
||||
model: parentModel,
|
||||
id: parentId,
|
||||
});
|
||||
if (!parentStock || parentStock.error) return;
|
||||
|
||||
const events = await getStockEventTotal(parentId, parentType);
|
||||
if (!events?.count) return;
|
||||
|
||||
await editObject({
|
||||
model: parentModel,
|
||||
id: parentStock._id,
|
||||
updateData: appendParentHistoryIfChanged(
|
||||
parentType,
|
||||
parentStock,
|
||||
buildParentUpdateData(parentType, parentStock, events, stockEvent)
|
||||
),
|
||||
user,
|
||||
recalculate: parentType === 'productStock' || parentType === 'partStock',
|
||||
});
|
||||
};
|
||||
|
||||
const stockEventSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
@ -227,83 +25,13 @@ const stockEventSchema = new Schema(
|
||||
ownerType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['user', 'subJob', 'stockAudit', 'stockTransfer', 'productStock'],
|
||||
enum: ['user', 'subJob', 'stockAudit'],
|
||||
},
|
||||
history: [
|
||||
{
|
||||
value: { type: Number, required: true },
|
||||
timestamp: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
timestamp: { type: Date, default: Date.now },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
stockEventSchema.index({ parentType: 'text', ownerType: 'text', unit: 'text' });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'partStock',
|
||||
filter: { parentType: 'partStock' },
|
||||
rollups: [{ name: 'partStock', property: 'parentType', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'filamentStock',
|
||||
filter: { parentType: 'filamentStock' },
|
||||
rollups: [{ name: 'filamentStock', property: 'parentType', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'productStock',
|
||||
filter: { parentType: 'productStock' },
|
||||
rollups: [{ name: 'productStock', property: 'parentType', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
stockEventSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
stockEventSchema.statics.history = async function (from, to) {
|
||||
const results = await aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
stockEventSchema.statics.recalculate = async function (stockEvent, user) {
|
||||
const history = stockEvent.history || [];
|
||||
const lastEntry = history.at(-1);
|
||||
const lastHistoryValue = lastEntry?.value;
|
||||
const currentValue = stockEvent.value;
|
||||
const timestamp = stockEvent.timestamp || new Date();
|
||||
|
||||
if (currentValue !== lastHistoryValue && !isWithinHistoryRateLimit(lastEntry, timestamp)) {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: stockEvent._id,
|
||||
updateData: {
|
||||
history: [...history, { value: currentValue, timestamp }],
|
||||
},
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
}
|
||||
|
||||
const parentType = stockEvent.parentType;
|
||||
const parentId = stockEvent.parent?._id || stockEvent.parent;
|
||||
await recalculateParentStock(parentType, parentId, user, stockEvent);
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
stockEventSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const addressSchema = new Schema({
|
||||
building: { required: false, type: String },
|
||||
addressLine1: { required: false, type: String },
|
||||
addressLine2: { required: false, type: String },
|
||||
city: { required: false, type: String },
|
||||
state: { required: false, type: String },
|
||||
postcode: { required: false, type: String },
|
||||
country: { required: false, type: String },
|
||||
});
|
||||
|
||||
const stockLocationSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
address: { required: false, type: addressSchema },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
stockLocationSchema.index({ name: 'text' });
|
||||
|
||||
stockLocationSchema.statics.stats = async function () {
|
||||
const total = await this.countDocuments({});
|
||||
return { total: { count: total } };
|
||||
};
|
||||
|
||||
stockLocationSchema.statics.history = async function () {
|
||||
return [];
|
||||
};
|
||||
|
||||
stockLocationSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
stockLocationSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const stockLocationModel = mongoose.model('stockLocation', stockLocationSchema);
|
||||
@ -1,78 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const stockTransferLineSchema = new Schema(
|
||||
{
|
||||
fromStockType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['filamentStock', 'partStock', 'productStock'],
|
||||
},
|
||||
fromStock: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'lines.fromStockType',
|
||||
required: true,
|
||||
},
|
||||
quantity: { type: Number, required: true },
|
||||
toStockType: {
|
||||
type: String,
|
||||
required: false,
|
||||
enum: ['filamentStock', 'partStock', 'productStock'],
|
||||
},
|
||||
toStock: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'lines.toStockType',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const stockTransferSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'draft' },
|
||||
progress: { type: Number, required: false },
|
||||
},
|
||||
postedAt: { type: Date, required: false },
|
||||
fromLocation: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'stockLocation',
|
||||
required: true,
|
||||
},
|
||||
toLocation: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'stockLocation',
|
||||
required: true,
|
||||
},
|
||||
lines: { type: [stockTransferLineSchema], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
stockTransferSchema.index({ 'state.type': 'text' });
|
||||
|
||||
stockTransferSchema.statics.stats = async function () {
|
||||
const [draft, posted] = await Promise.all([
|
||||
this.countDocuments({ 'state.type': 'draft' }),
|
||||
this.countDocuments({ 'state.type': 'posted' }),
|
||||
]);
|
||||
return {
|
||||
draft: { count: draft },
|
||||
posted: { count: posted },
|
||||
};
|
||||
};
|
||||
|
||||
stockTransferSchema.statics.history = async function () {
|
||||
return [];
|
||||
};
|
||||
|
||||
stockTransferSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
stockTransferSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const stockTransferModel = mongoose.model('stockTransfer', stockTransferSchema);
|
||||
@ -1,24 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const appPasswordSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
user: { type: Schema.Types.ObjectId, ref: 'user', required: true },
|
||||
active: { type: Boolean, required: true, default: true },
|
||||
secret: { type: String, required: true, select: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
appPasswordSchema.index({ name: 'text' });
|
||||
|
||||
appPasswordSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
appPasswordSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const appPasswordModel = mongoose.model('appPassword', appPasswordSchema);
|
||||
@ -25,24 +25,17 @@ const auditLogSchema = new Schema(
|
||||
owner: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'ownerType',
|
||||
required: false,
|
||||
required: true,
|
||||
},
|
||||
ownerType: {
|
||||
type: String,
|
||||
required: false,
|
||||
enum: ['user', 'printer', 'host', 'marketplace'],
|
||||
},
|
||||
system: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
enum: ['user', 'printer', 'host'],
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
auditLogSchema.index({ operation: 'text', parentType: 'text', ownerType: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
auditLogSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -14,15 +14,6 @@ const courierSchema = new mongoose.Schema(
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
courierSchema.index({
|
||||
name: 'text',
|
||||
website: 'text',
|
||||
email: 'text',
|
||||
phone: 'text',
|
||||
contact: 'text',
|
||||
country: 'text',
|
||||
});
|
||||
|
||||
courierSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
@ -1,18 +1,7 @@
|
||||
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(
|
||||
{
|
||||
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
||||
externalReference: { type: String, required: false },
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const courierServiceSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
@ -22,49 +11,14 @@ const courierServiceSchema = new mongoose.Schema(
|
||||
tracked: { required: true, type: Boolean },
|
||||
deliveryTime: { required: true, type: Number },
|
||||
website: { required: false, type: String },
|
||||
cost: { required: true, type: Number, default: 0 },
|
||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
costWithTax: { required: false, type: Number },
|
||||
additionalCost: { required: false, type: Number },
|
||||
additionalCostWithTax: { required: false, type: Number },
|
||||
shippingCurrency: { required: true, type: String, default: 'GBP' },
|
||||
international: { required: true, type: Boolean, default: false },
|
||||
marketplaces: { type: [marketplaceMappingSchema], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
courierServiceSchema.index({ name: 'text', website: 'text' });
|
||||
|
||||
courierServiceSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@ -14,15 +14,8 @@ const documentJobSchema = new Schema(
|
||||
object: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'objectType',
|
||||
required: false,
|
||||
required: true,
|
||||
},
|
||||
objects: [
|
||||
{
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'objectType',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'queued' },
|
||||
progress: { type: Number, required: false },
|
||||
@ -38,12 +31,6 @@ const documentJobSchema = new Schema(
|
||||
ref: 'documentPrinter',
|
||||
required: true,
|
||||
},
|
||||
quantity: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 1,
|
||||
min: 1,
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
required: false,
|
||||
@ -52,8 +39,6 @@ const documentJobSchema = new Schema(
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
documentJobSchema.index({ name: 'text', objectType: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
documentJobSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -8,8 +8,6 @@ const connectionSchema = new Schema(
|
||||
protocol: { type: String, required: true },
|
||||
host: { type: String, required: true },
|
||||
port: { type: Number, required: false },
|
||||
username: { type: String, required: false },
|
||||
password: { type: String, required: false },
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
@ -24,8 +22,6 @@ const documentPrinterSchema = new Schema(
|
||||
},
|
||||
connection: { type: connectionSchema, required: true },
|
||||
currentDocumentSize: { type: Schema.Types.ObjectId, ref: 'documentSize', required: false },
|
||||
supportedDocumentSizes: [{ type: Schema.Types.ObjectId, ref: 'documentSize', required: false }],
|
||||
rotateOrientation: { type: Boolean, required: false, default: false },
|
||||
tags: [{ type: String }],
|
||||
online: { type: Boolean, required: true, default: false },
|
||||
active: { type: Boolean, required: true, default: true },
|
||||
@ -34,10 +30,6 @@ const documentPrinterSchema = new Schema(
|
||||
message: { type: String, required: false },
|
||||
progress: { type: Number, required: false },
|
||||
},
|
||||
paperState: {
|
||||
type: { type: String, required: true, default: 'unknown' },
|
||||
message: { type: String, required: false },
|
||||
},
|
||||
connectedAt: { type: Date, default: null },
|
||||
host: { type: Schema.Types.ObjectId, ref: 'host', required: true },
|
||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: false },
|
||||
@ -46,8 +38,6 @@ const documentPrinterSchema = new Schema(
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
documentPrinterSchema.index({ name: 'text', tags: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
documentPrinterSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -25,37 +25,10 @@ const documentSizeSchema = new Schema(
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
printPadding: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
paddingLeft: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
paddingRight: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
paddingTop: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
paddingBottom: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
documentSizeSchema.index({ name: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
documentSizeSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -2,78 +2,6 @@ import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const RESOURCE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
function normalizeTemplateResourceName(value) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function validateTemplateResources(resources) {
|
||||
const names = new Set();
|
||||
for (const resource of resources || []) {
|
||||
if (
|
||||
!RESOURCE_NAME_PATTERN.test(resource?.name || '') ||
|
||||
resource?.file == null ||
|
||||
resource.file === '' ||
|
||||
names.has(resource.name)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
names.add(resource.name);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const RENDER_DOCUMENT_TEMPLATE_CALL = /fc\.renderDocumentTemplate\s*\(\s*(['"])([^'"]+)\1/g;
|
||||
|
||||
function extractRenderDocumentTemplateReferences(content) {
|
||||
if (content == null || typeof content !== 'string' || content === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const references = [];
|
||||
const seen = new Set();
|
||||
const regex = new RegExp(RENDER_DOCUMENT_TEMPLATE_CALL.source, 'g');
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const reference = match[2]?.trim();
|
||||
if (!reference || seen.has(reference)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(reference);
|
||||
references.push(reference);
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
function objectIdsFromFilterValue(value) {
|
||||
if (value == null) {
|
||||
return [];
|
||||
}
|
||||
if (value instanceof mongoose.Types.ObjectId) {
|
||||
return [value];
|
||||
}
|
||||
if (typeof value === 'string' && /^[a-f\d]{24}$/i.test(value)) {
|
||||
return [new mongoose.Types.ObjectId(value)];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap(objectIdsFromFilterValue);
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
if (Array.isArray(value.$in)) {
|
||||
return objectIdsFromFilterValue(value.$in);
|
||||
}
|
||||
if (value._id != null) {
|
||||
return objectIdsFromFilterValue(value._id);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const documentTemplateSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
@ -111,50 +39,19 @@ const documentTemplateSchema = new Schema(
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
referencedTemplates: [
|
||||
{
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'documentTemplate',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
content: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '<Container></Container>',
|
||||
},
|
||||
testObject: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'objectType',
|
||||
type: Schema.Types.Mixed,
|
||||
required: false,
|
||||
},
|
||||
resources: {
|
||||
type: [
|
||||
new Schema(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
set: normalizeTemplateResourceName,
|
||||
match: RESOURCE_NAME_PATTERN,
|
||||
},
|
||||
file: { type: Schema.Types.ObjectId, ref: 'file', required: true },
|
||||
},
|
||||
{ _id: true }
|
||||
),
|
||||
],
|
||||
default: [],
|
||||
validate: {
|
||||
validator: validateTemplateResources,
|
||||
message: 'Template resource names must be unique.',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
documentTemplateSchema.index({ name: 'text', tags: 'text', objectType: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
documentTemplateSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
@ -163,50 +60,4 @@ documentTemplateSchema.virtual('id').get(function () {
|
||||
// Configure JSON serialization to include virtuals
|
||||
documentTemplateSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
documentTemplateSchema.statics.recalculate = async function (documentTemplate, user) {
|
||||
const documentTemplateId = documentTemplate?._id || documentTemplate;
|
||||
if (!documentTemplateId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stillExists = await this.exists({ _id: documentTemplateId });
|
||||
if (!stillExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { getFilter } = await import('../../../utils.js');
|
||||
const references = extractRenderDocumentTemplateReferences(documentTemplate?.content);
|
||||
const referencedTemplateIds = [];
|
||||
const seenIds = new Set();
|
||||
|
||||
for (const reference of references) {
|
||||
const filter = await getFilter({ parent: reference }, ['parent'], true, this);
|
||||
const ids = objectIdsFromFilterValue(filter.parent);
|
||||
for (const id of ids) {
|
||||
const idString = String(id);
|
||||
if (!idString || seenIds.has(idString)) {
|
||||
continue;
|
||||
}
|
||||
seenIds.add(idString);
|
||||
referencedTemplateIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (documentTemplate && typeof documentTemplate === 'object' && !documentTemplate._bsontype) {
|
||||
documentTemplate.referencedTemplates = referencedTemplateIds.map((id) => ({
|
||||
_id: String(id),
|
||||
}));
|
||||
}
|
||||
|
||||
const { editObject } = await import('../../database.js');
|
||||
await editObject({
|
||||
model: this,
|
||||
id: documentTemplateId,
|
||||
updateData: { referencedTemplates: referencedTemplateIds },
|
||||
user,
|
||||
populate: [{ path: 'referencedTemplates', strictPopulate: false }],
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const documentTemplateModel = mongoose.model('documentTemplate', documentTemplateSchema);
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const emailAccountSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true, unique: true },
|
||||
active: { type: Boolean, required: true, default: true },
|
||||
host: { type: String, required: true },
|
||||
port: { type: Number, required: true, default: 587, min: 1, max: 65535 },
|
||||
secure: { type: Boolean, required: true, default: false },
|
||||
username: { type: String, required: false },
|
||||
password: { type: String, required: false, select: false },
|
||||
fromName: { type: String, required: true },
|
||||
fromEmail: { type: String, required: true },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
emailAccountSchema.index({ name: 'text', host: 'text', fromEmail: 'text' });
|
||||
emailAccountSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
function omitPassword(_document, value) {
|
||||
delete value.password;
|
||||
return value;
|
||||
}
|
||||
emailAccountSchema.set('toJSON', { virtuals: true, transform: omitPassword });
|
||||
emailAccountSchema.set('toObject', { virtuals: true, transform: omitPassword });
|
||||
|
||||
export const emailAccountModel = mongoose.model('emailAccount', emailAccountSchema);
|
||||
@ -1,77 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const emailRecipientSchema = new Schema(
|
||||
{
|
||||
recipientEmail: { type: String, required: true },
|
||||
recipientType: { type: String, enum: ['client', 'vendor'], required: false },
|
||||
recipient: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'recipients.recipientType',
|
||||
required: false,
|
||||
},
|
||||
objectType: { type: String, required: false },
|
||||
object: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'recipients.objectType',
|
||||
required: false,
|
||||
},
|
||||
pixelId: { type: String, required: true },
|
||||
messageId: { type: String, required: false },
|
||||
attachments: [{ type: Schema.Types.ObjectId, ref: 'file', required: false }],
|
||||
read: { type: Boolean, required: true, default: false },
|
||||
sent: { type: Boolean, required: true, default: false },
|
||||
sentAt: { type: Date, required: false },
|
||||
readAt: { type: Date, required: false },
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const emailMessageSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
emailTemplate: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'emailTemplate',
|
||||
required: true,
|
||||
},
|
||||
objectType: { type: String, required: true },
|
||||
object: { type: Schema.Types.ObjectId, refPath: 'objectType', required: false },
|
||||
objects: [{ type: Schema.Types.ObjectId, refPath: 'objectType', required: false }],
|
||||
emailAccount: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'emailAccount',
|
||||
required: true,
|
||||
},
|
||||
recipients: { type: [emailRecipientSchema], required: true, default: [] },
|
||||
fromEmail: { type: String, required: true, immutable: true },
|
||||
subject: { type: String, required: false, default: '' },
|
||||
content: { type: String, required: false, default: '' },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'queued' },
|
||||
progress: { type: Number, required: false, default: 0 },
|
||||
message: { type: String, required: false },
|
||||
},
|
||||
sentAt: { type: Date, required: false },
|
||||
firstReadAt: { type: Date, required: false },
|
||||
lastReadAt: { type: Date, required: false },
|
||||
},
|
||||
{ timestamps: true, suppressReservedKeysWarning: true }
|
||||
);
|
||||
|
||||
emailMessageSchema.index({
|
||||
name: 'text',
|
||||
fromEmail: 'text',
|
||||
objectType: 'text',
|
||||
'recipients.recipientEmail': 'text',
|
||||
});
|
||||
emailMessageSchema.index({ 'recipients.pixelId': 1 }, { unique: true, sparse: true });
|
||||
emailMessageSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
emailMessageSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const emailMessageModel = mongoose.model('emailMessage', emailMessageSchema);
|
||||
@ -1,123 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
const RESOURCE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
function normalizeTemplateResourceName(value) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function validateTemplateResources(resources) {
|
||||
const names = new Set();
|
||||
for (const resource of resources || []) {
|
||||
if (
|
||||
!RESOURCE_NAME_PATTERN.test(resource?.name || '') ||
|
||||
resource?.file == null ||
|
||||
resource.file === '' ||
|
||||
names.has(resource.name)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
names.add(resource.name);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const RENDER_EMAIL_TEMPLATE_CALL = /fc\.renderEmailTemplate\s*\(\s*(['"])([^'"]+)\1/g;
|
||||
|
||||
function extractReferences(content) {
|
||||
const references = [];
|
||||
const seen = new Set();
|
||||
for (const match of String(content || '').matchAll(RENDER_EMAIL_TEMPLATE_CALL)) {
|
||||
const reference = match[2]?.trim();
|
||||
if (reference && !seen.has(reference)) {
|
||||
seen.add(reference);
|
||||
references.push(reference);
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
const emailTemplateAttachmentSchema = new Schema(
|
||||
{
|
||||
type: { type: String, enum: ['file', 'documentTemplate'], required: true },
|
||||
file: { type: Schema.Types.ObjectId, refPath: 'attachments.type', required: true },
|
||||
fileName: { type: String, required: true },
|
||||
fileType: { type: String, enum: ['pdf', 'jpeg', 'png', 'svg'], required: false },
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const emailTemplateSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true, unique: true },
|
||||
objectType: { type: String, required: false },
|
||||
tags: [{ type: String }],
|
||||
active: { type: Boolean, required: true, default: true },
|
||||
global: { type: Boolean, required: true, default: false },
|
||||
parent: { type: Schema.Types.ObjectId, ref: 'emailTemplate', required: false },
|
||||
referencedTemplates: [{ type: Schema.Types.ObjectId, ref: 'emailTemplate', required: false }],
|
||||
subject: { type: String, required: false, default: '' },
|
||||
content: { type: String, required: false, default: '<Container></Container>' },
|
||||
testObject: { type: Schema.Types.ObjectId, refPath: 'objectType', required: false },
|
||||
attachments: { type: [emailTemplateAttachmentSchema], default: [] },
|
||||
resources: {
|
||||
type: [
|
||||
new Schema(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
set: normalizeTemplateResourceName,
|
||||
match: RESOURCE_NAME_PATTERN,
|
||||
},
|
||||
file: { type: Schema.Types.ObjectId, ref: 'file', required: true },
|
||||
},
|
||||
{ _id: true }
|
||||
),
|
||||
],
|
||||
default: [],
|
||||
validate: {
|
||||
validator: validateTemplateResources,
|
||||
message: 'Template resource names must be unique.',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
emailTemplateSchema.index({ name: 'text', tags: 'text', objectType: 'text' });
|
||||
emailTemplateSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
emailTemplateSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
emailTemplateSchema.statics.recalculate = async function (emailTemplate, user) {
|
||||
const id = emailTemplate?._id || emailTemplate;
|
||||
if (!id || !(await this.exists({ _id: id }))) return;
|
||||
|
||||
const referencedTemplates = [];
|
||||
for (const reference of extractReferences(emailTemplate?.content)) {
|
||||
const normalizedReference = reference.replace(/^ETP:/i, '').trim();
|
||||
const found = await this.findOne({ _reference: normalizedReference }).select('_id').lean();
|
||||
if (found?._id) referencedTemplates.push(found._id);
|
||||
}
|
||||
|
||||
const { editObject } = await import('../../database.js');
|
||||
await editObject({
|
||||
model: this,
|
||||
id,
|
||||
updateData: { referencedTemplates },
|
||||
user,
|
||||
populate: [{ path: 'referencedTemplates', strictPopulate: false }],
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const emailTemplateModel = mongoose.model('emailTemplate', emailTemplateSchema);
|
||||
@ -1,28 +1,25 @@
|
||||
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
|
||||
const filamentSchema = new mongoose.Schema({
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { required: true, type: String },
|
||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: false },
|
||||
barcode: { required: false, type: String },
|
||||
url: { required: false, type: String },
|
||||
image: { required: false, type: Buffer },
|
||||
material: { type: Schema.Types.ObjectId, ref: 'material', required: true },
|
||||
color: { required: true, type: String },
|
||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
||||
type: { required: true, type: String },
|
||||
cost: { required: true, type: Number },
|
||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: true },
|
||||
costWithTax: { required: true, type: Number },
|
||||
diameter: { required: true, type: Number },
|
||||
density: { required: true, type: Number },
|
||||
createdAt: { required: true, type: Date },
|
||||
updatedAt: { required: true, type: Date },
|
||||
emptySpoolWeight: { required: true, type: Number },
|
||||
cost: { type: Number, required: false },
|
||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
costWithTax: { type: Number, required: false },
|
||||
}, { timestamps: true });
|
||||
|
||||
filamentSchema.index({ name: 'text', barcode: 'text', url: 'text' });
|
||||
});
|
||||
|
||||
filamentSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
@ -30,30 +27,4 @@ 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) {
|
||||
await filamentSkuModel.recalculate(sku, user);
|
||||
}
|
||||
};
|
||||
|
||||
export const filamentModel = mongoose.model('filament', filamentSchema);
|
||||
|
||||
@ -1,82 +0,0 @@
|
||||
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
|
||||
const filamentSkuSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
barcode: { type: String, required: false },
|
||||
filament: { type: Schema.Types.ObjectId, ref: 'filament', required: true },
|
||||
name: { type: String, required: true },
|
||||
description: { type: String, required: false },
|
||||
color: { type: String, required: true },
|
||||
cost: { type: Number, required: false },
|
||||
overrideCost: { type: Boolean, default: false },
|
||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
costWithTax: { type: Number, required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
filamentSkuSchema.index({ name: 'text', barcode: 'text', description: 'text', color: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
filamentSkuSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
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
|
||||
.find({
|
||||
'state.type': 'draft',
|
||||
itemType: 'filament',
|
||||
sku: skuId,
|
||||
})
|
||||
.populate('order')
|
||||
.lean();
|
||||
|
||||
for (const orderItem of draftOrderItems) {
|
||||
await orderItemModel.recalculate(orderItem, user);
|
||||
}
|
||||
};
|
||||
|
||||
// Create and export the model
|
||||
export const filamentSkuModel = mongoose.model('filamentSku', filamentSkuSchema);
|
||||
@ -9,13 +9,10 @@ const fileSchema = new mongoose.Schema(
|
||||
extension: { required: true, type: String },
|
||||
size: { required: false, type: Number },
|
||||
metaData: { required: false, type: Object },
|
||||
hasThumbnails: { required: false, type: Boolean, default: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
fileSchema.index({ name: 'text', type: 'text', extension: 'text' });
|
||||
|
||||
fileSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
@ -10,12 +10,20 @@ const deviceInfoSchema = new mongoose.Schema(
|
||||
release: { type: String },
|
||||
arch: { type: String },
|
||||
hostname: { type: String },
|
||||
uptime: { type: Number },
|
||||
},
|
||||
cpu: {
|
||||
cores: { type: Number },
|
||||
model: { type: String },
|
||||
speedMHz: { type: Number },
|
||||
},
|
||||
memory: {
|
||||
totalGB: { type: String }, // stored as string from .toFixed(2), could also use Number
|
||||
freeGB: { type: String },
|
||||
},
|
||||
network: {
|
||||
type: mongoose.Schema.Types.Mixed, // since it's an object with dynamic interface names
|
||||
},
|
||||
user: {
|
||||
uid: { type: Number },
|
||||
gid: { type: Number },
|
||||
@ -25,6 +33,8 @@ const deviceInfoSchema = new mongoose.Schema(
|
||||
},
|
||||
process: {
|
||||
nodeVersion: { type: String },
|
||||
pid: { type: Number },
|
||||
cwd: { type: String },
|
||||
execPath: { type: String },
|
||||
},
|
||||
},
|
||||
@ -45,16 +55,12 @@ const hostSchema = new mongoose.Schema(
|
||||
active: { required: true, type: Boolean, default: true },
|
||||
connectedAt: { required: false, type: Date },
|
||||
authCode: { type: { required: false, type: String } },
|
||||
deviceInfo: deviceInfoSchema,
|
||||
otp: { type: { required: false, type: String } },
|
||||
otpExpiresAt: { required: false, type: Date },
|
||||
deviceInfo: { deviceInfoSchema },
|
||||
files: [{ type: mongoose.Schema.Types.ObjectId, ref: 'file' }],
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
hostSchema.index({ name: 'text', tags: 'text' });
|
||||
|
||||
hostSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
@ -1,17 +1,13 @@
|
||||
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 },
|
||||
tags: [{ type: String }],
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
materialSchema.index({ name: 'text', url: 'text', tags: 'text' });
|
||||
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 }],
|
||||
});
|
||||
|
||||
materialSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -23,8 +23,6 @@ const noteTypeSchema = new Schema(
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
noteTypeSchema.index({ name: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
noteTypeSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -1,31 +1,27 @@
|
||||
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
|
||||
// Define the main part schema
|
||||
const partSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
file: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
|
||||
cost: { type: Number, required: false },
|
||||
price: { type: Number, required: false },
|
||||
fileName: { type: String, required: false },
|
||||
priceMode: { type: String, default: 'margin' },
|
||||
price: { type: Number, required: true },
|
||||
cost: { type: Number, required: true },
|
||||
margin: { type: Number, required: false },
|
||||
amount: { type: Number, required: false },
|
||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
||||
file: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
|
||||
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
costWithTax: { type: Number, required: false },
|
||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
priceWithTax: { type: Number, required: false },
|
||||
costWithTax: { type: Number, required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
partSchema.index({ name: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
partSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
@ -34,35 +30,5 @@ partSchema.virtual('id').get(function () {
|
||||
// Configure JSON serialization to include virtuals
|
||||
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) {
|
||||
await partSkuModel.recalculate(sku, user);
|
||||
}
|
||||
};
|
||||
|
||||
// Create and export the model
|
||||
export const partModel = mongoose.model('part', partSchema);
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
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
|
||||
const partSkuSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
barcode: { type: String, required: false },
|
||||
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
|
||||
name: { type: String, required: true },
|
||||
description: { type: String, required: false },
|
||||
priceMode: { type: String, default: 'margin' },
|
||||
price: { type: Number, required: false },
|
||||
cost: { type: Number, required: false },
|
||||
overrideCost: { type: Boolean, default: false },
|
||||
overridePrice: { type: Boolean, default: false },
|
||||
margin: { type: Number, required: false },
|
||||
amount: { type: Number, required: false },
|
||||
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
priceWithTax: { type: Number, required: false },
|
||||
costWithTax: { type: Number, required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
partSkuSchema.index({ name: 'text', barcode: 'text', description: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
partSkuSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
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
|
||||
.find({
|
||||
'state.type': 'draft',
|
||||
itemType: 'part',
|
||||
sku: skuId,
|
||||
})
|
||||
.populate('order')
|
||||
.lean();
|
||||
|
||||
for (const orderItem of draftOrderItems) {
|
||||
await orderItemModel.recalculate(orderItem, user);
|
||||
}
|
||||
};
|
||||
|
||||
// Create and export the model
|
||||
export const partSkuModel = mongoose.model('partSku', partSkuSchema);
|
||||
@ -1,62 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { excludeIdFromList, getPermissionSettingsId } from '../../permissions.js';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const permissionSettingSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
permissions: { type: Schema.Types.Mixed, required: false, default: () => ({}) },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
permissionSettingSchema.index({ name: 'text' });
|
||||
|
||||
permissionSettingSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
permissionSettingSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
const recalculateAssignees = async (model, permissionSettingId, stillExists, user) => {
|
||||
const assigned = await model.find({ permissionSettings: permissionSettingId }).lean();
|
||||
|
||||
if (!stillExists) {
|
||||
await model.updateMany(
|
||||
{ permissionSettings: permissionSettingId },
|
||||
{ $pull: { permissionSettings: permissionSettingId } }
|
||||
);
|
||||
}
|
||||
|
||||
for (const item of assigned) {
|
||||
const nextItem = stillExists
|
||||
? item
|
||||
: {
|
||||
...item,
|
||||
permissionSettings: excludeIdFromList(item.permissionSettings, permissionSettingId),
|
||||
};
|
||||
await model.recalculate(nextItem, user);
|
||||
}
|
||||
};
|
||||
|
||||
permissionSettingSchema.statics.recalculate = async function (permissionSetting, user) {
|
||||
const permissionSettingId = getPermissionSettingsId(permissionSetting);
|
||||
if (!permissionSettingId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stillExists = await this.exists({ _id: permissionSettingId });
|
||||
const userGroupModel = mongoose.model('userGroup');
|
||||
const userModel = mongoose.model('user');
|
||||
|
||||
await recalculateAssignees(userGroupModel, permissionSettingId, stillExists, user);
|
||||
await recalculateAssignees(userModel, permissionSettingId, stillExists, user);
|
||||
};
|
||||
|
||||
export const permissionSettingModel = mongoose.model(
|
||||
'permissionSetting',
|
||||
permissionSettingSchema
|
||||
);
|
||||
@ -1,34 +1,29 @@
|
||||
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;
|
||||
|
||||
const partSchema = new Schema({
|
||||
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
|
||||
quantity: { type: Number, required: true },
|
||||
});
|
||||
|
||||
// Define the main product schema
|
||||
const productSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
productCategory: { type: Schema.Types.ObjectId, ref: 'productCategory', required: true },
|
||||
tags: [{ type: String }],
|
||||
version: { type: String },
|
||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
||||
cost: { type: Number, required: false },
|
||||
price: { type: Number, required: false },
|
||||
priceMode: { type: String, default: 'margin' },
|
||||
margin: { type: Number, required: false },
|
||||
amount: { type: Number, required: false },
|
||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
||||
parts: [partSchema],
|
||||
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
costWithTax: { type: Number, required: false },
|
||||
priceWithTax: { type: Number, required: false },
|
||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
productSchema.index({ name: 'text', tags: 'text', version: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
productSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
@ -37,51 +32,5 @@ productSchema.virtual('id').get(function () {
|
||||
// Configure JSON serialization to include virtuals
|
||||
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
|
||||
.find({
|
||||
'state.type': 'draft',
|
||||
itemType: 'product',
|
||||
item: itemId,
|
||||
})
|
||||
.populate('order')
|
||||
.lean();
|
||||
|
||||
for (const orderItem of draftOrderItems) {
|
||||
await orderItemModel.recalculate(orderItem, user);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// Create and export the model
|
||||
export const productModel = mongoose.model('product', productSchema);
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const marketplaceMappingSchema = new mongoose.Schema(
|
||||
{
|
||||
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
||||
externalReference: { type: String, required: false },
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const productCategorySchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { required: true, type: String },
|
||||
marketplaces: { type: [marketplaceMappingSchema], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
productCategorySchema.index({ name: 'text' });
|
||||
|
||||
productCategorySchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
productCategorySchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const productCategoryModel = mongoose.model('productCategory', productCategorySchema);
|
||||
@ -1,119 +0,0 @@
|
||||
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({
|
||||
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
|
||||
partSku: { type: Schema.Types.ObjectId, ref: 'partSku', required: true },
|
||||
quantity: { type: Number, required: true },
|
||||
});
|
||||
|
||||
// Define the main product SKU schema
|
||||
const productSkuSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
barcode: { type: String, required: false },
|
||||
product: { type: Schema.Types.ObjectId, ref: 'product', required: true },
|
||||
name: { type: String, required: true },
|
||||
description: { type: String, required: false },
|
||||
priceMode: { type: String, default: 'margin' },
|
||||
price: { type: Number, required: false },
|
||||
cost: { type: Number, required: false },
|
||||
overrideCost: { type: Boolean, default: false },
|
||||
overridePrice: { type: Boolean, default: false },
|
||||
margin: { type: Number, required: false },
|
||||
amount: { type: Number, required: false },
|
||||
parts: { type: [partSkuUsageSchema], default: [] },
|
||||
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
priceWithTax: { type: Number, required: false },
|
||||
costWithTax: { type: Number, required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
productSkuSchema.index({ name: 'text', barcode: 'text', description: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
productSkuSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
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
|
||||
.find({
|
||||
'state.type': 'draft',
|
||||
itemType: 'product',
|
||||
sku: skuId,
|
||||
})
|
||||
.populate('order')
|
||||
.lean();
|
||||
|
||||
for (const orderItem of draftOrderItems) {
|
||||
await orderItemModel.recalculate(orderItem, user);
|
||||
}
|
||||
};
|
||||
|
||||
// Create and export the model
|
||||
export const productSkuModel = mongoose.model('productSku', productSkuSchema);
|
||||
@ -1,108 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { editObject } from '../../database.js';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const toId = (value) => {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'object' && value._id != null) return String(value._id);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export function normalizeAuditLevelLine(line) {
|
||||
const allItems = Boolean(line?.allItems);
|
||||
const allSkus = allItems ? true : Boolean(line?.allSkus);
|
||||
const item = allItems ? null : (line?.item?._id ?? line?.item ?? null);
|
||||
const itemSku = allItems || allSkus ? null : (line?.itemSku?._id ?? line?.itemSku ?? null);
|
||||
|
||||
return {
|
||||
_id: line?._id,
|
||||
itemType: line?.itemType,
|
||||
allItems,
|
||||
allSkus,
|
||||
item,
|
||||
itemSku,
|
||||
};
|
||||
}
|
||||
|
||||
function auditLevelLineChanged(before, after) {
|
||||
return (
|
||||
Boolean(before?.allItems) !== Boolean(after.allItems) ||
|
||||
Boolean(before?.allSkus) !== Boolean(after.allSkus) ||
|
||||
toId(before?.item) !== toId(after.item) ||
|
||||
toId(before?.itemSku) !== toId(after.itemSku)
|
||||
);
|
||||
}
|
||||
|
||||
const stockAuditLevelLineSchema = new Schema(
|
||||
{
|
||||
itemType: {
|
||||
type: String,
|
||||
enum: ['filament', 'part', 'product'],
|
||||
required: true,
|
||||
},
|
||||
allItems: { type: Boolean, default: false },
|
||||
item: { type: Schema.Types.ObjectId, refPath: 'auditLines.itemType', required: false },
|
||||
allSkus: { type: Boolean, default: false },
|
||||
itemSku: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: function () {
|
||||
return ['filament', 'part', 'product'].includes(this.itemType)
|
||||
? this.itemType + 'Sku'
|
||||
: null;
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const stockAuditLevelSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
tags: [{ type: String, required: true }],
|
||||
auditLines: { type: [stockAuditLevelLineSchema], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
stockAuditLevelSchema.index({ name: 'text', tags: 'text' });
|
||||
|
||||
stockAuditLevelSchema.statics.stats = async function () {
|
||||
const count = await this.countDocuments();
|
||||
return { total: { count } };
|
||||
};
|
||||
|
||||
stockAuditLevelSchema.statics.history = async function () {
|
||||
return [];
|
||||
};
|
||||
|
||||
stockAuditLevelSchema.statics.recalculate = async function (stockAuditLevel, user) {
|
||||
if (!stockAuditLevel?._id) return;
|
||||
|
||||
const auditLines = stockAuditLevel.auditLines || [];
|
||||
const normalizedLines = auditLines.map((line) => normalizeAuditLevelLine(line));
|
||||
const changed = auditLines.some((line, index) =>
|
||||
auditLevelLineChanged(line, normalizedLines[index])
|
||||
);
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
await editObject({
|
||||
model: this,
|
||||
id: stockAuditLevel._id,
|
||||
updateData: { auditLines: normalizedLines },
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
stockAuditLevelSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
stockAuditLevelSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const stockAuditLevelModel = mongoose.model('stockAuditLevel', stockAuditLevelSchema);
|
||||
@ -1,6 +1,5 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { marketplaceSyncMappingSchema } from '../sales/marketplaceMapping.schema.js';
|
||||
|
||||
const taxRateSchema = new mongoose.Schema(
|
||||
{
|
||||
@ -11,17 +10,12 @@ const taxRateSchema = new mongoose.Schema(
|
||||
active: { required: true, type: Boolean, default: true },
|
||||
description: { required: false, type: String },
|
||||
country: { required: false, type: String },
|
||||
jurisdiction: { required: false, type: String },
|
||||
shippingAndHandlingTaxed: { required: false, type: Boolean, default: false },
|
||||
effectiveFrom: { required: false, type: Date },
|
||||
effectiveTo: { required: false, type: Date },
|
||||
marketplaces: { type: [marketplaceSyncMappingSchema()], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
taxRateSchema.index({ name: 'text', description: 'text', country: 'text' });
|
||||
|
||||
taxRateSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
28
src/database/schemas/management/taxrecord.schema.js
Normal file
28
src/database/schemas/management/taxrecord.schema.js
Normal file
@ -0,0 +1,28 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const taxRecordSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: true },
|
||||
transactionType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['purchaseOrder', 'salesOrder', 'other'],
|
||||
},
|
||||
transaction: { type: Schema.Types.ObjectId, refPath: 'transactionType', required: true },
|
||||
amount: { type: Number, required: true },
|
||||
taxAmount: { type: Number, required: true },
|
||||
transactionDate: { required: true, type: Date, default: Date.now },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
taxRecordSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
taxRecordSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const taxRecordModel = mongoose.model('taxRecord', taxRecordSchema);
|
||||
@ -1,13 +1,5 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import {
|
||||
applyPermissionSettingsList,
|
||||
resolvePermissionSettings,
|
||||
resolveReferencedDocs,
|
||||
saveUserPermissionsToRedis,
|
||||
} from '../../permissions.js';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const userSchema = new mongoose.Schema(
|
||||
{
|
||||
@ -18,47 +10,14 @@ const userSchema = new mongoose.Schema(
|
||||
lastName: { required: false, type: String },
|
||||
email: { required: true, type: String },
|
||||
profileImage: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
|
||||
appPasswordHash: { type: String, required: false, select: false },
|
||||
groups: [{ type: Schema.Types.ObjectId, ref: 'userGroup', required: false }],
|
||||
permissionSettings: [
|
||||
{ type: Schema.Types.ObjectId, ref: 'permissionSetting', required: false },
|
||||
],
|
||||
permissions: { type: Schema.Types.Mixed, required: false, default: () => ({}) },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
userSchema.index({ username: 'text', name: 'text', firstName: 'text', lastName: 'text', email: 'text' });
|
||||
|
||||
userSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
userSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
userSchema.statics.recalculate = async function (user, actingUser) {
|
||||
const userId = user?._id || user;
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = await resolveReferencedDocs('userGroup', user?.groups);
|
||||
const settings = await resolvePermissionSettings(user);
|
||||
const permissions = applyPermissionSettingsList([...groups, ...settings]);
|
||||
if (user && typeof user === 'object' && !user._bsontype) {
|
||||
user.permissions = permissions;
|
||||
}
|
||||
await saveUserPermissionsToRedis(userId, permissions);
|
||||
const { editObject } = await import('../../database.js');
|
||||
|
||||
await editObject({
|
||||
model: this,
|
||||
id: userId,
|
||||
updateData: { permissions },
|
||||
user: actingUser,
|
||||
populate: ['profileImage', 'permissionSettings', 'groups'],
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const userModel = mongoose.model('user', userSchema);
|
||||
|
||||
@ -1,74 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import {
|
||||
applyPermissionSettingsList,
|
||||
excludeIdFromList,
|
||||
resolvePermissionSettings,
|
||||
} from '../../permissions.js';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const userGroupSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
permissionSettings: [
|
||||
{ type: Schema.Types.ObjectId, ref: 'permissionSetting', required: false },
|
||||
],
|
||||
permissions: { type: Schema.Types.Mixed, required: false, default: () => ({}) },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
userGroupSchema.index({ name: 'text' });
|
||||
|
||||
userGroupSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
userGroupSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
userGroupSchema.statics.recalculate = async function (userGroup, actingUser) {
|
||||
const userGroupId = userGroup?._id || userGroup;
|
||||
if (!userGroupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stillExists = await this.exists({ _id: userGroupId });
|
||||
if (stillExists) {
|
||||
const settings = await resolvePermissionSettings(userGroup);
|
||||
const permissions = applyPermissionSettingsList(settings);
|
||||
if (userGroup && typeof userGroup === 'object' && !userGroup._bsontype) {
|
||||
userGroup.permissions = permissions;
|
||||
}
|
||||
const { editObject } = await import('../../database.js');
|
||||
|
||||
await editObject({
|
||||
model: this,
|
||||
id: userGroupId,
|
||||
updateData: { permissions },
|
||||
user: actingUser,
|
||||
populate: ['permissionSettings'],
|
||||
recalculate: false,
|
||||
});
|
||||
}
|
||||
|
||||
const userModel = mongoose.model('user');
|
||||
const users = await userModel.find({ groups: userGroupId }).lean();
|
||||
|
||||
if (!stillExists) {
|
||||
await userModel.updateMany({ groups: userGroupId }, { $pull: { groups: userGroupId } });
|
||||
}
|
||||
|
||||
for (const assignedUser of users) {
|
||||
const nextUser = stillExists
|
||||
? assignedUser
|
||||
: {
|
||||
...assignedUser,
|
||||
groups: excludeIdFromList(assignedUser.groups, userGroupId),
|
||||
};
|
||||
await userModel.recalculate(nextUser, actingUser);
|
||||
}
|
||||
};
|
||||
|
||||
export const userGroupModel = mongoose.model('userGroup', userGroupSchema);
|
||||
@ -26,8 +26,6 @@ const vendorSchema = new mongoose.Schema(
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
vendorSchema.index({ name: 'text', website: 'text', email: 'text', phone: 'text', contact: 'text', country: 'text' });
|
||||
|
||||
vendorSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const notificationSchema = new mongoose.Schema({
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
user: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'user',
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: 'info',
|
||||
},
|
||||
read: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
metadata: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now,
|
||||
},
|
||||
});
|
||||
|
||||
notificationSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
notificationSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const notificationModel = mongoose.model('notification', notificationSchema);
|
||||
@ -1,58 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const objectViewSchema = new mongoose.Schema({
|
||||
user: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'user',
|
||||
required: true,
|
||||
},
|
||||
objectType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: '#3498DB',
|
||||
},
|
||||
private: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: true,
|
||||
},
|
||||
filter: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: () => ({}),
|
||||
},
|
||||
sort: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: () => ({}),
|
||||
},
|
||||
viewMode: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: null,
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now,
|
||||
},
|
||||
});
|
||||
|
||||
objectViewSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
objectViewSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const objectViewModel = mongoose.model('objectView', objectViewSchema);
|
||||
@ -1,44 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const userNotifierSchema = new mongoose.Schema({
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
user: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'user',
|
||||
required: true,
|
||||
},
|
||||
email: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
object: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'objectType',
|
||||
required: true,
|
||||
},
|
||||
objectType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now,
|
||||
},
|
||||
});
|
||||
|
||||
userNotifierSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
userNotifierSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const userNotifierModel = mongoose.model('userNotifier', userNotifierSchema);
|
||||
@ -1,47 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const userSettingsSchema = new mongoose.Schema({
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
user: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'user',
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
defaults: {
|
||||
viewMode: { type: Schema.Types.Mixed, default: () => ({}) },
|
||||
filterSidebarVisibility: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: () => ({}),
|
||||
},
|
||||
sortSidebarVisibility: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: () => ({}),
|
||||
},
|
||||
columnVisibility: { type: Schema.Types.Mixed, default: () => ({}) },
|
||||
collapseState: { type: Schema.Types.Mixed, default: () => ({}) },
|
||||
},
|
||||
pageLayout: { type: Schema.Types.Mixed, default: () => ({}) },
|
||||
appearance: { type: Schema.Types.Mixed, default: () => ({}) },
|
||||
|
||||
createdAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now,
|
||||
},
|
||||
});
|
||||
|
||||
userSettingsSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
userSettingsSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const userSettingsModel = mongoose.model('userSettings', userSettingsSchema);
|
||||
@ -1,138 +1,101 @@
|
||||
import { jobModel } from './production/job.schema.js';
|
||||
import { subJobModel } from './production/subjob.schema.js';
|
||||
import { printerModel } from './production/printer.schema.js';
|
||||
import { printerProfileModel } from './production/printerprofile.schema.js';
|
||||
import { filamentProfileModel } from './production/filamentprofile.schema.js';
|
||||
import { filamentModel } from './management/filament.schema.js';
|
||||
import { filamentSkuModel } from './management/filamentsku.schema.js';
|
||||
import { gcodeFileModel } from './production/gcodefile.schema.js';
|
||||
import { partModel } from './management/part.schema.js';
|
||||
import { partSkuModel } from './management/partsku.schema.js';
|
||||
import { productModel } from './management/product.schema.js';
|
||||
import { productCategoryModel } from './management/productcategory.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';
|
||||
import { stockEventModel } from './inventory/stockevent.schema.js';
|
||||
import { stockAuditModel } from './inventory/stockaudit.schema.js';
|
||||
import { stockAuditLevelModel } from './management/stockauditlevel.schema.js';
|
||||
import { partStockModel } from './inventory/partstock.schema.js';
|
||||
import { productStockModel } from './inventory/productstock.schema.js';
|
||||
import { stockLocationModel } from './inventory/stocklocation.schema.js';
|
||||
import { stockTransferModel } from './inventory/stocktransfer.schema.js';
|
||||
import { auditLogModel } from './management/auditlog.schema.js';
|
||||
import { userModel } from './management/user.schema.js';
|
||||
import { userGroupModel } from './management/usergroup.schema.js';
|
||||
import { permissionSettingModel } from './management/permissionsetting.schema.js';
|
||||
import { appPasswordModel } from './management/apppassword.schema.js';
|
||||
import { noteTypeModel } from './management/notetype.schema.js';
|
||||
import { noteModel } from './misc/note.schema.js';
|
||||
import { notificationModel } from './misc/notification.schema.js';
|
||||
import { userNotifierModel } from './misc/usernotifier.schema.js';
|
||||
import { objectViewModel } from './misc/objectview.schema.js';
|
||||
import { documentSizeModel } from './management/documentsize.schema.js';
|
||||
import { documentTemplateModel } from './management/documenttemplate.schema.js';
|
||||
import { emailAccountModel } from './management/emailaccount.schema.js';
|
||||
import { emailTemplateModel } from './management/emailtemplate.schema.js';
|
||||
import { emailMessageModel } from './management/emailmessage.schema.js';
|
||||
import { hostModel } from './management/host.schema.js';
|
||||
import { documentPrinterModel } from './management/documentprinter.schema.js';
|
||||
import { documentJobModel } from './management/documentjob.schema.js';
|
||||
import { fileModel } from './management/file.schema.js';
|
||||
import { courierServiceModel } from './management/courierservice.schema.js';
|
||||
import { courierModel } from './management/courier.schema.js';
|
||||
import { taxRateModel } from './management/taxrate.schema.js';
|
||||
import { taxRecordModel } from './finance/taxrecord.schema.js';
|
||||
import { shipmentModel } from './inventory/shipment.schema.js';
|
||||
import { invoiceModel } from './finance/invoice.schema.js';
|
||||
import { clientModel } from './sales/client.schema.js';
|
||||
import { salesOrderModel } from './sales/salesorder.schema.js';
|
||||
import { marketplaceModel } from './sales/marketplace.schema.js';
|
||||
import { listingModel } from './sales/listing.schema.js';
|
||||
import { listingVarientModel } from './sales/listingvarient.schema.js';
|
||||
import { marketplaceEventModel } from './sales/marketplaceevent.schema.js';
|
||||
import { paymentModel } from './finance/payment.schema.js';
|
||||
import { paymentPolicyModel } from './finance/paymentpolicy.schema.js';
|
||||
import { fulfillmentPolicyModel } from './sales/fulfillmentpolicy.schema.js';
|
||||
import { returnPolicyModel } from './sales/returnpolicy.schema.js';
|
||||
import { taxRateModel } from './management/taxrates.schema.js';
|
||||
import { taxRecordModel } from './management/taxrecord.schema.js';
|
||||
|
||||
function modelEntry(getModel, type, label) {
|
||||
return {
|
||||
get model() {
|
||||
return getModel();
|
||||
},
|
||||
idField: '_id',
|
||||
type,
|
||||
referenceField: '_reference',
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
// Map prefixes to models and id fields.
|
||||
// Model getters are lazy so circular ESM imports (schema -> utils -> models -> schema)
|
||||
// do not read bindings that are still in the temporal dead zone.
|
||||
// Map prefixes to models and id fields
|
||||
export const models = {
|
||||
PRN: modelEntry(() => printerModel, 'printer', 'Printer'),
|
||||
PPF: modelEntry(() => printerProfileModel, 'printerProfile', 'Printer Profile'),
|
||||
FPF: modelEntry(() => filamentProfileModel, 'filamentProfile', 'Filament Profile'),
|
||||
FIL: modelEntry(() => filamentModel, 'filament', 'Filament'),
|
||||
FSU: modelEntry(() => filamentSkuModel, 'filamentSku', 'Filament SKU'),
|
||||
GCF: modelEntry(() => gcodeFileModel, 'gcodeFile', 'G-Code File'),
|
||||
JOB: modelEntry(() => jobModel, 'job', 'Job'),
|
||||
PRT: modelEntry(() => partModel, 'part', 'Part'),
|
||||
PSU: modelEntry(() => partSkuModel, 'partSku', 'Part SKU'),
|
||||
PRD: modelEntry(() => productModel, 'product', 'Product'),
|
||||
PCG: modelEntry(() => productCategoryModel, 'productCategory', 'Product Category'),
|
||||
SKU: modelEntry(() => productSkuModel, 'productSku', 'Product SKU'),
|
||||
VEN: modelEntry(() => vendorModel, 'vendor', 'Vendor'),
|
||||
MAT: modelEntry(() => materialModel, 'material', 'Material'),
|
||||
SJB: modelEntry(() => subJobModel, 'subJob', 'Sub Job'),
|
||||
FLS: modelEntry(() => filamentStockModel, 'filamentStock', 'Filament Stock'),
|
||||
SEV: modelEntry(() => stockEventModel, 'stockEvent', 'Stock Event'),
|
||||
SAU: modelEntry(() => stockAuditModel, 'stockAudit', 'Stock Audit'),
|
||||
SAL: modelEntry(() => stockAuditLevelModel, 'stockAuditLevel', 'Stock Audit Level'),
|
||||
PTS: modelEntry(() => partStockModel, 'partStock', 'Part Stock'),
|
||||
PDS: modelEntry(() => productStockModel, 'productStock', 'Product Stock'),
|
||||
SLN: modelEntry(() => stockLocationModel, 'stockLocation', 'Stock Location'),
|
||||
STT: modelEntry(() => stockTransferModel, 'stockTransfer', 'Stock Transfer'),
|
||||
ADL: modelEntry(() => auditLogModel, 'auditLog', 'Audit Log'),
|
||||
USR: modelEntry(() => userModel, 'user', 'User'),
|
||||
UGP: modelEntry(() => userGroupModel, 'userGroup', 'User Group'),
|
||||
PMS: modelEntry(() => permissionSettingModel, 'permissionSetting', 'Permission Settings'),
|
||||
APP: modelEntry(() => appPasswordModel, 'appPassword', 'App Password'),
|
||||
NTY: modelEntry(() => noteTypeModel, 'noteType', 'Note Type'),
|
||||
NTE: modelEntry(() => noteModel, 'note', 'Note'),
|
||||
NTF: modelEntry(() => notificationModel, 'notification', 'Notification'),
|
||||
ONF: modelEntry(() => userNotifierModel, 'userNotifier', 'User Notifier'),
|
||||
OVW: modelEntry(() => objectViewModel, 'objectView', 'Object View'),
|
||||
DSZ: modelEntry(() => documentSizeModel, 'documentSize', 'Document Size'),
|
||||
DTP: modelEntry(() => documentTemplateModel, 'documentTemplate', 'Document Template'),
|
||||
EAC: modelEntry(() => emailAccountModel, 'emailAccount', 'Email Account'),
|
||||
ETP: modelEntry(() => emailTemplateModel, 'emailTemplate', 'Email Template'),
|
||||
EMS: modelEntry(() => emailMessageModel, 'emailMessage', 'Email Message'),
|
||||
DPR: modelEntry(() => documentPrinterModel, 'documentPrinter', 'Document Printer'),
|
||||
DJB: modelEntry(() => documentJobModel, 'documentJob', 'Document Job'),
|
||||
HST: modelEntry(() => hostModel, 'host', 'Host'),
|
||||
FLE: modelEntry(() => fileModel, 'file', 'File'),
|
||||
POR: modelEntry(() => purchaseOrderModel, 'purchaseOrder', 'Purchase Order'),
|
||||
ODI: modelEntry(() => orderItemModel, 'orderItem', 'Order Item'),
|
||||
COS: modelEntry(() => courierServiceModel, 'courierService', 'Courier Service'),
|
||||
COR: modelEntry(() => courierModel, 'courier', 'Courier'),
|
||||
TXR: modelEntry(() => taxRateModel, 'taxRate', 'Tax Rate'),
|
||||
TXD: modelEntry(() => taxRecordModel, 'taxRecord', 'Tax Record'),
|
||||
SHP: modelEntry(() => shipmentModel, 'shipment', 'Shipment'),
|
||||
INV: modelEntry(() => invoiceModel, 'invoice', 'Invoice'),
|
||||
CLI: modelEntry(() => clientModel, 'client', 'Client'),
|
||||
SOR: modelEntry(() => salesOrderModel, 'salesOrder', 'Sales Order'),
|
||||
MKT: modelEntry(() => marketplaceModel, 'marketplace', 'Marketplace'),
|
||||
LST: modelEntry(() => listingModel, 'listing', 'Listing'),
|
||||
LVR: modelEntry(() => listingVarientModel, 'listingVarient', 'Listing Varient'),
|
||||
MKE: modelEntry(() => marketplaceEventModel, 'marketplaceEvent', 'Marketplace Event'),
|
||||
PAY: modelEntry(() => paymentModel, 'payment', 'Payment'),
|
||||
PPL: modelEntry(() => paymentPolicyModel, 'paymentPolicy', 'Payment Policy'),
|
||||
FPL: modelEntry(() => fulfillmentPolicyModel, 'fulfillmentPolicy', 'Fulfillment Policy'),
|
||||
RPL: modelEntry(() => returnPolicyModel, 'returnPolicy', 'Return Policy'),
|
||||
PRN: { model: printerModel, idField: '_id', type: 'printer', referenceField: '_reference' },
|
||||
FIL: { model: filamentModel, idField: '_id', type: 'filament', referenceField: '_reference' },
|
||||
GCF: { model: gcodeFileModel, idField: '_id', type: 'gcodeFile', referenceField: '_reference' },
|
||||
JOB: { model: jobModel, idField: '_id', type: 'job', referenceField: '_reference' },
|
||||
PRT: { model: partModel, idField: '_id', type: 'part', referenceField: '_reference' },
|
||||
PRD: { model: productModel, idField: '_id', type: 'product', referenceField: '_reference' },
|
||||
VEN: { model: vendorModel, idField: '_id', type: 'vendor', referenceField: '_reference' },
|
||||
SJB: { model: subJobModel, idField: '_id', type: 'subJob', referenceField: '_reference' },
|
||||
FLS: {
|
||||
model: filamentStockModel,
|
||||
idField: '_id',
|
||||
type: 'filamentStock',
|
||||
referenceField: '_reference',
|
||||
},
|
||||
SEV: { model: stockEventModel, idField: '_id', type: 'stockEvent', referenceField: '_reference' },
|
||||
SAU: { model: stockAuditModel, idField: '_id', type: 'stockAudit', referenceField: '_reference' },
|
||||
PTS: { model: partStockModel, idField: '_id', type: 'partStock', referenceField: '_reference' },
|
||||
PDS: { model: null, idField: '_id', type: 'productStock', referenceField: '_reference' }, // No productStockModel found
|
||||
ADL: { model: auditLogModel, idField: '_id', type: 'auditLog', referenceField: '_reference' },
|
||||
USR: { model: userModel, idField: '_id', type: 'user', referenceField: '_reference' },
|
||||
NTY: { model: noteTypeModel, idField: '_id', type: 'noteType', referenceField: '_reference' },
|
||||
NTE: { model: noteModel, idField: '_id', type: 'note', referenceField: '_reference' },
|
||||
DSZ: {
|
||||
model: documentSizeModel,
|
||||
idField: '_id',
|
||||
type: 'documentSize',
|
||||
referenceField: '_reference',
|
||||
},
|
||||
DTP: {
|
||||
model: documentTemplateModel,
|
||||
idField: '_id',
|
||||
type: 'documentTemplate',
|
||||
referenceField: '_reference',
|
||||
},
|
||||
DPR: {
|
||||
model: documentPrinterModel,
|
||||
idField: '_id',
|
||||
type: 'documentPrinter',
|
||||
referenceField: '_reference',
|
||||
},
|
||||
DJB: {
|
||||
model: documentJobModel,
|
||||
idField: '_id',
|
||||
type: 'documentJob',
|
||||
referenceField: '_reference',
|
||||
},
|
||||
HST: { model: hostModel, idField: '_id', type: 'host', referenceField: '_reference' },
|
||||
FLE: { model: fileModel, idField: '_id', type: 'file', referenceField: '_reference' },
|
||||
POR: {
|
||||
model: purchaseOrderModel,
|
||||
idField: '_id',
|
||||
type: 'purchaseOrder',
|
||||
referenceField: '_reference',
|
||||
},
|
||||
ODI: {
|
||||
model: orderItemModel,
|
||||
idField: '_id',
|
||||
type: 'orderItem',
|
||||
referenceField: '_reference',
|
||||
},
|
||||
COS: {
|
||||
model: courierServiceModel,
|
||||
idField: '_id',
|
||||
type: 'courierService',
|
||||
referenceField: '_reference',
|
||||
},
|
||||
COR: { model: courierModel, idField: '_id', type: 'courier', referenceField: '_reference' },
|
||||
TXR: { model: taxRateModel, idField: '_id', type: 'taxRate', referenceField: '_reference' },
|
||||
TXD: { model: taxRecordModel, idField: '_id', type: 'taxRecord', referenceField: '_reference' },
|
||||
};
|
||||
|
||||
@ -1,165 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
|
||||
const filamentProfileSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
filamentType: { type: String, required: true },
|
||||
filament: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
refPath: 'filamentType',
|
||||
required: true,
|
||||
},
|
||||
filamentIsSupport: { type: Boolean },
|
||||
filamentSoluble: { type: Boolean },
|
||||
filamentPrintable: { type: Number },
|
||||
filamentAdhesivenessCategory: { type: Number },
|
||||
temperatureVitrification: { type: Number },
|
||||
idleTemperature: { type: Number },
|
||||
pelletFlowCoefficient: { type: Number },
|
||||
requiredNozzleHrc: { type: Number },
|
||||
filamentFlowRatio: { type: Number },
|
||||
enablePressureAdvance: { type: Boolean },
|
||||
pressureAdvance: { type: Number },
|
||||
adaptivePressureAdvance: { type: Boolean },
|
||||
adaptivePressureAdvanceBridges: { type: Boolean },
|
||||
adaptivePressureAdvanceOverhangs: { type: Boolean },
|
||||
adaptivePressureAdvanceModel: { type: String },
|
||||
activateChamberTempControl: { type: Boolean },
|
||||
chamberTemperature: { type: Number },
|
||||
chamberMinimalTemperature: { type: Number },
|
||||
nozzleTemperatureInitialLayer: { type: Number },
|
||||
nozzleTemperature: { type: Number },
|
||||
nozzleTemperatureRangeLow: { type: Number },
|
||||
nozzleTemperatureRangeHigh: { type: Number },
|
||||
hotPlateTempInitialLayer: { type: Number },
|
||||
hotPlateTemp: { type: Number },
|
||||
coolPlateTempInitialLayer: { type: Number },
|
||||
coolPlateTemp: { type: Number },
|
||||
engPlateTempInitialLayer: { type: Number },
|
||||
engPlateTemp: { type: Number },
|
||||
texturedPlateTempInitialLayer: { type: Number },
|
||||
texturedPlateTemp: { type: Number },
|
||||
texturedCoolPlateTempInitialLayer: { type: Number },
|
||||
texturedCoolPlateTemp: { type: Number },
|
||||
supertackPlateTempInitialLayer: { type: Number },
|
||||
supertackPlateTemp: { type: Number },
|
||||
filamentAdaptiveVolumetricSpeed: { type: Boolean },
|
||||
filamentMaxVolumetricSpeed: { type: Number },
|
||||
volumetricSpeedCoefficients: { type: String },
|
||||
closeFanTheFirstXLayers: { type: Number },
|
||||
fullFanSpeedLayer: { type: Number },
|
||||
fanMinSpeed: { type: Number },
|
||||
fanMaxSpeed: { type: Number },
|
||||
reduceFanStopStartFreq: { type: Boolean },
|
||||
slowDownForLayerCooling: { type: Boolean },
|
||||
dontSlowDownOuterWall: { type: Boolean },
|
||||
slowDownMinSpeed: { type: Number },
|
||||
slowDownLayerTime: { type: Number },
|
||||
fanCoolingLayerTime: { type: Number },
|
||||
enableOverhangBridgeFan: { type: Boolean },
|
||||
overhangFanThreshold: { type: String },
|
||||
overhangFanSpeed: { type: Number },
|
||||
internalBridgeFanSpeed: { type: Number },
|
||||
supportMaterialInterfaceFanSpeed: { type: Number },
|
||||
ironingFanSpeed: { type: Number },
|
||||
initialLayerFanSpeed: { type: Number },
|
||||
firstXLayerFanSpeed: { type: Number },
|
||||
additionalCoolingFanSpeed: { type: Number },
|
||||
additionalFanFullSpeedLayer: { type: Number },
|
||||
closeAdditionalFanFirstXLayers: { type: Boolean },
|
||||
activateAirFiltration: { type: Boolean },
|
||||
activateAirFiltrationDuringPrint: { type: Boolean },
|
||||
activateAirFiltrationOnCompletion: { type: Boolean },
|
||||
duringPrintExhaustFanSpeed: { type: Number },
|
||||
completePrintExhaustFanSpeed: { type: Number },
|
||||
filamentRetractionLength: { type: Number },
|
||||
filamentRetractionSpeed: { type: Number },
|
||||
filamentDeretractionSpeed: { type: Number },
|
||||
filamentRetractionMinimumTravel: { type: Number },
|
||||
filamentRetractWhenChangingLayer: { type: Boolean },
|
||||
filamentRetractBeforeWipe: { type: Number },
|
||||
filamentRetractAfterWipe: { type: Number },
|
||||
filamentRetractRestartExtra: { type: Number },
|
||||
filamentRetractLiftAbove: { type: Number },
|
||||
filamentRetractLiftBelow: { type: Number },
|
||||
filamentRetractLiftEnforce: { type: String },
|
||||
filamentWipe: { type: Boolean },
|
||||
filamentWipeDistance: { type: Number },
|
||||
filamentZHop: { type: Number },
|
||||
filamentZHopTypes: { type: String },
|
||||
filamentLongRetractionsWhenCut: { type: Boolean },
|
||||
filamentRetractionDistancesWhenCut: { type: Number },
|
||||
longRetractionsWhenEc: { type: Boolean },
|
||||
retractionDistancesWhenEc: { type: Number },
|
||||
filamentLoadingSpeed: { type: Number },
|
||||
filamentLoadingSpeedStart: { type: Number },
|
||||
filamentUnloadingSpeed: { type: Number },
|
||||
filamentUnloadingSpeedStart: { type: Number },
|
||||
filamentChangeLength: { type: Number },
|
||||
filamentChangeLengthNc: { type: Number },
|
||||
filamentToolchangeDelay: { type: Number },
|
||||
filamentExtruderCompatibility: { type: Number },
|
||||
filamentExtruderVariant: { type: String },
|
||||
filamentMultitoolRamming: { type: Boolean },
|
||||
filamentMultitoolRammingFlow: { type: Number },
|
||||
filamentMultitoolRammingVolume: { type: Number },
|
||||
filamentRammingParameters: { type: String },
|
||||
filamentRammingTravelTime: { type: Number },
|
||||
filamentRammingTravelTimeNc: { type: Number },
|
||||
filamentRammingVolumetricSpeed: { type: Number },
|
||||
filamentRammingVolumetricSpeedNc: { type: Number },
|
||||
filamentMinimalPurgeOnWipeTower: { type: Number },
|
||||
filamentTowerInterfacePreExtrusionDist: { type: Number },
|
||||
filamentTowerInterfacePreExtrusionLength: { type: Number },
|
||||
filamentTowerInterfacePrintTemp: { type: Number },
|
||||
filamentTowerInterfacePurgeVolume: { type: Number },
|
||||
filamentTowerIroningArea: { type: Number },
|
||||
filamentCoolingBeforeTower: { type: Number },
|
||||
filamentCoolingInitialSpeed: { type: Number },
|
||||
filamentCoolingFinalSpeed: { type: Number },
|
||||
filamentCoolingMoves: { type: Number },
|
||||
filamentFlushTemp: { type: Number },
|
||||
filamentFlushTempFast: { type: Number },
|
||||
filamentFlushVolumetricSpeed: { type: Number },
|
||||
filamentPreCoolingTemperature: { type: Number },
|
||||
filamentPreCoolingTemperatureNc: { type: Number },
|
||||
filamentPreheatTemperatureDelta: { type: Number },
|
||||
filamentPrimeVolumeNc: { type: Number },
|
||||
filamentRetractLengthNc: { type: Number },
|
||||
filamentStampingDistance: { type: Number },
|
||||
filamentStampingLoadingSpeed: { type: Number },
|
||||
filamentStartGcode: { type: String },
|
||||
filamentEndGcode: { type: String },
|
||||
filamentChangeExtrusionRoleGcode: { type: String },
|
||||
filamentShrink: { type: String },
|
||||
filamentShrinkageCompensationZ: { type: String },
|
||||
filamentDevAmsDryingTemperature: { type: Number },
|
||||
filamentDevAmsDryingTime: { type: Number },
|
||||
filamentDevAmsDryingHeatDistortionTemperature: { type: Number },
|
||||
filamentDevAmsDryingAmsLimitations: { type: String },
|
||||
filamentDevChamberDryingBedTemperature: { type: Number },
|
||||
filamentDevChamberDryingTime: { type: Number },
|
||||
filamentDevDryingCoolingTemperature: { type: Number },
|
||||
filamentDevDryingSofteningTemperature: { type: Number },
|
||||
compatiblePrinters: [
|
||||
{ type: mongoose.Schema.Types.ObjectId, ref: 'printerProfile' },
|
||||
],
|
||||
compatiblePrintersCondition: { type: String },
|
||||
compatiblePrints: [{ type: String }],
|
||||
compatiblePrintsCondition: { type: String },
|
||||
filamentNotes: { type: String },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
filamentProfileSchema.index({ name: 'text', filamentNotes: 'text' });
|
||||
|
||||
filamentProfileSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
filamentProfileSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const filamentProfileModel = mongoose.model('filamentProfile', filamentProfileSchema);
|
||||
@ -4,7 +4,6 @@ const { Schema } = mongoose;
|
||||
|
||||
const partSchema = new mongoose.Schema({
|
||||
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
|
||||
partSku: { type: Schema.Types.ObjectId, ref: 'partSku', required: true },
|
||||
quantity: { type: Number, required: true },
|
||||
});
|
||||
|
||||
@ -15,7 +14,6 @@ const gcodeFileSchema = new mongoose.Schema(
|
||||
gcodeFileName: { required: false, type: String },
|
||||
size: { type: Number, required: false },
|
||||
filament: { type: Schema.Types.ObjectId, ref: 'filament', required: true },
|
||||
filamentSku: { type: Schema.Types.ObjectId, ref: 'filamentSku', required: true },
|
||||
parts: [partSchema],
|
||||
file: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
|
||||
cost: { type: Number, required: false },
|
||||
@ -23,22 +21,7 @@ const gcodeFileSchema = new mongoose.Schema(
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
gcodeFileSchema.pre('validate', async function () {
|
||||
if (!this.filament && this.filamentSku) {
|
||||
const sku = await mongoose.model('filamentSku').findById(this.filamentSku).select('filament').lean();
|
||||
if (sku?.filament) this.filament = sku.filament;
|
||||
}
|
||||
if (this.parts?.length) {
|
||||
for (const partItem of this.parts) {
|
||||
if (!partItem.part && partItem.partSku) {
|
||||
const sku = await mongoose.model('partSku').findById(partItem.partSku).select('part').lean();
|
||||
if (sku?.part) partItem.part = sku.part;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
gcodeFileSchema.index({ name: 'text', gcodeFileName: 'text' });
|
||||
gcodeFileSchema.index({ name: 'text', brand: 'text' });
|
||||
|
||||
gcodeFileSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -1,15 +1,7 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import {
|
||||
aggregateRollups,
|
||||
aggregateRollupsHistory,
|
||||
listObjects,
|
||||
editObject,
|
||||
newObject,
|
||||
deleteObject,
|
||||
} from '../../database.js';
|
||||
import { subJobModel } from './subjob.schema.js';
|
||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
||||
|
||||
const jobSchema = new mongoose.Schema(
|
||||
{
|
||||
@ -40,8 +32,6 @@ const jobSchema = new mongoose.Schema(
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
jobSchema.index({ 'state.type': 'text' });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'queued',
|
||||
@ -92,95 +82,6 @@ jobSchema.statics.history = async function (from, to) {
|
||||
return results;
|
||||
};
|
||||
|
||||
const normalizeId = (value) => value?._id ?? value;
|
||||
|
||||
jobSchema.statics.recalculate = async function (job, user) {
|
||||
const jobId = job._id || job;
|
||||
if (!jobId || job?.state?.type !== 'draft') {
|
||||
return;
|
||||
}
|
||||
|
||||
const quantity = job.quantity || 1;
|
||||
const printers = (job.printers || []).map((printer) => normalizeId(printer));
|
||||
const gcodeFile = normalizeId(job.gcodeFile);
|
||||
|
||||
const existingSubJobs = await listObjects({
|
||||
model: subJobModel,
|
||||
filter: { job: jobId },
|
||||
sort: 'number',
|
||||
order: 'ascend',
|
||||
pagination: false,
|
||||
});
|
||||
|
||||
if (existingSubJobs?.error) {
|
||||
throw existingSubJobs;
|
||||
}
|
||||
|
||||
const existingSubJobsByNumber = new Map(
|
||||
existingSubJobs.map((subJob) => [subJob.number, subJob])
|
||||
);
|
||||
|
||||
var printerCount = 0;
|
||||
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const subJobNumber = i + 1;
|
||||
const printer = printers[printerCount];
|
||||
const subJobUpdateData = {
|
||||
updatedAt: new Date(),
|
||||
printer,
|
||||
gcodeFile,
|
||||
number: subJobNumber,
|
||||
};
|
||||
|
||||
const existingSubJob = existingSubJobsByNumber.get(subJobNumber);
|
||||
|
||||
if (existingSubJob) {
|
||||
const subJobResult = await editObject({
|
||||
model: subJobModel,
|
||||
id: existingSubJob._id,
|
||||
updateData: subJobUpdateData,
|
||||
user,
|
||||
});
|
||||
if (subJobResult.error) {
|
||||
throw subJobResult;
|
||||
}
|
||||
} else {
|
||||
const subJobResult = await newObject({
|
||||
model: subJobModel,
|
||||
newData: {
|
||||
createdAt: new Date(),
|
||||
...subJobUpdateData,
|
||||
job: jobId,
|
||||
state: { type: 'draft' },
|
||||
},
|
||||
user,
|
||||
});
|
||||
if (subJobResult.error) {
|
||||
throw subJobResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (printerCount >= printers.length - 1) {
|
||||
printerCount = 0;
|
||||
} else {
|
||||
printerCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const existingSubJob of existingSubJobs) {
|
||||
if (existingSubJob.number > quantity) {
|
||||
const deleteResult = await deleteObject({
|
||||
model: subJobModel,
|
||||
id: existingSubJob._id,
|
||||
user,
|
||||
});
|
||||
if (deleteResult.error) {
|
||||
throw deleteResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
jobSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
||||
|
||||
// Define the moonraker connection schema
|
||||
const moonrakerSchema = new Schema(
|
||||
@ -19,25 +19,12 @@ const alertSchema = new Schema(
|
||||
type: { type: String, required: true }, // error, info, message
|
||||
message: { type: String, required: false },
|
||||
actions: [{ type: String, required: false, default: [] }],
|
||||
_id: { type: String, required: true, default: () => generateId()() },
|
||||
code: { type: String, required: false },
|
||||
_id: { type: String, required: true },
|
||||
canDismiss: { type: Boolean, required: true, default: true },
|
||||
},
|
||||
{ timestamps: true, _id: false }
|
||||
);
|
||||
|
||||
const pendingSlicerUploadSchema = new Schema(
|
||||
{
|
||||
file: { type: Schema.Types.ObjectId, ref: 'file', required: true },
|
||||
gcodeFile: { type: Schema.Types.ObjectId, ref: 'gcodeFile', default: null },
|
||||
job: { type: Schema.Types.ObjectId, ref: 'job', default: null },
|
||||
shouldPrint: { type: Boolean, required: true, default: false },
|
||||
new: { type: Boolean, required: true, default: true },
|
||||
properties: { type: Object, required: true, default: () => ({}) },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
// Define the main printer schema
|
||||
const printerSchema = new Schema(
|
||||
{
|
||||
@ -66,13 +53,10 @@ const printerSchema = new Schema(
|
||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', default: null },
|
||||
host: { type: Schema.Types.ObjectId, ref: 'host', default: null },
|
||||
alerts: [alertSchema],
|
||||
pendingSlicerUploads: { type: [pendingSlicerUploadSchema], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
printerSchema.index({ name: 'text', tags: 'text', firmware: 'text' });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'standby',
|
||||
@ -108,6 +92,8 @@ printerSchema.statics.stats = async function () {
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
console.log(results);
|
||||
|
||||
// Transform the results to match the expected format
|
||||
return results;
|
||||
};
|
||||
@ -124,28 +110,6 @@ printerSchema.statics.history = async function (from, to) {
|
||||
return results;
|
||||
};
|
||||
|
||||
printerSchema.statics.recalculate = async function (printer, user) {
|
||||
if (printer.active === false && printer.state?.type !== 'inactive') {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: printer._id,
|
||||
updateData: { state: { type: 'inactive' } },
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (printer.active === true && printer.state?.type === 'inactive' && printer.online === false) {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: printer._id,
|
||||
updateData: { state: { type: 'offline' } },
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
printerSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -1,111 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
|
||||
const minMaxNumberSchema = {
|
||||
min: { type: Number },
|
||||
max: { type: Number }
|
||||
};
|
||||
|
||||
const thumbnailSchema = new mongoose.Schema(
|
||||
{
|
||||
width: { type: Number },
|
||||
height: { type: Number }
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const bedExcludeAreaSchema = new mongoose.Schema(
|
||||
{
|
||||
x: { type: Number },
|
||||
y: { type: Number }
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const extruderSchema = new mongoose.Schema(
|
||||
{
|
||||
defaultNozzleVolumeType: { type: String },
|
||||
nozzleDiameter: { type: Number },
|
||||
nozzleVolume: { type: Number },
|
||||
nozzleType: { type: String },
|
||||
nozzleFlushDataset: { type: String },
|
||||
minLayerHeight: { type: Number },
|
||||
maxLayerHeight: { type: Number },
|
||||
extruderColour: { type: String },
|
||||
extruderType: { type: String },
|
||||
extruderVariantList: { type: String },
|
||||
extruderPrintableHeight: { type: Number },
|
||||
printerExtruderId: { type: String },
|
||||
printerExtruderVariant: { type: String },
|
||||
positionOffsetX: { type: Number },
|
||||
positionOffsetY: { type: Number },
|
||||
retractionLength: { type: Number },
|
||||
retractionExtraLengthOnRestart: { type: Number },
|
||||
retractionSpeed: { type: Number },
|
||||
deretractionSpeed: { type: Number },
|
||||
retractionTravelDistanceThreshold: { type: Number },
|
||||
retractOnLayerChange: { type: Boolean },
|
||||
wipeWhileRetracting: { type: Boolean },
|
||||
wipeDistance: { type: Number },
|
||||
retractAmountBeforeWipe: { type: Number },
|
||||
retractAmountAfterWipe: { type: Number },
|
||||
zHopOnSurfaces: { type: String },
|
||||
zHopType: { type: String },
|
||||
zHopHeight: { type: Number },
|
||||
zHopTravelingAngle: { type: Number },
|
||||
zHopOnlyLiftZAbove: { type: Number },
|
||||
zHopOnlyLiftZBelow: { type: Number },
|
||||
materialSwitchRetractionLength: { type: Number },
|
||||
materialSwitchExtraLengthOnRestart: { type: Number },
|
||||
longRetractionWhenCut: { type: Boolean },
|
||||
retractionDistancesWhenCut: { type: Number },
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const printerProfileSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
printer: { type: mongoose.Schema.Types.ObjectId, ref: 'printer', default: null },
|
||||
extruders: [extruderSchema],
|
||||
printBedWidth: { type: Number },
|
||||
printBedHeight: { type: Number },
|
||||
originX: { type: Number },
|
||||
originY: { type: Number },
|
||||
bedExcludeArea: [bedExcludeAreaSchema],
|
||||
printableHeight: { type: Number },
|
||||
supportMultiBedTypes: { type: Boolean },
|
||||
bestObjectPositionX: { type: Number },
|
||||
bestObjectPositionY: { type: Number },
|
||||
zOffset: { type: Number },
|
||||
preferredOrientation: { type: Number },
|
||||
machineMaxAccelerationRetracting: minMaxNumberSchema,
|
||||
machineMaxSpeedE: minMaxNumberSchema,
|
||||
machineMaxSpeedX: minMaxNumberSchema,
|
||||
machineMaxSpeedY: minMaxNumberSchema,
|
||||
machinePauseGcode: { type: String },
|
||||
machineStartGcode: { type: String },
|
||||
machineEndGcode: { type: String },
|
||||
layerChangeGcode: { type: String },
|
||||
beforeLayerChangeGcode: { type: String },
|
||||
printerNotes: { type: String },
|
||||
scanFirstLayer: { type: Boolean },
|
||||
machineLoadFilamentTime: { type: Number },
|
||||
machineUnloadFilamentTime: { type: Number },
|
||||
thumbnails: [thumbnailSchema],
|
||||
auxiliaryFan: { type: Boolean },
|
||||
machineMaxJunctionDeviation: minMaxNumberSchema,
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
printerProfileSchema.index({ name: 'text', printerNotes: 'text' });
|
||||
|
||||
printerProfileSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
printerProfileSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const printerProfileModel = mongoose.model('printerProfile', printerProfileSchema);
|
||||
@ -43,8 +43,6 @@ const subJobSchema = new mongoose.Schema({
|
||||
finishedAt: { required: false, type: Date, default: null },
|
||||
});
|
||||
|
||||
subJobSchema.index({ moonrakerJobId: 'text', 'state.type': 'text' });
|
||||
|
||||
subJobSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
||||
|
||||
const addressSchema = new mongoose.Schema({
|
||||
building: { required: false, type: String },
|
||||
addressLine1: { required: false, type: String },
|
||||
addressLine2: { required: false, type: String },
|
||||
city: { required: false, type: String },
|
||||
state: { required: false, type: String },
|
||||
postcode: { required: false, type: String },
|
||||
country: { required: false, type: String },
|
||||
});
|
||||
|
||||
const clientSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { required: true, type: String },
|
||||
marketplace: { type: mongoose.Schema.Types.ObjectId, ref: 'marketplace', required: false },
|
||||
externalReference: { type: String, required: false },
|
||||
email: { required: false, type: String },
|
||||
phone: { required: false, type: String },
|
||||
country: { required: false, type: String },
|
||||
active: { required: true, type: Boolean, default: true },
|
||||
address: { required: false, type: addressSchema },
|
||||
tags: [{ required: false, type: String }],
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
clientSchema.index({ name: 'text', email: 'text', phone: 'text', country: 'text', tags: 'text' });
|
||||
clientSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
clientSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
clientSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'active',
|
||||
filter: { active: true },
|
||||
rollups: [{ name: 'active', property: 'active', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'inactive',
|
||||
filter: { active: false },
|
||||
rollups: [{ name: 'inactive', property: 'active', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
clientSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
clientSchema.statics.history = async function (from, to) {
|
||||
const results = await aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const clientModel = mongoose.model('client', clientSchema);
|
||||
@ -1,31 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { marketplaceSyncMappingSchema } from './marketplaceMapping.schema.js';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const fulfillmentPolicySchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
description: { type: String, required: false },
|
||||
handlingTime: { type: Number, required: false, default: 1 },
|
||||
localPickup: { type: Boolean, required: false, default: false },
|
||||
globalShipping: { type: Boolean, required: false, default: false },
|
||||
freightShipping: { type: Boolean, required: false, default: false },
|
||||
pickupDropOff: { type: Boolean, required: false, default: false },
|
||||
courierServices: [{ type: Schema.Types.ObjectId, ref: 'courierService', required: false }],
|
||||
marketplaces: { type: [marketplaceSyncMappingSchema()], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
fulfillmentPolicySchema.index({ name: 'text', description: 'text' });
|
||||
|
||||
fulfillmentPolicySchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
fulfillmentPolicySchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const fulfillmentPolicyModel = mongoose.model('fulfillmentPolicy', fulfillmentPolicySchema);
|
||||
@ -1,260 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import {
|
||||
editObject,
|
||||
newObject,
|
||||
deleteObject,
|
||||
aggregateRollups,
|
||||
aggregateRollupsHistory,
|
||||
} from '../../database.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const listingSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
product: { type: Schema.Types.ObjectId, ref: 'product', required: false },
|
||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
||||
stockLocation: { type: Schema.Types.ObjectId, ref: 'stockLocation', required: true },
|
||||
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
||||
title: { type: String, required: false },
|
||||
state: {
|
||||
type: {
|
||||
type: String,
|
||||
enum: [
|
||||
'draft',
|
||||
'active',
|
||||
'inactive',
|
||||
'deleted',
|
||||
'suspended',
|
||||
'syncing',
|
||||
'publishing',
|
||||
'unpublishing',
|
||||
],
|
||||
default: 'draft',
|
||||
},
|
||||
progress: { type: Number, required: false },
|
||||
message: { type: String, required: false },
|
||||
},
|
||||
url: { type: String, required: false },
|
||||
description: { type: String, required: false },
|
||||
listingImages: [{ type: Schema.Types.ObjectId, ref: 'file', required: false }],
|
||||
externalReference: { type: String, required: false },
|
||||
price: { type: Number, required: false },
|
||||
currency: { type: String, required: false },
|
||||
lastSyncedAt: { type: Date, required: false },
|
||||
syncHash: { type: String, required: false },
|
||||
syncImageHash: { type: String, required: false },
|
||||
marketplaceImageUrls: [{ type: String, required: false }],
|
||||
stockQuantity: { type: Number, required: false, default: 0 },
|
||||
condition: {
|
||||
type: String,
|
||||
enum: [
|
||||
'new',
|
||||
'likeNew',
|
||||
'newOther',
|
||||
'newWithDefects',
|
||||
'manufacturerRefurbished',
|
||||
'certifiedRefurbished',
|
||||
'excellentRefurbished',
|
||||
'veryGoodRefurbished',
|
||||
'goodRefurbished',
|
||||
'sellerRefurbished',
|
||||
'usedExcellent',
|
||||
'usedVeryGood',
|
||||
'usedGood',
|
||||
'usedAcceptable',
|
||||
'forPartsOrNotWorking',
|
||||
'preOwnedExcellent',
|
||||
'preOwnedFair',
|
||||
],
|
||||
default: 'new',
|
||||
required: false,
|
||||
},
|
||||
courierServices: [{ type: Schema.Types.ObjectId, ref: 'courierService', required: true }],
|
||||
fulfillmentPolicy: { type: Schema.Types.ObjectId, ref: 'fulfillmentPolicy', required: false },
|
||||
paymentPolicy: { type: Schema.Types.ObjectId, ref: 'paymentPolicy', required: false },
|
||||
returnPolicy: { type: Schema.Types.ObjectId, ref: 'returnPolicy', required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
listingSchema.index({ title: 'text', url: 'text' });
|
||||
listingSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
listingSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
listingSchema.set('toJSON', {
|
||||
virtuals: true,
|
||||
transform(doc, ret) {
|
||||
if (!ret.state && ret.status) {
|
||||
ret.state = { type: ret.status, message: null };
|
||||
}
|
||||
if (ret.status) delete ret.status;
|
||||
return ret;
|
||||
},
|
||||
});
|
||||
|
||||
const refId = (value) => value?._id ?? value;
|
||||
|
||||
listingSchema.statics.recalculate = async function (listing, user) {
|
||||
const listingId = refId(listing);
|
||||
if (!listingId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listingVarientModel = mongoose.model('listingVarient');
|
||||
const productSkuModel = mongoose.model('productSku');
|
||||
const listingProductId = refId(listing.product);
|
||||
|
||||
const findVarients = () =>
|
||||
listingVarientModel.find({ listing: listingId }).sort({ createdAt: 1 }).lean();
|
||||
|
||||
let varients = await findVarients();
|
||||
|
||||
if (listingProductId) {
|
||||
const productSkus = await productSkuModel
|
||||
.find({ product: listingProductId })
|
||||
.sort({ createdAt: 1 })
|
||||
.lean();
|
||||
|
||||
const varientsBySkuId = new Map();
|
||||
const unmatchedVarients = [];
|
||||
for (const varient of varients) {
|
||||
const skuId = refId(varient.productSku);
|
||||
if (skuId && !varientsBySkuId.has(String(skuId))) {
|
||||
varientsBySkuId.set(String(skuId), varient);
|
||||
} else {
|
||||
unmatchedVarients.push(varient);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sku of productSkus) {
|
||||
const skuId = sku._id;
|
||||
const varientUpdateData = {
|
||||
product: listingProductId,
|
||||
productSku: skuId,
|
||||
};
|
||||
const existingVarient = varientsBySkuId.get(String(skuId)) || unmatchedVarients.shift();
|
||||
|
||||
if (existingVarient) {
|
||||
varientsBySkuId.delete(String(skuId));
|
||||
const existingProductId = refId(existingVarient.product);
|
||||
const existingSkuId = refId(existingVarient.productSku);
|
||||
if (
|
||||
String(existingProductId) === String(listingProductId) &&
|
||||
String(existingSkuId) === String(skuId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const varientResult = await editObject({
|
||||
model: listingVarientModel,
|
||||
id: existingVarient._id,
|
||||
updateData: varientUpdateData,
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
if (varientResult.error) {
|
||||
throw varientResult;
|
||||
}
|
||||
} else {
|
||||
const varientResult = await newObject({
|
||||
model: listingVarientModel,
|
||||
newData: {
|
||||
...varientUpdateData,
|
||||
listing: listingId,
|
||||
state: { type: listing.state?.type || 'draft' },
|
||||
},
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
if (varientResult.error) {
|
||||
throw varientResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const extra of [...varientsBySkuId.values(), ...unmatchedVarients]) {
|
||||
const deleteResult = await deleteObject({
|
||||
model: listingVarientModel,
|
||||
id: extra._id,
|
||||
user,
|
||||
});
|
||||
if (deleteResult.error) {
|
||||
throw deleteResult;
|
||||
}
|
||||
}
|
||||
|
||||
varients = await findVarients();
|
||||
}
|
||||
|
||||
for (const varient of varients) {
|
||||
await listingVarientModel.recalculate(varient, user);
|
||||
}
|
||||
};
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'draft',
|
||||
filter: { 'state.type': 'draft' },
|
||||
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
filter: { 'state.type': 'active' },
|
||||
rollups: [{ name: 'active', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'inactive',
|
||||
filter: { 'state.type': 'inactive' },
|
||||
rollups: [{ name: 'inactive', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'syncing',
|
||||
filter: { 'state.type': 'syncing' },
|
||||
rollups: [{ name: 'syncing', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'publishing',
|
||||
filter: { 'state.type': 'publishing' },
|
||||
rollups: [{ name: 'publishing', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'unpublishing',
|
||||
filter: { 'state.type': 'unpublishing' },
|
||||
rollups: [{ name: 'unpublishing', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'suspended',
|
||||
filter: { 'state.type': 'suspended' },
|
||||
rollups: [{ name: 'suspended', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'deleted',
|
||||
filter: { 'state.type': 'deleted' },
|
||||
rollups: [{ name: 'deleted', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
listingSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
listingSchema.statics.history = async function (from, to) {
|
||||
const results = await aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const listingModel = mongoose.model('listing', listingSchema);
|
||||
@ -1,171 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { aggregateRollups, editObject } from '../../database.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const toId = (value) => {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'object' && value._id) return String(value._id);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const listingVarientAspectSchema = new Schema(
|
||||
{
|
||||
name: { type: String, required: true },
|
||||
value: { type: String, required: true },
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const listingVarientSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
listing: { type: Schema.Types.ObjectId, ref: 'listing', required: true },
|
||||
product: { type: Schema.Types.ObjectId, ref: 'product', required: false },
|
||||
productSku: { type: Schema.Types.ObjectId, ref: 'productSku', required: false },
|
||||
aspects: { type: [listingVarientAspectSchema], default: [] },
|
||||
state: {
|
||||
type: {
|
||||
type: String,
|
||||
enum: [
|
||||
'draft',
|
||||
'active',
|
||||
'inactive',
|
||||
'deleted',
|
||||
'suspended',
|
||||
'syncing',
|
||||
'publishing',
|
||||
'unpublishing',
|
||||
],
|
||||
default: 'draft',
|
||||
},
|
||||
message: { type: String, required: false },
|
||||
},
|
||||
externalReference: { type: String, required: false },
|
||||
price: { type: Number, required: false },
|
||||
currency: { type: String, required: false },
|
||||
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
priceWithTax: { type: Number, required: false },
|
||||
lastSyncedAt: { type: Date, required: false },
|
||||
syncHash: { type: String, required: false },
|
||||
syncImageHash: { type: String, required: false },
|
||||
marketplaceImageUrls: [{ type: String, required: false }],
|
||||
listingImages: [{ type: Schema.Types.ObjectId, ref: 'file', required: false }],
|
||||
stockQuantity: { type: Number, required: false, default: 0 },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
listingVarientSchema.index({ currency: 'text', 'state.type': 'text' });
|
||||
listingVarientSchema.index(
|
||||
{ listing: 1, externalReference: 1 },
|
||||
{
|
||||
unique: true,
|
||||
name: 'listing_1_externalReference_1',
|
||||
partialFilterExpression: { externalReference: { $type: 'string', $gt: '' } },
|
||||
}
|
||||
);
|
||||
|
||||
listingVarientSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
listingVarientSchema.set('toJSON', {
|
||||
virtuals: true,
|
||||
transform(doc, ret) {
|
||||
if (!ret.state && ret.status) {
|
||||
ret.state = { type: ret.status, message: null };
|
||||
}
|
||||
if (ret.status) delete ret.status;
|
||||
return ret;
|
||||
},
|
||||
});
|
||||
|
||||
listingVarientSchema.statics.recalculate = async function (listingVarient, user) {
|
||||
const listingId = listingVarient?.listing?._id || listingVarient?.listing;
|
||||
const varientId = listingVarient?._id;
|
||||
const productSkuId = toId(listingVarient?.productSku);
|
||||
|
||||
if (varientId && productSkuId && (await this.exists({ _id: varientId }))) {
|
||||
let listing = listingVarient.listing;
|
||||
if (!listing?.stockLocation) {
|
||||
listing = await mongoose
|
||||
.model('listing')
|
||||
.findById(listingId)
|
||||
.select('stockLocation product')
|
||||
.lean();
|
||||
}
|
||||
const stockLocationId = toId(listing?.stockLocation);
|
||||
if (stockLocationId) {
|
||||
const stockRollup = await aggregateRollups({
|
||||
model: mongoose.model('productStock'),
|
||||
baseFilter: {
|
||||
productSku: new mongoose.Types.ObjectId(productSkuId),
|
||||
stockLocation: new mongoose.Types.ObjectId(stockLocationId),
|
||||
},
|
||||
rollupConfigs: [
|
||||
{
|
||||
name: 'stockQuantity',
|
||||
rollups: [{ name: 'stockQuantity', property: 'currentQuantity', operation: 'sum' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const stockQuantity = stockRollup.stockQuantity?.sum || 0;
|
||||
if (listingVarient.stockQuantity !== stockQuantity) {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: varientId,
|
||||
updateData: { stockQuantity },
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!listingId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rollupResults = await aggregateRollups({
|
||||
model: this,
|
||||
baseFilter: { listing: new mongoose.Types.ObjectId(listingId) },
|
||||
rollupConfigs: [
|
||||
{
|
||||
name: 'stockQuantity',
|
||||
rollups: [{ name: 'stockQuantity', property: 'stockQuantity', operation: 'sum' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await editObject({
|
||||
model: mongoose.model('listing'),
|
||||
id: listingId,
|
||||
updateData: {
|
||||
stockQuantity: rollupResults.stockQuantity?.sum || 0,
|
||||
},
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const listingVarientModel = mongoose.model('listingVarient', listingVarientSchema);
|
||||
|
||||
async function replaceSparseExternalReferenceIndex() {
|
||||
try {
|
||||
const indexes = await listingVarientModel.collection.indexes();
|
||||
const current = indexes.find((idx) => idx.name === 'listing_1_externalReference_1');
|
||||
if (current && (current.sparse || !current.partialFilterExpression)) {
|
||||
await listingVarientModel.collection.dropIndex('listing_1_externalReference_1');
|
||||
}
|
||||
await listingVarientModel.createIndexes();
|
||||
} catch {
|
||||
// Collection/index may not exist until Mongo is connected.
|
||||
}
|
||||
}
|
||||
|
||||
if (mongoose.connection.readyState === 1) {
|
||||
replaceSparseExternalReferenceIndex();
|
||||
} else {
|
||||
mongoose.connection.once('open', replaceSparseExternalReferenceIndex);
|
||||
}
|
||||
@ -1,125 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { editObject, aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
||||
import { generateId } from '../../utils.js';
|
||||
|
||||
const marketplaceSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { required: true, type: String },
|
||||
provider: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['ebay', 'etsy', 'tiktokShop'],
|
||||
},
|
||||
active: { required: true, type: Boolean, default: true },
|
||||
connected: { type: Boolean, required: true, default: false },
|
||||
connectedAt: { type: Date, required: false },
|
||||
state: {
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['active', 'inactive', 'suspended', 'ready', 'offline', 'syncing', 'disconnected'],
|
||||
default: 'offline',
|
||||
},
|
||||
message: { type: String, required: false },
|
||||
},
|
||||
// Provider-specific API configuration (flexible for eBay, Etsy, TikTok Shop)
|
||||
config: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
defaultFulfillmentPolicy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'fulfillmentPolicy',
|
||||
required: false,
|
||||
},
|
||||
defaultPaymentPolicy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'paymentPolicy',
|
||||
required: false,
|
||||
},
|
||||
defaultReturnPolicy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'returnPolicy',
|
||||
required: false,
|
||||
},
|
||||
eBay: {
|
||||
availableShippingServices: { type: [String], default: [] },
|
||||
categoryReferences: { type: [mongoose.Schema.Types.Mixed], default: [] },
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
marketplaceSchema.index({ name: 'text', provider: 'text' });
|
||||
|
||||
marketplaceSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
marketplaceSchema.statics.recalculate = async function (marketplace, user) {
|
||||
let stateType;
|
||||
if (marketplace.active === false) {
|
||||
stateType = 'inactive';
|
||||
} else if (marketplace.connected === false) {
|
||||
stateType = 'disconnected';
|
||||
} else {
|
||||
stateType = 'ready';
|
||||
}
|
||||
marketplace.state = { type: stateType };
|
||||
await editObject({
|
||||
model: this,
|
||||
id: marketplace._id,
|
||||
updateData: { state: { type: stateType } },
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'ready',
|
||||
filter: { 'state.type': 'ready' },
|
||||
rollups: [{ name: 'ready', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'syncing',
|
||||
filter: { 'state.type': 'syncing' },
|
||||
rollups: [{ name: 'syncing', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'disconnected',
|
||||
filter: { 'state.type': 'disconnected' },
|
||||
rollups: [{ name: 'disconnected', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'inactive',
|
||||
filter: { 'state.type': 'inactive' },
|
||||
rollups: [{ name: 'inactive', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'offline',
|
||||
filter: { 'state.type': 'offline' },
|
||||
rollups: [{ name: 'offline', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
marketplaceSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
marketplaceSchema.statics.history = async function (from, to) {
|
||||
const results = await aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
marketplaceSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const marketplaceModel = mongoose.model('marketplace', marketplaceSchema);
|
||||
@ -1,24 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
export const MARKETPLACE_MAPPING_STATES = ['pending', 'syncing', 'ready', 'failed'];
|
||||
|
||||
export function marketplaceSyncMappingSchema() {
|
||||
return new Schema(
|
||||
{
|
||||
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
||||
externalReference: { type: String, required: false },
|
||||
syncHash: { type: String, required: false },
|
||||
state: {
|
||||
type: {
|
||||
type: String,
|
||||
enum: MARKETPLACE_MAPPING_STATES,
|
||||
default: 'pending',
|
||||
},
|
||||
message: { type: String, required: false },
|
||||
},
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
|
||||
const marketplaceEventSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
marketplace: { type: mongoose.Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
||||
externalReference: { type: String, required: true },
|
||||
topic: { type: String, required: true },
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['received', 'processed', 'failed'],
|
||||
default: 'received',
|
||||
},
|
||||
processedAt: { type: Date, required: false },
|
||||
error: { type: String, required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
marketplaceEventSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true });
|
||||
marketplaceEventSchema.index({ topic: 'text', status: 'text' });
|
||||
|
||||
marketplaceEventSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
marketplaceEventSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const marketplaceEventModel = mongoose.model('marketplaceEvent', marketplaceEventSchema);
|
||||
@ -1,46 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { marketplaceSyncMappingSchema } from './marketplaceMapping.schema.js';
|
||||
|
||||
const returnPolicySchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
description: { type: String, required: false },
|
||||
returnsAccepted: { type: Boolean, required: true, default: true },
|
||||
returnPeriodDays: { type: Number, required: false, default: 30 },
|
||||
returnShippingCostPayer: {
|
||||
type: String,
|
||||
enum: ['buyer', 'seller'],
|
||||
required: false,
|
||||
default: 'buyer',
|
||||
},
|
||||
refundMethod: {
|
||||
type: String,
|
||||
enum: ['moneyBack', 'merchandiseCredit'],
|
||||
required: false,
|
||||
default: 'moneyBack',
|
||||
},
|
||||
restockingFeePercentage: { type: Number, required: false },
|
||||
returnInstructions: { type: String, required: false },
|
||||
internationalReturnsAccepted: { type: Boolean, required: false },
|
||||
internationalReturnPeriodDays: { type: Number, required: false },
|
||||
internationalReturnShippingCostPayer: {
|
||||
type: String,
|
||||
enum: ['buyer', 'seller'],
|
||||
required: false,
|
||||
},
|
||||
marketplaces: { type: [marketplaceSyncMappingSchema()], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
returnPolicySchema.index({ name: 'text', description: 'text', returnInstructions: 'text' });
|
||||
|
||||
returnPolicySchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
returnPolicySchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const returnPolicyModel = mongoose.model('returnPolicy', returnPolicySchema);
|
||||
@ -1,229 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||
|
||||
const salesOrderSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
totalAmount: { type: Number, required: true, default: 0 },
|
||||
totalAmountWithTax: { type: Number, required: true, default: 0 },
|
||||
shippingAmount: { type: Number, required: true, default: 0 },
|
||||
shippingAmountWithTax: { type: Number, required: true, default: 0 },
|
||||
grandTotalAmount: { type: Number, required: true, default: 0 },
|
||||
totalTaxAmount: { type: Number, required: true, default: 0 },
|
||||
timestamp: { type: Date, default: Date.now },
|
||||
client: { type: Schema.Types.ObjectId, ref: 'client', required: true },
|
||||
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: false },
|
||||
externalReference: { type: String, required: false },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'draft' },
|
||||
},
|
||||
postedAt: { type: Date, required: false },
|
||||
confirmedAt: { type: Date, required: false },
|
||||
cancelledAt: { type: Date, required: false },
|
||||
completedAt: { type: Date, required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
salesOrderSchema.index({ 'state.type': 'text' });
|
||||
salesOrderSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'draft',
|
||||
filter: { 'state.type': 'draft' },
|
||||
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'sent',
|
||||
filter: { 'state.type': 'sent' },
|
||||
rollups: [{ name: 'sent', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'confirmed',
|
||||
filter: { 'state.type': 'confirmed' },
|
||||
rollups: [{ name: 'confirmed', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'partiallyShipped',
|
||||
filter: { 'state.type': 'partiallyShipped' },
|
||||
rollups: [{ name: 'partiallyShipped', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'shipped',
|
||||
filter: { 'state.type': 'shipped' },
|
||||
rollups: [{ name: 'shipped', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'partiallyDelivered',
|
||||
filter: { 'state.type': 'partiallyDelivered' },
|
||||
rollups: [{ name: 'partiallyDelivered', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'delivered',
|
||||
filter: { 'state.type': 'delivered' },
|
||||
rollups: [{ name: 'delivered', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'cancelled',
|
||||
filter: { 'state.type': 'cancelled' },
|
||||
rollups: [{ name: 'cancelled', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'completed',
|
||||
filter: { 'state.type': 'completed' },
|
||||
rollups: [{ name: 'completed', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'pipelineValue',
|
||||
filter: {
|
||||
'state.type': {
|
||||
$in: ['sent', 'confirmed', 'partiallyShipped', 'shipped', 'partiallyDelivered'],
|
||||
},
|
||||
},
|
||||
rollups: [{ name: 'grandTotalAmount', property: 'grandTotalAmount', operation: 'sum' }],
|
||||
},
|
||||
{
|
||||
name: 'completedValue',
|
||||
filter: { 'state.type': { $in: ['delivered', 'completed'] } },
|
||||
rollups: [{ name: 'grandTotalAmount', property: 'grandTotalAmount', operation: 'sum' }],
|
||||
},
|
||||
];
|
||||
|
||||
salesOrderSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
// Transform the results to match the expected format
|
||||
return results;
|
||||
};
|
||||
|
||||
salesOrderSchema.statics.history = async function (from, to) {
|
||||
const results = await aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
// Return time-series data array
|
||||
return results;
|
||||
};
|
||||
|
||||
salesOrderSchema.statics.recalculate = async function (salesOrder, user) {
|
||||
const orderId = salesOrder._id || salesOrder;
|
||||
if (!orderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderItemModel = mongoose.model('orderItem');
|
||||
const shipmentModel = mongoose.model('shipment');
|
||||
|
||||
const orderIdObj = new mongoose.Types.ObjectId(orderId);
|
||||
const baseFilter = { order: orderIdObj, orderType: 'salesOrder' };
|
||||
|
||||
const orderItemRollupResults = await aggregateRollups({
|
||||
model: orderItemModel,
|
||||
baseFilter,
|
||||
rollupConfigs: [
|
||||
{
|
||||
name: 'orderTotals',
|
||||
rollups: [
|
||||
{ name: 'totalAmount', property: 'totalAmount', operation: 'sum' },
|
||||
{ name: 'totalAmountWithTax', property: 'totalAmountWithTax', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'overallCount',
|
||||
rollups: [{ name: 'overallCount', property: '_id', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'shipped',
|
||||
filter: { 'state.type': 'shipped' },
|
||||
rollups: [{ name: 'shipped', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'received',
|
||||
filter: { 'state.type': 'received' },
|
||||
rollups: [{ name: 'received', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const shipmentRollupResults = await aggregateRollups({
|
||||
model: shipmentModel,
|
||||
baseFilter,
|
||||
rollupConfigs: [
|
||||
{
|
||||
name: 'shipmentTotals',
|
||||
rollups: [
|
||||
{ name: 'amount', property: 'amount', operation: 'sum' },
|
||||
{ name: 'amountWithTax', property: 'amountWithTax', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const orderTotals = orderItemRollupResults.orderTotals || {};
|
||||
const totalAmount = orderTotals.totalAmount?.sum?.toFixed(2) || 0;
|
||||
const totalAmountWithTax = orderTotals.totalAmountWithTax?.sum?.toFixed(2) || 0;
|
||||
|
||||
const shipmentTotals = shipmentRollupResults.shipmentTotals || {};
|
||||
const totalShippingAmount = shipmentTotals.amount?.sum?.toFixed(2) || 0;
|
||||
const totalShippingAmountWithTax = shipmentTotals.amountWithTax?.sum?.toFixed(2) || 0;
|
||||
|
||||
const grandTotalAmount =
|
||||
parseFloat(totalAmountWithTax || 0) + parseFloat(totalShippingAmountWithTax || 0);
|
||||
|
||||
const overallCount = orderItemRollupResults.overallCount?.count || 0;
|
||||
const shippedCount = orderItemRollupResults.shipped?.count || 0;
|
||||
const receivedCount = orderItemRollupResults.received?.count || 0;
|
||||
|
||||
let updateData = {
|
||||
totalAmount: parseFloat(totalAmount).toFixed(2),
|
||||
totalAmountWithTax: parseFloat(totalAmountWithTax).toFixed(2),
|
||||
totalTaxAmount: parseFloat((totalAmountWithTax - totalAmount).toFixed(2)),
|
||||
shippingAmount: parseFloat(totalShippingAmount).toFixed(2),
|
||||
shippingAmountWithTax: parseFloat(totalShippingAmountWithTax).toFixed(2),
|
||||
grandTotalAmount: parseFloat(grandTotalAmount).toFixed(2),
|
||||
};
|
||||
|
||||
if (shippedCount > 0 && shippedCount < overallCount) {
|
||||
updateData = { ...updateData, state: { type: 'partiallyShipped' } };
|
||||
}
|
||||
|
||||
if (shippedCount > 0 && shippedCount === overallCount) {
|
||||
updateData = { ...updateData, state: { type: 'shipped' } };
|
||||
}
|
||||
|
||||
if (receivedCount > 0 && receivedCount < overallCount) {
|
||||
updateData = { ...updateData, state: { type: 'partiallyDelivered' } };
|
||||
}
|
||||
|
||||
if (receivedCount > 0 && receivedCount === overallCount) {
|
||||
updateData = { ...updateData, state: { type: 'delivered' } };
|
||||
}
|
||||
|
||||
await editObject({
|
||||
model: this,
|
||||
id: orderId,
|
||||
updateData,
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
salesOrderSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
salesOrderSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
// Create and export the model
|
||||
export const salesOrderModel = mongoose.model('salesOrder', salesOrderSchema);
|
||||
@ -1,54 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user