Initial Commit
Some checks failed
farmcontrol/farmcontrol-scheduler/pipeline/head There was a failure building this commit
Some checks failed
farmcontrol/farmcontrol-scheduler/pipeline/head There was a failure building this commit
This commit is contained in:
commit
f9284ed1f2
13
.eslintignore
Normal file
13
.eslintignore
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
|
*.min.js
|
||||||
|
*.bundle.js
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
100
.eslintrc.json
Normal file
100
.eslintrc.json
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"es2022": true
|
||||||
|
},
|
||||||
|
"extends": ["eslint:recommended", "prettier"],
|
||||||
|
"plugins": ["prettier"],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2022,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"prettier/prettier": "error",
|
||||||
|
"indent": ["error", 2],
|
||||||
|
"linebreak-style": ["error", "unix"],
|
||||||
|
"quotes": ["error", "single"],
|
||||||
|
"semi": ["error", "always"],
|
||||||
|
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
||||||
|
"no-console": "warn",
|
||||||
|
"prefer-const": "error",
|
||||||
|
"no-var": "error",
|
||||||
|
"object-shorthand": "error",
|
||||||
|
"prefer-template": "error",
|
||||||
|
"template-curly-spacing": ["error", "never"],
|
||||||
|
"arrow-spacing": "error",
|
||||||
|
"no-duplicate-imports": "error",
|
||||||
|
"no-useless-rename": "error",
|
||||||
|
"prefer-destructuring": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"array": true,
|
||||||
|
"object": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prefer-rest-params": "error",
|
||||||
|
"prefer-spread": "error",
|
||||||
|
"no-useless-constructor": "error",
|
||||||
|
"no-useless-computed-key": "error",
|
||||||
|
"no-useless-escape": "error",
|
||||||
|
"no-useless-return": "error",
|
||||||
|
"no-constant-condition": ["error", { "checkLoops": false }],
|
||||||
|
"no-empty": ["error", { "allowEmptyCatch": true }],
|
||||||
|
"no-extra-boolean-cast": "error",
|
||||||
|
"no-extra-semi": "error",
|
||||||
|
"no-irregular-whitespace": "error",
|
||||||
|
"no-multiple-empty-lines": ["error", { "max": 2 }],
|
||||||
|
"no-trailing-spaces": "error",
|
||||||
|
"eol-last": "error",
|
||||||
|
"comma-dangle": ["error", "never"],
|
||||||
|
"comma-spacing": ["error", { "before": false, "after": true }],
|
||||||
|
"comma-style": ["error", "last"],
|
||||||
|
"key-spacing": ["error", { "beforeColon": false, "afterColon": true }],
|
||||||
|
"keyword-spacing": ["error", { "before": true, "after": true }],
|
||||||
|
"object-curly-spacing": ["error", "always"],
|
||||||
|
"array-bracket-spacing": ["error", "never"],
|
||||||
|
"space-before-blocks": "error",
|
||||||
|
"space-before-function-paren": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"anonymous": "always",
|
||||||
|
"named": "never",
|
||||||
|
"asyncArrow": "always"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"space-in-parens": ["error", "never"],
|
||||||
|
"space-infix-ops": "error",
|
||||||
|
"space-unary-ops": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"words": true,
|
||||||
|
"nonwords": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"spaced-comment": ["error", "always"],
|
||||||
|
"brace-style": ["error", "1tbs", { "allowSingleLine": true }],
|
||||||
|
"camelcase": ["error", { "properties": "never" }],
|
||||||
|
"new-cap": ["error", { "newIsCap": true, "capIsNew": false }],
|
||||||
|
"new-parens": "error",
|
||||||
|
"no-array-constructor": "error",
|
||||||
|
"no-new-object": "error",
|
||||||
|
"no-new-require": "error",
|
||||||
|
"no-path-concat": "error",
|
||||||
|
"no-process-exit": "error",
|
||||||
|
"no-return-assign": "error",
|
||||||
|
"no-self-compare": "error",
|
||||||
|
"no-sequences": "error",
|
||||||
|
"no-throw-literal": "error",
|
||||||
|
"no-unmodified-loop-condition": "error",
|
||||||
|
"no-unused-expressions": "error",
|
||||||
|
"no-useless-call": "error",
|
||||||
|
"no-useless-concat": "error",
|
||||||
|
"no-useless-escape": "error",
|
||||||
|
"no-void": "error",
|
||||||
|
"no-warning-comments": "warn",
|
||||||
|
"prefer-promise-reject-errors": "error",
|
||||||
|
"require-await": "error",
|
||||||
|
"yoda": "error"
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["node_modules/", "dist/", "build/", "*.min.js"]
|
||||||
|
}
|
||||||
144
.gitignore
vendored
Normal file
144
.gitignore
vendored
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
*.DS_STORE
|
||||||
|
|
||||||
|
*.env
|
||||||
|
|
||||||
|
test-results.xml
|
||||||
|
|
||||||
|
# Jenkins generated build metadata
|
||||||
|
src/buildInfo.json
|
||||||
|
|
||||||
|
DS_STORE
|
||||||
|
**/DS_Store
|
||||||
|
|
||||||
|
test-results.xml
|
||||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.min.js
|
||||||
|
package-lock.json
|
||||||
|
.git/
|
||||||
|
.vscode/
|
||||||
104
Jenkinsfile
vendored
Normal file
104
Jenkinsfile
vendored
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
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
|
||||||
|
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: 'node_modules/**',
|
||||||
|
execCommand: '''
|
||||||
|
cd /home/farmcontrol/farmcontrol-scheduler
|
||||||
|
pnpm install --production
|
||||||
|
sudo systemctl restart farmcontrol-scheduler
|
||||||
|
''',
|
||||||
|
execTimeout: 120000,
|
||||||
|
flatten: false,
|
||||||
|
makeEmptyDirs: false,
|
||||||
|
noDefaultExcludes: false,
|
||||||
|
patternSeparator: '[, ]+',
|
||||||
|
remoteDirectory: 'farmcontrol-scheduler',
|
||||||
|
remoteDirectorySDF: false,
|
||||||
|
removePrefix: '',
|
||||||
|
sourceFiles: '**/*'
|
||||||
|
)
|
||||||
|
],
|
||||||
|
usePromotionTimestamp: false,
|
||||||
|
useWorkspaceInPromotion: false,
|
||||||
|
verbose: true
|
||||||
|
)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
cleanWs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
83
README.md
Normal file
83
README.md
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# Farm Control Scheduler
|
||||||
|
|
||||||
|
[](https://ci.tombutcher.work/job/farmcontrol/job/farmcontrol-scheduler/job/main/)
|
||||||
|
|
||||||
|
A background service for Farm Control ERP that fires time-sensitive events on model objects once their scheduled date/time has passed.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Scans configured models for scheduled properties (e.g. a "publish at" or "expires at" date) and picks up any that are now due.
|
||||||
|
- Runs the scheduling loop on a dedicated worker thread, so checks never block the main application event loop.
|
||||||
|
- Calls `onSchedulerEvent(property, value)` on the relevant model instance once its scheduled time arrives.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. On startup, `SchedulerManager` inspects every registered model for a `scheduledProperties` definition and queries the database for any matching objects that are due (or already overdue).
|
||||||
|
2. Each match is wrapped in a `Scheduler` (`id`, `objectType`, `object`, `property`, `value`) and added to the manager's schedulers list.
|
||||||
|
3. A worker thread (`schedulerWorker.js`) polls the list on an interval, comparing each scheduler's `value` against the current date/time.
|
||||||
|
4. When a scheduler is due, the worker notifies the main thread, which calls `object.onSchedulerEvent(property, value)` and removes the scheduler from the list.
|
||||||
|
|
||||||
|
Models opt in to this behaviour by defining `scheduledProperties` on their Mongoose schema and implementing an `onSchedulerEvent(property, value)` method to handle the callback.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js (v16 or higher)
|
||||||
|
- MongoDB server
|
||||||
|
- Redis server
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The application uses `config.json` for configuration. At minimum it expects a `server` block controlling logging, plus connection details for MongoDB and Redis:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"logLevel": "debug"
|
||||||
|
},
|
||||||
|
"mongo": {
|
||||||
|
"uri": "mongodb://localhost:27017/farmcontrol"
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 6379
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See `config.js` for the full set of supported options.
|
||||||
|
|
||||||
|
## Running the Application
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
The application uses [log4js](https://log4js-node.github.io/log4js-node/) for logging. Set the log level under `server.logLevel` in your configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"logLevel": "debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Available log levels (least to most verbose): `error`, `warn`, `info`, `debug`, `trace`.
|
||||||
59
config.json
Normal file
59
config.json
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"development": {
|
||||||
|
"server": {
|
||||||
|
"logLevel": "trace"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"mongo": {
|
||||||
|
"url": "mongodb://127.0.0.1:27017/farmcontrol"
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 6379,
|
||||||
|
"password": ""
|
||||||
|
},
|
||||||
|
"nats": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 4222
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"server": {
|
||||||
|
"logLevel": "error"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"mongo": {
|
||||||
|
"url": "mongodb://127.0.0.1:27017/farmcontrol-test"
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 6379,
|
||||||
|
"password": ""
|
||||||
|
},
|
||||||
|
"nats": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 4222
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"server": {
|
||||||
|
"logLevel": "info"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"mongo": {
|
||||||
|
"url": "mongodb://192.168.68.38:27017/farmcontrol"
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 6379,
|
||||||
|
"password": ""
|
||||||
|
},
|
||||||
|
"nats": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 4222
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
package.json
Normal file
63
package.json
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "farmcontrol-scheduler",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"@nats-io/transport-node": "^3.2.0",
|
||||||
|
"body-parser": "^2.2.0",
|
||||||
|
"canonical-json": "^0.2.0",
|
||||||
|
"dayjs": "^1.11.23",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"i": "^0.3.7",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
|
"keycloak-connect": "^26.1.1",
|
||||||
|
"lodash": "^4.17.23",
|
||||||
|
"log4js": "^6.9.1",
|
||||||
|
"mongodb": "^6.21.0",
|
||||||
|
"mongoose": "^9.9.1",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
|
"node-cache": "^5.1.2",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
|
"nodemon": "^3.1.11",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"sequelize": "^6.37.7"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/cli": "^7.28.3",
|
||||||
|
"@babel/core": "^7.28.5",
|
||||||
|
"@babel/node": "^7.28.0",
|
||||||
|
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||||
|
"@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"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js",
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "nodemon src/index.js",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"lint:fix": "eslint src/ --fix",
|
||||||
|
"format": "prettier --write \"src/**/*.{js,json}\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.{js,json}\"",
|
||||||
|
"fix": "npm run lint:fix && npm run format",
|
||||||
|
"ensure-references": "node scripts/ensure-references.js"
|
||||||
|
},
|
||||||
|
"author": "Tom Butcher",
|
||||||
|
"license": "ISC",
|
||||||
|
"packageManager": "pnpm@10.28.0"
|
||||||
|
}
|
||||||
11030
pnpm-lock.yaml
generated
Normal file
11030
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
src/config.js
Normal file
55
src/config.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
// config.js - Configuration handling
|
||||||
|
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);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const CONFIG_PATH = path.resolve(__dirname, '../config.json');
|
||||||
|
|
||||||
|
// Load config file
|
||||||
|
export function loadConfig() {
|
||||||
|
const env = process.env.NODE_ENV || 'development';
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(CONFIG_PATH)) {
|
||||||
|
throw new Error(`Configuration file not found at ${CONFIG_PATH}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configData = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||||
|
const config = JSON.parse(configData);
|
||||||
|
|
||||||
|
if (!config[env]) {
|
||||||
|
throw new Error(
|
||||||
|
`Configuration for environment '${env}' not found in config.json`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envConfig = config[env];
|
||||||
|
|
||||||
|
// Override keycloak client secret with environment variable if available
|
||||||
|
if (process.env.KEYCLOAK_CLIENT_SECRET) {
|
||||||
|
if (!envConfig.auth) {
|
||||||
|
envConfig.auth = {};
|
||||||
|
}
|
||||||
|
if (!envConfig.auth.keycloak) {
|
||||||
|
envConfig.auth.keycloak = {};
|
||||||
|
}
|
||||||
|
envConfig.auth.keycloak.clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
|
||||||
|
}
|
||||||
|
|
||||||
|
return envConfig;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading config:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current environment
|
||||||
|
export function getEnvironment() {
|
||||||
|
return process.env.NODE_ENV || 'development';
|
||||||
|
}
|
||||||
675
src/database/database.js
Normal file
675
src/database/database.js
Normal file
@ -0,0 +1,675 @@
|
|||||||
|
import _ from "lodash";
|
||||||
|
import {
|
||||||
|
deleteAuditLog,
|
||||||
|
expandObjectIds,
|
||||||
|
editAuditLog,
|
||||||
|
editNotification,
|
||||||
|
distributeUpdate,
|
||||||
|
newAuditLog,
|
||||||
|
distributeNew,
|
||||||
|
distributeStats,
|
||||||
|
} from "./utils.js";
|
||||||
|
import log4js from "log4js";
|
||||||
|
import { loadConfig } from "../config.js";
|
||||||
|
import { formatTraceData, getQueryToCacheKey } from "../utils.js";
|
||||||
|
import { redisServer } from "./redis.js";
|
||||||
|
import { auditLogModel } from "./schemas/management/auditlog.schema.js";
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
const logger = log4js.getLogger("Database");
|
||||||
|
const cacheLogger = log4js.getLogger("Local Cache");
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
cacheLogger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
const mergeObjectUpdates = (target, source) =>
|
||||||
|
_.mergeWith(target, source, (objValue, srcValue, key) => {
|
||||||
|
if (Array.isArray(objValue) || Array.isArray(srcValue)) {
|
||||||
|
return srcValue;
|
||||||
|
}
|
||||||
|
if (key === "permissions" && srcValue !== undefined) {
|
||||||
|
return srcValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default cache TTL in seconds (similar to previous in-memory cache)
|
||||||
|
const CACHE_TTL_SECONDS = config.database?.redis?.ttlSeconds || 5;
|
||||||
|
|
||||||
|
export const retrieveObjectCache = async ({ model, id, populate = [] }) => {
|
||||||
|
const cacheKey = getQueryToCacheKey({ model: model.modelName, id, populate });
|
||||||
|
|
||||||
|
cacheLogger.trace("Retrieving:", cacheKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cachedObject = await redisServer.getKey(cacheKey);
|
||||||
|
|
||||||
|
if (cachedObject == null) {
|
||||||
|
cacheLogger.trace("Miss:", cacheKey);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheLogger.trace("Hit:", cacheKey);
|
||||||
|
|
||||||
|
return cachedObject;
|
||||||
|
} catch (err) {
|
||||||
|
cacheLogger.error("Error retrieving object from Redis cache:", err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateObjectCache = async ({
|
||||||
|
model,
|
||||||
|
id,
|
||||||
|
object,
|
||||||
|
populate = [],
|
||||||
|
}) => {
|
||||||
|
const cacheKeyFilter = `${model.modelName}:${id?.toString()}*`;
|
||||||
|
const cacheKey = getQueryToCacheKey({ model: model.modelName, id, populate });
|
||||||
|
|
||||||
|
cacheLogger.trace("Updating:", cacheKeyFilter);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all keys matching the filter pattern
|
||||||
|
const matchingKeys = await redisServer.getKeysByPattern(cacheKeyFilter);
|
||||||
|
|
||||||
|
logger.trace(`Matching keys: ${formatTraceData(matchingKeys)}`);
|
||||||
|
|
||||||
|
// Merge the object with each cached object and update
|
||||||
|
const mergedObjects = [];
|
||||||
|
for (const key of matchingKeys) {
|
||||||
|
logger.trace("Updating object cache:", key);
|
||||||
|
const cachedObject = (await redisServer.getKey(key)) || {};
|
||||||
|
const mergedObject = mergeObjectUpdates(cachedObject, object);
|
||||||
|
await redisServer.setKey(key, mergedObject, CACHE_TTL_SECONDS);
|
||||||
|
mergedObjects.push(mergedObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheObject = (await redisServer.getKey(cacheKey)) || {};
|
||||||
|
const mergedObject = mergeObjectUpdates(cacheObject, object);
|
||||||
|
await redisServer.setKey(cacheKey, mergedObject, CACHE_TTL_SECONDS);
|
||||||
|
|
||||||
|
cacheLogger.trace("Updated:", {
|
||||||
|
filter: cacheKeyFilter,
|
||||||
|
keysUpdated: matchingKeys.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the merged object
|
||||||
|
return mergedObject;
|
||||||
|
} catch (err) {
|
||||||
|
cacheLogger.error("Error updating object in Redis cache:", err);
|
||||||
|
// Fallback to returning the provided object if cache fails
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteObjectCache = async ({ model, id }) => {
|
||||||
|
const cacheKeyFilter = `${model.modelName}:${id?.toString()}*`;
|
||||||
|
|
||||||
|
cacheLogger.trace("Deleting:", cacheKeyFilter);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all keys matching the filter pattern and delete them
|
||||||
|
const matchingKeys = await redisServer.getKeysByPattern(cacheKeyFilter);
|
||||||
|
|
||||||
|
for (const cacheKey of matchingKeys) {
|
||||||
|
await redisServer.deleteKey(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheLogger.trace("Deleted:", {
|
||||||
|
filter: cacheKeyFilter,
|
||||||
|
keysDeleted: matchingKeys.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
cacheLogger.error("Error deleting object from Redis cache:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reusable function to list objects with aggregation, filtering, search, sorting, and pagination
|
||||||
|
export const listObjects = async ({
|
||||||
|
model,
|
||||||
|
populate = [],
|
||||||
|
filter = {},
|
||||||
|
sort = "",
|
||||||
|
order = "ascend",
|
||||||
|
project = {}, // optional: override default projection
|
||||||
|
cached = false,
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
logger.trace(
|
||||||
|
`Listing objects: ${formatTraceData({
|
||||||
|
model,
|
||||||
|
populate,
|
||||||
|
filter,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
project,
|
||||||
|
cached,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fix: descend should be -1, ascend should be 1
|
||||||
|
const sortOrder = order === "descend" ? -1 : 1;
|
||||||
|
|
||||||
|
if (!sort || sort === "") {
|
||||||
|
sort = "createdAt";
|
||||||
|
}
|
||||||
|
// Translate parent._id to parent for Mongoose
|
||||||
|
if (filter["parent._id"]) {
|
||||||
|
filter.parent = filter["parent._id"];
|
||||||
|
delete filter["parent._id"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate owner._id to owner for Mongoose
|
||||||
|
if (filter["owner._id"]) {
|
||||||
|
filter.owner = filter["owner._id"];
|
||||||
|
delete filter["owner._id"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use find with population and filter
|
||||||
|
let query = model.find(filter).sort({ [sort]: sortOrder });
|
||||||
|
|
||||||
|
// Handle populate (array or single value)
|
||||||
|
if (populate.length > 0) {
|
||||||
|
if (Array.isArray(populate)) {
|
||||||
|
for (const pop of populate) {
|
||||||
|
query = query.populate(pop);
|
||||||
|
}
|
||||||
|
} else if (typeof populate === "string" || typeof populate === "object") {
|
||||||
|
query = query.populate(populate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle select (projection)
|
||||||
|
if (project && Object.keys(project).length > 0) {
|
||||||
|
query = query.select(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query.lean();
|
||||||
|
|
||||||
|
const queryResult = await query;
|
||||||
|
|
||||||
|
const finalResult = expandObjectIds(queryResult);
|
||||||
|
|
||||||
|
logger.trace(
|
||||||
|
`Retreived from database: ${formatTraceData({
|
||||||
|
model,
|
||||||
|
populate,
|
||||||
|
filter,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
project,
|
||||||
|
cached,
|
||||||
|
length: finalResult.length,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
return finalResult;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Object list error:", error);
|
||||||
|
return { error: error, code: 500 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reusable function to get a single object by ID
|
||||||
|
export const getObject = async ({
|
||||||
|
model,
|
||||||
|
id,
|
||||||
|
populate = [],
|
||||||
|
cached = false,
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
logger.trace(
|
||||||
|
`Getting object: ${formatTraceData({
|
||||||
|
model,
|
||||||
|
id,
|
||||||
|
populate,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cached == true) {
|
||||||
|
const cachedObject = await retrieveObjectCache({ model, id, populate });
|
||||||
|
if (cachedObject != undefined) {
|
||||||
|
return cachedObject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = model.findById(id).lean();
|
||||||
|
|
||||||
|
// Handle populate (array or single value)
|
||||||
|
if (populate) {
|
||||||
|
if (Array.isArray(populate)) {
|
||||||
|
for (const pop of populate) {
|
||||||
|
query = query.populate(pop);
|
||||||
|
}
|
||||||
|
} else if (typeof populate === "string" || typeof populate === "object") {
|
||||||
|
query = query.populate(populate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const finalResult = await query;
|
||||||
|
|
||||||
|
if (!finalResult) {
|
||||||
|
logger.warn("Object not found in database:", {
|
||||||
|
model,
|
||||||
|
id,
|
||||||
|
populate,
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.trace(
|
||||||
|
`Retreived object from database: ${formatTraceData({
|
||||||
|
model,
|
||||||
|
id,
|
||||||
|
populate,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.trace(formatTraceData(finalResult));
|
||||||
|
|
||||||
|
await updateObjectCache({
|
||||||
|
model: model,
|
||||||
|
id: finalResult._id.toString(),
|
||||||
|
populate,
|
||||||
|
object: finalResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalResult;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("An error retreiving object:", error.message);
|
||||||
|
throw error;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility to run one or many rollup aggregations in a single query via $facet.
|
||||||
|
export const aggregateRollups = async ({
|
||||||
|
model,
|
||||||
|
baseFilter = {},
|
||||||
|
rollupConfigs = [],
|
||||||
|
}) => {
|
||||||
|
if (!rollupConfigs.length) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const facetStage = rollupConfigs.reduce((facets, definition, index) => {
|
||||||
|
const key = definition.name || `rollup${index}`;
|
||||||
|
const matchStage = {
|
||||||
|
$match: { ...baseFilter, ...(definition.filter || {}) },
|
||||||
|
};
|
||||||
|
const groupStage = { $group: { _id: null } };
|
||||||
|
|
||||||
|
(definition.rollups || []).forEach((rollup) => {
|
||||||
|
switch (rollup.operation) {
|
||||||
|
case "sum":
|
||||||
|
groupStage.$group[rollup.name] = { $sum: `$${rollup.property}` };
|
||||||
|
break;
|
||||||
|
case "count":
|
||||||
|
groupStage.$group[rollup.name] = { $sum: 1 };
|
||||||
|
break;
|
||||||
|
case "avg":
|
||||||
|
groupStage.$group[rollup.name] = { $avg: `$${rollup.property}` };
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported rollup operation: ${rollup.operation}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
facets[key] = [matchStage, groupStage];
|
||||||
|
return facets;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const [results] = await model.aggregate([{ $facet: facetStage }]);
|
||||||
|
|
||||||
|
return rollupConfigs.reduce((acc, definition, index) => {
|
||||||
|
const key = definition.name || `rollup${index}`;
|
||||||
|
const rawResult = results?.[key]?.[0] || {};
|
||||||
|
|
||||||
|
// Transform the result to nest rollup values under operation type
|
||||||
|
const transformedResult = {};
|
||||||
|
(definition.rollups || []).forEach((rollup) => {
|
||||||
|
const value = rawResult[rollup.name] || 0;
|
||||||
|
// If there's only one rollup and its name matches the key, flatten the structure
|
||||||
|
if (definition.rollups.length === 1 && rollup.name === key) {
|
||||||
|
transformedResult[rollup.operation] = value;
|
||||||
|
} else {
|
||||||
|
transformedResult[rollup.name] = { [rollup.operation]: value };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
acc[key] = transformedResult;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Snapshot absolute rollup values at each point in time by reconstructing
|
||||||
|
// object state from the current documents plus audit logs.
|
||||||
|
export const aggregateRollupsHistory = async ({
|
||||||
|
model,
|
||||||
|
baseFilter = {},
|
||||||
|
rollupConfigs = [],
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
}) => {
|
||||||
|
if (!rollupConfigs.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = endDate ? new Date(endDate) : new Date();
|
||||||
|
const start = startDate
|
||||||
|
? new Date(startDate)
|
||||||
|
: new Date(end.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
const parentType = model.modelName ? model.modelName : "unknown";
|
||||||
|
|
||||||
|
const matchesFilter = (obj, filter) => {
|
||||||
|
if (!filter || Object.keys(filter).length === 0) return true;
|
||||||
|
|
||||||
|
for (const [key, expectedValue] of Object.entries(filter)) {
|
||||||
|
const actualValue = _.get(obj, key);
|
||||||
|
if (actualValue != expectedValue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const existedAt = (obj, bucketDate) => {
|
||||||
|
if (!obj?.createdAt) return true;
|
||||||
|
return new Date(obj.createdAt) <= bucketDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshotRollups = (objects, bucketDate) => {
|
||||||
|
const bucketResult = {
|
||||||
|
date: bucketDate.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
rollupConfigs.forEach((config) => {
|
||||||
|
const matchingObjects = objects.filter(
|
||||||
|
(obj) =>
|
||||||
|
existedAt(obj, bucketDate) &&
|
||||||
|
matchesFilter(obj, baseFilter) &&
|
||||||
|
matchesFilter(obj, config.filter),
|
||||||
|
);
|
||||||
|
|
||||||
|
(config.rollups || []).forEach((rollup) => {
|
||||||
|
let value = 0;
|
||||||
|
if (rollup.operation === "count") {
|
||||||
|
value = matchingObjects.length;
|
||||||
|
} else if (rollup.operation === "sum") {
|
||||||
|
value = _.sumBy(
|
||||||
|
matchingObjects,
|
||||||
|
(obj) => _.get(obj, rollup.property) || 0,
|
||||||
|
);
|
||||||
|
} else if (rollup.operation === "avg") {
|
||||||
|
const sum = _.sumBy(
|
||||||
|
matchingObjects,
|
||||||
|
(obj) => _.get(obj, rollup.property) || 0,
|
||||||
|
);
|
||||||
|
value = matchingObjects.length ? sum / matchingObjects.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketResult[rollup.name] = { [rollup.operation]: value };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return bucketResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditLogs = await auditLogModel
|
||||||
|
.find({
|
||||||
|
parentType,
|
||||||
|
createdAt: { $gte: start },
|
||||||
|
})
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
const currentObjects = await model.find(baseFilter).lean();
|
||||||
|
const objectMap = new Map();
|
||||||
|
currentObjects.forEach((obj) => {
|
||||||
|
objectMap.set(obj._id.toString(), expandObjectIds(obj));
|
||||||
|
});
|
||||||
|
|
||||||
|
const extraIds = [
|
||||||
|
...new Set(
|
||||||
|
auditLogs
|
||||||
|
.map((log) => log.parent?.toString())
|
||||||
|
.filter((id) => id && !objectMap.has(id)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (extraIds.length) {
|
||||||
|
const extraObjects = await model.find({ _id: { $in: extraIds } }).lean();
|
||||||
|
extraObjects.forEach((obj) => {
|
||||||
|
objectMap.set(obj._id.toString(), expandObjectIds(obj));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (objectMap.size === 0 && auditLogs.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = [];
|
||||||
|
let currentTime = new Date(end);
|
||||||
|
currentTime.setSeconds(0, 0);
|
||||||
|
|
||||||
|
while (currentTime >= start) {
|
||||||
|
buckets.push(new Date(currentTime));
|
||||||
|
currentTime = new Date(currentTime.getTime() - 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!buckets.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const workingObjects = new Map();
|
||||||
|
objectMap.forEach((val, key) => workingObjects.set(key, _.cloneDeep(val)));
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
let logIndex = 0;
|
||||||
|
|
||||||
|
for (const bucketDate of buckets) {
|
||||||
|
while (logIndex < auditLogs.length) {
|
||||||
|
const log = auditLogs[logIndex];
|
||||||
|
const logDate = new Date(log.createdAt);
|
||||||
|
|
||||||
|
if (logDate <= bucketDate) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectId = log.parent.toString();
|
||||||
|
const object = workingObjects.get(objectId);
|
||||||
|
|
||||||
|
if (log.operation === "new") {
|
||||||
|
workingObjects.delete(objectId);
|
||||||
|
} else if (log.operation === "delete" && log.changes?.old) {
|
||||||
|
if (!workingObjects.has(objectId)) {
|
||||||
|
workingObjects.set(
|
||||||
|
objectId,
|
||||||
|
expandObjectIds({ ...log.changes.old, _id: log.parent }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (object && log.changes?.old) {
|
||||||
|
mergeObjectUpdates(object, log.changes.old);
|
||||||
|
}
|
||||||
|
|
||||||
|
logIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(
|
||||||
|
snapshotRollups(Array.from(workingObjects.values()), bucketDate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.reverse();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getModelStats = async ({ model }) => {
|
||||||
|
if (!model.stats) {
|
||||||
|
return { error: "Model does not have a stats method.", code: 500 };
|
||||||
|
}
|
||||||
|
return await model.stats();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reusable function to edit an object by ID, with audit logging and distribution
|
||||||
|
export const editObject = async ({
|
||||||
|
model,
|
||||||
|
id,
|
||||||
|
updateData,
|
||||||
|
owner = undefined,
|
||||||
|
ownerType = undefined,
|
||||||
|
populate = [],
|
||||||
|
auditLog = true,
|
||||||
|
notify = true,
|
||||||
|
recalculate = true,
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
// Determine parentType from model name
|
||||||
|
const parentType = model.modelName ? model.modelName : "unknown";
|
||||||
|
// Fetch the and update object
|
||||||
|
var query = model.findByIdAndUpdate(id, updateData).lean();
|
||||||
|
var newQuery = model.findById(id).lean();
|
||||||
|
|
||||||
|
if (populate) {
|
||||||
|
if (Array.isArray(populate)) {
|
||||||
|
for (const pop of populate) {
|
||||||
|
query = query.populate(pop);
|
||||||
|
newQuery = newQuery.populate(pop);
|
||||||
|
}
|
||||||
|
} else if (typeof populate === "string" || typeof populate === "object") {
|
||||||
|
query = query.populate(populate);
|
||||||
|
newQuery = newQuery.populate(populate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousObject = await query;
|
||||||
|
const newObject = await newQuery;
|
||||||
|
if (!previousObject || !newObject) {
|
||||||
|
return { error: `${parentType} not found.`, code: 404 };
|
||||||
|
}
|
||||||
|
const previousExpandedObject = expandObjectIds(previousObject);
|
||||||
|
const newExpandedObject = expandObjectIds(newObject);
|
||||||
|
|
||||||
|
if (auditLog == true && owner != undefined && ownerType != undefined) {
|
||||||
|
// Audit log before update
|
||||||
|
await editAuditLog(
|
||||||
|
previousExpandedObject,
|
||||||
|
newExpandedObject,
|
||||||
|
id,
|
||||||
|
parentType,
|
||||||
|
owner,
|
||||||
|
ownerType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
notify == true &&
|
||||||
|
owner != undefined &&
|
||||||
|
ownerType != undefined &&
|
||||||
|
parentType !== "notification" &&
|
||||||
|
parentType !== "auditLog" &&
|
||||||
|
parentType !== "userNotifier" &&
|
||||||
|
parentType !== "objectView"
|
||||||
|
) {
|
||||||
|
await editNotification(
|
||||||
|
previousExpandedObject,
|
||||||
|
newExpandedObject,
|
||||||
|
id,
|
||||||
|
parentType,
|
||||||
|
owner,
|
||||||
|
ownerType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribute update
|
||||||
|
await distributeUpdate(updateData, id, parentType);
|
||||||
|
|
||||||
|
await updateObjectCache({
|
||||||
|
model: model,
|
||||||
|
id: id.toString(),
|
||||||
|
object: { ...previousExpandedObject, ...updateData },
|
||||||
|
populate,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (model.recalculate && recalculate == true) {
|
||||||
|
logger.debug(`Recalculating ${model.modelName}`);
|
||||||
|
await model.recalculate(newExpandedObject, owner, ownerType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.stats) {
|
||||||
|
logger.debug(`Getting stats for ${model.modelName}`);
|
||||||
|
const statsData = await model.stats(newExpandedObject);
|
||||||
|
await distributeStats(statsData, parentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...previousExpandedObject, ...updateData };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("editObject error:", error);
|
||||||
|
return { error: error.message, code: 500 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reusable function to create a new object
|
||||||
|
export const newObject = async ({
|
||||||
|
model,
|
||||||
|
newData,
|
||||||
|
owner = null,
|
||||||
|
ownerType = undefined,
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const parentType = model.modelName ? model.modelName : "unknown";
|
||||||
|
|
||||||
|
const result = await model.create(newData);
|
||||||
|
if (!result || result.length === 0) {
|
||||||
|
return { error: "No object created.", code: 500 };
|
||||||
|
}
|
||||||
|
const created = result;
|
||||||
|
|
||||||
|
if (owner != undefined && ownerType != undefined) {
|
||||||
|
await newAuditLog(newData, created._id, parentType, owner, ownerType);
|
||||||
|
}
|
||||||
|
|
||||||
|
await distributeNew(created, parentType);
|
||||||
|
|
||||||
|
await updateObjectCache({
|
||||||
|
model: model,
|
||||||
|
id: created._id.toString(),
|
||||||
|
object: { _id: created._id, ...newData },
|
||||||
|
populate: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("newObject error:", error);
|
||||||
|
return { error: error.message, code: 500 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reusable function to delete an object by ID, with audit logging and distribution
|
||||||
|
export const deleteObject = async ({
|
||||||
|
model,
|
||||||
|
id,
|
||||||
|
owner = null,
|
||||||
|
ownerType = undefined,
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const parentType = model.modelName ? model.modelName : "unknown";
|
||||||
|
// Delete the object
|
||||||
|
const result = await model.findByIdAndDelete(id);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return { error: `${parentType} not found.`, code: 404 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (owner != undefined && ownerType != undefined) {
|
||||||
|
// Audit log the deletion
|
||||||
|
await deleteAuditLog(result, id, parentType, owner, ownerType);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteObjectCache({ model: model, id: id.toString() });
|
||||||
|
|
||||||
|
// Distribute the deletion event
|
||||||
|
await distributeUpdate({ deleted: true }, id, parentType);
|
||||||
|
|
||||||
|
return { deleted: true, id: id.toString() };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("deleteObject error:", error);
|
||||||
|
return { error: error.message, code: 500 };
|
||||||
|
}
|
||||||
|
};
|
||||||
1371
src/database/filter.js
Normal file
1371
src/database/filter.js
Normal file
File diff suppressed because it is too large
Load Diff
59
src/database/mongo.js
Normal file
59
src/database/mongo.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { loadConfig } from '../config.js';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const logger = log4js.getLogger('Mongo DB');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
class MongoServer {
|
||||||
|
constructor() {
|
||||||
|
this.connected = false;
|
||||||
|
this.connecting = false;
|
||||||
|
this.connectionPromise = null;
|
||||||
|
this.url = config.database.mongo.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.connected) return mongoose.connection;
|
||||||
|
if (this.connecting) return this.connectionPromise;
|
||||||
|
this.connecting = true;
|
||||||
|
logger.info('Connecting to MongoDB...');
|
||||||
|
logger.debug('Connection URL:', this.url);
|
||||||
|
this.connectionPromise = mongoose
|
||||||
|
.connect(this.url, {})
|
||||||
|
.then(conn => {
|
||||||
|
this.connected = true;
|
||||||
|
logger.info('Database connected.');
|
||||||
|
return conn.connection;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
this.connected = false;
|
||||||
|
logger.error('MongoDB connection error:', err);
|
||||||
|
throw err;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.connecting = false;
|
||||||
|
});
|
||||||
|
return this.connectionPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConnection() {
|
||||||
|
if (!this.connected) {
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
return mongoose.connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect() {
|
||||||
|
if (!this.connected) return;
|
||||||
|
logger.info('Disconnecting from MongoDB...');
|
||||||
|
await mongoose.connection.close();
|
||||||
|
this.connected = false;
|
||||||
|
logger.info('Disconnected from MongoDB');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mongoServer = new MongoServer();
|
||||||
|
|
||||||
|
export { MongoServer, mongoServer };
|
||||||
310
src/database/nats.js
Normal file
310
src/database/nats.js
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
import { connect } from '@nats-io/transport-node';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
import { loadConfig } from '../config.js';
|
||||||
|
import { formatTraceData } from '../utils.js';
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const logger = log4js.getLogger('Nats');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
class NatsServer {
|
||||||
|
constructor() {
|
||||||
|
this.client = null;
|
||||||
|
this.subscriptions = new Map(); // subject → { subscription, callbacks }
|
||||||
|
this.requestHandlers = new Map(); // subject → { handler, callbacks }
|
||||||
|
this.queuedSubscriptions = new Map(); // subject → { subscription, callbacks, queue }
|
||||||
|
|
||||||
|
const natsConfig = config.database?.nats || config.database; // fallback for production config
|
||||||
|
const host = natsConfig.host || 'localhost';
|
||||||
|
const port = natsConfig.port || 4222;
|
||||||
|
this.servers = [`nats://${host}:${port}`];
|
||||||
|
this.textEncoder = new TextEncoder();
|
||||||
|
this.textDecoder = new TextDecoder();
|
||||||
|
|
||||||
|
logger.trace(`NatsServer: servers set to ${JSON.stringify(this.servers)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
if (!this.client) {
|
||||||
|
logger.info('Connecting to NATS...');
|
||||||
|
logger.trace(
|
||||||
|
`Creating NATS client with servers ${JSON.stringify(this.servers)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.client = await connect({
|
||||||
|
servers: this.servers,
|
||||||
|
reconnect: true,
|
||||||
|
maxReconnectAttempts: -1, // unlimited reconnects
|
||||||
|
reconnectTimeWait: 1000,
|
||||||
|
timeout: 20000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection by checking if client is connected
|
||||||
|
try {
|
||||||
|
if (this.client.isClosed()) {
|
||||||
|
throw new Error('NATS client connection failed');
|
||||||
|
}
|
||||||
|
logger.trace('NATS client connected successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to connect to NATS:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.trace('NATS client already exists, skipping connection.');
|
||||||
|
}
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClient() {
|
||||||
|
if (!this.client) {
|
||||||
|
logger.trace('No client found, calling connect().');
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(subject, data) {
|
||||||
|
const client = await this.getClient();
|
||||||
|
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.publish(subject, this.textEncoder.encode(payload));
|
||||||
|
logger.trace(`Published to subject: ${subject}, data: ${formatTraceData(payload)}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to publish to subject ${subject}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(subject, data, timeout = 30000) {
|
||||||
|
const client = await this.getClient();
|
||||||
|
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.request(
|
||||||
|
subject,
|
||||||
|
this.textEncoder.encode(payload),
|
||||||
|
{
|
||||||
|
timeout: timeout
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData = this.textDecoder.decode(response.data);
|
||||||
|
logger.trace(
|
||||||
|
`Request to subject: ${subject}, response: ${formatTraceData(responseData)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to parse as JSON, fallback to string
|
||||||
|
try {
|
||||||
|
return JSON.parse(responseData);
|
||||||
|
} catch {
|
||||||
|
return responseData;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'TIMEOUT') {
|
||||||
|
logger.trace(`Request timeout for subject: ${subject}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe(subject, owner, callback) {
|
||||||
|
const client = await this.getClient();
|
||||||
|
const subscriptionKey = subject;
|
||||||
|
|
||||||
|
if (this.subscriptions.has(subscriptionKey)) {
|
||||||
|
this.subscriptions.get(subscriptionKey).callbacks.set(owner, callback);
|
||||||
|
logger.trace(
|
||||||
|
`Added subscription callback for owner=${owner} on subject=${subject}`
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.trace(`Creating new subscription for subject: ${subject}`);
|
||||||
|
const subscription = client.subscribe(subject);
|
||||||
|
const callbacks = new Map();
|
||||||
|
callbacks.set(owner, callback);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for await (const msg of subscription) {
|
||||||
|
logger.trace(`Message received on subject: ${subject}`);
|
||||||
|
const data = this.textDecoder.decode(msg.data);
|
||||||
|
let parsedData;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsedData = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
parsedData = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [ownerId, cb] of callbacks) {
|
||||||
|
try {
|
||||||
|
cb(subject, parsedData, msg);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`Error in subscription callback for owner=${ownerId}, subject=${subject}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})().catch(err => {
|
||||||
|
logger.error(`Subscription error for subject ${subject}:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscriptions.set(subscriptionKey, { subscription, callbacks });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setRequestHandler(subject, owner, handler) {
|
||||||
|
const client = await this.getClient();
|
||||||
|
const handlerKey = subject;
|
||||||
|
|
||||||
|
if (this.requestHandlers.has(handlerKey)) {
|
||||||
|
this.requestHandlers.get(handlerKey).callbacks.set(owner, handler);
|
||||||
|
logger.trace(
|
||||||
|
`Added request handler for owner=${owner} on subject=${subject}`
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.trace(`Creating new request handler for subject: ${subject}`);
|
||||||
|
const subscription = client.subscribe(subject);
|
||||||
|
const callbacks = new Map();
|
||||||
|
callbacks.set(owner, handler);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for await (const msg of subscription) {
|
||||||
|
logger.trace(`Request received on subject: ${subject}`);
|
||||||
|
const data = this.textDecoder.decode(msg.data);
|
||||||
|
let parsedData;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsedData = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
parsedData = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [ownerId, cb] of callbacks) {
|
||||||
|
try {
|
||||||
|
const response = await cb(subject, parsedData, msg);
|
||||||
|
const responsePayload =
|
||||||
|
typeof response === 'string'
|
||||||
|
? response
|
||||||
|
: JSON.stringify(response);
|
||||||
|
msg.respond(this.textEncoder.encode(responsePayload));
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`Error in request handler for owner=${ownerId}, subject=${subject}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
// Send error response
|
||||||
|
msg.respond(
|
||||||
|
this.textEncoder.encode(JSON.stringify({ error: err.message }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})().catch(err => {
|
||||||
|
logger.error(`Request handler error for subject ${subject}:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.requestHandlers.set(handlerKey, { subscription, callbacks });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSubscription(subject, owner) {
|
||||||
|
const entry = this.subscriptions.get(subject);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
logger.trace(`Subscription not found for subject: ${subject}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.callbacks.delete(owner)) {
|
||||||
|
logger.trace(
|
||||||
|
`Removed subscription callback for owner: ${owner} on subject: ${subject}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.trace(
|
||||||
|
`No subscription callback found for owner: ${owner} on subject: ${subject}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.callbacks.size === 0) {
|
||||||
|
logger.trace(`No callbacks left, stopping subscription for ${subject}`);
|
||||||
|
entry.subscription.unsubscribe();
|
||||||
|
this.subscriptions.delete(subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeRequestHandler(subject, owner) {
|
||||||
|
const entry = this.requestHandlers.get(subject);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
logger.trace(`Request handler not found for subject: ${subject}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.callbacks.delete(owner)) {
|
||||||
|
logger.trace(
|
||||||
|
`Removed request handler for owner: ${owner} on subject: ${subject}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.trace(
|
||||||
|
`No request handler found for owner: ${owner} on subject: ${subject}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.callbacks.size === 0) {
|
||||||
|
logger.trace(`No handlers left, stopping request handler for ${subject}`);
|
||||||
|
entry.subscription.unsubscribe();
|
||||||
|
this.requestHandlers.delete(subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect() {
|
||||||
|
logger.info('Disconnecting from NATS...');
|
||||||
|
|
||||||
|
// Stop all subscriptions
|
||||||
|
for (const [subject, entry] of this.subscriptions) {
|
||||||
|
logger.trace(`Stopping subscription: ${subject}`);
|
||||||
|
entry.subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
this.subscriptions.clear();
|
||||||
|
|
||||||
|
// Stop all queued subscriptions
|
||||||
|
for (const [key, entry] of this.queuedSubscriptions) {
|
||||||
|
logger.trace(`Stopping queued subscription: ${key}`);
|
||||||
|
entry.subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
this.queuedSubscriptions.clear();
|
||||||
|
|
||||||
|
// Stop all request handlers
|
||||||
|
for (const [subject, entry] of this.requestHandlers) {
|
||||||
|
logger.trace(`Stopping request handler: ${subject}`);
|
||||||
|
entry.subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
this.requestHandlers.clear();
|
||||||
|
|
||||||
|
if (this.client) {
|
||||||
|
await this.client.close();
|
||||||
|
this.client = null;
|
||||||
|
logger.info('Disconnected from NATS');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const natsServer = new NatsServer();
|
||||||
|
|
||||||
|
export { NatsServer, natsServer };
|
||||||
78
src/database/permissions.js
Normal file
78
src/database/permissions.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import mongoose from "mongoose";
|
||||||
|
import { redisServer } from "./redis.js";
|
||||||
|
|
||||||
|
export const getUserPermissionsCacheKey = (userId) => `permissions:${userId}`;
|
||||||
|
|
||||||
|
export const saveUserPermissionsToRedis = async (userId, permissions = {}) => {
|
||||||
|
if (!userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await redisServer.setKey(
|
||||||
|
getUserPermissionsCacheKey(userId),
|
||||||
|
permissions || {},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
110
src/database/redis.js
Normal file
110
src/database/redis.js
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import Redis from "ioredis";
|
||||||
|
import log4js from "log4js";
|
||||||
|
import { loadConfig } from "../config.js";
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const logger = log4js.getLogger("Redis");
|
||||||
|
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;
|
||||||
|
|
||||||
|
this.client = new Redis(url, {
|
||||||
|
password,
|
||||||
|
lazyConnect: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on("error", (err) => {
|
||||||
|
logger.error("Redis Client Error", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
if (this.connected) return;
|
||||||
|
logger.info("Connecting to Redis...");
|
||||||
|
await this.client.connect();
|
||||||
|
this.connected = true;
|
||||||
|
logger.info("Connected to Redis");
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect() {
|
||||||
|
await this.client.disconnect();
|
||||||
|
this.connected = false;
|
||||||
|
logger.info("Disconnected from Redis");
|
||||||
|
}
|
||||||
|
|
||||||
|
async setKey(key, value, ttlSeconds) {
|
||||||
|
await this.connect();
|
||||||
|
const payload = typeof value === "string" ? value : JSON.stringify(value);
|
||||||
|
if (ttlSeconds) {
|
||||||
|
await this.client.set(key, payload, "EX", ttlSeconds);
|
||||||
|
} else {
|
||||||
|
await this.client.set(key, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getKey(key) {
|
||||||
|
await this.connect();
|
||||||
|
const value = await this.client.get(key);
|
||||||
|
if (value == null) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteKey(key) {
|
||||||
|
await this.connect();
|
||||||
|
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);
|
||||||
|
} while (cursor !== "0");
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const redisServer = new RedisServer();
|
||||||
|
|
||||||
|
export { RedisServer, redisServer };
|
||||||
416
src/database/schemas/__tests__/stockQuantity.recalculate.test.js
Normal file
416
src/database/schemas/__tests__/stockQuantity.recalculate.test.js
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
251
src/database/schemas/finance/invoice.schema.js
Normal file
251
src/database/schemas/finance/invoice.schema.js
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
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);
|
||||||
124
src/database/schemas/finance/payment.schema.js
Normal file
124
src/database/schemas/finance/payment.schema.js
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
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);
|
||||||
25
src/database/schemas/finance/paymentpolicy.schema.js
Normal file
25
src/database/schemas/finance/paymentpolicy.schema.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
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);
|
||||||
69
src/database/schemas/finance/taxrecord.schema.js
Normal file
69
src/database/schemas/finance/taxrecord.schema.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
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);
|
||||||
136
src/database/schemas/inventory/filamentstock.schema.js
Normal file
136
src/database/schemas/inventory/filamentstock.schema.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
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' },
|
||||||
|
progress: { type: Number, required: false },
|
||||||
|
},
|
||||||
|
postedAt: { type: Date, required: false },
|
||||||
|
startingWeight: {
|
||||||
|
net: { type: Number, required: true },
|
||||||
|
gross: { type: Number, required: true },
|
||||||
|
},
|
||||||
|
currentWeight: {
|
||||||
|
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 () {
|
||||||
|
const results = await aggregateRollups({
|
||||||
|
model: this,
|
||||||
|
rollupConfigs: rollupConfigs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
filamentStockSchema.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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure JSON serialization to include virtuals
|
||||||
|
filamentStockSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
// Create and export the model
|
||||||
|
export const filamentStockModel = mongoose.model('filamentStock', filamentStockSchema);
|
||||||
319
src/database/schemas/inventory/orderitem.schema.js
Normal file
319
src/database/schemas/inventory/orderitem.schema.js
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
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 { 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 },
|
||||||
|
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') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderId = orderItem.order?._id || orderItem.order;
|
||||||
|
if (!orderId) {
|
||||||
|
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: {
|
||||||
|
order: new mongoose.Types.ObjectId(orderId),
|
||||||
|
orderType: orderItem.orderType,
|
||||||
|
},
|
||||||
|
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' }],
|
||||||
|
},
|
||||||
|
...rollupConfigs,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const totals = rollupResults.orderTotals || {};
|
||||||
|
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,
|
||||||
|
id: orderId,
|
||||||
|
updateData: updateData,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add virtual id getter
|
||||||
|
orderItemSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure JSON serialization to include virtuals
|
||||||
|
orderItemSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
// Create and export the model
|
||||||
|
export const orderItemModel = mongoose.model('orderItem', orderItemSchema);
|
||||||
134
src/database/schemas/inventory/partstock.schema.js
Normal file
134
src/database/schemas/inventory/partstock.schema.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define the main partStock schema
|
||||||
|
const partStockSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
state: {
|
||||||
|
type: { type: String, required: true, default: 'draft' },
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ 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 () {
|
||||||
|
const results = await aggregateRollups({
|
||||||
|
model: this,
|
||||||
|
rollupConfigs: rollupConfigs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
partStockSchema.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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure JSON serialization to include virtuals
|
||||||
|
partStockSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
// Create and export the model
|
||||||
|
export const partStockModel = mongoose.model('partStock', partStockSchema);
|
||||||
218
src/database/schemas/inventory/productstock.schema.js
Normal file
218
src/database/schemas/inventory/productstock.schema.js
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
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);
|
||||||
123
src/database/schemas/inventory/purchaseorder.schema.js
Normal file
123
src/database/schemas/inventory/purchaseorder.schema.js
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
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 },
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure JSON serialization to include virtuals
|
||||||
|
purchaseOrderSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
// Create and export the model
|
||||||
|
export const purchaseOrderModel = mongoose.model('purchaseOrder', purchaseOrderSchema);
|
||||||
174
src/database/schemas/inventory/shipment.schema.js
Normal file
174
src/database/schemas/inventory/shipment.schema.js
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
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 shipmentSchema = new Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
orderType: { type: String, required: true },
|
||||||
|
order: { type: Schema.Types.ObjectId, refPath: 'orderType', 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 },
|
||||||
|
state: {
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ 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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure JSON serialization to include virtuals
|
||||||
|
shipmentSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
// Create and export the model
|
||||||
|
export const shipmentModel = mongoose.model('shipment', shipmentSchema);
|
||||||
311
src/database/schemas/inventory/stockaudit.schema.js
Normal file
311
src/database/schemas/inventory/stockaudit.schema.js
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
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 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',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
stockLocation: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: 'stockLocation',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
postedAt: { type: Date, required: false },
|
||||||
|
auditLines: { type: [stockAuditLineSchema], default: [] },
|
||||||
|
},
|
||||||
|
{ 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
stockAuditSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
stockAuditSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
316
src/database/schemas/inventory/stockevent.schema.js
Normal file
316
src/database/schemas/inventory/stockevent.schema.js
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
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()() },
|
||||||
|
value: { type: Number, required: true },
|
||||||
|
unit: { type: String, required: true },
|
||||||
|
parent: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
refPath: 'parentType',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
parentType: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
enum: ['filamentStock', 'partStock', 'productStock'], // Add other models as needed
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
refPath: 'ownerType',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
ownerType: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
enum: ['user', 'subJob', 'stockAudit', 'stockTransfer', 'productStock'],
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure JSON serialization to include virtuals
|
||||||
|
stockEventSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
// Create and export the model
|
||||||
|
export const stockEventModel = mongoose.model('stockEvent', stockEventSchema);
|
||||||
41
src/database/schemas/inventory/stocklocation.schema.js
Normal file
41
src/database/schemas/inventory/stocklocation.schema.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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);
|
||||||
78
src/database/schemas/inventory/stocktransfer.schema.js
Normal file
78
src/database/schemas/inventory/stocktransfer.schema.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
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);
|
||||||
24
src/database/schemas/management/apppassword.schema.js
Normal file
24
src/database/schemas/management/apppassword.schema.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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);
|
||||||
50
src/database/schemas/management/auditlog.schema.js
Normal file
50
src/database/schemas/management/auditlog.schema.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const auditLogSchema = new Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
changes: {
|
||||||
|
old: { type: Object, required: false },
|
||||||
|
new: { type: Object, required: false },
|
||||||
|
},
|
||||||
|
operation: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
parent: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
refPath: 'parentType',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
parentType: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
refPath: 'ownerType',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
ownerType: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
enum: ['user', 'printer', 'host', 'marketplace'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
auditLogSchema.index({ operation: 'text', parentType: 'text', ownerType: 'text' });
|
||||||
|
|
||||||
|
// Add virtual id getter
|
||||||
|
auditLogSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure JSON serialization to include virtuals
|
||||||
|
auditLogSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
// Create and export the model
|
||||||
|
export const auditLogModel = mongoose.model('auditLog', auditLogSchema);
|
||||||
32
src/database/schemas/management/courier.schema.js
Normal file
32
src/database/schemas/management/courier.schema.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
|
||||||
|
const courierSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: { required: true, type: String },
|
||||||
|
website: { required: false, type: String },
|
||||||
|
email: { required: false, type: String },
|
||||||
|
phone: { required: false, type: String },
|
||||||
|
contact: { required: false, type: String },
|
||||||
|
country: { required: false, type: String },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
courierSchema.index({
|
||||||
|
name: 'text',
|
||||||
|
website: 'text',
|
||||||
|
email: 'text',
|
||||||
|
phone: 'text',
|
||||||
|
contact: 'text',
|
||||||
|
country: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
courierSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
courierSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const courierModel = mongoose.model('courier', courierSchema);
|
||||||
70
src/database/schemas/management/courierservice.schema.js
Normal file
70
src/database/schemas/management/courierservice.schema.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
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()() },
|
||||||
|
name: { required: true, type: String },
|
||||||
|
courier: { type: Schema.Types.ObjectId, ref: 'courier', required: true },
|
||||||
|
active: { required: true, type: Boolean },
|
||||||
|
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);
|
||||||
58
src/database/schemas/management/documentjob.schema.js
Normal file
58
src/database/schemas/management/documentjob.schema.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const documentJobSchema = new Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
objectType: { type: String, required: false },
|
||||||
|
object: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
refPath: 'objectType',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
type: { type: String, required: true, default: 'queued' },
|
||||||
|
progress: { type: Number, required: false },
|
||||||
|
message: { type: String, required: false },
|
||||||
|
},
|
||||||
|
documentTemplate: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: 'documentTemplate',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
documentPrinter: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: 'documentPrinter',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
quantity: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
default: 1,
|
||||||
|
min: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
documentJobSchema.index({ name: 'text', objectType: 'text' });
|
||||||
|
|
||||||
|
// Add virtual id getter
|
||||||
|
documentJobSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure JSON serialization to include virtuals
|
||||||
|
documentJobSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const documentJobModel = mongoose.model('documentJob', documentJobSchema);
|
||||||
59
src/database/schemas/management/documentprinter.schema.js
Normal file
59
src/database/schemas/management/documentprinter.schema.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const connectionSchema = new Schema(
|
||||||
|
{
|
||||||
|
interface: { type: String, required: true },
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const documentPrinterSchema = new Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
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 },
|
||||||
|
state: {
|
||||||
|
type: { type: String, required: true, default: 'offline' },
|
||||||
|
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 },
|
||||||
|
queue: [{ type: Schema.Types.ObjectId, ref: 'documentJob', required: false }],
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
documentPrinterSchema.index({ name: 'text', tags: 'text' });
|
||||||
|
|
||||||
|
// Add virtual id getter
|
||||||
|
documentPrinterSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure JSON serialization to include virtuals
|
||||||
|
documentPrinterSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const documentPrinterModel = mongoose.model('documentPrinter', documentPrinterSchema);
|
||||||
67
src/database/schemas/management/documentsize.schema.js
Normal file
67
src/database/schemas/management/documentsize.schema.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const documentSizeSchema = new Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
infiniteHeight: {
|
||||||
|
type: Boolean,
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure JSON serialization to include virtuals
|
||||||
|
documentSizeSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const documentSizeModel = mongoose.model('documentSize', documentSizeSchema);
|
||||||
166
src/database/schemas/management/documenttemplate.schema.js
Normal file
166
src/database/schemas/management/documenttemplate.schema.js
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
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()() },
|
||||||
|
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: 'documentTemplate',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
documentSize: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: 'documentSize',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
documentPrinters: [
|
||||||
|
{
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: 'documentPrinter',
|
||||||
|
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',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
documentTemplateSchema.index({ name: 'text', tags: 'text', objectType: 'text' });
|
||||||
|
|
||||||
|
// Add virtual id getter
|
||||||
|
documentTemplateSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
59
src/database/schemas/management/filament.schema.js
Normal file
59
src/database/schemas/management/filament.schema.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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 },
|
||||||
|
diameter: { required: true, type: Number },
|
||||||
|
density: { required: true, type: Number },
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
82
src/database/schemas/management/filamentsku.schema.js
Normal file
82
src/database/schemas/management/filamentsku.schema.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
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);
|
||||||
25
src/database/schemas/management/file.schema.js
Normal file
25
src/database/schemas/management/file.schema.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
|
||||||
|
const fileSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: { required: true, type: String },
|
||||||
|
type: { required: true, type: String },
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
fileSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const fileModel = mongoose.model('file', fileSchema);
|
||||||
64
src/database/schemas/management/host.schema.js
Normal file
64
src/database/schemas/management/host.schema.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
|
||||||
|
// Define the device schema
|
||||||
|
const deviceInfoSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
os: {
|
||||||
|
platform: { type: String },
|
||||||
|
type: { type: String },
|
||||||
|
release: { type: String },
|
||||||
|
arch: { type: String },
|
||||||
|
hostname: { type: String },
|
||||||
|
},
|
||||||
|
cpu: {
|
||||||
|
cores: { type: Number },
|
||||||
|
model: { type: String },
|
||||||
|
speedMHz: { type: Number },
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
uid: { type: Number },
|
||||||
|
gid: { type: Number },
|
||||||
|
username: { type: String },
|
||||||
|
homedir: { type: String },
|
||||||
|
shell: { type: String },
|
||||||
|
},
|
||||||
|
process: {
|
||||||
|
nodeVersion: { type: String },
|
||||||
|
execPath: { type: String },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const hostSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: { required: true, type: String },
|
||||||
|
tags: [{ required: false, type: String }],
|
||||||
|
online: { required: true, type: Boolean, default: false },
|
||||||
|
state: {
|
||||||
|
type: { type: String, required: true, default: 'offline' },
|
||||||
|
message: { type: String, required: false },
|
||||||
|
percent: { type: Number, required: false },
|
||||||
|
},
|
||||||
|
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 },
|
||||||
|
files: [{ type: mongoose.Schema.Types.ObjectId, ref: 'file' }],
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
hostSchema.index({ name: 'text', tags: 'text' });
|
||||||
|
|
||||||
|
hostSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
hostSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const hostModel = mongoose.model('host', hostSchema);
|
||||||
22
src/database/schemas/management/material.schema.js
Normal file
22
src/database/schemas/management/material.schema.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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' });
|
||||||
|
|
||||||
|
materialSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
materialSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const materialModel = mongoose.model('material', materialSchema);
|
||||||
36
src/database/schemas/management/notetype.schema.js
Normal file
36
src/database/schemas/management/notetype.schema.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const noteTypeSchema = new Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
noteTypeSchema.index({ name: 'text' });
|
||||||
|
|
||||||
|
// Add virtual id getter
|
||||||
|
noteTypeSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure JSON serialization to include virtuals
|
||||||
|
noteTypeSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const noteTypeModel = mongoose.model('noteType', noteTypeSchema);
|
||||||
68
src/database/schemas/management/part.schema.js
Normal file
68
src/database/schemas/management/part.schema.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
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
|
||||||
|
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 },
|
||||||
|
priceMode: { type: String, default: 'margin' },
|
||||||
|
margin: { type: Number, required: false },
|
||||||
|
amount: { type: Number, required: false },
|
||||||
|
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||||
|
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||||
|
costWithTax: { type: Number, required: false },
|
||||||
|
priceWithTax: { type: Number, required: false },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
partSchema.index({ name: 'text' });
|
||||||
|
|
||||||
|
// Add virtual id getter
|
||||||
|
partSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
112
src/database/schemas/management/partsku.schema.js
Normal file
112
src/database/schemas/management/partsku.schema.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
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);
|
||||||
62
src/database/schemas/management/permissionsetting.schema.js
Normal file
62
src/database/schemas/management/permissionsetting.schema.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
87
src/database/schemas/management/product.schema.js
Normal file
87
src/database/schemas/management/product.schema.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
import { taxRateModel } from '../management/taxrate.schema.js';
|
||||||
|
import { editObject, getObject } from '../../database.js';
|
||||||
|
import { amountWithTax, resolveTaxRate } from '../../tax.js';
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
// Define the main product schema
|
||||||
|
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 },
|
||||||
|
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||||
|
costWithTax: { type: Number, required: false },
|
||||||
|
priceWithTax: { type: Number, required: false },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
productSchema.index({ name: 'text', tags: 'text', version: 'text' });
|
||||||
|
|
||||||
|
// Add virtual id getter
|
||||||
|
productSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
30
src/database/schemas/management/productcategory.schema.js
Normal file
30
src/database/schemas/management/productcategory.schema.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
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);
|
||||||
119
src/database/schemas/management/productsku.schema.js
Normal file
119
src/database/schemas/management/productsku.schema.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
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);
|
||||||
108
src/database/schemas/management/stockauditlevel.schema.js
Normal file
108
src/database/schemas/management/stockauditlevel.schema.js
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
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);
|
||||||
31
src/database/schemas/management/taxrate.schema.js
Normal file
31
src/database/schemas/management/taxrate.schema.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
import { marketplaceSyncMappingSchema } from '../sales/marketplaceMapping.schema.js';
|
||||||
|
|
||||||
|
const taxRateSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: { required: true, type: String },
|
||||||
|
rate: { required: true, type: Number },
|
||||||
|
rateType: { required: true, type: String, enum: ['percentage', 'fixed'] },
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
taxRateSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const taxRateModel = mongoose.model('taxRate', taxRateSchema);
|
||||||
64
src/database/schemas/management/user.schema.js
Normal file
64
src/database/schemas/management/user.schema.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
username: { required: true, type: String },
|
||||||
|
name: { required: true, type: String },
|
||||||
|
firstName: { required: false, type: String },
|
||||||
|
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);
|
||||||
74
src/database/schemas/management/usergroup.schema.js
Normal file
74
src/database/schemas/management/usergroup.schema.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
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);
|
||||||
37
src/database/schemas/management/vendor.schema.js
Normal file
37
src/database/schemas/management/vendor.schema.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.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 vendorSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: { required: true, type: String },
|
||||||
|
website: { required: false, type: String },
|
||||||
|
email: { required: false, type: String },
|
||||||
|
phone: { required: false, type: String },
|
||||||
|
contact: { required: false, type: String },
|
||||||
|
country: { required: false, type: String },
|
||||||
|
active: { required: true, type: Boolean, default: true },
|
||||||
|
address: { required: false, type: addressSchema },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
vendorSchema.index({ name: 'text', website: 'text', email: 'text', phone: 'text', contact: 'text', country: 'text' });
|
||||||
|
|
||||||
|
vendorSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
vendorSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const vendorModel = mongoose.model('vendor', vendorSchema);
|
||||||
48
src/database/schemas/misc/note.schema.js
Normal file
48
src/database/schemas/misc/note.schema.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const noteSchema = new mongoose.Schema({
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
parent: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
refPath: 'parentType',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
parentType: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
noteType: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: 'noteType',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
required: true,
|
||||||
|
default: Date.now,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Date,
|
||||||
|
required: true,
|
||||||
|
default: Date.now,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: 'user',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
noteSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
noteSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const noteModel = mongoose.model('note', noteSchema);
|
||||||
51
src/database/schemas/misc/notification.schema.js
Normal file
51
src/database/schemas/misc/notification.schema.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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);
|
||||||
58
src/database/schemas/misc/objectview.schema.js
Normal file
58
src/database/schemas/misc/objectview.schema.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
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);
|
||||||
44
src/database/schemas/misc/usernotifier.schema.js
Normal file
44
src/database/schemas/misc/usernotifier.schema.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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);
|
||||||
47
src/database/schemas/misc/usersettings.schema.js
Normal file
47
src/database/schemas/misc/usersettings.schema.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
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);
|
||||||
132
src/database/schemas/models.js
Normal file
132
src/database/schemas/models.js
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
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 { 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';
|
||||||
|
|
||||||
|
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.
|
||||||
|
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'),
|
||||||
|
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'),
|
||||||
|
};
|
||||||
165
src/database/schemas/production/filamentprofile.schema.js
Normal file
165
src/database/schemas/production/filamentprofile.schema.js
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
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);
|
||||||
49
src/database/schemas/production/gcodefile.schema.js
Normal file
49
src/database/schemas/production/gcodefile.schema.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const gcodeFileSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: { required: true, type: String },
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
{ 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.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
gcodeFileSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const gcodeFileModel = mongoose.model('gcodeFile', gcodeFileSchema);
|
||||||
190
src/database/schemas/production/job.schema.js
Normal file
190
src/database/schemas/production/job.schema.js
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const jobSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
state: {
|
||||||
|
type: { required: true, type: String },
|
||||||
|
progress: { type: Number, required: false },
|
||||||
|
},
|
||||||
|
printers: [{ type: Schema.Types.ObjectId, ref: 'printer', required: false }],
|
||||||
|
createdAt: { required: true, type: Date },
|
||||||
|
updatedAt: { required: true, type: Date },
|
||||||
|
startedAt: { required: false, type: Date, default: null },
|
||||||
|
finishedAt: { required: false, type: Date, default: null },
|
||||||
|
gcodeFile: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: 'gcodeFile',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
quantity: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
default: 1,
|
||||||
|
min: 1,
|
||||||
|
},
|
||||||
|
subJobs: [{ type: Schema.Types.ObjectId, ref: 'subJob', required: false }],
|
||||||
|
notes: [{ type: Schema.Types.ObjectId, ref: 'note', required: false }],
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
jobSchema.index({ 'state.type': 'text' });
|
||||||
|
|
||||||
|
const rollupConfigs = [
|
||||||
|
{
|
||||||
|
name: 'queued',
|
||||||
|
filter: { 'state.type': 'queued' },
|
||||||
|
rollups: [{ name: 'queued', property: 'state.type', operation: 'count' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'printing',
|
||||||
|
filter: { 'state.type': 'printing' },
|
||||||
|
rollups: [{ name: 'printing', property: 'state.type', operation: 'count' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'draft',
|
||||||
|
filter: { 'state.type': 'draft' },
|
||||||
|
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'complete',
|
||||||
|
filter: { 'state.type': 'complete' },
|
||||||
|
rollups: [{ name: 'complete', property: 'state.type', operation: 'count' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'failed',
|
||||||
|
filter: { 'state.type': 'failed' },
|
||||||
|
rollups: [{ name: 'failed', property: 'state.type', operation: 'count' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
jobSchema.statics.stats = async function () {
|
||||||
|
const results = await aggregateRollups({
|
||||||
|
model: this,
|
||||||
|
rollupConfigs: rollupConfigs,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform the results to match the expected format
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
jobSchema.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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
jobSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const jobModel = mongoose.model('job', jobSchema);
|
||||||
158
src/database/schemas/production/printer.schema.js
Normal file
158
src/database/schemas/production/printer.schema.js
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||||
|
|
||||||
|
// Define the moonraker connection schema
|
||||||
|
const moonrakerSchema = new Schema(
|
||||||
|
{
|
||||||
|
host: { type: String, required: true },
|
||||||
|
port: { type: Number, required: true },
|
||||||
|
protocol: { type: String, required: true },
|
||||||
|
apiKey: { type: String, default: null, required: false },
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
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 },
|
||||||
|
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(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: { type: String, required: true },
|
||||||
|
online: { type: Boolean, required: true, default: false },
|
||||||
|
active: { type: Boolean, required: true, default: true },
|
||||||
|
state: {
|
||||||
|
type: { type: String, required: true, default: 'offline' },
|
||||||
|
message: { type: String, required: false },
|
||||||
|
progress: { type: Number, required: false },
|
||||||
|
},
|
||||||
|
connectedAt: { type: Date, default: null },
|
||||||
|
loadedFilament: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: 'filament',
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
moonraker: { type: moonrakerSchema, required: true },
|
||||||
|
tags: [{ type: String }],
|
||||||
|
firmware: { type: String },
|
||||||
|
currentJob: { type: Schema.Types.ObjectId, ref: 'job' },
|
||||||
|
currentSubJob: { type: Schema.Types.ObjectId, ref: 'subJob' },
|
||||||
|
currentFilamentStock: { type: Schema.Types.ObjectId, ref: 'filamentStock' },
|
||||||
|
queue: [{ type: Schema.Types.ObjectId, ref: 'subJob' }],
|
||||||
|
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',
|
||||||
|
filter: { 'state.type': 'standby' },
|
||||||
|
rollups: [{ name: 'standby', property: 'state.type', operation: 'count' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'complete',
|
||||||
|
filter: { 'state.type': 'complete' },
|
||||||
|
rollups: [{ name: 'complete', property: 'state.type', operation: 'count' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'printing',
|
||||||
|
filter: { 'state.type': 'printing' },
|
||||||
|
rollups: [{ name: 'printing', property: 'state.type', operation: 'count' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'error',
|
||||||
|
filter: { 'state.type': 'error' },
|
||||||
|
rollups: [{ name: 'error', property: 'state.type', operation: 'count' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'offline',
|
||||||
|
filter: { 'state.type': 'offline' },
|
||||||
|
rollups: [{ name: 'offline', property: 'state.type', operation: 'count' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
printerSchema.statics.stats = async function () {
|
||||||
|
const results = await aggregateRollups({
|
||||||
|
model: this,
|
||||||
|
baseFilter: { active: true },
|
||||||
|
rollupConfigs: rollupConfigs,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform the results to match the expected format
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
printerSchema.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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure JSON serialization to include virtuals
|
||||||
|
printerSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
// Create and export the model
|
||||||
|
export const printerModel = mongoose.model('printer', printerSchema);
|
||||||
111
src/database/schemas/production/printerprofile.schema.js
Normal file
111
src/database/schemas/production/printerprofile.schema.js
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
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);
|
||||||
54
src/database/schemas/production/subjob.schema.js
Normal file
54
src/database/schemas/production/subjob.schema.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const subJobSchema = new mongoose.Schema({
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
printer: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: 'printer',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
job: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: 'job',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
moonrakerJobId: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
gcodeFile: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: 'gcodeFile',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
type: { required: true, type: String },
|
||||||
|
progress: { required: false, type: Number },
|
||||||
|
},
|
||||||
|
number: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
},
|
||||||
|
startedAt: { required: false, type: Date, default: null },
|
||||||
|
finishedAt: { required: false, type: Date, default: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
subJobSchema.index({ moonrakerJobId: 'text', 'state.type': 'text' });
|
||||||
|
|
||||||
|
subJobSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
subJobSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const subJobModel = mongoose.model('subJob', subJobSchema);
|
||||||
73
src/database/schemas/sales/client.schema.js
Normal file
73
src/database/schemas/sales/client.schema.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
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);
|
||||||
31
src/database/schemas/sales/fulfillmentpolicy.schema.js
Normal file
31
src/database/schemas/sales/fulfillmentpolicy.schema.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
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);
|
||||||
260
src/database/schemas/sales/listing.schema.js
Normal file
260
src/database/schemas/sales/listing.schema.js
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
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);
|
||||||
171
src/database/schemas/sales/listingvarient.schema.js
Normal file
171
src/database/schemas/sales/listingvarient.schema.js
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
125
src/database/schemas/sales/marketplace.schema.js
Normal file
125
src/database/schemas/sales/marketplace.schema.js
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
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);
|
||||||
24
src/database/schemas/sales/marketplaceMapping.schema.js
Normal file
24
src/database/schemas/sales/marketplaceMapping.schema.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/database/schemas/sales/marketplaceevent.schema.js
Normal file
31
src/database/schemas/sales/marketplaceevent.schema.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
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);
|
||||||
46
src/database/schemas/sales/returnpolicy.schema.js
Normal file
46
src/database/schemas/sales/returnpolicy.schema.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
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);
|
||||||
229
src/database/schemas/sales/salesorder.schema.js
Normal file
229
src/database/schemas/sales/salesorder.schema.js
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
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);
|
||||||
54
src/database/tax.js
Normal file
54
src/database/tax.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Tax calculation helpers mirroring farmcontrol-ui model value functions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function isPopulatedTaxRate(taxRate) {
|
||||||
|
return taxRate != null && taxRate.rateType != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveTaxRate(taxRateRef, getObject, taxRateModel) {
|
||||||
|
if (!taxRateRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (isPopulatedTaxRate(taxRateRef)) {
|
||||||
|
return taxRateRef;
|
||||||
|
}
|
||||||
|
const id = taxRateRef._id ?? taxRateRef;
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof taxRateRef === 'object' &&
|
||||||
|
taxRateRef._id &&
|
||||||
|
Object.keys(taxRateRef).length === 1
|
||||||
|
) {
|
||||||
|
return await getObject({ model: taxRateModel, id, cached: true });
|
||||||
|
}
|
||||||
|
return await getObject({ model: taxRateModel, id, cached: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function amountWithTax(amount, taxRate) {
|
||||||
|
const base = Number.parseFloat(amount) || 0;
|
||||||
|
if (!base) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!taxRate) {
|
||||||
|
return Number.parseFloat(base.toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = Number.parseFloat(taxRate.rate) || 0;
|
||||||
|
if (taxRate.rateType === 'percentage') {
|
||||||
|
return Number.parseFloat((base * (1 + rate / 100)).toFixed(2));
|
||||||
|
}
|
||||||
|
if (taxRate.rateType === 'amount' || taxRate.rateType === 'fixed') {
|
||||||
|
return Number.parseFloat((base + rate).toFixed(2));
|
||||||
|
}
|
||||||
|
return Number.parseFloat(base.toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveMarginPrice({ priceMode, price, cost, margin }) {
|
||||||
|
if (priceMode === 'margin' && margin != null && cost != null) {
|
||||||
|
return cost * (1 + margin / 100);
|
||||||
|
}
|
||||||
|
return price;
|
||||||
|
}
|
||||||
755
src/database/utils.js
Normal file
755
src/database/utils.js
Normal file
@ -0,0 +1,755 @@
|
|||||||
|
import { ObjectId } from 'mongodb';
|
||||||
|
import { auditLogModel } from './schemas/management/auditlog.schema.js';
|
||||||
|
import { notificationModel } from './schemas/misc/notification.schema.js';
|
||||||
|
import { userNotifierModel } from './schemas/misc/usernotifier.schema.js';
|
||||||
|
import { natsServer } from './nats.js';
|
||||||
|
|
||||||
|
import { customAlphabet } from 'nanoid';
|
||||||
|
|
||||||
|
const NOTIFICATION_EXCLUDED_MODELS = [
|
||||||
|
'notification',
|
||||||
|
'userNotifier',
|
||||||
|
'objectView',
|
||||||
|
'auditLog'
|
||||||
|
];
|
||||||
|
const AUDIT_EXCLUDED_MODELS = [
|
||||||
|
'notification',
|
||||||
|
'userNotifier',
|
||||||
|
'objectView',
|
||||||
|
'marketplaceEvent'
|
||||||
|
];
|
||||||
|
const AUDIT_EXCLUDED_CHANGES = ['state.message'];
|
||||||
|
const SENSITIVE_KEYS = ['secret'];
|
||||||
|
|
||||||
|
function omitSensitive(obj) {
|
||||||
|
if (obj == null || typeof obj !== 'object') return obj;
|
||||||
|
if (Array.isArray(obj)) return obj.map(omitSensitive);
|
||||||
|
const result = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (SENSITIVE_KEYS.includes(key)) continue;
|
||||||
|
result[key] = omitSensitive(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function omitPath(obj, path) {
|
||||||
|
if (obj == null || typeof obj !== 'object') return obj;
|
||||||
|
const keys = path.split('.');
|
||||||
|
if (keys.length === 1) {
|
||||||
|
const result = { ...obj };
|
||||||
|
delete result[keys[0]];
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const [first, ...rest] = keys;
|
||||||
|
if (
|
||||||
|
obj[first] == null ||
|
||||||
|
typeof obj[first] !== 'object' ||
|
||||||
|
Array.isArray(obj[first])
|
||||||
|
) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
const nested = obj[first];
|
||||||
|
const leafKey = rest[rest.length - 1];
|
||||||
|
const result = { ...obj };
|
||||||
|
|
||||||
|
if (rest.length === 1) {
|
||||||
|
const nestedKeys = Object.keys(nested);
|
||||||
|
if (nestedKeys.length === 1 && nestedKeys[0] === leafKey) {
|
||||||
|
delete result[first];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedKeys = Object.keys(nested);
|
||||||
|
if (
|
||||||
|
nestedKeys.length > 1 ||
|
||||||
|
(nestedKeys.length === 1 && nestedKeys[0] !== rest[0])
|
||||||
|
) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedNested = omitPath({ ...nested }, rest.join('.'));
|
||||||
|
if (updatedNested == null || Object.keys(updatedNested).length === 0) {
|
||||||
|
delete result[first];
|
||||||
|
} else {
|
||||||
|
result[first] = updatedNested;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function omitExcludedChanges(obj) {
|
||||||
|
if (obj == null || typeof obj !== 'object') return obj;
|
||||||
|
return AUDIT_EXCLUDED_CHANGES.reduce((acc, path) => omitPath(acc, path), {
|
||||||
|
...obj
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let modelsCache = null;
|
||||||
|
|
||||||
|
async function getModelEntryByType(parentType) {
|
||||||
|
if (!modelsCache) {
|
||||||
|
modelsCache = (await import('./schemas/models.js')).models;
|
||||||
|
}
|
||||||
|
return Object.values(modelsCache).find(
|
||||||
|
entry => entry.type === parentType || entry.model?.modelName === parentType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationUserFromOwner(owner, ownerType) {
|
||||||
|
if (!owner) return null;
|
||||||
|
if (ownerType === 'user') {
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
if (ownerType === 'host' || ownerType === 'marketplace') {
|
||||||
|
return {
|
||||||
|
_id: owner._id,
|
||||||
|
firstName: owner.name ?? 'unknown',
|
||||||
|
lastName: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
_id: owner._id,
|
||||||
|
firstName: owner.name ?? owner.firstName ?? 'unknown',
|
||||||
|
lastName: owner.lastName ?? ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
export const generateId = () => {
|
||||||
|
// 10 characters
|
||||||
|
return customAlphabet(ALPHABET, 12);
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseFilter(property, value) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
var trimmed = value.trim();
|
||||||
|
if (trimmed.charAt(3) == ':') {
|
||||||
|
trimmed = value.split(':')[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle booleans
|
||||||
|
if (trimmed.toLowerCase() === 'true') return { [property]: true };
|
||||||
|
if (trimmed.toLowerCase() === 'false') return { [property]: false };
|
||||||
|
|
||||||
|
// Handle ObjectId (24-char hex)
|
||||||
|
|
||||||
|
if (/^[a-f\d]{24}$/i.test(trimmed) && trimmed.length >= 24) {
|
||||||
|
return { [property]: new ObjectId(trimmed) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle numbers
|
||||||
|
if (!isNaN(trimmed)) {
|
||||||
|
return { [property]: parseFloat(trimmed) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to case-insensitive regex for non-numeric strings
|
||||||
|
return {
|
||||||
|
[property]: {
|
||||||
|
$regex: trimmed,
|
||||||
|
$options: 'i'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle actual booleans, numbers, objects, etc.
|
||||||
|
return { [property]: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToCamelCase(obj) {
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
for (const key in obj) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||||
|
const value = obj[key];
|
||||||
|
|
||||||
|
// Convert the key to camelCase
|
||||||
|
let camelKey = key
|
||||||
|
// First handle special cases with spaces, brackets and other characters
|
||||||
|
.replace(/\s*\[.*?\]\s*/g, '') // Remove brackets and their contents
|
||||||
|
.replace(/\s+/g, ' ') // Normalize spaces
|
||||||
|
.trim()
|
||||||
|
// Split by common separators (space, underscore, hyphen)
|
||||||
|
.split(/[\s_-]/)
|
||||||
|
// Convert to camelCase
|
||||||
|
.map((word, index) => {
|
||||||
|
// Remove any non-alphanumeric characters
|
||||||
|
word = word.replace(/[^a-zA-Z0-9]/g, '');
|
||||||
|
|
||||||
|
// Lowercase first word, uppercase others
|
||||||
|
return index === 0
|
||||||
|
? word.toLowerCase()
|
||||||
|
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// Handle values that are objects recursively
|
||||||
|
if (
|
||||||
|
value !== null &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
!Array.isArray(value)
|
||||||
|
) {
|
||||||
|
result[camelKey] = convertToCamelCase(value);
|
||||||
|
} else {
|
||||||
|
result[camelKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractConfigBlock(fileContent, useCamelCase = true) {
|
||||||
|
const configObject = {};
|
||||||
|
|
||||||
|
// Extract header information
|
||||||
|
const headerBlockRegex =
|
||||||
|
/; HEADER_BLOCK_START([\s\S]*?)(?:; HEADER_BLOCK_END|$)/;
|
||||||
|
const headerBlockMatch = fileContent.match(headerBlockRegex);
|
||||||
|
if (headerBlockMatch && headerBlockMatch[1]) {
|
||||||
|
const headerLines = headerBlockMatch[1].split('\n');
|
||||||
|
headerLines.forEach(line => {
|
||||||
|
const keyValueRegex = /^\s*;\s*([^:]+?):\s*(.*?)\s*$/;
|
||||||
|
const simpleValueRegex = /^\s*;\s*(.*?)\s*$/;
|
||||||
|
|
||||||
|
// Try key-value format first
|
||||||
|
let match = line.match(keyValueRegex);
|
||||||
|
if (match) {
|
||||||
|
const key = match[1].trim();
|
||||||
|
let value = match[2].trim();
|
||||||
|
|
||||||
|
// Try to convert value to appropriate type
|
||||||
|
if (!isNaN(value) && value !== '') {
|
||||||
|
value = Number(value);
|
||||||
|
}
|
||||||
|
configObject[key] = value;
|
||||||
|
} else {
|
||||||
|
// Try the simple format like "; generated by OrcaSlicer 2.1.1 on 2025-04-28 at 13:30:11"
|
||||||
|
match = line.match(simpleValueRegex);
|
||||||
|
if (match && match[1] && !match[1].includes('HEADER_BLOCK')) {
|
||||||
|
const text = match[1].trim();
|
||||||
|
|
||||||
|
// Extract slicer info
|
||||||
|
const slicerMatch = text.match(
|
||||||
|
/generated by (.*?) on (.*?) at (.*?)$/
|
||||||
|
);
|
||||||
|
if (slicerMatch) {
|
||||||
|
configObject['slicer'] = slicerMatch[1].trim();
|
||||||
|
configObject['date'] = slicerMatch[2].trim();
|
||||||
|
configObject['time'] = slicerMatch[3].trim();
|
||||||
|
} else {
|
||||||
|
// Just add as a general header entry if it doesn't match any specific pattern
|
||||||
|
const key = `header_${Object.keys(configObject).length}`;
|
||||||
|
configObject[key] = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract thumbnail data
|
||||||
|
const thumbnailBlockRegex =
|
||||||
|
/; THUMBNAIL_BLOCK_START([\s\S]*?)(?:; THUMBNAIL_BLOCK_END|$)/;
|
||||||
|
const thumbnailBlockMatch = fileContent.match(thumbnailBlockRegex);
|
||||||
|
if (thumbnailBlockMatch && thumbnailBlockMatch[1]) {
|
||||||
|
const thumbnailLines = thumbnailBlockMatch[1].split('\n');
|
||||||
|
let base64Data = '';
|
||||||
|
let thumbnailInfo = {};
|
||||||
|
|
||||||
|
thumbnailLines.forEach(line => {
|
||||||
|
// Extract thumbnail dimensions and size from the line "thumbnail begin 640x640 27540"
|
||||||
|
const thumbnailHeaderRegex = /^\s*;\s*thumbnail begin (\d+)x(\d+) (\d+)/;
|
||||||
|
const match = line.match(thumbnailHeaderRegex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
thumbnailInfo.width = parseInt(match[1], 10);
|
||||||
|
thumbnailInfo.height = parseInt(match[2], 10);
|
||||||
|
thumbnailInfo.size = parseInt(match[3], 10);
|
||||||
|
} else if (
|
||||||
|
line.trim().startsWith('; ') &&
|
||||||
|
!line.includes('THUMBNAIL_BLOCK')
|
||||||
|
) {
|
||||||
|
// Collect base64 data (remove the leading semicolon and space and thumbnail end)
|
||||||
|
const dataLine = line.trim().substring(2);
|
||||||
|
if (dataLine && dataLine != 'thumbnail end') {
|
||||||
|
base64Data += dataLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add thumbnail data to config object
|
||||||
|
if (base64Data) {
|
||||||
|
configObject.thumbnail = {
|
||||||
|
data: base64Data,
|
||||||
|
...thumbnailInfo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract CONFIG_BLOCK
|
||||||
|
const configBlockRegex =
|
||||||
|
/; CONFIG_BLOCK_START([\s\S]*?)(?:; CONFIG_BLOCK_END|$)/;
|
||||||
|
const configBlockMatch = fileContent.match(configBlockRegex);
|
||||||
|
if (configBlockMatch && configBlockMatch[1]) {
|
||||||
|
// Extract each config line
|
||||||
|
const configLines = configBlockMatch[1].split('\n');
|
||||||
|
// Process each line
|
||||||
|
configLines.forEach(line => {
|
||||||
|
// Check if the line starts with a semicolon and has an equals sign
|
||||||
|
const configLineRegex = /^\s*;\s*([^=]+?)\s*=\s*(.*?)\s*$/;
|
||||||
|
const match = line.match(configLineRegex);
|
||||||
|
if (match) {
|
||||||
|
const key = match[1].trim();
|
||||||
|
let value = match[2].trim();
|
||||||
|
// Try to convert value to appropriate type
|
||||||
|
if (value === 'true' || value === 'false') {
|
||||||
|
value = value === 'true';
|
||||||
|
} else if (!isNaN(value) && value !== '') {
|
||||||
|
// Check if it's a number (but not a percentage)
|
||||||
|
if (!value.includes('%')) {
|
||||||
|
value = Number(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configObject[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract additional variables that appear after EXECUTABLE_BLOCK_END
|
||||||
|
const additionalVarsRegex =
|
||||||
|
/; EXECUTABLE_BLOCK_(?:START|END)([\s\S]*?)(?:; CONFIG_BLOCK_START|$)/i;
|
||||||
|
const additionalVarsMatch = fileContent.match(additionalVarsRegex);
|
||||||
|
if (additionalVarsMatch && additionalVarsMatch[1]) {
|
||||||
|
const additionalLines = additionalVarsMatch[1].split('\n');
|
||||||
|
additionalLines.forEach(line => {
|
||||||
|
// Match both standard format and the special case for "total filament cost"
|
||||||
|
const varRegex =
|
||||||
|
/^\s*;\s*((?:filament used|filament cost|total filament used|total filament cost|total layers count|estimated printing time)[^=]*?)\s*=\s*(.*?)\s*$/;
|
||||||
|
const match = line.match(varRegex);
|
||||||
|
if (match) {
|
||||||
|
const key = match[1].replace(/\[([^\]]+)\]/g, '$1').trim();
|
||||||
|
let value = match[2].trim();
|
||||||
|
// Clean up values - remove units in brackets and handle special cases
|
||||||
|
if (key.includes('filament used')) {
|
||||||
|
// Extract just the numeric value, ignoring units in brackets
|
||||||
|
const numMatch = value.match(/(\d+\.\d+)/);
|
||||||
|
if (numMatch) {
|
||||||
|
value = parseFloat(numMatch[1]);
|
||||||
|
}
|
||||||
|
} else if (key.includes('filament cost')) {
|
||||||
|
// Extract just the numeric value
|
||||||
|
const numMatch = value.match(/(\d+\.\d+)/);
|
||||||
|
if (numMatch) {
|
||||||
|
value = parseFloat(numMatch[1]);
|
||||||
|
}
|
||||||
|
} else if (key.includes('total layers count')) {
|
||||||
|
value = parseInt(value, 10);
|
||||||
|
} else if (key.includes('estimated printing time')) {
|
||||||
|
// Keep as string but trim any additional whitespace
|
||||||
|
value = value.trim();
|
||||||
|
}
|
||||||
|
configObject[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also extract extrusion width settings
|
||||||
|
const extrusionWidthRegex = /;\s*(.*?)\s*extrusion width\s*=\s*(.*?)mm/g;
|
||||||
|
let extrusionMatch;
|
||||||
|
while ((extrusionMatch = extrusionWidthRegex.exec(fileContent)) !== null) {
|
||||||
|
const settingName = extrusionMatch[1].trim();
|
||||||
|
const settingValue = parseFloat(extrusionMatch[2].trim());
|
||||||
|
configObject[`${settingName} extrusion width`] = settingValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract additional parameters after CONFIG_BLOCK_END if they exist
|
||||||
|
const postConfigParams = /; CONFIG_BLOCK_END\s*\n([\s\S]*?)$/;
|
||||||
|
const postConfigMatch = fileContent.match(postConfigParams);
|
||||||
|
if (postConfigMatch && postConfigMatch[1]) {
|
||||||
|
const postConfigLines = postConfigMatch[1].split('\n');
|
||||||
|
postConfigLines.forEach(line => {
|
||||||
|
// Match lines with format "; parameter_name = value"
|
||||||
|
const paramRegex = /^\s*;\s*([^=]+?)\s*=\s*(.*?)\s*$/;
|
||||||
|
const match = line.match(paramRegex);
|
||||||
|
if (match) {
|
||||||
|
const key = match[1].trim();
|
||||||
|
let value = match[2].trim();
|
||||||
|
|
||||||
|
// Try to convert value to appropriate type
|
||||||
|
if (value === 'true' || value === 'false') {
|
||||||
|
value = value === 'true';
|
||||||
|
} else if (!isNaN(value) && value !== '') {
|
||||||
|
// Check if it's a number (but not a percentage)
|
||||||
|
if (!value.includes('%')) {
|
||||||
|
value = Number(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to config object if not already present
|
||||||
|
if (!configObject[key]) {
|
||||||
|
configObject[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply camelCase conversion if requested
|
||||||
|
return useCamelCase ? convertToCamelCase(configObject) : configObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChangedValues(oldObj, newObj, old = false) {
|
||||||
|
const changes = {};
|
||||||
|
|
||||||
|
const combinedObj = { ...oldObj, ...newObj };
|
||||||
|
|
||||||
|
// Check all keys in the new object
|
||||||
|
for (const key in combinedObj) {
|
||||||
|
// Skip if the key is _id or timestamps
|
||||||
|
if (key === 'createdAt' || key === 'updatedAt' || key === '_id') continue;
|
||||||
|
|
||||||
|
const oldVal = oldObj ? oldObj[key] : undefined;
|
||||||
|
const newVal = newObj ? newObj[key] : undefined;
|
||||||
|
|
||||||
|
// If both values are objects (but not arrays or null), recurse
|
||||||
|
if (
|
||||||
|
oldVal &&
|
||||||
|
newVal &&
|
||||||
|
typeof oldVal === 'object' &&
|
||||||
|
typeof newVal === 'object' &&
|
||||||
|
!Array.isArray(oldVal) &&
|
||||||
|
!Array.isArray(newVal) &&
|
||||||
|
oldVal !== null &&
|
||||||
|
newVal !== null
|
||||||
|
) {
|
||||||
|
if (oldVal?._id || newVal?._id) {
|
||||||
|
if (JSON.stringify(oldVal?._id) !== JSON.stringify(newVal?._id)) {
|
||||||
|
changes[key] = old ? oldVal : newVal;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const nestedChanges = getChangedValues(oldVal, newVal, old);
|
||||||
|
if (Object.keys(nestedChanges).length > 0) {
|
||||||
|
// Exclude progress and currentWeight from nested changes
|
||||||
|
const excludeKeys = ['progress', 'currentWeight', 'net', 'gross'];
|
||||||
|
const filteredChanges = Object.keys(nestedChanges)
|
||||||
|
.filter(nestedKey => !excludeKeys.includes(nestedKey))
|
||||||
|
.reduce((acc, nestedKey) => {
|
||||||
|
acc[nestedKey] = nestedChanges[nestedKey];
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
if (Object.keys(filteredChanges).length > 0) {
|
||||||
|
changes[key] = filteredChanges;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
||||||
|
// If the old value is different from the new value, include it
|
||||||
|
changes[key] = old ? oldVal : newVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newAuditLog(newValue, parentId, parentType, owner, ownerType) {
|
||||||
|
if (AUDIT_EXCLUDED_MODELS.includes(parentType)) return;
|
||||||
|
|
||||||
|
// Filter out createdAt, updatedAt, and sensitive fields from newValue
|
||||||
|
const filteredNewValue = omitSensitive({ ...newValue });
|
||||||
|
delete filteredNewValue.createdAt;
|
||||||
|
delete filteredNewValue.updatedAt;
|
||||||
|
const auditLog = new auditLogModel({
|
||||||
|
changes: {
|
||||||
|
new: filteredNewValue
|
||||||
|
},
|
||||||
|
parent: parentId,
|
||||||
|
parentType,
|
||||||
|
owner: owner._id,
|
||||||
|
ownerType: ownerType,
|
||||||
|
operation: 'new'
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog.save();
|
||||||
|
|
||||||
|
await distributeNew(auditLog, 'auditLog');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editAuditLog(
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
parentId,
|
||||||
|
parentType,
|
||||||
|
owner,
|
||||||
|
ownerType
|
||||||
|
) {
|
||||||
|
if (parentType === 'stockEvent') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filteredOldValue = omitExcludedChanges(oldValue);
|
||||||
|
const filteredNewValue = omitExcludedChanges(newValue);
|
||||||
|
// Get only the changed values
|
||||||
|
const changedOldValues = getChangedValues(
|
||||||
|
filteredOldValue,
|
||||||
|
filteredNewValue,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const changedNewValues = getChangedValues(
|
||||||
|
filteredOldValue,
|
||||||
|
filteredNewValue,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no values changed, don't create an audit log
|
||||||
|
if (
|
||||||
|
Object.keys(changedOldValues).length === 0 ||
|
||||||
|
Object.keys(changedNewValues).length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditLog = new auditLogModel({
|
||||||
|
changes: {
|
||||||
|
old: changedOldValues,
|
||||||
|
new: changedNewValues
|
||||||
|
},
|
||||||
|
parent: parentId,
|
||||||
|
parentType,
|
||||||
|
owner: owner._id,
|
||||||
|
ownerType: ownerType,
|
||||||
|
operation: 'edit'
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog.save();
|
||||||
|
|
||||||
|
await distributeNew(auditLog, 'auditLog');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAuditLog(
|
||||||
|
deleteValue,
|
||||||
|
parentId,
|
||||||
|
parentType,
|
||||||
|
owner,
|
||||||
|
ownerType
|
||||||
|
) {
|
||||||
|
const auditLog = new auditLogModel({
|
||||||
|
changes: {
|
||||||
|
old: deleteValue
|
||||||
|
},
|
||||||
|
parent: parentId,
|
||||||
|
parentType,
|
||||||
|
owner: owner._id,
|
||||||
|
ownerType: ownerType,
|
||||||
|
operation: 'delete'
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog.save();
|
||||||
|
|
||||||
|
await distributeNew(auditLog, 'auditLog');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuditLogs(idOrIds) {
|
||||||
|
if (Array.isArray(idOrIds)) {
|
||||||
|
return auditLogModel.find({ parent: { $in: idOrIds } }).populate('owner');
|
||||||
|
} else {
|
||||||
|
return auditLogModel.find({ parent: idOrIds }).populate('owner');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function distributeUpdate(value, id, type) {
|
||||||
|
await natsServer.publish(`${type}s.${id}.object`, { ...value, _id: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function distributeStats(value, type) {
|
||||||
|
await natsServer.publish(`${type}s.stats`, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function distributeNew(value, type) {
|
||||||
|
await natsServer.publish(`${type}s.new`, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editNotification(
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
parentId,
|
||||||
|
parentType,
|
||||||
|
owner,
|
||||||
|
ownerType
|
||||||
|
) {
|
||||||
|
if (NOTIFICATION_EXCLUDED_MODELS.includes(parentType)) return;
|
||||||
|
|
||||||
|
const modelEntry = await getModelEntryByType(parentType);
|
||||||
|
const user = notificationUserFromOwner(owner, ownerType);
|
||||||
|
const objectName =
|
||||||
|
oldValue?.name ?? newValue?.name ?? modelEntry?.label ?? parentType;
|
||||||
|
const changedOldValues = omitSensitive(
|
||||||
|
getChangedValues(oldValue, newValue, true)
|
||||||
|
);
|
||||||
|
const changedNewValues = omitSensitive(
|
||||||
|
getChangedValues(oldValue, newValue, false)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(changedOldValues).length === 0 ||
|
||||||
|
Object.keys(changedNewValues).length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await notfiyObjectUserNotifiers(
|
||||||
|
parentId,
|
||||||
|
parentType,
|
||||||
|
`${objectName} edited by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
|
||||||
|
`The ${parentType} ${parentId} has been updated.`,
|
||||||
|
'editObject',
|
||||||
|
{
|
||||||
|
old: changedOldValues,
|
||||||
|
new: changedNewValues,
|
||||||
|
objectType: parentType,
|
||||||
|
object: { _id: String(parentId ?? '') },
|
||||||
|
user: {
|
||||||
|
_id: String(user?._id ?? ''),
|
||||||
|
firstName: user?.firstName,
|
||||||
|
lastName: user?.lastName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notfiyObjectUserNotifiers(
|
||||||
|
id,
|
||||||
|
objectType,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type = 'info',
|
||||||
|
metadata
|
||||||
|
) {
|
||||||
|
const userNotifiers = await userNotifierModel
|
||||||
|
.find({ object: id, objectType })
|
||||||
|
.populate('user');
|
||||||
|
for (const userNotifier of userNotifiers) {
|
||||||
|
await createNotification(
|
||||||
|
userNotifier.user._id,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
metadata
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNotification(
|
||||||
|
user,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type = 'info',
|
||||||
|
metadata
|
||||||
|
) {
|
||||||
|
const notification = new notificationModel({
|
||||||
|
user,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
metadata: omitSensitive(metadata ?? {})
|
||||||
|
});
|
||||||
|
await notification.save();
|
||||||
|
const value = notification.toJSON ? notification.toJSON() : notification;
|
||||||
|
await natsServer.publish(`notifications.${user._id ?? user}`, value);
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flatternObjectIds(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) {
|
||||||
|
// If the value is an object with _id, convert to just the _id
|
||||||
|
result[key] = value._id;
|
||||||
|
} else {
|
||||||
|
// Keep primitive values as is
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandObjectIds(input) {
|
||||||
|
// Helper to check if a value is an ObjectId or a 24-char hex string
|
||||||
|
function isObjectId(val) {
|
||||||
|
// Check for MongoDB ObjectId instance
|
||||||
|
if (val instanceof ObjectId) return true;
|
||||||
|
// Check for exactly 24 hex characters (no special characters)
|
||||||
|
if (typeof val === 'string' && /^[a-fA-F\d]{24}$/.test(val)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursive function
|
||||||
|
function expand(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(expand);
|
||||||
|
} else if (
|
||||||
|
value &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
!(value instanceof ObjectId)
|
||||||
|
) {
|
||||||
|
var result = {};
|
||||||
|
for (const [key, val] of Object.entries(value)) {
|
||||||
|
if (key === '_id') {
|
||||||
|
// Do not expand keys that are already named _id
|
||||||
|
result[key] = val;
|
||||||
|
} else if (isObjectId(val)) {
|
||||||
|
result[key] = { _id: val };
|
||||||
|
} else if (Array.isArray(val)) {
|
||||||
|
result[key] = val.map(expand);
|
||||||
|
} else if (val instanceof Date) {
|
||||||
|
result[key] = val;
|
||||||
|
} else if (val && typeof val === 'object') {
|
||||||
|
result[key] = expand(val);
|
||||||
|
} else {
|
||||||
|
result[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} else if (isObjectId(value)) {
|
||||||
|
return { _id: value };
|
||||||
|
} else {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expand(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getFilter } from './filter.js';
|
||||||
|
|
||||||
|
// Converts a properties argument (string or array) to an array of strings
|
||||||
|
function convertPropertiesString(properties) {
|
||||||
|
if (typeof properties === 'string') {
|
||||||
|
return properties.split(',');
|
||||||
|
} else if (!Array.isArray(properties)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
parseFilter,
|
||||||
|
convertToCamelCase,
|
||||||
|
extractConfigBlock,
|
||||||
|
newAuditLog,
|
||||||
|
editAuditLog,
|
||||||
|
deleteAuditLog,
|
||||||
|
getAuditLogs,
|
||||||
|
flatternObjectIds,
|
||||||
|
expandObjectIds,
|
||||||
|
distributeUpdate,
|
||||||
|
distributeNew,
|
||||||
|
distributeStats,
|
||||||
|
editNotification,
|
||||||
|
notfiyObjectUserNotifiers,
|
||||||
|
createNotification,
|
||||||
|
convertPropertiesString
|
||||||
|
};
|
||||||
57
src/index.js
Normal file
57
src/index.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { loadConfig } from "./config.js";
|
||||||
|
import { SchedulerManager } from "./scheduler/schedulermanager.js";
|
||||||
|
import { natsServer } from "./database/nats.js";
|
||||||
|
import { redisServer } from "./database/redis.js";
|
||||||
|
import log4js from "log4js";
|
||||||
|
import { mongoServer } from "./database/mongo.js";
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// Load configuration
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
// Setup logger
|
||||||
|
const logger = log4js.getLogger("FarmControl Scheduler");
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
// Connect to NATS (await)
|
||||||
|
try {
|
||||||
|
await natsServer.connect();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to connect to NATS:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to Mongo DB (await)
|
||||||
|
try {
|
||||||
|
await mongoServer.connect();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to connect to Mongo DB:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to Redis (await)
|
||||||
|
try {
|
||||||
|
await redisServer.connect();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to connect to Redis:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedulerManager = new SchedulerManager(
|
||||||
|
redisServer,
|
||||||
|
mongoServer,
|
||||||
|
natsServer,
|
||||||
|
);
|
||||||
|
|
||||||
|
await schedulerManager.start();
|
||||||
|
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
logger.info("Shutting down...");
|
||||||
|
await schedulerManager.stop();
|
||||||
|
await redisServer.disconnect();
|
||||||
|
await mongoServer.disconnect();
|
||||||
|
await natsServer.disconnect();
|
||||||
|
logger.info("Shutdown complete");
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
})();
|
||||||
57
src/scheduler/__tests__/scheduler.test.js
Normal file
57
src/scheduler/__tests__/scheduler.test.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { jest } from "@jest/globals";
|
||||||
|
import { Scheduler } from "../scheduler.js";
|
||||||
|
|
||||||
|
describe("Scheduler", () => {
|
||||||
|
const object = { _id: "inv-1" };
|
||||||
|
const model = { onSchedulerEvent: jest.fn() };
|
||||||
|
|
||||||
|
it("should generate an id and coerce a string value to a Date", () => {
|
||||||
|
const scheduler = new Scheduler({
|
||||||
|
objectType: "invoice",
|
||||||
|
object,
|
||||||
|
property: "dueAt",
|
||||||
|
value: "2026-09-06T12:00:00.000Z",
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scheduler.id).toEqual(expect.any(String));
|
||||||
|
expect(scheduler.objectType).toBe("invoice");
|
||||||
|
expect(scheduler.object).toBe(object);
|
||||||
|
expect(scheduler.property).toBe("dueAt");
|
||||||
|
expect(scheduler.model).toBe(model);
|
||||||
|
expect(scheduler.value).toEqual(new Date("2026-09-06T12:00:00.000Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep an explicit id and Date value", () => {
|
||||||
|
const value = new Date("2026-09-07T09:00:00.000Z");
|
||||||
|
const scheduler = new Scheduler({
|
||||||
|
id: "scheduler-1",
|
||||||
|
objectType: "invoice",
|
||||||
|
object,
|
||||||
|
property: "dueAt",
|
||||||
|
value,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scheduler.id).toBe("scheduler-1");
|
||||||
|
expect(scheduler.value).toBe(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should serialize only the worker-safe payload", () => {
|
||||||
|
const scheduler = new Scheduler({
|
||||||
|
id: "scheduler-1",
|
||||||
|
objectType: "invoice",
|
||||||
|
object,
|
||||||
|
property: "dueAt",
|
||||||
|
value: "2026-09-06T12:00:00.000Z",
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scheduler.toPayload()).toEqual({
|
||||||
|
id: "scheduler-1",
|
||||||
|
objectType: "invoice",
|
||||||
|
property: "dueAt",
|
||||||
|
value: "2026-09-06T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
520
src/scheduler/__tests__/schedulermanager.test.js
Normal file
520
src/scheduler/__tests__/schedulermanager.test.js
Normal file
@ -0,0 +1,520 @@
|
|||||||
|
import { jest } from "@jest/globals";
|
||||||
|
|
||||||
|
const workerInstances = [];
|
||||||
|
|
||||||
|
jest.unstable_mockModule("worker_threads", () => ({
|
||||||
|
Worker: class Worker {
|
||||||
|
constructor(filename) {
|
||||||
|
this.filename = filename;
|
||||||
|
this.postMessage = jest.fn();
|
||||||
|
this.terminate = jest.fn().mockResolvedValue(undefined);
|
||||||
|
this.listeners = {};
|
||||||
|
workerInstances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event, handler) {
|
||||||
|
this.listeners[event] = handler;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule("log4js", () => ({
|
||||||
|
default: {
|
||||||
|
getLogger: () => ({
|
||||||
|
level: "info",
|
||||||
|
debug: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
trace: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule("../../config.js", () => ({
|
||||||
|
loadConfig: jest.fn(() => ({
|
||||||
|
server: {
|
||||||
|
logLevel: "info",
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const onSchedulerEvent = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const invoiceMongooseModel = {
|
||||||
|
scheduledProperties: [
|
||||||
|
{
|
||||||
|
name: "dueAt",
|
||||||
|
filter: { state: "due|sent|acknowledged" },
|
||||||
|
type: "dateTime",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remindAt",
|
||||||
|
filter: { state: "due|sent|acknowledged" },
|
||||||
|
type: "dateTime",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onSchedulerEvent,
|
||||||
|
};
|
||||||
|
|
||||||
|
const invoiceModelEntry = {
|
||||||
|
type: "invoice",
|
||||||
|
label: "Invoice",
|
||||||
|
model: invoiceMongooseModel,
|
||||||
|
};
|
||||||
|
|
||||||
|
const printerModelEntry = {
|
||||||
|
type: "printer",
|
||||||
|
label: "Printer",
|
||||||
|
model: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.unstable_mockModule("../../database/schemas/models.js", () => ({
|
||||||
|
models: {
|
||||||
|
INV: invoiceModelEntry,
|
||||||
|
PRN: printerModelEntry,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule("../../database/database.js", () => ({
|
||||||
|
listObjects: jest.fn(async () => []),
|
||||||
|
getObject: jest.fn(async ({ id }) => ({ _id: id })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule("../../database/utils.js", () => ({
|
||||||
|
getFilter: jest.fn(async (filter) => filter),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updateManagerInstances = [];
|
||||||
|
|
||||||
|
jest.unstable_mockModule("../../updates/updatemanager.js", () => ({
|
||||||
|
UpdateManager: class UpdateManager {
|
||||||
|
constructor(socketClient) {
|
||||||
|
this.socketClient = socketClient;
|
||||||
|
this.subscribeToObjectNew = jest.fn().mockResolvedValue(undefined);
|
||||||
|
this.subscribeToObjectDelete = jest.fn().mockResolvedValue(undefined);
|
||||||
|
this.subscribeToAllObjectUpdates = jest.fn().mockResolvedValue(undefined);
|
||||||
|
this.removeAllListeners = jest.fn().mockResolvedValue(undefined);
|
||||||
|
updateManagerInstances.push(this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { SchedulerManager } = await import("../schedulermanager.js");
|
||||||
|
const { Scheduler } = await import("../scheduler.js");
|
||||||
|
const { listObjects, getObject } = await import("../../database/database.js");
|
||||||
|
const { getFilter } = await import("../../database/utils.js");
|
||||||
|
|
||||||
|
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
const createManager = () =>
|
||||||
|
new SchedulerManager(
|
||||||
|
{ name: "redis" },
|
||||||
|
{ name: "mongo" },
|
||||||
|
{ name: "nats" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const dueAt = new Date("2026-09-06T12:00:00.000Z");
|
||||||
|
|
||||||
|
describe("SchedulerManager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
workerInstances.length = 0;
|
||||||
|
updateManagerInstances.length = 0;
|
||||||
|
onSchedulerEvent.mockClear();
|
||||||
|
listObjects.mockResolvedValue([]);
|
||||||
|
getObject.mockImplementation(async ({ id }) => ({ _id: id }));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("start", () => {
|
||||||
|
it("should start the worker, discover scheduled properties, subscribe, and seed schedulers", async () => {
|
||||||
|
const object = { _id: "inv-1", dueAt, remindAt: null };
|
||||||
|
listObjects.mockResolvedValue([object]);
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
await manager.start();
|
||||||
|
|
||||||
|
expect(workerInstances).toHaveLength(1);
|
||||||
|
expect(manager.scheduledProperties).toEqual([
|
||||||
|
{
|
||||||
|
model: invoiceModelEntry,
|
||||||
|
property: "dueAt",
|
||||||
|
filter: { state: "due|sent|acknowledged" },
|
||||||
|
type: "dateTime",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: invoiceModelEntry,
|
||||||
|
property: "remindAt",
|
||||||
|
filter: { state: "due|sent|acknowledged" },
|
||||||
|
type: "dateTime",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const updateManager = manager.updateManager;
|
||||||
|
expect(updateManager.subscribeToObjectNew).toHaveBeenCalledTimes(1);
|
||||||
|
expect(updateManager.subscribeToObjectDelete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(updateManager.subscribeToAllObjectUpdates).toHaveBeenCalledTimes(1);
|
||||||
|
expect(updateManager.subscribeToObjectNew).toHaveBeenCalledWith(
|
||||||
|
"invoice",
|
||||||
|
{ state: "due|sent|acknowledged" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getFilter).toHaveBeenCalledWith(
|
||||||
|
{ state: "due|sent|acknowledged", dueAt: "TODAY.." },
|
||||||
|
["*"],
|
||||||
|
true,
|
||||||
|
invoiceMongooseModel,
|
||||||
|
);
|
||||||
|
expect(manager.schedulers).toHaveLength(1);
|
||||||
|
expect(manager.schedulers[0]).toMatchObject({
|
||||||
|
objectType: "invoice",
|
||||||
|
object,
|
||||||
|
property: "dueAt",
|
||||||
|
value: dueAt,
|
||||||
|
});
|
||||||
|
expect(workerInstances[0].postMessage).toHaveBeenCalledWith({
|
||||||
|
type: "add",
|
||||||
|
scheduler: manager.schedulers[0].toPayload(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip objects that are missing a scheduled property value", async () => {
|
||||||
|
listObjects.mockResolvedValue([{ _id: "inv-1" }]);
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
await manager.start();
|
||||||
|
|
||||||
|
expect(manager.schedulers).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleWorkerMessage", () => {
|
||||||
|
it("should fire a known scheduler and remove it afterwards", async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
manager.startWorker();
|
||||||
|
const scheduler = new Scheduler({
|
||||||
|
id: "scheduler-1",
|
||||||
|
objectType: "invoice",
|
||||||
|
object: { _id: "inv-1" },
|
||||||
|
property: "dueAt",
|
||||||
|
value: dueAt,
|
||||||
|
model: invoiceMongooseModel,
|
||||||
|
});
|
||||||
|
manager.addScheduler(scheduler);
|
||||||
|
|
||||||
|
manager.handleWorkerMessage({ type: "due", id: "scheduler-1" });
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(onSchedulerEvent).toHaveBeenCalledWith(
|
||||||
|
{ _id: "inv-1" },
|
||||||
|
"dueAt",
|
||||||
|
);
|
||||||
|
expect(manager.schedulers).toHaveLength(0);
|
||||||
|
expect(workerInstances[0].postMessage).toHaveBeenCalledWith({
|
||||||
|
type: "remove",
|
||||||
|
id: "scheduler-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore unknown schedulers and non-due messages", async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
manager.startWorker();
|
||||||
|
|
||||||
|
manager.handleWorkerMessage({ type: "due", id: "missing" });
|
||||||
|
manager.handleWorkerMessage({ type: "add", id: "scheduler-1" });
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(onSchedulerEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should still remove a scheduler when firing fails", async () => {
|
||||||
|
onSchedulerEvent.mockRejectedValueOnce(new Error("boom"));
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.startWorker();
|
||||||
|
const scheduler = new Scheduler({
|
||||||
|
id: "scheduler-1",
|
||||||
|
objectType: "invoice",
|
||||||
|
object: { _id: "inv-1" },
|
||||||
|
property: "dueAt",
|
||||||
|
value: dueAt,
|
||||||
|
model: invoiceMongooseModel,
|
||||||
|
});
|
||||||
|
manager.addScheduler(scheduler);
|
||||||
|
|
||||||
|
manager.handleWorkerMessage({ type: "due", id: "scheduler-1" });
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(manager.schedulers).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upsertScheduler", () => {
|
||||||
|
it("should no-op when the object has no id", () => {
|
||||||
|
const manager = createManager();
|
||||||
|
manager.startWorker();
|
||||||
|
|
||||||
|
manager.upsertScheduler({
|
||||||
|
objectType: "invoice",
|
||||||
|
object: {},
|
||||||
|
property: "dueAt",
|
||||||
|
value: dueAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.schedulers).toHaveLength(0);
|
||||||
|
expect(workerInstances[0].postMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove an existing scheduler when the value is cleared", () => {
|
||||||
|
const manager = createManager();
|
||||||
|
manager.startWorker();
|
||||||
|
const scheduler = new Scheduler({
|
||||||
|
id: "scheduler-1",
|
||||||
|
objectType: "invoice",
|
||||||
|
object: { _id: "inv-1" },
|
||||||
|
property: "dueAt",
|
||||||
|
value: dueAt,
|
||||||
|
model: invoiceMongooseModel,
|
||||||
|
});
|
||||||
|
manager.addScheduler(scheduler);
|
||||||
|
workerInstances[0].postMessage.mockClear();
|
||||||
|
|
||||||
|
manager.upsertScheduler({
|
||||||
|
objectType: "invoice",
|
||||||
|
object: { _id: "inv-1" },
|
||||||
|
property: "dueAt",
|
||||||
|
value: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.schedulers).toHaveLength(0);
|
||||||
|
expect(workerInstances[0].postMessage).toHaveBeenCalledWith({
|
||||||
|
type: "remove",
|
||||||
|
id: "scheduler-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update an existing scheduler without notifying the worker when the time is unchanged", () => {
|
||||||
|
const manager = createManager();
|
||||||
|
manager.startWorker();
|
||||||
|
const scheduler = new Scheduler({
|
||||||
|
id: "scheduler-1",
|
||||||
|
objectType: "invoice",
|
||||||
|
object: { _id: "inv-1", name: "old" },
|
||||||
|
property: "dueAt",
|
||||||
|
value: dueAt,
|
||||||
|
model: invoiceMongooseModel,
|
||||||
|
});
|
||||||
|
manager.addScheduler(scheduler);
|
||||||
|
workerInstances[0].postMessage.mockClear();
|
||||||
|
|
||||||
|
const nextObject = { _id: "inv-1", name: "new" };
|
||||||
|
manager.upsertScheduler({
|
||||||
|
objectType: "invoice",
|
||||||
|
object: nextObject,
|
||||||
|
property: "dueAt",
|
||||||
|
value: dueAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.schedulers).toHaveLength(1);
|
||||||
|
expect(manager.schedulers[0].object).toBe(nextObject);
|
||||||
|
expect(workerInstances[0].postMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should notify the worker when an existing scheduler time changes", () => {
|
||||||
|
const manager = createManager();
|
||||||
|
manager.startWorker();
|
||||||
|
const scheduler = new Scheduler({
|
||||||
|
id: "scheduler-1",
|
||||||
|
objectType: "invoice",
|
||||||
|
object: { _id: "inv-1" },
|
||||||
|
property: "dueAt",
|
||||||
|
value: dueAt,
|
||||||
|
model: invoiceMongooseModel,
|
||||||
|
});
|
||||||
|
manager.addScheduler(scheduler);
|
||||||
|
workerInstances[0].postMessage.mockClear();
|
||||||
|
|
||||||
|
const nextValue = new Date("2026-09-08T12:00:00.000Z");
|
||||||
|
manager.upsertScheduler({
|
||||||
|
objectType: "invoice",
|
||||||
|
object: { _id: "inv-1" },
|
||||||
|
property: "dueAt",
|
||||||
|
value: nextValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.schedulers[0].value).toEqual(nextValue);
|
||||||
|
expect(workerInstances[0].postMessage).toHaveBeenCalledWith({
|
||||||
|
type: "add",
|
||||||
|
scheduler: manager.schedulers[0].toPayload(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findScheduler and removeSchedulersForObject", () => {
|
||||||
|
it("should match schedulers by type, property, and stringified object id", () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const scheduler = new Scheduler({
|
||||||
|
id: "scheduler-1",
|
||||||
|
objectType: "invoice",
|
||||||
|
object: { _id: 123 },
|
||||||
|
property: "dueAt",
|
||||||
|
value: dueAt,
|
||||||
|
model: invoiceMongooseModel,
|
||||||
|
});
|
||||||
|
manager.schedulers.push(scheduler);
|
||||||
|
|
||||||
|
expect(manager.findScheduler("invoice", "123", "dueAt")).toBe(scheduler);
|
||||||
|
expect(manager.findScheduler("invoice", "123", "remindAt")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove every scheduler for an object and ignore missing ids", () => {
|
||||||
|
const manager = createManager();
|
||||||
|
manager.startWorker();
|
||||||
|
manager.addScheduler(
|
||||||
|
new Scheduler({
|
||||||
|
id: "scheduler-1",
|
||||||
|
objectType: "invoice",
|
||||||
|
object: { _id: "inv-1" },
|
||||||
|
property: "dueAt",
|
||||||
|
value: dueAt,
|
||||||
|
model: invoiceMongooseModel,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
manager.addScheduler(
|
||||||
|
new Scheduler({
|
||||||
|
id: "scheduler-2",
|
||||||
|
objectType: "invoice",
|
||||||
|
object: { _id: "inv-1" },
|
||||||
|
property: "remindAt",
|
||||||
|
value: dueAt,
|
||||||
|
model: invoiceMongooseModel,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
manager.addScheduler(
|
||||||
|
new Scheduler({
|
||||||
|
id: "scheduler-3",
|
||||||
|
objectType: "invoice",
|
||||||
|
object: { _id: "inv-2" },
|
||||||
|
property: "dueAt",
|
||||||
|
value: dueAt,
|
||||||
|
model: invoiceMongooseModel,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.removeSchedulersForObject("invoice", {});
|
||||||
|
expect(manager.schedulers).toHaveLength(3);
|
||||||
|
|
||||||
|
manager.removeSchedulersForObject("invoice", { _id: "inv-1" });
|
||||||
|
expect(manager.schedulers.map((scheduler) => scheduler.id)).toEqual([
|
||||||
|
"scheduler-3",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleObjectEvent", () => {
|
||||||
|
it("should sync schedulers from objectNew and objectUpdate events", async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
manager.startWorker();
|
||||||
|
manager.scheduledProperties = [
|
||||||
|
{
|
||||||
|
model: invoiceModelEntry,
|
||||||
|
property: "dueAt",
|
||||||
|
filter: { state: "due|sent|acknowledged" },
|
||||||
|
type: "dateTime",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
getObject.mockResolvedValue({ _id: "inv-1", dueAt });
|
||||||
|
|
||||||
|
manager.handleObjectEvent("objectNew", {
|
||||||
|
objectType: "invoice",
|
||||||
|
object: { _id: "inv-1" },
|
||||||
|
filter: { state: "due|sent|acknowledged" },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(getObject).toHaveBeenCalledWith({
|
||||||
|
model: invoiceMongooseModel,
|
||||||
|
id: "inv-1",
|
||||||
|
});
|
||||||
|
expect(manager.schedulers).toHaveLength(1);
|
||||||
|
|
||||||
|
getObject.mockResolvedValue({
|
||||||
|
_id: "inv-1",
|
||||||
|
dueAt: new Date("2026-09-09T12:00:00.000Z"),
|
||||||
|
});
|
||||||
|
manager.handleObjectEvent("objectUpdate", {
|
||||||
|
objectType: "invoice",
|
||||||
|
_id: "inv-1",
|
||||||
|
object: { dueAt: new Date("2026-09-09T12:00:00.000Z") },
|
||||||
|
filter: { state: "due|sent|acknowledged" },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(manager.schedulers[0].value).toEqual(
|
||||||
|
new Date("2026-09-09T12:00:00.000Z"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore events whose filter does not match a scheduled property", async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
manager.startWorker();
|
||||||
|
manager.scheduledProperties = [
|
||||||
|
{
|
||||||
|
model: invoiceModelEntry,
|
||||||
|
property: "dueAt",
|
||||||
|
filter: { state: "due|sent|acknowledged" },
|
||||||
|
type: "dateTime",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
manager.handleObjectEvent("objectNew", {
|
||||||
|
objectType: "invoice",
|
||||||
|
object: { _id: "inv-1" },
|
||||||
|
filter: { state: "draft" },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(manager.schedulers).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove schedulers on objectDelete", () => {
|
||||||
|
const manager = createManager();
|
||||||
|
manager.startWorker();
|
||||||
|
manager.addScheduler(
|
||||||
|
new Scheduler({
|
||||||
|
id: "scheduler-1",
|
||||||
|
objectType: "invoice",
|
||||||
|
object: { _id: "inv-1" },
|
||||||
|
property: "dueAt",
|
||||||
|
value: dueAt,
|
||||||
|
model: invoiceMongooseModel,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.handleObjectEvent("objectDelete", {
|
||||||
|
objectType: "invoice",
|
||||||
|
object: { _id: "inv-1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.schedulers).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stop", () => {
|
||||||
|
it("should remove listeners and terminate the worker", async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
await manager.start();
|
||||||
|
manager.updateSubscriptions.add("invoice:{}");
|
||||||
|
|
||||||
|
await manager.stop();
|
||||||
|
|
||||||
|
expect(manager.updateManager.removeAllListeners).toHaveBeenCalled();
|
||||||
|
expect(manager.updateSubscriptions.size).toBe(0);
|
||||||
|
expect(workerInstances[0].postMessage).toHaveBeenCalledWith({
|
||||||
|
type: "stop",
|
||||||
|
});
|
||||||
|
expect(workerInstances[0].terminate).toHaveBeenCalled();
|
||||||
|
expect(manager.worker).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
115
src/scheduler/__tests__/schedulerworker.test.js
Normal file
115
src/scheduler/__tests__/schedulerworker.test.js
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { jest } from "@jest/globals";
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
jest.unstable_mockModule("worker_threads", () => ({
|
||||||
|
parentPort: {
|
||||||
|
postMessage: jest.fn(),
|
||||||
|
on: jest.fn(),
|
||||||
|
close: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { parentPort } = await import("worker_threads");
|
||||||
|
await import("../schedulerworker.js");
|
||||||
|
|
||||||
|
const handleParentMessage = parentPort.on.mock.calls[0][1];
|
||||||
|
|
||||||
|
describe("scheduler worker", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
handleParentMessage({ type: "stop" });
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fire due schedulers and remove them immediately", () => {
|
||||||
|
handleParentMessage({
|
||||||
|
type: "add",
|
||||||
|
scheduler: {
|
||||||
|
id: "due-1",
|
||||||
|
objectType: "invoice",
|
||||||
|
property: "dueAt",
|
||||||
|
value: "2020-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(10);
|
||||||
|
|
||||||
|
expect(parentPort.postMessage).toHaveBeenCalledWith({
|
||||||
|
type: "due",
|
||||||
|
id: "due-1",
|
||||||
|
objectType: "invoice",
|
||||||
|
property: "dueAt",
|
||||||
|
value: "2020-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
parentPort.postMessage.mockClear();
|
||||||
|
jest.advanceTimersByTime(20);
|
||||||
|
|
||||||
|
expect(parentPort.postMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not fire schedulers that are still in the future", () => {
|
||||||
|
const future = new Date(Date.now() + 60_000).toISOString();
|
||||||
|
|
||||||
|
handleParentMessage({
|
||||||
|
type: "add",
|
||||||
|
scheduler: {
|
||||||
|
id: "future-1",
|
||||||
|
objectType: "invoice",
|
||||||
|
property: "dueAt",
|
||||||
|
value: future,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(20);
|
||||||
|
|
||||||
|
expect(parentPort.postMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
handleParentMessage({ type: "remove", id: "future-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not fire a removed scheduler", () => {
|
||||||
|
handleParentMessage({
|
||||||
|
type: "add",
|
||||||
|
scheduler: {
|
||||||
|
id: "remove-1",
|
||||||
|
objectType: "invoice",
|
||||||
|
property: "dueAt",
|
||||||
|
value: "2020-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
handleParentMessage({ type: "remove", id: "remove-1" });
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(10);
|
||||||
|
|
||||||
|
expect(parentPort.postMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore unknown message types", () => {
|
||||||
|
expect(() => handleParentMessage({ type: "unknown" })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should stop polling and close the parent port", () => {
|
||||||
|
handleParentMessage({ type: "stop" });
|
||||||
|
|
||||||
|
expect(parentPort.close).toHaveBeenCalled();
|
||||||
|
|
||||||
|
handleParentMessage({
|
||||||
|
type: "add",
|
||||||
|
scheduler: {
|
||||||
|
id: "after-stop",
|
||||||
|
objectType: "invoice",
|
||||||
|
property: "dueAt",
|
||||||
|
value: "2020-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(20);
|
||||||
|
|
||||||
|
expect(parentPort.postMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
53
src/scheduler/scheduler.js
Normal file
53
src/scheduler/scheduler.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single scheduled property change on a model object.
|
||||||
|
*
|
||||||
|
* A Scheduler is created whenever a scheduled property on some object needs
|
||||||
|
* to "fire" once a target date/time has passed (e.g. a `publishAt` field).
|
||||||
|
* The SchedulerManager watches these from a worker thread and, once
|
||||||
|
* `value` is <= the current date/time, calls
|
||||||
|
* `object.onSchedulerEvent(property, value)` back on the main thread.
|
||||||
|
*/
|
||||||
|
export class Scheduler {
|
||||||
|
/**
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {string} [params.id] - Unique id for this scheduler. Generated if omitted.
|
||||||
|
* @param {string} params.objectType - The model/object type (e.g. model.type).
|
||||||
|
* @param {Object} params.object - The live mongoose document/object this scheduler acts on.
|
||||||
|
* @param {string} params.property - The name of the scheduled property.
|
||||||
|
* @param {Date|string} params.value - The date/time the property should fire at.
|
||||||
|
*/
|
||||||
|
constructor({ id, objectType, object, property, value, model }) {
|
||||||
|
console.log("Scheduler constructor", {
|
||||||
|
id,
|
||||||
|
objectType,
|
||||||
|
object,
|
||||||
|
property,
|
||||||
|
value,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
this.id = id || randomUUID();
|
||||||
|
this.objectType = objectType;
|
||||||
|
this.object = object;
|
||||||
|
this.property = property;
|
||||||
|
this.value = value instanceof Date ? value : new Date(value);
|
||||||
|
this.model = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain, serializable representation of this scheduler.
|
||||||
|
*
|
||||||
|
* Used to hand scheduler data off to the worker thread - the live
|
||||||
|
* `object` (a mongoose document, functions, etc.) cannot cross the
|
||||||
|
* worker_threads boundary, so only the primitive fields are sent.
|
||||||
|
*/
|
||||||
|
toPayload() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
objectType: this.objectType,
|
||||||
|
property: this.property,
|
||||||
|
value: this.value.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
340
src/scheduler/schedulermanager.js
Normal file
340
src/scheduler/schedulermanager.js
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
import { Worker } from "worker_threads";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { models } from "../database/schemas/models.js";
|
||||||
|
import log4js from "log4js";
|
||||||
|
import { loadConfig } from "../config.js";
|
||||||
|
import { listObjects, getObject } from "../database/database.js";
|
||||||
|
import { getFilter } from "../database/utils.js";
|
||||||
|
import { UpdateManager } from "../updates/updatemanager.js";
|
||||||
|
import { Scheduler } from "./scheduler.js";
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const logger = log4js.getLogger("Scheduler Manager");
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const filtersEqual = (left, right) =>
|
||||||
|
JSON.stringify(left || {}) === JSON.stringify(right || {});
|
||||||
|
|
||||||
|
export class SchedulerManager {
|
||||||
|
constructor(redisServer, mongoServer, natsServer) {
|
||||||
|
this.redisServer = redisServer;
|
||||||
|
this.mongoServer = mongoServer;
|
||||||
|
this.natsServer = natsServer;
|
||||||
|
this.scheduledProperties = [];
|
||||||
|
this.schedulers = [];
|
||||||
|
this.worker = null;
|
||||||
|
this.updateSubscriptions = new Set();
|
||||||
|
this.updateManager = new UpdateManager({
|
||||||
|
socketId: "scheduler-manager",
|
||||||
|
socket: {
|
||||||
|
emit: (eventName, data) => this.handleObjectEvent(eventName, data),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
logger.info("Starting scheduler manager...");
|
||||||
|
this.startWorker();
|
||||||
|
logger.debug("Getting scheduled properties...");
|
||||||
|
this.populateScheduledProperties();
|
||||||
|
logger.debug("Subscribing to scheduled object updates...");
|
||||||
|
await this.setupUpdateSubscriptions();
|
||||||
|
logger.debug("Setting up schedulers...");
|
||||||
|
await this.setupSchedulers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spins up the worker thread that runs the polling loop, and wires up
|
||||||
|
* its messages back to this manager. The loop itself lives entirely in
|
||||||
|
* schedulerWorker.js so it never blocks the main event loop.
|
||||||
|
*/
|
||||||
|
startWorker() {
|
||||||
|
this.worker = new Worker(path.join(__dirname, "schedulerWorker.js"));
|
||||||
|
|
||||||
|
this.worker.on("message", (message) => this.handleWorkerMessage(message));
|
||||||
|
|
||||||
|
this.worker.on("error", (error) => {
|
||||||
|
logger.error("Scheduler worker error:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.worker.on("exit", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
logger.error(`Scheduler worker stopped with exit code ${code}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWorkerMessage(message) {
|
||||||
|
if (message.type !== "due") return;
|
||||||
|
|
||||||
|
const scheduler = this.schedulers.find((s) => s.id === message.id);
|
||||||
|
if (!scheduler) {
|
||||||
|
logger.warn(`Received due event for unknown scheduler id ${message.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fireScheduler(scheduler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fireScheduler(scheduler) {
|
||||||
|
try {
|
||||||
|
logger.debug(
|
||||||
|
`Firing scheduler ${scheduler.id} for ${scheduler.objectType}.${scheduler.property}`,
|
||||||
|
);
|
||||||
|
await scheduler.model.onSchedulerEvent(
|
||||||
|
scheduler.object,
|
||||||
|
scheduler.property,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Error firing scheduler ${scheduler.id} for ${scheduler.objectType}.${scheduler.property}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.removeScheduler(scheduler.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addScheduler(scheduler) {
|
||||||
|
this.schedulers.push(scheduler);
|
||||||
|
this.worker?.postMessage({ type: "add", scheduler: scheduler.toPayload() });
|
||||||
|
}
|
||||||
|
|
||||||
|
removeScheduler(id) {
|
||||||
|
this.schedulers = this.schedulers.filter((s) => s.id !== id);
|
||||||
|
this.worker?.postMessage({ type: "remove", id });
|
||||||
|
}
|
||||||
|
|
||||||
|
findScheduler(objectType, objectId, property) {
|
||||||
|
return this.schedulers.find(
|
||||||
|
(scheduler) =>
|
||||||
|
scheduler.objectType === objectType &&
|
||||||
|
scheduler.property === property &&
|
||||||
|
String(scheduler.object?._id) === String(objectId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getScheduledPropertiesForEvent(objectType, filter) {
|
||||||
|
return this.scheduledProperties.filter(
|
||||||
|
(scheduledProperty) =>
|
||||||
|
scheduledProperty.model.type === objectType &&
|
||||||
|
filtersEqual(scheduledProperty.filter, filter),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertScheduler({ objectType, object, property, value }) {
|
||||||
|
const objectId = object?._id;
|
||||||
|
if (objectId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("Upserting scheduler for", {
|
||||||
|
objectType,
|
||||||
|
objectId,
|
||||||
|
property,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
const existing = this.findScheduler(objectType, objectId, property);
|
||||||
|
if (!value) {
|
||||||
|
if (existing) {
|
||||||
|
this.removeScheduler(existing.id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
logger.debug("Existing scheduler found", { existing });
|
||||||
|
const nextValue = value instanceof Date ? value : new Date(value);
|
||||||
|
existing.object = object;
|
||||||
|
if (existing.value.getTime() !== nextValue.getTime()) {
|
||||||
|
existing.value = nextValue;
|
||||||
|
this.worker?.postMessage({
|
||||||
|
type: "add",
|
||||||
|
scheduler: existing.toPayload(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = Object.values(models).find(
|
||||||
|
(m) => m.type === objectType,
|
||||||
|
).model;
|
||||||
|
|
||||||
|
logger.debug("Creating new scheduler", {
|
||||||
|
objectType,
|
||||||
|
objectId,
|
||||||
|
property,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduler = new Scheduler({
|
||||||
|
objectType,
|
||||||
|
object,
|
||||||
|
property,
|
||||||
|
value,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
this.addScheduler(scheduler);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSchedulersForObject(objectType, object) {
|
||||||
|
const objectId = object?._id;
|
||||||
|
if (objectId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedulersToRemove = this.schedulers.filter(
|
||||||
|
(scheduler) =>
|
||||||
|
scheduler.objectType === objectType &&
|
||||||
|
String(scheduler.object?._id) === String(objectId),
|
||||||
|
);
|
||||||
|
for (const scheduler of schedulersToRemove) {
|
||||||
|
this.removeScheduler(scheduler.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncSchedulersForObject(objectType, object, filter) {
|
||||||
|
const model = Object.values(models).find(
|
||||||
|
(m) => m.type === objectType,
|
||||||
|
).model;
|
||||||
|
const retrievedObject = await getObject({
|
||||||
|
model,
|
||||||
|
id: object._id,
|
||||||
|
});
|
||||||
|
for (const scheduledProperty of this.getScheduledPropertiesForEvent(
|
||||||
|
objectType,
|
||||||
|
filter,
|
||||||
|
)) {
|
||||||
|
this.upsertScheduler({
|
||||||
|
objectType,
|
||||||
|
object: retrievedObject,
|
||||||
|
property: scheduledProperty.property,
|
||||||
|
value: retrievedObject?.[scheduledProperty.property],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleObjectEvent(eventName, data) {
|
||||||
|
if (eventName === "objectNew") {
|
||||||
|
logger.debug(
|
||||||
|
`Received objectNew for ${data.objectType} ${data.object?._id}`,
|
||||||
|
);
|
||||||
|
this.syncSchedulersForObject(data.objectType, data.object, data.filter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventName === "objectDelete") {
|
||||||
|
logger.debug(
|
||||||
|
`Received objectDelete for ${data.objectType} ${data.object?._id}`,
|
||||||
|
);
|
||||||
|
this.removeSchedulersForObject(data.objectType, data.object);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventName === "objectUpdate") {
|
||||||
|
const object = { _id: data._id, ...data.object };
|
||||||
|
logger.debug(`Received objectUpdate for ${data.objectType} ${data._id}`);
|
||||||
|
this.syncSchedulersForObject(data.objectType, object, data.filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupUpdateSubscriptions() {
|
||||||
|
for (const scheduledProperty of this.scheduledProperties) {
|
||||||
|
const objectType = scheduledProperty.model.type;
|
||||||
|
const filter = scheduledProperty.filter || {};
|
||||||
|
const subscriptionKey = `${objectType}:${JSON.stringify(filter)}`;
|
||||||
|
if (this.updateSubscriptions.has(subscriptionKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Subscribing to ${objectType} updates with filter ${JSON.stringify(filter)}`,
|
||||||
|
);
|
||||||
|
await this.updateManager.subscribeToObjectNew(objectType, filter);
|
||||||
|
await this.updateManager.subscribeToObjectDelete(objectType, filter);
|
||||||
|
await this.updateManager.subscribeToAllObjectUpdates(objectType, filter);
|
||||||
|
this.updateSubscriptions.add(subscriptionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
populateScheduledProperties() {
|
||||||
|
for (const model of Object.values(models)) {
|
||||||
|
const modelLabel = model.label;
|
||||||
|
const modelName = model.type;
|
||||||
|
const mongooseModel = model.model;
|
||||||
|
logger.trace(`Checking model: ${modelLabel} for scheduled properties...`);
|
||||||
|
const scheduledProperties = mongooseModel?.scheduledProperties || [];
|
||||||
|
for (const scheduledProperty of scheduledProperties) {
|
||||||
|
const property = scheduledProperty.name;
|
||||||
|
const filter = scheduledProperty.filter;
|
||||||
|
const type = scheduledProperty.type;
|
||||||
|
logger.debug(
|
||||||
|
`Found scheduled property: ${modelName}.${property} of type ${type} with filter ${JSON.stringify(filter)}`,
|
||||||
|
);
|
||||||
|
this.scheduledProperties.push({
|
||||||
|
model,
|
||||||
|
property,
|
||||||
|
filter,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupSchedulers() {
|
||||||
|
for (const scheduledProperty of this.scheduledProperties) {
|
||||||
|
const model = scheduledProperty.model;
|
||||||
|
const mongooseModel = model.model;
|
||||||
|
const property = scheduledProperty.property;
|
||||||
|
const filter = { ...scheduledProperty.filter, [property]: "TODAY.." };
|
||||||
|
const type = scheduledProperty.type;
|
||||||
|
logger.debug(
|
||||||
|
`Setting up scheduler for: ${model.type}.${property} of type ${type} with filter ${JSON.stringify(filter)}`,
|
||||||
|
);
|
||||||
|
const retrievedFilter = await getFilter(
|
||||||
|
filter,
|
||||||
|
["*"],
|
||||||
|
true,
|
||||||
|
mongooseModel,
|
||||||
|
);
|
||||||
|
const objects = await listObjects({
|
||||||
|
model: mongooseModel,
|
||||||
|
filter: retrievedFilter,
|
||||||
|
});
|
||||||
|
for (const object of objects) {
|
||||||
|
const propertyValue = object[property];
|
||||||
|
if (!propertyValue) {
|
||||||
|
logger.warn(
|
||||||
|
`Object ${object._id} of type ${model.type} has no value for scheduled property ${property}, skipping.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.upsertScheduler({
|
||||||
|
objectType: model.type,
|
||||||
|
object,
|
||||||
|
property,
|
||||||
|
value: propertyValue,
|
||||||
|
});
|
||||||
|
logger.debug(
|
||||||
|
`Created scheduler for ${model.type}.${property} on ${object._id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
logger.info("Stopping scheduler manager...");
|
||||||
|
await this.updateManager.removeAllListeners();
|
||||||
|
this.updateSubscriptions.clear();
|
||||||
|
if (this.worker) {
|
||||||
|
this.worker.postMessage({ type: "stop" });
|
||||||
|
await this.worker.terminate();
|
||||||
|
this.worker = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/scheduler/schedulerworker.js
Normal file
50
src/scheduler/schedulerworker.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { parentPort } from "worker_threads";
|
||||||
|
|
||||||
|
// How often to check whether any scheduler's value has passed.
|
||||||
|
const POLL_INTERVAL_MS = 10;
|
||||||
|
|
||||||
|
// id -> { objectType, property, value: Date }
|
||||||
|
// Only plain, serializable data lives here - the real mongoose objects
|
||||||
|
// stay on the main thread since they can't cross the worker boundary.
|
||||||
|
const schedulers = new Map();
|
||||||
|
|
||||||
|
function checkSchedulers() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, scheduler] of schedulers) {
|
||||||
|
if (scheduler.value.getTime() <= now) {
|
||||||
|
// Remove immediately so we don't fire the same scheduler twice
|
||||||
|
// while waiting for the main thread to process/remove it.
|
||||||
|
schedulers.delete(id);
|
||||||
|
parentPort.postMessage({
|
||||||
|
type: "due",
|
||||||
|
id,
|
||||||
|
objectType: scheduler.objectType,
|
||||||
|
property: scheduler.property,
|
||||||
|
value: scheduler.value.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let intervalHandle = setInterval(checkSchedulers, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
parentPort.on("message", (message) => {
|
||||||
|
switch (message.type) {
|
||||||
|
case "add":
|
||||||
|
schedulers.set(message.scheduler.id, {
|
||||||
|
objectType: message.scheduler.objectType,
|
||||||
|
property: message.scheduler.property,
|
||||||
|
value: new Date(message.scheduler.value),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "remove":
|
||||||
|
schedulers.delete(message.id);
|
||||||
|
break;
|
||||||
|
case "stop":
|
||||||
|
clearInterval(intervalHandle);
|
||||||
|
parentPort.close();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
336
src/updates/__tests__/updatemanager.test.js
Normal file
336
src/updates/__tests__/updatemanager.test.js
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
import { jest } from "@jest/globals";
|
||||||
|
|
||||||
|
jest.unstable_mockModule("../../database/nats.js", () => ({
|
||||||
|
natsServer: {
|
||||||
|
subscribe: jest.fn().mockResolvedValue({ success: true }),
|
||||||
|
removeSubscription: jest.fn().mockResolvedValue({ success: true }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule("log4js", () => ({
|
||||||
|
default: {
|
||||||
|
getLogger: () => ({
|
||||||
|
level: "info",
|
||||||
|
debug: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
trace: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule("../../config.js", () => ({
|
||||||
|
loadConfig: jest.fn(() => ({
|
||||||
|
server: {
|
||||||
|
logLevel: "info",
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule("../../database/filter.js", () => ({
|
||||||
|
getFilter: jest.fn(async (query) => query),
|
||||||
|
getObjectTypeModel: jest.fn(async () => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { UpdateManager } = await import("../updatemanager.js");
|
||||||
|
const { natsServer } = await import("../../database/nats.js");
|
||||||
|
const { getFilter } = await import("../../database/filter.js");
|
||||||
|
|
||||||
|
describe("UpdateManager", () => {
|
||||||
|
let mockSocketClient;
|
||||||
|
let updateManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
mockSocketClient = {
|
||||||
|
socketId: "test-socket-id",
|
||||||
|
socket: {
|
||||||
|
emit: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
updateManager = new UpdateManager(mockSocketClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subscribeToObjectNew", () => {
|
||||||
|
it("should subscribe to new object events and emit", async () => {
|
||||||
|
await updateManager.subscribeToObjectNew("printer");
|
||||||
|
|
||||||
|
expect(natsServer.subscribe).toHaveBeenCalledWith(
|
||||||
|
"printers.new",
|
||||||
|
"test-socket-id:{}",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
|
||||||
|
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||||
|
const data = { name: "New Printer" };
|
||||||
|
await natsCallback("printers.new", data);
|
||||||
|
|
||||||
|
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith("objectNew", {
|
||||||
|
object: data,
|
||||||
|
objectType: "printer",
|
||||||
|
filter: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit filtered new object events when the list filter matches", async () => {
|
||||||
|
const filter = { "state.type": "ready" };
|
||||||
|
const data = {
|
||||||
|
_id: "123",
|
||||||
|
name: "New Printer",
|
||||||
|
state: { type: "ready" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateManager.subscribeToObjectNew("printer", filter);
|
||||||
|
|
||||||
|
expect(getFilter).toHaveBeenCalledWith(
|
||||||
|
filter,
|
||||||
|
Object.keys(filter),
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(natsServer.subscribe).toHaveBeenCalledWith(
|
||||||
|
"printers.new",
|
||||||
|
'test-socket-id:{"state.type":"ready"}',
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
|
||||||
|
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||||
|
await natsCallback("printers.new", data);
|
||||||
|
|
||||||
|
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith("objectNew", {
|
||||||
|
object: data,
|
||||||
|
objectType: "printer",
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip filtered new object events when the list filter misses", async () => {
|
||||||
|
const filter = { "state.type": "ready" };
|
||||||
|
|
||||||
|
await updateManager.subscribeToObjectNew("printer", filter);
|
||||||
|
|
||||||
|
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||||
|
await natsCallback("printers.new", { _id: "123" });
|
||||||
|
|
||||||
|
expect(mockSocketClient.socket.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should match reference filters when the id is populated or flat", async () => {
|
||||||
|
const filter = { "parent._id": "parent-id" };
|
||||||
|
|
||||||
|
await updateManager.subscribeToObjectNew("note", filter);
|
||||||
|
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||||
|
|
||||||
|
await natsCallback("notes.new", {
|
||||||
|
_id: "note-1",
|
||||||
|
parent: { _id: "parent-id" },
|
||||||
|
});
|
||||||
|
expect(mockSocketClient.socket.emit).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
mockSocketClient.socket.emit.mockClear();
|
||||||
|
|
||||||
|
await natsCallback("notes.new", {
|
||||||
|
_id: "note-2",
|
||||||
|
parent: "parent-id",
|
||||||
|
});
|
||||||
|
expect(mockSocketClient.socket.emit).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
mockSocketClient.socket.emit.mockClear();
|
||||||
|
|
||||||
|
await natsCallback("notes.new", {
|
||||||
|
_id: "note-3",
|
||||||
|
parent: "other-parent",
|
||||||
|
});
|
||||||
|
expect(mockSocketClient.socket.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subscribeToObjectDelete", () => {
|
||||||
|
it("should subscribe to delete events and emit", async () => {
|
||||||
|
await updateManager.subscribeToObjectDelete("printer");
|
||||||
|
|
||||||
|
expect(natsServer.subscribe).toHaveBeenCalledWith(
|
||||||
|
"printers.delete",
|
||||||
|
"test-socket-id:{}",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
|
||||||
|
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||||
|
const data = { _id: "123" };
|
||||||
|
await natsCallback("printers.delete", data);
|
||||||
|
|
||||||
|
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith(
|
||||||
|
"objectDelete",
|
||||||
|
{
|
||||||
|
object: data,
|
||||||
|
objectType: "printer",
|
||||||
|
filter: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subscribeToObjectUpdate", () => {
|
||||||
|
it("should subscribe to update events for specific object", async () => {
|
||||||
|
await updateManager.subscribeToObjectUpdate("123", "printer");
|
||||||
|
|
||||||
|
expect(natsServer.subscribe).toHaveBeenCalledWith(
|
||||||
|
"printers.123.object",
|
||||||
|
"test-socket-id",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
|
||||||
|
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||||
|
const data = { _id: "123", status: "idle" };
|
||||||
|
natsCallback("printers.123.object", data);
|
||||||
|
|
||||||
|
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith(
|
||||||
|
"objectUpdate",
|
||||||
|
{
|
||||||
|
_id: "123",
|
||||||
|
objectType: "printer",
|
||||||
|
object: { status: "idle" },
|
||||||
|
filter: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subscribeToAllObjectUpdates", () => {
|
||||||
|
it("should subscribe to all update events for an object type", async () => {
|
||||||
|
await updateManager.subscribeToAllObjectUpdates("printer");
|
||||||
|
|
||||||
|
expect(natsServer.subscribe).toHaveBeenCalledWith(
|
||||||
|
"printers.*.object",
|
||||||
|
"test-socket-id:{}",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
|
||||||
|
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||||
|
const data = { _id: "456", status: "idle" };
|
||||||
|
natsCallback("printers.456.object", data);
|
||||||
|
|
||||||
|
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith(
|
||||||
|
"objectUpdate",
|
||||||
|
{
|
||||||
|
_id: "456",
|
||||||
|
objectType: "printer",
|
||||||
|
object: { status: "idle" },
|
||||||
|
filter: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit filtered update events when the list filter matches", async () => {
|
||||||
|
const filter = { "state.type": "ready" };
|
||||||
|
const data = {
|
||||||
|
_id: "456",
|
||||||
|
status: "idle",
|
||||||
|
state: { type: "ready" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateManager.subscribeToAllObjectUpdates("printer", filter);
|
||||||
|
|
||||||
|
expect(getFilter).toHaveBeenCalledWith(
|
||||||
|
filter,
|
||||||
|
Object.keys(filter),
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(natsServer.subscribe).toHaveBeenCalledWith(
|
||||||
|
"printers.*.object",
|
||||||
|
'test-socket-id:{"state.type":"ready"}',
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
|
||||||
|
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||||
|
natsCallback("printers.456.object", data);
|
||||||
|
|
||||||
|
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith(
|
||||||
|
"objectUpdate",
|
||||||
|
{
|
||||||
|
_id: "456",
|
||||||
|
objectType: "printer",
|
||||||
|
object: { status: "idle", state: { type: "ready" } },
|
||||||
|
filter,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip filtered update events when the list filter misses", async () => {
|
||||||
|
const filter = { "state.type": "ready" };
|
||||||
|
|
||||||
|
await updateManager.subscribeToAllObjectUpdates("printer", filter);
|
||||||
|
|
||||||
|
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||||
|
natsCallback("printers.456.object", { _id: "456", status: "idle" });
|
||||||
|
|
||||||
|
expect(mockSocketClient.socket.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip all-object updates when a specific subscription exists", async () => {
|
||||||
|
await updateManager.subscribeToObjectUpdate("123", "printer");
|
||||||
|
await updateManager.subscribeToAllObjectUpdates("printer");
|
||||||
|
|
||||||
|
const allUpdatesCallback = natsServer.subscribe.mock.calls[1][2];
|
||||||
|
allUpdatesCallback("printers.123.object", {
|
||||||
|
_id: "123",
|
||||||
|
status: "idle",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSocketClient.socket.emit).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove methods", () => {
|
||||||
|
it("should remove new listener", async () => {
|
||||||
|
await updateManager.removeObjectNewListener("printer");
|
||||||
|
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
|
||||||
|
"printers.new",
|
||||||
|
"test-socket-id:{}",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove delete listener", async () => {
|
||||||
|
await updateManager.removeObjectDeleteListener("printer");
|
||||||
|
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
|
||||||
|
"printers.delete",
|
||||||
|
"test-socket-id:{}",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove update listener", async () => {
|
||||||
|
await updateManager.removeObjectUpdateListener("123", "printer");
|
||||||
|
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
|
||||||
|
"printers.123.object",
|
||||||
|
"test-socket-id",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove all object updates listener", async () => {
|
||||||
|
await updateManager.removeAllObjectUpdatesListener("printer");
|
||||||
|
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
|
||||||
|
"printers.*.object",
|
||||||
|
"test-socket-id:{}",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeAllListeners", () => {
|
||||||
|
it("should remove all subscriptions", async () => {
|
||||||
|
await updateManager.subscribeToObjectNew("printer");
|
||||||
|
await updateManager.subscribeToObjectDelete("printer");
|
||||||
|
await updateManager.subscribeToObjectUpdate("123", "printer");
|
||||||
|
|
||||||
|
expect(updateManager.subscriptions.size).toBe(3);
|
||||||
|
|
||||||
|
await updateManager.removeAllListeners();
|
||||||
|
|
||||||
|
expect(natsServer.removeSubscription).toHaveBeenCalledTimes(3);
|
||||||
|
expect(updateManager.subscriptions.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
448
src/updates/updatemanager.js
Normal file
448
src/updates/updatemanager.js
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
import log4js from "log4js";
|
||||||
|
import _ from "lodash";
|
||||||
|
import { loadConfig } from "../config.js";
|
||||||
|
import { natsServer } from "../database/nats.js";
|
||||||
|
import { expandObjectIds } from "../database/utils.js";
|
||||||
|
import { getFilter, getObjectTypeModel } from "../database/filter.js";
|
||||||
|
import { formatTraceData } from "../utils.js";
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
// Setup logger
|
||||||
|
const logger = log4js.getLogger("Update Manager");
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
const normalizeFilter = (filter) =>
|
||||||
|
filter && typeof filter === "object" && !Array.isArray(filter) ? filter : {};
|
||||||
|
|
||||||
|
const unwrapId = (value) => {
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
value._id != null
|
||||||
|
) {
|
||||||
|
return value._id;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFilterValue = (object, key) => {
|
||||||
|
if (key.endsWith("._id")) {
|
||||||
|
const refPath = key.slice(0, -4);
|
||||||
|
const ref = _.get(object, refPath);
|
||||||
|
if (ref && typeof ref === "object" && ref._id) {
|
||||||
|
return ref._id;
|
||||||
|
}
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
return unwrapId(_.get(object, key));
|
||||||
|
};
|
||||||
|
|
||||||
|
const valuesMatch = (actual, expected) => {
|
||||||
|
const left = unwrapId(actual);
|
||||||
|
const right = unwrapId(expected);
|
||||||
|
|
||||||
|
if (left == right) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left != null && right != null) {
|
||||||
|
return String(left) === String(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOperatorObject = (value) => {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value instanceof Date || value._bsontype === "ObjectId") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
return keys.length > 0 && keys.every((key) => key.startsWith("$"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const compareValues = (actual, expected, operator) => {
|
||||||
|
if (actual == null || expected == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (operator === "$gt") return actual > expected;
|
||||||
|
if (operator === "$gte") return actual >= expected;
|
||||||
|
if (operator === "$lt") return actual < expected;
|
||||||
|
if (operator === "$lte") return actual <= expected;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesOperators = (actual, operators) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(operators, "$eq")) {
|
||||||
|
if (!valuesMatch(actual, operators.$eq)) return false;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(operators, "$ne")) {
|
||||||
|
if (valuesMatch(actual, operators.$ne)) return false;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(operators, "$in")) {
|
||||||
|
if (
|
||||||
|
!Array.isArray(operators.$in) ||
|
||||||
|
!operators.$in.some((value) => valuesMatch(actual, value))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(operators, "$nin")) {
|
||||||
|
if (
|
||||||
|
Array.isArray(operators.$nin) &&
|
||||||
|
operators.$nin.some((value) => valuesMatch(actual, value))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(operators, "$regex")) {
|
||||||
|
const regex = new RegExp(operators.$regex, operators.$options || "");
|
||||||
|
if (!regex.test(String(actual ?? ""))) return false;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(operators, "$not")) {
|
||||||
|
if (matchesExpected(actual, operators.$not)) return false;
|
||||||
|
}
|
||||||
|
for (const operator of ["$gt", "$gte", "$lt", "$lte"]) {
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(operators, operator) &&
|
||||||
|
!compareValues(actual, operators[operator], operator)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesExpected = (actual, expected) => {
|
||||||
|
if (isOperatorObject(expected)) {
|
||||||
|
return matchesOperators(actual, expected);
|
||||||
|
}
|
||||||
|
return valuesMatch(actual, expected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesFilter = (object, filter) => {
|
||||||
|
if (!filter || Object.keys(filter).length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (object == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedObject =
|
||||||
|
typeof object === "object" && !Array.isArray(object)
|
||||||
|
? object
|
||||||
|
: { _id: object };
|
||||||
|
|
||||||
|
if (Array.isArray(filter.$and)) {
|
||||||
|
return filter.$and.every((clause) =>
|
||||||
|
matchesFilter(normalizedObject, clause),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Array.isArray(filter.$or)) {
|
||||||
|
return filter.$or.some((clause) => matchesFilter(normalizedObject, clause));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, expectedValue] of Object.entries(filter)) {
|
||||||
|
if (key === "$and" || key === "$or") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!matchesExpected(getFilterValue(normalizedObject, key), expectedValue)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveObjectTypeFilter = async (objectType, filter = {}) => {
|
||||||
|
const normalizedFilter = normalizeFilter(filter);
|
||||||
|
if (Object.keys(normalizedFilter).length === 0) {
|
||||||
|
return { normalizedFilter, processedFilter: normalizedFilter };
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = await getObjectTypeModel(objectType);
|
||||||
|
const processedFilter = await getFilter(
|
||||||
|
normalizedFilter,
|
||||||
|
Object.keys(normalizedFilter),
|
||||||
|
true,
|
||||||
|
model,
|
||||||
|
);
|
||||||
|
return { normalizedFilter, processedFilter };
|
||||||
|
};
|
||||||
|
|
||||||
|
const stableStringify = (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return `[${value.map(stableStringify).join(",")}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return `{${Object.keys(value)
|
||||||
|
.sort()
|
||||||
|
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
||||||
|
.join(",")}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSubscriptionOwner = (socketId, filter) =>
|
||||||
|
`${socketId}:${stableStringify(normalizeFilter(filter))}`;
|
||||||
|
|
||||||
|
const getSubscriptionKey = (subject, owner) => `${subject}:${owner}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UpdateManager handles tracking object updates and broadcasts update events via websockets.
|
||||||
|
*/
|
||||||
|
export class UpdateManager {
|
||||||
|
constructor(socketClient) {
|
||||||
|
this.socketClient = socketClient;
|
||||||
|
this.subscriptions = new Set();
|
||||||
|
this.objectUpdateSubscriptions = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
getObjectUpdateSubscriptionKey(objectType, id) {
|
||||||
|
return `${objectType}:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchesObjectTypeFilter(objectType, filter, value) {
|
||||||
|
return matchesFilter(value, normalizeFilter(filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
emitObjectTypeEvent(
|
||||||
|
eventName,
|
||||||
|
objectType,
|
||||||
|
filter,
|
||||||
|
value,
|
||||||
|
matchFilter = filter,
|
||||||
|
) {
|
||||||
|
const normalizedFilter = normalizeFilter(filter);
|
||||||
|
const matches = this.matchesObjectTypeFilter(
|
||||||
|
objectType,
|
||||||
|
matchFilter,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
logger.trace(
|
||||||
|
`Filtered ${eventName} event: ${formatTraceData({
|
||||||
|
objectType,
|
||||||
|
filter: normalizedFilter,
|
||||||
|
value,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socketClient.socket.emit(eventName, {
|
||||||
|
object: value,
|
||||||
|
objectType: objectType,
|
||||||
|
filter: normalizedFilter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribeToObjectNew(objectType, filter = {}) {
|
||||||
|
const { normalizedFilter, processedFilter } = await resolveObjectTypeFilter(
|
||||||
|
objectType,
|
||||||
|
filter,
|
||||||
|
);
|
||||||
|
const subject = `${objectType}s.new`;
|
||||||
|
const owner = getSubscriptionOwner(
|
||||||
|
this.socketClient.socketId,
|
||||||
|
normalizedFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
await natsServer.subscribe(subject, owner, async (key, value) => {
|
||||||
|
logger.trace(`Object new event: ${formatTraceData(value)}`);
|
||||||
|
this.emitObjectTypeEvent(
|
||||||
|
"objectNew",
|
||||||
|
objectType,
|
||||||
|
normalizedFilter,
|
||||||
|
value,
|
||||||
|
processedFilter,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscriptions.add(getSubscriptionKey(subject, owner));
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribeToObjectDelete(objectType, filter = {}) {
|
||||||
|
const { normalizedFilter, processedFilter } = await resolveObjectTypeFilter(
|
||||||
|
objectType,
|
||||||
|
filter,
|
||||||
|
);
|
||||||
|
const subject = `${objectType}s.delete`;
|
||||||
|
const owner = getSubscriptionOwner(
|
||||||
|
this.socketClient.socketId,
|
||||||
|
normalizedFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
await natsServer.subscribe(subject, owner, async (key, value) => {
|
||||||
|
logger.trace(`Object delete event: ${formatTraceData(value)}`);
|
||||||
|
this.emitObjectTypeEvent(
|
||||||
|
"objectDelete",
|
||||||
|
objectType,
|
||||||
|
normalizedFilter,
|
||||||
|
value,
|
||||||
|
processedFilter,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscriptions.add(getSubscriptionKey(subject, owner));
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribeToObjectUpdate(id, objectType) {
|
||||||
|
logger.debug("Subscribing to object update...", id, objectType);
|
||||||
|
const subject = `${objectType}s.${id}.object`;
|
||||||
|
const owner = this.socketClient.socketId;
|
||||||
|
|
||||||
|
await natsServer.subscribe(subject, owner, (key, value) => {
|
||||||
|
this.emitObjectUpdate(objectType, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.objectUpdateSubscriptions.add(
|
||||||
|
this.getObjectUpdateSubscriptionKey(objectType, id),
|
||||||
|
);
|
||||||
|
this.subscriptions.add(getSubscriptionKey(subject, owner));
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
emitObjectUpdate(objectType, value, filter = {}, matchFilter = filter) {
|
||||||
|
const expandedValue = expandObjectIds(value);
|
||||||
|
if (!expandedValue || expandedValue._id == null) {
|
||||||
|
logger.warn("Object update missing _id:", objectType, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedFilter = normalizeFilter(filter);
|
||||||
|
const matches = this.matchesObjectTypeFilter(
|
||||||
|
objectType,
|
||||||
|
matchFilter,
|
||||||
|
expandedValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
logger.trace(
|
||||||
|
`Filtered objectUpdate event: ${formatTraceData({
|
||||||
|
objectType,
|
||||||
|
filter: normalizedFilter,
|
||||||
|
value: expandedValue,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { _id, ...object } = { ...expandedValue };
|
||||||
|
logger.trace("Object update event:", _id, objectType);
|
||||||
|
this.socketClient.socket.emit("objectUpdate", {
|
||||||
|
_id,
|
||||||
|
objectType,
|
||||||
|
object,
|
||||||
|
filter: normalizedFilter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribeToAllObjectUpdates(objectType, filter = {}) {
|
||||||
|
logger.debug("Subscribing to all object updates...", objectType);
|
||||||
|
const { normalizedFilter, processedFilter } = await resolveObjectTypeFilter(
|
||||||
|
objectType,
|
||||||
|
filter,
|
||||||
|
);
|
||||||
|
const subject = `${objectType}s.*.object`;
|
||||||
|
const owner = getSubscriptionOwner(
|
||||||
|
this.socketClient.socketId,
|
||||||
|
normalizedFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
await natsServer.subscribe(subject, owner, (key, value) => {
|
||||||
|
const id = value?._id;
|
||||||
|
if (id == null) {
|
||||||
|
logger.warn("Object update missing _id:", objectType, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.objectUpdateSubscriptions.has(
|
||||||
|
this.getObjectUpdateSubscriptionKey(objectType, id),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitObjectUpdate(
|
||||||
|
objectType,
|
||||||
|
value,
|
||||||
|
normalizedFilter,
|
||||||
|
processedFilter,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscriptions.add(getSubscriptionKey(subject, owner));
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeObjectNewListener(objectType, filter = {}) {
|
||||||
|
const subject = `${objectType}s.new`;
|
||||||
|
const owner = getSubscriptionOwner(this.socketClient.socketId, filter);
|
||||||
|
|
||||||
|
await natsServer.removeSubscription(subject, owner);
|
||||||
|
this.subscriptions.delete(getSubscriptionKey(subject, owner));
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeObjectDeleteListener(objectType, filter = {}) {
|
||||||
|
const subject = `${objectType}s.delete`;
|
||||||
|
const owner = getSubscriptionOwner(this.socketClient.socketId, filter);
|
||||||
|
|
||||||
|
await natsServer.removeSubscription(subject, owner);
|
||||||
|
this.subscriptions.delete(getSubscriptionKey(subject, owner));
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeObjectUpdateListener(id, objectType) {
|
||||||
|
const subject = `${objectType}s.${id}.object`;
|
||||||
|
const owner = this.socketClient.socketId;
|
||||||
|
|
||||||
|
await natsServer.removeSubscription(subject, owner);
|
||||||
|
this.objectUpdateSubscriptions.delete(
|
||||||
|
this.getObjectUpdateSubscriptionKey(objectType, id),
|
||||||
|
);
|
||||||
|
this.subscriptions.delete(getSubscriptionKey(subject, owner));
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAllObjectUpdatesListener(objectType, filter = {}) {
|
||||||
|
const subject = `${objectType}s.*.object`;
|
||||||
|
const owner = getSubscriptionOwner(this.socketClient.socketId, filter);
|
||||||
|
|
||||||
|
await natsServer.removeSubscription(subject, owner);
|
||||||
|
this.subscriptions.delete(getSubscriptionKey(subject, owner));
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAllListeners() {
|
||||||
|
logger.debug("Removing all update listeners...");
|
||||||
|
const removePromises = Array.from(this.subscriptions).map(
|
||||||
|
(subscriptionKey) => {
|
||||||
|
const separatorIndex = subscriptionKey.indexOf(":");
|
||||||
|
const subject = subscriptionKey.slice(0, separatorIndex);
|
||||||
|
const owner = subscriptionKey.slice(separatorIndex + 1);
|
||||||
|
return natsServer.removeSubscription(subject, owner);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(removePromises);
|
||||||
|
this.subscriptions.clear();
|
||||||
|
this.objectUpdateSubscriptions.clear();
|
||||||
|
logger.debug(`Removed ${removePromises.length} update listener(s)`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/utils.js
Normal file
17
src/utils.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export function formatTraceData(value, maxLength = 128) {
|
||||||
|
if (value === undefined) return "undefined";
|
||||||
|
const text = typeof value === "string" ? value : JSON.stringify(value);
|
||||||
|
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQueryToCacheKey({ model, id, populate }) {
|
||||||
|
const populateKey = [];
|
||||||
|
for (const pop of populate) {
|
||||||
|
if (typeof pop === "string") {
|
||||||
|
populateKey.push(pop);
|
||||||
|
} else if (typeof pop === "object") {
|
||||||
|
populateKey.push(pop.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${model}:${id?.toString()}-${populateKey.join(",")}`;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user