Password Policy/App rename/PM Scheduling Improvements
This commit is contained in:
14
.env.example
14
.env.example
@@ -1,7 +1,7 @@
|
|||||||
MIXEDASSETS_PORT=3000
|
DEPRECORE_PORT=3000
|
||||||
MIXEDASSETS_DATA_DIR=./data
|
DEPRECORE_DATA_DIR=./data
|
||||||
MIXEDASSETS_DB_PATH=./data/mixedassets.sqlite
|
DEPRECORE_DB_PATH=./data/deprecore.sqlite
|
||||||
MIXEDASSETS_SERVER_FQDN=localhost
|
DEPRECORE_SERVER_FQDN=localhost
|
||||||
MIXEDASSETS_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
DEPRECORE_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
MIXEDASSETS_JWT_SECRET=replace-with-a-long-random-secret
|
DEPRECORE_JWT_SECRET=replace-with-a-long-random-secret
|
||||||
MIXEDASSETS_ADMIN_PASSWORD=ChangeMe123!
|
DEPRECORE_ADMIN_PASSWORD=ChangeMe123!
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -1,6 +1,6 @@
|
|||||||
# MixedAssets
|
# DepreCore
|
||||||
|
|
||||||
MixedAssets is a fixed asset management and depreciation SPA for small and mid-sized organizations. This build includes an Express API, Vue/Vuetify frontend, SQLite persistence, role-aware login, asset register, templates, JSON tax-rule sets, depreciation reports, import/export, and admin configuration.
|
DepreCore is a fixed asset management and depreciation SPA for small and mid-sized organizations. This build includes an Express API, Vue/Vuetify frontend, SQLite persistence, role-aware login, asset register, templates, JSON tax-rule sets, depreciation reports, import/export, and admin configuration.
|
||||||
|
|
||||||
Each asset carries six independent books (GAAP, Federal, State, AMT, ACE, User-defined), each with its own cost, depreciation method, useful life, §179 amount, bonus percentage, and convention. The asset workbench also includes a File Cabinet (invoices, photos, manuals), warranty/service-agreement tracking, a per-asset task list, and a running notes timeline.
|
Each asset carries six independent books (GAAP, Federal, State, AMT, ACE, User-defined), each with its own cost, depreciation method, useful life, §179 amount, bonus percentage, and convention. The asset workbench also includes a File Cabinet (invoices, photos, manuals), warranty/service-agreement tracking, a per-asset task list, and a running notes timeline.
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ Open `http://localhost:5173`.
|
|||||||
Default seeded login:
|
Default seeded login:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
admin@mixedassets.local
|
admin@deprecore.local
|
||||||
ChangeMe123!
|
ChangeMe123!
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -51,13 +51,13 @@ Environment variables are shown in `.env.example`. The app also stores editable
|
|||||||
|
|
||||||
| Variable | Default | Purpose |
|
| Variable | Default | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `MIXEDASSETS_PORT` (or `PORT`) | `3000` | API/server port |
|
| `DEPRECORE_PORT` (or `PORT`) | `3000` | API/server port |
|
||||||
| `MIXEDASSETS_DATA_DIR` | `./data` | Data directory |
|
| `DEPRECORE_DATA_DIR` | `./data` | Data directory |
|
||||||
| `MIXEDASSETS_DB_PATH` | `./data/mixedassets.sqlite` | SQLite database file |
|
| `DEPRECORE_DB_PATH` | `./data/deprecore.sqlite` | SQLite database file |
|
||||||
| `MIXEDASSETS_SERVER_FQDN` | `localhost` | Server FQDN (also editable in Admin) |
|
| `DEPRECORE_SERVER_FQDN` | `localhost` | Server FQDN (also editable in Admin) |
|
||||||
| `MIXEDASSETS_CORS_ORIGINS` | `http://localhost:5173,...` | Allowed CORS origins (comma-separated) |
|
| `DEPRECORE_CORS_ORIGINS` | `http://localhost:5173,...` | Allowed CORS origins (comma-separated) |
|
||||||
| `MIXEDASSETS_JWT_SECRET` | dev fallback | JWT signing secret — set a long random value in production |
|
| `DEPRECORE_JWT_SECRET` | dev fallback | JWT signing secret — set a long random value in production |
|
||||||
| `MIXEDASSETS_ADMIN_PASSWORD` | `ChangeMe123!` | Password for the seeded admin user |
|
| `DEPRECORE_ADMIN_PASSWORD` | `ChangeMe123!` | Password for the seeded admin user |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ Tokens expire after 12 hours. The only routes that do **not** require a token ar
|
|||||||
# Login
|
# Login
|
||||||
curl -s http://localhost:3000/api/auth/login \
|
curl -s http://localhost:3000/api/auth/login \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"email":"admin@mixedassets.local","password":"ChangeMe123!"}'
|
-d '{"email":"admin@deprecore.local","password":"ChangeMe123!"}'
|
||||||
# => { "token": "<jwt>", "user": { "id":1, "name":"...", "role":"admin", ... } }
|
# => { "token": "<jwt>", "user": { "id":1, "name":"...", "role":"admin", ... } }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -307,7 +307,7 @@ Movement `type` is `receive` (add on-hand), `issue` (consume), `adjust` (set on-
|
|||||||
|
|
||||||
Alert types: `maintenance_overdue`, `maintenance_due`, `warranty_expired`, `warranty_expiring`, `lease_expiring`, `pm_due`, `pm_overdue`. Severity is `critical` (overdue/expired or within 7 days) or `warning`. The server also runs the scan-and-notify cycle ~twice daily; it's a no-op unless something is configured.
|
Alert types: `maintenance_overdue`, `maintenance_due`, `warranty_expired`, `warranty_expiring`, `lease_expiring`, `pm_due`, `pm_overdue`. Severity is `critical` (overdue/expired or within 7 days) or `warning`. The server also runs the scan-and-notify cycle ~twice daily; it's a no-op unless something is configured.
|
||||||
|
|
||||||
**Webhook delivery.** When `webhook_enabled` is true and `webhook_url` is set, each newly raised alert is delivered as its own JSON `POST` (one request per alert) on the alert cycle — independently of, or alongside, email. An alert is marked notified once any channel handles it, so it is delivered at most once. If `webhook_secret` is set, the raw request body is signed with HMAC-SHA256 and sent in the `X-MixedAssets-Signature: sha256=<hex>` header so the receiver can verify authenticity. Per-alert payload:
|
**Webhook delivery.** When `webhook_enabled` is true and `webhook_url` is set, each newly raised alert is delivered as its own JSON `POST` (one request per alert) on the alert cycle — independently of, or alongside, email. An alert is marked notified once any channel handles it, so it is delivered at most once. If `webhook_secret` is set, the raw request body is signed with HMAC-SHA256 and sent in the `X-DepreCore-Signature: sha256=<hex>` header so the receiver can verify authenticity. Per-alert payload:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -369,7 +369,7 @@ Each **book** (GAAP, Federal, …) has a `tax_rule_set_id`. When the engine depr
|
|||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"schema": "mixedassets.taxRuleSet.v1", // optional label
|
"schema": "deprecore.taxRuleSet.v1", // optional label
|
||||||
"jurisdiction": "US-FED", // unique key with version
|
"jurisdiction": "US-FED", // unique key with version
|
||||||
"name": "Federal 68-method depreciation catalog",
|
"name": "Federal 68-method depreciation catalog",
|
||||||
"version": "2026.2", // unique per jurisdiction
|
"version": "2026.2", // unique per jurisdiction
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>MixedAssets</title>
|
<title>DepreCore</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mixedassets",
|
"name": "deprecore",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "MixedAssets fixed asset management and depreciation platform",
|
"description": "DepreCore fixed asset management and depreciation platform",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm:dev:api\" \"npm:dev:web\"",
|
"dev": "concurrently \"npm:dev:api\" \"npm:dev:web\"",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const moment = require('moment');
|
||||||
|
moment.locale('en');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { DB_PATH, initialize } = require('./db');
|
const { DB_PATH, initialize } = require('./db');
|
||||||
@@ -27,16 +29,19 @@ const taxRuleRoutes = require('./routes/taxRules');
|
|||||||
const templateRoutes = require('./routes/templates');
|
const templateRoutes = require('./routes/templates');
|
||||||
const workdayRoutes = require('./routes/workday');
|
const workdayRoutes = require('./routes/workday');
|
||||||
|
|
||||||
|
console.log(`${moment().format()} ✅ Starting DepreCore server with database at ${DB_PATH}`);
|
||||||
function createApp() {
|
function createApp() {
|
||||||
initialize();
|
initialize();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(createCorsMiddleware());
|
app.use(createCorsMiddleware());
|
||||||
app.use(express.json({ limit: '12mb' }));
|
app.use(express.json({ limit: '12mb' }));
|
||||||
|
console.log(`${moment().format()} ✅ Express app initialized with JSON body parsing and CORS middleware`);
|
||||||
|
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({ ok: true, app: 'MixedAssets', database: DB_PATH });
|
res.json({ ok: true, app: 'DepreCore', database: DB_PATH });
|
||||||
});
|
});
|
||||||
|
console.log(`${moment().format()} ✅ Health check endpoint registered at /api/health`);
|
||||||
|
|
||||||
app.use('/api', authRoutes);
|
app.use('/api', authRoutes);
|
||||||
app.use('/api', requireAuth);
|
app.use('/api', requireAuth);
|
||||||
@@ -59,9 +64,12 @@ function createApp() {
|
|||||||
app.use('/api', dataPortabilityRoutes);
|
app.use('/api', dataPortabilityRoutes);
|
||||||
app.use('/api', adminRoutes);
|
app.use('/api', adminRoutes);
|
||||||
app.use('/api', workdayRoutes);
|
app.use('/api', workdayRoutes);
|
||||||
|
console.log(`${moment().format()} ✅ API routes registered under /api`);
|
||||||
|
|
||||||
const distPath = path.join(process.cwd(), 'dist');
|
const distPath = path.join(process.cwd(), 'dist');
|
||||||
app.use(express.static(distPath));
|
app.use(express.static(distPath));
|
||||||
|
console.log(`${moment().format()} ✅ Static file serving configured for ${distPath}`);
|
||||||
|
|
||||||
app.get(/.*/, (req, res, next) => {
|
app.get(/.*/, (req, res, next) => {
|
||||||
if (req.path.startsWith('/api')) return next();
|
if (req.path.startsWith('/api')) return next();
|
||||||
return res.sendFile(path.join(distPath, 'index.html'));
|
return res.sendFile(path.join(distPath, 'index.html'));
|
||||||
@@ -70,8 +78,10 @@ function createApp() {
|
|||||||
app.use((error, req, res, next) => {
|
app.use((error, req, res, next) => {
|
||||||
if (res.headersSent) return next(error);
|
if (res.headersSent) return next(error);
|
||||||
const status = error.status || 500;
|
const status = error.status || 500;
|
||||||
return res.status(status).json({ error: error.message || 'MixedAssets server error' });
|
console.error(`${moment().format()} ❌ Error: ${error.message}\n${error.stack}`);
|
||||||
|
return res.status(status).json({ error: error.message || 'DepreCore server error' });
|
||||||
});
|
});
|
||||||
|
console.log(`${moment().format()} ✅ Global error handler registered`);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
109
server/db.js
109
server/db.js
@@ -4,8 +4,8 @@ const { DatabaseSync } = require('node:sqlite');
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const { expandRuleSet } = require('./rule-catalog');
|
const { expandRuleSet } = require('./rule-catalog');
|
||||||
|
|
||||||
const DATA_DIR = process.env.MIXEDASSETS_DATA_DIR || path.join(process.cwd(), 'data');
|
const DATA_DIR = process.env.DEPRECORE_DATA_DIR || path.join(process.cwd(), 'data');
|
||||||
const DB_PATH = process.env.MIXEDASSETS_DB_PATH || path.join(DATA_DIR, 'mixedassets.sqlite');
|
const DB_PATH = process.env.DEPRECORE_DB_PATH || path.join(DATA_DIR, 'deprecore.sqlite');
|
||||||
|
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
@@ -493,6 +493,39 @@ function initialize() {
|
|||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pm_plan_meters (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id INTEGER NOT NULL REFERENCES pm_plans(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
unit TEXT,
|
||||||
|
usage_interval REAL NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_pm_meters (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
asset_pm_id INTEGER NOT NULL REFERENCES asset_pm_plans(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
unit TEXT,
|
||||||
|
usage_interval REAL NOT NULL DEFAULT 0,
|
||||||
|
baseline_reading REAL NOT NULL DEFAULT 0,
|
||||||
|
due_at_reading REAL,
|
||||||
|
last_reading REAL,
|
||||||
|
last_reading_date TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pm_meter_readings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
asset_pm_meter_id INTEGER NOT NULL REFERENCES asset_pm_meters(id) ON DELETE CASCADE,
|
||||||
|
reading REAL NOT NULL,
|
||||||
|
reading_date TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
completion_id INTEGER REFERENCES pm_completions(id) ON DELETE SET NULL,
|
||||||
|
created_by INTEGER REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
type TEXT NOT NULL DEFAULT 'other',
|
type TEXT NOT NULL DEFAULT 'other',
|
||||||
@@ -671,6 +704,13 @@ function initialize() {
|
|||||||
after_json TEXT,
|
after_json TEXT,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS password_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
migrate();
|
migrate();
|
||||||
@@ -705,6 +745,19 @@ function migrate() {
|
|||||||
ensureColumn('assets', 'special_zone', 'TEXT');
|
ensureColumn('assets', 'special_zone', 'TEXT');
|
||||||
ensureColumn('depreciation_zones', 'section_179_increase', 'REAL NOT NULL DEFAULT 0');
|
ensureColumn('depreciation_zones', 'section_179_increase', 'REAL NOT NULL DEFAULT 0');
|
||||||
ensureColumn('depreciation_zones', 'section_179_cost_factor', 'REAL NOT NULL DEFAULT 1');
|
ensureColumn('depreciation_zones', 'section_179_cost_factor', 'REAL NOT NULL DEFAULT 1');
|
||||||
|
ensureColumn('depreciation_zones', 'section_179_threshold_increase', 'REAL NOT NULL DEFAULT 0');
|
||||||
|
ensureColumn('depreciation_zones', 'section_179_pis_start', 'TEXT');
|
||||||
|
ensureColumn('depreciation_zones', 'section_179_pis_end', 'TEXT');
|
||||||
|
// Dynamic PM scheduling: usage-meter & lifespan-curve signals layered on the calendar interval.
|
||||||
|
ensureColumn('pm_plans', 'lifespan_adjust', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
ensureColumn('asset_pm_plans', 'lifespan_adjust', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
ensureColumn('asset_pm_plans', 'schedule_mode', "TEXT NOT NULL DEFAULT 'earliest'");
|
||||||
|
// Password access control & account-lockout columns.
|
||||||
|
ensureColumn('users', 'password_changed_at', 'TEXT');
|
||||||
|
ensureColumn('users', 'failed_attempts', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
ensureColumn('users', 'locked_until', 'TEXT');
|
||||||
|
ensureColumn('users', 'last_login_at', 'TEXT');
|
||||||
|
ensureColumn('users', 'must_change_password', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
dropUsersRoleCheck();
|
dropUsersRoleCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,9 +835,42 @@ function seed() {
|
|||||||
[createdAt, createdAt]
|
[createdAt, createdAt]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Qualified Gulf Opportunity (GO) Zone property: 50% §168 special allowance (placed in service
|
||||||
|
// 2005-08-28 through 2011-03-31, real property through 2010-12-31) AND an increased §179 limit of
|
||||||
|
// +$100,000 (2005-08-28 through 2008-12-31), with the investment-phaseout threshold raised by the
|
||||||
|
// lesser of $600,000 or the property's cost.
|
||||||
|
run(
|
||||||
|
`INSERT OR IGNORE INTO depreciation_zones
|
||||||
|
(code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years,
|
||||||
|
section_179_increase, section_179_cost_factor, section_179_threshold_increase, section_179_pis_start, section_179_pis_end,
|
||||||
|
notes, enabled, created_at, updated_at)
|
||||||
|
VALUES ('go_zone', 'Qualified Gulf Opportunity (GO) Zone', 50, '2005-08-28', '2011-03-31', '2010-12-31', 20, 9,
|
||||||
|
100000, 1, 600000, '2005-08-28', '2008-12-31',
|
||||||
|
'50% special depreciation allowance for qualified GO Zone property (placed in service 2005-08-28 to 2011-03-31; real property to 2010-12-31). Increased §179 limit of +$100,000 (2005-08-28 to 2008-12-31), with the phaseout threshold raised by the lesser of $600,000 or the property''s cost. Qualified leasehold improvements use a 9-year life. Verify current tax law before filing.', 1, ?, ?)`,
|
||||||
|
[createdAt, createdAt]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Qualified Recovery Assistance Property (Kansas Disaster Zone): 50% §168 special allowance (placed
|
||||||
|
// in service after 2007-05-04 through 2008-12-31; real property through 2009-12-31) AND an increased
|
||||||
|
// §179 limit of +$100,000 (2007-2008), with the phaseout threshold raised by the lesser of $600,000
|
||||||
|
// or the property's cost. The §168 and §179 windows coincide, so no separate §179 window is needed.
|
||||||
|
run(
|
||||||
|
`INSERT OR IGNORE INTO depreciation_zones
|
||||||
|
(code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years,
|
||||||
|
section_179_increase, section_179_cost_factor, section_179_threshold_increase, section_179_pis_start, section_179_pis_end,
|
||||||
|
notes, enabled, created_at, updated_at)
|
||||||
|
VALUES ('kansas_disaster', 'Qualified Recovery Assistance (Kansas Disaster Zone)', 50, '2007-05-05', '2008-12-31', '2009-12-31', 20, NULL,
|
||||||
|
100000, 1, 600000, NULL, NULL,
|
||||||
|
'50% special depreciation allowance for qualified recovery assistance (Kansas Disaster Zone) property (placed in service 2007-05-05 to 2008-12-31; real property to 2009-12-31). Increased §179 limit of +$100,000 (2007-2008), with the phaseout threshold raised by the lesser of $600,000 or the property''s cost. Verify current tax law before filing.', 1, ?, ?)`,
|
||||||
|
[createdAt, createdAt]
|
||||||
|
);
|
||||||
|
|
||||||
// Annual Section 179 deduction limits & investment-phaseout thresholds (IRS historical values).
|
// Annual Section 179 deduction limits & investment-phaseout thresholds (IRS historical values).
|
||||||
// Seeded once, then user-managed in Admin → Application Configuration → Section 179 limits.
|
// Seeded once, then user-managed in Admin → Application Configuration → Section 179 limits.
|
||||||
const section179Limits = [
|
const section179Limits = [
|
||||||
|
[2003, 100000, 400000], [2004, 102000, 410000], [2005, 105000, 420000], [2006, 108000, 430000],
|
||||||
|
[2007, 125000, 500000], [2008, 250000, 800000], [2009, 250000, 800000], [2010, 500000, 2000000],
|
||||||
|
[2011, 500000, 2000000], [2012, 500000, 2000000], [2013, 500000, 2000000],
|
||||||
[2014, 500000, 2000000], [2015, 500000, 2000000], [2016, 500000, 2010000], [2017, 510000, 2030000],
|
[2014, 500000, 2000000], [2015, 500000, 2000000], [2016, 500000, 2010000], [2017, 510000, 2030000],
|
||||||
[2018, 1000000, 2500000], [2019, 1020000, 2550000], [2020, 1040000, 2590000], [2021, 1050000, 2620000],
|
[2018, 1000000, 2500000], [2019, 1020000, 2550000], [2020, 1040000, 2590000], [2021, 1050000, 2620000],
|
||||||
[2022, 1080000, 2700000], [2023, 1160000, 2890000], [2024, 1220000, 3050000], [2025, 1250000, 3130000],
|
[2022, 1080000, 2700000], [2023, 1160000, 2890000], [2024, 1220000, 3050000], [2025, 1250000, 3130000],
|
||||||
@@ -800,7 +886,7 @@ function seed() {
|
|||||||
if (!one('SELECT id FROM teams LIMIT 1')) {
|
if (!one('SELECT id FROM teams LIMIT 1')) {
|
||||||
run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
|
run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
|
||||||
'Finance Operations',
|
'Finance Operations',
|
||||||
'Default MixedAssets team',
|
'Default DepreCore team',
|
||||||
createdAt
|
createdAt
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -808,14 +894,15 @@ function seed() {
|
|||||||
const team = one('SELECT id FROM teams WHERE name = ?', ['Finance Operations']);
|
const team = one('SELECT id FROM teams WHERE name = ?', ['Finance Operations']);
|
||||||
if (!one('SELECT id FROM users LIMIT 1')) {
|
if (!one('SELECT id FROM users LIMIT 1')) {
|
||||||
run(
|
run(
|
||||||
'INSERT INTO users (team_id, name, email, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO users (team_id, name, email, password_hash, role, password_changed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
[
|
[
|
||||||
team.id,
|
team.id,
|
||||||
'MixedAssets Admin',
|
'DepreCore Admin',
|
||||||
'admin@mixedassets.local',
|
'admin@deprecore.local',
|
||||||
bcrypt.hashSync(process.env.MIXEDASSETS_ADMIN_PASSWORD || 'ChangeMe123!', 12),
|
bcrypt.hashSync(process.env.DEPRECORE_ADMIN_PASSWORD || 'ChangeMe123!', 12),
|
||||||
'admin',
|
'admin',
|
||||||
createdAt,
|
createdAt,
|
||||||
|
createdAt,
|
||||||
createdAt
|
createdAt
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -858,7 +945,7 @@ function seed() {
|
|||||||
`INSERT OR IGNORE INTO employees (
|
`INSERT OR IGNORE INTO employees (
|
||||||
employee_number, name, email, department, location, manager_name, status, source, created_at, updated_at
|
employee_number, name, email, department, location, manager_name, status, source, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
['E-1001', 'Jordan Avery', 'jordan.avery@example.com', 'Operations', 'Headquarters', 'MixedAssets Admin', 'active', 'seed', createdAt, createdAt]
|
['E-1001', 'Jordan Avery', 'jordan.avery@example.com', 'Operations', 'Headquarters', 'DepreCore Admin', 'active', 'seed', createdAt, createdAt]
|
||||||
);
|
);
|
||||||
|
|
||||||
run(
|
run(
|
||||||
@@ -946,8 +1033,8 @@ function seed() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
server_fqdn: process.env.MIXEDASSETS_SERVER_FQDN || 'localhost',
|
server_fqdn: process.env.DEPRECORE_SERVER_FQDN || 'localhost',
|
||||||
cors_allowed_domains: process.env.MIXEDASSETS_CORS_ORIGINS || 'http://localhost:5173',
|
cors_allowed_domains: process.env.DEPRECORE_CORS_ORIGINS || 'http://localhost:5173',
|
||||||
database_driver: 'node:sqlite',
|
database_driver: 'node:sqlite',
|
||||||
database_path: DB_PATH,
|
database_path: DB_PATH,
|
||||||
default_books: 'GAAP,FEDERAL,STATE,AMT,ACE,USER',
|
default_books: 'GAAP,FEDERAL,STATE,AMT,ACE,USER',
|
||||||
@@ -959,7 +1046,7 @@ function seed() {
|
|||||||
smtp_secure: 'false',
|
smtp_secure: 'false',
|
||||||
smtp_user: '',
|
smtp_user: '',
|
||||||
smtp_password: '',
|
smtp_password: '',
|
||||||
smtp_from: 'MixedAssets <no-reply@mixedassets.local>',
|
smtp_from: 'DepreCore <no-reply@deprecore.local>',
|
||||||
webhook_enabled: 'false',
|
webhook_enabled: 'false',
|
||||||
webhook_url: '',
|
webhook_url: '',
|
||||||
webhook_secret: '',
|
webhook_secret: '',
|
||||||
|
|||||||
@@ -1,36 +1,57 @@
|
|||||||
|
/*
|
||||||
|
Depreciation calculation module: generates annual depreciation schedules based on asset, book, and method rules.
|
||||||
|
The main entry point is annualSchedule(), which returns an array of { year, book, method, depreciation, accumulated } rows.
|
||||||
|
Method rules can be defined in the rule catalog or directly on the book; built-in formulas include straight-line,
|
||||||
|
declining balance, sum-of-years-digits, and rate tables. Special handling for Section 179, bonus depreciation,
|
||||||
|
salvage value, and conventions is included.
|
||||||
|
*/
|
||||||
|
|
||||||
const { parseJson } = require('./db');
|
const { parseJson } = require('./db');
|
||||||
const { buildDepreciationMethods } = require('./rule-catalog');
|
const { buildDepreciationMethods } = require('./rule-catalog');
|
||||||
const { getZone } = require('./services/zones');
|
const { getZone } = require('./services/zones');
|
||||||
const { getLimit: getSection179Limit } = require('./services/section179Limits');
|
const { getLimit: getSection179Limit } = require('./services/section179Limits');
|
||||||
|
const moment = require('moment');
|
||||||
|
console.log(`${moment().format()} ✅ Depreciation module loaded with dependencies: db, rule-catalog, zones, section179Limits`);
|
||||||
|
|
||||||
// Built-in method catalog, used as a fallback so codes like MF200/DB200 always resolve
|
// Built-in method catalog, used as a fallback so codes like MF200/DB200 always resolve
|
||||||
// even if a particular rule set hasn't been re-expanded to include them.
|
// even if a particular rule set hasn't been re-expanded to include them.
|
||||||
let methodCatalog = null;
|
let methodCatalog = null;
|
||||||
function catalog() {
|
function catalog() {
|
||||||
if (!methodCatalog) methodCatalog = buildDepreciationMethods();
|
if (!methodCatalog) methodCatalog = buildDepreciationMethods();
|
||||||
|
console.log(`${moment().format()} ✅ Depreciation method catalog initialized with ${methodCatalog.length} methods`);
|
||||||
return methodCatalog;
|
return methodCatalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Round a number to 2 decimal places, returning a Number (not a string).
|
||||||
function money(value) {
|
function money(value) {
|
||||||
|
console.log(`${moment().format()} 💰 Formatting value ${value} as money`);
|
||||||
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
|
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract the year from a date string, defaulting to the current year when no value is provided.
|
||||||
function yearFromDate(value) {
|
function yearFromDate(value) {
|
||||||
|
console.log(`${moment().format()} 🕒 Converting date ${value} to year`);
|
||||||
if (!value) return new Date().getFullYear();
|
if (!value) return new Date().getFullYear();
|
||||||
return new Date(`${value}T00:00:00`).getFullYear();
|
return new Date(`${value}T00:00:00`).getFullYear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract the month from a date string, defaulting to January (1) when no value is provided. Months are 1-indexed here.
|
||||||
function monthFromDate(value) {
|
function monthFromDate(value) {
|
||||||
|
console.log(`${moment().format()} 🕒 Converting date ${value} to month`);
|
||||||
if (!value) return 1;
|
if (!value) return 1;
|
||||||
return new Date(`${value}T00:00:00`).getMonth() + 1;
|
return new Date(`${value}T00:00:00`).getMonth() + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse a value that may be JSON, returning the fallback when parsing fails or when given null/undefined.
|
||||||
function parseMaybeJson(value, fallback = null) {
|
function parseMaybeJson(value, fallback = null) {
|
||||||
|
console.log(`${moment().format()} 📄 Parsing JSON value ${value}`);
|
||||||
if (typeof value === 'string') return parseJson(value, fallback);
|
if (typeof value === 'string') return parseJson(value, fallback);
|
||||||
return value ?? fallback;
|
return value ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Business-use factor: the percentage of the asset's use that is business-related, applied as a multiplier to cost basis and salvage. Determined by the book's business_use_percent, falling back to the asset's business_use_percent, and defaulting to 100% when neither is set.
|
||||||
function businessUseFactor(asset, book) {
|
function businessUseFactor(asset, book) {
|
||||||
|
console.log(`${moment().format()} 📊 Calculating business use factor for asset ${asset.id}`);
|
||||||
return Number(book.business_use_percent ?? asset.business_use_percent ?? 100) / 100;
|
return Number(book.business_use_percent ?? asset.business_use_percent ?? 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,16 +59,20 @@ function businessUseFactor(asset, book) {
|
|||||||
function grossBasis(asset, book) {
|
function grossBasis(asset, book) {
|
||||||
const cost = Number(book.cost ?? asset.acquisition_cost ?? 0);
|
const cost = Number(book.cost ?? asset.acquisition_cost ?? 0);
|
||||||
const land = Number(asset.land_value || 0);
|
const land = Number(asset.land_value || 0);
|
||||||
|
console.log(`${moment().format()} 📈 Calculating gross basis for asset ${asset.id}: cost ${cost} - land ${land}`);
|
||||||
return Math.max(0, (cost - land) * businessUseFactor(asset, book));
|
return Math.max(0, (cost - land) * businessUseFactor(asset, book));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Salvage reduces what standard methods recover, but MACRS ignores it (ignoreSalvage flag).
|
// Salvage reduces what standard methods recover, but MACRS ignores it (ignoreSalvage flag).
|
||||||
function salvageValue(asset, book, methodRule) {
|
function salvageValue(asset, book, methodRule) {
|
||||||
if (methodRule && methodRule.ignoreSalvage) return 0;
|
if (methodRule && methodRule.ignoreSalvage) return 0;
|
||||||
|
console.log(`${moment().format()} 🛠️ Calculating salvage value for asset ${asset.id}`);
|
||||||
return Math.max(0, Number(asset.salvage_value || 0) * businessUseFactor(asset, book));
|
return Math.max(0, Number(asset.salvage_value || 0) * businessUseFactor(asset, book));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Section 179 amount: the lesser of the entered amount, the gross basis, and the applicable limit for the placed-in-service year.
|
||||||
function section179Amount(asset, book) {
|
function section179Amount(asset, book) {
|
||||||
|
console.log(`${moment().format()} 📊 Calculating Section 179 amount for asset ${asset.id}`);
|
||||||
return Math.min(grossBasis(asset, book), Number(book.section_179_amount || 0), section179Limit(asset, book));
|
return Math.min(grossBasis(asset, book), Number(book.section_179_amount || 0), section179Limit(asset, book));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,37 +80,75 @@ function section179Amount(asset, book) {
|
|||||||
// limit and a reduced (e.g. 50%) share of cost counting toward the phaseout threshold. Applies
|
// limit and a reduced (e.g. 50%) share of cost counting toward the phaseout threshold. Applies
|
||||||
// only when the asset is flagged for such a zone and placed in service within the date window.
|
// only when the asset is flagged for such a zone and placed in service within the date window.
|
||||||
function zoneSection179(asset, book) {
|
function zoneSection179(asset, book) {
|
||||||
if (!asset.special_zone || String(book.book_type) !== 'FEDERAL') return null;
|
if (!asset.special_zone || String(book.book_type) !== 'FEDERAL') {
|
||||||
|
// Note: the zone treatment applies only to the federal book, and only when the asset is flagged for a zone; the UI enforces this but we check again just in case.
|
||||||
|
console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 179 treatment`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const zone = getZone(asset.special_zone);
|
const zone = getZone(asset.special_zone);
|
||||||
if (!zone) return null;
|
if (!zone) {
|
||||||
|
// Note: this should never happen since the UI only allows selecting valid zones, but guard against it just in case.
|
||||||
|
console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 179 treatment`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const increase = Number(zone.section_179_increase || 0);
|
const increase = Number(zone.section_179_increase || 0);
|
||||||
const costFactor = Number(zone.section_179_cost_factor || 0) || 1;
|
const costFactor = Number(zone.section_179_cost_factor || 0) || 1;
|
||||||
if (!increase && costFactor === 1) return null;
|
const thresholdIncrease = Number(zone.section_179_threshold_increase || 0);
|
||||||
|
if (!increase && costFactor === 1 && !thresholdIncrease){
|
||||||
|
// Note: if the zone has no configured increase, cost factor, or threshold increase, then it provides no actual benefit for Section 179 purposes.
|
||||||
|
console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 179 treatment`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// The §179 increase can have its own (often shorter) window than the §168 allowance; fall back
|
||||||
|
// to the zone's main placed-in-service window when no §179-specific window is set.
|
||||||
|
const start = zone.section_179_pis_start || zone.pis_start;
|
||||||
|
const end = zone.section_179_pis_end || zone.pis_end;
|
||||||
const pis = asset.in_service_date || asset.acquired_date || '';
|
const pis = asset.in_service_date || asset.acquired_date || '';
|
||||||
if (pis && zone.pis_start && pis < zone.pis_start) return null;
|
if (pis && start && pis < start) {
|
||||||
if (pis && zone.pis_end && pis > zone.pis_end) return null;
|
// Note: the placed-in-service date can be on either the in_service_date or acquired_date field, depending on the asset; check both just in case.
|
||||||
return { increase, costFactor };
|
console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 179 treatment`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (pis && end && pis > end) {
|
||||||
|
// Note: the placed-in-service date can be on either the in_service_date or acquired_date field, depending on the asset; check both just in case.
|
||||||
|
console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 179 treatment`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
console.log(`${moment().format()} 🏢 Asset ${asset.id} qualifies for zone Section 179 treatment: increase ${increase}, cost factor ${costFactor}, threshold increase ${thresholdIncrease}`);
|
||||||
|
return { increase, costFactor, thresholdIncrease };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applicable §179 dollar cap for an asset's placed-in-service year: the configured base limit
|
// Applicable §179 dollar cap for an asset's placed-in-service year: the configured base limit
|
||||||
// (plus any enterprise-zone increase) less the dollar-for-dollar investment phaseout. Returns
|
// (plus any enterprise-zone increase) less the dollar-for-dollar investment phaseout. Returns
|
||||||
// Infinity when no limit is configured for the year, leaving the entered §179 amount uncapped.
|
// Infinity when no limit is configured for the year, leaving the entered §179 amount uncapped.
|
||||||
function section179Limit(asset, book) {
|
function section179Limit(asset, book) {
|
||||||
|
console.log(`${moment().format()} 📊 Calculating Section 179 limit for asset ${asset.id}`);
|
||||||
const pisYear = yearFromDate(asset.in_service_date || asset.acquired_date);
|
const pisYear = yearFromDate(asset.in_service_date || asset.acquired_date);
|
||||||
const limitRow = getSection179Limit(pisYear);
|
const limitRow = getSection179Limit(pisYear);
|
||||||
if (!limitRow) return Infinity;
|
if (!limitRow) {
|
||||||
|
// Note: this can happen when the placed-in-service year is far in the future and no limit has been configured for it; in that case, we assume no limit applies rather than trying to extrapolate based on past years.
|
||||||
|
console.log(`${moment().format()} 📊 Asset ${asset.id} has no Section 179 limit configured for year ${pisYear}`);
|
||||||
|
return Infinity;
|
||||||
|
}
|
||||||
let limit = Number(limitRow.base_limit) || 0;
|
let limit = Number(limitRow.base_limit) || 0;
|
||||||
const threshold = Number(limitRow.phaseout_threshold) || 0;
|
let threshold = Number(limitRow.phaseout_threshold) || 0;
|
||||||
let costFactor = 1;
|
let costFactor = 1;
|
||||||
const zone = zoneSection179(asset, book);
|
const zone = zoneSection179(asset, book);
|
||||||
if (zone) {
|
if (zone) {
|
||||||
|
// Note: the zone increase and cost factor can be applied even when the asset doesn't qualify for the full zone allowance (e.g. for §168) because they are based solely on the placed-in-service date and zone flag, whereas the allowance can also be gated on the book's bonus percentage.
|
||||||
|
console.log(`${moment().format()} 🏢 Asset ${asset.id} qualifies for zone Section 179 treatment`);
|
||||||
limit += zone.increase;
|
limit += zone.increase;
|
||||||
costFactor = zone.costFactor;
|
costFactor = zone.costFactor;
|
||||||
|
// GO Zone-style rule: raise the phaseout threshold by the lesser of the cap and the cost.
|
||||||
|
if (zone.thresholdIncrease > 0) threshold += Math.min(zone.thresholdIncrease, grossBasis(asset, book));
|
||||||
}
|
}
|
||||||
if (threshold > 0) {
|
if (threshold > 0) {
|
||||||
|
// Note: the phaseout applies to the gross basis multiplied by the cost factor, which allows for a more generous limit when the zone's cost factor is less than 1 (e.g. 50% for GO Zone) even if the asset's cost is high.
|
||||||
|
console.log(`${moment().format()} 📊 Calculating Section 179 phaseout for asset ${asset.id}: limit ${limit}, threshold ${threshold}, cost factor ${costFactor}`);
|
||||||
const investment = grossBasis(asset, book) * costFactor;
|
const investment = grossBasis(asset, book) * costFactor;
|
||||||
limit -= Math.max(0, investment - threshold);
|
limit -= Math.max(0, investment - threshold);
|
||||||
}
|
}
|
||||||
|
console.log(`${moment().format()} 📊 Final Section 179 limit for asset ${asset.id} is ${limit}`);
|
||||||
return Math.max(0, limit);
|
return Math.max(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,17 +156,37 @@ function section179Limit(asset, book) {
|
|||||||
// when the asset is flagged for a zone and its placed-in-service date is within the window.
|
// when the asset is flagged for a zone and its placed-in-service date is within the window.
|
||||||
// Returns the allowance % (used as the §168 bonus) or 0.
|
// Returns the allowance % (used as the §168 bonus) or 0.
|
||||||
function zoneAllowance(asset, book) {
|
function zoneAllowance(asset, book) {
|
||||||
if (!asset.special_zone || String(book.book_type) !== 'FEDERAL') return 0;
|
console.log(`${moment().format()} 📊 Calculating zone allowance for asset ${asset.id}`);
|
||||||
|
if (!asset.special_zone || String(book.book_type) !== 'FEDERAL') {
|
||||||
|
// Note: the zone allowance applies only to the federal book, and only when the asset is flagged for a zone; the UI enforces this but we check again just in case.
|
||||||
|
console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 168 treatment`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
const zone = getZone(asset.special_zone);
|
const zone = getZone(asset.special_zone);
|
||||||
if (!zone || !Number(zone.allowance_percent)) return 0;
|
if (!zone || !Number(zone.allowance_percent)) {
|
||||||
|
// Note: this should never happen since the UI only allows selecting valid zones with configured allowances, but guard against it just in case.
|
||||||
|
console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 168 treatment`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
const pis = asset.in_service_date || asset.acquired_date || '';
|
const pis = asset.in_service_date || asset.acquired_date || '';
|
||||||
if (pis && zone.pis_start && pis < zone.pis_start) return 0;
|
if (pis && zone.pis_start && pis < zone.pis_start) {
|
||||||
if (pis && zone.pis_end && pis > zone.pis_end) return 0;
|
// Note: the placed-in-service date can be on either the in_service_date or acquired_date field, depending on the asset; check both just in case.
|
||||||
|
console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 168 treatment`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (pis && zone.pis_end && pis > zone.pis_end) {
|
||||||
|
// Note: the placed-in-service date can be on either the in_service_date or acquired_date field, depending on the asset; check both just in case.
|
||||||
|
console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 168 treatment`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
console.log(`${moment().format()} 📊 Zone allowance for asset ${asset.id} is ${Number(zone.allowance_percent)}`);
|
||||||
return Number(zone.allowance_percent);
|
return Number(zone.allowance_percent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Section 168 (bonus) allowance, applied after Section 179.
|
// Section 168 (bonus) allowance, applied after Section 179.
|
||||||
function bonusAmount(asset, book) {
|
function bonusAmount(asset, book) {
|
||||||
|
// Note: the bonus percentage can be set either on the book itself or inherited from the zone when the book has no explicit bonus; check both places just in case.
|
||||||
|
console.log(`${moment().format()} 📊 Calculating bonus amount for asset ${asset.id}`);
|
||||||
const after179 = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book));
|
const after179 = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book));
|
||||||
return after179 * (Number(book.bonus_percent || 0) / 100);
|
return after179 * (Number(book.bonus_percent || 0) / 100);
|
||||||
}
|
}
|
||||||
@@ -115,70 +198,136 @@ function recoverableBasis(asset, book, methodRule) {
|
|||||||
|
|
||||||
// Amount a straight-line / SYD method depreciates: net of Section 179, bonus, and salvage.
|
// Amount a straight-line / SYD method depreciates: net of Section 179, bonus, and salvage.
|
||||||
function depreciableBasis(asset, book, methodRule) {
|
function depreciableBasis(asset, book, methodRule) {
|
||||||
return Math.max(0, grossBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book) - salvageValue(asset, book, methodRule));
|
console.log(`${moment().format()} 📊 Calculating depreciable basis for asset ${asset.id}`);
|
||||||
|
const result = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book) - salvageValue(asset, book, methodRule));
|
||||||
|
console.log(`${moment().format()} 📊 Depreciable basis for asset ${asset.id} is ${result}`);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cost basis net of salvage (no 179/bonus) — retained for reports and disposals.
|
// Cost basis net of salvage (no 179/bonus) — retained for reports and disposals.
|
||||||
function totalDepreciableBasis(asset, book) {
|
function totalDepreciableBasis(asset, book) {
|
||||||
return Math.max(0, grossBasis(asset, book) - salvageValue(asset, book, null));
|
console.log(`${moment().format()} 📊 Calculating total depreciable basis for asset ${asset.id}`);
|
||||||
|
const result = Math.max(0, grossBasis(asset, book) - salvageValue(asset, book, null));
|
||||||
|
console.log(`${moment().format()} 📊 Total depreciable basis for asset ${asset.id} is ${result}`);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine the applicable depreciation convention for a book, based on the method rule's defaultConvention, falling back to the book's convention, and defaulting to 'none' when neither is set.
|
||||||
function conventionFor(book, methodRule) {
|
function conventionFor(book, methodRule) {
|
||||||
|
console.log(`${moment().format()} 🕒 Determining depreciation convention for book ${book.id}`);
|
||||||
return methodRule.defaultConvention || book.convention || 'none';
|
return methodRule.defaultConvention || book.convention || 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate the year fraction for a given year index based on the specified convention. The index is 0-based, so 0 corresponds to the placed-in-service year. Returns a fraction between 0 and 1 representing how much of the year's depreciation should be taken in that year, or null when the convention doesn't specify an explicit fraction for that year (e.g. for mid-month, which only has a special rule for the first year).
|
||||||
function yearFraction(index, convention, asset) {
|
function yearFraction(index, convention, asset) {
|
||||||
if (index < 0) return 0;
|
console.log(`${moment().format()} 🕒 Calculating year fraction for index ${index}, convention ${convention}, asset ${asset.id}`);
|
||||||
|
if (index < 0) {
|
||||||
|
// Note: negative indices are invalid, but just in case, return 0 rather than trying to apply the convention logic.
|
||||||
|
console.log(`${moment().format()} 🕒 Index is negative for asset ${asset.id}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
const month = monthFromDate(asset.in_service_date || asset.acquired_date);
|
const month = monthFromDate(asset.in_service_date || asset.acquired_date);
|
||||||
const quarter = Math.ceil(month / 3);
|
const quarter = Math.ceil(month / 3);
|
||||||
|
|
||||||
if (convention === 'none') return 1;
|
if (convention === 'none') {
|
||||||
|
// Note: the 'none' convention means no special first-year treatment, so the fraction is always 1 for valid indices.
|
||||||
|
console.log(`${moment().format()} 🕒 Using 'none' convention for asset ${asset.id}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
if (convention === 'full_month') {
|
if (convention === 'full_month') {
|
||||||
|
// Note: the 'full_month' convention treats the placed-in-service month as a full month, so the first-year fraction is based on the number of months remaining in the year including the placed-in-service month; subsequent years have no special treatment, so return null to indicate that the default fraction of 1 should be used.
|
||||||
|
console.log(`${moment().format()} 🕒 Using 'full_month' convention for asset ${asset.id}`);
|
||||||
const first = (13 - month) / 12;
|
const first = (13 - month) / 12;
|
||||||
if (index === 0) return first;
|
if (index === 0) {
|
||||||
|
// Note: for the first year, return the calculated fraction; for subsequent years, return null to indicate that the default fraction of 1 should be used, since the 'full_month' convention only has special treatment for the first year.
|
||||||
|
console.log(`${moment().format()} 🕒 First year fraction for asset ${asset.id} is ${first}`);
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
// Note: the 'full_month' convention has no special treatment for subsequent years, so return null to indicate that the default fraction of 1 should be used.
|
||||||
|
console.log(`${moment().format()} 🕒 Using 'full_month' convention for asset ${asset.id}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (convention === 'mid_month') {
|
if (convention === 'mid_month') {
|
||||||
|
// Note: the 'mid_month' convention treats the placed-in-service month as a half month, so the first-year fraction is based on the number of months remaining in the year including the placed-in-service month, but with a 0.5 month reduction; subsequent years have no special treatment, so return null to indicate that the default fraction of 1 should be used.
|
||||||
|
console.log(`${moment().format()} 🕒 Using 'mid_month' convention for asset ${asset.id}`);
|
||||||
const first = (12 - month + 0.5) / 12;
|
const first = (12 - month + 0.5) / 12;
|
||||||
if (index === 0) return first;
|
if (index === 0) {
|
||||||
|
// Note: for the first year, return the calculated fraction; for subsequent years, return null to indicate that the default fraction of 1 should be used, since the 'mid_month' convention only has special treatment for the first year.
|
||||||
|
console.log(`${moment().format()} 🕒 First year fraction for asset ${asset.id} is ${first}`);
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
// Note: the 'mid_month' convention has no special treatment for subsequent years, so return null to indicate that the default fraction of 1 should be used.
|
||||||
|
console.log(`${moment().format()} 🕒 Using 'mid_month' convention for asset ${asset.id}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (convention === 'mid_quarter') {
|
if (convention === 'mid_quarter') {
|
||||||
|
// Note: the 'mid_quarter' convention treats the placed-in-service quarter as a half quarter, so the first-year fraction is based on the number of quarters remaining in the year including the placed-in-service quarter, but with a 0.5 quarter reduction; subsequent years have no special treatment, so return null to indicate that the default fraction of 1 should be used.
|
||||||
const firstByQuarter = { 1: 0.875, 2: 0.625, 3: 0.375, 4: 0.125 };
|
const firstByQuarter = { 1: 0.875, 2: 0.625, 3: 0.375, 4: 0.125 };
|
||||||
if (index === 0) return firstByQuarter[quarter] || 0.5;
|
if (index === 0) {
|
||||||
|
// Note: for the first year, return the calculated fraction based on the placed-in-service quarter; for subsequent years, return null to indicate that the default fraction of 1 should be used, since the 'mid_quarter' convention only has special treatment for the first year.
|
||||||
|
console.log(`${moment().format()} 🕒 First year fraction for asset ${asset.id} is ${firstByQuarter[quarter] || 0.5}`);
|
||||||
|
return firstByQuarter[quarter] || 0.5;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (convention === 'half_year') {
|
if (convention === 'half_year') {
|
||||||
if (index === 0) return 0.5;
|
// Note: the 'half_year' convention treats the placed-in-service period as a half year, so the first-year fraction is always 0.5; subsequent years have no special treatment, so return null to indicate that the default fraction of 1 should be used.
|
||||||
|
if (index === 0) {
|
||||||
|
// Note: for the first year, return the fixed fraction of 0.5; for subsequent years, return null to indicate that the default fraction of 1 should be used, since the 'half_year' convention only has special treatment for the first year.
|
||||||
|
console.log(`${moment().format()} 🕒 First year fraction for asset ${asset.id} is 0.5`);
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine the fraction of the year's depreciation to take for a given year index, based on the convention's rules. For conventions with explicit fractions for each year (e.g. 'full_month', 'mid_month', 'mid_quarter', 'half_year'), use those fractions for the first year and return 1 for subsequent years. For conventions without explicit fractions for subsequent years (e.g. 'full_month', 'mid_month'), return null for subsequent years to indicate that the default fraction of 1 should be used. For conventions with no special treatment (e.g. 'none'), return 1 for all years. Additionally, for years beyond the asset's useful life, return 0 to indicate that no depreciation should be taken.
|
||||||
function fractionForIndex(index, convention, asset, totalYears) {
|
function fractionForIndex(index, convention, asset, totalYears) {
|
||||||
|
console.log(`${moment().format()} 🕒 Calculating fraction for index ${index}, convention ${convention}, asset ${asset.id}, totalYears ${totalYears}`);
|
||||||
const first = yearFraction(0, convention, asset);
|
const first = yearFraction(0, convention, asset);
|
||||||
const explicit = yearFraction(index, convention, asset);
|
const explicit = yearFraction(index, convention, asset);
|
||||||
if (explicit !== null) return explicit;
|
if (explicit !== null){
|
||||||
|
// Note: when the convention provides an explicit fraction for the given index, use it directly; this covers the first year for conventions like 'full_month' and 'mid_month', as well as all years for a convention like 'half_year'.
|
||||||
|
console.log(`${moment().format()} 🕒 Using explicit fraction for asset ${asset.id}`);
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
const finalIndex = Math.ceil(totalYears + (1 - first)) - 1;
|
const finalIndex = Math.ceil(totalYears + (1 - first)) - 1;
|
||||||
if (index > finalIndex) return 0;
|
if (index > finalIndex){
|
||||||
if (index === finalIndex) return Math.max(0, 1 - first);
|
// Note: when the index exceeds the final index for the asset's useful life (adjusted for the first year's fraction), return 0 to indicate that no depreciation should be taken; this ensures that we don't apply depreciation in years beyond the asset's recoverable life, even if the schedule extends further.
|
||||||
|
console.log(`${moment().format()} 🕒 Index exceeds final index for asset ${asset.id}`);
|
||||||
|
return 0};
|
||||||
|
if (index === finalIndex) {
|
||||||
|
// Note: when the index is exactly the final index for the asset's useful life (adjusted for the first year's fraction), return a fraction that represents the remaining portion of the year after accounting for the first year's fraction; this ensures that we take the appropriate amount of depreciation in the final year, which may be a partial year depending on the convention.
|
||||||
|
console.log(`${moment().format()} 🕒 Using final index fraction for asset ${asset.id}`);
|
||||||
|
return Math.max(0, 1 - first);
|
||||||
|
}
|
||||||
|
console.log(`${moment().format()} 🕒 Using default fraction for asset ${asset.id}`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Straight-line depreciation: (basis / total years) * convention fraction. The basis is net of Section 179, bonus, and salvage, since those reduce the amount recoverable through standard depreciation methods.
|
||||||
function straightLine(asset, book, index, totalYears, methodRule) {
|
function straightLine(asset, book, index, totalYears, methodRule) {
|
||||||
|
console.log(`${moment().format()} 🕒 Calculating straight-line depreciation for asset ${asset.id}, index ${index}, totalYears ${totalYears}`);
|
||||||
const basis = depreciableBasis(asset, book, methodRule);
|
const basis = depreciableBasis(asset, book, methodRule);
|
||||||
|
console.log(`${moment().format()} 🕒 Depreciable basis for straight-line calculation is ${basis} for asset ${asset.id}`);
|
||||||
return (basis / totalYears) * fractionForIndex(index, conventionFor(book, methodRule), asset, totalYears);
|
return (basis / totalYears) * fractionForIndex(index, conventionFor(book, methodRule), asset, totalYears);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sum-of-years-digits depreciation: (basis * (remaining years / sum of years)) * convention fraction. The basis is net of Section 179, bonus, and salvage, since those reduce the amount recoverable through standard depreciation methods.
|
||||||
function sumOfYearsDigits(asset, book, index, totalYears, methodRule) {
|
function sumOfYearsDigits(asset, book, index, totalYears, methodRule) {
|
||||||
|
console.log(`${moment().format()} 🕒 Calculating sum-of-years-digits depreciation for asset ${asset.id}, index ${index}, totalYears ${totalYears}`);
|
||||||
const basis = depreciableBasis(asset, book, methodRule);
|
const basis = depreciableBasis(asset, book, methodRule);
|
||||||
const denominator = (totalYears * (totalYears + 1)) / 2;
|
const denominator = (totalYears * (totalYears + 1)) / 2;
|
||||||
|
console.log(`${moment().format()} 🕒 Depreciable basis for sum-of-years-digits calculation is ${basis} for asset ${asset.id}`);
|
||||||
return basis * ((totalYears - index) / denominator);
|
return basis * ((totalYears - index) / denominator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Declining balance depreciation: (book value * (multiplier / total years)) * convention fraction, with an optional switch to straight-line when that becomes more favorable. The book value is net of Section 179 and bonus, but not salvage (since salvage doesn't reduce the amount recoverable through declining balance methods, except for methods with the ignoreSalvage flag).
|
||||||
function decliningBalance(asset, book, index, totalYears, multiplier, methodRule) {
|
function decliningBalance(asset, book, index, totalYears, multiplier, methodRule) {
|
||||||
// The declining-balance rate applies to the full (post-179/bonus) book value; depreciation
|
// The declining-balance rate applies to the full (post-179/bonus) book value; depreciation
|
||||||
// is floored at salvage. MACRS methods set salvage to 0 via ignoreSalvage.
|
// is floored at salvage. MACRS methods set salvage to 0 via ignoreSalvage.
|
||||||
|
console.log(`${moment().format()} 🕒 Calculating declining balance depreciation for asset ${asset.id}, index ${index}, totalYears ${totalYears}, multiplier ${multiplier}`);
|
||||||
const start = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book));
|
const start = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book));
|
||||||
const salvage = salvageValue(asset, book, methodRule);
|
const salvage = salvageValue(asset, book, methodRule);
|
||||||
let bookValue = start;
|
let bookValue = start;
|
||||||
@@ -186,9 +335,15 @@ function decliningBalance(asset, book, index, totalYears, multiplier, methodRule
|
|||||||
const convention = conventionFor(book, methodRule);
|
const convention = conventionFor(book, methodRule);
|
||||||
|
|
||||||
for (let i = 0; i <= index; i += 1) {
|
for (let i = 0; i <= index; i += 1) {
|
||||||
|
// Note: for each year up to and including the target index, calculate the declining balance amount based on the current book value, multiplier, total years, and convention fraction; if the method has a switchToStraightLine flag, also calculate the straight-line amount based on the remaining recoverable basis and remaining life, and use whichever is greater; then reduce the book value by the selected amount and accumulate the elapsed years based on the convention fraction. For the target index, return the selected amount; for prior indices, just update the book value and elapsed years for the next iteration. If we exceed the total years before reaching the target index, return 0 since no depreciation should be taken.
|
||||||
|
console.log(`${moment().format()} 🕒 Declining balance loop for asset ${asset.id}, iteration ${i}, book value ${bookValue}, elapsed ${elapsed}`);
|
||||||
const fraction = fractionForIndex(i, convention, asset, totalYears);
|
const fraction = fractionForIndex(i, convention, asset, totalYears);
|
||||||
const recoverable = Math.max(0, bookValue - salvage);
|
const recoverable = Math.max(0, bookValue - salvage);
|
||||||
if (fraction <= 0 || recoverable <= 0) return 0;
|
if (fraction <= 0 || recoverable <= 0) {
|
||||||
|
// Note: if the convention fraction is zero or negative (which can happen for years beyond the asset's useful life, depending on the convention) or if the recoverable amount is zero or negative (which can happen when salvage is high relative to the remaining book value), then no depreciation should be taken for this year or any subsequent years, so return 0.
|
||||||
|
console.log(`${moment().format()} 🕒 Fraction or recoverable is zero for asset ${asset.id}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
const dbAmount = bookValue * (multiplier / totalYears) * fraction;
|
const dbAmount = bookValue * (multiplier / totalYears) * fraction;
|
||||||
const remainingLife = Math.max(0.0001, totalYears - elapsed);
|
const remainingLife = Math.max(0.0001, totalYears - elapsed);
|
||||||
@@ -196,33 +351,50 @@ function decliningBalance(asset, book, index, totalYears, multiplier, methodRule
|
|||||||
const selected = methodRule.switchToStraightLine ? Math.max(dbAmount, slAmount) : dbAmount;
|
const selected = methodRule.switchToStraightLine ? Math.max(dbAmount, slAmount) : dbAmount;
|
||||||
const amount = Math.min(recoverable, selected);
|
const amount = Math.min(recoverable, selected);
|
||||||
|
|
||||||
if (i === index) return amount;
|
if (i === index) {
|
||||||
|
// Note: for the target index, return the calculated amount; for prior indices, just update the book value and elapsed years for the next iteration. If we exceed the total years before reaching the target index, return 0 since no depreciation should be taken.
|
||||||
|
console.log(`${moment().format()} 🕒 Returning declining balance amount for asset ${asset.id}: ${amount}`);
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
bookValue -= amount;
|
bookValue -= amount;
|
||||||
elapsed += fraction;
|
elapsed += fraction;
|
||||||
}
|
}
|
||||||
|
console.log(`${moment().format()} 🕒 Index exceeds total years for asset ${asset.id}`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate table depreciation: (basis * rate). The basis is net of Section 179, bonus, and salvage, since those reduce the amount recoverable through standard depreciation methods. The rate is determined by the method rule's rates array based on the year index, with an optional fallback to straight-line when no rates are provided.
|
||||||
function rateTable(asset, book, index, methodRule) {
|
function rateTable(asset, book, index, methodRule) {
|
||||||
|
console.log(`${moment().format()} 🕒 Calculating rate table depreciation for asset ${asset.id}, index ${index}`);
|
||||||
const basis = depreciableBasis(asset, book, methodRule);
|
const basis = depreciableBasis(asset, book, methodRule);
|
||||||
const rates = methodRule.rates || [];
|
const rates = methodRule.rates || [];
|
||||||
if (!rates.length && methodRule.fallbackFormula === 'straight_line') {
|
if (!rates.length && methodRule.fallbackFormula === 'straight_line') {
|
||||||
|
// Note: when the method rule has no rates defined but specifies a fallback to straight-line, use the straight-line formula with the total years determined by the method rule's lifeMonths or the book's useful_life_months; this allows for flexible handling of cases where a rate table is desired but specific rates haven't been configured, while still providing a reasonable default calculation.
|
||||||
|
console.log(`${moment().format()} 🕒 No rates found for asset ${asset.id}, falling back to straight-line calculation`);
|
||||||
return straightLine(asset, book, index, Math.max(1, Math.ceil((methodRule.lifeMonths || book.useful_life_months || 60) / 12)), methodRule);
|
return straightLine(asset, book, index, Math.max(1, Math.ceil((methodRule.lifeMonths || book.useful_life_months || 60) / 12)), methodRule);
|
||||||
}
|
}
|
||||||
|
console.log(`${moment().format()} 🕒 Using rate ${Number(rates[index] || 0)} for asset ${asset.id}`);
|
||||||
return basis * Number(rates[index] || 0);
|
return basis * Number(rates[index] || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retrieve the method rule for a given code from the provided rule set, falling back to the built-in catalog and a default rule when not found. The rule set can be a JSON string or an object; if it's a string, it will be parsed as JSON. The method rule is determined by looking for a method with the matching code in the rule set's methods array, then in the built-in catalog; if no matching method is found, a default rule with the given code and formula is returned.
|
||||||
function getMethodRule(ruleSet, code) {
|
function getMethodRule(ruleSet, code) {
|
||||||
|
console.log(`${moment().format()} 🕵️ Retrieving method rule for code ${code} from rule set`);
|
||||||
const rules = typeof ruleSet === 'string' ? parseJson(ruleSet, {}) : ruleSet || {};
|
const rules = typeof ruleSet === 'string' ? parseJson(ruleSet, {}) : ruleSet || {};
|
||||||
|
console.log(`${moment().format()} 🕵️ Method rules retrieved: ${JSON.stringify(rules)}`);
|
||||||
return (rules.methods || []).find((method) => method.code === code)
|
return (rules.methods || []).find((method) => method.code === code)
|
||||||
|| catalog().find((method) => method.code === code)
|
|| catalog().find((method) => method.code === code)
|
||||||
|| { code, formula: code, label: code };
|
|| { code, formula: code, label: code };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate an annual depreciation schedule for a given asset and book based on the applicable method rule, conventions, and special treatments. The schedule includes rows for each year from the placed-in-service year to the end year (which is determined by the useful life), with columns for the year, book type, method code and label, depreciation amount, accumulated depreciation, and net book value. The calculation applies Section 179 and bonus amounts in the first year, uses the method rule's formula to calculate depreciation for each year, and ensures that depreciation does not exceed the recoverable basis of the asset.
|
||||||
function annualSchedule(asset, book, ruleSet, options = {}) {
|
function annualSchedule(asset, book, ruleSet, options = {}) {
|
||||||
|
console.log(`${moment().format()} 🕒 Generating annual depreciation schedule for asset ${asset.id}, book ${book.id}`);
|
||||||
const methodRule = getMethodRule(ruleSet, book.depreciation_method || 'straight_line');
|
const methodRule = getMethodRule(ruleSet, book.depreciation_method || 'straight_line');
|
||||||
// Apply a special-zone §168 allowance when the book has no explicit bonus of its own.
|
// Apply a special-zone §168 allowance when the book has no explicit bonus of its own.
|
||||||
if (!(Number(book.bonus_percent) > 0)) {
|
if (!(Number(book.bonus_percent) > 0)) {
|
||||||
|
// Note: the zone allowance can provide a significant boost to the first-year depreciation through the bonus, so we check for it and apply it as the book's bonus percentage when the book doesn't have its own configured bonus; this allows assets in qualified zones to get the appropriate benefit even when using a book that doesn't have an explicit bonus configured, while still allowing for more flexible handling of cases where a zone allowance is desired but specific books haven't been configured with a bonus percentage.
|
||||||
|
console.log(`${moment().format()} 🏢 Checking for zone allowance for asset ${asset.id} since book has no explicit bonus`);
|
||||||
const allowance = zoneAllowance(asset, book);
|
const allowance = zoneAllowance(asset, book);
|
||||||
if (allowance > 0) book = { ...book, bonus_percent: allowance };
|
if (allowance > 0) book = { ...book, bonus_percent: allowance };
|
||||||
}
|
}
|
||||||
@@ -236,24 +408,41 @@ function annualSchedule(asset, book, ruleSet, options = {}) {
|
|||||||
let accumulated = Number(asset.prior_depreciation || 0);
|
let accumulated = Number(asset.prior_depreciation || 0);
|
||||||
|
|
||||||
for (let year = placedInServiceYear; year <= endYear; year += 1) {
|
for (let year = placedInServiceYear; year <= endYear; year += 1) {
|
||||||
|
// Note: for each year from the placed-in-service year to the end year, calculate the depreciation based on the method rule's formula, applying Section 179 and bonus amounts in the first year, and ensuring that we don't exceed the recoverable basis of the asset; then add a row to the schedule with the year, book type, method code and label, depreciation amount, accumulated depreciation, and net book value. The loop continues through the end year to allow for cases where depreciation extends beyond the useful life, but the fractionForIndex function will return 0 for years beyond the recoverable life to ensure that no depreciation is taken in those years.
|
||||||
|
console.log(`${moment().format()} 🕒 Calculating depreciation for asset ${asset.id}, year ${year}`);
|
||||||
const index = year - placedInServiceYear;
|
const index = year - placedInServiceYear;
|
||||||
let depreciation = 0;
|
let depreciation = 0;
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
|
// Note: we only calculate depreciation for years on or after the placed-in-service year, but we still loop through the end year to allow for cases where depreciation extends beyond the useful life; the fractionForIndex function will return 0 for years beyond the recoverable life to ensure that no depreciation is taken in those years.
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
|
// Note: in the first year, apply Section 179 and bonus amounts before calculating the method depreciation, since those reduce the amount recoverable through standard depreciation methods; in subsequent years, those amounts have already been accounted for and should not be applied again.
|
||||||
depreciation += section179Amount(asset, book) + bonusAmount(asset, book);
|
depreciation += section179Amount(asset, book) + bonusAmount(asset, book);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`${moment().format()} 🕒 Evaluating method rule for asset ${asset.id}, year ${year}`);
|
||||||
if (methodRule.formula === 'rate_table') {
|
if (methodRule.formula === 'rate_table') {
|
||||||
|
// Note: for the rate table formula, we look up the rate for the current year index and apply it to the depreciable basis; if no rates are defined but a fallback to straight-line is specified, we use the straight-line formula instead with the total years determined by the method rule's lifeMonths or the book's useful_life_months. This allows for flexible handling of cases where a rate table is desired but specific rates haven't been configured, while still providing a reasonable default calculation.
|
||||||
|
console.log(`${moment().format()} 🕒 Using rate table formula for asset ${asset.id}, year ${year}`);
|
||||||
depreciation += rateTable(asset, book, index, methodRule);
|
depreciation += rateTable(asset, book, index, methodRule);
|
||||||
} else if (methodRule.formula === 'sum_of_years_digits') {
|
} else if (methodRule.formula === 'sum_of_years_digits') {
|
||||||
|
// Note: for the sum-of-years-digits formula, we calculate the sum of the years based on the total years of useful life, and apply the formula to the depreciable basis; this allows for accelerated depreciation in the earlier years of the asset's life, while still ensuring that we don't exceed the recoverable basis.
|
||||||
|
console.log(`${moment().format()} 🕒 Using sum-of-years-digits formula for asset ${asset.id}, year ${year}`);
|
||||||
depreciation += sumOfYearsDigits(asset, book, index, totalYears, methodRule);
|
depreciation += sumOfYearsDigits(asset, book, index, totalYears, methodRule);
|
||||||
} else if (methodRule.formula === 'declining_balance' || methodRule.formula === 'macrs_declining_balance') {
|
} else if (methodRule.formula === 'declining_balance' || methodRule.formula === 'macrs_declining_balance') {
|
||||||
|
// Note: for the declining balance formula, we apply the formula to the post-Section 179/bonus book value, with an optional switch to straight-line when that becomes more favorable; for MACRS declining balance, we also set salvage to 0 via the ignoreSalvage flag since MACRS doesn't allow salvage to reduce the amount recoverable through declining balance methods. This allows for accelerated depreciation in the earlier years of the asset's life, while still ensuring that we don't exceed the recoverable basis and providing appropriate handling for MACRS methods.
|
||||||
|
console.log(`${moment().format()} 🕒 Using declining balance formula for asset ${asset.id}, year ${year}`);
|
||||||
depreciation += decliningBalance(asset, book, index, totalYears, Number(methodRule.rateMultiplier || 2), methodRule);
|
depreciation += decliningBalance(asset, book, index, totalYears, Number(methodRule.rateMultiplier || 2), methodRule);
|
||||||
} else if (methodRule.formula === 'acrs_alternate_straight_line') {
|
} else if (methodRule.formula === 'acrs_alternate_straight_line') {
|
||||||
|
// Note: the ACRS alternate straight-line formula is a special case that applies a straight-line calculation with the half-year convention regardless of the book's actual convention; this allows for accurate handling of assets that fall under the ACRS rules, which have specific requirements for the depreciation schedule.
|
||||||
|
console.log(`${moment().format()} 🕒 Using ACRS alternate straight-line formula for asset ${asset.id}, year ${year}`);
|
||||||
depreciation += straightLine(asset, book, index, totalYears, { ...methodRule, defaultConvention: 'half_year' });
|
depreciation += straightLine(asset, book, index, totalYears, { ...methodRule, defaultConvention: 'half_year' });
|
||||||
} else if (methodRule.formula === 'manual') {
|
} else if (methodRule.formula === 'manual') {
|
||||||
|
// Note: for the manual formula, we look up the depreciation amount for the current year in the book's manual_depreciation field, which is expected to be a JSON object with year keys and depreciation amount values; this allows for completely custom depreciation schedules to be entered on the book, while still providing a fallback to straight-line when no manual amounts are provided.
|
||||||
|
console.log(`${moment().format()} 🕒 Using manual formula for asset ${asset.id}, year ${year}`);
|
||||||
depreciation = Number(manual[year] || 0);
|
depreciation = Number(manual[year] || 0);
|
||||||
} else {
|
} else {
|
||||||
|
// Note: for any other formula (including straight-line), we apply the straight-line calculation to the depreciable basis; this serves as a reasonable default for methods that don't have specific logic implemented, while still ensuring that we don't exceed the recoverable basis.
|
||||||
|
console.log(`${moment().format()} 🕒 Using straight-line formula for asset ${asset.id}, year ${year}`);
|
||||||
depreciation += straightLine(asset, book, index, totalYears, methodRule);
|
depreciation += straightLine(asset, book, index, totalYears, methodRule);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,6 +452,8 @@ function annualSchedule(asset, book, ruleSet, options = {}) {
|
|||||||
accumulated += depreciation;
|
accumulated += depreciation;
|
||||||
|
|
||||||
if (year >= startYear) {
|
if (year >= startYear) {
|
||||||
|
// Note: we only include rows for years on or after the specified start year, but we still loop through the end year to allow for cases where depreciation extends beyond the useful life; the fractionForIndex function will return 0 for years beyond the recoverable life to ensure that no depreciation is taken in those years, and we will cap the depreciation at the recoverable basis to ensure that we don't exceed it even if the schedule extends further.
|
||||||
|
console.log(`${moment().format()} 🕒 Adding schedule row for asset ${asset.id}, year ${year}: depreciation ${depreciation}, accumulated ${accumulated}`);
|
||||||
rows.push({
|
rows.push({
|
||||||
year,
|
year,
|
||||||
book: book.book_type,
|
book: book.book_type,
|
||||||
@@ -274,12 +465,15 @@ function annualSchedule(asset, book, ruleSet, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log(`${moment().format()} 🕒 Completed annual schedule for asset ${asset.id} with ${rows.length} rows`);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert an annual depreciation schedule to a monthly schedule by dividing each year's depreciation by 12 and creating 12 rows for each year with the corresponding month numbers. This allows for more granular reporting and analysis of depreciation on a monthly basis, while still based on the annual calculations.
|
||||||
function monthlyFromAnnual(row) {
|
function monthlyFromAnnual(row) {
|
||||||
|
console.log(`${moment().format()} 🕒 Converting annual schedule row to monthly for year ${row.year}, book ${row.book}`);
|
||||||
const monthly = money(row.depreciation / 12);
|
const monthly = money(row.depreciation / 12);
|
||||||
|
console.log(`${moment().format()} 🕒 Monthly depreciation for asset ${row.book} in year ${row.year} is ${monthly}`);
|
||||||
return Array.from({ length: 12 }, (_, index) => ({
|
return Array.from({ length: 12 }, (_, index) => ({
|
||||||
month: index + 1,
|
month: index + 1,
|
||||||
year: row.year,
|
year: row.year,
|
||||||
@@ -288,6 +482,12 @@ function monthlyFromAnnual(row) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format a number as money with two decimal places. This is used to ensure consistent formatting of depreciation amounts in the schedule, making it easier to read and analyze the results.
|
||||||
|
function money(value) {
|
||||||
|
return Number(Number(value || 0).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the functions for use in other modules. This allows the depreciation logic to be organized in a single module and reused across different parts of the application, such as in API endpoints or report generation.
|
||||||
module.exports = {
|
module.exports = {
|
||||||
annualSchedule,
|
annualSchedule,
|
||||||
depreciableBasis,
|
depreciableBasis,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
const { createApp } = require('./app');
|
const { createApp } = require('./app');
|
||||||
const { runAlertCycle } = require('./services/alerts');
|
const { runAlertCycle } = require('./services/alerts');
|
||||||
const { runScheduledSync } = require('./services/workday');
|
const { runScheduledSync } = require('./services/workday');
|
||||||
|
const { runScheduledRecompute } = require('./services/pm');
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || process.env.MIXEDASSETS_PORT || 3000);
|
const PORT = Number(process.env.PORT || process.env.DEPRECORE_PORT || 3000);
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`MixedAssets API listening on http://localhost:${PORT}`);
|
console.log(`DepreCore API listening on http://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Periodically scan for due/expiring items and email digests. These are no-ops
|
// Periodically scan for due/expiring items and email digests. These are no-ops
|
||||||
@@ -24,5 +25,20 @@ function scheduleWorkdaySync() {
|
|||||||
setInterval(tick, 60 * 60 * 1000).unref();
|
setInterval(tick, 60 * 60 * 1000).unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hourly tick that lets the PM recompute job self-gate to its configured nightly hour. Keeps dynamic
|
||||||
|
// (usage/lifespan) due-dates fresh for schedules that aren't completed or read between runs.
|
||||||
|
function schedulePmRecompute() {
|
||||||
|
const tick = () => {
|
||||||
|
try {
|
||||||
|
runScheduledRecompute();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PM recompute failed:', error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(tick, 90_000).unref();
|
||||||
|
setInterval(tick, 60 * 60 * 1000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
scheduleAlertCycle();
|
scheduleAlertCycle();
|
||||||
scheduleWorkdaySync();
|
scheduleWorkdaySync();
|
||||||
|
schedulePmRecompute();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { one } = require('../db');
|
const { one } = require('../db');
|
||||||
|
|
||||||
const JWT_SECRET = process.env.MIXEDASSETS_JWT_SECRET || 'mixedassets-local-development-secret';
|
const JWT_SECRET = process.env.DEPRECORE_JWT_SECRET || 'deprecore-local-development-secret';
|
||||||
|
|
||||||
function publicUser(user) {
|
function publicUser(user) {
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function isLocalDevOrigin(origin) {
|
|||||||
|
|
||||||
function createCorsMiddleware() {
|
function createCorsMiddleware() {
|
||||||
const persistedCors = one('SELECT value FROM app_settings WHERE key = ?', ['cors_allowed_domains'])?.value || '';
|
const persistedCors = one('SELECT value FROM app_settings WHERE key = ?', ['cors_allowed_domains'])?.value || '';
|
||||||
const allowedOrigins = (process.env.MIXEDASSETS_CORS_ORIGINS || persistedCors || 'http://localhost:5173,http://127.0.0.1:5173')
|
const allowedOrigins = (process.env.DEPRECORE_CORS_ORIGINS || persistedCors || 'http://localhost:5173,http://127.0.0.1:5173')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((origin) => origin.trim())
|
.map((origin) => origin.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -20,7 +20,7 @@ function createCorsMiddleware() {
|
|||||||
return cors({
|
return cors({
|
||||||
origin(origin, callback) {
|
origin(origin, callback) {
|
||||||
if (!origin || allowedOrigins.includes(origin) || isLocalDevOrigin(origin)) return callback(null, true);
|
if (!origin || allowedOrigins.includes(origin) || isLocalDevOrigin(origin)) return callback(null, true);
|
||||||
return callback(new Error('Origin is not allowed by MixedAssets CORS settings'));
|
return callback(new Error('Origin is not allowed by DepreCore CORS settings'));
|
||||||
},
|
},
|
||||||
credentials: true
|
credentials: true
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ const { all, audit, now, one, run } = require('../db');
|
|||||||
const { requireCapability } = require('../middleware/auth');
|
const { requireCapability } = require('../middleware/auth');
|
||||||
const { CAPABILITIES } = require('../services/accessControl');
|
const { CAPABILITIES } = require('../services/accessControl');
|
||||||
const { createRole, deleteRole, listRoles, roleExists, updateRole } = require('../services/roles');
|
const { createRole, deleteRole, listRoles, roleExists, updateRole } = require('../services/roles');
|
||||||
|
const passwordPolicy = require('../services/passwordPolicy');
|
||||||
|
const auditTrail = require('../services/auditTrail');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const userSelect = `
|
const userSelect = `
|
||||||
SELECT u.id, u.team_id, t.name AS team_name, u.name, u.email, u.role, u.status, u.created_at, u.updated_at
|
SELECT u.id, u.team_id, t.name AS team_name, u.name, u.email, u.role, u.status,
|
||||||
|
u.last_login_at, u.locked_until, u.must_change_password, u.password_changed_at,
|
||||||
|
u.created_at, u.updated_at
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN teams t ON t.id = u.team_id
|
LEFT JOIN teams t ON t.id = u.team_id
|
||||||
`;
|
`;
|
||||||
@@ -47,20 +51,32 @@ router.get('/users', requireCapability('admin.users'), (req, res) => {
|
|||||||
router.post('/users', requireCapability('admin.users'), (req, res) => {
|
router.post('/users', requireCapability('admin.users'), (req, res) => {
|
||||||
validateRole(req.body.role || 'viewer');
|
validateRole(req.body.role || 'viewer');
|
||||||
if (req.body.status && !['active', 'inactive'].includes(req.body.status)) return res.status(400).json({ error: 'Invalid status' });
|
if (req.body.status && !['active', 'inactive'].includes(req.body.status)) return res.status(400).json({ error: 'Invalid status' });
|
||||||
|
|
||||||
|
// An admin-supplied password must satisfy the policy. When none is given we issue a temporary
|
||||||
|
// password and force a change at first login (so a throwaway temp isn't policy-checked).
|
||||||
|
const supplied = req.body.password;
|
||||||
|
if (supplied) passwordPolicy.validatePassword(supplied, { name: req.body.name, email: req.body.email });
|
||||||
|
const rawPassword = supplied || 'ChangeMe123!';
|
||||||
|
const mustChange = req.body.must_change_password || !supplied ? 1 : 0;
|
||||||
|
|
||||||
const created = now();
|
const created = now();
|
||||||
|
const hash = bcrypt.hashSync(rawPassword, 12);
|
||||||
const result = run(
|
const result = run(
|
||||||
'INSERT INTO users (team_id, name, email, password_hash, role, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO users (team_id, name, email, password_hash, role, status, password_changed_at, must_change_password, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
[
|
[
|
||||||
req.body.team_id || one('SELECT id FROM teams ORDER BY id LIMIT 1')?.id,
|
req.body.team_id || one('SELECT id FROM teams ORDER BY id LIMIT 1')?.id,
|
||||||
req.body.name,
|
req.body.name,
|
||||||
req.body.email,
|
req.body.email,
|
||||||
bcrypt.hashSync(req.body.password || 'ChangeMe123!', 12),
|
hash,
|
||||||
req.body.role || 'viewer',
|
req.body.role || 'viewer',
|
||||||
req.body.status || 'active',
|
req.body.status || 'active',
|
||||||
created,
|
created,
|
||||||
|
mustChange,
|
||||||
|
created,
|
||||||
created
|
created
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
passwordPolicy.recordPassword(result.lastInsertRowid, hash);
|
||||||
audit(req.user.id, 'create', 'user', result.lastInsertRowid, null, { ...req.body, password: '[redacted]' });
|
audit(req.user.id, 'create', 'user', result.lastInsertRowid, null, { ...req.body, password: '[redacted]' });
|
||||||
res.status(201).json({ user: publicAdminUser(result.lastInsertRowid) });
|
res.status(201).json({ user: publicAdminUser(result.lastInsertRowid) });
|
||||||
});
|
});
|
||||||
@@ -79,6 +95,19 @@ router.put('/users/:id', requireCapability('admin.users'), (req, res) => {
|
|||||||
return res.status(400).json({ error: 'At least one active admin is required' });
|
return res.status(400).json({ error: 'At least one active admin is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate before any write so a rejected password leaves the row untouched.
|
||||||
|
if (req.body.password) {
|
||||||
|
passwordPolicy.validatePassword(req.body.password, {
|
||||||
|
id: Number(req.params.id),
|
||||||
|
name: req.body.name || before.name,
|
||||||
|
email: req.body.email || before.email
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMustChange = req.body.must_change_password !== undefined
|
||||||
|
? (req.body.must_change_password ? 1 : 0)
|
||||||
|
: before.must_change_password;
|
||||||
|
|
||||||
const updated = now();
|
const updated = now();
|
||||||
run(
|
run(
|
||||||
`UPDATE users SET
|
`UPDATE users SET
|
||||||
@@ -87,6 +116,7 @@ router.put('/users/:id', requireCapability('admin.users'), (req, res) => {
|
|||||||
email = ?,
|
email = ?,
|
||||||
role = ?,
|
role = ?,
|
||||||
status = ?,
|
status = ?,
|
||||||
|
must_change_password = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
[
|
[
|
||||||
@@ -95,17 +125,24 @@ router.put('/users/:id', requireCapability('admin.users'), (req, res) => {
|
|||||||
req.body.email || before.email,
|
req.body.email || before.email,
|
||||||
nextRole,
|
nextRole,
|
||||||
nextStatus,
|
nextStatus,
|
||||||
|
nextMustChange,
|
||||||
updated,
|
updated,
|
||||||
req.params.id
|
req.params.id
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (req.body.password) {
|
if (req.body.password) {
|
||||||
run('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?', [
|
const hash = bcrypt.hashSync(req.body.password, 12);
|
||||||
bcrypt.hashSync(req.body.password, 12),
|
// Admin-set password forces a change at next login unless explicitly cleared.
|
||||||
|
const forceChange = req.body.must_change_password === false ? 0 : 1;
|
||||||
|
run('UPDATE users SET password_hash = ?, password_changed_at = ?, must_change_password = ?, updated_at = ? WHERE id = ?', [
|
||||||
|
hash,
|
||||||
|
updated,
|
||||||
|
forceChange,
|
||||||
updated,
|
updated,
|
||||||
req.params.id
|
req.params.id
|
||||||
]);
|
]);
|
||||||
|
passwordPolicy.recordPassword(req.params.id, hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = publicAdminUser(req.params.id);
|
const user = publicAdminUser(req.params.id);
|
||||||
@@ -181,4 +218,51 @@ router.delete('/roles/:key', requireCapability('admin.roles'), (req, res) => {
|
|||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Account lockout --------------------------------------------------------
|
||||||
|
|
||||||
|
router.post('/users/:id/unlock', requireCapability('admin.users'), (req, res) => {
|
||||||
|
const user = publicAdminUser(req.params.id);
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
passwordPolicy.unlock(req.params.id, req.user.id);
|
||||||
|
return res.json({ user: publicAdminUser(req.params.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Password & lockout policy ----------------------------------------------
|
||||||
|
|
||||||
|
router.get('/password-policy', requireCapability('admin.security'), (req, res) => {
|
||||||
|
const policy = passwordPolicy.getPolicy();
|
||||||
|
res.json({ policy, rules: passwordPolicy.describe(policy) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/password-policy', requireCapability('admin.security'), (req, res) => {
|
||||||
|
const policy = passwordPolicy.savePolicy(req.body.policy || req.body, req.user.id);
|
||||||
|
res.json({ policy, rules: passwordPolicy.describe(policy) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Activity & audit trail -------------------------------------------------
|
||||||
|
|
||||||
|
function auditFilters(query) {
|
||||||
|
return {
|
||||||
|
actor: query.actor || null,
|
||||||
|
action: query.action || null,
|
||||||
|
entity_type: query.entity_type || null,
|
||||||
|
from: query.from || null,
|
||||||
|
to: query.to || null,
|
||||||
|
q: query.q || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/audit-logs', requireCapability('admin.audit'), (req, res) => {
|
||||||
|
const result = auditTrail.query(auditFilters(req.query), { page: req.query.page, pageSize: req.query.pageSize });
|
||||||
|
res.json({ ...result, facets: auditTrail.facets() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/audit-logs/export', requireCapability('admin.audit'), (req, res) => {
|
||||||
|
const rows = auditTrail.queryAll(auditFilters(req.query));
|
||||||
|
audit(req.user.id, 'export', 'audit_logs', 'csv', null, { count: rows.length, filters: auditFilters(req.query) });
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="audit-trail-${now().slice(0, 10)}.csv"`);
|
||||||
|
res.send(auditTrail.toCsv(rows));
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ const bcrypt = require('bcryptjs');
|
|||||||
const { audit, one } = require('../db');
|
const { audit, one } = require('../db');
|
||||||
const { publicUser, tokenFor } = require('../middleware/auth');
|
const { publicUser, tokenFor } = require('../middleware/auth');
|
||||||
const { capabilitiesForRole } = require('../services/roles');
|
const { capabilitiesForRole } = require('../services/roles');
|
||||||
|
const { isLocked, registerFailure, registerSuccess, mustChangePassword } = require('../services/passwordPolicy');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/public/bootstrap', (req, res) => {
|
router.get('/public/bootstrap', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
appName: 'MixedAssets',
|
appName: 'DepreCore',
|
||||||
setupComplete: Boolean(one('SELECT id FROM users LIMIT 1')),
|
setupComplete: Boolean(one('SELECT id FROM users LIMIT 1')),
|
||||||
database: 'sqlite'
|
database: 'sqlite'
|
||||||
});
|
});
|
||||||
@@ -17,11 +18,33 @@ router.get('/public/bootstrap', (req, res) => {
|
|||||||
router.post('/auth/login', (req, res) => {
|
router.post('/auth/login', (req, res) => {
|
||||||
const { email, password } = req.body;
|
const { email, password } = req.body;
|
||||||
const user = one('SELECT * FROM users WHERE lower(email) = lower(?) AND status = ?', [email || '', 'active']);
|
const user = one('SELECT * FROM users WHERE lower(email) = lower(?) AND status = ?', [email || '', 'active']);
|
||||||
if (!user || !bcrypt.compareSync(password || '', user.password_hash)) {
|
if (!user) return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
|
|
||||||
|
// Locked accounts are refused before the password is even checked.
|
||||||
|
if (isLocked(user)) {
|
||||||
|
const minutes = Math.max(1, Math.ceil((new Date(user.locked_until).getTime() - Date.now()) / 60000));
|
||||||
|
return res.status(423).json({ error: `Account locked. Try again in ${minutes} minute(s).`, lockedUntil: user.locked_until });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bcrypt.compareSync(password || '', user.password_hash)) {
|
||||||
|
const { locked, lockedUntil } = registerFailure(user);
|
||||||
|
audit(user.id, 'login_failed', 'user', user.id, null, { email: user.email });
|
||||||
|
if (locked) {
|
||||||
|
const minutes = Math.max(1, Math.ceil((new Date(lockedUntil).getTime() - Date.now()) / 60000));
|
||||||
|
return res.status(423).json({ error: `Too many failed attempts. Account locked for ${minutes} minute(s).`, lockedUntil });
|
||||||
|
}
|
||||||
return res.status(401).json({ error: 'Invalid email or password' });
|
return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerSuccess(user);
|
||||||
audit(user.id, 'login', 'user', user.id, null, { email: user.email });
|
audit(user.id, 'login', 'user', user.id, null, { email: user.email });
|
||||||
return res.json({ token: tokenFor(user), user: publicUser(user), capabilities: capabilitiesForRole(user.role) });
|
// Token is still issued so the user can call the self-service change endpoint when forced.
|
||||||
|
return res.json({
|
||||||
|
token: tokenFor(user),
|
||||||
|
user: publicUser(user),
|
||||||
|
capabilities: capabilitiesForRole(user.role),
|
||||||
|
mustChangePassword: mustChangePassword(user)
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ router.post('/books/:code/ledger/export', async (req, res) => {
|
|||||||
const output = await exportReport(report, format);
|
const output = await exportReport(report, format);
|
||||||
const extension = { csv: 'csv', xlsx: 'xlsx', pdf: 'pdf' }[format] || 'txt';
|
const extension = { csv: 'csv', xlsx: 'xlsx', pdf: 'pdf' }[format] || 'txt';
|
||||||
res.setHeader('Content-Type', output.contentType);
|
res.setHeader('Content-Type', output.contentType);
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="mixedassets-${req.params.code}-ledger-${year}.${extension}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="deprecore-${req.params.code}-ledger-${year}.${extension}"`);
|
||||||
return res.send(output.body);
|
return res.send(output.body);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const {
|
|||||||
getSettings,
|
getSettings,
|
||||||
listCompletions,
|
listCompletions,
|
||||||
listPlans,
|
listPlans,
|
||||||
|
logReading,
|
||||||
|
recomputeAllSchedules,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
updateAssetPm,
|
updateAssetPm,
|
||||||
updatePlan
|
updatePlan
|
||||||
@@ -56,6 +58,11 @@ router.put('/pm-settings', planEditor, (req, res) => {
|
|||||||
res.json({ settings: saveSettings(req.body, req.user.id) });
|
res.json({ settings: saveSettings(req.body, req.user.id) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Manually run the schedule recompute now ("Run now" in PM settings).
|
||||||
|
router.post('/pm-recompute', planEditor, (req, res) => {
|
||||||
|
res.json({ result: recomputeAllSchedules(req.user.id), settings: getSettings() });
|
||||||
|
});
|
||||||
|
|
||||||
// Completed PM forms
|
// Completed PM forms
|
||||||
router.get('/pm-completions', (req, res) => {
|
router.get('/pm-completions', (req, res) => {
|
||||||
res.json({ completions: listCompletions(req.query) });
|
res.json({ completions: listCompletions(req.query) });
|
||||||
@@ -93,4 +100,15 @@ router.post('/assets/:id/pm/:apId/complete', assetPm, (req, res) => {
|
|||||||
return res.json({ pm });
|
return res.json({ pm });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Log a standalone usage meter reading (quick-log / inspection) and recompute the schedule.
|
||||||
|
router.post('/assets/:id/pm/:apId/readings', assetPm, (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const pm = logReading(req.params.id, req.params.apId, req.body, req.user.id);
|
||||||
|
if (!pm) return res.status(404).json({ error: 'PM schedule not found' });
|
||||||
|
return res.json({ pm });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { audit } = require('../db');
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { audit, now, one, run } = require('../db');
|
||||||
const { getProfile, savePreferences } = require('../services/profile');
|
const { getProfile, savePreferences } = require('../services/profile');
|
||||||
|
const {
|
||||||
|
describe, getPolicy, minAgeRemainingMinutes, recordPassword, validatePassword
|
||||||
|
} = require('../services/passwordPolicy');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/profile', (req, res) => {
|
router.get('/profile', (req, res) => {
|
||||||
res.json(getProfile(req.user));
|
res.json({ ...getProfile(req.user), passwordPolicy: { rules: describe() } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/profile', (req, res) => {
|
router.put('/profile', (req, res) => {
|
||||||
@@ -14,4 +18,31 @@ router.put('/profile', (req, res) => {
|
|||||||
res.json({ user: getProfile(req.user).user, preferences });
|
res.json({ user: getProfile(req.user).user, preferences });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Self-service password change. Honors the configured policy (complexity, identifiers, history,
|
||||||
|
// and minimum age), then clears any forced-change flag.
|
||||||
|
router.put('/profile/password', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { current_password: current, new_password: next } = req.body || {};
|
||||||
|
const user = one('SELECT * FROM users WHERE id = ?', [req.user.id]);
|
||||||
|
if (!user || !bcrypt.compareSync(current || '', user.password_hash)) {
|
||||||
|
return res.status(400).json({ error: 'Your current password is incorrect' });
|
||||||
|
}
|
||||||
|
// The minimum-age rule never blocks a forced change (expired / admin-required).
|
||||||
|
const remaining = minAgeRemainingMinutes(user, getPolicy());
|
||||||
|
if (remaining > 0 && !user.must_change_password) {
|
||||||
|
return res.status(400).json({ error: `You can change your password again in ${remaining} minute(s)` });
|
||||||
|
}
|
||||||
|
validatePassword(next, { id: user.id, name: user.name, email: user.email });
|
||||||
|
const hash = bcrypt.hashSync(next, 12);
|
||||||
|
run('UPDATE users SET password_hash = ?, password_changed_at = ?, must_change_password = 0, updated_at = ? WHERE id = ?', [
|
||||||
|
hash, now(), now(), user.id
|
||||||
|
]);
|
||||||
|
recordPassword(user.id, hash);
|
||||||
|
audit(user.id, 'password_change', 'user', user.id, null, { self: true });
|
||||||
|
return res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -129,11 +129,18 @@ function categoryRow(row) {
|
|||||||
const meta = parseJson(row.metadata, {});
|
const meta = parseJson(row.metadata, {});
|
||||||
const templateId = meta.id_template_id || null;
|
const templateId = meta.id_template_id || null;
|
||||||
const template = templateId ? one('SELECT name, pattern FROM asset_id_templates WHERE id = ?', [templateId]) : null;
|
const template = templateId ? one('SELECT name, pattern FROM asset_id_templates WHERE id = ?', [templateId]) : null;
|
||||||
|
// Categories link to an asset class by its unique id (class numbers are NOT unique — e.g. 00.12
|
||||||
|
// covers both Computers and Casinos). The number/label are resolved from the linked class so the
|
||||||
|
// right one is always shown; a manually-typed number (no id) falls back to that text.
|
||||||
|
const classId = meta.asset_class_id || null;
|
||||||
|
const cls = classId ? one('SELECT id, class_number, description FROM asset_classes WHERE id = ?', [classId]) : null;
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
asset_class_number: meta.asset_class_number || null,
|
asset_class_id: cls ? cls.id : null,
|
||||||
|
asset_class_number: cls ? cls.class_number : (meta.asset_class_number || null),
|
||||||
|
asset_class_label: cls ? `${cls.class_number} — ${cls.description}` : null,
|
||||||
id_template_id: template ? templateId : null,
|
id_template_id: template ? templateId : null,
|
||||||
id_template_name: template ? template.name : null,
|
id_template_name: template ? template.name : null,
|
||||||
id_template_pattern: template ? template.pattern : null,
|
id_template_pattern: template ? template.pattern : null,
|
||||||
@@ -141,6 +148,20 @@ function categoryRow(row) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the class number a category should carry: the linked class's number wins; otherwise a
|
||||||
|
// manually-entered number. Returns { classId, classNumber }.
|
||||||
|
function resolveCategoryClass(body, prev = {}) {
|
||||||
|
if (body.asset_class_id !== undefined && body.asset_class_id !== null && body.asset_class_id !== '') {
|
||||||
|
const cls = one('SELECT class_number FROM asset_classes WHERE id = ?', [body.asset_class_id]);
|
||||||
|
if (cls) return { classId: Number(body.asset_class_id), classNumber: cls.class_number };
|
||||||
|
}
|
||||||
|
// Explicit null id, or a manual number with no id → manual override (clears the link).
|
||||||
|
if (body.asset_class_id === null || body.asset_class_id === '' || body.asset_class_number !== undefined) {
|
||||||
|
return { classId: null, classNumber: body.asset_class_number || null };
|
||||||
|
}
|
||||||
|
return { classId: prev.asset_class_id || null, classNumber: prev.asset_class_number || null };
|
||||||
|
}
|
||||||
|
|
||||||
const categoryEditor = requireCapability('config.manage');
|
const categoryEditor = requireCapability('config.manage');
|
||||||
|
|
||||||
router.get('/asset-categories', (req, res) => {
|
router.get('/asset-categories', (req, res) => {
|
||||||
@@ -154,9 +175,10 @@ router.post('/asset-categories', categoryEditor, (req, res) => {
|
|||||||
if (one("SELECT id FROM reference_values WHERE type = 'category' AND name = ?", [name])) {
|
if (one("SELECT id FROM reference_values WHERE type = 'category' AND name = ?", [name])) {
|
||||||
return res.status(400).json({ error: 'That category already exists' });
|
return res.status(400).json({ error: 'That category already exists' });
|
||||||
}
|
}
|
||||||
|
const { classId, classNumber } = resolveCategoryClass(req.body);
|
||||||
const result = run(
|
const result = run(
|
||||||
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('category', ?, ?, ?)",
|
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('category', ?, ?, ?)",
|
||||||
[name, req.body.code || null, json({ id_template_id: req.body.id_template_id || null, asset_class_number: req.body.asset_class_number || null })]
|
[name, req.body.code || null, json({ id_template_id: req.body.id_template_id || null, asset_class_id: classId, asset_class_number: classNumber })]
|
||||||
);
|
);
|
||||||
audit(req.user.id, 'create', 'asset_category', result.lastInsertRowid, null, { name });
|
audit(req.user.id, 'create', 'asset_category', result.lastInsertRowid, null, { name });
|
||||||
return res.status(201).json({ category: categoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
return res.status(201).json({ category: categoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
||||||
@@ -172,14 +194,19 @@ router.put('/asset-categories/:id', categoryEditor, (req, res) => {
|
|||||||
}
|
}
|
||||||
const meta = parseJson(before.metadata, {});
|
const meta = parseJson(before.metadata, {});
|
||||||
if (req.body.id_template_id !== undefined) meta.id_template_id = req.body.id_template_id || null;
|
if (req.body.id_template_id !== undefined) meta.id_template_id = req.body.id_template_id || null;
|
||||||
if (req.body.asset_class_number !== undefined) meta.asset_class_number = req.body.asset_class_number || null;
|
const classChanged = req.body.asset_class_id !== undefined || req.body.asset_class_number !== undefined;
|
||||||
|
if (classChanged) {
|
||||||
|
const resolved = resolveCategoryClass(req.body, meta);
|
||||||
|
meta.asset_class_id = resolved.classId;
|
||||||
|
meta.asset_class_number = resolved.classNumber;
|
||||||
|
}
|
||||||
run('UPDATE reference_values SET name = ?, code = ?, metadata = ? WHERE id = ?', [name, req.body.code ?? before.code, json(meta), req.params.id]);
|
run('UPDATE reference_values SET name = ?, code = ?, metadata = ? WHERE id = ?', [name, req.body.code ?? before.code, json(meta), req.params.id]);
|
||||||
let renamed = 0;
|
let renamed = 0;
|
||||||
if (name !== before.name) {
|
if (name !== before.name) {
|
||||||
renamed = run('UPDATE assets SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0;
|
renamed = run('UPDATE assets SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0;
|
||||||
}
|
}
|
||||||
// The class number is shared by every asset in the category — cascade any change.
|
// The class number is shared by every asset in the category — cascade any change.
|
||||||
if (req.body.asset_class_number !== undefined) {
|
if (classChanged) {
|
||||||
run('UPDATE assets SET asset_class_number = ?, updated_at = ? WHERE category = ?', [meta.asset_class_number, now(), name]);
|
run('UPDATE assets SET asset_class_number = ?, updated_at = ? WHERE category = ?', [meta.asset_class_number, now(), name]);
|
||||||
}
|
}
|
||||||
audit(req.user.id, 'update', 'asset_category', req.params.id, before, { name, renamed_assets: renamed });
|
audit(req.user.id, 'update', 'asset_category', req.params.id, before, { name, renamed_assets: renamed });
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ router.post('/reports/export', async (req, res) => {
|
|||||||
const output = await exportReport(report, format);
|
const output = await exportReport(report, format);
|
||||||
const extension = EXPORT_EXTENSIONS[format] || 'txt';
|
const extension = EXPORT_EXTENSIONS[format] || 'txt';
|
||||||
res.setHeader('Content-Type', output.contentType);
|
res.setHeader('Content-Type', output.contentType);
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="mixedassets-${req.body.type}.${extension}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="deprecore-${req.body.type}.${extension}"`);
|
||||||
res.send(output.body);
|
res.send(output.body);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
|
/*
|
||||||
|
This module defines the catalog of depreciation methods available for use in rules. It includes functions to build the full catalog of methods, as well as helper functions to create specific types of methods (e.g., MACRS, ACRS, GAAP). The main function `expandRuleSet` takes a rule set and adds the full method catalog to it, along with counts of methods by family.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const moment = require('moment');
|
||||||
|
|
||||||
|
// Helper function to convert years to months, with logging for debugging
|
||||||
function months(years) {
|
function months(years) {
|
||||||
|
console.log(`${moment().format()} 🕒 Converting years to months: ${years} years is ${Math.round(Number(years) * 12)} months`);
|
||||||
return Math.round(Number(years) * 12);
|
return Math.round(Number(years) * 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Factory function to create a depreciation method object, with logging for debugging
|
||||||
function method(code, family, label, formula, options = {}) {
|
function method(code, family, label, formula, options = {}) {
|
||||||
|
console.log(`${moment().format()} 🕒 Creating method: ${code}`);
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
family,
|
family,
|
||||||
@@ -12,7 +22,9 @@ function method(code, family, label, formula, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Functions to build specific types of methods, with logging to trace the creation process
|
||||||
function macrsDeclining(prefix, labelPrefix, system, multiplier, lives, options = {}) {
|
function macrsDeclining(prefix, labelPrefix, system, multiplier, lives, options = {}) {
|
||||||
|
console.log(`${moment().format()} 🕒 Creating MACRS declining balance methods for lives: ${lives.join(', ')}`);
|
||||||
return lives.map((life) => method(
|
return lives.map((life) => method(
|
||||||
`${prefix}_${String(life).replace('.', '_')}`,
|
`${prefix}_${String(life).replace('.', '_')}`,
|
||||||
'MACRS',
|
'MACRS',
|
||||||
@@ -29,7 +41,9 @@ function macrsDeclining(prefix, labelPrefix, system, multiplier, lives, options
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Straight-line methods for a range of lives, with logging to trace the creation process
|
||||||
function straightLineMethods(prefix, family, labelPrefix, system, lives, options = {}) {
|
function straightLineMethods(prefix, family, labelPrefix, system, lives, options = {}) {
|
||||||
|
console.log(`${moment().format()} 🕒 Creating straight-line methods for lives: ${lives.join(', ')}`);
|
||||||
return lives.map((life) => method(
|
return lives.map((life) => method(
|
||||||
`${prefix}_${String(life).replace('.', '_')}`,
|
`${prefix}_${String(life).replace('.', '_')}`,
|
||||||
family,
|
family,
|
||||||
@@ -44,7 +58,9 @@ function straightLineMethods(prefix, family, labelPrefix, system, lives, options
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ACRS methods based on specified rates, with logging to trace the creation process
|
||||||
function acrsRateMethod(code, label, life, rates, options = {}) {
|
function acrsRateMethod(code, label, life, rates, options = {}) {
|
||||||
|
console.log(`${moment().format()} 🕒 Creating ACRS rate method: ${code}`);
|
||||||
return method(code, 'ACRS', label, 'rate_table', {
|
return method(code, 'ACRS', label, 'rate_table', {
|
||||||
system: 'ACRS',
|
system: 'ACRS',
|
||||||
lifeMonths: months(life),
|
lifeMonths: months(life),
|
||||||
@@ -54,7 +70,9 @@ function acrsRateMethod(code, label, life, rates, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Main function to build the full catalog of depreciation methods, with logging to trace the overall process
|
||||||
function buildMacrsMethods() {
|
function buildMacrsMethods() {
|
||||||
|
console.log(`${moment().format()} 🕒 Building MACRS methods`);
|
||||||
return [
|
return [
|
||||||
...macrsDeclining('macrs_gds_200db', 'MACRS GDS 200% DB', 'GDS', 2, [3, 5, 7, 10]),
|
...macrsDeclining('macrs_gds_200db', 'MACRS GDS 200% DB', 'GDS', 2, [3, 5, 7, 10]),
|
||||||
...macrsDeclining('macrs_gds_150db', 'MACRS GDS 150% DB', 'GDS', 1.5, [3, 5, 7, 10, 15, 20]),
|
...macrsDeclining('macrs_gds_150db', 'MACRS GDS 150% DB', 'GDS', 1.5, [3, 5, 7, 10, 15, 20]),
|
||||||
@@ -67,7 +85,9 @@ function buildMacrsMethods() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ACRS methods, including regular and alternate straight-line options, with logging to trace the creation process
|
||||||
function buildAcrsMethods() {
|
function buildAcrsMethods() {
|
||||||
|
console.log(`${moment().format()} 🕒 Building ACRS methods`);
|
||||||
const regular = [
|
const regular = [
|
||||||
acrsRateMethod('acrs_regular_3', 'ACRS regular 3-year', 3, [0.25, 0.38, 0.37]),
|
acrsRateMethod('acrs_regular_3', 'ACRS regular 3-year', 3, [0.25, 0.38, 0.37]),
|
||||||
acrsRateMethod('acrs_regular_5', 'ACRS regular 5-year', 5, [0.15, 0.22, 0.21, 0.21, 0.21]),
|
acrsRateMethod('acrs_regular_5', 'ACRS regular 5-year', 5, [0.15, 0.22, 0.21, 0.21, 0.21]),
|
||||||
@@ -79,11 +99,13 @@ function buildAcrsMethods() {
|
|||||||
acrsRateMethod('acrs_regular_listed_property', 'ACRS listed property predominant-use table', 5, [], { requiresListedPropertyTable: true, fallbackFormula: 'straight_line' })
|
acrsRateMethod('acrs_regular_listed_property', 'ACRS listed property predominant-use table', 5, [], { requiresListedPropertyTable: true, fallbackFormula: 'straight_line' })
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ACRS alternate straight-line methods for the same lives, with logging to trace the creation process
|
||||||
const alternate = straightLineMethods('acrs_alternate_sl', 'ACRS', 'ACRS alternate modified straight-line', 'ACRS', [3, 5, 10, 12, 15, 18, 19, 25], {
|
const alternate = straightLineMethods('acrs_alternate_sl', 'ACRS', 'ACRS alternate modified straight-line', 'ACRS', [3, 5, 10, 12, 15, 18, 19, 25], {
|
||||||
formula: 'acrs_alternate_straight_line',
|
formula: 'acrs_alternate_straight_line',
|
||||||
defaultConvention: 'half_year'
|
defaultConvention: 'half_year'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ACRS required straight-line methods for certain lives, with logging to trace the creation process
|
||||||
const straightLine = straightLineMethods('acrs_required_sl', 'ACRS', 'ACRS required straight-line', 'ACRS', [3, 5, 10, 15, 18, 19, 35, 45], {
|
const straightLine = straightLineMethods('acrs_required_sl', 'ACRS', 'ACRS required straight-line', 'ACRS', [3, 5, 10, 15, 18, 19, 35, 45], {
|
||||||
formula: 'straight_line',
|
formula: 'straight_line',
|
||||||
defaultConvention: 'half_year',
|
defaultConvention: 'half_year',
|
||||||
@@ -93,7 +115,9 @@ function buildAcrsMethods() {
|
|||||||
return [...regular, ...alternate, ...straightLine];
|
return [...regular, ...alternate, ...straightLine];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GAAP methods, including straight-line, sum-of-years-digits, and various declining balance options, with logging to trace the creation process
|
||||||
function buildGaapMethods() {
|
function buildGaapMethods() {
|
||||||
|
console.log(`${moment().format()} 🕒 Building GAAP methods`);
|
||||||
return [
|
return [
|
||||||
method('straight_line', 'GAAP', 'Straight-line', 'straight_line'),
|
method('straight_line', 'GAAP', 'Straight-line', 'straight_line'),
|
||||||
method('sum_of_years_digits', 'GAAP', 'Sum-of-years-digits', 'sum_of_years_digits'),
|
method('sum_of_years_digits', 'GAAP', 'Sum-of-years-digits', 'sum_of_years_digits'),
|
||||||
@@ -104,7 +128,9 @@ function buildGaapMethods() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special methods that don't fit into the main families, with logging to trace the creation process
|
||||||
function buildSpecialMethods() {
|
function buildSpecialMethods() {
|
||||||
|
console.log(`${moment().format()} 🕒 Building special methods`);
|
||||||
return [
|
return [
|
||||||
method('manual', 'Manual', 'Manual depreciation entry', 'manual'),
|
method('manual', 'Manual', 'Manual depreciation entry', 'manual'),
|
||||||
method('amortization', 'Lease', 'Straight-line amortization', 'straight_line')
|
method('amortization', 'Lease', 'Straight-line amortization', 'straight_line')
|
||||||
@@ -115,6 +141,7 @@ function buildSpecialMethods() {
|
|||||||
// salvage value and recovers the full basis; the standard family (DB/DH/DD) honors salvage
|
// salvage value and recovers the full basis; the standard family (DB/DH/DD) honors salvage
|
||||||
// (depreciates only down to it). All use a 200% rate and switch to straight-line.
|
// (depreciates only down to it). All use a 200% rate and switch to straight-line.
|
||||||
function build200Methods() {
|
function build200Methods() {
|
||||||
|
console.log(`${moment().format()} 🕒 Building 200% declining balance methods`);
|
||||||
return [
|
return [
|
||||||
method('MF200', 'MACRS', 'MF200 — MACRS Formula, 200% DB (GDS)', 'macrs_declining_balance', {
|
method('MF200', 'MACRS', 'MF200 — MACRS Formula, 200% DB (GDS)', 'macrs_declining_balance', {
|
||||||
system: 'GDS', rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year', ignoreSalvage: true
|
system: 'GDS', rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year', ignoreSalvage: true
|
||||||
@@ -135,9 +162,12 @@ function build200Methods() {
|
|||||||
rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'none'
|
rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'none'
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
console.log(`${moment().format()} 🕒 Completed building 200% declining balance methods`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Main function to build the full catalog of depreciation methods, with logging to trace the overall process
|
||||||
function buildDepreciationMethods() {
|
function buildDepreciationMethods() {
|
||||||
|
console.log(`${moment().format()} 🕒 Building full depreciation method catalog`);
|
||||||
return [
|
return [
|
||||||
...build200Methods(),
|
...build200Methods(),
|
||||||
...buildMacrsMethods(),
|
...buildMacrsMethods(),
|
||||||
@@ -145,13 +175,16 @@ function buildDepreciationMethods() {
|
|||||||
...buildGaapMethods(),
|
...buildGaapMethods(),
|
||||||
...buildSpecialMethods()
|
...buildSpecialMethods()
|
||||||
];
|
];
|
||||||
|
console.log(`${moment().format()} 🕒 Completed building full depreciation method catalog with ${methods.length} methods`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to expand a rule set with the full method catalog and counts of methods by family, with logging to trace the process
|
||||||
function expandRuleSet(ruleSet) {
|
function expandRuleSet(ruleSet) {
|
||||||
|
console.log(`${moment().format()} 🕒 Expanding rule set with method catalog`);
|
||||||
const methods = buildDepreciationMethods();
|
const methods = buildDepreciationMethods();
|
||||||
return {
|
return {
|
||||||
...ruleSet,
|
...ruleSet,
|
||||||
methodCatalog: ruleSet.methodCatalog || 'mixedassets-68-us-tax-and-accounting-v1',
|
methodCatalog: ruleSet.methodCatalog || 'deprecore-68-us-tax-and-accounting-v1',
|
||||||
methodCounts: {
|
methodCounts: {
|
||||||
macrs: methods.filter((item) => item.family === 'MACRS').length,
|
macrs: methods.filter((item) => item.family === 'MACRS').length,
|
||||||
acrs: methods.filter((item) => item.family === 'ACRS').length,
|
acrs: methods.filter((item) => item.family === 'ACRS').length,
|
||||||
@@ -163,6 +196,8 @@ function expandRuleSet(ruleSet) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export the main functions for building the method catalog and expanding rule sets, with logging to indicate when these functions are called
|
||||||
|
console.log(`${moment().format()} 🕒 Initializing rule catalog module`);
|
||||||
module.exports = {
|
module.exports = {
|
||||||
buildDepreciationMethods,
|
buildDepreciationMethods,
|
||||||
expandRuleSet
|
expandRuleSet
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const CAPABILITIES = [
|
|||||||
// Administration
|
// Administration
|
||||||
{ key: 'admin.users', label: 'Manage users & teams', group: 'Administration' },
|
{ key: 'admin.users', label: 'Manage users & teams', group: 'Administration' },
|
||||||
{ key: 'admin.roles', label: 'Manage roles', group: 'Administration' },
|
{ key: 'admin.roles', label: 'Manage roles', group: 'Administration' },
|
||||||
|
{ key: 'admin.security', label: 'Manage password & lockout policy', group: 'Administration' },
|
||||||
|
{ key: 'admin.audit', label: 'View activity & audit trail', group: 'Administration' },
|
||||||
{ key: 'admin.settings', label: 'Manage application settings', group: 'Administration' },
|
{ key: 'admin.settings', label: 'Manage application settings', group: 'Administration' },
|
||||||
{ key: 'admin.integrations', label: 'Manage integrations (email, webhook, ServiceNow, Workday)', group: 'Administration' }
|
{ key: 'admin.integrations', label: 'Manage integrations (email, webhook, ServiceNow, Workday)', group: 'Administration' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -151,14 +151,14 @@ function digestHtml(alerts) {
|
|||||||
`<td style="padding:6px 10px">${a.title}</td><td style="padding:6px 10px">${a.message || ''}</td>` +
|
`<td style="padding:6px 10px">${a.title}</td><td style="padding:6px 10px">${a.message || ''}</td>` +
|
||||||
`<td style="padding:6px 10px">${a.due_date || ''}</td></tr>`
|
`<td style="padding:6px 10px">${a.due_date || ''}</td></tr>`
|
||||||
).join('');
|
).join('');
|
||||||
return `<h2>MixedAssets alerts</h2><p>${alerts.length} item(s) need attention.</p>` +
|
return `<h2>DepreCore alerts</h2><p>${alerts.length} item(s) need attention.</p>` +
|
||||||
`<table style="border-collapse:collapse;font-family:Arial,sans-serif;font-size:13px">` +
|
`<table style="border-collapse:collapse;font-family:Arial,sans-serif;font-size:13px">` +
|
||||||
`<thead><tr><th align="left" style="padding:6px 10px">Severity</th><th align="left" style="padding:6px 10px">Alert</th>` +
|
`<thead><tr><th align="left" style="padding:6px 10px">Severity</th><th align="left" style="padding:6px 10px">Alert</th>` +
|
||||||
`<th align="left" style="padding:6px 10px">Detail</th><th align="left" style="padding:6px 10px">Due</th></tr></thead><tbody>${rows}</tbody></table>`;
|
`<th align="left" style="padding:6px 10px">Detail</th><th align="left" style="padding:6px 10px">Due</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function digestText(alerts) {
|
function digestText(alerts) {
|
||||||
return `MixedAssets alerts (${alerts.length}):\n` +
|
return `DepreCore alerts (${alerts.length}):\n` +
|
||||||
alerts.map((a) => `- [${a.severity}] ${a.title} — ${a.message || ''} ${a.due_date ? `(due ${a.due_date})` : ''}`).join('\n');
|
alerts.map((a) => `- [${a.severity}] ${a.title} — ${a.message || ''} ${a.due_date ? `(due ${a.due_date})` : ''}`).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ async function runAlertCycle(userId) {
|
|||||||
|
|
||||||
if (emailReady) {
|
if (emailReady) {
|
||||||
await sendMail({
|
await sendMail({
|
||||||
subject: `MixedAssets: ${pending.length} new alert(s)`,
|
subject: `DepreCore: ${pending.length} new alert(s)`,
|
||||||
html: digestHtml(pending),
|
html: digestHtml(pending),
|
||||||
text: digestText(pending)
|
text: digestText(pending)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,18 +2,32 @@ const { all, audit, now, one, parseJson, run } = require('../db');
|
|||||||
|
|
||||||
const SOURCES = ['common', 'industry', 'business', 'custom'];
|
const SOURCES = ['common', 'industry', 'business', 'custom'];
|
||||||
|
|
||||||
// How many asset categories reference a given class number (categories store it in metadata).
|
// How many asset categories reference a class. Categories link by unique asset_class_id; older
|
||||||
|
// records that only stored asset_class_number fall back to matching on the (non-unique) number.
|
||||||
function categoryUsage() {
|
function categoryUsage() {
|
||||||
const counts = {};
|
const byId = {};
|
||||||
|
const byNumber = {};
|
||||||
for (const row of all("SELECT metadata FROM reference_values WHERE type = 'category'")) {
|
for (const row of all("SELECT metadata FROM reference_values WHERE type = 'category'")) {
|
||||||
const num = parseJson(row.metadata, {}).asset_class_number;
|
const meta = parseJson(row.metadata, {});
|
||||||
if (num) counts[num] = (counts[num] || 0) + 1;
|
if (meta.asset_class_id) byId[meta.asset_class_id] = (byId[meta.asset_class_id] || 0) + 1;
|
||||||
|
else if (meta.asset_class_number) byNumber[meta.asset_class_number] = (byNumber[meta.asset_class_number] || 0) + 1;
|
||||||
}
|
}
|
||||||
return counts;
|
// Class numbers are not unique (e.g. 00.12 is shared by Computers, Casinos, and a dozen others). A
|
||||||
|
// category that only stored a number can't be pinned to one class, so the number fallback is only
|
||||||
|
// safe when exactly one class carries that number — otherwise it would falsely flag every sharing
|
||||||
|
// class as "assigned."
|
||||||
|
const numberOwners = {};
|
||||||
|
for (const row of all('SELECT class_number FROM asset_classes WHERE class_number IS NOT NULL')) {
|
||||||
|
numberOwners[row.class_number] = (numberOwners[row.class_number] || 0) + 1;
|
||||||
|
}
|
||||||
|
return { byId, byNumber, numberOwners };
|
||||||
}
|
}
|
||||||
|
|
||||||
function fromRow(row, usage) {
|
function fromRow(row, usage) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
const idCount = usage.byId[row.id] || 0;
|
||||||
|
const uniqueNumber = row.class_number && usage.numberOwners[row.class_number] === 1;
|
||||||
|
const numberCount = uniqueNumber ? (usage.byNumber[row.class_number] || 0) : 0;
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
class_number: row.class_number,
|
class_number: row.class_number,
|
||||||
@@ -22,7 +36,7 @@ function fromRow(row, usage) {
|
|||||||
ads: row.ads,
|
ads: row.ads,
|
||||||
source: row.source,
|
source: row.source,
|
||||||
table: row.source, // back-compat alias used by the category picker
|
table: row.source, // back-compat alias used by the category picker
|
||||||
category_count: row.class_number ? (usage[row.class_number] || 0) : 0
|
category_count: idCount + numberCount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
92
server/services/auditTrail.js
Normal file
92
server/services/auditTrail.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
const { all, one } = require('../db');
|
||||||
|
|
||||||
|
// Read-only access to the system-wide audit trail captured by db.audit(). Powers the admin
|
||||||
|
// Activity & Audit Trail viewer: filtered + paginated queries, filter facets, and CSV export.
|
||||||
|
|
||||||
|
const MAX_PAGE_SIZE = 200;
|
||||||
|
|
||||||
|
// Build a parameterized WHERE clause from the supported filters.
|
||||||
|
function buildWhere(filters = {}) {
|
||||||
|
const clauses = [];
|
||||||
|
const params = [];
|
||||||
|
if (filters.actor) { clauses.push('al.actor_id = ?'); params.push(Number(filters.actor)); }
|
||||||
|
if (filters.action) { clauses.push('al.action = ?'); params.push(filters.action); }
|
||||||
|
if (filters.entity_type) { clauses.push('al.entity_type = ?'); params.push(filters.entity_type); }
|
||||||
|
if (filters.from) { clauses.push('al.created_at >= ?'); params.push(String(filters.from)); }
|
||||||
|
if (filters.to) { clauses.push('al.created_at <= ?'); params.push(`${String(filters.to)}`); }
|
||||||
|
if (filters.q) {
|
||||||
|
clauses.push('(al.entity_type LIKE ? OR al.entity_id LIKE ? OR al.action LIKE ? OR u.name LIKE ?)');
|
||||||
|
const like = `%${filters.q}%`;
|
||||||
|
params.push(like, like, like, like);
|
||||||
|
}
|
||||||
|
return { where: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', params };
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = `
|
||||||
|
FROM audit_logs al
|
||||||
|
LEFT JOIN users u ON u.id = al.actor_id
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Paginated query. Returns { rows, total, page, pageSize }.
|
||||||
|
function query(filters = {}, { page = 1, pageSize = 25 } = {}) {
|
||||||
|
const size = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(pageSize) || 25));
|
||||||
|
const current = Math.max(1, Number(page) || 1);
|
||||||
|
const { where, params } = buildWhere(filters);
|
||||||
|
const total = one(`SELECT COUNT(*) AS c ${BASE} ${where}`, params).c;
|
||||||
|
const rows = all(
|
||||||
|
`SELECT al.id, al.actor_id, u.name AS actor_name, al.action, al.entity_type, al.entity_id,
|
||||||
|
al.before_json, al.after_json, al.created_at
|
||||||
|
${BASE} ${where}
|
||||||
|
ORDER BY al.id DESC
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
[...params, size, (current - 1) * size]
|
||||||
|
);
|
||||||
|
return { rows, total, page: current, pageSize: size };
|
||||||
|
}
|
||||||
|
|
||||||
|
// All matching rows (no pagination) — used by CSV export. Capped to avoid unbounded memory.
|
||||||
|
function queryAll(filters = {}, limit = 50000) {
|
||||||
|
const { where, params } = buildWhere(filters);
|
||||||
|
return all(
|
||||||
|
`SELECT al.id, al.actor_id, u.name AS actor_name, al.action, al.entity_type, al.entity_id,
|
||||||
|
al.before_json, al.after_json, al.created_at
|
||||||
|
${BASE} ${where}
|
||||||
|
ORDER BY al.id DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[...params, limit]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct values for the filter dropdowns.
|
||||||
|
function facets() {
|
||||||
|
return {
|
||||||
|
actions: all('SELECT DISTINCT action FROM audit_logs ORDER BY action').map((r) => r.action),
|
||||||
|
entity_types: all('SELECT DISTINCT entity_type FROM audit_logs ORDER BY entity_type').map((r) => r.entity_type),
|
||||||
|
actors: all(
|
||||||
|
`SELECT DISTINCT al.actor_id AS id, u.name AS name
|
||||||
|
FROM audit_logs al LEFT JOIN users u ON u.id = al.actor_id
|
||||||
|
WHERE al.actor_id IS NOT NULL
|
||||||
|
ORDER BY u.name`
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvCell(value) {
|
||||||
|
const s = value === null || value === undefined ? '' : String(value);
|
||||||
|
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize rows to CSV text.
|
||||||
|
function toCsv(rows) {
|
||||||
|
const header = ['id', 'timestamp', 'actor', 'action', 'entity_type', 'entity_id', 'before', 'after'];
|
||||||
|
const lines = [header.join(',')];
|
||||||
|
for (const r of rows) {
|
||||||
|
lines.push([
|
||||||
|
r.id, r.created_at, r.actor_name || `#${r.actor_id || ''}`, r.action,
|
||||||
|
r.entity_type, r.entity_id, r.before_json, r.after_json
|
||||||
|
].map(csvCell).join(','));
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { facets, query, queryAll, toCsv };
|
||||||
@@ -14,7 +14,7 @@ async function rowsFromImport(format, buffer) {
|
|||||||
}
|
}
|
||||||
if (format === 'xml') {
|
if (format === 'xml') {
|
||||||
const parsed = new XMLParser({ ignoreAttributes: false }).parse(text);
|
const parsed = new XMLParser({ ignoreAttributes: false }).parse(text);
|
||||||
const assets = parsed.assets?.asset || parsed.MixedAssets?.assets?.asset || [];
|
const assets = parsed.assets?.asset || parsed.DepreCore?.assets?.asset || [];
|
||||||
return Array.isArray(assets) ? assets : [assets];
|
return Array.isArray(assets) ? assets : [assets];
|
||||||
}
|
}
|
||||||
if (format === 'xlsx' || format === 'xls') {
|
if (format === 'xlsx' || format === 'xls') {
|
||||||
@@ -30,7 +30,7 @@ async function assetExport(format) {
|
|||||||
if (format === 'csv') {
|
if (format === 'csv') {
|
||||||
return {
|
return {
|
||||||
contentType: 'text/csv',
|
contentType: 'text/csv',
|
||||||
filename: 'mixedassets-assets.csv',
|
filename: 'deprecore-assets.csv',
|
||||||
body: Papa.unparse(rows)
|
body: Papa.unparse(rows)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -51,20 +51,20 @@ async function assetExport(format) {
|
|||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
filename: 'mixedassets-assets.xlsx',
|
filename: 'deprecore-assets.xlsx',
|
||||||
body: await writeXlsxFile(sheetData).toBuffer()
|
body: await writeXlsxFile(sheetData).toBuffer()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (format === 'xml') {
|
if (format === 'xml') {
|
||||||
return {
|
return {
|
||||||
contentType: 'application/xml',
|
contentType: 'application/xml',
|
||||||
filename: 'mixedassets-assets.xml',
|
filename: 'deprecore-assets.xml',
|
||||||
body: new XMLBuilder({ format: true }).build({ assets: { asset: rows } })
|
body: new XMLBuilder({ format: true }).build({ assets: { asset: rows } })
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
filename: 'mixedassets-assets.json',
|
filename: 'deprecore-assets.json',
|
||||||
body: JSON.stringify({ exportedAt: now(), assets: rows })
|
body: JSON.stringify({ exportedAt: now(), assets: rows })
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,9 +107,9 @@ async function sendMail({ to, subject, html, text }) {
|
|||||||
async function sendTestEmail(to, userId) {
|
async function sendTestEmail(to, userId) {
|
||||||
const info = await sendMail({
|
const info = await sendMail({
|
||||||
to: to ? [to] : null,
|
to: to ? [to] : null,
|
||||||
subject: 'MixedAssets test email',
|
subject: 'DepreCore test email',
|
||||||
html: '<p>Your MixedAssets SMTP settings are working. This is a test message.</p>',
|
html: '<p>Your DepreCore SMTP settings are working. This is a test message.</p>',
|
||||||
text: 'Your MixedAssets SMTP settings are working. This is a test message.'
|
text: 'Your DepreCore SMTP settings are working. This is a test message.'
|
||||||
});
|
});
|
||||||
audit(userId, 'send', 'test_email', null, null, { to });
|
audit(userId, 'send', 'test_email', null, null, { to });
|
||||||
return { messageId: info.messageId, accepted: info.accepted };
|
return { messageId: info.messageId, accepted: info.accepted };
|
||||||
|
|||||||
212
server/services/passwordPolicy.js
Normal file
212
server/services/passwordPolicy.js
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { all, audit, now, run } = require('../db');
|
||||||
|
|
||||||
|
// Configurable password & lockout policy for locally managed users. The policy lives as
|
||||||
|
// `password_*` rows in app_settings (no schema churn to tune rules), is read back through
|
||||||
|
// getPolicy(), and is enforced at every password-set point plus on login.
|
||||||
|
|
||||||
|
const DEFAULT_POLICY = {
|
||||||
|
min_length: 12,
|
||||||
|
require_upper: true,
|
||||||
|
require_lower: true,
|
||||||
|
require_number: true,
|
||||||
|
require_symbol: true,
|
||||||
|
disallow_identifiers: true, // block the user's name / email local-part inside the password
|
||||||
|
history_count: 5, // disallow reuse of the last N passwords (0 = off)
|
||||||
|
expiry_days: 90, // force a change after this many days (0 = never)
|
||||||
|
min_age_hours: 0, // minimum age before a user may change again (0 = off)
|
||||||
|
lockout_threshold: 5, // failed logins before lock (0 = off)
|
||||||
|
lockout_window_minutes: 15 // how long an account stays locked
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each policy field plus how to coerce the stored string back to its typed value.
|
||||||
|
const NUMERIC_KEYS = ['min_length', 'history_count', 'expiry_days', 'min_age_hours', 'lockout_threshold', 'lockout_window_minutes'];
|
||||||
|
const BOOLEAN_KEYS = ['require_upper', 'require_lower', 'require_number', 'require_symbol', 'disallow_identifiers'];
|
||||||
|
|
||||||
|
function clampInt(value, fallback, min, max) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBool(value, fallback) {
|
||||||
|
if (value === undefined || value === null || value === '') return fallback;
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
return value === '1' || value === 'true' || value === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the saved policy, falling back to defaults for any unset key.
|
||||||
|
function getPolicy() {
|
||||||
|
const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'password_%'");
|
||||||
|
const stored = Object.fromEntries(rows.map((r) => [r.key.replace(/^password_/, ''), r.value]));
|
||||||
|
const policy = { ...DEFAULT_POLICY };
|
||||||
|
for (const key of NUMERIC_KEYS) {
|
||||||
|
if (stored[key] !== undefined) policy[key] = clampInt(stored[key], DEFAULT_POLICY[key], 0, 100000);
|
||||||
|
}
|
||||||
|
for (const key of BOOLEAN_KEYS) {
|
||||||
|
if (stored[key] !== undefined) policy[key] = toBool(stored[key], DEFAULT_POLICY[key]);
|
||||||
|
}
|
||||||
|
// min_length has a sane floor regardless of what was stored.
|
||||||
|
policy.min_length = Math.max(4, policy.min_length);
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist a (partial) policy. Unknown keys are ignored; values are normalized through getPolicy's coercion.
|
||||||
|
function savePolicy(body, userId) {
|
||||||
|
const before = getPolicy();
|
||||||
|
const incoming = body && typeof body === 'object' ? body : {};
|
||||||
|
const ts = now();
|
||||||
|
const write = (key, value) => run(
|
||||||
|
'INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
|
||||||
|
[`password_${key}`, String(value), ts]
|
||||||
|
);
|
||||||
|
for (const key of NUMERIC_KEYS) {
|
||||||
|
if (incoming[key] !== undefined) write(key, clampInt(incoming[key], DEFAULT_POLICY[key], 0, 100000));
|
||||||
|
}
|
||||||
|
for (const key of BOOLEAN_KEYS) {
|
||||||
|
if (incoming[key] !== undefined) write(key, toBool(incoming[key], DEFAULT_POLICY[key]) ? 1 : 0);
|
||||||
|
}
|
||||||
|
const after = getPolicy();
|
||||||
|
audit(userId, 'update', 'password_policy', 'app', before, after);
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable rules for the UI hint/preview.
|
||||||
|
function describe(policy = getPolicy()) {
|
||||||
|
const rules = [`At least ${policy.min_length} characters`];
|
||||||
|
if (policy.require_upper) rules.push('An uppercase letter (A–Z)');
|
||||||
|
if (policy.require_lower) rules.push('A lowercase letter (a–z)');
|
||||||
|
if (policy.require_number) rules.push('A number (0–9)');
|
||||||
|
if (policy.require_symbol) rules.push('A symbol (e.g. !@#$%)');
|
||||||
|
if (policy.disallow_identifiers) rules.push('Must not contain your name or email');
|
||||||
|
if (policy.history_count > 0) rules.push(`Cannot reuse your last ${policy.history_count} password(s)`);
|
||||||
|
if (policy.expiry_days > 0) rules.push(`Expires every ${policy.expiry_days} days`);
|
||||||
|
if (policy.lockout_threshold > 0) rules.push(`Locks for ${policy.lockout_window_minutes} min after ${policy.lockout_threshold} failed sign-ins`);
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYMBOL_RE = /[^A-Za-z0-9]/;
|
||||||
|
|
||||||
|
// Returns an array of human-readable failure messages ([] when the password satisfies the policy).
|
||||||
|
// `user` (optional) enables identifier and history checks; pass { id, name, email }.
|
||||||
|
function checkPassword(password, user = null, policy = getPolicy()) {
|
||||||
|
const failures = [];
|
||||||
|
const value = String(password || '');
|
||||||
|
if (value.length < policy.min_length) failures.push(`Use at least ${policy.min_length} characters`);
|
||||||
|
if (policy.require_upper && !/[A-Z]/.test(value)) failures.push('Add an uppercase letter');
|
||||||
|
if (policy.require_lower && !/[a-z]/.test(value)) failures.push('Add a lowercase letter');
|
||||||
|
if (policy.require_number && !/[0-9]/.test(value)) failures.push('Add a number');
|
||||||
|
if (policy.require_symbol && !SYMBOL_RE.test(value)) failures.push('Add a symbol');
|
||||||
|
|
||||||
|
if (policy.disallow_identifiers && user) {
|
||||||
|
const haystack = value.toLowerCase();
|
||||||
|
const needles = [];
|
||||||
|
if (user.name) needles.push(...String(user.name).toLowerCase().split(/\s+/).filter((p) => p.length >= 3));
|
||||||
|
if (user.email) {
|
||||||
|
const local = String(user.email).toLowerCase().split('@')[0];
|
||||||
|
if (local && local.length >= 3) needles.push(local);
|
||||||
|
}
|
||||||
|
if (needles.some((n) => haystack.includes(n))) failures.push('Must not contain your name or email');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy.history_count > 0 && user && user.id) {
|
||||||
|
const recent = all(
|
||||||
|
'SELECT password_hash FROM password_history WHERE user_id = ? ORDER BY id DESC LIMIT ?',
|
||||||
|
[user.id, policy.history_count]
|
||||||
|
);
|
||||||
|
if (recent.some((row) => bcrypt.compareSync(value, row.password_hash))) {
|
||||||
|
failures.push(`Choose a password you have not used in your last ${policy.history_count}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throwing wrapper used by routes: 400s with the joined failure list.
|
||||||
|
function validatePassword(password, user = null) {
|
||||||
|
const failures = checkPassword(password, user, getPolicy());
|
||||||
|
if (failures.length) throw Object.assign(new Error(failures.join('. ')), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record a newly set hash and prune the per-user history to the configured depth.
|
||||||
|
function recordPassword(userId, hash) {
|
||||||
|
run('INSERT INTO password_history (user_id, password_hash, created_at) VALUES (?, ?, ?)', [userId, hash, now()]);
|
||||||
|
const keep = Math.max(getPolicy().history_count, 1);
|
||||||
|
run(
|
||||||
|
`DELETE FROM password_history
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND id NOT IN (SELECT id FROM password_history WHERE user_id = ? ORDER BY id DESC LIMIT ?)`,
|
||||||
|
[userId, userId, keep]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Account lockout ---------------------------------------------------------
|
||||||
|
|
||||||
|
function isLocked(user) {
|
||||||
|
return Boolean(user && user.locked_until && new Date(user.locked_until).getTime() > Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a failed login: bump the counter and lock the account if the threshold is reached.
|
||||||
|
// Returns { locked, lockedUntil } so the caller can shape the response.
|
||||||
|
function registerFailure(user) {
|
||||||
|
const policy = getPolicy();
|
||||||
|
const attempts = (user.failed_attempts || 0) + 1;
|
||||||
|
let lockedUntil = null;
|
||||||
|
if (policy.lockout_threshold > 0 && attempts >= policy.lockout_threshold) {
|
||||||
|
lockedUntil = new Date(Date.now() + policy.lockout_window_minutes * 60000).toISOString();
|
||||||
|
}
|
||||||
|
run('UPDATE users SET failed_attempts = ?, locked_until = ?, updated_at = ? WHERE id = ?', [
|
||||||
|
attempts, lockedUntil, now(), user.id
|
||||||
|
]);
|
||||||
|
if (lockedUntil) audit(user.id, 'account_locked', 'user', user.id, null, { until: lockedUntil, attempts });
|
||||||
|
return { locked: Boolean(lockedUntil), lockedUntil, attempts };
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a successful login: clear the lockout counters and stamp last_login_at.
|
||||||
|
function registerSuccess(user) {
|
||||||
|
run('UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = ?, updated_at = ? WHERE id = ?', [
|
||||||
|
now(), now(), user.id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin action: clear a lock so the user can sign in again immediately.
|
||||||
|
function unlock(userId, actorId) {
|
||||||
|
run('UPDATE users SET failed_attempts = 0, locked_until = NULL, updated_at = ? WHERE id = ?', [now(), userId]);
|
||||||
|
audit(actorId, 'account_unlocked', 'user', userId, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has this user's password aged past the expiry window?
|
||||||
|
function passwordExpired(user, policy = getPolicy()) {
|
||||||
|
if (!policy.expiry_days || !user || !user.password_changed_at) return false;
|
||||||
|
const age = Date.now() - new Date(user.password_changed_at).getTime();
|
||||||
|
return age > policy.expiry_days * 86400000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Does the policy forbid changing again this soon? Returns minutes remaining (0 when allowed).
|
||||||
|
function minAgeRemainingMinutes(user, policy = getPolicy()) {
|
||||||
|
if (!policy.min_age_hours || !user || !user.password_changed_at) return 0;
|
||||||
|
const elapsedMs = Date.now() - new Date(user.password_changed_at).getTime();
|
||||||
|
const remainingMs = policy.min_age_hours * 3600000 - elapsedMs;
|
||||||
|
return remainingMs > 0 ? Math.ceil(remainingMs / 60000) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience used by login to decide whether to force a change step.
|
||||||
|
function mustChangePassword(user) {
|
||||||
|
return Boolean(user.must_change_password) || passwordExpired(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_POLICY,
|
||||||
|
checkPassword,
|
||||||
|
describe,
|
||||||
|
getPolicy,
|
||||||
|
isLocked,
|
||||||
|
minAgeRemainingMinutes,
|
||||||
|
mustChangePassword,
|
||||||
|
passwordExpired,
|
||||||
|
recordPassword,
|
||||||
|
registerFailure,
|
||||||
|
registerSuccess,
|
||||||
|
savePolicy,
|
||||||
|
unlock,
|
||||||
|
validatePassword
|
||||||
|
};
|
||||||
80
server/services/passwordPolicy.test.js
Normal file
80
server/services/passwordPolicy.test.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* Tests for the password-policy validation and lockout helpers. These exercise the pure logic
|
||||||
|
* (complexity checks, identifier detection, lockout math) against an explicit policy object so
|
||||||
|
* they do not depend on the database-backed settings store.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { checkPassword, describe: describePolicy, DEFAULT_POLICY, isLocked, minAgeRemainingMinutes, passwordExpired } = require('./passwordPolicy');
|
||||||
|
|
||||||
|
const strictPolicy = {
|
||||||
|
...DEFAULT_POLICY,
|
||||||
|
min_length: 12,
|
||||||
|
require_upper: true,
|
||||||
|
require_lower: true,
|
||||||
|
require_number: true,
|
||||||
|
require_symbol: true,
|
||||||
|
disallow_identifiers: true,
|
||||||
|
history_count: 0 // disable DB-backed history for these pure tests
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('password policy', () => {
|
||||||
|
test('a compliant password passes every rule', () => {
|
||||||
|
expect(checkPassword('Str0ng!Passphrase', null, strictPolicy)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each missing character class is reported', () => {
|
||||||
|
expect(checkPassword('short', null, strictPolicy).length).toBeGreaterThan(0);
|
||||||
|
expect(checkPassword('alllowercase1!', null, strictPolicy)).toContain('Add an uppercase letter');
|
||||||
|
expect(checkPassword('ALLUPPERCASE1!', null, strictPolicy)).toContain('Add a lowercase letter');
|
||||||
|
expect(checkPassword('NoNumbersHere!', null, strictPolicy)).toContain('Add a number');
|
||||||
|
expect(checkPassword('NoSymbolsHere1', null, strictPolicy)).toContain('Add a symbol');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minimum length is enforced', () => {
|
||||||
|
expect(checkPassword('Ab1!', null, strictPolicy)).toContain('Use at least 12 characters');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the user name or email cannot appear in the password', () => {
|
||||||
|
const user = { name: 'Jordan Banks', email: 'jbanks@example.com' };
|
||||||
|
expect(checkPassword('Jordan!Secret99', user, strictPolicy)).toContain('Must not contain your name or email');
|
||||||
|
expect(checkPassword('jbanks!Secret99', user, strictPolicy)).toContain('Must not contain your name or email');
|
||||||
|
// A password without the identifiers is fine.
|
||||||
|
expect(checkPassword('Unrelated!Secret99', user, strictPolicy)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('identifier check is skipped when the policy disables it', () => {
|
||||||
|
const policy = { ...strictPolicy, disallow_identifiers: false };
|
||||||
|
const user = { name: 'Jordan Banks', email: 'jbanks@example.com' };
|
||||||
|
expect(checkPassword('Jordan!Secret99', user, policy)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('describe lists the active rules', () => {
|
||||||
|
const rules = describePolicy(strictPolicy);
|
||||||
|
expect(rules.some((r) => /12 characters/.test(r))).toBe(true);
|
||||||
|
expect(rules.some((r) => /uppercase/.test(r))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isLocked reflects the locked_until timestamp', () => {
|
||||||
|
expect(isLocked({ locked_until: new Date(Date.now() + 60000).toISOString() })).toBe(true);
|
||||||
|
expect(isLocked({ locked_until: new Date(Date.now() - 60000).toISOString() })).toBe(false);
|
||||||
|
expect(isLocked({ locked_until: null })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passwordExpired compares age against expiry_days', () => {
|
||||||
|
const policy = { ...DEFAULT_POLICY, expiry_days: 30 };
|
||||||
|
const old = new Date(Date.now() - 40 * 86400000).toISOString();
|
||||||
|
const recent = new Date(Date.now() - 5 * 86400000).toISOString();
|
||||||
|
expect(passwordExpired({ password_changed_at: old }, policy)).toBe(true);
|
||||||
|
expect(passwordExpired({ password_changed_at: recent }, policy)).toBe(false);
|
||||||
|
// expiry_days 0 means passwords never expire.
|
||||||
|
expect(passwordExpired({ password_changed_at: old }, { ...policy, expiry_days: 0 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minAgeRemainingMinutes blocks changes that are too soon', () => {
|
||||||
|
const policy = { ...DEFAULT_POLICY, min_age_hours: 24 };
|
||||||
|
const justChanged = new Date(Date.now() - 60000).toISOString();
|
||||||
|
const longAgo = new Date(Date.now() - 48 * 3600000).toISOString();
|
||||||
|
expect(minAgeRemainingMinutes({ password_changed_at: justChanged }, policy)).toBeGreaterThan(0);
|
||||||
|
expect(minAgeRemainingMinutes({ password_changed_at: longAgo }, policy)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
|
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
|
||||||
|
const { computeNextDue, projectUsageDue } = require('./pmScheduling');
|
||||||
|
|
||||||
const FREQUENCY_UNITS = ['days', 'weeks', 'months', 'quarters', 'semiannual', 'years'];
|
const FREQUENCY_UNITS = ['days', 'weeks', 'months', 'quarters', 'semiannual', 'years'];
|
||||||
|
|
||||||
@@ -39,6 +40,34 @@ function planComponents(planId) {
|
|||||||
return all('SELECT id, sort_order, part_number, description, supplier, cost FROM pm_plan_components WHERE plan_id = ? ORDER BY sort_order, id', [planId]);
|
return all('SELECT id, sort_order, part_number, description, supplier, cost FROM pm_plan_components WHERE plan_id = ? ORDER BY sort_order, id', [planId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Usage meters declared on a plan template (e.g. Operating Hours / hrs / due every 500).
|
||||||
|
function planMeters(planId) {
|
||||||
|
return all('SELECT id, sort_order, label, unit, usage_interval FROM pm_plan_meters WHERE plan_id = ? ORDER BY sort_order, id', [planId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-schedule meters with their reading log, used by the engine and surfaced to the UI.
|
||||||
|
function assetPmMeters(apId) {
|
||||||
|
return all('SELECT * FROM asset_pm_meters WHERE asset_pm_id = ? ORDER BY sort_order, id', [apId]).map((m) => {
|
||||||
|
const readings = all(
|
||||||
|
'SELECT reading, reading_date FROM pm_meter_readings WHERE asset_pm_meter_id = ? ORDER BY reading_date, id',
|
||||||
|
[m.id]
|
||||||
|
);
|
||||||
|
return { ...m, readings, projected_usage_due_date: projectUsageDue(m, readings, todayStr()) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePlanMeters(planId, meters) {
|
||||||
|
run('DELETE FROM pm_plan_meters WHERE plan_id = ?', [planId]);
|
||||||
|
(meters || []).forEach((meter, index) => {
|
||||||
|
const label = (meter.label || '').trim();
|
||||||
|
if (!label) return;
|
||||||
|
run(
|
||||||
|
'INSERT INTO pm_plan_meters (plan_id, sort_order, label, unit, usage_interval) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[planId, index, label, (meter.unit || '').trim() || null, Number(meter.usage_interval) || 0]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Guidance photos for a plan. `withData` is false for list views (omits the base64 blob).
|
// Guidance photos for a plan. `withData` is false for list views (omits the base64 blob).
|
||||||
function planPhotos(planId, withData = true) {
|
function planPhotos(planId, withData = true) {
|
||||||
const columns = withData ? 'id, name, mime_type, caption, data_base64, sort_order' : 'id, name, mime_type, caption, sort_order';
|
const columns = withData ? 'id, name, mime_type, caption, data_base64, sort_order' : 'id, name, mime_type, caption, sort_order';
|
||||||
@@ -58,7 +87,16 @@ function planFromRow(row, { withPhotoData = true } = {}) {
|
|||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const components = planComponents(row.id);
|
const components = planComponents(row.id);
|
||||||
const photos = planPhotos(row.id, withPhotoData);
|
const photos = planPhotos(row.id, withPhotoData);
|
||||||
return { ...row, steps: planSteps(row.id), components, components_total: componentsTotal(components), photos, photo_count: photos.length };
|
return {
|
||||||
|
...row,
|
||||||
|
lifespan_adjust: Boolean(row.lifespan_adjust),
|
||||||
|
steps: planSteps(row.id),
|
||||||
|
components,
|
||||||
|
components_total: componentsTotal(components),
|
||||||
|
meters: planMeters(row.id),
|
||||||
|
photos,
|
||||||
|
photo_count: photos.length
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listPlans() {
|
function listPlans() {
|
||||||
@@ -128,17 +166,18 @@ function createPlan(body, userId) {
|
|||||||
const ts = now();
|
const ts = now();
|
||||||
return tx(() => {
|
return tx(() => {
|
||||||
const result = run(
|
const result = run(
|
||||||
`INSERT INTO pm_plans (name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, created_by, created_at, updated_at)
|
`INSERT INTO pm_plans (name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, lifespan_adjust, created_by, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
body.name || 'PM plan', body.description || null, body.category || null,
|
body.name || 'PM plan', body.description || null, body.category || null,
|
||||||
Number(body.frequency_value) || 1, FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : 'months',
|
Number(body.frequency_value) || 1, FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : 'months',
|
||||||
resolveEstimatedMinutes(body, null), body.instructions || null,
|
resolveEstimatedMinutes(body, null), body.instructions || null, body.lifespan_adjust ? 1 : 0,
|
||||||
userId, ts, ts
|
userId, ts, ts
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
writeSteps(result.lastInsertRowid, body.steps);
|
writeSteps(result.lastInsertRowid, body.steps);
|
||||||
writeComponents(result.lastInsertRowid, body.components);
|
writeComponents(result.lastInsertRowid, body.components);
|
||||||
|
writePlanMeters(result.lastInsertRowid, body.meters);
|
||||||
writePhotos(result.lastInsertRowid, body.photos);
|
writePhotos(result.lastInsertRowid, body.photos);
|
||||||
audit(userId, 'create', 'pm_plan', result.lastInsertRowid, null, { name: body.name });
|
audit(userId, 'create', 'pm_plan', result.lastInsertRowid, null, { name: body.name });
|
||||||
return getPlan(result.lastInsertRowid);
|
return getPlan(result.lastInsertRowid);
|
||||||
@@ -151,17 +190,20 @@ function updatePlan(id, body, userId) {
|
|||||||
return tx(() => {
|
return tx(() => {
|
||||||
run(
|
run(
|
||||||
`UPDATE pm_plans SET name = ?, description = ?, category = ?, frequency_value = ?, frequency_unit = ?,
|
`UPDATE pm_plans SET name = ?, description = ?, category = ?, frequency_value = ?, frequency_unit = ?,
|
||||||
estimated_minutes = ?, instructions = ?, updated_at = ? WHERE id = ?`,
|
estimated_minutes = ?, instructions = ?, lifespan_adjust = ?, updated_at = ? WHERE id = ?`,
|
||||||
[
|
[
|
||||||
body.name ?? before.name, body.description ?? before.description, body.category ?? before.category,
|
body.name ?? before.name, body.description ?? before.description, body.category ?? before.category,
|
||||||
body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value,
|
body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value,
|
||||||
FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit,
|
FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit,
|
||||||
resolveEstimatedMinutes(body, before.estimated_minutes),
|
resolveEstimatedMinutes(body, before.estimated_minutes),
|
||||||
body.instructions ?? before.instructions, now(), id
|
body.instructions ?? before.instructions,
|
||||||
|
body.lifespan_adjust !== undefined ? (body.lifespan_adjust ? 1 : 0) : before.lifespan_adjust,
|
||||||
|
now(), id
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
if (Array.isArray(body.steps)) writeSteps(id, body.steps);
|
if (Array.isArray(body.steps)) writeSteps(id, body.steps);
|
||||||
if (Array.isArray(body.components)) writeComponents(id, body.components);
|
if (Array.isArray(body.components)) writeComponents(id, body.components);
|
||||||
|
if (Array.isArray(body.meters)) writePlanMeters(id, body.meters);
|
||||||
if (Array.isArray(body.photos)) writePhotos(id, body.photos);
|
if (Array.isArray(body.photos)) writePhotos(id, body.photos);
|
||||||
audit(userId, 'update', 'pm_plan', id, before, { name: body.name });
|
audit(userId, 'update', 'pm_plan', id, before, { name: body.name });
|
||||||
return getPlan(id);
|
return getPlan(id);
|
||||||
@@ -179,12 +221,14 @@ function deletePlan(id, userId) {
|
|||||||
// ---- Per-asset PM schedules ------------------------------------------------
|
// ---- Per-asset PM schedules ------------------------------------------------
|
||||||
|
|
||||||
function assetPmRows(assetId) {
|
function assetPmRows(assetId) {
|
||||||
|
const settings = engineSettings();
|
||||||
return all(
|
return all(
|
||||||
`SELECT ap.*, p.name AS plan_name, p.description AS plan_description, p.instructions AS plan_instructions,
|
`SELECT ap.*, p.name AS plan_name, p.description AS plan_description, p.instructions AS plan_instructions,
|
||||||
u.name AS assigned_to_name
|
u.name AS assigned_to_name, a.in_service_date AS asset_in_service_date, a.useful_life_months AS asset_useful_life_months
|
||||||
FROM asset_pm_plans ap
|
FROM asset_pm_plans ap
|
||||||
LEFT JOIN pm_plans p ON p.id = ap.plan_id
|
LEFT JOIN pm_plans p ON p.id = ap.plan_id
|
||||||
LEFT JOIN users u ON u.id = ap.assigned_to
|
LEFT JOIN users u ON u.id = ap.assigned_to
|
||||||
|
LEFT JOIN assets a ON a.id = ap.asset_id
|
||||||
WHERE ap.asset_id = ?
|
WHERE ap.asset_id = ?
|
||||||
ORDER BY ap.active DESC, ap.next_due_date IS NULL, ap.next_due_date`,
|
ORDER BY ap.active DESC, ap.next_due_date IS NULL, ap.next_due_date`,
|
||||||
[assetId]
|
[assetId]
|
||||||
@@ -208,12 +252,25 @@ function assetPmRows(assetId) {
|
|||||||
ratings: { safety: c.rating_safety, physical: c.rating_physical, operating: c.rating_operating }
|
ratings: { safety: c.rating_safety, physical: c.rating_physical, operating: c.rating_operating }
|
||||||
}));
|
}));
|
||||||
const totalCost = round2(completions.reduce((sum, c) => sum + Number(c.cost || 0), 0));
|
const totalCost = round2(completions.reduce((sum, c) => sum + Number(c.cost || 0), 0));
|
||||||
|
const meters = assetPmMeters(row.id);
|
||||||
|
// Re-derive the per-signal breakdown for display (the persisted next_due_date stays authoritative).
|
||||||
|
const projection = computeNextDue(
|
||||||
|
{ lastDate: row.last_completed_date || row.start_date, frequency_value: row.frequency_value, frequency_unit: row.frequency_unit, lifespan_adjust: Boolean(row.lifespan_adjust) },
|
||||||
|
{ in_service_date: row.asset_in_service_date, useful_life_months: row.asset_useful_life_months },
|
||||||
|
meters,
|
||||||
|
settings,
|
||||||
|
todayStr()
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
active: Boolean(row.active),
|
active: Boolean(row.active),
|
||||||
|
lifespan_adjust: Boolean(row.lifespan_adjust),
|
||||||
steps: row.plan_id ? planSteps(row.plan_id) : [],
|
steps: row.plan_id ? planSteps(row.plan_id) : [],
|
||||||
plan_photos: row.plan_id ? planPhotos(row.plan_id) : [],
|
plan_photos: row.plan_id ? planPhotos(row.plan_id) : [],
|
||||||
components,
|
components,
|
||||||
|
meters,
|
||||||
|
signals: projection.signals,
|
||||||
|
due_driver: projection.driver,
|
||||||
expected_cost: componentsTotal(components),
|
expected_cost: componentsTotal(components),
|
||||||
total_cost: totalCost,
|
total_cost: totalCost,
|
||||||
last_cost: completions[0] ? round2(completions[0].cost) : 0,
|
last_cost: completions[0] ? round2(completions[0].cost) : 0,
|
||||||
@@ -229,11 +286,24 @@ function attachPlan(assetId, body, userId) {
|
|||||||
const unit = FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : (plan ? plan.frequency_unit : 'months');
|
const unit = FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : (plan ? plan.frequency_unit : 'months');
|
||||||
const startDate = body.start_date || todayStr();
|
const startDate = body.start_date || todayStr();
|
||||||
const nextDue = body.next_due_date || startDate;
|
const nextDue = body.next_due_date || startDate;
|
||||||
|
const lifespanAdjust = body.lifespan_adjust !== undefined ? (body.lifespan_adjust ? 1 : 0) : (plan && plan.lifespan_adjust ? 1 : 0);
|
||||||
const result = run(
|
const result = run(
|
||||||
`INSERT INTO asset_pm_plans (asset_id, plan_id, frequency_value, frequency_unit, start_date, end_date, next_due_date, assigned_to, active, notes, created_at, updated_at)
|
`INSERT INTO asset_pm_plans (asset_id, plan_id, frequency_value, frequency_unit, start_date, end_date, next_due_date, assigned_to, active, notes, lifespan_adjust, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)`,
|
||||||
[assetId, body.plan_id || null, value, unit, startDate, body.end_date || null, nextDue, body.assigned_to || null, body.notes || null, ts, ts]
|
[assetId, body.plan_id || null, value, unit, startDate, body.end_date || null, nextDue, body.assigned_to || null, body.notes || null, lifespanAdjust, ts, ts]
|
||||||
);
|
);
|
||||||
|
// Copy the plan's meter definitions onto the schedule; the first threshold is one interval out.
|
||||||
|
const meters = Array.isArray(body.meters) ? body.meters : (body.plan_id ? planMeters(body.plan_id) : []);
|
||||||
|
meters.forEach((meter, index) => {
|
||||||
|
const label = (meter.label || '').trim();
|
||||||
|
if (!label) return;
|
||||||
|
const interval = Number(meter.usage_interval) || 0;
|
||||||
|
run(
|
||||||
|
`INSERT INTO asset_pm_meters (asset_pm_id, sort_order, label, unit, usage_interval, baseline_reading, due_at_reading)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0, ?)`,
|
||||||
|
[result.lastInsertRowid, index, label, (meter.unit || '').trim() || null, interval, interval > 0 ? interval : null]
|
||||||
|
);
|
||||||
|
});
|
||||||
audit(userId, 'attach', 'asset_pm', result.lastInsertRowid, null, { asset_id: assetId, plan_id: body.plan_id });
|
audit(userId, 'attach', 'asset_pm', result.lastInsertRowid, null, { asset_id: assetId, plan_id: body.plan_id });
|
||||||
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [result.lastInsertRowid]);
|
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [result.lastInsertRowid]);
|
||||||
}
|
}
|
||||||
@@ -243,7 +313,7 @@ function updateAssetPm(assetId, apId, body, userId) {
|
|||||||
if (!before) return null;
|
if (!before) return null;
|
||||||
run(
|
run(
|
||||||
`UPDATE asset_pm_plans SET frequency_value = ?, frequency_unit = ?, start_date = ?, end_date = ?,
|
`UPDATE asset_pm_plans SET frequency_value = ?, frequency_unit = ?, start_date = ?, end_date = ?,
|
||||||
next_due_date = ?, assigned_to = ?, active = ?, notes = ?, updated_at = ? WHERE id = ?`,
|
next_due_date = ?, assigned_to = ?, active = ?, notes = ?, lifespan_adjust = ?, updated_at = ? WHERE id = ?`,
|
||||||
[
|
[
|
||||||
body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value,
|
body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value,
|
||||||
FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit,
|
FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit,
|
||||||
@@ -253,6 +323,7 @@ function updateAssetPm(assetId, apId, body, userId) {
|
|||||||
body.assigned_to !== undefined ? body.assigned_to : before.assigned_to,
|
body.assigned_to !== undefined ? body.assigned_to : before.assigned_to,
|
||||||
body.active === undefined ? before.active : (body.active ? 1 : 0),
|
body.active === undefined ? before.active : (body.active ? 1 : 0),
|
||||||
body.notes ?? before.notes,
|
body.notes ?? before.notes,
|
||||||
|
body.lifespan_adjust !== undefined ? (body.lifespan_adjust ? 1 : 0) : before.lifespan_adjust,
|
||||||
now(), apId
|
now(), apId
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -272,6 +343,44 @@ function clampRating(value) {
|
|||||||
return Math.max(1, Math.min(5, Number(value)));
|
return Math.max(1, Math.min(5, Number(value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Composite next-due for a schedule given the date it was last serviced. Loads the schedule's meters
|
||||||
|
// (with their reading history) and the owning asset's life, runs the earliest-wins engine, and applies
|
||||||
|
// the asset's maintenance end-date cutoff. Shared by completion and standalone reading logs.
|
||||||
|
function scheduleNextDue(ap, lastDate) {
|
||||||
|
const asset = one('SELECT in_service_date, useful_life_months FROM assets WHERE id = ?', [ap.asset_id]) || {};
|
||||||
|
const projection = computeNextDue(
|
||||||
|
{ lastDate, frequency_value: ap.frequency_value, frequency_unit: ap.frequency_unit, lifespan_adjust: Boolean(ap.lifespan_adjust) },
|
||||||
|
asset,
|
||||||
|
assetPmMeters(ap.id),
|
||||||
|
engineSettings(),
|
||||||
|
todayStr()
|
||||||
|
);
|
||||||
|
let nextDue = projection.next_due_date;
|
||||||
|
let active = ap.active;
|
||||||
|
if (ap.end_date && nextDue && nextDue > ap.end_date) {
|
||||||
|
nextDue = null;
|
||||||
|
active = 0; // schedule complete — past the asset's maintenance end date
|
||||||
|
}
|
||||||
|
return { nextDue, active };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record a meter reading against a schedule's meter (resets its baseline/threshold when this is a
|
||||||
|
// service completion). Insert into the log so the usage-rate projection stays current.
|
||||||
|
function recordMeterReading(meter, reading, readingDate, source, completionId, userId) {
|
||||||
|
const value = Number(reading);
|
||||||
|
if (!Number.isFinite(value)) return;
|
||||||
|
if (source === 'completion') {
|
||||||
|
// Completing service resets the cycle: the next threshold is one interval past this reading.
|
||||||
|
const dueAt = Number(meter.usage_interval) > 0 ? value + Number(meter.usage_interval) : null;
|
||||||
|
run('UPDATE asset_pm_meters SET baseline_reading = ?, due_at_reading = ?, last_reading = ?, last_reading_date = ? WHERE id = ?',
|
||||||
|
[value, dueAt, value, readingDate, meter.id]);
|
||||||
|
} else {
|
||||||
|
run('UPDATE asset_pm_meters SET last_reading = ?, last_reading_date = ? WHERE id = ?', [value, readingDate, meter.id]);
|
||||||
|
}
|
||||||
|
run('INSERT INTO pm_meter_readings (asset_pm_meter_id, reading, reading_date, source, completion_id, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[meter.id, value, readingDate, source, completionId || null, userId || null, now()]);
|
||||||
|
}
|
||||||
|
|
||||||
function completePm(assetId, apId, body, userId) {
|
function completePm(assetId, apId, body, userId) {
|
||||||
const ap = one('SELECT * FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
|
const ap = one('SELECT * FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
|
||||||
if (!ap) return null;
|
if (!ap) return null;
|
||||||
@@ -279,18 +388,13 @@ function completePm(assetId, apId, body, userId) {
|
|||||||
|
|
||||||
const ts = now();
|
const ts = now();
|
||||||
const completedDate = (body.completed_at || ts).slice(0, 10);
|
const completedDate = (body.completed_at || ts).slice(0, 10);
|
||||||
let nextDue = addInterval(completedDate, ap.frequency_value, ap.frequency_unit);
|
|
||||||
let active = ap.active;
|
|
||||||
if (ap.end_date && nextDue > ap.end_date) {
|
|
||||||
nextDue = null;
|
|
||||||
active = 0; // schedule complete — past the asset's maintenance end date
|
|
||||||
}
|
|
||||||
const planComps = ap.plan_id ? planComponents(ap.plan_id) : [];
|
const planComps = ap.plan_id ? planComponents(ap.plan_id) : [];
|
||||||
const components = Array.isArray(body.components) ? body.components : planComps;
|
const components = Array.isArray(body.components) ? body.components : planComps;
|
||||||
const cost = body.cost != null && body.cost !== '' ? round2(body.cost) : componentsTotal(planComps);
|
const cost = body.cost != null && body.cost !== '' ? round2(body.cost) : componentsTotal(planComps);
|
||||||
const notesList = Array.isArray(body.notes_list) ? body.notes_list.filter((n) => String(n || '').trim()) : (body.notes ? [body.notes] : []);
|
const notesList = Array.isArray(body.notes_list) ? body.notes_list.filter((n) => String(n || '').trim()) : (body.notes ? [body.notes] : []);
|
||||||
const ratings = body.ratings || {};
|
const ratings = body.ratings || {};
|
||||||
const photos = Array.isArray(body.photos) ? body.photos.filter((p) => p && p.data) : [];
|
const photos = Array.isArray(body.photos) ? body.photos.filter((p) => p && p.data) : [];
|
||||||
|
const meterReadings = Array.isArray(body.meter_readings) ? body.meter_readings : [];
|
||||||
|
|
||||||
return tx(() => {
|
return tx(() => {
|
||||||
const inserted = run(
|
const inserted = run(
|
||||||
@@ -299,7 +403,7 @@ function completePm(assetId, apId, body, userId) {
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
apId, ts, userId, notesList.join('\n') || null, json(notesList), json(body.steps || []), cost, json(components),
|
apId, ts, userId, notesList.join('\n') || null, json(notesList), json(body.steps || []), cost, json(components),
|
||||||
body.signature, clampRating(ratings.safety), clampRating(ratings.physical), clampRating(ratings.operating), nextDue, ts
|
body.signature, clampRating(ratings.safety), clampRating(ratings.physical), clampRating(ratings.operating), null, ts
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
for (const photo of photos) {
|
for (const photo of photos) {
|
||||||
@@ -307,12 +411,44 @@ function completePm(assetId, apId, body, userId) {
|
|||||||
inserted.lastInsertRowid, photo.name || null, photo.mime || photo.mime_type || 'image/jpeg', photo.data, ts
|
inserted.lastInsertRowid, photo.name || null, photo.mime || photo.mime_type || 'image/jpeg', photo.data, ts
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
// Reset each meter's cycle from the reading captured during service.
|
||||||
|
for (const entry of meterReadings) {
|
||||||
|
const meter = one('SELECT * FROM asset_pm_meters WHERE id = ? AND asset_pm_id = ?', [entry.asset_pm_meter_id, apId]);
|
||||||
|
if (meter && entry.reading != null && entry.reading !== '') {
|
||||||
|
recordMeterReading(meter, entry.reading, completedDate, 'completion', inserted.lastInsertRowid, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Earliest-wins next-due now reflects the reset meters and the lifespan curve.
|
||||||
|
const { nextDue, active } = scheduleNextDue(ap, completedDate);
|
||||||
|
run('UPDATE pm_completions SET next_due_date = ? WHERE id = ?', [nextDue, inserted.lastInsertRowid]);
|
||||||
run('UPDATE asset_pm_plans SET last_completed_date = ?, next_due_date = ?, active = ?, updated_at = ? WHERE id = ?', [completedDate, nextDue, active, ts, apId]);
|
run('UPDATE asset_pm_plans SET last_completed_date = ?, next_due_date = ?, active = ?, updated_at = ? WHERE id = ?', [completedDate, nextDue, active, ts, apId]);
|
||||||
audit(userId, 'complete', 'asset_pm', apId, null, { next_due_date: nextDue, completion_id: inserted.lastInsertRowid });
|
audit(userId, 'complete', 'asset_pm', apId, null, { next_due_date: nextDue, completion_id: inserted.lastInsertRowid });
|
||||||
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [apId]);
|
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [apId]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standalone usage reading (quick-log / inspection) — updates the meter's current reading and
|
||||||
|
// recomputes the schedule's next-due so a faster-than-expected usage rate pulls service in sooner.
|
||||||
|
function logReading(assetId, apId, body, userId) {
|
||||||
|
const ap = one('SELECT * FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
|
||||||
|
if (!ap) return null;
|
||||||
|
const meter = one('SELECT * FROM asset_pm_meters WHERE id = ? AND asset_pm_id = ?', [body.asset_pm_meter_id, apId]);
|
||||||
|
if (!meter) throw Object.assign(new Error('Meter not found on this schedule'), { status: 404 });
|
||||||
|
if (body.reading == null || body.reading === '' || !Number.isFinite(Number(body.reading))) {
|
||||||
|
throw Object.assign(new Error('A numeric reading is required'), { status: 400 });
|
||||||
|
}
|
||||||
|
const readingDate = (body.reading_date || now()).slice(0, 10);
|
||||||
|
const source = body.source === 'inspection' ? 'inspection' : 'manual';
|
||||||
|
return tx(() => {
|
||||||
|
recordMeterReading(meter, body.reading, readingDate, source, null, userId);
|
||||||
|
const lastDate = ap.last_completed_date || ap.start_date || readingDate;
|
||||||
|
const { nextDue, active } = scheduleNextDue(ap, lastDate);
|
||||||
|
run('UPDATE asset_pm_plans SET next_due_date = ?, active = ?, updated_at = ? WHERE id = ?', [nextDue, active, now(), apId]);
|
||||||
|
audit(userId, 'reading', 'asset_pm', apId, null, { meter: meter.label, reading: Number(body.reading), next_due_date: nextDue });
|
||||||
|
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [apId]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function listCompletions(query = {}) {
|
function listCompletions(query = {}) {
|
||||||
return all(
|
return all(
|
||||||
`SELECT c.id, c.completed_at, c.cost, c.notes_json, c.rating_safety, c.rating_physical, c.rating_operating,
|
`SELECT c.id, c.completed_at, c.cost, c.notes_json, c.rating_safety, c.rating_physical, c.rating_operating,
|
||||||
@@ -369,7 +505,16 @@ function getSettings() {
|
|||||||
return {
|
return {
|
||||||
pm_default_frequency_value: Number(map.pm_default_frequency_value || 3),
|
pm_default_frequency_value: Number(map.pm_default_frequency_value || 3),
|
||||||
pm_default_frequency_unit: map.pm_default_frequency_unit || 'months',
|
pm_default_frequency_unit: map.pm_default_frequency_unit || 'months',
|
||||||
pm_lead_days: Number(map.pm_lead_days || 7)
|
pm_lead_days: Number(map.pm_lead_days || 7),
|
||||||
|
pm_usage_tracking_enabled: map.pm_usage_tracking_enabled === '1',
|
||||||
|
pm_lifespan_curve_enabled: map.pm_lifespan_curve_enabled === '1',
|
||||||
|
pm_lifespan_min_factor: map.pm_lifespan_min_factor != null ? Number(map.pm_lifespan_min_factor) : 0.5,
|
||||||
|
pm_lifespan_curve_exponent: map.pm_lifespan_curve_exponent != null ? Number(map.pm_lifespan_curve_exponent) : 2,
|
||||||
|
// Nightly recompute job: refreshes projected due-dates so dynamic schedules don't drift between
|
||||||
|
// completions/readings. pm_last_recompute_at is job-managed (read-only to clients).
|
||||||
|
pm_recompute_enabled: map.pm_recompute_enabled === '1',
|
||||||
|
pm_recompute_hour: map.pm_recompute_hour != null ? Number(map.pm_recompute_hour) : 2,
|
||||||
|
pm_last_recompute_at: map.pm_last_recompute_at || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,10 +523,27 @@ function saveSettings(body, userId) {
|
|||||||
if (body.pm_default_frequency_value !== undefined) set('pm_default_frequency_value', Number(body.pm_default_frequency_value) || 3);
|
if (body.pm_default_frequency_value !== undefined) set('pm_default_frequency_value', Number(body.pm_default_frequency_value) || 3);
|
||||||
if (body.pm_default_frequency_unit !== undefined) set('pm_default_frequency_unit', FREQUENCY_UNITS.includes(body.pm_default_frequency_unit) ? body.pm_default_frequency_unit : 'months');
|
if (body.pm_default_frequency_unit !== undefined) set('pm_default_frequency_unit', FREQUENCY_UNITS.includes(body.pm_default_frequency_unit) ? body.pm_default_frequency_unit : 'months');
|
||||||
if (body.pm_lead_days !== undefined) set('pm_lead_days', Number(body.pm_lead_days) || 7);
|
if (body.pm_lead_days !== undefined) set('pm_lead_days', Number(body.pm_lead_days) || 7);
|
||||||
|
if (body.pm_usage_tracking_enabled !== undefined) set('pm_usage_tracking_enabled', body.pm_usage_tracking_enabled ? 1 : 0);
|
||||||
|
if (body.pm_lifespan_curve_enabled !== undefined) set('pm_lifespan_curve_enabled', body.pm_lifespan_curve_enabled ? 1 : 0);
|
||||||
|
// Floor multiplier is clamped to a sane 0.1–1.0; exponent to 0.5–6.
|
||||||
|
if (body.pm_lifespan_min_factor !== undefined) set('pm_lifespan_min_factor', Math.min(1, Math.max(0.1, Number(body.pm_lifespan_min_factor) || 0.5)));
|
||||||
|
if (body.pm_lifespan_curve_exponent !== undefined) set('pm_lifespan_curve_exponent', Math.min(6, Math.max(0.5, Number(body.pm_lifespan_curve_exponent) || 2)));
|
||||||
|
if (body.pm_recompute_enabled !== undefined) set('pm_recompute_enabled', body.pm_recompute_enabled ? 1 : 0);
|
||||||
|
if (body.pm_recompute_hour !== undefined) set('pm_recompute_hour', Math.min(23, Math.max(0, Math.round(Number(body.pm_recompute_hour)) || 0)));
|
||||||
audit(userId, 'update', 'pm_settings', null, null, body);
|
audit(userId, 'update', 'pm_settings', null, null, body);
|
||||||
return getSettings();
|
return getSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map persisted settings into the shape pmScheduling.computeNextDue expects.
|
||||||
|
function engineSettings(settings = getSettings()) {
|
||||||
|
return {
|
||||||
|
usage_enabled: settings.pm_usage_tracking_enabled,
|
||||||
|
curve_enabled: settings.pm_lifespan_curve_enabled,
|
||||||
|
min_factor: settings.pm_lifespan_min_factor,
|
||||||
|
curve_exponent: settings.pm_lifespan_curve_exponent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Alert candidates for PM schedules that are due/overdue.
|
// Alert candidates for PM schedules that are due/overdue.
|
||||||
function pmAlertCandidates() {
|
function pmAlertCandidates() {
|
||||||
const lead = getSettings().pm_lead_days;
|
const lead = getSettings().pm_lead_days;
|
||||||
@@ -398,15 +560,54 @@ function pmAlertCandidates() {
|
|||||||
for (const ap of rows) {
|
for (const ap of rows) {
|
||||||
const name = ap.plan_name || 'Preventative maintenance';
|
const name = ap.plan_name || 'Preventative maintenance';
|
||||||
const where = ap.asset_code ? ` on ${ap.asset_code}` : '';
|
const where = ap.asset_code ? ` on ${ap.asset_code}` : '';
|
||||||
if (ap.next_due_date < today) {
|
// Meters at/over their threshold are reported in the alert text (e.g. "Operating Hours 512/500").
|
||||||
list.push({ type: 'pm_overdue', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'critical', title: `PM overdue: ${name}`, message: `${name}${where} was due ${ap.next_due_date}.`, due_date: ap.next_due_date });
|
const crossed = assetPmMeters(ap.id)
|
||||||
|
.filter((m) => m.last_reading != null && m.due_at_reading != null && Number(m.last_reading) >= Number(m.due_at_reading))
|
||||||
|
.map((m) => `${m.label} ${Math.round(m.last_reading)}/${Math.round(m.due_at_reading)}${m.unit ? ' ' + m.unit : ''}`);
|
||||||
|
const usageNote = crossed.length ? ` Usage threshold reached: ${crossed.join('; ')}.` : '';
|
||||||
|
if (ap.next_due_date < today || crossed.length) {
|
||||||
|
list.push({ type: 'pm_overdue', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'critical', title: `PM overdue: ${name}`, message: `${name}${where} was due ${ap.next_due_date}.${usageNote}`, due_date: ap.next_due_date });
|
||||||
} else if (ap.next_due_date <= horizon) {
|
} else if (ap.next_due_date <= horizon) {
|
||||||
list.push({ type: 'pm_due', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'warning', title: `PM due: ${name}`, message: `${name}${where} is due ${ap.next_due_date}.`, due_date: ap.next_due_date });
|
list.push({ type: 'pm_due', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'warning', title: `PM due: ${name}`, message: `${name}${where} is due ${ap.next_due_date}.${usageNote}`, due_date: ap.next_due_date });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Nightly recompute -----------------------------------------------------
|
||||||
|
|
||||||
|
// Re-derive next_due_date for every active schedule. Usage projections shift as time passes (a meter
|
||||||
|
// that has crossed its threshold becomes due "today"), so without a periodic sweep a schedule that
|
||||||
|
// isn't completed or read keeps a stale projected date. Cheap: one engine pass per active schedule.
|
||||||
|
function recomputeAllSchedules(userId) {
|
||||||
|
const rows = all('SELECT * FROM asset_pm_plans WHERE active = 1');
|
||||||
|
let updated = 0;
|
||||||
|
for (const ap of rows) {
|
||||||
|
const lastDate = ap.last_completed_date || ap.start_date || todayStr();
|
||||||
|
const { nextDue, active } = scheduleNextDue(ap, lastDate);
|
||||||
|
if (nextDue !== ap.next_due_date || (active ? 1 : 0) !== (ap.active ? 1 : 0)) {
|
||||||
|
run('UPDATE asset_pm_plans SET next_due_date = ?, active = ?, updated_at = ? WHERE id = ?', [nextDue, active, now(), ap.id]);
|
||||||
|
updated += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ranAt = now();
|
||||||
|
run(
|
||||||
|
"INSERT INTO app_settings (key, value, updated_at) VALUES ('pm_last_recompute_at', ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
||||||
|
[ranAt, ranAt]
|
||||||
|
);
|
||||||
|
if (userId) audit(userId, 'recompute', 'asset_pm', null, null, { scanned: rows.length, updated });
|
||||||
|
return { scanned: rows.length, updated, ran_at: ranAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-gating entry point for the scheduler: runs once per day at the configured hour when enabled.
|
||||||
|
function runScheduledRecompute() {
|
||||||
|
const s = getSettings();
|
||||||
|
if (!s.pm_recompute_enabled) return { skipped: 'disabled' };
|
||||||
|
if (new Date().getHours() !== s.pm_recompute_hour) return { skipped: 'off-hour' };
|
||||||
|
if (s.pm_last_recompute_at && String(s.pm_last_recompute_at).slice(0, 10) === todayStr()) return { skipped: 'already-ran-today' };
|
||||||
|
return recomputeAllSchedules(null);
|
||||||
|
}
|
||||||
|
|
||||||
// Maintenance-cost "ledger" for the PM book: actual PM spend per asset for a year.
|
// Maintenance-cost "ledger" for the PM book: actual PM spend per asset for a year.
|
||||||
function pmBookLedger(year) {
|
function pmBookLedger(year) {
|
||||||
const rows = all(
|
const rows = all(
|
||||||
@@ -460,7 +661,10 @@ module.exports = {
|
|||||||
listCompletions,
|
listCompletions,
|
||||||
getSettings,
|
getSettings,
|
||||||
listPlans,
|
listPlans,
|
||||||
|
logReading,
|
||||||
pmAlertCandidates,
|
pmAlertCandidates,
|
||||||
|
recomputeAllSchedules,
|
||||||
|
runScheduledRecompute,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
updateAssetPm,
|
updateAssetPm,
|
||||||
updatePlan
|
updatePlan
|
||||||
|
|||||||
137
server/services/pmScheduling.js
Normal file
137
server/services/pmScheduling.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// Pure (DB-free) scheduling math for dynamic PM. The PM service feeds it plain objects so it can be
|
||||||
|
// unit-tested in isolation. The core idea: a PM's next-due is the EARLIEST of every enabled signal —
|
||||||
|
// the calendar interval (optionally compressed by an age/wear curve) and a projected usage threshold.
|
||||||
|
|
||||||
|
const DAY_MS = 86400000;
|
||||||
|
|
||||||
|
function toDate(dateStr) {
|
||||||
|
return new Date(`${String(dateStr).slice(0, 10)}T00:00:00`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayString(date) {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(dateStr, days) {
|
||||||
|
const d = toDate(dateStr);
|
||||||
|
d.setDate(d.getDate() + Math.round(days));
|
||||||
|
return dayString(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysBetween(aStr, bStr) {
|
||||||
|
return (toDate(bStr).getTime() - toDate(aStr).getTime()) / DAY_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a calendar frequency to an approximate number of days (months ≈ 30, year = 365) so the
|
||||||
|
// lifespan factor can scale it smoothly. Mirrors the unit cases in pm.addInterval.
|
||||||
|
function intervalToDays(value, unit) {
|
||||||
|
const v = Math.max(1, Number(value) || 1);
|
||||||
|
switch (unit) {
|
||||||
|
case 'days': return v;
|
||||||
|
case 'weeks': return v * 7;
|
||||||
|
case 'quarters': return v * 91;
|
||||||
|
case 'semiannual': return v * 182;
|
||||||
|
case 'years': return v * 365;
|
||||||
|
case 'months':
|
||||||
|
default: return v * 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(n, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wear curve: returns an interval multiplier in [minFactor, 1]. New equipment (ageFraction 0) keeps the
|
||||||
|
// full interval (1.0); at/after end of estimated life (ageFraction ≥ 1) it shrinks to minFactor. The
|
||||||
|
// exponent shapes the knee — exponent 1 is linear, higher exponents hold the base cadence longer then
|
||||||
|
// drop sharply near end of life.
|
||||||
|
function lifespanFactor(ageFraction, { minFactor = 0.5, exponent = 2 } = {}) {
|
||||||
|
const frac = clamp(Number(ageFraction) || 0, 0, 1);
|
||||||
|
const floor = clamp(Number(minFactor) || 0, 0.05, 1);
|
||||||
|
const exp = Math.max(0.1, Number(exponent) || 1);
|
||||||
|
return 1 - (1 - floor) * Math.pow(frac, exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age fraction of an asset's estimated life at a given date (0 = brand new, 1 = at end of life).
|
||||||
|
function ageFraction(inServiceDate, usefulLifeMonths, asOfStr) {
|
||||||
|
const months = Number(usefulLifeMonths);
|
||||||
|
if (!inServiceDate || !months || months <= 0) return 0;
|
||||||
|
const elapsedDays = Math.max(0, daysBetween(inServiceDate, asOfStr));
|
||||||
|
const lifeDays = months * 30;
|
||||||
|
return clamp(elapsedDays / lifeDays, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage per day inferred from a meter's readings (chronological [{ reading, reading_date }]). Uses the
|
||||||
|
// span between the earliest and latest reading; needs ≥2 readings over a positive number of days.
|
||||||
|
function usageRatePerDay(readings) {
|
||||||
|
if (!Array.isArray(readings) || readings.length < 2) return null;
|
||||||
|
const sorted = [...readings].sort((a, b) => toDate(a.reading_date) - toDate(b.reading_date));
|
||||||
|
const first = sorted[0];
|
||||||
|
const last = sorted[sorted.length - 1];
|
||||||
|
const days = daysBetween(first.reading_date, last.reading_date);
|
||||||
|
const delta = Number(last.reading) - Number(first.reading);
|
||||||
|
if (days <= 0 || delta <= 0) return null;
|
||||||
|
return delta / days;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projected date a meter reaches its threshold (due_at_reading). Already crossed → today; otherwise
|
||||||
|
// today + remaining/ratePerDay. Returns null when there is no threshold or no usable rate.
|
||||||
|
function projectUsageDue(meter, readings, todayStr) {
|
||||||
|
if (!meter || meter.due_at_reading == null) return null;
|
||||||
|
const current = meter.last_reading == null ? meter.baseline_reading : meter.last_reading;
|
||||||
|
if (current != null && Number(current) >= Number(meter.due_at_reading)) return todayStr;
|
||||||
|
const rate = usageRatePerDay(readings);
|
||||||
|
if (!rate || rate <= 0) return null;
|
||||||
|
const remaining = Number(meter.due_at_reading) - Number(current || 0);
|
||||||
|
return addDays(todayStr, Math.ceil(remaining / rate));
|
||||||
|
}
|
||||||
|
|
||||||
|
function earliest(dates) {
|
||||||
|
const valid = dates.filter(Boolean).sort();
|
||||||
|
return valid.length ? valid[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose the next-due date from every enabled signal and return the winner plus the per-signal
|
||||||
|
// breakdown (so the UI can explain WHY a date was chosen).
|
||||||
|
// schedule: { lastDate, frequency_value, frequency_unit, lifespan_adjust }
|
||||||
|
// asset: { in_service_date, useful_life_months }
|
||||||
|
// meters: [{ ...meter, readings: [...] }]
|
||||||
|
// settings: { usage_enabled, curve_enabled, min_factor, curve_exponent }
|
||||||
|
function computeNextDue(schedule, asset, meters, settings, today) {
|
||||||
|
const lastDate = schedule.lastDate || today;
|
||||||
|
const baseDays = intervalToDays(schedule.frequency_value, schedule.frequency_unit);
|
||||||
|
|
||||||
|
let factor = 1;
|
||||||
|
if (settings.curve_enabled && schedule.lifespan_adjust) {
|
||||||
|
const frac = ageFraction(asset.in_service_date, asset.useful_life_months, lastDate);
|
||||||
|
factor = lifespanFactor(frac, { minFactor: settings.min_factor, exponent: settings.curve_exponent });
|
||||||
|
}
|
||||||
|
const calendarDue = addDays(lastDate, baseDays * factor);
|
||||||
|
|
||||||
|
let usageDue = null;
|
||||||
|
if (settings.usage_enabled && Array.isArray(meters)) {
|
||||||
|
const projections = meters
|
||||||
|
.filter((m) => Number(m.usage_interval) > 0)
|
||||||
|
.map((m) => projectUsageDue(m, m.readings || [], today));
|
||||||
|
usageDue = earliest(projections);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lifespanDue = factor < 1 ? calendarDue : null; // surfaced separately for transparency
|
||||||
|
const nextDue = earliest([calendarDue, usageDue]);
|
||||||
|
return {
|
||||||
|
next_due_date: nextDue,
|
||||||
|
signals: { calendar: calendarDue, usage: usageDue, lifespan: lifespanDue, factor },
|
||||||
|
driver: nextDue === usageDue && usageDue ? 'usage' : (factor < 1 ? 'lifespan' : 'calendar')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
addDays,
|
||||||
|
ageFraction,
|
||||||
|
computeNextDue,
|
||||||
|
daysBetween,
|
||||||
|
intervalToDays,
|
||||||
|
lifespanFactor,
|
||||||
|
projectUsageDue,
|
||||||
|
usageRatePerDay
|
||||||
|
};
|
||||||
118
server/services/pmScheduling.test.js
Normal file
118
server/services/pmScheduling.test.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/*
|
||||||
|
* Tests for the pure dynamic-PM scheduling math: the lifespan wear curve, usage-rate inference,
|
||||||
|
* usage-threshold projection, and the earliest-wins composite next-due. No DB — plain objects in,
|
||||||
|
* dates out.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const {
|
||||||
|
ageFraction, computeNextDue, intervalToDays, lifespanFactor, projectUsageDue, usageRatePerDay
|
||||||
|
} = require('./pmScheduling');
|
||||||
|
|
||||||
|
describe('pm scheduling — lifespan curve', () => {
|
||||||
|
test('factor is 1 when new and minFactor at end of life', () => {
|
||||||
|
expect(lifespanFactor(0, { minFactor: 0.5, exponent: 2 })).toBeCloseTo(1);
|
||||||
|
expect(lifespanFactor(1, { minFactor: 0.5, exponent: 2 })).toBeCloseTo(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('factor decreases monotonically with age', () => {
|
||||||
|
const early = lifespanFactor(0.25, { minFactor: 0.5, exponent: 2 });
|
||||||
|
const mid = lifespanFactor(0.5, { minFactor: 0.5, exponent: 2 });
|
||||||
|
const late = lifespanFactor(0.9, { minFactor: 0.5, exponent: 2 });
|
||||||
|
expect(early).toBeGreaterThan(mid);
|
||||||
|
expect(mid).toBeGreaterThan(late);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ageFraction clamps to [0,1] and handles missing life', () => {
|
||||||
|
expect(ageFraction(null, 60, '2026-01-01')).toBe(0);
|
||||||
|
expect(ageFraction('2020-01-01', 0, '2026-01-01')).toBe(0);
|
||||||
|
// 12 years old against a 5-year (60mo) life → clamped to 1.
|
||||||
|
expect(ageFraction('2014-01-01', 60, '2026-01-01')).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pm scheduling — usage', () => {
|
||||||
|
test('intervalToDays converts units', () => {
|
||||||
|
expect(intervalToDays(1, 'weeks')).toBe(7);
|
||||||
|
expect(intervalToDays(2, 'months')).toBe(60);
|
||||||
|
expect(intervalToDays(1, 'years')).toBe(365);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('usageRatePerDay needs ≥2 increasing readings over time', () => {
|
||||||
|
expect(usageRatePerDay([{ reading: 100, reading_date: '2026-01-01' }])).toBeNull();
|
||||||
|
const rate = usageRatePerDay([
|
||||||
|
{ reading: 100, reading_date: '2026-01-01' },
|
||||||
|
{ reading: 200, reading_date: '2026-01-11' }
|
||||||
|
]);
|
||||||
|
expect(rate).toBeCloseTo(10); // 100 units / 10 days
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projectUsageDue returns today when already past threshold', () => {
|
||||||
|
const meter = { due_at_reading: 500, last_reading: 520, baseline_reading: 0 };
|
||||||
|
expect(projectUsageDue(meter, [], '2026-06-06')).toBe('2026-06-06');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projectUsageDue projects forward from the measured rate', () => {
|
||||||
|
const meter = { due_at_reading: 500, last_reading: 400, baseline_reading: 0 };
|
||||||
|
const readings = [
|
||||||
|
{ reading: 300, reading_date: '2026-01-01' },
|
||||||
|
{ reading: 400, reading_date: '2026-01-11' } // 10/day → 100 remaining ≈ 10 days
|
||||||
|
];
|
||||||
|
expect(projectUsageDue(meter, readings, '2026-06-06')).toBe('2026-06-16');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pm scheduling — composite next-due (earliest wins)', () => {
|
||||||
|
const asset = { in_service_date: '2026-01-01', useful_life_months: 60 };
|
||||||
|
|
||||||
|
test('falls back to the calendar date when signals are disabled', () => {
|
||||||
|
const out = computeNextDue(
|
||||||
|
{ lastDate: '2026-06-06', frequency_value: 6, frequency_unit: 'months', lifespan_adjust: true },
|
||||||
|
asset, [], { usage_enabled: false, curve_enabled: false }, '2026-06-06'
|
||||||
|
);
|
||||||
|
// 6 months ≈ 180 days out, no compression.
|
||||||
|
expect(out.next_due_date).toBe('2026-12-03');
|
||||||
|
expect(out.driver).toBe('calendar');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fast usage rate pulls the due date in before the calendar date', () => {
|
||||||
|
const meters = [{
|
||||||
|
usage_interval: 500, due_at_reading: 500, last_reading: 450, baseline_reading: 0,
|
||||||
|
readings: [
|
||||||
|
{ reading: 0, reading_date: '2026-05-01' },
|
||||||
|
{ reading: 450, reading_date: '2026-06-05' } // ~12.8/day → ~4 days to 500
|
||||||
|
]
|
||||||
|
}];
|
||||||
|
const out = computeNextDue(
|
||||||
|
{ lastDate: '2026-06-06', frequency_value: 6, frequency_unit: 'months', lifespan_adjust: false },
|
||||||
|
asset, meters, { usage_enabled: true, curve_enabled: false }, '2026-06-06'
|
||||||
|
);
|
||||||
|
expect(out.driver).toBe('usage');
|
||||||
|
expect(out.next_due_date < '2026-12-03').toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the lifespan curve compresses the interval for an aged asset', () => {
|
||||||
|
const old = { in_service_date: '2021-06-06', useful_life_months: 60 }; // ~5yr old → end of life
|
||||||
|
const settings = { usage_enabled: false, curve_enabled: true, min_factor: 0.5, curve_exponent: 1 };
|
||||||
|
const fresh = computeNextDue(
|
||||||
|
{ lastDate: '2026-06-06', frequency_value: 6, frequency_unit: 'months', lifespan_adjust: true },
|
||||||
|
asset, [], settings, '2026-06-06'
|
||||||
|
);
|
||||||
|
const aged = computeNextDue(
|
||||||
|
{ lastDate: '2026-06-06', frequency_value: 6, frequency_unit: 'months', lifespan_adjust: true },
|
||||||
|
old, [], settings, '2026-06-06'
|
||||||
|
);
|
||||||
|
expect(aged.next_due_date < fresh.next_due_date).toBe(true);
|
||||||
|
expect(aged.driver).toBe('lifespan');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lifespan compression is ignored when the schedule opts out', () => {
|
||||||
|
const old = { in_service_date: '2021-06-06', useful_life_months: 60 };
|
||||||
|
const settings = { usage_enabled: false, curve_enabled: true, min_factor: 0.5, curve_exponent: 1 };
|
||||||
|
const out = computeNextDue(
|
||||||
|
{ lastDate: '2026-06-06', frequency_value: 6, frequency_unit: 'months', lifespan_adjust: false },
|
||||||
|
old, [], settings, '2026-06-06'
|
||||||
|
);
|
||||||
|
expect(out.driver).toBe('calendar');
|
||||||
|
expect(out.signals.factor).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ const { publicUser } = require('../middleware/auth');
|
|||||||
const { capabilitiesForRole } = require('./roles');
|
const { capabilitiesForRole } = require('./roles');
|
||||||
|
|
||||||
const defaultPreferences = {
|
const defaultPreferences = {
|
||||||
theme: 'mixedAssetsDark'
|
theme: 'depreCoreDark'
|
||||||
};
|
};
|
||||||
|
|
||||||
function getPreferences(userId) {
|
function getPreferences(userId) {
|
||||||
|
|||||||
@@ -14,6 +14,50 @@ function yearOf(value) {
|
|||||||
return new Date(`${value}T00:00:00`).getFullYear();
|
return new Date(`${value}T00:00:00`).getFullYear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function monthOf(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
return new Date(`${value}T00:00:00`).getMonth() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A date falls in the period when the year matches and (no month filter, or the month matches).
|
||||||
|
function inPeriod(date, year, month) {
|
||||||
|
if (yearOf(date) !== year) return false;
|
||||||
|
if (month && monthOf(date) !== month) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REVALUATION_TYPES = ['revaluation', 'write_up', 'write_down'];
|
||||||
|
|
||||||
|
// Adjustment rows joined to their asset, filtered by period and (optionally) a set of types.
|
||||||
|
function adjustmentRows(year, month, types, entityId) {
|
||||||
|
return all(
|
||||||
|
`SELECT adj.*, a.asset_id, a.description, a.category, e.name AS entity_name, u.name AS created_by_name
|
||||||
|
FROM asset_adjustments adj
|
||||||
|
JOIN assets a ON a.id = adj.asset_id
|
||||||
|
LEFT JOIN entities e ON e.id = a.entity_id
|
||||||
|
LEFT JOIN users u ON u.id = adj.created_by
|
||||||
|
WHERE (? IS NULL OR a.entity_id = ?)
|
||||||
|
ORDER BY adj.adjustment_date`,
|
||||||
|
[entityId || null, entityId || null]
|
||||||
|
).filter((row) => inPeriod(row.adjustment_date, year, month) && (!types || types.includes(row.type)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic aggregation: group items by keyFn, summing the given numeric fields and counting rows.
|
||||||
|
function groupSummary(items, keyFn, fields) {
|
||||||
|
const groups = {};
|
||||||
|
for (const item of items) {
|
||||||
|
const key = keyFn(item) || 'Uncategorized';
|
||||||
|
const group = groups[key] || (groups[key] = { key, count: 0 });
|
||||||
|
group.count += 1;
|
||||||
|
for (const field of fields) group[field] = (group[field] || 0) + Number(item[field] || 0);
|
||||||
|
}
|
||||||
|
return Object.values(groups).map((group) => {
|
||||||
|
const out = { key: group.key, count: group.count };
|
||||||
|
for (const field of fields) out[field] = money(group[field]);
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function sumBy(rows, keys) {
|
function sumBy(rows, keys) {
|
||||||
const totals = {};
|
const totals = {};
|
||||||
for (const key of keys) totals[key] = money(rows.reduce((sum, row) => sum + Number(row[key] || 0), 0));
|
for (const key of keys) totals[key] = money(rows.reduce((sum, row) => sum + Number(row[key] || 0), 0));
|
||||||
@@ -210,13 +254,14 @@ function lifetimeDepreciation(options) {
|
|||||||
|
|
||||||
function acquisitions(options) {
|
function acquisitions(options) {
|
||||||
const year = Number(options.year) || currentYear();
|
const year = Number(options.year) || currentYear();
|
||||||
|
const month = options.txn_month ? Number(options.txn_month) : null;
|
||||||
const rows = all(
|
const rows = all(
|
||||||
`SELECT a.asset_id, a.description, a.category, a.acquired_date, a.vendor, a.invoice_number, a.acquisition_cost
|
`SELECT a.asset_id, a.description, a.category, a.acquired_date, a.vendor, a.invoice_number, a.acquisition_cost
|
||||||
FROM assets a WHERE a.acquired_date IS NOT NULL
|
FROM assets a WHERE a.acquired_date IS NOT NULL
|
||||||
AND (? IS NULL OR a.entity_id = ?)
|
AND (? IS NULL OR a.entity_id = ?)
|
||||||
ORDER BY a.acquired_date`,
|
ORDER BY a.acquired_date`,
|
||||||
[options.entity_id || null, options.entity_id || null]
|
[options.entity_id || null, options.entity_id || null]
|
||||||
).filter((row) => yearOf(row.acquired_date) === year)
|
).filter((row) => inPeriod(row.acquired_date, year, month))
|
||||||
.map((row) => ({ ...row, acquisition_cost: money(row.acquisition_cost) }));
|
.map((row) => ({ ...row, acquisition_cost: money(row.acquisition_cost) }));
|
||||||
return {
|
return {
|
||||||
columns: [
|
columns: [
|
||||||
@@ -241,7 +286,10 @@ function disposalRows(year, entityId) {
|
|||||||
|
|
||||||
function disposals(options) {
|
function disposals(options) {
|
||||||
const year = Number(options.year) || currentYear();
|
const year = Number(options.year) || currentYear();
|
||||||
const rows = disposalRows(year, options.entity_id).map((row) => ({
|
const month = options.txn_month ? Number(options.txn_month) : null;
|
||||||
|
const rows = disposalRows(year, options.entity_id)
|
||||||
|
.filter((row) => !month || monthOf(row.disposal_date) === month)
|
||||||
|
.map((row) => ({
|
||||||
asset_id: row.asset_id,
|
asset_id: row.asset_id,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
method: row.method,
|
method: row.method,
|
||||||
@@ -351,15 +399,17 @@ function projection(options) {
|
|||||||
function journalDepreciation(options) {
|
function journalDepreciation(options) {
|
||||||
const book = options.book || 'GAAP';
|
const book = options.book || 'GAAP';
|
||||||
const year = Number(options.year) || currentYear();
|
const year = Number(options.year) || currentYear();
|
||||||
|
const factor = options.monthlyFactor || 1;
|
||||||
const rules = ruleSetForBook(book);
|
const rules = ruleSetForBook(book);
|
||||||
const accounts = {};
|
const accounts = {};
|
||||||
for (const { asset, book: bk } of assetBookRows(book, options).map(toAssetBook)) {
|
for (const { asset, book: bk } of assetBookRows(book, options).map(toAssetBook)) {
|
||||||
const c = computeYear(asset, bk, rules, year);
|
const c = computeYear(asset, bk, rules, year);
|
||||||
if (!c.depreciation) continue;
|
const amount = money(c.depreciation * factor);
|
||||||
|
if (!amount) continue;
|
||||||
const expense = asset.gl_expense_account || '6400';
|
const expense = asset.gl_expense_account || '6400';
|
||||||
const accum = asset.gl_accumulated_account || '1590';
|
const accum = asset.gl_accumulated_account || '1590';
|
||||||
accounts[expense] = (accounts[expense] || 0) + c.depreciation;
|
accounts[expense] = (accounts[expense] || 0) + amount;
|
||||||
accounts[`__accum__${accum}`] = (accounts[`__accum__${accum}`] || 0) + c.depreciation;
|
accounts[`__accum__${accum}`] = (accounts[`__accum__${accum}`] || 0) + amount;
|
||||||
}
|
}
|
||||||
const rows = [];
|
const rows = [];
|
||||||
for (const [key, amount] of Object.entries(accounts)) {
|
for (const [key, amount] of Object.entries(accounts)) {
|
||||||
@@ -379,8 +429,10 @@ function journalDepreciation(options) {
|
|||||||
|
|
||||||
function journalDisposals(options) {
|
function journalDisposals(options) {
|
||||||
const year = Number(options.year) || currentYear();
|
const year = Number(options.year) || currentYear();
|
||||||
|
const month = options.txn_month ? Number(options.txn_month) : null;
|
||||||
const rows = [];
|
const rows = [];
|
||||||
for (const d of disposalRows(year, options.entity_id)) {
|
for (const d of disposalRows(year, options.entity_id)) {
|
||||||
|
if (month && monthOf(d.disposal_date) !== month) continue;
|
||||||
const proceeds = money(d.disposal_price - d.disposal_expense);
|
const proceeds = money(d.disposal_price - d.disposal_expense);
|
||||||
rows.push({ asset_id: d.asset_id, memo: 'Cash / proceeds', debit: proceeds, credit: 0 });
|
rows.push({ asset_id: d.asset_id, memo: 'Cash / proceeds', debit: proceeds, credit: 0 });
|
||||||
rows.push({ asset_id: d.asset_id, memo: 'Accumulated depreciation', debit: money(d.accumulated_depreciation), credit: 0 });
|
rows.push({ asset_id: d.asset_id, memo: 'Accumulated depreciation', debit: money(d.accumulated_depreciation), credit: 0 });
|
||||||
@@ -721,6 +773,314 @@ function alertsSince(options) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Revaluation & write-off (transactions, journals, summaries) -----------
|
||||||
|
|
||||||
|
function revaluationTransactions(options) {
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const month = options.txn_month ? Number(options.txn_month) : null;
|
||||||
|
const rows = adjustmentRows(year, month, REVALUATION_TYPES, options.entity_id).map((r) => ({
|
||||||
|
adjustment_date: r.adjustment_date, asset_id: r.asset_id, description: r.description,
|
||||||
|
type: String(r.type).replace(/_/g, ' '), book_type: r.book_type, amount: money(r.amount), reason: r.reason
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('adjustment_date', 'Date', 'date'), col('asset_id', 'Asset ID'), col('description', 'Description'),
|
||||||
|
col('type', 'Type'), col('book_type', 'Book'), col('amount', 'Amount', 'currency'), col('reason', 'Reason')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['amount'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write-offs are impairment adjustments plus abandonment disposals (asset removed at no value).
|
||||||
|
function writeoffItems(year, month, entityId) {
|
||||||
|
const impairments = adjustmentRows(year, month, ['impairment'], entityId).map((r) => ({
|
||||||
|
date: r.adjustment_date, asset_id: r.asset_id, description: r.description, category: r.category,
|
||||||
|
source: 'Impairment', book_type: r.book_type, amount: money(r.amount), reason: r.reason
|
||||||
|
}));
|
||||||
|
const abandonments = disposalRows(year, entityId)
|
||||||
|
.filter((d) => d.method === 'abandonment' && (!month || monthOf(d.disposal_date) === month))
|
||||||
|
.map((d) => ({
|
||||||
|
date: d.disposal_date, asset_id: d.asset_id, description: d.description, category: d.category,
|
||||||
|
source: 'Abandonment', book_type: d.book_type, amount: money(d.net_book_value), reason: d.notes
|
||||||
|
}));
|
||||||
|
return [...impairments, ...abandonments].sort((a, b) => String(a.date).localeCompare(String(b.date)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeoffTransactions(options) {
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const month = options.txn_month ? Number(options.txn_month) : null;
|
||||||
|
const rows = writeoffItems(year, month, options.entity_id);
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('date', 'Date', 'date'), col('asset_id', 'Asset ID'), col('description', 'Description'),
|
||||||
|
col('source', 'Source'), col('book_type', 'Book'), col('amount', 'Amount', 'currency'), col('reason', 'Reason')
|
||||||
|
],
|
||||||
|
rows: rows.map(({ category, ...rest }) => rest),
|
||||||
|
totals: sumBy(rows, ['amount'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function journalRevaluation(options) {
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const month = options.txn_month ? Number(options.txn_month) : null;
|
||||||
|
const rows = [];
|
||||||
|
for (const r of adjustmentRows(year, month, REVALUATION_TYPES, options.entity_id)) {
|
||||||
|
const amount = money(r.amount);
|
||||||
|
if (!amount) continue;
|
||||||
|
// write_up / revaluation increase carrying value (Dr asset, Cr revaluation surplus); write_down reverses.
|
||||||
|
const up = r.type !== 'write_down';
|
||||||
|
rows.push({ asset_id: r.asset_id, memo: 'Asset carrying value', debit: up ? amount : 0, credit: up ? 0 : amount });
|
||||||
|
rows.push({ asset_id: r.asset_id, memo: 'Revaluation surplus / reserve', debit: up ? 0 : amount, credit: up ? amount : 0 });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
columns: [col('asset_id', 'Asset ID'), col('memo', 'Memo'), col('debit', 'Debit', 'currency'), col('credit', 'Credit', 'currency')],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['debit', 'credit'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function journalWriteoff(options) {
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const month = options.txn_month ? Number(options.txn_month) : null;
|
||||||
|
const rows = [];
|
||||||
|
for (const item of writeoffItems(year, month, options.entity_id)) {
|
||||||
|
const amount = money(item.amount);
|
||||||
|
if (!amount) continue;
|
||||||
|
rows.push({ asset_id: item.asset_id, memo: `${item.source} loss / expense`, debit: amount, credit: 0 });
|
||||||
|
rows.push({ asset_id: item.asset_id, memo: 'Asset / accumulated depreciation', debit: 0, credit: amount });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
columns: [col('asset_id', 'Asset ID'), col('memo', 'Memo'), col('debit', 'Debit', 'currency'), col('credit', 'Credit', 'currency')],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['debit', 'credit'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Summaries -------------------------------------------------------------
|
||||||
|
|
||||||
|
function bookSummaryItems(options) {
|
||||||
|
const book = options.book || 'GAAP';
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const rules = ruleSetForBook(book);
|
||||||
|
return assetBookRows(book, options).map(toAssetBook).map(({ asset, book: bk }) => {
|
||||||
|
const c = computeYear(asset, bk, rules, year);
|
||||||
|
return {
|
||||||
|
category: asset.category || 'Uncategorized',
|
||||||
|
cost: c.cost, depreciation: c.depreciation, accumulated: c.accumulated_end, nbv: c.nbv_end
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetSummary(options) {
|
||||||
|
const rows = groupSummary(bookSummaryItems(options), (i) => i.category, ['cost', 'accumulated', 'nbv'])
|
||||||
|
.map((g) => ({ category: g.key || 'Uncategorized', count: g.count, cost: g.cost, accumulated: g.accumulated, nbv: g.nbv }));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('category', 'Category'), col('count', 'Assets', 'number'), col('cost', 'Cost', 'currency'),
|
||||||
|
col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['cost', 'accumulated', 'nbv']) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function depreciationSummary(options) {
|
||||||
|
const rows = groupSummary(bookSummaryItems(options), (i) => i.category, ['cost', 'depreciation', 'accumulated', 'nbv'])
|
||||||
|
.map((g) => ({ category: g.key, count: g.count, cost: g.cost, depreciation: g.depreciation, accumulated: g.accumulated, nbv: g.nbv }));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('category', 'Category'), col('count', 'Assets', 'number'), col('cost', 'Cost', 'currency'),
|
||||||
|
col('depreciation', 'Depreciation', 'currency'), col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['cost', 'depreciation', 'accumulated', 'nbv']) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ytdDepreciationSummary(options) {
|
||||||
|
const month = Math.min(12, Math.max(1, Number(options.month) || 12));
|
||||||
|
const items = bookSummaryItems(options).map((i) => ({ ...i, ytd: money((i.depreciation * month) / 12) }));
|
||||||
|
const rows = groupSummary(items, (i) => i.category, ['depreciation', 'ytd', 'accumulated', 'nbv'])
|
||||||
|
.map((g) => ({ category: g.key, count: g.count, depreciation: g.depreciation, ytd: g.ytd, accumulated: g.accumulated, nbv: g.nbv }));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('category', 'Category'), col('count', 'Assets', 'number'), col('depreciation', 'Annual', 'currency'),
|
||||||
|
col('ytd', `YTD through M${month}`, 'currency'), col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['depreciation', 'ytd', 'accumulated', 'nbv']) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function purchaseSummary(options) {
|
||||||
|
const items = acquisitions(options).rows.map((r) => ({ category: r.category || 'Uncategorized', cost: r.acquisition_cost }));
|
||||||
|
const rows = groupSummary(items, (i) => i.category, ['cost']).map((g) => ({ category: g.key, count: g.count, cost: g.cost }));
|
||||||
|
return {
|
||||||
|
columns: [col('category', 'Category'), col('count', 'Purchases', 'number'), col('cost', 'Cost', 'currency')],
|
||||||
|
rows,
|
||||||
|
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['cost']) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposalSummary(options) {
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const month = options.txn_month ? Number(options.txn_month) : null;
|
||||||
|
const items = disposalRows(year, options.entity_id)
|
||||||
|
.filter((d) => !month || monthOf(d.disposal_date) === month)
|
||||||
|
.map((d) => ({
|
||||||
|
method: String(d.method).replace(/_/g, ' '),
|
||||||
|
proceeds: money(d.disposal_price - d.disposal_expense), nbv: money(d.net_book_value), gain_loss: money(d.gain_loss)
|
||||||
|
}));
|
||||||
|
const rows = groupSummary(items, (i) => i.method, ['proceeds', 'nbv', 'gain_loss'])
|
||||||
|
.map((g) => ({ method: g.key, count: g.count, proceeds: g.proceeds, nbv: g.nbv, gain_loss: g.gain_loss }));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('method', 'Method'), col('count', 'Disposals', 'number'), col('proceeds', 'Net proceeds', 'currency'),
|
||||||
|
col('nbv', 'Net book value', 'currency'), col('gain_loss', 'Gain / (loss)', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['proceeds', 'nbv', 'gain_loss']) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function revaluationSummary(options) {
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const month = options.txn_month ? Number(options.txn_month) : null;
|
||||||
|
const items = adjustmentRows(year, month, REVALUATION_TYPES, options.entity_id)
|
||||||
|
.map((r) => ({ type: String(r.type).replace(/_/g, ' '), amount: money(r.amount) }));
|
||||||
|
const rows = groupSummary(items, (i) => i.type, ['amount']).map((g) => ({ type: g.key, count: g.count, amount: g.amount }));
|
||||||
|
return {
|
||||||
|
columns: [col('type', 'Type'), col('count', 'Count', 'number'), col('amount', 'Net amount', 'currency')],
|
||||||
|
rows,
|
||||||
|
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['amount']) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeoffSummary(options) {
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const month = options.txn_month ? Number(options.txn_month) : null;
|
||||||
|
const items = writeoffItems(year, month, options.entity_id);
|
||||||
|
const rows = groupSummary(items, (i) => i.category, ['amount']).map((g) => ({ category: g.key, count: g.count, amount: g.amount }));
|
||||||
|
return {
|
||||||
|
columns: [col('category', 'Category'), col('count', 'Write-offs', 'number'), col('amount', 'Amount', 'currency')],
|
||||||
|
rows,
|
||||||
|
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['amount']) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Registers, journals & history -----------------------------------------
|
||||||
|
|
||||||
|
function fixedAssetRegister(options) {
|
||||||
|
const book = options.book || 'GAAP';
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const rules = ruleSetForBook(book);
|
||||||
|
const rows = assetBookRows(book, options).map(toAssetBook).map(({ asset, book: bk }) => {
|
||||||
|
const c = computeYear(asset, bk, rules, year);
|
||||||
|
return {
|
||||||
|
asset_id: asset.asset_id, description: asset.description, category: asset.category, status: asset.status,
|
||||||
|
acquired_date: asset.acquired_date, in_service_date: asset.in_service_date,
|
||||||
|
location: asset.location, custodian: asset.custodian,
|
||||||
|
cost: c.cost, accumulated: c.accumulated_end, nbv: c.nbv_end
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('asset_id', 'Asset ID'), col('description', 'Description'), col('category', 'Category'), col('status', 'Status'),
|
||||||
|
col('acquired_date', 'Acquired', 'date'), col('in_service_date', 'In service', 'date'),
|
||||||
|
col('location', 'Location'), col('custodian', 'Custodian'),
|
||||||
|
col('cost', 'Cost', 'currency'), col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['cost', 'accumulated', 'nbv'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comprehensive per-asset dossier: per-book financials (reused from the asset card) plus rich
|
||||||
|
// sections (maintenance, notes, warranties, adjustments, disposals) and photo thumbnails for PDF.
|
||||||
|
function assetJournal(options) {
|
||||||
|
const card = fixedAssetCard(options);
|
||||||
|
if (!card.meta || !card.meta.asset) return card;
|
||||||
|
const asset = one('SELECT * FROM assets WHERE id = ? OR asset_id = ? LIMIT 1', [options.asset_id, options.asset_id]);
|
||||||
|
const id = asset.id;
|
||||||
|
|
||||||
|
const maintenance = all(
|
||||||
|
`SELECT c.completed_at, p.name AS plan_name, u.name AS by_name, c.cost
|
||||||
|
FROM pm_completions c JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
|
||||||
|
LEFT JOIN pm_plans p ON p.id = ap.plan_id LEFT JOIN users u ON u.id = c.completed_by
|
||||||
|
WHERE ap.asset_id = ? ORDER BY c.completed_at DESC`, [id]
|
||||||
|
).map((r) => ({ completed_at: String(r.completed_at).slice(0, 10), plan_name: r.plan_name || 'Maintenance', by_name: r.by_name, cost: money(r.cost) }));
|
||||||
|
|
||||||
|
const notes = all(
|
||||||
|
`SELECT n.created_at, n.body, u.name AS by_name FROM asset_notes n
|
||||||
|
LEFT JOIN users u ON u.id = n.created_by WHERE n.asset_id = ? ORDER BY n.created_at DESC`, [id]
|
||||||
|
).map((r) => ({ created_at: String(r.created_at).slice(0, 10), by_name: r.by_name, body: r.body }));
|
||||||
|
|
||||||
|
const warranties = all(
|
||||||
|
'SELECT provider, start_date, expiration_date, warranty_limit, coverage_details FROM warranties WHERE asset_id = ? ORDER BY expiration_date', [id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const adjustments = all(
|
||||||
|
'SELECT adjustment_date, type, book_type, amount, reason FROM asset_adjustments WHERE asset_id = ? ORDER BY adjustment_date DESC', [id]
|
||||||
|
).map((r) => ({ adjustment_date: r.adjustment_date, type: String(r.type).replace(/_/g, ' '), book_type: r.book_type, amount: money(r.amount), reason: r.reason }));
|
||||||
|
|
||||||
|
const disposalsList = all(
|
||||||
|
'SELECT disposal_date, method, net_book_value, gain_loss FROM disposals WHERE asset_id = ? ORDER BY disposal_date DESC', [id]
|
||||||
|
).map((r) => ({ disposal_date: r.disposal_date, method: String(r.method).replace(/_/g, ' '), net_book_value: money(r.net_book_value), gain_loss: money(r.gain_loss) }));
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{ title: 'Maintenance history', columns: [col('completed_at', 'Completed', 'date'), col('plan_name', 'Plan'), col('by_name', 'By'), col('cost', 'Cost', 'currency')], rows: maintenance },
|
||||||
|
{ title: 'Notes', columns: [col('created_at', 'Date', 'date'), col('by_name', 'By'), col('body', 'Note')], rows: notes },
|
||||||
|
{ title: 'Warranties', columns: [col('provider', 'Provider'), col('start_date', 'Start', 'date'), col('expiration_date', 'Expires', 'date'), col('warranty_limit', 'Limit'), col('coverage_details', 'Coverage')], rows: warranties },
|
||||||
|
{ title: 'Adjustments & revaluations', columns: [col('adjustment_date', 'Date', 'date'), col('type', 'Type'), col('book_type', 'Book'), col('amount', 'Amount', 'currency'), col('reason', 'Reason')], rows: adjustments }
|
||||||
|
];
|
||||||
|
if (disposalsList.length) {
|
||||||
|
sections.push({ title: 'Disposals', columns: [col('disposal_date', 'Date', 'date'), col('method', 'Type'), col('net_book_value', 'NBV', 'currency'), col('gain_loss', 'Gain / (loss)', 'currency')], rows: disposalsList });
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoRows = all('SELECT name, caption, data_base64 FROM asset_photos WHERE asset_id = ? ORDER BY sort_order, id LIMIT 12', [id]);
|
||||||
|
const photos = photoRows.map((p) => ({ caption: p.caption || p.name || 'Photo', data: p.data_base64 }));
|
||||||
|
const photoCount = one('SELECT COUNT(*) AS c FROM asset_photos WHERE asset_id = ?', [id]).c;
|
||||||
|
|
||||||
|
return { ...card, meta: { ...card.meta, layout: 'dossier', sections, photos, photo_count: photoCount } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetHistory(options) {
|
||||||
|
const row = options.asset_id ? one('SELECT * FROM assets WHERE id = ? OR asset_id = ? LIMIT 1', [options.asset_id, options.asset_id]) : null;
|
||||||
|
const asset = row ? assetFromRow(row) : null;
|
||||||
|
if (!asset) return { columns: [], rows: [], totals: {}, meta: { note: 'Select an asset to view its history.' } };
|
||||||
|
const id = asset.id;
|
||||||
|
const events = [];
|
||||||
|
if (asset.acquired_date) events.push({ date: asset.acquired_date, event: 'Acquired', detail: [asset.vendor, asset.invoice_number ? `#${asset.invoice_number}` : ''].filter(Boolean).join(' '), by: '', amount: money(asset.acquisition_cost) });
|
||||||
|
if (asset.in_service_date) events.push({ date: asset.in_service_date, event: 'Placed in service', detail: asset.description, by: '', amount: '' });
|
||||||
|
for (const r of all('SELECT * FROM asset_adjustments WHERE asset_id = ?', [id])) {
|
||||||
|
events.push({ date: r.adjustment_date, event: String(r.type).replace(/_/g, ' '), detail: [r.book_type, r.reason].filter(Boolean).join(' · '), by: '', amount: money(r.amount) });
|
||||||
|
}
|
||||||
|
for (const d of all('SELECT * FROM disposals WHERE asset_id = ?', [id])) {
|
||||||
|
events.push({ date: d.disposal_date, event: `Disposal (${String(d.method).replace(/_/g, ' ')})`, detail: `Gain/(loss) ${money(d.gain_loss)}`, by: '', amount: money(d.net_book_value) });
|
||||||
|
}
|
||||||
|
for (const c of all('SELECT c.completed_at, c.cost, u.name AS by_name FROM pm_completions c JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id LEFT JOIN users u ON u.id = c.completed_by WHERE ap.asset_id = ?', [id])) {
|
||||||
|
events.push({ date: String(c.completed_at).slice(0, 10), event: 'PM completed', detail: '', by: c.by_name, amount: money(c.cost) });
|
||||||
|
}
|
||||||
|
for (const n of all('SELECT n.created_at, n.body, u.name AS by_name FROM asset_notes n LEFT JOIN users u ON u.id = n.created_by WHERE n.asset_id = ?', [id])) {
|
||||||
|
events.push({ date: String(n.created_at).slice(0, 10), event: 'Note', detail: n.body, by: n.by_name, amount: '' });
|
||||||
|
}
|
||||||
|
for (const w of all('SELECT provider, start_date FROM warranties WHERE asset_id = ?', [id])) {
|
||||||
|
events.push({ date: w.start_date || '', event: 'Warranty added', detail: w.provider || '', by: '', amount: '' });
|
||||||
|
}
|
||||||
|
for (const lg of all("SELECT al.action, al.created_at, u.name AS by_name FROM audit_logs al LEFT JOIN users u ON u.id = al.actor_id WHERE al.entity_type = 'asset' AND al.entity_id = ?", [String(id)])) {
|
||||||
|
if (['dispose', 'adjust', 'reverse_disposal'].includes(lg.action)) continue;
|
||||||
|
events.push({ date: String(lg.created_at).slice(0, 10), event: String(lg.action).replace(/_/g, ' '), detail: '', by: lg.by_name, amount: '' });
|
||||||
|
}
|
||||||
|
events.sort((a, b) => String(a.date).localeCompare(String(b.date)));
|
||||||
|
return {
|
||||||
|
columns: [col('date', 'Date', 'date'), col('event', 'Event'), col('detail', 'Detail'), col('by', 'By'), col('amount', 'Amount', 'currency')],
|
||||||
|
rows: events,
|
||||||
|
totals: {},
|
||||||
|
meta: { asset: { asset_id: asset.asset_id, description: asset.description, status: asset.status }, note: `${events.length} event(s) recorded.` }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Registry --------------------------------------------------------------
|
// ---- Registry --------------------------------------------------------------
|
||||||
|
|
||||||
const REPORTS = {
|
const REPORTS = {
|
||||||
@@ -750,7 +1110,34 @@ const REPORTS = {
|
|||||||
pm_plan: { title: 'PM plan (printable)', group: 'Maintenance', params: ['pm_plan_id'], build: (o) => pmPlanPrint(o) },
|
pm_plan: { title: 'PM plan (printable)', group: 'Maintenance', params: ['pm_plan_id'], build: (o) => pmPlanPrint(o) },
|
||||||
alerts_since: { title: 'Alerts since date', group: 'Maintenance', params: ['since_date'], build: (o) => alertsSince(o) },
|
alerts_since: { title: 'Alerts since date', group: 'Maintenance', params: ['since_date'], build: (o) => alertsSince(o) },
|
||||||
fixed_asset_card: { title: 'Fixed asset card', group: 'Detail', params: ['asset_id', 'year'], build: (o) => fixedAssetCard(o) },
|
fixed_asset_card: { title: 'Fixed asset card', group: 'Detail', params: ['asset_id', 'year'], build: (o) => fixedAssetCard(o) },
|
||||||
custom: { title: 'Report builder (custom)', group: 'Detail', params: ['columns', 'entity_id', 'category', 'status'], build: (o) => customReport(o) }
|
custom: { title: 'Report builder (custom)', group: 'Detail', params: ['columns', 'entity_id', 'category', 'status'], build: (o) => customReport(o) },
|
||||||
|
|
||||||
|
// Journals & registers
|
||||||
|
asset_journal: { title: 'Asset journal (full dossier)', group: 'Journals & registers', params: ['asset_id', 'year'], build: (o) => assetJournal(o) },
|
||||||
|
fixed_asset_journal: { title: 'Fixed asset journal (register)', group: 'Journals & registers', params: ['book', 'year', 'entity_id', 'category', 'status'], build: (o) => fixedAssetRegister(o) },
|
||||||
|
journal_depreciation_monthly: { title: 'Monthly depreciation journal entry', group: 'Journals & registers', params: ['book', 'year', 'month', 'entity_id'], build: (o) => journalDepreciation({ ...o, monthlyFactor: 1 / 12 }) },
|
||||||
|
journal_disposals_monthly: { title: 'Monthly disposal journal entry', group: 'Journals & registers', params: ['year', 'txn_month', 'entity_id'], build: (o) => journalDisposals(o) },
|
||||||
|
journal_revaluation: { title: 'Monthly revaluation journal entry', group: 'Journals & registers', params: ['book', 'year', 'txn_month', 'entity_id'], build: (o) => journalRevaluation(o) },
|
||||||
|
journal_writeoff: { title: 'Monthly write-off journal entry', group: 'Journals & registers', params: ['book', 'year', 'txn_month', 'entity_id'], build: (o) => journalWriteoff(o) },
|
||||||
|
|
||||||
|
// Transactions
|
||||||
|
txn_purchases: { title: 'Asset purchased (transactions)', group: 'Transactions', params: ['year', 'txn_month', 'entity_id'], build: (o) => acquisitions(o) },
|
||||||
|
txn_disposals: { title: 'Asset disposed (transactions)', group: 'Transactions', params: ['year', 'txn_month', 'entity_id'], build: (o) => disposals(o) },
|
||||||
|
txn_revaluations: { title: 'Asset revalued (transactions)', group: 'Transactions', params: ['year', 'txn_month', 'entity_id'], build: (o) => revaluationTransactions(o) },
|
||||||
|
txn_writeoffs: { title: 'Asset write-off (transactions)', group: 'Transactions', params: ['year', 'txn_month', 'entity_id'], build: (o) => writeoffTransactions(o) },
|
||||||
|
|
||||||
|
// Summaries
|
||||||
|
asset_summary: { title: 'Asset summary', group: 'Summaries', params: ['book', 'year', 'entity_id', 'category', 'status'], build: (o) => assetSummary(o) },
|
||||||
|
depreciation_summary: { title: 'Asset depreciation summary', group: 'Summaries', params: ['book', 'year', 'entity_id', 'category', 'status'], build: (o) => depreciationSummary(o) },
|
||||||
|
ytd_depreciation_summary: { title: 'YTD asset depreciation summary', group: 'Summaries', params: ['book', 'year', 'month', 'entity_id'], build: (o) => ytdDepreciationSummary(o) },
|
||||||
|
purchase_summary: { title: 'Asset purchase summary', group: 'Summaries', params: ['year', 'txn_month', 'entity_id'], build: (o) => purchaseSummary(o) },
|
||||||
|
disposal_summary: { title: 'Asset disposal summary', group: 'Summaries', params: ['year', 'txn_month', 'entity_id'], build: (o) => disposalSummary(o) },
|
||||||
|
revaluation_summary: { title: 'Asset revaluation summary', group: 'Summaries', params: ['year', 'txn_month', 'entity_id'], build: (o) => revaluationSummary(o) },
|
||||||
|
writeoff_summary: { title: 'Asset write-off summary', group: 'Summaries', params: ['year', 'txn_month', 'entity_id'], build: (o) => writeoffSummary(o) },
|
||||||
|
|
||||||
|
// Detail
|
||||||
|
asset_history: { title: 'Asset history', group: 'Detail', params: ['asset_id'], build: (o) => assetHistory(o) },
|
||||||
|
future_depreciation: { title: 'Future depreciation calculation', group: 'Depreciation', params: ['book', 'startYear', 'endYear', 'entity_id'], build: (o) => projection(o) }
|
||||||
};
|
};
|
||||||
|
|
||||||
function paramSpecs() {
|
function paramSpecs() {
|
||||||
@@ -775,6 +1162,7 @@ function paramSpecs() {
|
|||||||
book: { label: 'Book', type: 'select', options: bookCodes, default: bookCodes[0] || 'GAAP' },
|
book: { label: 'Book', type: 'select', options: bookCodes, default: bookCodes[0] || 'GAAP' },
|
||||||
year: { label: 'Year', type: 'number', default: year },
|
year: { label: 'Year', type: 'number', default: year },
|
||||||
month: { label: 'Through month', type: 'select', options: Array.from({ length: 12 }, (_, i) => i + 1), default: 12 },
|
month: { label: 'Through month', type: 'select', options: Array.from({ length: 12 }, (_, i) => i + 1), default: 12 },
|
||||||
|
txn_month: { label: 'Month (optional)', type: 'select', options: Array.from({ length: 12 }, (_, i) => i + 1), clearable: true, default: null },
|
||||||
startYear: { label: 'Start year', type: 'number', default: year },
|
startYear: { label: 'Start year', type: 'number', default: year },
|
||||||
endYear: { label: 'End year', type: 'number', default: year + 5 },
|
endYear: { label: 'End year', type: 'number', default: year + 5 },
|
||||||
entity_id: { label: 'Entity', type: 'select', options: entities, clearable: true, default: null },
|
entity_id: { label: 'Entity', type: 'select', options: entities, clearable: true, default: null },
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ function exportPdf(report) {
|
|||||||
const chunks = [];
|
const chunks = [];
|
||||||
doc.on('data', (chunk) => chunks.push(chunk));
|
doc.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
|
||||||
doc.fontSize(16).text(report.title || 'MixedAssets report');
|
doc.fontSize(16).text(report.title || 'DepreCore report');
|
||||||
doc.fontSize(9).fillColor('#555').text(
|
doc.fontSize(9).fillColor('#555').text(
|
||||||
`Generated ${new Date(report.generatedAt || Date.now()).toLocaleString()}` +
|
`Generated ${new Date(report.generatedAt || Date.now()).toLocaleString()}` +
|
||||||
(report.options ? ` · ${Object.entries(report.options).filter(([, v]) => v !== null && v !== undefined && v !== '').map(([k, v]) => `${k}: ${v}`).join(' · ')}` : '')
|
(report.options ? ` · ${Object.entries(report.options).filter(([, v]) => v !== null && v !== undefined && v !== '').map(([k, v]) => `${k}: ${v}`).join(' · ')}` : '')
|
||||||
@@ -113,10 +113,111 @@ function exportPdf(report) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw a simple bordered table for a {columns, rows} block into an existing PDF document.
|
||||||
|
function drawTable(doc, columns, rows, totals) {
|
||||||
|
const left = doc.page.margins.left;
|
||||||
|
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||||
|
const colWidth = width / columns.length;
|
||||||
|
const line = (cells, bold) => {
|
||||||
|
if (doc.y > doc.page.height - 60) doc.addPage();
|
||||||
|
const y = doc.y;
|
||||||
|
doc.fontSize(bold ? 8.5 : 8).fillColor('#000');
|
||||||
|
cells.forEach((cell, index) => {
|
||||||
|
const column = columns[index];
|
||||||
|
const align = column.format === 'currency' || column.format === 'number' ? 'right' : 'left';
|
||||||
|
doc.text(String(cell ?? ''), left + index * colWidth, y, { width: colWidth - 6, align, ellipsis: true });
|
||||||
|
});
|
||||||
|
doc.moveDown(0.25);
|
||||||
|
};
|
||||||
|
line(columns.map((column) => column.label), true);
|
||||||
|
doc.moveTo(left, doc.y).lineTo(left + width, doc.y).strokeColor('#ccc').stroke();
|
||||||
|
doc.moveDown(0.15);
|
||||||
|
if (!rows.length) {
|
||||||
|
doc.fontSize(8).fillColor('#777').text('None recorded.', left).moveDown(0.2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const row of rows) line(columns.map((column) => formatValue(row[column.key], column.format)));
|
||||||
|
if (totals && Object.keys(totals).length) {
|
||||||
|
doc.moveTo(left, doc.y).lineTo(left + width, doc.y).strokeColor('#ccc').stroke();
|
||||||
|
doc.moveDown(0.15);
|
||||||
|
line(columns.map((column, index) => {
|
||||||
|
if (index === 0) return 'Totals';
|
||||||
|
return Object.prototype.hasOwnProperty.call(totals, column.key) ? formatValue(totals[column.key], column.format || 'currency') : '';
|
||||||
|
}), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-asset dossier PDF: identity, per-book financials, each meta section, then photo thumbnails.
|
||||||
|
function exportDossierPdf(report) {
|
||||||
|
const doc = new PDFDocument({ margin: 40, size: 'LETTER' });
|
||||||
|
const chunks = [];
|
||||||
|
doc.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
const meta = report.meta || {};
|
||||||
|
const asset = meta.asset || {};
|
||||||
|
|
||||||
|
doc.fontSize(17).fillColor('#000').text(`Asset journal — ${asset.asset_id || ''}`);
|
||||||
|
if (asset.description) doc.fontSize(11).fillColor('#333').text(asset.description);
|
||||||
|
doc.fontSize(8).fillColor('#777').text(`Generated ${new Date(report.generatedAt || Date.now()).toLocaleString()}`);
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
|
||||||
|
// Identity / financial key-values (two columns).
|
||||||
|
doc.fontSize(11).fillColor('#000').text('Asset details').moveDown(0.2);
|
||||||
|
const entries = Object.entries(asset).filter(([key]) => key !== 'asset_id' && key !== 'description');
|
||||||
|
const half = Math.ceil(entries.length / 2);
|
||||||
|
const colW = (doc.page.width - doc.page.margins.left - doc.page.margins.right) / 2;
|
||||||
|
const startY = doc.y;
|
||||||
|
entries.forEach(([key, value], index) => {
|
||||||
|
const column = index < half ? 0 : 1;
|
||||||
|
const y = startY + (index % half) * 14;
|
||||||
|
const label = key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
doc.fontSize(8).fillColor('#777').text(`${label}: `, doc.page.margins.left + column * colW, y, { continued: true });
|
||||||
|
doc.fillColor('#000').text(value === null || value === undefined || value === '' ? '—' : String(value));
|
||||||
|
});
|
||||||
|
doc.y = startY + half * 14;
|
||||||
|
doc.moveDown(0.6);
|
||||||
|
|
||||||
|
doc.fontSize(11).fillColor('#000').text('Depreciation by book').moveDown(0.2);
|
||||||
|
drawTable(doc, report.columns || [], report.rows || [], report.totals);
|
||||||
|
doc.moveDown(0.4);
|
||||||
|
|
||||||
|
for (const section of meta.sections || []) {
|
||||||
|
doc.fontSize(11).fillColor('#000').text(section.title).moveDown(0.2);
|
||||||
|
drawTable(doc, section.columns || [], section.rows || []);
|
||||||
|
doc.moveDown(0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const photos = meta.photos || [];
|
||||||
|
if (meta.photo_count) {
|
||||||
|
doc.fontSize(11).fillColor('#000').text(`Photos (${meta.photo_count})`).moveDown(0.2);
|
||||||
|
let x = doc.page.margins.left;
|
||||||
|
let rowY = doc.y;
|
||||||
|
const thumbW = 160;
|
||||||
|
const thumbH = 120;
|
||||||
|
for (const photo of photos) {
|
||||||
|
if (x + thumbW > doc.page.width - doc.page.margins.right) { x = doc.page.margins.left; rowY += thumbH + 20; }
|
||||||
|
if (rowY + thumbH > doc.page.height - 50) { doc.addPage(); rowY = doc.y; x = doc.page.margins.left; }
|
||||||
|
try {
|
||||||
|
const base64 = String(photo.data || '').replace(/^data:[^;]+;base64,/, '');
|
||||||
|
if (base64) doc.image(Buffer.from(base64, 'base64'), x, rowY, { fit: [thumbW, thumbH] });
|
||||||
|
} catch {
|
||||||
|
doc.fontSize(7).fillColor('#999').text('[image unavailable]', x, rowY + 50, { width: thumbW, align: 'center' });
|
||||||
|
}
|
||||||
|
doc.fontSize(7).fillColor('#555').text(photo.caption || '', x, rowY + thumbH + 2, { width: thumbW, ellipsis: true });
|
||||||
|
x += thumbW + 20;
|
||||||
|
}
|
||||||
|
doc.y = rowY + thumbH + 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
doc.on('end', () => resolve({ contentType: 'application/pdf', body: Buffer.concat(chunks) }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function exportReport(report, format) {
|
async function exportReport(report, format) {
|
||||||
if (format === 'csv') return exportCsv(report);
|
if (format === 'csv') return exportCsv(report);
|
||||||
if (format === 'xlsx') return exportXlsx(report);
|
if (format === 'xlsx') return exportXlsx(report);
|
||||||
if (format === 'pdf') return exportPdf(report);
|
if (format === 'pdf') return report.meta && report.meta.layout === 'dossier' ? exportDossierPdf(report) : exportPdf(report);
|
||||||
return { contentType: 'application/json', body: JSON.stringify(report, null, 2) };
|
return { contentType: 'application/json', body: JSON.stringify(report, null, 2) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,9 +76,9 @@ function writeDepreciationPdf(res, year, book) {
|
|||||||
const rows = assetExportRows();
|
const rows = assetExportRows();
|
||||||
const doc = new PDFDocument({ margin: 36, size: 'LETTER' });
|
const doc = new PDFDocument({ margin: 36, size: 'LETTER' });
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="mixedassets-${book}-${year}.pdf"`);
|
res.setHeader('Content-Disposition', `attachment; filename="deprecore-${book}-${year}.pdf"`);
|
||||||
doc.pipe(res);
|
doc.pipe(res);
|
||||||
doc.fontSize(18).text('MixedAssets Depreciation Report');
|
doc.fontSize(18).text('DepreCore Depreciation Report');
|
||||||
doc.fontSize(10).text(`Book: ${book} Year: ${year} Generated: ${new Date().toLocaleString()}`);
|
doc.fontSize(10).text(`Book: ${book} Year: ${year} Generated: ${new Date().toLocaleString()}`);
|
||||||
doc.moveDown();
|
doc.moveDown();
|
||||||
doc.fontSize(9).text('Asset ID', 36, doc.y, { continued: true, width: 80 });
|
doc.fontSize(9).text('Asset ID', 36, doc.y, { continued: true, width: 80 });
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
|
/*
|
||||||
|
ServiceNow integration for DepreCore.
|
||||||
|
|
||||||
|
This module provides functions to manage the configuration of the ServiceNow integration, create incidents in ServiceNow for DepreCore alerts, and synchronize assets from a ServiceNow CMDB table. It includes functions to read and save the integration settings, test the connection to ServiceNow, submit tickets for alerts, and perform a CMDB sync to import assets from ServiceNow into DepreCore.
|
||||||
|
|
||||||
|
The configuration settings are stored in the app_settings table and include details such as the ServiceNow instance URL, credentials, incident table name, assignment group, caller ID, CMDB table name, and field mappings. The module also handles authentication with ServiceNow using basic auth and makes API requests to create incidents and retrieve CMDB data.
|
||||||
|
|
||||||
|
The autoTicketAlerts function can be called during the alert cycle to automatically create incidents in ServiceNow for new critical alerts that do not already have an associated ticket. The syncCmdb function can be called to pull CI data from the configured ServiceNow CMDB table and create or update
|
||||||
|
assets in DepreCore based on the retrieved data, using the defined field mappings to map ServiceNow CI fields to DepreCore asset fields.
|
||||||
|
*/
|
||||||
|
const moment = require('moment');
|
||||||
const { all, audit, now, one, run } = require('../db');
|
const { all, audit, now, one, run } = require('../db');
|
||||||
const { createAsset, updateAsset } = require('./assets');
|
const { createAsset, updateAsset } = require('./assets');
|
||||||
|
|
||||||
const TIMEOUT_MS = 20000;
|
const TIMEOUT_MS = 20000;
|
||||||
|
console.log(`${moment().format()} 🛠️ ServiceNow integration module loaded`);
|
||||||
// Default CMDB CI field -> MixedAssets asset field mapping. Values are ServiceNow
|
// Default CMDB CI field -> DepreCore asset field mapping. Values are ServiceNow
|
||||||
// column names on the configured CI class; requested with display values so that
|
// column names on the configured CI class; requested with display values so that
|
||||||
// reference fields (manufacturer, location, model) arrive as readable strings.
|
// reference fields (manufacturer, location, model) arrive as readable strings.
|
||||||
const DEFAULT_CMDB_MAP = {
|
const DEFAULT_CMDB_MAP = {
|
||||||
@@ -20,23 +31,35 @@ const DEFAULT_CMDB_MAP = {
|
|||||||
notes: 'short_description'
|
notes: 'short_description'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Configuration management ------------------------------------------------
|
||||||
|
console.log(`${moment().format()} 🛠️ ServiceNow configuration management initialized`);
|
||||||
|
|
||||||
|
// Read the ServiceNow integration settings from the database and return them as an object. The settings are stored in the app_settings table with keys that start with 'servicenow_'. This function retrieves all relevant settings, processes them as needed (e.g., parsing JSON for the CMDB field mapping), and returns a structured configuration object that can be used by other functions in this module to interact with the ServiceNow API and manage the integration.
|
||||||
function readSettings() {
|
function readSettings() {
|
||||||
|
console.log(`${moment().format()} 🔍 Reading ServiceNow integration settings from database`);
|
||||||
const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'servicenow_%'");
|
const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'servicenow_%'");
|
||||||
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse the CMDB field mapping from a JSON string. The CMDB mapping defines how fields from the ServiceNow CMDB CI records should be mapped to fields in DepreCore when synchronizing assets. This function takes the raw value from the database (which may be a JSON string or an already parsed object), attempts to parse it if necessary, and returns a valid mapping object. If the value is missing, empty, or cannot be parsed, it returns a default mapping defined by DEFAULT_CMDB_MAP.
|
||||||
function parseMap(value) {
|
function parseMap(value) {
|
||||||
|
console.log(`${moment().format()} 🔍 Parsing ServiceNow CMDB field mapping`);
|
||||||
if (!value) return { ...DEFAULT_CMDB_MAP };
|
if (!value) return { ...DEFAULT_CMDB_MAP };
|
||||||
try {
|
try {
|
||||||
|
// Note: we attempt to parse the CMDB mapping value as JSON if it is a string, and we validate that the parsed value is an object with keys; if the parsing fails or the resulting object is invalid, we return the default CMDB mapping; this allows us to ensure that we always have a valid mapping to work with when synchronizing assets from ServiceNow, even if the stored configuration is not set or is malformed.
|
||||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||||
return parsed && typeof parsed === 'object' && Object.keys(parsed).length ? parsed : { ...DEFAULT_CMDB_MAP };
|
return parsed && typeof parsed === 'object' && Object.keys(parsed).length ? parsed : { ...DEFAULT_CMDB_MAP };
|
||||||
|
console.log(`${moment().format()} ✅ ServiceNow CMDB field mapping parsed successfully`);
|
||||||
} catch {
|
} catch {
|
||||||
|
console.log(`${moment().format()} ❌ Failed to parse ServiceNow CMDB field mapping, using default mapping`);
|
||||||
return { ...DEFAULT_CMDB_MAP };
|
return { ...DEFAULT_CMDB_MAP };
|
||||||
}
|
}
|
||||||
|
console.log(`${moment().format()} ✅ ServiceNow CMDB field mapping parsed successfully`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw config (includes password) — for making API calls only.
|
// Raw config (includes password) — for making API calls only.
|
||||||
function getConfig() {
|
function getConfig() {
|
||||||
|
console.log(`${moment().format()} 🔍 Retrieving ServiceNow integration configuration`);
|
||||||
const s = readSettings();
|
const s = readSettings();
|
||||||
return {
|
return {
|
||||||
instanceUrl: (s.servicenow_instance_url || '').replace(/\/+$/, ''),
|
instanceUrl: (s.servicenow_instance_url || '').replace(/\/+$/, ''),
|
||||||
@@ -58,6 +81,7 @@ function getConfig() {
|
|||||||
|
|
||||||
// Safe config for the API/UI — password is never returned, only whether it is set.
|
// Safe config for the API/UI — password is never returned, only whether it is set.
|
||||||
function publicConfig() {
|
function publicConfig() {
|
||||||
|
console.log(`${moment().format()} 🔍 Retrieving public ServiceNow integration configuration`);
|
||||||
const c = getConfig();
|
const c = getConfig();
|
||||||
return {
|
return {
|
||||||
servicenow_instance_url: c.instanceUrl,
|
servicenow_instance_url: c.instanceUrl,
|
||||||
@@ -78,58 +102,110 @@ function publicConfig() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update a single configuration value in the database. This function is used by the saveConfig function to update individual settings for the ServiceNow integration. It uses an upsert query to insert a new setting if it does not exist or update the existing setting if it does, ensuring that the app_settings table always has the correct values for the ServiceNow configuration.
|
||||||
function setValue(key, value) {
|
function setValue(key, value) {
|
||||||
|
console.log(`${moment().format()} 📝 Setting ServiceNow integration config value: ${key} = ${value ? '[set]' : '[empty]'}`);
|
||||||
run(
|
run(
|
||||||
'INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
|
'INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
|
||||||
[key, String(value == null ? '' : value), now()]
|
[key, String(value == null ? '' : value), now()]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save the ServiceNow integration settings from the provided body object. This function takes the input from an API request (e.g., from a settings form in the UI), updates the relevant configuration values in the database using the setValue function, and returns the updated public configuration. It also audits the update action for tracking purposes. The body object may contain various fields related to the ServiceNow integration, and this function ensures that they are properly saved and that sensitive information like passwords is handled securely.
|
||||||
function saveConfig(body, userId) {
|
function saveConfig(body, userId) {
|
||||||
if (body.servicenow_instance_url !== undefined) setValue('servicenow_instance_url', (body.servicenow_instance_url || '').replace(/\/+$/, ''));
|
console.log(`${moment().format()} 🛠️ Saving ServiceNow integration configuration`);
|
||||||
if (body.servicenow_username !== undefined) setValue('servicenow_username', body.servicenow_username || '');
|
if (body.servicenow_instance_url !== undefined) {
|
||||||
if (body.servicenow_password_clear) setValue('servicenow_password', '');
|
console.log(`${moment().format()} 📝 Setting ServiceNow instance URL`);
|
||||||
else if (body.servicenow_password) setValue('servicenow_password', body.servicenow_password);
|
setValue('servicenow_instance_url', (body.servicenow_instance_url || '').replace(/\/+$/, ''));
|
||||||
if (body.servicenow_incident_table !== undefined) setValue('servicenow_incident_table', body.servicenow_incident_table || 'incident');
|
}
|
||||||
if (body.servicenow_assignment_group !== undefined) setValue('servicenow_assignment_group', body.servicenow_assignment_group || '');
|
if (body.servicenow_username !== undefined) {
|
||||||
if (body.servicenow_caller_id !== undefined) setValue('servicenow_caller_id', body.servicenow_caller_id || '');
|
console.log(`${moment().format()} 📝 Setting ServiceNow username`);
|
||||||
if (body.servicenow_ticket_enabled !== undefined) setValue('servicenow_ticket_enabled', body.servicenow_ticket_enabled ? 'true' : 'false');
|
setValue('servicenow_username', body.servicenow_username || '');
|
||||||
if (body.servicenow_auto_ticket !== undefined) setValue('servicenow_auto_ticket', body.servicenow_auto_ticket ? 'true' : 'false');
|
}
|
||||||
if (body.servicenow_cmdb_table !== undefined) setValue('servicenow_cmdb_table', body.servicenow_cmdb_table || 'cmdb_ci_hardware');
|
if (body.servicenow_password_clear) {
|
||||||
if (body.servicenow_cmdb_query !== undefined) setValue('servicenow_cmdb_query', body.servicenow_cmdb_query || '');
|
console.log(`${moment().format()} 📝 Clearing ServiceNow password`);
|
||||||
if (body.servicenow_cmdb_limit !== undefined) setValue('servicenow_cmdb_limit', Number(body.servicenow_cmdb_limit) || 200);
|
setValue('servicenow_password', '');
|
||||||
|
} else if (body.servicenow_password) {
|
||||||
|
console.log(`${moment().format()} 📝 Setting ServiceNow password`);
|
||||||
|
setValue('servicenow_password', body.servicenow_password);
|
||||||
|
}
|
||||||
|
if (body.servicenow_incident_table !== undefined) {
|
||||||
|
console.log(`${moment().format()} 📝 Setting ServiceNow incident table`);
|
||||||
|
setValue('servicenow_incident_table', body.servicenow_incident_table || 'incident');
|
||||||
|
}
|
||||||
|
if (body.servicenow_assignment_group !== undefined) {
|
||||||
|
console.log(`${moment().format()} 📝 Setting ServiceNow assignment group`);
|
||||||
|
setValue('servicenow_assignment_group', body.servicenow_assignment_group || '');
|
||||||
|
}
|
||||||
|
if (body.servicenow_caller_id !== undefined) {
|
||||||
|
console.log(`${moment().format()} 📝 Setting ServiceNow caller ID`);
|
||||||
|
setValue('servicenow_caller_id', body.servicenow_caller_id || '');
|
||||||
|
}
|
||||||
|
if (body.servicenow_ticket_enabled !== undefined) {
|
||||||
|
console.log(`${moment().format()} 📝 Setting ServiceNow ticket enabled`);
|
||||||
|
setValue('servicenow_ticket_enabled', body.servicenow_ticket_enabled ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
if (body.servicenow_auto_ticket !== undefined) {
|
||||||
|
console.log(`${moment().format()} 📝 Setting ServiceNow auto ticket`);
|
||||||
|
setValue('servicenow_auto_ticket', body.servicenow_auto_ticket ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
if (body.servicenow_cmdb_table !== undefined) {
|
||||||
|
console.log(`${moment().format()} 📝 Setting ServiceNow CMDB table`);
|
||||||
|
setValue('servicenow_cmdb_table', body.servicenow_cmdb_table || 'cmdb_ci_hardware');
|
||||||
|
}
|
||||||
|
if (body.servicenow_cmdb_query !== undefined) {
|
||||||
|
console.log(`${moment().format()} 📝 Setting ServiceNow CMDB query`);
|
||||||
|
setValue('servicenow_cmdb_query', body.servicenow_cmdb_query || '');
|
||||||
|
}
|
||||||
|
if (body.servicenow_cmdb_limit !== undefined) {
|
||||||
|
console.log(`${moment().format()} 📝 Setting ServiceNow CMDB limit`);
|
||||||
|
setValue('servicenow_cmdb_limit', Number(body.servicenow_cmdb_limit) || 200);
|
||||||
|
}
|
||||||
if (body.servicenow_cmdb_map !== undefined) {
|
if (body.servicenow_cmdb_map !== undefined) {
|
||||||
|
console.log(`${moment().format()} 📝 Setting ServiceNow CMDB map`);
|
||||||
const value = typeof body.servicenow_cmdb_map === 'string' ? body.servicenow_cmdb_map : JSON.stringify(body.servicenow_cmdb_map || {});
|
const value = typeof body.servicenow_cmdb_map === 'string' ? body.servicenow_cmdb_map : JSON.stringify(body.servicenow_cmdb_map || {});
|
||||||
setValue('servicenow_cmdb_map', value.trim() ? value : '');
|
setValue('servicenow_cmdb_map', value.trim() ? value : '');
|
||||||
}
|
}
|
||||||
|
console.log(`${moment().format()} 📝 Updating ServiceNow integration settings`);
|
||||||
audit(userId, 'update', 'servicenow_settings', null, null, { ...body, servicenow_password: body.servicenow_password ? '[set]' : undefined });
|
audit(userId, 'update', 'servicenow_settings', null, null, { ...body, servicenow_password: body.servicenow_password ? '[set]' : undefined });
|
||||||
|
console.log(`${moment().format()} ✅ ServiceNow integration configuration saved successfully`);
|
||||||
return publicConfig();
|
return publicConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure that the ServiceNow integration is properly configured before making API calls. This function checks that the required configuration values (instance URL and username) are present, and if not, it logs an error message and throws an error indicating that the configuration is incomplete. This helps prevent attempts to make API calls to ServiceNow without the necessary configuration, which would result in failed requests and unclear errors.
|
||||||
function ensureConfigured(c) {
|
function ensureConfigured(c) {
|
||||||
|
console.log(`${moment().format()} 🔍 Ensuring ServiceNow integration is properly configured`);
|
||||||
if (!c.instanceUrl || !c.username) {
|
if (!c.instanceUrl || !c.username) {
|
||||||
|
console.log(`${moment().format()} ❌ ServiceNow integration is not properly configured: Missing instance URL or username`);
|
||||||
throw Object.assign(new Error('ServiceNow instance URL and username are required'), { status: 400 });
|
throw Object.assign(new Error('ServiceNow instance URL and username are required'), { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// return the value for the Authorization header for ServiceNow API requests, using basic authentication with the provided credentials. This function takes the configuration object (which includes the username and password), constructs the basic auth string by encoding the credentials in base64, and returns the complete Authorization header value that can be included in API requests to authenticate with the ServiceNow instance.
|
||||||
function authHeader(c) {
|
function authHeader(c) {
|
||||||
|
console.log(`${moment().format()} 🔍 Constructing ServiceNow API Authorization header`);
|
||||||
return `Basic ${Buffer.from(`${c.username}:${c.password}`).toString('base64')}`;
|
return `Basic ${Buffer.from(`${c.username}:${c.password}`).toString('base64')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make a fetch request to the ServiceNow API with the given path and options, including authentication and error handling. This function constructs the full URL for the API request based on the instance URL and the provided path, sets up an AbortController to enforce a timeout on the request, and includes the necessary headers for authentication and content type. It then performs the fetch request, processes the response, and handles any errors that may occur during the request or if the response indicates a failure (e.g., non-OK status). The function returns the parsed JSON response body if the request is successful.
|
||||||
async function snFetch(c, pathAndQuery, options = {}) {
|
async function snFetch(c, pathAndQuery, options = {}) {
|
||||||
|
console.log(`${moment().format()} 🔍 Making ServiceNow API request to: ${pathAndQuery} with options: ${JSON.stringify(options)}`);
|
||||||
const url = `${c.instanceUrl}${pathAndQuery}`;
|
const url = `${c.instanceUrl}${pathAndQuery}`;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
|
// Note: we attempt to make the API request to ServiceNow using fetch with the constructed URL and options, including the Authorization header for authentication; if the request fails (e.g., due to network issues, timeout, or other errors), we catch the error, log an error message with details about the failure, and throw a new error indicating that the ServiceNow request failed, along with the original error message for context; this allows us to provide clear feedback about why the API request failed and helps users troubleshoot any issues with their ServiceNow integration or connectivity.
|
||||||
response = await fetch(url, {
|
response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: authHeader(c), ...(options.headers || {}) },
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: authHeader(c), ...(options.headers || {}) },
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log(`${moment().format()} ❌ ServiceNow API request failed: ${error.message}`);
|
||||||
throw Object.assign(new Error(`ServiceNow request failed: ${error.message}`), { status: 502 });
|
throw Object.assign(new Error(`ServiceNow request failed: ${error.message}`), { status: 502 });
|
||||||
} finally {
|
} finally {
|
||||||
|
console.log(`${moment().format()} ⏱️ ServiceNow API request completed, clearing timeout`);
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
@@ -137,37 +213,47 @@ async function snFetch(c, pathAndQuery, options = {}) {
|
|||||||
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
|
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = body?.error?.message || body?.error?.detail || `HTTP ${response.status}`;
|
const detail = body?.error?.message || body?.error?.detail || `HTTP ${response.status}`;
|
||||||
|
console.log(`${moment().format()} ❌ ServiceNow API request failed: ${detail}`);
|
||||||
throw Object.assign(new Error(`ServiceNow: ${detail}`), { status: response.status === 401 ? 400 : 502 });
|
throw Object.assign(new Error(`ServiceNow: ${detail}`), { status: response.status === 401 ? 400 : 502 });
|
||||||
}
|
}
|
||||||
|
console.log(`${moment().format()} ✅ ServiceNow API request successful: ${url}`);
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test the connection to ServiceNow by making a simple API request to retrieve one record from the incident table. This function ensures that the integration is properly configured, attempts to make an authenticated API request to ServiceNow, and if successful, it audits the test action and returns a success response; if the request fails, it throws an error with details about the failure. This function can be used in an API endpoint to allow users to verify that their ServiceNow integration settings are correct and that the application can successfully connect to their ServiceNow instance.
|
||||||
async function testConnection(userId) {
|
async function testConnection(userId) {
|
||||||
|
console.log(`${moment().format()} 🚀 Testing ServiceNow connection`);
|
||||||
const c = getConfig();
|
const c = getConfig();
|
||||||
ensureConfigured(c);
|
ensureConfigured(c);
|
||||||
await snFetch(c, `/api/now/table/${encodeURIComponent(c.incidentTable)}?sysparm_limit=1&sysparm_fields=sys_id`, { method: 'GET' });
|
await snFetch(c, `/api/now/table/${encodeURIComponent(c.incidentTable)}?sysparm_limit=1&sysparm_fields=sys_id`, { method: 'GET' });
|
||||||
audit(userId, 'test', 'servicenow', null, null, { instance: c.instanceUrl });
|
audit(userId, 'test', 'servicenow', null, null, { instance: c.instanceUrl });
|
||||||
|
console.log(`${moment().format()} ✅ ServiceNow connection test successful for instance: ${c.instanceUrl}`);
|
||||||
return { ok: true, instance: c.instanceUrl };
|
return { ok: true, instance: c.instanceUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
// MixedAssets severity -> ServiceNow impact/urgency (1 high … 3 low).
|
// DepreCore severity -> ServiceNow impact/urgency (1 high … 3 low).
|
||||||
function severityToImpactUrgency(severity) {
|
function severityToImpactUrgency(severity) {
|
||||||
if (severity === 'critical') return { impact: '1', urgency: '1' };
|
console.log(`${moment().format()} 🔍 Mapping DepreCore alert severity to ServiceNow impact and urgency: ${severity}`);
|
||||||
if (severity === 'warning') return { impact: '2', urgency: '2' };
|
if (severity === 'critical') return { impact: '1', urgency: '1' }; // highest priority
|
||||||
return { impact: '3', urgency: '3' };
|
if (severity === 'warning') return { impact: '2', urgency: '2' }; // medium priority
|
||||||
|
return { impact: '3', urgency: '3' }; // lowest priority
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the payload for creating a ServiceNow incident based on a DepreCore alert. This function takes an alert object and the ServiceNow configuration, and constructs the appropriate payload for the ServiceNow API to create an incident. It maps the alert severity to the corresponding impact and urgency values expected by ServiceNow, and it formats the description to include relevant information from the alert, such as the message, asset code, and due date.
|
||||||
function incidentUrl(c, sysId) {
|
function incidentUrl(c, sysId) {
|
||||||
|
console.log(`${moment().format()} 🔍 Constructing ServiceNow incident URL for sys_id: ${sysId}`);
|
||||||
return `${c.instanceUrl}/nav_to.do?uri=${encodeURIComponent(`/${c.incidentTable}.do?sys_id=${sysId}`)}`;
|
return `${c.instanceUrl}/nav_to.do?uri=${encodeURIComponent(`/${c.incidentTable}.do?sys_id=${sysId}`)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a ServiceNow incident for a given DepreCore alert. This function checks that the integration is properly configured, builds the payload for the incident based on the alert details, and makes an API request to ServiceNow to create the incident. It then updates the alert in the database with the external ticket information (ID, number, URL) and audits the ticket creation action. The function returns the details of the created incident, including the sys_id, number, and URL.
|
||||||
async function createIncidentForAlert(alert, userId, c = getConfig()) {
|
async function createIncidentForAlert(alert, userId, c = getConfig()) {
|
||||||
ensureConfigured(c);
|
console.log(`${moment().format()} 🚀 Creating ServiceNow incident for alert ID: ${alert.id}`);
|
||||||
|
ensureConfigured(c); // Verify that the ServiceNow integration is properly configured before attempting to create an incident; if the configuration is incomplete, this will throw an error and prevent the API call from being made, which helps avoid failed requests and provides clear feedback about what needs to be configured.
|
||||||
const { impact, urgency } = severityToImpactUrgency(alert.severity);
|
const { impact, urgency } = severityToImpactUrgency(alert.severity);
|
||||||
const descriptionLines = [
|
const descriptionLines = [
|
||||||
alert.message || '',
|
alert.message || '',
|
||||||
'',
|
'',
|
||||||
`MixedAssets alert #${alert.id} (${alert.type})`,
|
`DepreCore alert #${alert.id} (${alert.type})`,
|
||||||
alert.asset_code ? `Asset: ${alert.asset_code}` : null,
|
alert.asset_code ? `Asset: ${alert.asset_code}` : null,
|
||||||
alert.due_date ? `Due: ${alert.due_date}` : null
|
alert.due_date ? `Due: ${alert.due_date}` : null
|
||||||
].filter((line) => line !== null);
|
].filter((line) => line !== null);
|
||||||
@@ -178,21 +264,33 @@ async function createIncidentForAlert(alert, userId, c = getConfig()) {
|
|||||||
impact,
|
impact,
|
||||||
urgency
|
urgency
|
||||||
};
|
};
|
||||||
if (c.assignmentGroup) payload.assignment_group = c.assignmentGroup;
|
if (c.assignmentGroup) {
|
||||||
if (c.callerId) payload.caller_id = c.callerId;
|
// Note: if an assignment group is configured in the ServiceNow integration settings, we include it in the payload for the incident creation request; this allows the created incident to be automatically assigned to the specified group in ServiceNow, which can help ensure that it is routed to the appropriate team for handling.
|
||||||
|
console.log(`${moment().format()} 📝 Setting ServiceNow incident assignment group: ${c.assignmentGroup}`);
|
||||||
|
payload.assignment_group = c.assignmentGroup;
|
||||||
|
}
|
||||||
|
if (c.callerId) {
|
||||||
|
// Note: if a caller ID is configured in the ServiceNow integration settings, we include it in the payload for the incident creation request; this allows the created incident to have the specified caller information in ServiceNow, which can help provide context about who or what is associated with the incident.
|
||||||
|
console.log(`${moment().format()} 📝 Setting ServiceNow incident caller ID: ${c.callerId}`);
|
||||||
|
payload.caller_id = c.callerId;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await snFetch(c, `/api/now/table/${encodeURIComponent(c.incidentTable)}`, { method: 'POST', body: JSON.stringify(payload) });
|
const data = await snFetch(c, `/api/now/table/${encodeURIComponent(c.incidentTable)}`, { method: 'POST', body: JSON.stringify(payload) });
|
||||||
const result = data?.result || {};
|
const result = data?.result || {};
|
||||||
const url = result.sys_id ? incidentUrl(c, result.sys_id) : null;
|
const url = result.sys_id ? incidentUrl(c, result.sys_id) : null;
|
||||||
|
// Note: after successfully creating the incident in ServiceNow, we update the corresponding alert in the database with the external ticket information (sys_id, number, URL) so that we can track the association between the alert and the ServiceNow incident; we also audit the ticket creation action for tracking purposes, including details about the system and incident number; this allows us to maintain a record of which alerts have associated ServiceNow incidents and provides visibility into the ticket creation process.
|
||||||
run(
|
run(
|
||||||
'UPDATE alerts SET external_ticket_id = ?, external_ticket_number = ?, external_ticket_url = ?, external_ticket_system = ?, updated_at = ? WHERE id = ?',
|
'UPDATE alerts SET external_ticket_id = ?, external_ticket_number = ?, external_ticket_url = ?, external_ticket_system = ?, updated_at = ? WHERE id = ?',
|
||||||
[result.sys_id || null, result.number || null, url, 'servicenow', now(), alert.id]
|
[result.sys_id || null, result.number || null, url, 'servicenow', now(), alert.id]
|
||||||
);
|
);
|
||||||
audit(userId, 'ticket', 'alert', alert.id, null, { system: 'servicenow', number: result.number });
|
audit(userId, 'ticket', 'alert', alert.id, null, { system: 'servicenow', number: result.number });
|
||||||
|
console.log(`${moment().format()} ✅ ServiceNow incident created successfully for alert ID: ${alert.id}, incident number: ${result.number}`);
|
||||||
return { sys_id: result.sys_id, number: result.number, url };
|
return { sys_id: result.sys_id, number: result.number, url };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retrieve an alert from the database along with its associated asset code, if available. This function performs a SQL query to get the alert details and the asset code by joining the alerts table with the assets table based on the asset_id. This is used when creating a ServiceNow incident for an alert, as the asset code can be included in the incident description for additional context.
|
||||||
function alertWithCode(alertId) {
|
function alertWithCode(alertId) {
|
||||||
|
console.log(`${moment().format()} 🔍 Retrieving alert with ID: ${alertId} and associated asset code`);
|
||||||
return one(
|
return one(
|
||||||
`SELECT al.*, a.asset_id AS asset_code FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id WHERE al.id = ?`,
|
`SELECT al.*, a.asset_id AS asset_code FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id WHERE al.id = ?`,
|
||||||
[alertId]
|
[alertId]
|
||||||
@@ -201,85 +299,128 @@ function alertWithCode(alertId) {
|
|||||||
|
|
||||||
// Manually submit a ServiceNow incident for one alert (returns existing ticket if already raised).
|
// Manually submit a ServiceNow incident for one alert (returns existing ticket if already raised).
|
||||||
async function submitTicket(alertId, userId) {
|
async function submitTicket(alertId, userId) {
|
||||||
|
console.log(`${moment().format()} 🚀 Submitting ServiceNow ticket for alert ID: ${alertId}`);
|
||||||
const c = getConfig();
|
const c = getConfig();
|
||||||
ensureConfigured(c);
|
ensureConfigured(c);
|
||||||
const alert = alertWithCode(alertId);
|
const alert = alertWithCode(alertId);
|
||||||
if (!alert) return null;
|
if (!alert) {
|
||||||
|
console.log(`${moment().format()} ❌ Alert with ID: ${alertId} not found`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (alert.external_ticket_id) {
|
if (alert.external_ticket_id) {
|
||||||
|
console.log(`${moment().format()} 📝 Reusing existing ServiceNow ticket for alert ID: ${alertId}`);
|
||||||
return { sys_id: alert.external_ticket_id, number: alert.external_ticket_number, url: alert.external_ticket_url, duplicate: true };
|
return { sys_id: alert.external_ticket_id, number: alert.external_ticket_number, url: alert.external_ticket_url, duplicate: true };
|
||||||
}
|
}
|
||||||
|
console.log(`${moment().format()} 🚀 Creating new ServiceNow ticket for alert ID: ${alertId}`);
|
||||||
return createIncidentForAlert(alert, userId, c);
|
return createIncidentForAlert(alert, userId, c);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-create incidents for new critical alerts during the alert cycle (best-effort).
|
// Auto-create incidents for new critical alerts during the alert cycle (best-effort).
|
||||||
async function autoTicketAlerts(alerts, userId) {
|
async function autoTicketAlerts(alerts, userId) {
|
||||||
|
console.log(`${moment().format()} 🚀 Auto-ticketing ServiceNow incidents for new critical alerts`);
|
||||||
const c = getConfig();
|
const c = getConfig();
|
||||||
if (!c.ticketEnabled || !c.autoTicket || !c.instanceUrl) return { created: 0 };
|
if (!c.ticketEnabled || !c.autoTicket || !c.instanceUrl) return { created: 0 }; // If ticketing is not enabled or auto-ticketing is disabled or ServiceNow instance URL is not configured, we skip the auto-ticketing process and return early with a count of created tickets as 0; this allows us to avoid unnecessary processing and API calls when the auto-ticketing feature is not fully configured or enabled.
|
||||||
let created = 0;
|
let created = 0;
|
||||||
for (const alert of alerts) {
|
for (const alert of alerts) {
|
||||||
if (alert.severity !== 'critical' || alert.external_ticket_id) continue;
|
if (alert.severity !== 'critical' || alert.external_ticket_id) continue; // We only want to auto-create incidents for new critical alerts that do not already have an associated ticket; if the alert severity is not critical or if there is already an external_ticket_id, we skip the auto-ticketing for that alert and move on to the next one, ensuring that we only create incidents for the appropriate alerts.
|
||||||
|
console.log(`${moment().format()} 🚀 Creating new ServiceNow ticket for alert ID: ${alert.id}`);
|
||||||
try {
|
try {
|
||||||
await createIncidentForAlert(alert, userId, c);
|
await createIncidentForAlert(alert, userId, c);
|
||||||
created += 1;
|
created += 1; // We increment the count of created tickets only if the incident creation process completes successfully; if there is an error during the creation of the incident (e.g., API failure, configuration issue), we catch the error and do not increment the created count, allowing us to provide an accurate count of how many incidents were successfully created during the auto-ticketing process.
|
||||||
} catch {
|
} catch(error) {
|
||||||
|
console.log(`${moment().format()} ❌ Failed to create ServiceNow ticket for alert ID: ${alert.id}, with error: ${error.message}`);
|
||||||
// best-effort: a failed ticket never breaks the alert cycle
|
// best-effort: a failed ticket never breaks the alert cycle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log(`${moment().format()} ✅ Auto-ticketing process completed, total new incidents created: ${created}`);
|
||||||
return { created };
|
return { created };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- CMDB pull -------------------------------------------------------------
|
// ---- CMDB pull -------------------------------------------------------------
|
||||||
|
// Parse a cost value from the ServiceNow CMDB, removing any non-numeric characters and converting it to a number. This function takes a raw value from the CMDB (which may include currency symbols, commas, or other formatting), cleans it to extract only the numeric characters, and returns it as a number. If the resulting value is not a valid finite number, it returns undefined. This is used when mapping CMDB CI fields to DepreCore asset fields during synchronization.
|
||||||
function parseCost(value) {
|
function parseCost(value) {
|
||||||
if (value == null || value === '') return undefined;
|
console.log(`${moment().format()} 🔍 Parsing cost value from ServiceNow CMDB: ${value}`);
|
||||||
|
if (value == null || value === '') {
|
||||||
|
// Note: if the cost value from the ServiceNow CMDB is null, undefined, or an empty string, we return undefined to indicate that there is no valid cost value; this allows us to handle cases where the cost information is not provided or is missing without causing errors during the asset synchronization process.
|
||||||
|
console.log(`${moment().format()} ❌ Invalid cost value: ${value}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const num = Number(String(value).replace(/[^0-9.\-]/g, ''));
|
const num = Number(String(value).replace(/[^0-9.\-]/g, ''));
|
||||||
|
console.log(`${moment().format()} 📝 Parsed cost value: ${num} from raw input: ${value}`);
|
||||||
return Number.isFinite(num) ? num : undefined;
|
return Number.isFinite(num) ? num : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse a date value from the ServiceNow CMDB, extracting the date portion in YYYY-MM-DD format. This function takes a raw value from the CMDB (which may include time information or be in a different format), uses a regular expression to extract the date portion, and returns it as a string in YYYY-MM-DD format. If the value is missing or does not contain a valid date, it returns undefined. This is used when mapping CMDB CI fields to DepreCore asset fields during synchronization.
|
||||||
function parseDate(value) {
|
function parseDate(value) {
|
||||||
if (!value) return undefined;
|
console.log(`${moment().format()} 🔍 Parsing date value from ServiceNow CMDB: ${value}`);
|
||||||
|
if (!value) {
|
||||||
|
// Note: if the date value from the ServiceNow CMDB is null, undefined, or an empty string, we return undefined to indicate that there is no valid date value; this allows us to handle cases where the date information is not provided or is missing without causing errors during the asset synchronization process.
|
||||||
|
console.log(`${moment().format()} ❌ Invalid date value: ${value}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const match = String(value).match(/\d{4}-\d{2}-\d{2}/);
|
const match = String(value).match(/\d{4}-\d{2}-\d{2}/);
|
||||||
|
console.log(`${moment().format()} 📝 Parsed date value: ${match ? match[0] : undefined} from raw input: ${value}`);
|
||||||
return match ? match[0] : undefined;
|
return match ? match[0] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map a ServiceNow CMDB CI record to a DepreCore asset object based on the provided field mapping. This function takes a CI record from ServiceNow and a mapping object that defines how ServiceNow fields correspond to DepreCore asset fields. It iterates through the mapping, extracts the relevant values from the CI, processes them as needed (e.g., parsing costs and dates), and constructs an asset object that can be used to create or update an asset in DepreCore during synchronization.
|
||||||
function mapCi(ci, map) {
|
function mapCi(ci, map) {
|
||||||
|
console.log(`${moment().format()} 🔍 Mapping ServiceNow CMDB CI to DepreCore asset using mapping: ${JSON.stringify(map)}`);
|
||||||
const asset = {};
|
const asset = {};
|
||||||
for (const [assetField, ciField] of Object.entries(map)) {
|
for (const [assetField, ciField] of Object.entries(map)) {
|
||||||
if (!ciField) continue;
|
if (!ciField) continue; // If the mapping for this asset field is empty or falsy, we skip it and do not attempt to extract a value from the CI; this allows us to have optional mappings where certain asset fields may not be mapped to any CI field, and we simply ignore those during the mapping process.
|
||||||
let value = ci[ciField];
|
let value = ci[ciField];
|
||||||
if (value == null || value === '') continue;
|
if (value == null || value === '') continue; // If the value extracted from the CI for this field is null, undefined, or an empty string, we skip it and do not include it in the asset object; this allows us to avoid setting asset fields with invalid or empty values during the mapping process.
|
||||||
value = String(value).trim();
|
value = String(value).trim();
|
||||||
if (!value) continue;
|
if (!value) continue; // If the trimmed value is an empty string, we skip it and do not include it in the asset object; this allows us to avoid setting asset fields with empty values that may not be meaningful or valid in DepreCore.
|
||||||
if (assetField === 'acquisition_cost') {
|
if (assetField === 'acquisition_cost') {
|
||||||
|
console.log(`${moment().format()} 🔍 Parsing acquisition cost for asset field: ${assetField} from CI field: ${ciField} with raw value: ${value}`);
|
||||||
const cost = parseCost(value);
|
const cost = parseCost(value);
|
||||||
if (cost !== undefined) asset.acquisition_cost = cost;
|
if (cost !== undefined) {
|
||||||
|
// Note: if the acquisition cost value from the ServiceNow CMDB is successfully parsed into a valid number, we set it on the asset object; if the parsing fails and returns undefined, we skip setting the acquisition_cost field on the asset, allowing us to avoid setting invalid cost values during the mapping process.
|
||||||
|
console.log(`${moment().format()} 📝 Parsed acquisition cost: ${cost} for asset field: ${assetField}`);
|
||||||
|
asset.acquisition_cost = cost;
|
||||||
|
}
|
||||||
} else if (assetField === 'acquired_date' || assetField === 'in_service_date' || assetField === 'disposal_date') {
|
} else if (assetField === 'acquired_date' || assetField === 'in_service_date' || assetField === 'disposal_date') {
|
||||||
|
// Note: for date fields such as acquired_date, in_service_date, and disposal_date, we attempt to parse the date value from the ServiceNow CMDB using the parseDate function; if a valid date is extracted, we set it on the asset object; if the parsing fails and returns undefined, we skip setting that date field on the asset, allowing us to avoid setting invalid date values during the mapping process.
|
||||||
|
console.log(`${moment().format()} 🔍 Parsing date for asset field: ${assetField} from CI field: ${ciField} with raw value: ${value}`);
|
||||||
const date = parseDate(value);
|
const date = parseDate(value);
|
||||||
if (date) asset[assetField] = date;
|
if (date) asset[assetField] = date;
|
||||||
} else {
|
} else {
|
||||||
|
// For other fields, we simply set the trimmed value from the CI on the asset object based on the mapping; this allows us to map string fields directly from the ServiceNow CMDB to DepreCore without additional processing, while still ensuring that we only set valid, non-empty values.
|
||||||
|
console.log(`${moment().format()} 📝 Mapping CI field: ${ciField} with value: ${value} to asset field: ${assetField}`);
|
||||||
asset[assetField] = value;
|
asset[assetField] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log(`${moment().format()} ✅ Completed mapping ServiceNow CMDB CI to DepreCore asset: ${JSON.stringify(asset)}`);
|
||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find an existing asset in DepreCore that corresponds to the given ServiceNow CMDB CI, based on the sys_id and mapped fields. This function attempts to find an existing asset in the database that matches the provided ServiceNow sys_id or has matching values for key fields such as serial_number or asset_id based on the mapping; this is used during synchronization to determine whether to create a new asset or update an existing one, ensuring that we maintain accurate associations between ServiceNow CIs and DepreCore assets.
|
||||||
function findExistingAsset(sysId, mapped) {
|
function findExistingAsset(sysId, mapped) {
|
||||||
|
console.log(`${moment().format()} 🔍 Finding existing asset for ServiceNow CMDB CI with sys_id: ${sysId} and mapped fields: ${JSON.stringify(mapped)}`);
|
||||||
if (sysId) {
|
if (sysId) {
|
||||||
|
console.log(`${moment().format()} 🔍 Looking up existing asset by ServiceNow sys_id: ${sysId}`);
|
||||||
const bySysId = one('SELECT id FROM assets WHERE servicenow_sys_id = ?', [sysId]);
|
const bySysId = one('SELECT id FROM assets WHERE servicenow_sys_id = ?', [sysId]);
|
||||||
if (bySysId) return bySysId.id;
|
if (bySysId) return bySysId.id;
|
||||||
}
|
}
|
||||||
if (mapped.serial_number) {
|
if (mapped.serial_number) {
|
||||||
|
console.log(`${moment().format()} 🔍 Looking up existing asset by serial number: ${mapped.serial_number}`);
|
||||||
const bySerial = one('SELECT id FROM assets WHERE serial_number = ? COLLATE NOCASE', [mapped.serial_number]);
|
const bySerial = one('SELECT id FROM assets WHERE serial_number = ? COLLATE NOCASE', [mapped.serial_number]);
|
||||||
if (bySerial) return bySerial.id;
|
if (bySerial) return bySerial.id;
|
||||||
}
|
}
|
||||||
if (mapped.asset_id) {
|
if (mapped.asset_id) {
|
||||||
|
console.log(`${moment().format()} 🔍 Looking up existing asset by asset ID: ${mapped.asset_id}`);
|
||||||
const byCode = one('SELECT id FROM assets WHERE asset_id = ? COLLATE NOCASE', [mapped.asset_id]);
|
const byCode = one('SELECT id FROM assets WHERE asset_id = ? COLLATE NOCASE', [mapped.asset_id]);
|
||||||
if (byCode) return byCode.id;
|
if (byCode) return byCode.id;
|
||||||
}
|
}
|
||||||
|
console.log(`${moment().format()} ❌ No existing asset found for ServiceNow CMDB CI with sys_id: ${sysId}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synchronize assets from the ServiceNow CMDB based on the configured table and mapping. This function retrieves CI records from the specified ServiceNow CMDB table using the API, maps them to DepreCore asset fields based on the configured mapping, and then either creates new assets or updates existing ones in the database. It also tracks the synchronization status and audits the sync action for reporting purposes. The function returns a summary of the synchronization results, including how many assets were created, updated, skipped, and the total number of CIs processed.
|
||||||
async function syncCmdb(userId) {
|
async function syncCmdb(userId) {
|
||||||
|
console.log(`${moment().format()} 🚀 Starting synchronization of assets from ServiceNow CMDB`);
|
||||||
const c = getConfig();
|
const c = getConfig();
|
||||||
ensureConfigured(c);
|
ensureConfigured(c);
|
||||||
const fields = [...new Set(['sys_id', ...Object.values(c.cmdbMap).filter(Boolean)])];
|
const fields = [...new Set(['sys_id', ...Object.values(c.cmdbMap).filter(Boolean)])];
|
||||||
@@ -295,6 +436,7 @@ async function syncCmdb(userId) {
|
|||||||
try {
|
try {
|
||||||
data = await snFetch(c, `/api/now/table/${encodeURIComponent(c.cmdbTable)}?${params.toString()}`, { method: 'GET' });
|
data = await snFetch(c, `/api/now/table/${encodeURIComponent(c.cmdbTable)}?${params.toString()}`, { method: 'GET' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log(`${moment().format()} ❌ Failed to fetch ServiceNow CMDB data: ${error.message}`);
|
||||||
setValue('servicenow_last_sync_at', now());
|
setValue('servicenow_last_sync_at', now());
|
||||||
setValue('servicenow_last_sync_status', `Failed: ${error.message}`);
|
setValue('servicenow_last_sync_status', `Failed: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -304,15 +446,21 @@ async function syncCmdb(userId) {
|
|||||||
let updated = 0;
|
let updated = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
for (const ci of cis) {
|
for (const ci of cis) {
|
||||||
|
// Note: for each CI record retrieved from the ServiceNow CMDB, we log the processing of that CI, map it to a DepreCore asset using the configured mapping, and then determine whether to create a new asset or update an existing one based on the sys_id and key fields; if the mapped asset does not have sufficient information (e.g., missing description, asset_id, and serial_number), we skip it and do not create or update an asset for that CI; this allows us to ensure that we only synchronize CIs that have meaningful data and maintain accurate associations between ServiceNow CIs and DepreCore assets.
|
||||||
|
console.log(`${moment().format()} 🔄 Processing CI: ${ci.sys_id || 'N/A'}`);
|
||||||
const sysId = ci.sys_id || null;
|
const sysId = ci.sys_id || null;
|
||||||
const mapped = mapCi(ci, c.cmdbMap);
|
const mapped = mapCi(ci, c.cmdbMap);
|
||||||
if (!mapped.description && !mapped.asset_id && !mapped.serial_number) { skipped += 1; continue; }
|
if (!mapped.description && !mapped.asset_id && !mapped.serial_number) { skipped += 1; continue; }
|
||||||
const existingId = findExistingAsset(sysId, mapped);
|
const existingId = findExistingAsset(sysId, mapped);
|
||||||
if (existingId) {
|
if (existingId) {
|
||||||
|
// Note: if an existing asset is found that corresponds to the ServiceNow CMDB CI (based on sys_id or key fields), we update that asset with the new mapped values from the CI; if the sys_id is available, we also ensure that it is set on the asset for future reference; this allows us to keep our asset records up-to-date with the latest information from ServiceNow while maintaining the association between the CI and the asset.
|
||||||
|
console.log(`${moment().format()} 🔄 Updating existing asset with ID: ${existingId} for CI sys_id: ${sysId}`);
|
||||||
updateAsset(existingId, mapped, userId);
|
updateAsset(existingId, mapped, userId);
|
||||||
if (sysId) run('UPDATE assets SET servicenow_sys_id = ? WHERE id = ?', [sysId, existingId]);
|
if (sysId) run('UPDATE assets SET servicenow_sys_id = ? WHERE id = ?', [sysId, existingId]);
|
||||||
updated += 1;
|
updated += 1;
|
||||||
} else {
|
} else {
|
||||||
|
// Note: if no existing asset is found for the ServiceNow CMDB CI, we create a new asset in the database with the mapped values from the CI; if the sys_id is available, we set it on the new asset for future reference; this allows us to add new assets to our system based on the CIs in ServiceNow, ensuring that we have a comprehensive and up-to-date inventory of assets that correspond to the CIs in the ServiceNow CMDB.
|
||||||
|
console.log(`${moment().format()} ➕ Creating new asset for CI sys_id: ${sysId}`);
|
||||||
const asset = createAsset({ ...mapped, source: 'servicenow' }, userId);
|
const asset = createAsset({ ...mapped, source: 'servicenow' }, userId);
|
||||||
if (sysId) run('UPDATE assets SET servicenow_sys_id = ? WHERE id = ?', [sysId, asset.id]);
|
if (sysId) run('UPDATE assets SET servicenow_sys_id = ? WHERE id = ?', [sysId, asset.id]);
|
||||||
created += 1;
|
created += 1;
|
||||||
@@ -322,9 +470,11 @@ async function syncCmdb(userId) {
|
|||||||
setValue('servicenow_last_sync_at', now());
|
setValue('servicenow_last_sync_at', now());
|
||||||
setValue('servicenow_last_sync_status', status);
|
setValue('servicenow_last_sync_status', status);
|
||||||
audit(userId, 'cmdb_sync', 'servicenow', null, null, { table: c.cmdbTable, created, updated, skipped });
|
audit(userId, 'cmdb_sync', 'servicenow', null, null, { table: c.cmdbTable, created, updated, skipped });
|
||||||
|
console.log(`${moment().format()} ✅ ServiceNow CMDB synchronization completed. ${status}`);
|
||||||
return { created, updated, skipped, total: cis.length, status };
|
return { created, updated, skipped, total: cis.length, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export the functions and constants for use in other parts of the application, such as API route handlers. This allows us to keep the ServiceNow integration logic organized in a single module while still making it accessible to other parts of the codebase that need to interact with ServiceNow for configuration, ticket creation, and CMDB synchronization.
|
||||||
module.exports = {
|
module.exports = {
|
||||||
DEFAULT_CMDB_MAP,
|
DEFAULT_CMDB_MAP,
|
||||||
autoTicketAlerts,
|
autoTicketAlerts,
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
/*
|
||||||
|
* integration service for managing the connection to a webhook endpoint and delivering alert notifications as JSON POST requests. The service includes functions for building the payload for each alert, posting the payload to the configured webhook URL with optional HMAC-SHA256 signing for authenticity verification, and delivering alerts in a best-effort manner where failures are counted but do not throw errors. Additionally, there is a function for sending a test webhook to verify that the webhook destination is configured correctly, which also audits the action for tracking purposes.
|
||||||
|
*/
|
||||||
|
const moment = require('moment');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { audit } = require('../db');
|
const { audit } = require('../db');
|
||||||
const { getConfig } = require('./notifications');
|
const { getConfig } = require('./notifications');
|
||||||
|
|
||||||
const TIMEOUT_MS = 10000;
|
const TIMEOUT_MS = 10000;
|
||||||
|
console.log(`${moment().format()} ✅ Webhooks service initialized with timeout of ${TIMEOUT_MS}ms`);
|
||||||
|
|
||||||
// The JSON object delivered for each alert. Stable, flat shape for easy consumption.
|
// The JSON object delivered for each alert. Stable, flat shape for easy consumption.
|
||||||
function buildPayload(alert) {
|
function buildPayload(alert) {
|
||||||
|
console.log(`${moment().format()} 🔔 Building webhook payload for alert ID ${alert.id} with title "${alert.title}" and type "${alert.type}"`);
|
||||||
return {
|
return {
|
||||||
event: 'alert',
|
event: 'alert',
|
||||||
id: alert.id,
|
id: alert.id,
|
||||||
@@ -25,64 +31,95 @@ function buildPayload(alert) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST a single JSON object. When a secret is set, sign the raw body with HMAC-SHA256
|
// POST a single JSON object. When a secret is set, sign the raw body with HMAC-SHA256
|
||||||
// so the receiver can verify authenticity via the X-MixedAssets-Signature header.
|
// so the receiver can verify authenticity via the X-DepreCore-Signature header.
|
||||||
async function postJson(url, secret, payload) {
|
async function postJson(url, secret, payload) {
|
||||||
|
console.log(`${moment().format()} 🚀 Posting JSON payload to webhook URL ${url} with payload: ${JSON.stringify(payload)}`);
|
||||||
const body = JSON.stringify(payload);
|
const body = JSON.stringify(payload);
|
||||||
const headers = { 'Content-Type': 'application/json', 'User-Agent': 'MixedAssets-Webhook/1' };
|
const headers = { 'Content-Type': 'application/json', 'User-Agent': 'DepreCore-Webhook/1' };
|
||||||
if (secret) {
|
if (secret) {
|
||||||
|
// Note: when a webhook secret is configured, we sign the raw JSON body of the request using HMAC-SHA256 with the provided secret, and we include the resulting signature in the X-DepreCore-Signature header in the format "sha256=signature"; this allows the receiver of the webhook to verify the authenticity of the request by computing the HMAC-SHA256 signature on their end using the same secret and comparing it to the signature provided in the header, which helps ensure that the webhook requests are coming from a trusted source and have not been tampered with in transit.
|
||||||
|
console.log(`${moment().format()} 🔐 Signing webhook payload with HMAC-SHA256 using provided secret`);
|
||||||
const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
|
const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
|
||||||
headers['X-MixedAssets-Signature'] = `sha256=${signature}`;
|
headers['X-DepreCore-Signature'] = `sha256=${signature}`;
|
||||||
}
|
}
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
try {
|
try {
|
||||||
|
// Note: we use the Fetch API to send the POST request to the webhook URL with the JSON payload and appropriate headers, and we also set a timeout using AbortController to ensure that the request does not hang indefinitely; if the request takes longer than the configured timeout, it will be aborted and an error will be thrown, which allows us to handle cases where the webhook endpoint is unresponsive or slow to respond.
|
||||||
|
console.log(`${moment().format()} 📡 Sending POST request to ${url} with headers ${JSON.stringify(headers)} and body ${body}`);
|
||||||
const response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
|
const response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
|
||||||
return { ok: response.ok, status: response.status };
|
return { ok: response.ok, status: response.status };
|
||||||
} finally {
|
} finally {
|
||||||
|
console.log(`${moment().format()} ⏰ Clearing webhook request timeout`);
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
console.log(`${moment().format()} ✅ Webhook POST request completed successfully with status ${result.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deliver each alert as its own JSON POST. Best-effort: a failed delivery is counted
|
// Deliver each alert as its own JSON POST. Best-effort: a failed delivery is counted
|
||||||
// but never throws, so one bad endpoint can't break the alert cycle.
|
// but never throws, so one bad endpoint can't break the alert cycle.
|
||||||
async function deliverAlerts(alerts) {
|
async function deliverAlerts(alerts) {
|
||||||
|
console.log(`${moment().format()} 🚚 Delivering ${alerts.length} alerts to webhook endpoint`);
|
||||||
const c = getConfig();
|
const c = getConfig();
|
||||||
if (!c.webhookEnabled || !c.webhookUrl || !alerts.length) {
|
if (!c.webhookEnabled || !c.webhookUrl || !alerts.length) {
|
||||||
|
// Note: if the webhook is not enabled, the URL is not configured, or there are no alerts to deliver, we skip the delivery process and log a message indicating that the webhook delivery was skipped; this allows us to avoid unnecessary attempts to send webhook requests when the configuration is incomplete or when there are no alerts to send, and it also provides visibility into why webhook deliveries may not be occurring.
|
||||||
|
console.log(`${moment().format()} 🚫 Webhook delivery skipped: no alerts or webhook not enabled`);
|
||||||
return { attempted: false, delivered: 0, failed: 0 };
|
return { attempted: false, delivered: 0, failed: 0 };
|
||||||
}
|
}
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const alert of alerts) {
|
for (const alert of alerts) {
|
||||||
|
// Note: for each alert, we attempt to post the JSON payload to the configured webhook URL using the postJson function, and we count the number of successful deliveries and failures; if a delivery fails (either due to an error or a non-OK response), we increment the failure count but do not throw an error, which allows us to continue attempting to deliver subsequent alerts even if one or more deliveries fail, ensuring that a single bad endpoint or alert does not disrupt the entire alert delivery process.
|
||||||
|
console.log(`${moment().format()} 📡 Attempting to deliver alert to webhook: ${alert.title}`);
|
||||||
try {
|
try {
|
||||||
|
// Note: we build the payload for the alert using the buildPayload function, which creates a stable and flat JSON object with relevant information about the alert, and then we post it to the webhook URL using the postJson function; if the postJson function returns an OK response, we increment the delivered count, otherwise we increment the failed count, allowing us to track the success and failure of each delivery attempt.
|
||||||
const result = await postJson(c.webhookUrl, c.webhookSecret, buildPayload(alert));
|
const result = await postJson(c.webhookUrl, c.webhookSecret, buildPayload(alert));
|
||||||
if (result.ok) delivered += 1;
|
if (result.ok) delivered += 1;
|
||||||
else failed += 1;
|
else failed += 1;
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.log(`${moment().format()} ❌ Failed to deliver alert to webhook: ${error.message}`);
|
||||||
failed += 1;
|
failed += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log(`${moment().format()} 🚚 Webhook delivery complete: ${delivered} delivered, ${failed} failed`);
|
||||||
return { attempted: true, delivered, failed };
|
return { attempted: true, delivered, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send a test webhook with a simple payload to verify that the webhook URL is configured correctly and can receive requests. This function is intended to be called from an API endpoint where a user can trigger a test webhook, and it will also audit the action for tracking purposes. If the webhook URL is not configured or if the request fails, it throws an error with details about the failure.
|
||||||
async function sendTestWebhook(userId) {
|
async function sendTestWebhook(userId) {
|
||||||
|
console.log(`${moment().format()} 🚀 Sending test webhook to verify configuration`);
|
||||||
const c = getConfig();
|
const c = getConfig();
|
||||||
if (!c.webhookUrl) throw Object.assign(new Error('A webhook URL is not configured'), { status: 400 });
|
if (!c.webhookUrl) {
|
||||||
|
// Note: if the webhook URL is not configured, we cannot send the test webhook, so we log an error message and throw an error indicating that the webhook URL is missing; this allows us to provide clear feedback about why the test webhook could not be sent and helps users understand that they need to configure the webhook URL before they can successfully send a test webhook.
|
||||||
|
console.log(`${moment().format()} ❌ Test webhook failed: Webhook URL is not configured`);
|
||||||
|
throw Object.assign(new Error('A webhook URL is not configured'), { status: 400 });
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
event: 'test',
|
event: 'test',
|
||||||
severity: 'info',
|
severity: 'info',
|
||||||
title: 'MixedAssets webhook test',
|
title: 'DepreCore webhook test',
|
||||||
message: 'If you received this, your webhook destination is configured correctly.',
|
message: 'If you received this, your webhook destination is configured correctly.',
|
||||||
sent_at: new Date().toISOString()
|
sent_at: new Date().toISOString()
|
||||||
};
|
};
|
||||||
let result;
|
let result;
|
||||||
|
// Note: we attempt to send the test webhook using the postJson function with the test payload, and we catch any errors that occur during the request; if an error occurs, we log the error message and throw a new error indicating that the webhook request failed, along with the original error message for details; this allows us to provide clear feedback about why the test webhook failed and helps users troubleshoot any issues with their webhook configuration or endpoint.
|
||||||
try {
|
try {
|
||||||
|
console.log(`${moment().format()} 📡 Sending test webhook to: ${c.webhookUrl}`);
|
||||||
result = await postJson(c.webhookUrl, c.webhookSecret, payload);
|
result = await postJson(c.webhookUrl, c.webhookSecret, payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log(`${moment().format()} ❌ Test webhook failed: ${error.message}`);
|
||||||
throw Object.assign(new Error(`Webhook request failed: ${error.message}`), { status: 400 });
|
throw Object.assign(new Error(`Webhook request failed: ${error.message}`), { status: 400 });
|
||||||
}
|
}
|
||||||
|
console.log(`${moment().format()} 📡 Test webhook sent successfully to: ${c.webhookUrl}`) ;
|
||||||
audit(userId, 'send', 'test_webhook', null, null, { url: c.webhookUrl, status: result.status });
|
audit(userId, 'send', 'test_webhook', null, null, { url: c.webhookUrl, status: result.status });
|
||||||
if (!result.ok) throw Object.assign(new Error(`Webhook endpoint returned HTTP ${result.status}`), { status: 400 });
|
if (!result.ok) {
|
||||||
|
// Note: if the response from the webhook endpoint is not OK (i.e., not in the 200-299 range), we log an error message with the HTTP status code returned by the endpoint and throw an error indicating that the webhook endpoint returned a non-OK status; this allows us to provide clear feedback about why the test webhook failed and helps users understand that their webhook endpoint may be rejecting the request or encountering an error when processing it.
|
||||||
|
console.log(`${moment().format()} ❌ Test webhook failed: Webhook endpoint returned HTTP ${result.status}`);
|
||||||
|
throw Object.assign(new Error(`Webhook endpoint returned HTTP ${result.status}`), { status: 400 });
|
||||||
|
}
|
||||||
|
console.log(`${moment().format()} ✅ Test webhook delivered successfully with HTTP ${result.status}`);
|
||||||
return { ok: true, status: result.status };
|
return { ok: true, status: result.status };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export the functions for use in other modules. This allows the webhook-related logic to be organized in a single module and reused across different parts of the application, such as in API endpoints for sending test webhooks or in the alert processing logic for delivering alerts to the configured webhook endpoint.
|
||||||
module.exports = { buildPayload, deliverAlerts, sendTestWebhook };
|
module.exports = { buildPayload, deliverAlerts, sendTestWebhook };
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
|
/*
|
||||||
|
Workday integration service for managing the connection configuration and synchronizing worker data from Workday into the local system. This module provides functions to get and save the Workday connection settings, fetch an access token using OAuth client credentials, normalize worker data from the Workday API, and perform synchronization of workers on demand or on a scheduled basis. The synchronization process involves fetching worker data from Workday, normalizing it into a consistent format, and then upserting employee and contact records in the local database based on the Workday worker information. The module also includes error handling and auditing for tracking changes to the connection settings and synchronization actions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const moment = require('moment');
|
||||||
const { audit, json, now, one, run } = require('../db');
|
const { audit, json, now, one, run } = require('../db');
|
||||||
const { upsertEmployee } = require('./assignments');
|
const { upsertEmployee } = require('./assignments');
|
||||||
const { upsertWorkdayContact } = require('./contacts');
|
const { upsertWorkdayContact } = require('./contacts');
|
||||||
|
console.log(`${moment().format()} 🔧 Workday service module loaded`);
|
||||||
|
// Convert a database row into a public-facing connection object, excluding sensitive information like the client secret and including a flag to indicate whether the connection is fully configured based on the presence of required fields; this allows us to safely expose connection information in API responses without risking credential leaks.
|
||||||
function publicConnection(row) {
|
function publicConnection(row) {
|
||||||
if (!row) return null;
|
console.log(`${moment().format()} 🔍 Mapping database row to public connection object for row ID: ${row?.id}`);
|
||||||
|
if (!row) {
|
||||||
|
// Note: if no connection row is found in the database, we return null to indicate that there is no configured connection; this allows API endpoints to handle the absence of a connection gracefully.
|
||||||
|
console.log(`${moment().format()} ⚠️ No Workday connection found in database`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -21,22 +32,35 @@ function publicConnection(row) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear any cached connection data. In this implementation, we don't have an actual cache layer, but this function can be expanded in the future to clear in-memory caches or other caching mechanisms if needed; for now, it serves as a placeholder to indicate where cache clearing logic would go.
|
||||||
|
function clearCache() {
|
||||||
|
console.log(`${moment().format()} 🧹 Clearing Workday connection cache (no-op)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split a full name into first and last name components. This is a simple heuristic that takes the first word as the first name and the rest as the last name, which may not be perfect but provides a reasonable default for many cases; this allows us to extract more granular name information from a single full name field when normalizing worker data from Workday.
|
||||||
function splitName(full) {
|
function splitName(full) {
|
||||||
|
console.log(`${moment().format()} 🔍 Splitting full name into first and last name: "${full}"`);
|
||||||
const parts = String(full || '').trim().split(/\s+/).filter(Boolean);
|
const parts = String(full || '').trim().split(/\s+/).filter(Boolean);
|
||||||
if (!parts.length) return { first: null, last: null };
|
if (!parts.length) return { first: null, last: null }; // Note: if the full name is empty or only contains whitespace, we return null for both first and last name to indicate that we couldn't extract any name information; this allows us to handle cases where the name field might be missing or invalid without causing errors.
|
||||||
if (parts.length === 1) return { first: parts[0], last: null };
|
if (parts.length === 1) return { first: parts[0], last: null }; // Note: if the full name only contains one word, we treat it as the first name and set the last name to null; this allows us to handle cases where only a single name is provided without assuming that it must be a last name.
|
||||||
|
console.log(`${moment().format()} ✅ Split name into first: "${parts[0]}", last: "${parts.slice(1).join(' ')}"`);
|
||||||
return { first: parts[0], last: parts.slice(1).join(' ') };
|
return { first: parts[0], last: parts.slice(1).join(' ') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the current Workday connection configuration as a public-facing object. This function retrieves the connection information from the database and maps it to a format that can be safely exposed in API responses, excluding sensitive fields and including a flag for whether the connection is fully configured; this allows API endpoints to provide connection information to clients without risking credential exposure.
|
||||||
function getConnection() {
|
function getConnection() {
|
||||||
|
console.log(`${moment().format()} 🔍 Retrieving Workday connection from database`);
|
||||||
return publicConnection(one('SELECT * FROM workday_connections ORDER BY id LIMIT 1'));
|
return publicConnection(one('SELECT * FROM workday_connections ORDER BY id LIMIT 1'));
|
||||||
}
|
}
|
||||||
|
// Get the raw Workday connection row from the database, including sensitive fields. This function is used internally when we need to access the full connection information, such as when saving updates or performing synchronization, and should not be exposed in API responses; this allows us to maintain a clear separation between internal data access and public-facing data structures.
|
||||||
function getPrivateConnection() {
|
function getPrivateConnection() {
|
||||||
|
console.log(`${moment().format()} 🔍 Retrieving private Workday connection from database`);
|
||||||
return one('SELECT * FROM workday_connections ORDER BY id LIMIT 1');
|
return one('SELECT * FROM workday_connections ORDER BY id LIMIT 1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save the Workday connection configuration to the database, either by creating a new record or updating the existing one. This function takes the input from the API request body, merges it with any existing connection information, and saves it to the database while ensuring that required fields are present and that sensitive information like the client secret is handled appropriately; this allows API endpoints to update the connection settings while maintaining data integrity and security.
|
||||||
function saveConnection(body, userId) {
|
function saveConnection(body, userId) {
|
||||||
|
console.log(`${moment().format()} 🔄 Saving Workday connection with input: ${JSON.stringify(body)}`);
|
||||||
const timestamp = now();
|
const timestamp = now();
|
||||||
const existing = getPrivateConnection();
|
const existing = getPrivateConnection();
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -53,6 +77,8 @@ function saveConnection(body, userId) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
// Note: when updating an existing connection, we only update the fields that are provided in the input, while keeping the existing values for any fields that are not included; this allows for partial updates without requiring the client to resend all connection information, and it also ensures that we don't accidentally overwrite existing values with empty or default values if they are not included in the update request.
|
||||||
|
console.log(`${moment().format()} 🔄 Updating existing Workday connection with ID: ${existing.id}`);
|
||||||
run(
|
run(
|
||||||
`UPDATE workday_connections SET
|
`UPDATE workday_connections SET
|
||||||
name = ?, tenant = ?, base_url = ?, token_url = ?, client_id = ?, client_secret = ?,
|
name = ?, tenant = ?, base_url = ?, token_url = ?, client_id = ?, client_secret = ?,
|
||||||
@@ -61,6 +87,8 @@ function saveConnection(body, userId) {
|
|||||||
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, payload.sync_enabled, payload.sync_interval_hours, timestamp, existing.id]
|
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, payload.sync_enabled, payload.sync_interval_hours, timestamp, existing.id]
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
// Note: when creating a new connection, we require all necessary fields to be provided in the input, and we insert a new record into the database with the provided values; this allows us to ensure that we have a complete set of connection information when creating a new connection, and it also allows us to handle the case where there is no existing connection in the database.
|
||||||
|
console.log(`${moment().format()} ➕ Creating new Workday connection`);
|
||||||
run(
|
run(
|
||||||
`INSERT INTO workday_connections (
|
`INSERT INTO workday_connections (
|
||||||
name, tenant, base_url, token_url, client_id, client_secret, workers_path, enabled, sync_enabled, sync_interval_hours, created_at, updated_at
|
name, tenant, base_url, token_url, client_id, client_secret, workers_path, enabled, sync_enabled, sync_interval_hours, created_at, updated_at
|
||||||
@@ -71,10 +99,13 @@ function saveConnection(body, userId) {
|
|||||||
|
|
||||||
const saved = getConnection();
|
const saved = getConnection();
|
||||||
audit(userId, 'update', 'workday_connection', saved.id, null, { ...saved, client_secret: '[redacted]' });
|
audit(userId, 'update', 'workday_connection', saved.id, null, { ...saved, client_secret: '[redacted]' });
|
||||||
|
console.log(`${moment().format()} 📤 Auditing update to Workday connection: ${saved.id}`);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch an access token from Workday using the client credentials flow. This function constructs the appropriate authorization header using the client ID and client secret, makes a POST request to the token URL, and returns the access token if the request is successful; this allows us to authenticate with the Workday API and obtain a token that can be used for subsequent API requests to fetch worker data.
|
||||||
async function fetchAccessToken(connection) {
|
async function fetchAccessToken(connection) {
|
||||||
|
console.log(`${moment().format()} 🔐 Fetching Workday access token from token URL: ${connection.token_url}`);
|
||||||
const basic = Buffer.from(`${connection.client_id}:${connection.client_secret}`).toString('base64');
|
const basic = Buffer.from(`${connection.client_id}:${connection.client_secret}`).toString('base64');
|
||||||
const response = await fetch(connection.token_url, {
|
const response = await fetch(connection.token_url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -85,23 +116,35 @@ async function fetchAccessToken(connection) {
|
|||||||
body: new URLSearchParams({ grant_type: 'client_credentials' })
|
body: new URLSearchParams({ grant_type: 'client_credentials' })
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
// Note: if the token request fails, we throw an error with the status code to indicate that we were unable to authenticate with Workday; this allows API endpoints that call this function to handle authentication errors appropriately and provide feedback to the user.
|
||||||
|
console.log(`${moment().format()} ❌ Workday token request failed with status: ${response.status}`);
|
||||||
throw new Error(`Workday token request failed: ${response.status}`);
|
throw new Error(`Workday token request failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
if (!body.access_token) throw new Error('Workday token response did not include an access token');
|
if (!body.access_token) {
|
||||||
|
// Note: if the token response does not include an access token, we throw an error to indicate that the authentication was unsuccessful; this allows us to catch cases where the token request might succeed but still fail to provide the necessary token for API requests, and it also provides feedback for troubleshooting authentication issues.
|
||||||
|
console.log(`${moment().format()} ❌ Workday token response did not include an access token`);
|
||||||
|
throw new Error('Workday token response did not include an access token');
|
||||||
|
}
|
||||||
|
console.log(`${moment().format()} ✅ Successfully fetched Workday access token`);
|
||||||
return body.access_token;
|
return body.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize worker data from the Workday API into a consistent format for upserting into the local database. This function takes the raw worker data from Workday, which may have varying field names and structures, and maps it to a standardized format with consistent field names for things like worker ID, name, email, department, manager information, and so on; this allows us to handle different versions of the Workday API or different configurations that might return worker data in slightly different formats while still being able to process it in a consistent way for synchronization.
|
||||||
function normalizeWorkers(payload) {
|
function normalizeWorkers(payload) {
|
||||||
|
// Note: the payload from Workday can come in various formats depending on the API version and configuration, so we check for multiple possible structures to extract the array of worker data; this allows us to be flexible in handling different responses from Workday while still being able to normalize the worker data effectively.
|
||||||
const rows = Array.isArray(payload)
|
const rows = Array.isArray(payload)
|
||||||
? payload
|
? payload
|
||||||
: payload.workers || payload.Workers || payload.data || payload.value || [];
|
: payload.workers || payload.Workers || payload.data || payload.value || [];
|
||||||
|
|
||||||
|
// Note: when normalizing each worker, we check for multiple possible field names for each piece of information (e.g., name, email, department) to account for variations in the Workday API response; we also use the splitName function to extract first and last names from a full name field when necessary, and we include the original worker data in the source_payload for reference; this allows us to create a consistent format for worker data that can be used for upserting into the local database while still retaining the original information from Workday for troubleshooting and auditing purposes.
|
||||||
|
console.log(`${moment().format()} 🔍 Normalizing Workday worker data with ${rows.length} workers found in payload`);
|
||||||
return rows.map((worker) => {
|
return rows.map((worker) => {
|
||||||
const name = worker.name || worker.fullName || worker.Full_Name || worker.descriptor || [worker.firstName, worker.lastName].filter(Boolean).join(' ');
|
const name = worker.name || worker.fullName || worker.Full_Name || worker.descriptor || [worker.firstName, worker.lastName].filter(Boolean).join(' ');
|
||||||
const fallbackName = splitName(name);
|
const fallbackName = splitName(name);
|
||||||
const managerName = worker.managerName || worker.manager?.descriptor;
|
const managerName = worker.managerName || worker.manager?.descriptor;
|
||||||
const managerSplit = splitName(managerName);
|
const managerSplit = splitName(managerName);
|
||||||
|
console.log(`${moment().format()} 🔍 Normalizing worker data for: ${name}`);
|
||||||
return {
|
return {
|
||||||
workday_worker_id: worker.id || worker.workerId || worker.worker_id || worker.Worker_ID || worker.workerReference?.id,
|
workday_worker_id: worker.id || worker.workerId || worker.worker_id || worker.Worker_ID || worker.workerReference?.id,
|
||||||
employee_number: worker.employeeNumber || worker.employee_number || worker.Worker_ID || worker.workerNumber,
|
employee_number: worker.employeeNumber || worker.employee_number || worker.Worker_ID || worker.workerNumber,
|
||||||
@@ -124,31 +167,47 @@ function normalizeWorkers(payload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synchronize workers from Workday by fetching the worker data using the API, normalizing it, and then upserting employee and contact records in the local database based on the Workday worker information. This function handles the entire synchronization process, including error handling and auditing, and it updates the last synchronization timestamp and status in the database after the process is complete; this allows us to keep our local employee and contact records in sync with Workday on demand or on a scheduled basis, ensuring that we have up-to-date information about our workforce for reporting and analysis.
|
||||||
async function syncWorkers(userId) {
|
async function syncWorkers(userId) {
|
||||||
|
console.log(`${moment().format()} 🔄 Starting Workday worker synchronization`);
|
||||||
const connection = getPrivateConnection();
|
const connection = getPrivateConnection();
|
||||||
if (!connection || !connection.enabled) throw Object.assign(new Error('Workday connection is not enabled'), { status: 400 });
|
if (!connection || !connection.enabled) {
|
||||||
|
// Note: if there is no connection configured or if the connection is not enabled, we throw an error to indicate that synchronization cannot proceed; this allows API endpoints that call this function to provide feedback to the user about the need to configure and enable the Workday connection before attempting to synchronize workers.
|
||||||
|
console.log(`${moment().format()} ⚠️ Workday connection is not enabled or not configured`);
|
||||||
|
throw Object.assign(new Error('Workday connection is not enabled'), { status: 400 });
|
||||||
|
}
|
||||||
if (!connection.base_url || !connection.token_url || !connection.client_id || !connection.client_secret) {
|
if (!connection.base_url || !connection.token_url || !connection.client_id || !connection.client_secret) {
|
||||||
|
// Note: if the connection is missing any of the required fields for making API requests (base URL, token URL, client ID, or client secret), we throw an error to indicate that the connection is not properly configured; this allows us to catch configuration issues before attempting to make API requests and provides feedback for troubleshooting the connection settings.
|
||||||
|
console.log(`${moment().format()} ⚠️ Workday connection is missing URL or OAuth credentials`);
|
||||||
throw Object.assign(new Error('Workday connection is missing URL or OAuth credentials'), { status: 400 });
|
throw Object.assign(new Error('Workday connection is missing URL or OAuth credentials'), { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await fetchAccessToken(connection);
|
const token = await fetchAccessToken(connection); // Note: we fetch an access token using the client credentials flow before making API requests to Workday; this allows us to authenticate with the Workday API and obtain a token that can be used for subsequent requests to fetch worker data.
|
||||||
const url = new URL(connection.workers_path || '/workers', connection.base_url);
|
const url = new URL(connection.workers_path || '/workers', connection.base_url); // Note: we construct the URL for fetching workers by combining the base URL from the connection with the workers path, which allows for flexibility in how the API endpoint is structured; this also allows us to support different versions of the Workday API or custom configurations that might use different paths for accessing worker data.
|
||||||
|
console.log(`${moment().format()} 🔍 Fetching workers from Workday API at URL: ${url.href}`);
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
Accept: 'application/json'
|
Accept: 'application/json'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
console.log(`${moment().format()} 📥 Received response from Workday API`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
// Note: if the API request to fetch workers fails, we throw an error with the status code to indicate that we were unable to retrieve worker data from Workday; this allows API endpoints that call this function to handle API errors appropriately and provide feedback to the user about issues with fetching data from Workday.
|
||||||
|
console.log(`${moment().format()} ❌ Workday workers request failed with status: ${response.status}`);
|
||||||
throw new Error(`Workday workers request failed: ${response.status}`);
|
throw new Error(`Workday workers request failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: after successfully fetching the worker data from Workday, we normalize it into a consistent format and then upsert employee and contact records in the local database for each worker; this allows us to keep our local records in sync with Workday and ensures that we have up-to-date information about our workforce for reporting and analysis.
|
||||||
const workers = normalizeWorkers(await response.json());
|
const workers = normalizeWorkers(await response.json());
|
||||||
for (const worker of workers) {
|
for (const worker of workers) {
|
||||||
upsertEmployee(worker);
|
// Note: when processing each worker from Workday, we log the name and Workday ID for tracking purposes, and then we upsert employee and contact records in the local database based on the worker information; this allows us to maintain accurate employee and contact records that reflect the current state of our workforce as represented in Workday, and it also provides visibility into the synchronization process for troubleshooting and auditing.
|
||||||
upsertWorkdayContact(worker);
|
console.log(`${moment().format()} 🔄 Upserting worker into local database: ${worker.name} (Workday ID: ${worker.workday_worker_id})`);
|
||||||
|
upsertEmployee(worker); // Note: we upsert employee records based on the worker information, which allows us to keep our local employee data in sync with the worker data from Workday, and it also ensures that we have accurate employee records for reporting and analysis.
|
||||||
|
upsertWorkdayContact(worker); // Note: we also upsert contact records based on the worker information, which allows us to maintain accurate contact information for our workforce and ensures that we can reach out to employees as needed based on the contact details provided in Workday.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: after the synchronization process is complete, we update the last synchronization timestamp and status in the database to reflect the results of the sync, and we audit the synchronization action for tracking purposes; this allows us to maintain a record of when synchronizations occurred and how many workers were imported, which can be useful for troubleshooting and reporting on the integration with Workday.
|
||||||
const timestamp = now();
|
const timestamp = now();
|
||||||
run('UPDATE workday_connections SET last_sync_at = ?, last_sync_status = ?, updated_at = ? WHERE id = ?', [
|
run('UPDATE workday_connections SET last_sync_at = ?, last_sync_status = ?, updated_at = ? WHERE id = ?', [
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -157,31 +216,49 @@ async function syncWorkers(userId) {
|
|||||||
connection.id
|
connection.id
|
||||||
]);
|
]);
|
||||||
audit(userId, 'sync', 'workday_workers', connection.id, null, { imported: workers.length });
|
audit(userId, 'sync', 'workday_workers', connection.id, null, { imported: workers.length });
|
||||||
|
console.log(`${moment().format()} ✅ Workday worker synchronization complete: ${workers.length} workers imported`);
|
||||||
return { imported: workers.length, workers };
|
return { imported: workers.length, workers };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import workers from a provided payload, which is expected to be in the same format as the normalized worker data from Workday. This function allows for manual import of worker data, such as from a file upload or an API request with worker information, and it processes the data in the same way as the synchronization function by upserting employee and contact records in the local database; this provides flexibility for importing worker data from sources other than direct API calls to Workday while still maintaining consistency in how the data is processed and stored.
|
||||||
function importWorkersFromPayload(workers, userId) {
|
function importWorkersFromPayload(workers, userId) {
|
||||||
const normalized = normalizeWorkers(workers);
|
console.log(`${moment().format()} 🔄 Importing workers from provided payload with ${workers.length} workers`);
|
||||||
|
const normalized = normalizeWorkers(workers); // Note: we normalize the provided worker data using the same normalization function as the synchronization process to ensure that the data is in a consistent format for upserting into the local database; this allows us to handle different input formats while still processing the worker data in a consistent way.
|
||||||
for (const worker of normalized) {
|
for (const worker of normalized) {
|
||||||
upsertEmployee(worker);
|
// Note: when importing workers from a payload, we process the data in the same way as the synchronization function by normalizing it and then upserting employee and contact records in the local database; this allows us to maintain consistency in how worker data is handled regardless of the source, and it also provides a way to import worker data from sources other than direct API calls to Workday.
|
||||||
upsertWorkdayContact(worker);
|
console.log(`${moment().format()} 🔄 Upserting worker from payload into local database: ${worker.name} (Workday ID: ${worker.workday_worker_id})`);
|
||||||
|
upsertEmployee(worker); // Note: we upsert employee records based on the worker information, which allows us to keep our local employee data in sync with the worker data from Workday, and it also ensures that we have accurate employee records for reporting and analysis.
|
||||||
|
upsertWorkdayContact(worker); // Note: we also upsert contact records based on the worker information, which allows us to maintain accurate contact information for our workforce and ensures that we can reach out to employees as needed based on the contact details provided in Workday.
|
||||||
}
|
}
|
||||||
audit(userId, 'import', 'workday_workers', null, null, { imported: normalized.length, source: 'payload' });
|
audit(userId, 'import', 'workday_workers', null, null, { imported: normalized.length, source: 'payload' });
|
||||||
|
console.log(`${moment().format()} ✅ Worker import from payload complete: ${normalized.length} workers imported`);
|
||||||
return { imported: normalized.length };
|
return { imported: normalized.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run an automatic sync if it's enabled, configured, and the interval has elapsed.
|
// Run an automatic sync if it's enabled, configured, and the interval has elapsed.
|
||||||
async function runScheduledSync() {
|
async function runScheduledSync() {
|
||||||
|
console.log(`${moment().format()} ⏰ Checking if scheduled Workday sync should run`);
|
||||||
const connection = getPrivateConnection();
|
const connection = getPrivateConnection();
|
||||||
if (!connection || !connection.sync_enabled || !connection.enabled) return { skipped: true };
|
if (!connection || !connection.sync_enabled || !connection.enabled) {
|
||||||
if (!connection.base_url || !connection.token_url || !connection.client_id || !connection.client_secret) return { skipped: true };
|
// Note: if the connection is not enabled, we skip the sync to avoid unnecessary API calls and processing; this allows us to control when automatic synchronizations run and prevents errors when attempting to connect to Workday.
|
||||||
|
console.log(`${moment().format()} ⏰ Scheduled Workday sync skipped: Connection not enabled`);
|
||||||
|
return { skipped: true };
|
||||||
|
}
|
||||||
|
if (!connection.base_url || !connection.token_url || !connection.client_id || !connection.client_secret) {
|
||||||
|
// Note: before running a scheduled sync, we check if the connection is fully configured with all required fields; if any of the necessary configuration is missing, we skip the sync to avoid errors when attempting to connect to Workday, and we log a message indicating that the sync was skipped due to incomplete configuration; this allows us to ensure that automatic synchronizations only run when the connection is properly set up and prevents unnecessary errors or failed sync attempts.
|
||||||
|
console.log(`${moment().format()} ⏰ Scheduled Workday sync skipped: Incomplete connection configuration`);
|
||||||
|
return { skipped: true };
|
||||||
|
}
|
||||||
const intervalMs = (Number(connection.sync_interval_hours) || 24) * 3600000;
|
const intervalMs = (Number(connection.sync_interval_hours) || 24) * 3600000;
|
||||||
if (connection.last_sync_at && Date.now() - new Date(connection.last_sync_at).getTime() < intervalMs) {
|
if (connection.last_sync_at && Date.now() - new Date(connection.last_sync_at).getTime() < intervalMs) {
|
||||||
|
// Note: if the last synchronization occurred within the configured interval, we skip the sync to avoid unnecessary API calls and processing; this allows us to control the frequency of automatic synchronizations and prevent excessive load on the Workday API or our local system while still ensuring that we keep our worker data reasonably up-to-date.
|
||||||
|
console.log(`${moment().format()} ⏰ Scheduled Workday sync skipped: Sync interval not elapsed`);
|
||||||
return { skipped: true };
|
return { skipped: true };
|
||||||
}
|
}
|
||||||
return syncWorkers(null);
|
return syncWorkers(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export the functions for use in other modules. This allows the Workday integration logic to be organized in a single module and reused across different parts of the application, such as in API endpoints or scheduled tasks for automatic synchronization.
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getConnection,
|
getConnection,
|
||||||
importWorkersFromPayload,
|
importWorkersFromPayload,
|
||||||
|
|||||||
@@ -1,32 +1,50 @@
|
|||||||
|
/*
|
||||||
|
Service for managing depreciation zones, which are used to define special rules for assets located in certain areas (e.g. opportunity zones). This includes functions for creating, updating, deleting, and retrieving zones, as well as caching for efficient lookups during depreciation calculations.
|
||||||
|
*/
|
||||||
|
const moment = require('moment');
|
||||||
const { all, audit, now, one, run } = require('../db');
|
const { all, audit, now, one, run } = require('../db');
|
||||||
|
|
||||||
let cache = null;
|
let cache = null;
|
||||||
|
|
||||||
|
// Clear the cache, forcing a reload from the database on the next lookup. This should be called after any changes to the zones to ensure that the cache stays up to date.
|
||||||
function clearCache() {
|
function clearCache() {
|
||||||
|
console.log(`${moment().format()} 🧹 Clearing depreciation zones cache`);
|
||||||
cache = null;
|
cache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load all zones into a cache object keyed by code for efficient lookups. This is used during depreciation calculations to quickly retrieve zone information without hitting the database repeatedly, improving performance while still ensuring that we have the necessary data available.
|
||||||
function lookup() {
|
function lookup() {
|
||||||
if (!cache) {
|
if (!cache) {
|
||||||
|
console.log(`${moment().format()} 📦 Loading depreciation zones into cache`);
|
||||||
cache = {};
|
cache = {};
|
||||||
for (const row of all('SELECT * FROM depreciation_zones')) cache[row.code] = row;
|
for (const row of all('SELECT * FROM depreciation_zones')) cache[row.code] = row;
|
||||||
}
|
}
|
||||||
|
console.log(`${moment().format()} ✅ Depreciation zones cache loaded with ${Object.keys(cache).length} zones`);
|
||||||
return cache;
|
return cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw row (incl. dates) for the engine; null when unknown or disabled.
|
// Raw row (incl. dates) for the engine; null when unknown or disabled.
|
||||||
function getZone(code) {
|
function getZone(code) {
|
||||||
if (!code) return null;
|
if (!code) {
|
||||||
|
// Note: we allow getZone to be called with a falsy code (e.g. null or undefined) to simplify logic in the depreciation engine, but we return null in that case since a valid zone code is required for a lookup; this allows the engine to call getZone without needing to check for the presence of a code first, while still ensuring that we don't attempt to look up an invalid code.
|
||||||
|
console.log(`${moment().format()} ⚠️ No zone code provided for lookup`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const zone = lookup()[code];
|
const zone = lookup()[code];
|
||||||
return zone && zone.enabled ? zone : null;
|
return zone && zone.enabled ? zone : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to create a slug from a string, used for generating zone codes. This ensures that zone codes are consistent and URL-friendly, while still allowing for human-readable names to be used for the zones.
|
||||||
function slugify(value) {
|
function slugify(value) {
|
||||||
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
|
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert a database row into a zone object with the expected properties for the depreciation engine, including an asset count for reporting purposes. This allows us to separate the raw database representation from the format used in the engine, while still providing all the necessary information for both use cases.
|
||||||
function fromRow(row) {
|
function fromRow(row) {
|
||||||
if (!row) return null;
|
if (!row) {
|
||||||
|
console.log(`${moment().format()} ⚠️ No zone row provided for conversion`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
@@ -39,17 +57,24 @@ function fromRow(row) {
|
|||||||
leasehold_life_years: row.leasehold_life_years,
|
leasehold_life_years: row.leasehold_life_years,
|
||||||
section_179_increase: row.section_179_increase,
|
section_179_increase: row.section_179_increase,
|
||||||
section_179_cost_factor: row.section_179_cost_factor,
|
section_179_cost_factor: row.section_179_cost_factor,
|
||||||
|
section_179_threshold_increase: row.section_179_threshold_increase,
|
||||||
|
section_179_pis_start: row.section_179_pis_start,
|
||||||
|
section_179_pis_end: row.section_179_pis_end,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
enabled: Boolean(row.enabled),
|
enabled: Boolean(row.enabled),
|
||||||
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE special_zone = ?', [row.code]).c
|
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE special_zone = ?', [row.code]).c
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List all zones, converting each database row into a zone object for use in the application. This allows us to retrieve all zones in a format that includes the necessary properties for both the engine and reporting, while still keeping the database representation separate.
|
||||||
function list() {
|
function list() {
|
||||||
|
console.log(`${moment().format()} 📋 Listing all depreciation zones`);
|
||||||
return all('SELECT * FROM depreciation_zones ORDER BY name').map(fromRow);
|
return all('SELECT * FROM depreciation_zones ORDER BY name').map(fromRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize and validate input data for creating or updating a zone, ensuring that all required fields are present and properly formatted. This helps to prevent invalid data from being entered into the database, while still allowing for flexibility in the input format (e.g. allowing empty strings to be treated as null).
|
||||||
function normalize(body) {
|
function normalize(body) {
|
||||||
|
console.log(`${moment().format()} 🧹 Normalizing input data for zone: ${JSON.stringify(body)}`);
|
||||||
const num = (v) => (v === '' || v === null || v === undefined ? null : Number(v));
|
const num = (v) => (v === '' || v === null || v === undefined ? null : Number(v));
|
||||||
return {
|
return {
|
||||||
name: String(body.name || '').trim(),
|
name: String(body.name || '').trim(),
|
||||||
@@ -61,53 +86,91 @@ function normalize(body) {
|
|||||||
leasehold_life_years: num(body.leasehold_life_years),
|
leasehold_life_years: num(body.leasehold_life_years),
|
||||||
section_179_increase: Number(body.section_179_increase) || 0,
|
section_179_increase: Number(body.section_179_increase) || 0,
|
||||||
section_179_cost_factor: body.section_179_cost_factor === '' || body.section_179_cost_factor === null || body.section_179_cost_factor === undefined ? 1 : Number(body.section_179_cost_factor),
|
section_179_cost_factor: body.section_179_cost_factor === '' || body.section_179_cost_factor === null || body.section_179_cost_factor === undefined ? 1 : Number(body.section_179_cost_factor),
|
||||||
|
section_179_threshold_increase: Number(body.section_179_threshold_increase) || 0,
|
||||||
|
section_179_pis_start: body.section_179_pis_start || null,
|
||||||
|
section_179_pis_end: body.section_179_pis_end || null,
|
||||||
notes: body.notes || null,
|
notes: body.notes || null,
|
||||||
enabled: body.enabled === false ? 0 : 1
|
enabled: body.enabled === false ? 0 : 1
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a new zone with the specified properties, ensuring that the code is unique and properly formatted. This allows us to add new zones to the system while maintaining data integrity and providing feedback on any issues with the input.
|
||||||
function createZone(body, userId) {
|
function createZone(body, userId) {
|
||||||
|
console.log(`${moment().format()} ➕ Creating new depreciation zone with input: ${JSON.stringify(body)}`);
|
||||||
const input = normalize(body);
|
const input = normalize(body);
|
||||||
if (!input.name) throw Object.assign(new Error('A zone name is required'), { status: 400 });
|
if (!input.name) {
|
||||||
|
console.log(`${moment().format()} ⚠️ Zone name is required for creation`);
|
||||||
|
throw Object.assign(new Error('A zone name is required'), { status: 400 });
|
||||||
|
}
|
||||||
const code = slugify(body.code || input.name);
|
const code = slugify(body.code || input.name);
|
||||||
if (!code) throw Object.assign(new Error('A valid zone code is required'), { status: 400 });
|
if (!code) {
|
||||||
|
// Note: we require a valid code for the zone, which can be provided directly or generated from the name; if we can't generate a valid code, we throw an error since the code is necessary for lookups and must be unique.
|
||||||
|
console.log(`${moment().format()} ⚠️ Zone code is required for creation`);
|
||||||
|
throw Object.assign(new Error('A valid zone code is required'), { status: 400 });
|
||||||
|
}
|
||||||
if (one('SELECT id FROM depreciation_zones WHERE code = ?', [code])) {
|
if (one('SELECT id FROM depreciation_zones WHERE code = ?', [code])) {
|
||||||
|
// Note: we check for the existence of a zone with the same code before attempting to create a new one, and we throw an error if a duplicate is found since the code must be unique; this allows us to prevent conflicts and maintain data integrity in the database.
|
||||||
|
console.log(`${moment().format()} ⚠️ A zone with that code already exists`);
|
||||||
throw Object.assign(new Error('A zone with that code already exists'), { status: 400 });
|
throw Object.assign(new Error('A zone with that code already exists'), { status: 400 });
|
||||||
}
|
}
|
||||||
const ts = now();
|
const ts = now();
|
||||||
|
// Note: we use a parameterized query here to prevent SQL injection, and we include all the relevant fields in the insert statement to ensure that the new zone is created with the correct properties; this allows us to safely add new zones to the database while maintaining data integrity.
|
||||||
run(
|
run(
|
||||||
`INSERT INTO depreciation_zones (code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years, section_179_increase, section_179_cost_factor, notes, enabled, created_at, updated_at)
|
`INSERT INTO depreciation_zones (code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years, section_179_increase, section_179_cost_factor, section_179_threshold_increase, section_179_pis_start, section_179_pis_end, notes, enabled, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[code, input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.section_179_increase, input.section_179_cost_factor, input.notes, input.enabled, ts, ts]
|
[code, input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.section_179_increase, input.section_179_cost_factor, input.section_179_threshold_increase, input.section_179_pis_start, input.section_179_pis_end, input.notes, input.enabled, ts, ts]
|
||||||
);
|
);
|
||||||
|
// Clear the cache after creating a new zone to ensure that it is included in subsequent lookups, and audit the creation action for tracking purposes; this allows us to maintain an accurate cache and keep a record of changes to the zones for accountability.
|
||||||
|
console.log(`${moment().format()} 🧹 Clearing cache after creating new zone: ${code}`);
|
||||||
clearCache();
|
clearCache();
|
||||||
audit(userId, 'create', 'depreciation_zone', code, null, input);
|
audit(userId, 'create', 'depreciation_zone', code, null, input);
|
||||||
|
console.log(`${moment().format()} ✅ Depreciation zone created with code: ${code}`);
|
||||||
return fromRow(one('SELECT * FROM depreciation_zones WHERE code = ?', [code]));
|
return fromRow(one('SELECT * FROM depreciation_zones WHERE code = ?', [code]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update an existing zone with the specified properties, ensuring that the code exists and that the input is properly normalized. This allows us to modify existing zones while maintaining data integrity and providing feedback on any issues with the input.
|
||||||
function updateZone(code, body, userId) {
|
function updateZone(code, body, userId) {
|
||||||
|
console.log(`${moment().format()} ✏️ Updating depreciation zone with code: ${code}, input: ${JSON.stringify(body)}`);
|
||||||
const before = one('SELECT * FROM depreciation_zones WHERE code = ?', [code]);
|
const before = one('SELECT * FROM depreciation_zones WHERE code = ?', [code]);
|
||||||
if (!before) return null;
|
if (!before) {
|
||||||
|
// Note: we check for the existence of the zone before attempting to update it, and we return null if it doesn't exist since there's nothing to update; this allows us to handle cases where an invalid code is provided gracefully, while still ensuring that we don't attempt to update a non-existent record.
|
||||||
|
console.log(`${moment().format()} ⚠️ Zone with code ${code} not found`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const input = normalize({ ...before, ...body, enabled: body.enabled === undefined ? Boolean(before.enabled) : body.enabled });
|
const input = normalize({ ...before, ...body, enabled: body.enabled === undefined ? Boolean(before.enabled) : body.enabled });
|
||||||
|
// Note: we use a parameterized query here to prevent SQL injection, and we include all the relevant fields in the update statement to ensure that the zone is updated with the correct properties; this allows us to safely modify existing zones in the database while maintaining data integrity.
|
||||||
run(
|
run(
|
||||||
`UPDATE depreciation_zones SET name = ?, allowance_percent = ?, pis_start = ?, pis_end = ?, real_property_end = ?,
|
`UPDATE depreciation_zones SET name = ?, allowance_percent = ?, pis_start = ?, pis_end = ?, real_property_end = ?,
|
||||||
max_recovery_years = ?, leasehold_life_years = ?, section_179_increase = ?, section_179_cost_factor = ?, notes = ?, enabled = ?, updated_at = ? WHERE code = ?`,
|
max_recovery_years = ?, leasehold_life_years = ?, section_179_increase = ?, section_179_cost_factor = ?,
|
||||||
[input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.section_179_increase, input.section_179_cost_factor, input.notes, input.enabled, now(), code]
|
section_179_threshold_increase = ?, section_179_pis_start = ?, section_179_pis_end = ?, notes = ?, enabled = ?, updated_at = ? WHERE code = ?`,
|
||||||
|
[input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.section_179_increase, input.section_179_cost_factor, input.section_179_threshold_increase, input.section_179_pis_start, input.section_179_pis_end, input.notes, input.enabled, now(), code]
|
||||||
);
|
);
|
||||||
|
// Clear the cache after updating the zone to ensure that the changes are reflected in subsequent lookups, and audit the update action for tracking purposes; this allows us to maintain an accurate cache and keep a record of changes to the zones for accountability.
|
||||||
|
console.log(`${moment().format()} 🧹 Clearing cache after updating zone: ${code}`);
|
||||||
clearCache();
|
clearCache();
|
||||||
audit(userId, 'update', 'depreciation_zone', code, before, input);
|
audit(userId, 'update', 'depreciation_zone', code, before, input);
|
||||||
return fromRow(one('SELECT * FROM depreciation_zones WHERE code = ?', [code]));
|
return fromRow(one('SELECT * FROM depreciation_zones WHERE code = ?', [code]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete a zone by code, ensuring that the code exists before attempting to delete it. This allows us to remove zones from the system while maintaining data integrity and providing feedback on any issues with the input.
|
||||||
function deleteZone(code, userId) {
|
function deleteZone(code, userId) {
|
||||||
|
console.log(`${moment().format()} 🗑️ Deleting depreciation zone with code: ${code}`);
|
||||||
const before = one('SELECT * FROM depreciation_zones WHERE code = ?', [code]);
|
const before = one('SELECT * FROM depreciation_zones WHERE code = ?', [code]);
|
||||||
if (!before) return false;
|
if (!before) {
|
||||||
|
// Note: we check for the existence of the zone before attempting to delete it, and we return false if it doesn't exist since there's nothing to delete; this allows us to handle cases where an invalid code is provided gracefully, while still ensuring that we don't attempt to delete a non-existent record.
|
||||||
|
console.log(`${moment().format()} ⚠️ Zone with code ${code} not found`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log(`${moment().format()} 🗑️ Deleting depreciation zone with code: ${code}`);
|
||||||
run('DELETE FROM depreciation_zones WHERE code = ?', [code]);
|
run('DELETE FROM depreciation_zones WHERE code = ?', [code]);
|
||||||
|
// Clear the cache after deleting the zone to ensure that it is removed from subsequent lookups, and audit the deletion action for tracking purposes; this allows us to maintain an accurate cache and keep a record of changes to the zones for accountability.
|
||||||
|
console.log(`${moment().format()} 🧹 Clearing cache after deleting zone: ${code}`);
|
||||||
clearCache();
|
clearCache();
|
||||||
audit(userId, 'delete', 'depreciation_zone', code, before, null);
|
audit(userId, 'delete', 'depreciation_zone', code, before, null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export the functions for use in other modules. This allows the zone management logic to be organized in a single module and reused across different parts of the application, such as in API endpoints or the depreciation engine.
|
||||||
module.exports = {
|
module.exports = {
|
||||||
clearCache,
|
clearCache,
|
||||||
createZone,
|
createZone,
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ const port = 3999;
|
|||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
// Run against an isolated, freshly-seeded database so the test is deterministic
|
// Run against an isolated, freshly-seeded database so the test is deterministic
|
||||||
// and never reads or mutates the developer's working data.
|
// and never reads or mutates the developer's working data.
|
||||||
const dbPath = path.join(os.tmpdir(), `mixedassets-smoke-${Date.now()}.sqlite`);
|
const dbPath = path.join(os.tmpdir(), `deprecore-smoke-${Date.now()}.sqlite`);
|
||||||
const child = spawn(process.execPath, ['server/index.js'], {
|
const child = spawn(process.execPath, ['server/index.js'], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
env: { ...process.env, PORT: String(port), MIXEDASSETS_DB_PATH: dbPath },
|
env: { ...process.env, PORT: String(port), DEPRECORE_DB_PATH: dbPath },
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,8 +54,8 @@ async function main() {
|
|||||||
const login = await request('/api/auth/login', {
|
const login = await request('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: 'admin@mixedassets.local',
|
email: 'admin@deprecore.local',
|
||||||
password: process.env.MIXEDASSETS_ADMIN_PASSWORD || 'ChangeMe123!'
|
password: process.env.DEPRECORE_ADMIN_PASSWORD || 'ChangeMe123!'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ async function main() {
|
|||||||
throw new Error('Login did not return admin capabilities');
|
throw new Error('Login did not return admin capabilities');
|
||||||
}
|
}
|
||||||
const profile = await request('/api/profile', { headers: auth });
|
const profile = await request('/api/profile', { headers: auth });
|
||||||
if (profile.preferences.theme !== 'mixedAssetsDark') {
|
if (profile.preferences.theme !== 'depreCoreDark') {
|
||||||
throw new Error(`Expected default dark theme, found ${profile.preferences.theme}`);
|
throw new Error(`Expected default dark theme, found ${profile.preferences.theme}`);
|
||||||
}
|
}
|
||||||
if (!Array.isArray(profile.capabilities) || !profile.capabilities.includes('*')) {
|
if (!Array.isArray(profile.capabilities) || !profile.capabilities.includes('*')) {
|
||||||
@@ -73,9 +73,9 @@ async function main() {
|
|||||||
const updatedProfile = await request('/api/profile', {
|
const updatedProfile = await request('/api/profile', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: auth,
|
headers: auth,
|
||||||
body: JSON.stringify({ preferences: { theme: 'mixedAssetsDark' } })
|
body: JSON.stringify({ preferences: { theme: 'depreCoreDark' } })
|
||||||
});
|
});
|
||||||
if (updatedProfile.preferences.theme !== 'mixedAssetsDark') {
|
if (updatedProfile.preferences.theme !== 'depreCoreDark') {
|
||||||
throw new Error('Profile theme preference did not persist');
|
throw new Error('Profile theme preference did not persist');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +299,36 @@ async function main() {
|
|||||||
const reclassed = await request(`/api/assets/${classAsset.asset.id}`, { headers: auth });
|
const reclassed = await request(`/api/assets/${classAsset.asset.id}`, { headers: auth });
|
||||||
if (reclassed.asset.asset_class_number !== 'AC-200') throw new Error('Class number change did not cascade to assets');
|
if (reclassed.asset.asset_class_number !== 'AC-200') throw new Error('Class number change did not cascade to assets');
|
||||||
|
|
||||||
|
// Asset class collision: 00.12 is BOTH "Computers" (common) and "Casinos" (business). Linking a
|
||||||
|
// category by the class's unique id must resolve to the exact class, not the first number match.
|
||||||
|
const computersClass = irs.assetClasses.find((c) => c.description === 'Computers' && c.class_number === '00.12');
|
||||||
|
const casinoClass = irs.assetClasses.find((c) => c.description === 'Casinos' && c.class_number === '00.12');
|
||||||
|
if (!computersClass || !casinoClass || computersClass.id === casinoClass.id) {
|
||||||
|
throw new Error('Expected distinct Computers/Casinos classes sharing class number 00.12');
|
||||||
|
}
|
||||||
|
const computerCat = await request('/api/asset-categories', {
|
||||||
|
method: 'POST', headers: auth, body: JSON.stringify({ name: `Computers ${suffix}`, asset_class_id: computersClass.id })
|
||||||
|
});
|
||||||
|
if (computerCat.category.asset_class_id !== computersClass.id) throw new Error('Category did not store the unique asset_class_id');
|
||||||
|
if (computerCat.category.asset_class_number !== '00.12') throw new Error('Category did not derive the class number from the linked class');
|
||||||
|
if (!/Computers/.test(computerCat.category.asset_class_label) || /Casino/.test(computerCat.category.asset_class_label)) {
|
||||||
|
throw new Error(`Category resolved to the wrong class: ${computerCat.category.asset_class_label}`);
|
||||||
|
}
|
||||||
|
// Re-point the same category to the casino class by id; the label must follow the id, not the number.
|
||||||
|
const repointed = await request(`/api/asset-categories/${computerCat.category.id}`, {
|
||||||
|
method: 'PUT', headers: auth, body: JSON.stringify({ asset_class_id: casinoClass.id })
|
||||||
|
});
|
||||||
|
if (!/Casino/.test(repointed.category.asset_class_label) || repointed.category.asset_class_id !== casinoClass.id) {
|
||||||
|
throw new Error('Re-pointing category by id did not resolve to the casino class');
|
||||||
|
}
|
||||||
|
// A manual number with no id stays unlinked (asset_class_id null) but keeps the number.
|
||||||
|
const manualClassCat = await request(`/api/asset-categories/${computerCat.category.id}`, {
|
||||||
|
method: 'PUT', headers: auth, body: JSON.stringify({ asset_class_id: null, asset_class_number: '99.99' })
|
||||||
|
});
|
||||||
|
if (manualClassCat.category.asset_class_id !== null || manualClassCat.category.asset_class_number !== '99.99') {
|
||||||
|
throw new Error('Manual class number override did not clear the id link');
|
||||||
|
}
|
||||||
|
|
||||||
// Asset ID templates: pattern, preview, assign to a category, auto-increment on asset creation.
|
// Asset ID templates: pattern, preview, assign to a category, auto-increment on asset creation.
|
||||||
const idTemplate = await request('/api/asset-id-templates', {
|
const idTemplate = await request('/api/asset-id-templates', {
|
||||||
method: 'POST', headers: auth, body: JSON.stringify({ name: `Tag ${suffix}`, pattern: 'T-A#####', next_number: 5 })
|
method: 'POST', headers: auth, body: JSON.stringify({ name: `Tag ${suffix}`, pattern: 'T-A#####', next_number: 5 })
|
||||||
@@ -603,7 +633,7 @@ async function main() {
|
|||||||
headers: auth,
|
headers: auth,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
smtp_host: 'smtp.example.com', smtp_port: 587, smtp_secure: false,
|
smtp_host: 'smtp.example.com', smtp_port: 587, smtp_secure: false,
|
||||||
smtp_user: 'mailer@example.com', smtp_password: 'secret', smtp_from: 'MixedAssets <no-reply@example.com>',
|
smtp_user: 'mailer@example.com', smtp_password: 'secret', smtp_from: 'DepreCore <no-reply@example.com>',
|
||||||
alerts_enabled: false, alerts_lead_days: 45, alerts_recipients: 'ops@example.com, finance@example.com'
|
alerts_enabled: false, alerts_lead_days: 45, alerts_recipients: 'ops@example.com, finance@example.com'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -743,6 +773,108 @@ async function main() {
|
|||||||
throw new Error('PM settings did not persist');
|
throw new Error('PM settings did not persist');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Dynamic PM scheduling: usage meters + lifespan wear curve --------------
|
||||||
|
// Turn on both dynamic signals.
|
||||||
|
await request('/api/pm-settings', {
|
||||||
|
method: 'PUT', headers: auth,
|
||||||
|
body: JSON.stringify({ pm_usage_tracking_enabled: true, pm_lifespan_curve_enabled: true, pm_lifespan_min_factor: 0.5, pm_lifespan_curve_exponent: 1 })
|
||||||
|
});
|
||||||
|
const dynSettings = await request('/api/pm-settings', { headers: auth });
|
||||||
|
if (!dynSettings.settings.pm_usage_tracking_enabled || !dynSettings.settings.pm_lifespan_curve_enabled) {
|
||||||
|
throw new Error('Dynamic PM settings did not persist');
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plan that tracks a 500-unit meter (e.g. operating hours).
|
||||||
|
const meterPlan = await request('/api/pm-plans', {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: `Usage Plan ${suffix}`, frequency_value: 12, frequency_unit: 'months',
|
||||||
|
meters: [{ label: 'Operating Hours', unit: 'hrs', usage_interval: 500 }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!meterPlan.plan.meters || meterPlan.plan.meters.length !== 1 || Number(meterPlan.plan.meters[0].usage_interval) !== 500) {
|
||||||
|
throw new Error('Plan meter was not stored');
|
||||||
|
}
|
||||||
|
|
||||||
|
// A brand-new asset so the lifespan curve does NOT compress yet.
|
||||||
|
const youngAsset = await request('/api/assets', {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({ asset_id: `PM-YOUNG-${suffix}`, description: 'Young pump', category: 'Computer Equipment', acquisition_cost: 1000, in_service_date: new Date().toISOString().slice(0, 10), useful_life_months: 60 })
|
||||||
|
});
|
||||||
|
const youngId = youngAsset.asset.id;
|
||||||
|
const dueIn12mo = await request(`/api/assets/${youngId}/pm`, {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({ plan_id: meterPlan.plan.id, frequency_value: 12, frequency_unit: 'months', lifespan_adjust: true })
|
||||||
|
});
|
||||||
|
const youngApId = dueIn12mo.pm.id;
|
||||||
|
const youngMeterId = (await request(`/api/assets/${youngId}/pm`, { headers: auth })).pm.find((p) => p.id === youngApId).meters[0].id;
|
||||||
|
|
||||||
|
// Complete with a baseline reading; threshold resets to baseline + 500.
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const baseDue = (await request(`/api/assets/${youngId}/pm/${youngApId}/complete`, {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({ signature: pngDataUrl, completed_at: today, meter_readings: [{ asset_pm_meter_id: youngMeterId, reading: 1000 }] })
|
||||||
|
})).pm.next_due_date;
|
||||||
|
// A young asset on a 12-month calendar with no usage rate yet → ~12 months out (curve barely moves it).
|
||||||
|
if (!baseDue || baseDue <= today) throw new Error('Composite next-due did not advance after completion');
|
||||||
|
|
||||||
|
// Log a reading 10 days later implying ~30 hrs/day → reaches 1500 (the threshold) well before 12 months.
|
||||||
|
const tenDays = new Date(Date.now() + 10 * 86400000).toISOString().slice(0, 10);
|
||||||
|
const afterReading = await request(`/api/assets/${youngId}/pm/${youngApId}/readings`, {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({ asset_pm_meter_id: youngMeterId, reading: 1300, reading_date: tenDays })
|
||||||
|
});
|
||||||
|
if (!(afterReading.pm.next_due_date < baseDue)) {
|
||||||
|
throw new Error('Fast usage rate did not pull the next-due date in ahead of the calendar date');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the meter past its threshold → a usage-driven overdue PM alert.
|
||||||
|
await request(`/api/assets/${youngId}/pm/${youngApId}/readings`, {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({ asset_pm_meter_id: youngMeterId, reading: 1600, reading_date: tenDays })
|
||||||
|
});
|
||||||
|
const usageScan = await request('/api/alerts/scan', { method: 'POST', headers: auth });
|
||||||
|
const usageAlert = usageScan.alerts.find((a) => a.reference_id === youngApId && /Usage threshold/.test(a.message || ''));
|
||||||
|
if (!usageAlert) throw new Error('Usage-threshold PM alert was not raised');
|
||||||
|
|
||||||
|
// Lifespan curve: an end-of-life asset compresses the same interval vs the young one.
|
||||||
|
const oldDate = new Date(Date.now() - 58 * 30 * 86400000).toISOString().slice(0, 10); // ~58 months old of a 60-month life
|
||||||
|
const oldAsset = await request('/api/assets', {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({ asset_id: `PM-OLD-${suffix}`, description: 'Old pump', category: 'Computer Equipment', acquisition_cost: 1000, in_service_date: oldDate, useful_life_months: 60 })
|
||||||
|
});
|
||||||
|
const oldAttach = await request(`/api/assets/${oldAsset.asset.id}/pm`, {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({ plan_id: meterPlan.plan.id, frequency_value: 12, frequency_unit: 'months', lifespan_adjust: true })
|
||||||
|
});
|
||||||
|
const oldMeterId = (await request(`/api/assets/${oldAsset.asset.id}/pm`, { headers: auth })).pm.find((p) => p.id === oldAttach.pm.id).meters[0].id;
|
||||||
|
const oldDue = (await request(`/api/assets/${oldAsset.asset.id}/pm/${oldAttach.pm.id}/complete`, {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({ signature: pngDataUrl, completed_at: today, meter_readings: [{ asset_pm_meter_id: oldMeterId, reading: 1000 }] })
|
||||||
|
})).pm.next_due_date;
|
||||||
|
if (!(oldDue < baseDue)) {
|
||||||
|
throw new Error('Lifespan curve did not compress the interval for an end-of-life asset');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nightly recompute job: persist a manually-stale next_due_date, then sweep it back to "today"
|
||||||
|
// because the meter is already past its threshold (usage projection → today).
|
||||||
|
await request(`/api/assets/${youngId}/pm/${youngApId}`, {
|
||||||
|
method: 'PUT', headers: auth, body: JSON.stringify({ next_due_date: '2099-01-01' })
|
||||||
|
});
|
||||||
|
const recompute = await request('/api/pm-recompute', { method: 'POST', headers: auth, body: JSON.stringify({}) });
|
||||||
|
if (!recompute.result || recompute.result.updated < 1) throw new Error('Manual recompute did not update any schedules');
|
||||||
|
if (!recompute.settings.pm_last_recompute_at) throw new Error('Recompute did not stamp last-run time');
|
||||||
|
const sweptSchedule = (await request(`/api/assets/${youngId}/pm`, { headers: auth })).pm.find((p) => p.id === youngApId);
|
||||||
|
if (sweptSchedule.next_due_date >= '2099-01-01') {
|
||||||
|
throw new Error('Recompute did not refresh the stale projected due-date');
|
||||||
|
}
|
||||||
|
// Recompute settings persist.
|
||||||
|
await request('/api/pm-settings', { method: 'PUT', headers: auth, body: JSON.stringify({ pm_recompute_enabled: true, pm_recompute_hour: 3 }) });
|
||||||
|
const recomputeSettings = await request('/api/pm-settings', { headers: auth });
|
||||||
|
if (!recomputeSettings.settings.pm_recompute_enabled || recomputeSettings.settings.pm_recompute_hour !== 3) {
|
||||||
|
throw new Error('Recompute settings did not persist');
|
||||||
|
}
|
||||||
|
|
||||||
// Contacts directory: typed records, type-specific fields, filtering, notes.
|
// Contacts directory: typed records, type-specific fields, filtering, notes.
|
||||||
const vendorContact = await request('/api/contacts', {
|
const vendorContact = await request('/api/contacts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -949,6 +1081,61 @@ async function main() {
|
|||||||
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
|
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
|
||||||
if (ezY1 !== 528000) throw new Error(`Enterprise Zone §179 increase should allow 520k (year-1 528000), got ${ezY1}`);
|
if (ezY1 !== 528000) throw new Error(`Enterprise Zone §179 increase should allow 520k (year-1 528000), got ${ezY1}`);
|
||||||
|
|
||||||
|
// Gulf Opportunity (GO) Zone: combines a 50% §168 allowance AND a +$100,000 §179 increase.
|
||||||
|
const go = ezList.zones.find((z) => z.code === 'go_zone');
|
||||||
|
if (!go || go.allowance_percent !== 50 || go.section_179_increase !== 100000 || go.section_179_threshold_increase !== 600000) {
|
||||||
|
throw new Error('GO Zone was not seeded with the 50% allowance + §179 increase/threshold');
|
||||||
|
}
|
||||||
|
// 2007 base §179 limit is $125,000; GO Zone raises it to $225,000, so the entered $200,000 §179
|
||||||
|
// applies in full. Then the 50% §168 allowance hits the post-§179 basis, plus first-year SL.
|
||||||
|
// §179 200000 + bonus 50%×(600000−200000)=200000 + SL ((600000−200000−200000)/5)×0.5=20000 = 420000.
|
||||||
|
const goAsset = await request('/api/assets', {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({
|
||||||
|
asset_id: `GO-${suffix}`, description: 'GO Zone equipment', category: 'Computer Equipment',
|
||||||
|
acquisition_cost: 600000, in_service_date: '2007-06-01', useful_life_months: 60, special_zone: 'go_zone',
|
||||||
|
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, section_179_amount: 200000, cost: 600000 }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const goY1 = round((await request(`/api/assets/${goAsset.asset.id}/depreciation?startYear=2007&endYear=2007`, { headers: auth }))
|
||||||
|
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
|
||||||
|
if (goY1 !== 420000) throw new Error(`GO Zone year-1 (50% allowance + §179 increase) should be 420000, got ${goY1}`);
|
||||||
|
|
||||||
|
// A GO Zone asset placed in service in 2010 is past the §179 window (ends 2008) but still within
|
||||||
|
// the §168 allowance window (to 2011). It must get the 50% allowance but NOT the §179 increase:
|
||||||
|
// entered $550,000 §179 is capped at the 2010 base limit of $500,000 (not $600,000).
|
||||||
|
// §179 500000 + bonus 50%×(600000−500000)=50000 + SL ((600000−500000−50000)/5)×0.5=5000 = 555000.
|
||||||
|
const goLate = await request('/api/assets', {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({
|
||||||
|
asset_id: `GOL-${suffix}`, description: 'GO Zone late', category: 'Computer Equipment',
|
||||||
|
acquisition_cost: 600000, in_service_date: '2010-06-01', useful_life_months: 60, special_zone: 'go_zone',
|
||||||
|
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, section_179_amount: 550000, cost: 600000 }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const goLateY1 = round((await request(`/api/assets/${goLate.asset.id}/depreciation?startYear=2010&endYear=2010`, { headers: auth }))
|
||||||
|
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
|
||||||
|
if (goLateY1 !== 555000) throw new Error(`GO Zone 2010 asset: §179 increase expired but allowance applies (year-1 555000), got ${goLateY1}`);
|
||||||
|
|
||||||
|
// Kansas Disaster Zone (Qualified Recovery Assistance): 50% §168 allowance + $100,000 §179 increase,
|
||||||
|
// §168 and §179 windows coincide (2007-05-05 to 2008-12-31). Same shape as the GO Zone 2007 case.
|
||||||
|
const kz = ezList.zones.find((z) => z.code === 'kansas_disaster');
|
||||||
|
if (!kz || kz.allowance_percent !== 50 || kz.section_179_increase !== 100000 || kz.section_179_threshold_increase !== 600000) {
|
||||||
|
throw new Error('Kansas Disaster Zone was not seeded with the 50% allowance + §179 increase/threshold');
|
||||||
|
}
|
||||||
|
const kzAsset = await request('/api/assets', {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({
|
||||||
|
asset_id: `KZ-${suffix}`, description: 'Kansas recovery equipment', category: 'Computer Equipment',
|
||||||
|
acquisition_cost: 600000, in_service_date: '2007-06-01', useful_life_months: 60, special_zone: 'kansas_disaster',
|
||||||
|
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, section_179_amount: 200000, cost: 600000 }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const kzY1 = round((await request(`/api/assets/${kzAsset.asset.id}/depreciation?startYear=2007&endYear=2007`, { headers: auth }))
|
||||||
|
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
|
||||||
|
// §179 200000 (limit raised 125k→225k) + 50% allowance 200000 + SL 20000 = 420000.
|
||||||
|
if (kzY1 !== 420000) throw new Error(`Kansas Disaster Zone year-1 (50% allowance + §179 increase) should be 420000, got ${kzY1}`);
|
||||||
|
|
||||||
// Disposal: preview gain/loss, then record a full disposal.
|
// Disposal: preview gain/loss, then record a full disposal.
|
||||||
const disposalAsset = await request('/api/assets', {
|
const disposalAsset = await request('/api/assets', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -989,6 +1176,65 @@ async function main() {
|
|||||||
});
|
});
|
||||||
if (!adjustment.adjustment?.id) throw new Error('Adjustment was not recorded');
|
if (!adjustment.adjustment?.id) throw new Error('Adjustment was not recorded');
|
||||||
|
|
||||||
|
// Revaluation adjustment (feeds the revaluation reports)
|
||||||
|
const reval = await request(`/api/assets/${disposalAsset.asset.id}/adjustments`, {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({ type: 'revaluation', amount: 800, book_type: 'GAAP', reason: 'Market revaluation' })
|
||||||
|
});
|
||||||
|
if (!reval.adjustment?.id) throw new Error('Revaluation adjustment was not recorded');
|
||||||
|
|
||||||
|
// Abandonment disposal (counts as a write-off) on a fresh asset
|
||||||
|
const abandonAsset = await request('/api/assets', {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({
|
||||||
|
asset_id: `WO-${suffix}`, description: 'Abandoned unit', category: 'Computer Equipment',
|
||||||
|
acquisition_cost: 5000, acquired_date: '2023-01-01', in_service_date: '2023-01-01', useful_life_months: 60, depreciation_method: 'straight_line'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const abandoned = await request(`/api/assets/${abandonAsset.asset.id}/disposals`, {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({ book_type: 'GAAP', disposal_date: '2026-06-15', disposal_price: 0, disposal_expense: 0, method: 'abandonment' })
|
||||||
|
});
|
||||||
|
if (!abandoned.disposal?.id) throw new Error('Abandonment disposal was not recorded');
|
||||||
|
|
||||||
|
// New report families: catalog presence, summaries, journals, transactions, history, dossier.
|
||||||
|
const newCatalogKeys = (await request('/api/reports/catalog', { headers: auth })).reports.map((r) => r.key);
|
||||||
|
for (const key of ['asset_journal', 'fixed_asset_journal', 'journal_revaluation', 'journal_writeoff', 'txn_revaluations',
|
||||||
|
'txn_writeoffs', 'txn_purchases', 'txn_disposals', 'asset_summary', 'depreciation_summary', 'ytd_depreciation_summary',
|
||||||
|
'purchase_summary', 'disposal_summary', 'revaluation_summary', 'writeoff_summary', 'asset_history', 'future_depreciation',
|
||||||
|
'journal_depreciation_monthly', 'journal_disposals_monthly']) {
|
||||||
|
if (!newCatalogKeys.includes(key)) throw new Error(`Report catalog missing ${key}`);
|
||||||
|
}
|
||||||
|
const revalSummary = await runReportApi('revaluation_summary', { year: 2026 });
|
||||||
|
if (!revalSummary.report.rows.some((r) => /revaluation/i.test(r.type))) throw new Error('Revaluation summary did not include the revaluation');
|
||||||
|
const writeoffTxn = await runReportApi('txn_writeoffs', { year: 2026 });
|
||||||
|
const woSources = writeoffTxn.report.rows.map((r) => r.source);
|
||||||
|
if (!woSources.includes('Impairment') || !woSources.includes('Abandonment')) {
|
||||||
|
throw new Error('Write-off transactions must include both impairment and abandonment');
|
||||||
|
}
|
||||||
|
const woJournal = await runReportApi('journal_writeoff', { year: 2026 });
|
||||||
|
if (Math.round(woJournal.report.totals.debit) !== Math.round(woJournal.report.totals.credit) || woJournal.report.totals.debit <= 0) {
|
||||||
|
throw new Error('Write-off journal entry must be balanced and non-zero');
|
||||||
|
}
|
||||||
|
const assetSummaryReport = await runReportApi('asset_summary', { book: 'GAAP', year: 2026 });
|
||||||
|
if (!assetSummaryReport.report.rows.length || typeof assetSummaryReport.report.totals.count !== 'number') {
|
||||||
|
throw new Error('Asset summary did not aggregate by category');
|
||||||
|
}
|
||||||
|
const historyReport = await runReportApi('asset_history', { asset_id: disposalAsset.asset.id });
|
||||||
|
if (!historyReport.report.rows.some((r) => /disposal/i.test(r.event))) throw new Error('Asset history did not include the disposal event');
|
||||||
|
const journalReport = await runReportApi('asset_journal', { asset_id: disposalAsset.asset.id });
|
||||||
|
if (journalReport.report.meta?.layout !== 'dossier' || !journalReport.report.meta.sections.length) {
|
||||||
|
throw new Error('Asset journal did not return a dossier with sections');
|
||||||
|
}
|
||||||
|
const journalPdf = await fetch(`${baseUrl}/api/reports/export`, {
|
||||||
|
method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type: 'asset_journal', options: { asset_id: disposalAsset.asset.id }, format: 'pdf' })
|
||||||
|
});
|
||||||
|
const journalPdfBuf = Buffer.from(await journalPdf.arrayBuffer());
|
||||||
|
if (!journalPdf.ok || journalPdf.headers.get('content-type') !== 'application/pdf' || journalPdfBuf.length < 500) {
|
||||||
|
throw new Error('Asset journal PDF export failed');
|
||||||
|
}
|
||||||
|
|
||||||
// Lease accounting + amortization schedule
|
// Lease accounting + amortization schedule
|
||||||
const lease = await request('/api/leases', {
|
const lease = await request('/api/leases', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1124,7 +1370,7 @@ async function main() {
|
|||||||
const testHit = webhookHits.find((h) => h.body.event === 'test');
|
const testHit = webhookHits.find((h) => h.body.event === 'test');
|
||||||
if (!testHit) throw new Error('Webhook receiver did not get the test event');
|
if (!testHit) throw new Error('Webhook receiver did not get the test event');
|
||||||
const expectedSig = `sha256=${crypto.createHmac('sha256', hookSecret).update(testHit.raw).digest('hex')}`;
|
const expectedSig = `sha256=${crypto.createHmac('sha256', hookSecret).update(testHit.raw).digest('hex')}`;
|
||||||
if (testHit.headers['x-mixedassets-signature'] !== expectedSig) {
|
if (testHit.headers['x-deprecore-signature'] !== expectedSig) {
|
||||||
throw new Error('Webhook HMAC signature was missing or incorrect');
|
throw new Error('Webhook HMAC signature was missing or incorrect');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,7 +1418,7 @@ async function main() {
|
|||||||
const savedSn = await request('/api/servicenow/settings', {
|
const savedSn = await request('/api/servicenow/settings', {
|
||||||
method: 'PUT', headers: auth,
|
method: 'PUT', headers: auth,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
servicenow_instance_url: snUrl, servicenow_username: 'svc_mixedassets', servicenow_password: 'snpass',
|
servicenow_instance_url: snUrl, servicenow_username: 'svc_deprecore', servicenow_password: 'snpass',
|
||||||
servicenow_ticket_enabled: true, servicenow_cmdb_table: 'cmdb_ci_hardware'
|
servicenow_ticket_enabled: true, servicenow_cmdb_table: 'cmdb_ci_hardware'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -1226,6 +1472,109 @@ async function main() {
|
|||||||
await new Promise((resolve) => snServer.close(resolve));
|
await new Promise((resolve) => snServer.close(resolve));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Password access control, lockout & audit trail -----------------------
|
||||||
|
// Helper that returns status + body instead of throwing, so we can assert on rejections.
|
||||||
|
async function rawRequest(p, options = {}) {
|
||||||
|
const response = await fetch(`${baseUrl}${p}`, {
|
||||||
|
...options,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
||||||
|
});
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
return { status: response.status, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tighten the policy: long, complex, short reuse window, fast lockout. expiry_days 0 keeps the
|
||||||
|
// admin session from being forced to change mid-test.
|
||||||
|
const policyResp = await request('/api/password-policy', {
|
||||||
|
method: 'PUT', headers: auth,
|
||||||
|
body: JSON.stringify({ policy: {
|
||||||
|
min_length: 14, require_upper: true, require_lower: true, require_number: true, require_symbol: true,
|
||||||
|
disallow_identifiers: true, history_count: 3, expiry_days: 0, min_age_hours: 0,
|
||||||
|
lockout_threshold: 3, lockout_window_minutes: 5
|
||||||
|
} })
|
||||||
|
});
|
||||||
|
if (policyResp.policy.min_length !== 14 || policyResp.policy.lockout_threshold !== 3) {
|
||||||
|
throw new Error('Password policy did not persist');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(policyResp.rules) || !policyResp.rules.length) throw new Error('Policy rules were not described');
|
||||||
|
|
||||||
|
const secEmail = `sec-${suffix}@example.com`;
|
||||||
|
const strongPassword = 'Str0ng!Passphrase#2024';
|
||||||
|
|
||||||
|
// A weak password is rejected by the policy.
|
||||||
|
const weak = await rawRequest('/api/users', {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({ name: 'Pat Quinn', email: secEmail, password: 'short', role: 'viewer' })
|
||||||
|
});
|
||||||
|
if (weak.status !== 400) throw new Error(`Weak password should be rejected (got ${weak.status})`);
|
||||||
|
|
||||||
|
// A compliant password succeeds.
|
||||||
|
const created = await request('/api/users', {
|
||||||
|
method: 'POST', headers: auth,
|
||||||
|
body: JSON.stringify({ name: 'Pat Quinn', email: secEmail, password: strongPassword, role: 'viewer' })
|
||||||
|
});
|
||||||
|
const secUserId = created.user.id;
|
||||||
|
|
||||||
|
// The new user can sign in (admin-set real password does not force a change).
|
||||||
|
const secLogin = await request('/api/auth/login', {
|
||||||
|
method: 'POST', body: JSON.stringify({ email: secEmail, password: strongPassword })
|
||||||
|
});
|
||||||
|
if (secLogin.mustChangePassword) throw new Error('Admin-set password should not force a change');
|
||||||
|
const secAuth = { Authorization: `Bearer ${secLogin.token}` };
|
||||||
|
|
||||||
|
// Reusing the current password via self-service change is blocked by password history.
|
||||||
|
const reuse = await rawRequest('/api/profile/password', {
|
||||||
|
method: 'PUT', headers: secAuth,
|
||||||
|
body: JSON.stringify({ current_password: strongPassword, new_password: strongPassword })
|
||||||
|
});
|
||||||
|
if (reuse.status !== 400) throw new Error(`Password reuse should be rejected (got ${reuse.status})`);
|
||||||
|
|
||||||
|
// A fresh compliant password is accepted.
|
||||||
|
const newPassword = 'Rotated!Secret#2025';
|
||||||
|
const changed = await rawRequest('/api/profile/password', {
|
||||||
|
method: 'PUT', headers: secAuth,
|
||||||
|
body: JSON.stringify({ current_password: strongPassword, new_password: newPassword })
|
||||||
|
});
|
||||||
|
if (changed.status !== 200) throw new Error(`Valid password change failed (got ${changed.status} ${JSON.stringify(changed.body)})`);
|
||||||
|
|
||||||
|
// Account lockout: the threshold is 3, so the third bad attempt locks the account.
|
||||||
|
let lockedStatus = null;
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
const attempt = await rawRequest('/api/auth/login', {
|
||||||
|
method: 'POST', body: JSON.stringify({ email: secEmail, password: 'wrong-password' })
|
||||||
|
});
|
||||||
|
lockedStatus = attempt.status;
|
||||||
|
}
|
||||||
|
if (lockedStatus !== 423) throw new Error(`Account should lock after 3 failures (got ${lockedStatus})`);
|
||||||
|
|
||||||
|
// Even the correct password is refused while locked.
|
||||||
|
const whileLocked = await rawRequest('/api/auth/login', {
|
||||||
|
method: 'POST', body: JSON.stringify({ email: secEmail, password: newPassword })
|
||||||
|
});
|
||||||
|
if (whileLocked.status !== 423) throw new Error('Correct password should be refused while locked');
|
||||||
|
|
||||||
|
// An admin can unlock; the user can then sign in again.
|
||||||
|
await request(`/api/users/${secUserId}/unlock`, { method: 'POST', headers: auth });
|
||||||
|
const afterUnlock = await rawRequest('/api/auth/login', {
|
||||||
|
method: 'POST', body: JSON.stringify({ email: secEmail, password: newPassword })
|
||||||
|
});
|
||||||
|
if (afterUnlock.status !== 200) throw new Error(`Login after unlock failed (got ${afterUnlock.status})`);
|
||||||
|
|
||||||
|
// Audit trail: failed logins and the lockout were recorded and are queryable.
|
||||||
|
const failedLogs = await request('/api/audit-logs?action=login_failed', { headers: auth });
|
||||||
|
if (failedLogs.total < 3) throw new Error(`Expected >=3 login_failed audit rows, found ${failedLogs.total}`);
|
||||||
|
if (!failedLogs.facets || !failedLogs.facets.actions.includes('login_failed')) {
|
||||||
|
throw new Error('Audit facets did not include login_failed');
|
||||||
|
}
|
||||||
|
const lockLogs = await request('/api/audit-logs?action=account_locked', { headers: auth });
|
||||||
|
if (lockLogs.total < 1) throw new Error('account_locked event was not audited');
|
||||||
|
|
||||||
|
// CSV export returns text/csv with a header row.
|
||||||
|
const csvResp = await fetch(`${baseUrl}/api/audit-logs/export?action=login_failed`, { headers: auth });
|
||||||
|
if (!(csvResp.headers.get('content-type') || '').includes('text/csv')) throw new Error('Audit export was not text/csv');
|
||||||
|
const csvText = await csvResp.text();
|
||||||
|
if (!csvText.startsWith('id,timestamp,actor,action')) throw new Error('Audit CSV header was malformed');
|
||||||
|
|
||||||
console.log('Smoke test passed');
|
console.log('Smoke test passed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
132
src/App.vue
132
src/App.vue
@@ -3,13 +3,17 @@
|
|||||||
<v-app :theme="activeTheme">
|
<v-app :theme="activeTheme">
|
||||||
<LoginView v-if="!token" :error="error" :form="loginForm" :loading="loading" @login="login" />
|
<LoginView v-if="!token" :error="error" :form="loginForm" :loading="loading" @login="login" />
|
||||||
|
|
||||||
|
<div v-else-if="forcePasswordChange" class="forced-change-screen">
|
||||||
|
<ChangePasswordCard :token="token" forced standalone @changed="completeForcedChange" @cancel="logout" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="app-shell">
|
<div v-else class="app-shell">
|
||||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||||
<v-navigation-drawer class="rail" :width="254" :rail="navCollapsed" :rail-width="76" permanent aria-label="Primary navigation">
|
<v-navigation-drawer class="rail" :width="254" :rail="navCollapsed" :rail-width="76" permanent aria-label="Primary navigation">
|
||||||
<div class="brand-lockup">
|
<div class="brand-lockup">
|
||||||
<template v-if="!navCollapsed">
|
<template v-if="!navCollapsed">
|
||||||
<span class="brand-mark">MA</span>
|
<span class="brand-mark">MA</span>
|
||||||
<span class="brand-name">MixedAssets</span>
|
<span class="brand-name">DepreCore</span>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn icon="mdi-chevron-left" size="small" variant="text" title="Collapse menu" aria-label="Collapse navigation menu" @click="toggleNav" />
|
<v-btn icon="mdi-chevron-left" size="small" variant="text" title="Collapse menu" aria-label="Collapse navigation menu" @click="toggleNav" />
|
||||||
</template>
|
</template>
|
||||||
@@ -68,6 +72,7 @@
|
|||||||
:theme-items="themeItems"
|
:theme-items="themeItems"
|
||||||
:title="currentTitle"
|
:title="currentTitle"
|
||||||
:user="user"
|
:user="user"
|
||||||
|
@change-password="changePasswordDialog = true"
|
||||||
@logout="logout"
|
@logout="logout"
|
||||||
@open-help="helpDialog = true"
|
@open-help="helpDialog = true"
|
||||||
@save-preferences="saveProfilePreferences"
|
@save-preferences="saveProfilePreferences"
|
||||||
@@ -148,7 +153,7 @@
|
|||||||
<TaxRulesView
|
<TaxRulesView
|
||||||
v-if="activeView === 'tax-rules'"
|
v-if="activeView === 'tax-rules'"
|
||||||
:token="token"
|
:token="token"
|
||||||
:dark="activeTheme === 'mixedAssetsDark'"
|
:dark="activeTheme === 'depreCoreDark'"
|
||||||
@updated="reloadTaxRules"
|
@updated="reloadTaxRules"
|
||||||
/>
|
/>
|
||||||
<BooksView
|
<BooksView
|
||||||
@@ -197,6 +202,7 @@
|
|||||||
@save-workday="saveWorkdayConnection"
|
@save-workday="saveWorkdayConnection"
|
||||||
@sync-workday="syncWorkday"
|
@sync-workday="syncWorkday"
|
||||||
@update-user="updateUser"
|
@update-user="updateUser"
|
||||||
|
@unlock-user="unlockUser"
|
||||||
/>
|
/>
|
||||||
</v-main>
|
</v-main>
|
||||||
|
|
||||||
@@ -237,6 +243,7 @@
|
|||||||
@attach-pm="attachPm"
|
@attach-pm="attachPm"
|
||||||
@detach-pm="detachPm"
|
@detach-pm="detachPm"
|
||||||
@complete-pm="completePm"
|
@complete-pm="completePm"
|
||||||
|
@log-reading="logReading"
|
||||||
@view-completion="viewCompletion"
|
@view-completion="viewCompletion"
|
||||||
@add-adjustment="addAdjustment"
|
@add-adjustment="addAdjustment"
|
||||||
@add-note="addNote"
|
@add-note="addNote"
|
||||||
@@ -276,6 +283,14 @@
|
|||||||
<PmCompletionDialog v-model="completionDialog" :token="token" :completion-id="completionId" />
|
<PmCompletionDialog v-model="completionDialog" :token="token" :completion-id="completionId" />
|
||||||
|
|
||||||
<UserManual v-model="helpDialog" />
|
<UserManual v-model="helpDialog" />
|
||||||
|
|
||||||
|
<v-dialog v-model="changePasswordDialog" max-width="480">
|
||||||
|
<ChangePasswordCard :token="token" @changed="changePasswordDialog = false" @cancel="changePasswordDialog = false" />
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="5000" location="top end">
|
||||||
|
{{ snackbar.text }}
|
||||||
|
</v-snackbar>
|
||||||
</div>
|
</div>
|
||||||
</v-app>
|
</v-app>
|
||||||
</fluent-design-system-provider>
|
</fluent-design-system-provider>
|
||||||
@@ -290,6 +305,7 @@ import ContactsView from './views/ContactsView.vue';
|
|||||||
import AssetsView from './views/AssetsView.vue';
|
import AssetsView from './views/AssetsView.vue';
|
||||||
import DashboardView from './views/DashboardView.vue';
|
import DashboardView from './views/DashboardView.vue';
|
||||||
import LoginView from './views/LoginView.vue';
|
import LoginView from './views/LoginView.vue';
|
||||||
|
import ChangePasswordCard from './components/ChangePasswordCard.vue';
|
||||||
import PartsView from './views/PartsView.vue';
|
import PartsView from './views/PartsView.vue';
|
||||||
import PmView from './views/PmView.vue';
|
import PmView from './views/PmView.vue';
|
||||||
import ReportsView from './views/ReportsView.vue';
|
import ReportsView from './views/ReportsView.vue';
|
||||||
@@ -322,6 +338,7 @@ export default {
|
|||||||
AssetDrawer,
|
AssetDrawer,
|
||||||
AssetsView,
|
AssetsView,
|
||||||
BooksView,
|
BooksView,
|
||||||
|
ChangePasswordCard,
|
||||||
ContactsView,
|
ContactsView,
|
||||||
DashboardView,
|
DashboardView,
|
||||||
LabelSheet,
|
LabelSheet,
|
||||||
@@ -378,26 +395,29 @@ export default {
|
|||||||
entities: [],
|
entities: [],
|
||||||
error: '',
|
error: '',
|
||||||
helpDialog: false,
|
helpDialog: false,
|
||||||
|
changePasswordDialog: false,
|
||||||
|
snackbar: { show: false, text: '', color: 'error' },
|
||||||
labelDialog: false,
|
labelDialog: false,
|
||||||
labelAssetsOverride: null,
|
labelAssetsOverride: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
loginForm: { email: 'admin@mixedassets.local', password: 'ChangeMe123!' },
|
forcePasswordChange: false,
|
||||||
|
loginForm: { email: 'admin@deprecore.local', password: 'ChangeMe123!' },
|
||||||
massChanges: { status: null, location: '', department: '' },
|
massChanges: { status: null, location: '', department: '' },
|
||||||
massDialog: false,
|
massDialog: false,
|
||||||
navCollapsed: localStorage.getItem('mixedassets.navCollapsed') === 'true',
|
navCollapsed: localStorage.getItem('deprecore.navCollapsed') === 'true',
|
||||||
navItems,
|
navItems,
|
||||||
preferenceSaving: false,
|
preferenceSaving: false,
|
||||||
profilePreferences: {
|
profilePreferences: {
|
||||||
theme: localStorage.getItem('mixedassets.theme') || 'mixedAssetsDark',
|
theme: localStorage.getItem('deprecore.theme') || 'depreCoreDark',
|
||||||
fontScale: localStorage.getItem('mixedassets.fontScale') || 'normal',
|
fontScale: localStorage.getItem('deprecore.fontScale') || 'normal',
|
||||||
accent: localStorage.getItem('mixedassets.accent') || '',
|
accent: localStorage.getItem('deprecore.accent') || '',
|
||||||
contrast: localStorage.getItem('mixedassets.contrast') === 'true'
|
contrast: localStorage.getItem('deprecore.contrast') === 'true'
|
||||||
},
|
},
|
||||||
references: [],
|
references: [],
|
||||||
workLocations: [],
|
workLocations: [],
|
||||||
vendorContacts: [],
|
vendorContacts: [],
|
||||||
depreciationZones: [],
|
depreciationZones: [],
|
||||||
userCapabilities: JSON.parse(localStorage.getItem('mixedassets.capabilities') || '[]'),
|
userCapabilities: JSON.parse(localStorage.getItem('deprecore.capabilities') || '[]'),
|
||||||
capabilityCatalog: [],
|
capabilityCatalog: [],
|
||||||
roles: [],
|
roles: [],
|
||||||
saving: false,
|
saving: false,
|
||||||
@@ -418,8 +438,8 @@ export default {
|
|||||||
},
|
},
|
||||||
templates: [],
|
templates: [],
|
||||||
themeItems,
|
themeItems,
|
||||||
token: localStorage.getItem('mixedassets.token') || '',
|
token: localStorage.getItem('deprecore.token') || '',
|
||||||
user: JSON.parse(localStorage.getItem('mixedassets.user') || 'null'),
|
user: JSON.parse(localStorage.getItem('deprecore.user') || 'null'),
|
||||||
userSaving: false,
|
userSaving: false,
|
||||||
users: [],
|
users: [],
|
||||||
workdayConnection: {},
|
workdayConnection: {},
|
||||||
@@ -503,6 +523,8 @@ export default {
|
|||||||
'tax-rules': 'Upload, edit, export, and activate depreciation rule sets',
|
'tax-rules': 'Upload, edit, export, and activate depreciation rule sets',
|
||||||
admin: 'Users, configuration, and integrations',
|
admin: 'Users, configuration, and integrations',
|
||||||
'admin-users': 'User accounts, teams, and role permissions',
|
'admin-users': 'User accounts, teams, and role permissions',
|
||||||
|
'admin-security': 'Password rules, account lockout, and complexity policy',
|
||||||
|
'admin-audit': 'Complete audit trail of all user activity, with export',
|
||||||
'admin-config': 'Application settings, maintenance defaults, and tax rule sets',
|
'admin-config': 'Application settings, maintenance defaults, and tax rule sets',
|
||||||
'admin-integrations': 'Email, webhooks, ServiceNow, and Workday connectors'
|
'admin-integrations': 'Email, webhooks, ServiceNow, and Workday connectors'
|
||||||
}[this.activeView];
|
}[this.activeView];
|
||||||
@@ -523,6 +545,8 @@ export default {
|
|||||||
'tax-rules': 'Tax rules',
|
'tax-rules': 'Tax rules',
|
||||||
admin: 'Configuration',
|
admin: 'Configuration',
|
||||||
'admin-users': 'Users & Teams',
|
'admin-users': 'Users & Teams',
|
||||||
|
'admin-security': 'Password & Security',
|
||||||
|
'admin-audit': 'Activity & Audit Trail',
|
||||||
'admin-config': 'Application Configuration',
|
'admin-config': 'Application Configuration',
|
||||||
'admin-integrations': 'Integrations'
|
'admin-integrations': 'Integrations'
|
||||||
}[this.activeView];
|
}[this.activeView];
|
||||||
@@ -539,7 +563,7 @@ export default {
|
|||||||
return this.assets;
|
return this.assets;
|
||||||
},
|
},
|
||||||
activeTheme() {
|
activeTheme() {
|
||||||
return this.profilePreferences.theme || 'mixedAssetsDark';
|
return this.profilePreferences.theme || 'depreCoreDark';
|
||||||
},
|
},
|
||||||
fluentLuminance() {
|
fluentLuminance() {
|
||||||
return darkThemes.includes(this.activeTheme) ? '0.12' : '1';
|
return darkThemes.includes(this.activeTheme) ? '0.12' : '1';
|
||||||
@@ -581,6 +605,8 @@ export default {
|
|||||||
books: ['finance.manage'],
|
books: ['finance.manage'],
|
||||||
'tax-rules': ['finance.manage'],
|
'tax-rules': ['finance.manage'],
|
||||||
'admin-users': ['admin.users'],
|
'admin-users': ['admin.users'],
|
||||||
|
'admin-security': ['admin.security'],
|
||||||
|
'admin-audit': ['admin.audit'],
|
||||||
'admin-config': ['admin.settings', 'config.manage'],
|
'admin-config': ['admin.settings', 'config.manage'],
|
||||||
'admin-integrations': ['admin.integrations']
|
'admin-integrations': ['admin.integrations']
|
||||||
};
|
};
|
||||||
@@ -624,11 +650,11 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.applyAppearance();
|
this.applyAppearance();
|
||||||
window.addEventListener('mixedassets:unauthorized', this.handleUnauthorized);
|
window.addEventListener('deprecore:unauthorized', this.handleUnauthorized);
|
||||||
if (this.token) this.loadInitial();
|
if (this.token) this.loadInitial();
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
window.removeEventListener('mixedassets:unauthorized', this.handleUnauthorized);
|
window.removeEventListener('deprecore:unauthorized', this.handleUnauthorized);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
currency,
|
currency,
|
||||||
@@ -643,7 +669,7 @@ export default {
|
|||||||
},
|
},
|
||||||
setCapabilities(capabilities) {
|
setCapabilities(capabilities) {
|
||||||
this.userCapabilities = Array.isArray(capabilities) ? capabilities : [];
|
this.userCapabilities = Array.isArray(capabilities) ? capabilities : [];
|
||||||
localStorage.setItem('mixedassets.capabilities', JSON.stringify(this.userCapabilities));
|
localStorage.setItem('deprecore.capabilities', JSON.stringify(this.userCapabilities));
|
||||||
},
|
},
|
||||||
// Apply accessibility/appearance preferences to the live DOM: base text size (scales
|
// Apply accessibility/appearance preferences to the live DOM: base text size (scales
|
||||||
// the whole UI via the root font-size), a custom accent colour, and a high-contrast boost.
|
// the whole UI via the root font-size), a custom accent colour, and a high-contrast boost.
|
||||||
@@ -677,10 +703,10 @@ export default {
|
|||||||
return lum > 0.6 ? '0,0,0' : '255,255,255';
|
return lum > 0.6 ? '0,0,0' : '255,255,255';
|
||||||
},
|
},
|
||||||
persistAppearanceLocal() {
|
persistAppearanceLocal() {
|
||||||
localStorage.setItem('mixedassets.theme', this.activeTheme);
|
localStorage.setItem('deprecore.theme', this.activeTheme);
|
||||||
localStorage.setItem('mixedassets.fontScale', this.profilePreferences.fontScale || 'normal');
|
localStorage.setItem('deprecore.fontScale', this.profilePreferences.fontScale || 'normal');
|
||||||
localStorage.setItem('mixedassets.accent', this.profilePreferences.accent || '');
|
localStorage.setItem('deprecore.accent', this.profilePreferences.accent || '');
|
||||||
localStorage.setItem('mixedassets.contrast', String(Boolean(this.profilePreferences.contrast)));
|
localStorage.setItem('deprecore.contrast', String(Boolean(this.profilePreferences.contrast)));
|
||||||
},
|
},
|
||||||
async api(path, options = {}) {
|
async api(path, options = {}) {
|
||||||
return apiRequest(path, this.token, options);
|
return apiRequest(path, this.token, options);
|
||||||
@@ -747,15 +773,30 @@ export default {
|
|||||||
this.teamNotice = error.message;
|
this.teamNotice = error.message;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
notify(text, color = 'error') {
|
||||||
|
this.snackbar = { show: true, text, color };
|
||||||
|
},
|
||||||
async createUser(user) {
|
async createUser(user) {
|
||||||
this.userSaving = true;
|
this.userSaving = true;
|
||||||
try {
|
try {
|
||||||
await this.api('/api/users', { method: 'POST', body: JSON.stringify(user) });
|
await this.api('/api/users', { method: 'POST', body: JSON.stringify(user) });
|
||||||
await this.loadAdmin();
|
await this.loadAdmin();
|
||||||
|
this.notify('User created.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
this.notify(error.message);
|
||||||
} finally {
|
} finally {
|
||||||
this.userSaving = false;
|
this.userSaving = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async unlockUser(account) {
|
||||||
|
try {
|
||||||
|
await this.api(`/api/users/${account.id}/unlock`, { method: 'POST' });
|
||||||
|
await this.loadAdmin();
|
||||||
|
this.notify(`${account.name} unlocked.`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
this.notify(error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
async editAsset(asset) {
|
async editAsset(asset) {
|
||||||
this.drawerError = '';
|
this.drawerError = '';
|
||||||
this.disposalPreview = null;
|
this.disposalPreview = null;
|
||||||
@@ -926,6 +967,19 @@ export default {
|
|||||||
await this.refreshActiveAsset(this.assetForm.id);
|
await this.refreshActiveAsset(this.assetForm.id);
|
||||||
await this.loadDashboard();
|
await this.loadDashboard();
|
||||||
},
|
},
|
||||||
|
async logReading(payload) {
|
||||||
|
const { apId, ...body } = payload;
|
||||||
|
try {
|
||||||
|
await this.api(`/api/assets/${this.assetForm.id}/pm/${apId}/readings`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
await this.refreshActiveAsset(this.assetForm.id);
|
||||||
|
this.notify('Reading logged.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
this.notify(error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
viewCompletion(id) {
|
viewCompletion(id) {
|
||||||
this.completionId = id;
|
this.completionId = id;
|
||||||
this.completionDialog = true;
|
this.completionDialog = true;
|
||||||
@@ -955,7 +1009,7 @@ export default {
|
|||||||
},
|
},
|
||||||
async exportAssets(format) {
|
async exportAssets(format) {
|
||||||
const blob = await (await this.api(`/api/export/assets?format=${format}`)).blob();
|
const blob = await (await this.api(`/api/export/assets?format=${format}`)).blob();
|
||||||
saveBlob(blob, `mixedassets-assets.${format}`);
|
saveBlob(blob, `deprecore-assets.${format}`);
|
||||||
},
|
},
|
||||||
async loadReferences() {
|
async loadReferences() {
|
||||||
const data = await (await this.api('/api/references')).json();
|
const data = await (await this.api('/api/references')).json();
|
||||||
@@ -1068,9 +1122,9 @@ export default {
|
|||||||
async loadProfile() {
|
async loadProfile() {
|
||||||
const profile = await (await this.api('/api/profile')).json();
|
const profile = await (await this.api('/api/profile')).json();
|
||||||
this.user = profile.user;
|
this.user = profile.user;
|
||||||
this.profilePreferences = { theme: 'mixedAssetsDark', fontScale: 'normal', accent: '', contrast: false, ...(profile.preferences || {}) };
|
this.profilePreferences = { theme: 'depreCoreDark', fontScale: 'normal', accent: '', contrast: false, ...(profile.preferences || {}) };
|
||||||
this.setCapabilities(profile.capabilities);
|
this.setCapabilities(profile.capabilities);
|
||||||
localStorage.setItem('mixedassets.user', JSON.stringify(profile.user));
|
localStorage.setItem('deprecore.user', JSON.stringify(profile.user));
|
||||||
this.persistAppearanceLocal();
|
this.persistAppearanceLocal();
|
||||||
},
|
},
|
||||||
async login(form) {
|
async login(form) {
|
||||||
@@ -1082,22 +1136,35 @@ export default {
|
|||||||
this.token = data.token;
|
this.token = data.token;
|
||||||
this.user = data.user;
|
this.user = data.user;
|
||||||
this.setCapabilities(data.capabilities);
|
this.setCapabilities(data.capabilities);
|
||||||
localStorage.setItem('mixedassets.token', data.token);
|
localStorage.setItem('deprecore.token', data.token);
|
||||||
localStorage.setItem('mixedassets.user', JSON.stringify(data.user));
|
localStorage.setItem('deprecore.user', JSON.stringify(data.user));
|
||||||
await this.loadInitial();
|
// A forced/expired password gates the app behind the change-password step (token is held
|
||||||
|
// so the change endpoint is callable, but app data isn't loaded until the change succeeds).
|
||||||
|
if (data.mustChangePassword) {
|
||||||
|
this.forcePasswordChange = true;
|
||||||
|
} else {
|
||||||
|
await this.loadInitial();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error.message;
|
this.error = error.message;
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async completeForcedChange() {
|
||||||
|
this.forcePasswordChange = false;
|
||||||
|
this.error = '';
|
||||||
|
this.loginForm = { ...this.loginForm, password: '' };
|
||||||
|
await this.loadInitial();
|
||||||
|
},
|
||||||
logout() {
|
logout() {
|
||||||
this.token = '';
|
this.token = '';
|
||||||
this.user = null;
|
this.user = null;
|
||||||
|
this.forcePasswordChange = false;
|
||||||
this.userCapabilities = [];
|
this.userCapabilities = [];
|
||||||
localStorage.removeItem('mixedassets.token');
|
localStorage.removeItem('deprecore.token');
|
||||||
localStorage.removeItem('mixedassets.user');
|
localStorage.removeItem('deprecore.user');
|
||||||
localStorage.removeItem('mixedassets.capabilities');
|
localStorage.removeItem('deprecore.capabilities');
|
||||||
},
|
},
|
||||||
// Triggered when any API call reports an invalid/expired token: return to the login screen.
|
// Triggered when any API call reports an invalid/expired token: return to the login screen.
|
||||||
handleUnauthorized() {
|
handleUnauthorized() {
|
||||||
@@ -1115,7 +1182,7 @@ export default {
|
|||||||
},
|
},
|
||||||
toggleNav() {
|
toggleNav() {
|
||||||
this.navCollapsed = !this.navCollapsed;
|
this.navCollapsed = !this.navCollapsed;
|
||||||
localStorage.setItem('mixedassets.navCollapsed', String(this.navCollapsed));
|
localStorage.setItem('deprecore.navCollapsed', String(this.navCollapsed));
|
||||||
},
|
},
|
||||||
// Group-only items (no own screen) just expand; real screens navigate.
|
// Group-only items (no own screen) just expand; real screens navigate.
|
||||||
selectNav(item) {
|
selectNav(item) {
|
||||||
@@ -1204,8 +1271,8 @@ export default {
|
|||||||
body: JSON.stringify({ preferences: this.profilePreferences })
|
body: JSON.stringify({ preferences: this.profilePreferences })
|
||||||
})).json();
|
})).json();
|
||||||
this.user = profile.user;
|
this.user = profile.user;
|
||||||
this.profilePreferences = { theme: 'mixedAssetsDark', fontScale: 'normal', accent: '', contrast: false, ...(profile.preferences || {}) };
|
this.profilePreferences = { theme: 'depreCoreDark', fontScale: 'normal', accent: '', contrast: false, ...(profile.preferences || {}) };
|
||||||
localStorage.setItem('mixedassets.user', JSON.stringify(profile.user));
|
localStorage.setItem('deprecore.user', JSON.stringify(profile.user));
|
||||||
this.persistAppearanceLocal();
|
this.persistAppearanceLocal();
|
||||||
} finally {
|
} finally {
|
||||||
this.preferenceSaving = false;
|
this.preferenceSaving = false;
|
||||||
@@ -1267,6 +1334,9 @@ export default {
|
|||||||
if (!body.password) delete body.password;
|
if (!body.password) delete body.password;
|
||||||
await this.api(`/api/users/${user.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
await this.api(`/api/users/${user.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||||
await this.loadAdmin();
|
await this.loadAdmin();
|
||||||
|
this.notify('User updated.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
this.notify(error.message);
|
||||||
} finally {
|
} finally {
|
||||||
this.userSaving = false;
|
this.userSaving = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -424,12 +424,22 @@
|
|||||||
<v-list-item-title>{{ pm.plan_name || 'Maintenance' }} · every {{ pm.frequency_value }} {{ unitLabel(pm.frequency_unit) }}</v-list-item-title>
|
<v-list-item-title>{{ pm.plan_name || 'Maintenance' }} · every {{ pm.frequency_value }} {{ unitLabel(pm.frequency_unit) }}</v-list-item-title>
|
||||||
<v-list-item-subtitle>
|
<v-list-item-subtitle>
|
||||||
<template v-if="pm.active">
|
<template v-if="pm.active">
|
||||||
Next due {{ pm.next_due_date || '—' }}<template v-if="pm.last_completed_date"> · last done {{ pm.last_completed_date }}</template>
|
Next due {{ pm.next_due_date || '—' }}
|
||||||
|
<v-chip v-if="dueDriver(pm)" size="x-small" variant="tonal" :color="dueDriver(pm).color" class="ml-1">
|
||||||
|
<v-icon :icon="dueDriver(pm).icon" size="12" start /> {{ dueDriver(pm).label }}
|
||||||
|
</v-chip>
|
||||||
|
<template v-if="pm.last_completed_date"> · last done {{ pm.last_completed_date }}</template>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>Schedule complete (past {{ pm.end_date }})</template>
|
<template v-else>Schedule complete (past {{ pm.end_date }})</template>
|
||||||
<template v-if="pm.total_cost"> · spent {{ currency(pm.total_cost) }}</template>
|
<template v-if="pm.total_cost"> · spent {{ currency(pm.total_cost) }}</template>
|
||||||
|
<div v-for="m in pm.meters" :key="m.id" class="text-caption">
|
||||||
|
{{ m.label }}: {{ m.last_reading != null ? Math.round(m.last_reading) : '—' }}<template v-if="m.due_at_reading"> / {{ Math.round(m.due_at_reading) }}</template>
|
||||||
|
<span v-if="m.unit"> {{ m.unit }}</span>
|
||||||
|
<span v-if="m.projected_usage_due_date" class="quiet"> · projected {{ m.projected_usage_due_date }}</span>
|
||||||
|
</div>
|
||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>
|
||||||
<template #append>
|
<template #append>
|
||||||
|
<v-btn v-if="pm.active && pm.meters && pm.meters.length" size="small" variant="text" @click="openLogReading(pm)">Log reading</v-btn>
|
||||||
<v-btn v-if="pm.active" size="small" variant="text" color="primary" @click="openComplete(pm)">Complete</v-btn>
|
<v-btn v-if="pm.active" size="small" variant="text" color="primary" @click="openComplete(pm)">Complete</v-btn>
|
||||||
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="$emit('detach-pm', pm.id)" />
|
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="$emit('detach-pm', pm.id)" />
|
||||||
</template>
|
</template>
|
||||||
@@ -522,6 +532,19 @@
|
|||||||
|
|
||||||
<v-text-field v-model.number="completeCost" type="number" prefix="$" label="Service cost" density="compact" hide-details class="mt-3" />
|
<v-text-field v-model.number="completeCost" type="number" prefix="$" label="Service cost" density="compact" hide-details class="mt-3" />
|
||||||
|
|
||||||
|
<template v-if="completeMeters.length">
|
||||||
|
<v-divider class="my-3" />
|
||||||
|
<h3 class="section-title mb-1">Usage meter readings</h3>
|
||||||
|
<p class="quiet text-caption mb-2">Record the current reading; the next service threshold resets from here.</p>
|
||||||
|
<div v-for="(m, i) in completeMeters" :key="`m${i}`" class="d-flex align-center mb-2" style="gap:10px">
|
||||||
|
<div class="text-body-2" style="min-width:180px">
|
||||||
|
{{ m.label }}<span v-if="m.unit" class="quiet"> ({{ m.unit }})</span>
|
||||||
|
<div v-if="m.last_reading != null" class="quiet text-caption">last {{ Math.round(m.last_reading) }}<template v-if="m.due_at_reading"> · due at {{ Math.round(m.due_at_reading) }}</template></div>
|
||||||
|
</div>
|
||||||
|
<v-text-field v-model.number="m.reading" type="number" density="compact" hide-details placeholder="Current reading" :suffix="m.unit || ''" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<v-divider class="my-3" />
|
<v-divider class="my-3" />
|
||||||
<div class="toolbar-row mb-1">
|
<div class="toolbar-row mb-1">
|
||||||
<h3 class="section-title">Completion notes</h3>
|
<h3 class="section-title">Completion notes</h3>
|
||||||
@@ -559,6 +582,32 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Quick usage-meter reading (recomputes the schedule without completing a service) -->
|
||||||
|
<v-dialog v-model="logDialog" max-width="420">
|
||||||
|
<v-card v-if="logTarget">
|
||||||
|
<v-card-title>Log usage reading</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="quiet text-body-2 mb-3">{{ logTarget.plan_name || 'Maintenance' }}</div>
|
||||||
|
<v-select
|
||||||
|
v-if="(logTarget.meters || []).length > 1"
|
||||||
|
v-model="logForm.asset_pm_meter_id"
|
||||||
|
:items="(logTarget.meters || []).map((m) => ({ title: m.label, value: m.id }))"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
label="Meter"
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
<v-text-field v-model.number="logForm.reading" type="number" label="Current reading" density="compact" />
|
||||||
|
<v-text-field v-model="logForm.reading_date" type="date" label="Reading date" density="compact" />
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="logDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-check" :disabled="logForm.reading === '' || logForm.reading == null || !logForm.asset_pm_meter_id" @click="submitLogReading">Save reading</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
<!-- Prompt shown when depreciation-critical fields change on an already-depreciated asset -->
|
<!-- Prompt shown when depreciation-critical fields change on an already-depreciated asset -->
|
||||||
<v-dialog v-model="applyDialog" max-width="560">
|
<v-dialog v-model="applyDialog" max-width="560">
|
||||||
<v-card>
|
<v-card>
|
||||||
@@ -691,7 +740,7 @@ export default {
|
|||||||
'add-adjustment', 'delete-adjustment',
|
'add-adjustment', 'delete-adjustment',
|
||||||
'save-lease', 'delete-lease', 'load-lease-schedule',
|
'save-lease', 'delete-lease', 'load-lease-schedule',
|
||||||
'print-label',
|
'print-label',
|
||||||
'attach-pm', 'detach-pm', 'complete-pm', 'view-completion'
|
'attach-pm', 'detach-pm', 'complete-pm', 'log-reading', 'view-completion'
|
||||||
],
|
],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -722,6 +771,10 @@ export default {
|
|||||||
completeRatings: { safety: 0, physical: 0, operating: 0 },
|
completeRatings: { safety: 0, physical: 0, operating: 0 },
|
||||||
completeSignature: '',
|
completeSignature: '',
|
||||||
completePhotos: [],
|
completePhotos: [],
|
||||||
|
completeMeters: [],
|
||||||
|
logDialog: false,
|
||||||
|
logTarget: null,
|
||||||
|
logForm: { asset_pm_meter_id: null, reading: '', reading_date: new Date().toISOString().slice(0, 10) },
|
||||||
ratingQuestions: [
|
ratingQuestions: [
|
||||||
{ key: 'safety', label: 'Safety condition / Is it safe?' },
|
{ key: 'safety', label: 'Safety condition / Is it safe?' },
|
||||||
{ key: 'physical', label: 'Physical condition / appearance' },
|
{ key: 'physical', label: 'Physical condition / appearance' },
|
||||||
@@ -926,6 +979,19 @@ export default {
|
|||||||
unitLabel(unit) {
|
unitLabel(unit) {
|
||||||
return (pmFrequencyUnits.find((u) => u.value === unit) || {}).title || unit;
|
return (pmFrequencyUnits.find((u) => u.value === unit) || {}).title || unit;
|
||||||
},
|
},
|
||||||
|
// Which signal drove the next-due date (only meaningful once usage/lifespan are in play).
|
||||||
|
dueDriver(pm) {
|
||||||
|
const map = {
|
||||||
|
usage: { label: 'usage', icon: 'mdi-gauge', color: 'info' },
|
||||||
|
lifespan: { label: 'age', icon: 'mdi-chart-bell-curve-cumulative', color: 'warning' },
|
||||||
|
calendar: { label: 'date', icon: 'mdi-calendar', color: undefined }
|
||||||
|
};
|
||||||
|
// Only badge when a dynamic signal is active (meters present or lifespan compression applied).
|
||||||
|
const hasMeters = pm.meters && pm.meters.length;
|
||||||
|
const compressed = pm.signals && pm.signals.factor != null && pm.signals.factor < 1;
|
||||||
|
if (!hasMeters && !compressed) return null;
|
||||||
|
return map[pm.due_driver] || null;
|
||||||
|
},
|
||||||
resetPmForm() {
|
resetPmForm() {
|
||||||
this.pmForm = {
|
this.pmForm = {
|
||||||
plan_id: null,
|
plan_id: null,
|
||||||
@@ -956,8 +1022,31 @@ export default {
|
|||||||
this.completeRatings = { safety: 0, physical: 0, operating: 0 };
|
this.completeRatings = { safety: 0, physical: 0, operating: 0 };
|
||||||
this.completeSignature = '';
|
this.completeSignature = '';
|
||||||
this.completePhotos = [];
|
this.completePhotos = [];
|
||||||
|
this.completeMeters = (pm.meters || []).map((m) => ({
|
||||||
|
asset_pm_meter_id: m.id, label: m.label, unit: m.unit,
|
||||||
|
last_reading: m.last_reading, due_at_reading: m.due_at_reading, reading: ''
|
||||||
|
}));
|
||||||
this.completeDialog = true;
|
this.completeDialog = true;
|
||||||
},
|
},
|
||||||
|
openLogReading(pm) {
|
||||||
|
this.logTarget = pm;
|
||||||
|
this.logForm = {
|
||||||
|
asset_pm_meter_id: (pm.meters && pm.meters[0]) ? pm.meters[0].id : null,
|
||||||
|
reading: '',
|
||||||
|
reading_date: new Date().toISOString().slice(0, 10)
|
||||||
|
};
|
||||||
|
this.logDialog = true;
|
||||||
|
},
|
||||||
|
submitLogReading() {
|
||||||
|
this.$emit('log-reading', {
|
||||||
|
apId: this.logTarget.id,
|
||||||
|
asset_pm_meter_id: this.logForm.asset_pm_meter_id,
|
||||||
|
reading: Number(this.logForm.reading),
|
||||||
|
reading_date: this.logForm.reading_date,
|
||||||
|
source: 'manual'
|
||||||
|
});
|
||||||
|
this.logDialog = false;
|
||||||
|
},
|
||||||
onCompletePhotos(event) {
|
onCompletePhotos(event) {
|
||||||
for (const file of Array.from(event.target.files || [])) {
|
for (const file of Array.from(event.target.files || [])) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -975,7 +1064,10 @@ export default {
|
|||||||
notes_list: this.completeNotesList.map((n) => String(n || '').trim()).filter(Boolean),
|
notes_list: this.completeNotesList.map((n) => String(n || '').trim()).filter(Boolean),
|
||||||
ratings: { ...this.completeRatings },
|
ratings: { ...this.completeRatings },
|
||||||
signature: this.completeSignature,
|
signature: this.completeSignature,
|
||||||
photos: this.completePhotos
|
photos: this.completePhotos,
|
||||||
|
meter_readings: this.completeMeters
|
||||||
|
.filter((m) => m.reading !== '' && m.reading != null)
|
||||||
|
.map((m) => ({ asset_pm_meter_id: m.asset_pm_meter_id, reading: Number(m.reading) }))
|
||||||
});
|
});
|
||||||
this.completeDialog = false;
|
this.completeDialog = false;
|
||||||
}
|
}
|
||||||
|
|||||||
242
src/components/AuditTrailSettings.vue
Normal file
242
src/components/AuditTrailSettings.vue
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Activity & audit trail</h2>
|
||||||
|
<p class="quiet text-body-2">
|
||||||
|
Complete record of user activity — sign-ins, lockouts, password and permission changes, and every data edit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="tonal" prepend-icon="mdi-download" :loading="exporting" @click="exportCsv">Export CSV</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-grid mb-3">
|
||||||
|
<v-select
|
||||||
|
v-model="filters.actor"
|
||||||
|
:items="actorItems"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
label="User"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<v-select
|
||||||
|
v-model="filters.action"
|
||||||
|
:items="facets.actions"
|
||||||
|
label="Action"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<v-select
|
||||||
|
v-model="filters.entity_type"
|
||||||
|
:items="facets.entity_types"
|
||||||
|
label="Entity type"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<v-text-field v-model="filters.from" type="date" label="From" density="compact" hide-details clearable />
|
||||||
|
<v-text-field v-model="filters.to" type="date" label="To" density="compact" hide-details clearable />
|
||||||
|
<v-text-field
|
||||||
|
v-model="filters.q"
|
||||||
|
label="Search"
|
||||||
|
placeholder="entity, id, action…"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
@keyup.enter="applyFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<v-btn size="small" color="primary" variant="tonal" prepend-icon="mdi-filter" @click="applyFilters">Apply filters</v-btn>
|
||||||
|
<v-btn size="small" variant="text" @click="resetFilters">Reset</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<span class="quiet text-body-2">{{ total }} event{{ total === 1 ? '' : 's' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mb-3">{{ error }}</v-alert>
|
||||||
|
|
||||||
|
<v-table density="compact" class="audit-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Entity</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in rows" :key="row.id">
|
||||||
|
<td class="nowrap">{{ shortDate(row.created_at) }}</td>
|
||||||
|
<td>{{ row.actor_name || (row.actor_id ? `#${row.actor_id}` : 'System') }}</td>
|
||||||
|
<td><v-chip size="x-small" variant="tonal" :color="actionColor(row.action)">{{ row.action }}</v-chip></td>
|
||||||
|
<td>{{ row.entity_type }}</td>
|
||||||
|
<td class="nowrap">{{ row.entity_id || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<v-menu v-if="row.before_json || row.after_json" location="start">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn v-bind="props" size="x-small" variant="text" icon="mdi-information-outline" />
|
||||||
|
</template>
|
||||||
|
<v-card class="detail-card pa-3">
|
||||||
|
<div v-if="row.before_json" class="mb-2">
|
||||||
|
<div class="quiet text-caption mb-1">Before</div>
|
||||||
|
<pre class="detail-json">{{ pretty(row.before_json) }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.after_json">
|
||||||
|
<div class="quiet text-caption mb-1">After</div>
|
||||||
|
<pre class="detail-json">{{ pretty(row.after_json) }}</pre>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
|
<span v-else class="quiet">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!rows.length && !loading">
|
||||||
|
<td colspan="6" class="text-center quiet py-4">No activity matches these filters.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
|
||||||
|
<div class="toolbar-row mt-3">
|
||||||
|
<v-spacer />
|
||||||
|
<v-pagination
|
||||||
|
v-if="pageCount > 1"
|
||||||
|
v-model="page"
|
||||||
|
:length="pageCount"
|
||||||
|
:total-visible="5"
|
||||||
|
density="compact"
|
||||||
|
@update:model-value="load"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { apiRequest, saveBlob } from '../services/api';
|
||||||
|
import { shortDate } from '../utils/format';
|
||||||
|
|
||||||
|
const BLANK_FILTERS = { actor: null, action: null, entity_type: null, from: null, to: null, q: '' };
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
filters: { ...BLANK_FILTERS },
|
||||||
|
rows: [],
|
||||||
|
facets: { actions: [], entity_types: [], actors: [] },
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 25,
|
||||||
|
loading: false,
|
||||||
|
exporting: false,
|
||||||
|
error: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
pageCount() {
|
||||||
|
return Math.max(1, Math.ceil(this.total / this.pageSize));
|
||||||
|
},
|
||||||
|
actorItems() {
|
||||||
|
return this.facets.actors.map((a) => ({ title: a.name || `#${a.id}`, value: a.id }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
shortDate,
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
queryString() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(this.filters)) {
|
||||||
|
if (value !== null && value !== '' && value !== undefined) params.set(key, value);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const params = this.queryString();
|
||||||
|
params.set('page', this.page);
|
||||||
|
params.set('pageSize', this.pageSize);
|
||||||
|
const data = await (await this.api(`/api/audit-logs?${params.toString()}`)).json();
|
||||||
|
this.rows = data.rows;
|
||||||
|
this.total = data.total;
|
||||||
|
this.facets = data.facets;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
applyFilters() {
|
||||||
|
this.page = 1;
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
resetFilters() {
|
||||||
|
this.filters = { ...BLANK_FILTERS };
|
||||||
|
this.page = 1;
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
async exportCsv() {
|
||||||
|
this.exporting = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const response = await this.api(`/api/audit-logs/export?${this.queryString().toString()}`);
|
||||||
|
const blob = await response.blob();
|
||||||
|
saveBlob(blob, `audit-trail-${new Date().toISOString().slice(0, 10)}.csv`);
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.exporting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pretty(jsonText) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(jsonText), null, 2);
|
||||||
|
} catch {
|
||||||
|
return jsonText;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actionColor(action) {
|
||||||
|
if (/fail|locked|delete/.test(action)) return 'error';
|
||||||
|
if (/login|create/.test(action)) return 'success';
|
||||||
|
if (/update|change|export/.test(action)) return 'info';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.audit-table .nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.detail-card {
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.detail-json {
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -25,6 +25,11 @@
|
|||||||
<span class="font-weight-bold">{{ row.name }}</span>
|
<span class="font-weight-bold">{{ row.name }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-code="{ row }">{{ row.code || '—' }}</template>
|
<template #cell-code="{ row }">{{ row.code || '—' }}</template>
|
||||||
|
<template #cell-asset_class_number="{ row }">
|
||||||
|
<span v-if="row.asset_class_label" :title="row.asset_class_label">{{ row.asset_class_label }}</span>
|
||||||
|
<span v-else-if="row.asset_class_number">{{ row.asset_class_number }}</span>
|
||||||
|
<span v-else class="quiet">—</span>
|
||||||
|
</template>
|
||||||
<template #cell-id_template_name="{ row }">
|
<template #cell-id_template_name="{ row }">
|
||||||
<span v-if="row.id_template_name">{{ row.id_template_name }} <span class="quiet">({{ row.id_template_pattern }})</span></span>
|
<span v-if="row.id_template_name">{{ row.id_template_name }} <span class="quiet">({{ row.id_template_pattern }})</span></span>
|
||||||
<span v-else class="quiet">Default (MA-#####)</span>
|
<span v-else class="quiet">Default (MA-#####)</span>
|
||||||
@@ -59,6 +64,7 @@
|
|||||||
label="Asset class number (optional)"
|
label="Asset class number (optional)"
|
||||||
:hint="classHint || 'Shared by every asset in this category'"
|
:hint="classHint || 'Shared by every asset in this category'"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
|
@update:model-value="onClassNumberInput"
|
||||||
/>
|
/>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="form.id_template_id"
|
v-model="form.id_template_id"
|
||||||
@@ -133,7 +139,7 @@ export default {
|
|||||||
dialog: false,
|
dialog: false,
|
||||||
deleteDialog: false,
|
deleteDialog: false,
|
||||||
deleteTarget: null,
|
deleteTarget: null,
|
||||||
form: { id: null, name: '', code: '', asset_class_number: '', id_template_id: null },
|
form: { id: null, name: '', code: '', asset_class_id: null, asset_class_number: '', id_template_id: null },
|
||||||
saving: false,
|
saving: false,
|
||||||
error: '',
|
error: '',
|
||||||
dialogError: '',
|
dialogError: '',
|
||||||
@@ -155,14 +161,18 @@ export default {
|
|||||||
},
|
},
|
||||||
assetClassItems() {
|
assetClassItems() {
|
||||||
const tableLabels = { common: 'Common', industry: 'Manufacturing', business: 'Business', custom: 'Custom' };
|
const tableLabels = { common: 'Common', industry: 'Manufacturing', business: 'Business', custom: 'Custom' };
|
||||||
return this.pickableClasses.map((c, index) => ({
|
// Value is the class's unique id — class numbers are not unique, so an index/number would
|
||||||
|
// collide (e.g. 00.12 is both Computers and Casinos).
|
||||||
|
return this.pickableClasses.map((c) => ({
|
||||||
title: `${c.class_number} — ${c.description} (GDS ${c.gds ?? '—'} / ADS ${c.ads ?? '—'}) · ${tableLabels[c.table] || 'Common'}`,
|
title: `${c.class_number} — ${c.description} (GDS ${c.gds ?? '—'} / ADS ${c.ads ?? '—'}) · ${tableLabels[c.table] || 'Common'}`,
|
||||||
value: index
|
value: c.id
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
classHint() {
|
classHint() {
|
||||||
const match = this.assetClasses.find((c) => c.class_number && c.class_number === String(this.form.asset_class_number || '').trim());
|
const match = this.form.asset_class_id ? this.assetClasses.find((c) => c.id === this.form.asset_class_id) : null;
|
||||||
if (!match) return '';
|
if (!match) {
|
||||||
|
return this.form.asset_class_number ? `Manual entry — not linked to a catalog class.` : '';
|
||||||
|
}
|
||||||
return `IRS ${match.class_number} · ${match.description} · GDS ${match.gds ?? '—'} / ADS ${match.ads ?? '—'} yrs`;
|
return `IRS ${match.class_number} · ${match.description} · GDS ${match.gds ?? '—'} / ADS ${match.ads ?? '—'} yrs`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -200,20 +210,27 @@ export default {
|
|||||||
this.assetClasses = [];
|
this.assetClasses = [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
applyAssetClass(index) {
|
applyAssetClass(id) {
|
||||||
const entry = index != null ? this.pickableClasses[index] : null;
|
const entry = id != null ? this.assetClasses.find((c) => c.id === id) : null;
|
||||||
if (entry) this.form.asset_class_number = entry.class_number;
|
if (entry) {
|
||||||
|
this.form.asset_class_id = entry.id;
|
||||||
|
this.form.asset_class_number = entry.class_number;
|
||||||
|
}
|
||||||
this.$nextTick(() => { this.classLookup = null; });
|
this.$nextTick(() => { this.classLookup = null; });
|
||||||
},
|
},
|
||||||
|
onClassNumberInput() {
|
||||||
|
// Typing a number by hand detaches it from a catalog class (manual override).
|
||||||
|
this.form.asset_class_id = null;
|
||||||
|
},
|
||||||
openNew() {
|
openNew() {
|
||||||
this.loadTemplates(); this.loadAssetClasses(); this.classLookup = null;
|
this.loadTemplates(); this.loadAssetClasses(); this.classLookup = null;
|
||||||
this.form = { id: null, name: '', code: '', asset_class_number: '', id_template_id: null };
|
this.form = { id: null, name: '', code: '', asset_class_id: null, asset_class_number: '', id_template_id: null };
|
||||||
this.dialogError = '';
|
this.dialogError = '';
|
||||||
this.dialog = true;
|
this.dialog = true;
|
||||||
},
|
},
|
||||||
openEdit(row) {
|
openEdit(row) {
|
||||||
this.loadTemplates(); this.loadAssetClasses(); this.classLookup = null;
|
this.loadTemplates(); this.loadAssetClasses(); this.classLookup = null;
|
||||||
this.form = { id: row.id, name: row.name, code: row.code || '', asset_class_number: row.asset_class_number || '', id_template_id: row.id_template_id || null };
|
this.form = { id: row.id, name: row.name, code: row.code || '', asset_class_id: row.asset_class_id || null, asset_class_number: row.asset_class_number || '', id_template_id: row.id_template_id || null };
|
||||||
this.dialogError = '';
|
this.dialogError = '';
|
||||||
this.dialog = true;
|
this.dialog = true;
|
||||||
},
|
},
|
||||||
@@ -225,7 +242,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const method = this.form.id ? 'PUT' : 'POST';
|
const method = this.form.id ? 'PUT' : 'POST';
|
||||||
const url = this.form.id ? `/api/asset-categories/${this.form.id}` : '/api/asset-categories';
|
const url = this.form.id ? `/api/asset-categories/${this.form.id}` : '/api/asset-categories';
|
||||||
await this.api(url, { method, body: JSON.stringify({ name, code: this.form.code || null, asset_class_number: this.form.asset_class_number || null, id_template_id: this.form.id_template_id || null }) });
|
await this.api(url, { method, body: JSON.stringify({ name, code: this.form.code || null, asset_class_id: this.form.asset_class_id || null, asset_class_number: this.form.asset_class_number || null, id_template_id: this.form.id_template_id || null }) });
|
||||||
this.dialog = false;
|
this.dialog = false;
|
||||||
await this.load();
|
await this.load();
|
||||||
this.$emit('updated');
|
this.$emit('updated');
|
||||||
|
|||||||
132
src/components/ChangePasswordCard.vue
Normal file
132
src/components/ChangePasswordCard.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="panel-pad" :class="standalone ? 'change-password-standalone' : 'span-12'">
|
||||||
|
<h2 class="section-title mb-1">{{ forced ? 'Set a new password' : 'Change your password' }}</h2>
|
||||||
|
<p v-if="forced" class="quiet text-body-2 mb-3">
|
||||||
|
Your password must be changed before you can continue.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<v-alert v-if="rules.length" type="info" variant="tonal" density="compact" class="mb-3">
|
||||||
|
<div class="text-caption font-weight-medium mb-1">Password requirements</div>
|
||||||
|
<ul class="rule-list">
|
||||||
|
<li v-for="rule in rules" :key="rule">{{ rule }}</li>
|
||||||
|
</ul>
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="current"
|
||||||
|
type="password"
|
||||||
|
label="Current password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
prepend-inner-icon="mdi-lock-outline"
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="next"
|
||||||
|
type="password"
|
||||||
|
label="New password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
prepend-inner-icon="mdi-lock-plus-outline"
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="confirm"
|
||||||
|
type="password"
|
||||||
|
label="Confirm new password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
prepend-inner-icon="mdi-lock-check-outline"
|
||||||
|
density="compact"
|
||||||
|
:error-messages="confirm && confirm !== next ? 'Passwords do not match' : ''"
|
||||||
|
@keyup.enter="submit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mb-3">{{ error }}</v-alert>
|
||||||
|
<v-alert v-if="success" type="success" density="compact" class="mb-3">Password updated.</v-alert>
|
||||||
|
|
||||||
|
<div class="toolbar-row">
|
||||||
|
<v-btn v-if="forced" variant="text" @click="$emit('cancel')">Sign out</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
@click="submit"
|
||||||
|
>Update password</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true },
|
||||||
|
forced: { type: Boolean, default: false },
|
||||||
|
standalone: { type: Boolean, default: false }
|
||||||
|
},
|
||||||
|
emits: ['changed', 'cancel'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
current: '',
|
||||||
|
next: '',
|
||||||
|
confirm: '',
|
||||||
|
rules: [],
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
success: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
canSubmit() {
|
||||||
|
return this.current && this.next && this.next === this.confirm;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadRules();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadRules() {
|
||||||
|
try {
|
||||||
|
const data = await (await apiRequest('/api/profile', this.token)).json();
|
||||||
|
this.rules = data.passwordPolicy?.rules || [];
|
||||||
|
} catch {
|
||||||
|
this.rules = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async submit() {
|
||||||
|
if (!this.canSubmit) return;
|
||||||
|
this.saving = true;
|
||||||
|
this.error = '';
|
||||||
|
this.success = false;
|
||||||
|
try {
|
||||||
|
await apiRequest('/api/profile/password', this.token, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ current_password: this.current, new_password: this.next })
|
||||||
|
});
|
||||||
|
this.success = true;
|
||||||
|
this.current = '';
|
||||||
|
this.next = '';
|
||||||
|
this.confirm = '';
|
||||||
|
this.$emit('changed');
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.change-password-standalone {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 8vh auto;
|
||||||
|
}
|
||||||
|
.rule-list {
|
||||||
|
padding-left: 18px;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -51,8 +51,12 @@
|
|||||||
<v-text-field v-model="form.real_property_end" type="date" label="Real-property PIS end (optional)" />
|
<v-text-field v-model="form.real_property_end" type="date" label="Real-property PIS end (optional)" />
|
||||||
<v-text-field v-model.number="form.max_recovery_years" type="number" label="Max recovery years (optional)" />
|
<v-text-field v-model.number="form.max_recovery_years" type="number" label="Max recovery years (optional)" />
|
||||||
<v-text-field v-model.number="form.leasehold_life_years" type="number" label="Leasehold improvement life (yrs, optional)" />
|
<v-text-field v-model.number="form.leasehold_life_years" type="number" label="Leasehold improvement life (yrs, optional)" />
|
||||||
<v-text-field v-model.number="form.section_179_increase" type="number" label="§179 limit increase ($)" prefix="$" hint="Enterprise/empowerment zones, e.g. 35000" persistent-hint />
|
<v-text-field v-model.number="form.section_179_increase" type="number" label="§179 limit increase ($)" prefix="$" hint="e.g. 35000 (Enterprise), 100000 (GO Zone)" persistent-hint />
|
||||||
<v-text-field v-model.number="form.section_179_cost_factor" type="number" step="0.01" label="§179 cost factor for phaseout" hint="Share of cost counted toward the threshold (e.g. 0.5)" persistent-hint />
|
<v-text-field v-model.number="form.section_179_cost_factor" type="number" step="0.01" label="§179 cost factor for phaseout" hint="Share of cost counted toward the threshold (e.g. 0.5)" persistent-hint />
|
||||||
|
<v-text-field v-model.number="form.section_179_threshold_increase" type="number" label="§179 threshold increase cap ($)" prefix="$" hint="Threshold rises by lesser of this and cost (GO Zone: 600000)" persistent-hint />
|
||||||
|
<div></div>
|
||||||
|
<v-text-field v-model="form.section_179_pis_start" type="date" label="§179 window start (optional)" hint="Defaults to the allowance window" persistent-hint />
|
||||||
|
<v-text-field v-model="form.section_179_pis_end" type="date" label="§179 window end (optional)" hint="GO Zone §179 ends earlier than the allowance" persistent-hint />
|
||||||
</div>
|
</div>
|
||||||
<v-textarea v-model="form.notes" class="mt-2" label="Notes" rows="3" />
|
<v-textarea v-model="form.notes" class="mt-2" label="Notes" rows="3" />
|
||||||
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
|
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
|
||||||
@@ -92,7 +96,8 @@ function blankForm() {
|
|||||||
return {
|
return {
|
||||||
exists: false, code: '', name: '', allowance_percent: 0, enabled: true,
|
exists: false, code: '', name: '', allowance_percent: 0, enabled: true,
|
||||||
pis_start: '', pis_end: '', real_property_end: '', max_recovery_years: null, leasehold_life_years: null,
|
pis_start: '', pis_end: '', real_property_end: '', max_recovery_years: null, leasehold_life_years: null,
|
||||||
section_179_increase: 0, section_179_cost_factor: 1, notes: ''
|
section_179_increase: 0, section_179_cost_factor: 1, section_179_threshold_increase: 0,
|
||||||
|
section_179_pis_start: '', section_179_pis_end: '', notes: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +154,8 @@ export default {
|
|||||||
max_recovery_years: row.max_recovery_years, leasehold_life_years: row.leasehold_life_years,
|
max_recovery_years: row.max_recovery_years, leasehold_life_years: row.leasehold_life_years,
|
||||||
section_179_increase: row.section_179_increase || 0,
|
section_179_increase: row.section_179_increase || 0,
|
||||||
section_179_cost_factor: row.section_179_cost_factor === null || row.section_179_cost_factor === undefined ? 1 : row.section_179_cost_factor,
|
section_179_cost_factor: row.section_179_cost_factor === null || row.section_179_cost_factor === undefined ? 1 : row.section_179_cost_factor,
|
||||||
|
section_179_threshold_increase: row.section_179_threshold_increase || 0,
|
||||||
|
section_179_pis_start: row.section_179_pis_start || '', section_179_pis_end: row.section_179_pis_end || '',
|
||||||
notes: row.notes || ''
|
notes: row.notes || ''
|
||||||
};
|
};
|
||||||
this.dialogError = '';
|
this.dialogError = '';
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
|||||||
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||||||
|
|
||||||
// Configure Monaco web workers once (Vite bundles each ?worker as its own chunk).
|
// Configure Monaco web workers once (Vite bundles each ?worker as its own chunk).
|
||||||
if (!window.__mixedassetsMonacoEnv) {
|
if (!window.__deprecoreMonacoEnv) {
|
||||||
self.MonacoEnvironment = {
|
self.MonacoEnvironment = {
|
||||||
getWorker(_workerId, label) {
|
getWorker(_workerId, label) {
|
||||||
return label === 'json' ? new jsonWorker() : new editorWorker();
|
return label === 'json' ? new jsonWorker() : new editorWorker();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.__mixedassetsMonacoEnv = true;
|
window.__deprecoreMonacoEnv = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
178
src/components/PasswordPolicySettings.vue
Normal file
178
src/components/PasswordPolicySettings.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-1">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Password & account security</h2>
|
||||||
|
<p class="quiet text-body-2">
|
||||||
|
Rules enforced for locally managed users — applied when passwords are set or changed, and on sign-in.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mb-3">{{ error }}</v-alert>
|
||||||
|
|
||||||
|
<div class="policy-grid">
|
||||||
|
<div>
|
||||||
|
<h3 class="section-title text-body-1 mb-2">Complexity</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="policy.min_length"
|
||||||
|
type="number"
|
||||||
|
min="4"
|
||||||
|
label="Minimum length"
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<v-switch v-model="policy.require_upper" label="Require an uppercase letter" color="primary" density="compact" hide-details />
|
||||||
|
<v-switch v-model="policy.require_lower" label="Require a lowercase letter" color="primary" density="compact" hide-details />
|
||||||
|
<v-switch v-model="policy.require_number" label="Require a number" color="primary" density="compact" hide-details />
|
||||||
|
<v-switch v-model="policy.require_symbol" label="Require a symbol" color="primary" density="compact" hide-details />
|
||||||
|
<v-switch v-model="policy.disallow_identifiers" label="Disallow name or email in password" color="primary" density="compact" hide-details />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="section-title text-body-1 mb-2">Rotation & reuse</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="policy.history_count"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
label="Block reuse of last N passwords"
|
||||||
|
hint="0 disables password history"
|
||||||
|
persistent-hint
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="policy.expiry_days"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
label="Expire passwords after (days)"
|
||||||
|
hint="0 = never expires"
|
||||||
|
persistent-hint
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="policy.min_age_hours"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
label="Minimum password age (hours)"
|
||||||
|
hint="0 = can change anytime"
|
||||||
|
persistent-hint
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-title text-body-1 mb-2 mt-4">Account lockout</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="policy.lockout_threshold"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
label="Lock after N failed sign-ins"
|
||||||
|
hint="0 disables lockout"
|
||||||
|
persistent-hint
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="policy.lockout_window_minutes"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
label="Lock duration (minutes)"
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="section-title text-body-1 mb-2">What users will see</h3>
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable">
|
||||||
|
<ul class="rule-list">
|
||||||
|
<li v-for="rule in rules" :key="rule">{{ rule }}</li>
|
||||||
|
</ul>
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<div class="toolbar-row">
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save policy</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
policy: {
|
||||||
|
min_length: 12,
|
||||||
|
require_upper: true,
|
||||||
|
require_lower: true,
|
||||||
|
require_number: true,
|
||||||
|
require_symbol: true,
|
||||||
|
disallow_identifiers: true,
|
||||||
|
history_count: 5,
|
||||||
|
expiry_days: 90,
|
||||||
|
min_age_hours: 0,
|
||||||
|
lockout_threshold: 5,
|
||||||
|
lockout_window_minutes: 15
|
||||||
|
},
|
||||||
|
rules: [],
|
||||||
|
saving: false,
|
||||||
|
error: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/password-policy')).json();
|
||||||
|
this.policy = { ...this.policy, ...data.policy };
|
||||||
|
this.rules = data.rules;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
this.saving = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/password-policy', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ policy: this.policy })
|
||||||
|
})).json();
|
||||||
|
this.policy = { ...this.policy, ...data.policy };
|
||||||
|
this.rules = data.rules;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.policy-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.rule-list {
|
||||||
|
padding-left: 18px;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,6 +7,75 @@
|
|||||||
<v-select v-model="form.pm_default_frequency_unit" :items="pmFrequencyUnits" item-title="title" item-value="value" label="Default frequency unit" />
|
<v-select v-model="form.pm_default_frequency_unit" :items="pmFrequencyUnits" item-title="title" item-value="value" label="Default frequency unit" />
|
||||||
<v-text-field v-model.number="form.pm_lead_days" type="number" label="Alert lead days" />
|
<v-text-field v-model.number="form.pm_lead_days" type="number" label="Alert lead days" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<h3 class="section-title text-body-1 mb-1">Dynamic scheduling</h3>
|
||||||
|
<p class="quiet text-body-2 mb-2">
|
||||||
|
Beyond static dates, PM due-dates can auto-adjust to real-world signals. With these on, a schedule becomes
|
||||||
|
due at the <strong>earliest</strong> of its calendar date, a projected usage threshold, or an age-compressed date.
|
||||||
|
</p>
|
||||||
|
<v-switch
|
||||||
|
v-model="form.pm_usage_tracking_enabled"
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
label="Usage-based scheduling (meters: hours, kWh, gallons, cycles…)"
|
||||||
|
/>
|
||||||
|
<v-switch
|
||||||
|
v-model="form.pm_lifespan_curve_enabled"
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
label="Lifespan wear curve (service more often as equipment ages)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="form.pm_lifespan_curve_enabled" class="form-grid mt-2">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="form.pm_lifespan_min_factor"
|
||||||
|
type="number" step="0.05" min="0.1" max="1"
|
||||||
|
label="End-of-life interval factor"
|
||||||
|
hint="0.5 = half the interval at end of life"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="form.pm_lifespan_curve_exponent"
|
||||||
|
type="number" step="0.5" min="0.5" max="6"
|
||||||
|
label="Curve aggressiveness (exponent)"
|
||||||
|
hint="Higher holds the base cadence longer, then drops sharply"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="form.pm_lifespan_curve_enabled" type="info" variant="tonal" density="compact" class="mt-2">
|
||||||
|
{{ curvePreview }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<h3 class="section-title text-body-1 mb-1">Nightly schedule recompute</h3>
|
||||||
|
<p class="quiet text-body-2 mb-2">
|
||||||
|
A background job re-derives projected due-dates so usage and age-driven schedules stay current even when an asset
|
||||||
|
isn’t serviced or read for a while. Runs once a day at the chosen hour (server time).
|
||||||
|
</p>
|
||||||
|
<v-switch
|
||||||
|
v-model="form.pm_recompute_enabled"
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
label="Enable nightly recompute"
|
||||||
|
/>
|
||||||
|
<div v-if="form.pm_recompute_enabled" class="form-grid mt-2">
|
||||||
|
<v-select
|
||||||
|
v-model.number="form.pm_recompute_hour"
|
||||||
|
:items="hourItems"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
label="Run at (hour, server time)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center mt-2" style="gap:12px">
|
||||||
|
<v-btn size="small" variant="tonal" prepend-icon="mdi-refresh" :loading="recomputing" @click="runNow">Run now</v-btn>
|
||||||
|
<span class="quiet text-body-2">Last run: {{ form.pm_last_recompute_at ? shortDate(form.pm_last_recompute_at) : 'Never' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toolbar-row mt-3">
|
<div class="toolbar-row mt-3">
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save PM defaults</v-btn>
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save PM defaults</v-btn>
|
||||||
@@ -18,6 +87,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { apiRequest } from '../services/api';
|
import { apiRequest } from '../services/api';
|
||||||
import { pmFrequencyUnits } from '../constants';
|
import { pmFrequencyUnits } from '../constants';
|
||||||
|
import { shortDate } from '../utils/format';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
@@ -26,16 +96,47 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
pmFrequencyUnits,
|
pmFrequencyUnits,
|
||||||
form: { pm_default_frequency_value: 3, pm_default_frequency_unit: 'months', pm_lead_days: 7 },
|
form: {
|
||||||
|
pm_default_frequency_value: 3,
|
||||||
|
pm_default_frequency_unit: 'months',
|
||||||
|
pm_lead_days: 7,
|
||||||
|
pm_usage_tracking_enabled: false,
|
||||||
|
pm_lifespan_curve_enabled: false,
|
||||||
|
pm_lifespan_min_factor: 0.5,
|
||||||
|
pm_lifespan_curve_exponent: 2,
|
||||||
|
pm_recompute_enabled: false,
|
||||||
|
pm_recompute_hour: 2,
|
||||||
|
pm_last_recompute_at: null
|
||||||
|
},
|
||||||
|
recomputing: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
status: '',
|
status: '',
|
||||||
statusType: 'success'
|
statusType: 'success'
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
hourItems() {
|
||||||
|
return Array.from({ length: 24 }, (_, h) => ({
|
||||||
|
title: `${String(h).padStart(2, '0')}:00`,
|
||||||
|
value: h
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
// Plain-English example of the wear curve at the half-life point.
|
||||||
|
curvePreview() {
|
||||||
|
const min = Math.min(1, Math.max(0.1, Number(this.form.pm_lifespan_min_factor) || 0.5));
|
||||||
|
const exp = Math.min(6, Math.max(0.5, Number(this.form.pm_lifespan_curve_exponent) || 2));
|
||||||
|
const halfFactor = 1 - (1 - min) * Math.pow(0.5, exp);
|
||||||
|
const base = 6; // months, illustrative
|
||||||
|
const atHalf = Math.round(base * halfFactor * 10) / 10;
|
||||||
|
const atEnd = Math.round(base * min * 10) / 10;
|
||||||
|
return `Example: a ${base}-month interval becomes about ${atHalf} months at mid-life and ${atEnd} months at end of life.`;
|
||||||
|
}
|
||||||
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.load();
|
this.load();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
shortDate,
|
||||||
async api(path, options = {}) {
|
async api(path, options = {}) {
|
||||||
return apiRequest(path, this.token, options);
|
return apiRequest(path, this.token, options);
|
||||||
},
|
},
|
||||||
@@ -43,6 +144,21 @@ export default {
|
|||||||
const data = await (await this.api('/api/pm-settings')).json();
|
const data = await (await this.api('/api/pm-settings')).json();
|
||||||
this.form = { ...this.form, ...data.settings };
|
this.form = { ...this.form, ...data.settings };
|
||||||
},
|
},
|
||||||
|
async runNow() {
|
||||||
|
this.recomputing = true;
|
||||||
|
this.status = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/pm-recompute', { method: 'POST', body: '{}' })).json();
|
||||||
|
this.form = { ...this.form, ...data.settings };
|
||||||
|
this.statusType = 'success';
|
||||||
|
this.status = `Recompute complete — ${data.result.updated} of ${data.result.scanned} schedule(s) updated.`;
|
||||||
|
} catch (error) {
|
||||||
|
this.statusType = 'error';
|
||||||
|
this.status = error.message;
|
||||||
|
} finally {
|
||||||
|
this.recomputing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
async save() {
|
async save() {
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
this.status = '';
|
this.status = '';
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
class="mt-2"
|
class="mt-2"
|
||||||
label="Field mapping (asset field → CMDB column, JSON)"
|
label="Field mapping (asset field → CMDB column, JSON)"
|
||||||
:rows="6"
|
:rows="6"
|
||||||
hint="Maps MixedAssets asset fields to ServiceNow CI columns. Leave default to use the built-in hardware mapping."
|
hint="Maps DepreCore asset fields to ServiceNow CI columns. Leave default to use the built-in hardware mapping."
|
||||||
persistent-hint
|
persistent-hint
|
||||||
:error-messages="mapError"
|
:error-messages="mapError"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-btn variant="text" prepend-icon="mdi-logout" @click="$emit('logout')">Sign out</v-btn>
|
<v-btn variant="text" prepend-icon="mdi-logout" @click="$emit('logout')">Sign out</v-btn>
|
||||||
|
<v-btn variant="text" prepend-icon="mdi-lock-reset" @click="profileMenu = false; $emit('change-password')">Change password</v-btn>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save</v-btn>
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
@@ -118,7 +119,7 @@ export default {
|
|||||||
title: { type: String, required: true },
|
title: { type: String, required: true },
|
||||||
user: { type: Object, required: true }
|
user: { type: Object, required: true }
|
||||||
},
|
},
|
||||||
emits: ['logout', 'open-help', 'save-preferences', 'toggle-nav'],
|
emits: ['change-password', 'logout', 'open-help', 'save-preferences', 'toggle-nav'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
accentPresets,
|
accentPresets,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<v-card class="user-manual-print" height="90vh">
|
<v-card class="user-manual-print" height="90vh">
|
||||||
<v-toolbar color="primary" density="comfortable" class="px-2">
|
<v-toolbar color="primary" density="comfortable" class="px-2">
|
||||||
<v-icon icon="mdi-book-open-page-variant" class="ml-2 mr-2" />
|
<v-icon icon="mdi-book-open-page-variant" class="ml-2 mr-2" />
|
||||||
<v-toolbar-title class="font-weight-bold">MixedAssets User Guide</v-toolbar-title>
|
<v-toolbar-title class="font-weight-bold">DepreCore User Guide</v-toolbar-title>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn variant="text" prepend-icon="mdi-printer" class="d-print-none" @click="print">Print / Save PDF</v-btn>
|
<v-btn variant="text" prepend-icon="mdi-printer" class="d-print-none" @click="print">Print / Save PDF</v-btn>
|
||||||
<v-btn icon="mdi-close" variant="text" class="d-print-none" @click="$emit('update:modelValue', false)" />
|
<v-btn icon="mdi-close" variant="text" class="d-print-none" @click="$emit('update:modelValue', false)" />
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div ref="content" class="manual-content user-manual-content">
|
<div ref="content" class="manual-content user-manual-content">
|
||||||
<div class="manual-doc-title d-none d-print-block">
|
<div class="manual-doc-title d-none d-print-block">
|
||||||
<h1>MixedAssets User Guide</h1>
|
<h1>DepreCore User Guide</h1>
|
||||||
<p class="quiet">Fixed-asset management, depreciation, maintenance & integrations</p>
|
<p class="quiet">Fixed-asset management, depreciation, maintenance & integrations</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
<section v-show="isShown('overview')" class="manual-section">
|
<section v-show="isShown('overview')" class="manual-section">
|
||||||
<h2>Getting started</h2>
|
<h2>Getting started</h2>
|
||||||
<p>
|
<p>
|
||||||
MixedAssets is an integrated system for tracking physical assets through their full lifecycle —
|
DepreCore is an integrated system for tracking physical assets through their full lifecycle —
|
||||||
acquisition, depreciation across multiple accounting books, preventative maintenance, disposal — alongside
|
acquisition, depreciation across multiple accounting books, preventative maintenance, disposal — alongside
|
||||||
a parts inventory, a contacts/CRM directory, an alerting engine, and connectors to email, webhooks, and ServiceNow.
|
a parts inventory, a contacts/CRM directory, an alerting engine, and connectors to email, webhooks, and ServiceNow.
|
||||||
</p>
|
</p>
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Accessibility</h3>
|
<h3>Accessibility</h3>
|
||||||
<p>MixedAssets is built to work with assistive technology:</p>
|
<p>DepreCore is built to work with assistive technology:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Press <strong>Tab</strong> to reveal a “Skip to main content” link, and to move through controls with a clear keyboard focus outline.</li>
|
<li>Press <strong>Tab</strong> to reveal a “Skip to main content” link, and to move through controls with a clear keyboard focus outline.</li>
|
||||||
<li>Images and photos carry descriptive text for screen readers, and icon-only buttons have spoken labels.</li>
|
<li>Images and photos carry descriptive text for screen readers, and icon-only buttons have spoken labels.</li>
|
||||||
@@ -302,6 +302,16 @@
|
|||||||
applicable year’s limit (less any dollar-for-dollar phaseout). If no limit is configured for an asset’s placed-in-service
|
applicable year’s limit (less any dollar-for-dollar phaseout). If no limit is configured for an asset’s placed-in-service
|
||||||
year, the entered §179 amount is left uncapped.
|
year, the entered §179 amount is left uncapped.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
A zone can combine both incentives. The seeded <strong>Qualified Gulf Opportunity (GO) Zone</strong> applies a
|
||||||
|
<strong>50% §168 allowance</strong> (placed in service 2005–2011) <em>and</em> a <strong>+$100,000 §179 increase</strong>
|
||||||
|
(which ends earlier, in 2008) with the phaseout threshold raised by the lesser of $600,000 or the property’s cost. To
|
||||||
|
support cases like this, each zone has an optional separate <em>§179 window</em> (so the §179 increase can expire before
|
||||||
|
the bonus allowance does) and a <em>§179 threshold-increase cap</em>. Assign the zone on the asset and the engine applies
|
||||||
|
each benefit only while the asset’s placed-in-service date is inside that benefit’s own window. Other seeded disaster/relief
|
||||||
|
zones follow the same pattern — e.g. the <strong>Qualified Recovery Assistance (Kansas Disaster Zone)</strong> (50% allowance
|
||||||
|
+ $100,000 §179 increase, placed in service 2007–2008).
|
||||||
|
</p>
|
||||||
|
|
||||||
<h3>The PM (maintenance) book</h3>
|
<h3>The PM (maintenance) book</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -317,6 +327,25 @@
|
|||||||
reports. You can save report configurations to rerun later and export results to PDF, Excel, or CSV. To print an individual
|
reports. You can save report configurations to rerun later and export results to PDF, Excel, or CSV. To print an individual
|
||||||
PM plan, run the PM plan report and export it.
|
PM plan, run the PM plan report and export it.
|
||||||
</p>
|
</p>
|
||||||
|
<p>The catalog is grouped for audit-grade internal, board, and client reporting:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Journals & registers</strong> — the <strong>Asset journal</strong> (a full per-asset dossier of financials,
|
||||||
|
maintenance history, notes, warranties, adjustments and disposals, with photo thumbnails embedded in the PDF/print
|
||||||
|
export), the <strong>Fixed asset journal</strong> (complete register), and GL <strong>journal entries</strong> (debit/credit)
|
||||||
|
for monthly depreciation, disposals, revaluations, and write-offs.</li>
|
||||||
|
<li><strong>Transactions</strong> — event listings for assets <strong>purchased</strong>, <strong>disposed</strong>,
|
||||||
|
<strong>revalued</strong>, and <strong>written off</strong>, each filterable by year and an optional month.</li>
|
||||||
|
<li><strong>Summaries</strong> — aggregated by category or method: asset, depreciation, YTD depreciation, purchase,
|
||||||
|
disposal, revaluation, and write-off summaries.</li>
|
||||||
|
<li><strong>Detail</strong> — <strong>Asset history</strong>, a chronological timeline of every event for one asset
|
||||||
|
(acquisition, edits, maintenance, notes, warranties, adjustments, and disposal).</li>
|
||||||
|
</ul>
|
||||||
|
<p class="quiet text-body-2">
|
||||||
|
Reporting conventions: a <strong>“… journal”</strong> is a double-entry GL posting (debits = credits); a
|
||||||
|
<strong>“… transaction”</strong> lists the underlying records. <strong>Revaluation</strong> reports cover
|
||||||
|
revaluation / write-up / write-down adjustments; <strong>write-off</strong> reports cover impairments plus abandonment
|
||||||
|
disposals. <em>Future depreciation calculation</em> is the multi-year projection.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- TAX RULES -->
|
<!-- TAX RULES -->
|
||||||
@@ -593,7 +622,7 @@
|
|||||||
<section v-show="isShown('servicenow')" class="manual-section">
|
<section v-show="isShown('servicenow')" class="manual-section">
|
||||||
<h2>ServiceNow integration</h2>
|
<h2>ServiceNow integration</h2>
|
||||||
<p>
|
<p>
|
||||||
MixedAssets connects to ServiceNow in two directions, both configured in <strong>Administration → ServiceNow integration</strong>:
|
DepreCore connects to ServiceNow in two directions, both configured in <strong>Administration → ServiceNow integration</strong>:
|
||||||
pushing <strong>incidents</strong> out, and pulling <strong>asset data</strong> in from the CMDB.
|
pushing <strong>incidents</strong> out, and pulling <strong>asset data</strong> in from the CMDB.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -770,7 +799,7 @@
|
|||||||
</v-alert>
|
</v-alert>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="manual-footer quiet">MixedAssets User Guide · select a topic on the left, or use Print / Save PDF for the full manual.</div>
|
<div class="manual-footer quiet">DepreCore User Guide · select a topic on the left, or use Print / Save PDF for the full manual.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -790,7 +819,7 @@ export default {
|
|||||||
printing: false,
|
printing: false,
|
||||||
sampleRuleSet: [
|
sampleRuleSet: [
|
||||||
'{',
|
'{',
|
||||||
' "schema": "mixedassets.taxRuleSet.v1",',
|
' "schema": "deprecore.taxRuleSet.v1",',
|
||||||
' "jurisdiction": "US-CA",',
|
' "jurisdiction": "US-CA",',
|
||||||
' "name": "California depreciation",',
|
' "name": "California depreciation",',
|
||||||
' "version": "2026.1",',
|
' "version": "2026.1",',
|
||||||
|
|||||||
@@ -8,12 +8,12 @@
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<v-switch v-model="form.webhook_enabled" label="Enable webhook delivery" color="primary" density="compact" hide-details />
|
<v-switch v-model="form.webhook_enabled" label="Enable webhook delivery" color="primary" density="compact" hide-details />
|
||||||
<div />
|
<div />
|
||||||
<v-text-field v-model="form.webhook_url" class="full" label="Webhook URL" placeholder="https://example.com/hooks/mixedassets" />
|
<v-text-field v-model="form.webhook_url" class="full" label="Webhook URL" placeholder="https://example.com/hooks/deprecore" />
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="form.webhook_secret"
|
v-model="form.webhook_secret"
|
||||||
type="password"
|
type="password"
|
||||||
:label="settings.webhook_secret_set ? 'Signing secret (unchanged)' : 'Signing secret (optional)'"
|
:label="settings.webhook_secret_set ? 'Signing secret (unchanged)' : 'Signing secret (optional)'"
|
||||||
hint="If set, each request is signed with HMAC-SHA256 in the X-MixedAssets-Signature header"
|
hint="If set, each request is signed with HMAC-SHA256 in the X-DepreCore-Signature header"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -99,15 +99,15 @@ export const customFieldTypes = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const themeItems = [
|
export const themeItems = [
|
||||||
{ title: 'Dark', value: 'mixedAssetsDark', props: { subtitle: 'Default dark' } },
|
{ title: 'Dark', value: 'depreCoreDark', props: { subtitle: 'Default dark' } },
|
||||||
{ title: 'Light', value: 'mixedAssets', props: { subtitle: 'Default light' } },
|
{ title: 'Light', value: 'depreCore', props: { subtitle: 'Default light' } },
|
||||||
{ title: 'Ocean', value: 'mixedAssetsOcean', props: { subtitle: 'Dark · teal' } },
|
{ title: 'Ocean', value: 'depreCoreOcean', props: { subtitle: 'Dark · teal' } },
|
||||||
{ title: 'Slate', value: 'mixedAssetsSlate', props: { subtitle: 'Dark · neutral' } },
|
{ title: 'Slate', value: 'depreCoreSlate', props: { subtitle: 'Dark · neutral' } },
|
||||||
{ title: 'High contrast', value: 'mixedAssetsContrast', props: { subtitle: 'Dark · accessible' } },
|
{ title: 'High contrast', value: 'depreCoreContrast', props: { subtitle: 'Dark · accessible' } },
|
||||||
{ title: 'Sepia', value: 'mixedAssetsSepia', props: { subtitle: 'Light · warm' } }
|
{ title: 'Sepia', value: 'depreCoreSepia', props: { subtitle: 'Light · warm' } }
|
||||||
];
|
];
|
||||||
|
|
||||||
export const darkThemes = ['mixedAssetsDark', 'mixedAssetsOcean', 'mixedAssetsSlate', 'mixedAssetsContrast'];
|
export const darkThemes = ['depreCoreDark', 'depreCoreOcean', 'depreCoreSlate', 'depreCoreContrast'];
|
||||||
|
|
||||||
// Accessibility: user-adjustable base text size (scales the whole UI via the root font size).
|
// Accessibility: user-adjustable base text size (scales the whole UI via the root font size).
|
||||||
export const fontSizeItems = [
|
export const fontSizeItems = [
|
||||||
@@ -170,6 +170,8 @@ export const navItems = [
|
|||||||
group: true,
|
group: true,
|
||||||
children: [
|
children: [
|
||||||
{ key: 'admin-users', label: 'Users & Teams', icon: 'mdi-account-group-outline' },
|
{ key: 'admin-users', label: 'Users & Teams', icon: 'mdi-account-group-outline' },
|
||||||
|
{ key: 'admin-security', label: 'Password & Security', icon: 'mdi-shield-key-outline' },
|
||||||
|
{ key: 'admin-audit', label: 'Activity & Audit Trail', icon: 'mdi-history' },
|
||||||
{ key: 'admin-config', label: 'Application Configuration', icon: 'mdi-tune' },
|
{ key: 'admin-config', label: 'Application Configuration', icon: 'mdi-tune' },
|
||||||
{ key: 'admin-integrations', label: 'Integrations', icon: 'mdi-transit-connection-variant' }
|
{ key: 'admin-integrations', label: 'Integrations', icon: 'mdi-transit-connection-variant' }
|
||||||
]
|
]
|
||||||
|
|||||||
14
src/main.js
14
src/main.js
@@ -14,9 +14,9 @@ const vuetify = createVuetify({
|
|||||||
components,
|
components,
|
||||||
directives,
|
directives,
|
||||||
theme: {
|
theme: {
|
||||||
defaultTheme: 'mixedAssetsDark',
|
defaultTheme: 'depreCoreDark',
|
||||||
themes: {
|
themes: {
|
||||||
mixedAssets: {
|
depreCore: {
|
||||||
dark: false,
|
dark: false,
|
||||||
colors: {
|
colors: {
|
||||||
background: '#f7f8fb',
|
background: '#f7f8fb',
|
||||||
@@ -30,7 +30,7 @@ const vuetify = createVuetify({
|
|||||||
error: '#b3261e'
|
error: '#b3261e'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixedAssetsDark: {
|
depreCoreDark: {
|
||||||
dark: true,
|
dark: true,
|
||||||
colors: {
|
colors: {
|
||||||
background: '#11161d',
|
background: '#11161d',
|
||||||
@@ -44,7 +44,7 @@ const vuetify = createVuetify({
|
|||||||
error: '#ffb4ab'
|
error: '#ffb4ab'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixedAssetsOcean: {
|
depreCoreOcean: {
|
||||||
dark: true,
|
dark: true,
|
||||||
colors: {
|
colors: {
|
||||||
background: '#0b1620',
|
background: '#0b1620',
|
||||||
@@ -58,7 +58,7 @@ const vuetify = createVuetify({
|
|||||||
error: '#ff8f87'
|
error: '#ff8f87'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixedAssetsSlate: {
|
depreCoreSlate: {
|
||||||
dark: true,
|
dark: true,
|
||||||
colors: {
|
colors: {
|
||||||
background: '#17191e',
|
background: '#17191e',
|
||||||
@@ -72,7 +72,7 @@ const vuetify = createVuetify({
|
|||||||
error: '#f2a59c'
|
error: '#f2a59c'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixedAssetsContrast: {
|
depreCoreContrast: {
|
||||||
dark: true,
|
dark: true,
|
||||||
colors: {
|
colors: {
|
||||||
background: '#000000',
|
background: '#000000',
|
||||||
@@ -86,7 +86,7 @@ const vuetify = createVuetify({
|
|||||||
error: '#ff5252'
|
error: '#ff5252'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixedAssetsSepia: {
|
depreCoreSepia: {
|
||||||
dark: false,
|
dark: false,
|
||||||
colors: {
|
colors: {
|
||||||
background: '#f3ead6',
|
background: '#f3ead6',
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function apiRequest(path, token, options = {}) {
|
|||||||
// An invalid/expired token (401) means the session is no longer valid — signal the
|
// An invalid/expired token (401) means the session is no longer valid — signal the
|
||||||
// app to drop back to the login screen. (403 is a role/permission denial, not a session issue.)
|
// app to drop back to the login screen. (403 is a role/permission denial, not a session issue.)
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
window.dispatchEvent(new CustomEvent('mixedassets:unauthorized'));
|
window.dispatchEvent(new CustomEvent('deprecore:unauthorized'));
|
||||||
}
|
}
|
||||||
throw new Error(body.error || `Request failed: ${response.status}`);
|
throw new Error(body.error || `Request failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,11 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #cell-status="{ row }">
|
<template #cell-status="{ row }">
|
||||||
<span :class="['status-chip', row.status === 'active' ? 'status-in_service' : 'status-disposed']">{{ row.status }}</span>
|
<span :class="['status-chip', row.status === 'active' ? 'status-in_service' : 'status-disposed']">{{ row.status }}</span>
|
||||||
|
<v-chip v-if="isLocked(row)" size="x-small" color="error" variant="tonal" class="ml-1" prepend-icon="mdi-lock">Locked</v-chip>
|
||||||
|
<v-chip v-if="row.must_change_password" size="x-small" color="warning" variant="tonal" class="ml-1" title="Must change password at next sign-in">Pwd reset</v-chip>
|
||||||
</template>
|
</template>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
|
<v-btn v-if="isLocked(row)" icon="mdi-lock-open-variant" size="small" variant="text" color="warning" title="Unlock account" @click="unlockUser(row)" />
|
||||||
<v-btn icon="mdi-pencil" size="small" variant="text" @click="editUser(row)" />
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="editUser(row)" />
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
@@ -157,6 +160,16 @@
|
|||||||
</v-dialog>
|
</v-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- PASSWORD & SECURITY -->
|
||||||
|
<template v-if="adminSection === 'admin-security'">
|
||||||
|
<PasswordPolicySettings :token="token" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ACTIVITY & AUDIT TRAIL -->
|
||||||
|
<template v-if="adminSection === 'admin-audit'">
|
||||||
|
<AuditTrailSettings :token="token" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- APPLICATION CONFIGURATION -->
|
<!-- APPLICATION CONFIGURATION -->
|
||||||
<template v-if="adminSection === 'admin-config'">
|
<template v-if="adminSection === 'admin-config'">
|
||||||
<v-card class="span-6 panel-pad">
|
<v-card class="span-6 panel-pad">
|
||||||
@@ -244,8 +257,9 @@
|
|||||||
<v-select v-model="userForm.team_id" :items="teamOptions" item-title="title" item-value="value" clearable label="Team" />
|
<v-select v-model="userForm.team_id" :items="teamOptions" item-title="title" item-value="value" clearable label="Team" />
|
||||||
<v-select v-model="userForm.role" :items="roleSelectItems" item-title="title" item-value="value" label="Role" />
|
<v-select v-model="userForm.role" :items="roleSelectItems" item-title="title" item-value="value" label="Role" />
|
||||||
<v-select v-model="userForm.status" :items="['active', 'inactive']" label="Status" />
|
<v-select v-model="userForm.status" :items="['active', 'inactive']" label="Status" />
|
||||||
<v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" />
|
<v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" :hint="userForm.id ? 'Leave blank to keep the current password' : 'Leave blank to issue a temporary password'" persistent-hint />
|
||||||
</div>
|
</div>
|
||||||
|
<v-switch v-model="userForm.must_change_password" color="primary" density="compact" hide-details class="mt-2" label="Require password change at next sign-in" />
|
||||||
<v-alert v-if="userForm.role" class="mt-3" density="compact" type="info">
|
<v-alert v-if="userForm.role" class="mt-3" density="compact" type="info">
|
||||||
{{ roleSummary(userForm.role) }}
|
{{ roleSummary(userForm.role) }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
@@ -278,7 +292,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import AssetClassSettings from '../components/AssetClassSettings.vue';
|
import AssetClassSettings from '../components/AssetClassSettings.vue';
|
||||||
import AssetIdTemplateSettings from '../components/AssetIdTemplateSettings.vue';
|
import AssetIdTemplateSettings from '../components/AssetIdTemplateSettings.vue';
|
||||||
|
import AuditTrailSettings from '../components/AuditTrailSettings.vue';
|
||||||
import CategorySettings from '../components/CategorySettings.vue';
|
import CategorySettings from '../components/CategorySettings.vue';
|
||||||
|
import PasswordPolicySettings from '../components/PasswordPolicySettings.vue';
|
||||||
import DataTable from '../components/DataTable.vue';
|
import DataTable from '../components/DataTable.vue';
|
||||||
import DepartmentSettings from '../components/DepartmentSettings.vue';
|
import DepartmentSettings from '../components/DepartmentSettings.vue';
|
||||||
import DepreciationZoneSettings from '../components/DepreciationZoneSettings.vue';
|
import DepreciationZoneSettings from '../components/DepreciationZoneSettings.vue';
|
||||||
@@ -291,7 +307,7 @@ import ServiceNowSettings from '../components/ServiceNowSettings.vue';
|
|||||||
import WebhookSettings from '../components/WebhookSettings.vue';
|
import WebhookSettings from '../components/WebhookSettings.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { AssetClassSettings, AssetIdTemplateSettings, CategorySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, WebhookSettings },
|
components: { AssetClassSettings, AssetIdTemplateSettings, AuditTrailSettings, CategorySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PasswordPolicySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, WebhookSettings },
|
||||||
props: {
|
props: {
|
||||||
token: { type: String, default: '' },
|
token: { type: String, default: '' },
|
||||||
section: { type: String, default: 'admin-users' },
|
section: { type: String, default: 'admin-users' },
|
||||||
@@ -309,7 +325,7 @@ export default {
|
|||||||
workdaySaving: { type: Boolean, default: false },
|
workdaySaving: { type: Boolean, default: false },
|
||||||
workdaySyncing: { type: Boolean, default: false }
|
workdaySyncing: { type: Boolean, default: false }
|
||||||
},
|
},
|
||||||
emits: ['create-team', 'update-team', 'delete-team', 'create-user', 'update-user', 'create-role', 'update-role', 'delete-role', 'save-settings', 'save-workday', 'sync-workday', 'categories-updated'],
|
emits: ['create-team', 'update-team', 'delete-team', 'create-user', 'update-user', 'unlock-user', 'create-role', 'update-role', 'delete-role', 'save-settings', 'save-workday', 'sync-workday', 'categories-updated'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
localSettings: { ...this.settings },
|
localSettings: { ...this.settings },
|
||||||
@@ -358,7 +374,7 @@ export default {
|
|||||||
return groups;
|
return groups;
|
||||||
},
|
},
|
||||||
adminSection() {
|
adminSection() {
|
||||||
return ['admin-users', 'admin-config', 'admin-integrations'].includes(this.section) ? this.section : 'admin-users';
|
return ['admin-users', 'admin-security', 'admin-audit', 'admin-config', 'admin-integrations'].includes(this.section) ? this.section : 'admin-users';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -384,7 +400,8 @@ export default {
|
|||||||
team_id: null,
|
team_id: null,
|
||||||
role: 'viewer',
|
role: 'viewer',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
password: ''
|
password: '',
|
||||||
|
must_change_password: false
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
editUser(account) {
|
editUser(account) {
|
||||||
@@ -395,10 +412,17 @@ export default {
|
|||||||
team_id: account.team_id,
|
team_id: account.team_id,
|
||||||
role: account.role,
|
role: account.role,
|
||||||
status: account.status,
|
status: account.status,
|
||||||
password: ''
|
password: '',
|
||||||
|
must_change_password: Boolean(account.must_change_password)
|
||||||
};
|
};
|
||||||
this.userDialog = true;
|
this.userDialog = true;
|
||||||
},
|
},
|
||||||
|
isLocked(account) {
|
||||||
|
return Boolean(account.locked_until && new Date(account.locked_until).getTime() > Date.now());
|
||||||
|
},
|
||||||
|
unlockUser(account) {
|
||||||
|
this.$emit('unlock-user', account);
|
||||||
|
},
|
||||||
roleName(roleKey) {
|
roleName(roleKey) {
|
||||||
return (this.roles.find((r) => r.key === roleKey) || {}).name || roleKey;
|
return (this.roles.find((r) => r.key === roleKey) || {}).name || roleKey;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export default {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ year: this.ledgerYear, format })
|
body: JSON.stringify({ year: this.ledgerYear, format })
|
||||||
})).blob();
|
})).blob();
|
||||||
saveBlob(blob, `mixedassets-${this.ledgerBook}-ledger-${this.ledgerYear}.${format}`);
|
saveBlob(blob, `deprecore-${this.ledgerBook}-ledger-${this.ledgerYear}.${format}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<v-card class="login-card" elevation="12">
|
<v-card class="login-card" elevation="12">
|
||||||
<div class="brand-lockup">
|
<div class="brand-lockup">
|
||||||
<span class="brand-mark">MA</span>
|
<span class="brand-mark">MA</span>
|
||||||
<span class="brand-name">MixedAssets</span>
|
<span class="brand-name">DepreCore</span>
|
||||||
</div>
|
</div>
|
||||||
<v-divider />
|
<v-divider />
|
||||||
<v-card-text class="pa-5">
|
<v-card-text class="pa-5">
|
||||||
|
|||||||
@@ -109,6 +109,31 @@
|
|||||||
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="form.components.splice(index, 1)" />
|
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="form.components.splice(index, 1)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-row mt-4 mb-1">
|
||||||
|
<h3 class="section-title">Usage meters</h3>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="addMeter">Add meter</v-btn>
|
||||||
|
</div>
|
||||||
|
<p class="quiet text-body-2">
|
||||||
|
Track usage (operating hours, kWh, gallons, cycles…) so service comes due on accumulated use, not just the calendar.
|
||||||
|
Requires usage-based scheduling in PM settings.
|
||||||
|
</p>
|
||||||
|
<div v-for="(meter, index) in form.meters" :key="`m${index}`" class="pm-component">
|
||||||
|
<v-text-field v-model="meter.label" density="compact" hide-details placeholder="Meter (e.g. Operating Hours)" style="flex:1 1 auto" />
|
||||||
|
<v-text-field v-model="meter.unit" density="compact" hide-details placeholder="Unit" style="max-width:100px" />
|
||||||
|
<v-text-field v-model.number="meter.usage_interval" type="number" density="compact" hide-details placeholder="Due every" style="max-width:130px" />
|
||||||
|
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="form.meters.splice(index, 1)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-switch
|
||||||
|
v-model="form.lifespan_adjust"
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="mt-3"
|
||||||
|
label="Tighten the interval as the asset ages (lifespan wear curve)"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="toolbar-row mt-4 mb-1">
|
<div class="toolbar-row mt-4 mb-1">
|
||||||
<h3 class="section-title">Guidance photos</h3>
|
<h3 class="section-title">Guidance photos</h3>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
@@ -145,7 +170,7 @@ import { currency } from '../utils/format';
|
|||||||
import { clonePlain } from '../utils/clone';
|
import { clonePlain } from '../utils/clone';
|
||||||
|
|
||||||
function blankPlan() {
|
function blankPlan() {
|
||||||
return { id: null, name: '', description: '', category: '', frequency_value: 3, frequency_unit: 'months', estimated_minutes: null, instructions: '', steps: [], components: [], photos: [] };
|
return { id: null, name: '', description: '', category: '', frequency_value: 3, frequency_unit: 'months', estimated_minutes: null, instructions: '', steps: [], components: [], meters: [], lifespan_adjust: false, photos: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -248,6 +273,8 @@ export default {
|
|||||||
...clonePlain(full),
|
...clonePlain(full),
|
||||||
steps: clonePlain(full.steps || []),
|
steps: clonePlain(full.steps || []),
|
||||||
components: clonePlain(full.components || []),
|
components: clonePlain(full.components || []),
|
||||||
|
meters: clonePlain(full.meters || []),
|
||||||
|
lifespan_adjust: Boolean(full.lifespan_adjust),
|
||||||
photos: clonePlain(full.photos || [])
|
photos: clonePlain(full.photos || [])
|
||||||
};
|
};
|
||||||
this.dialog = true;
|
this.dialog = true;
|
||||||
@@ -258,6 +285,9 @@ export default {
|
|||||||
addComponent() {
|
addComponent() {
|
||||||
this.form.components.push({ part_number: '', description: '', supplier: '', cost: null });
|
this.form.components.push({ part_number: '', description: '', supplier: '', cost: null });
|
||||||
},
|
},
|
||||||
|
addMeter() {
|
||||||
|
this.form.meters.push({ label: '', unit: '', usage_interval: null });
|
||||||
|
},
|
||||||
onPhotoPick(event) {
|
onPhotoPick(event) {
|
||||||
for (const file of Array.from(event.target.files || [])) {
|
for (const file of Array.from(event.target.files || [])) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
|||||||
@@ -123,6 +123,26 @@
|
|||||||
search-placeholder="Filter this report"
|
search-placeholder="Filter this report"
|
||||||
empty-text="No data for the selected options."
|
empty-text="No data for the selected options."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<template v-if="report.meta && report.meta.sections">
|
||||||
|
<div v-for="section in report.meta.sections" :key="section.title" class="mt-5">
|
||||||
|
<h3 class="section-title mb-2">{{ section.title }}</h3>
|
||||||
|
<DataTable
|
||||||
|
:columns="section.columns"
|
||||||
|
:rows="section.rows"
|
||||||
|
:empty-text="`No ${section.title.toLowerCase()} recorded.`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="report.meta && report.meta.photo_count" class="mt-5">
|
||||||
|
<h3 class="section-title mb-2">Photos ({{ report.meta.photo_count }})</h3>
|
||||||
|
<p class="quiet text-body-2">
|
||||||
|
<span v-if="report.meta.photos && report.meta.photos.length">{{ report.meta.photos.map((p) => p.caption).join(' · ') }}</span>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</p>
|
||||||
|
<p class="quiet text-caption">Photo thumbnails are embedded in the PDF / print export.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="quiet text-body-2">Select a report to begin.</div>
|
<div v-else class="quiet text-body-2">Select a report to begin.</div>
|
||||||
|
|
||||||
@@ -265,7 +285,7 @@ export default {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ type: this.selectedType, options: this.options, format })
|
body: JSON.stringify({ type: this.selectedType, options: this.options, format })
|
||||||
})).blob();
|
})).blob();
|
||||||
saveBlob(blob, `mixedassets-${this.selectedType}.${format}`);
|
saveBlob(blob, `deprecore-${this.selectedType}.${format}`);
|
||||||
},
|
},
|
||||||
openSave() {
|
openSave() {
|
||||||
this.saveName = this.report?.title || 'Saved report';
|
this.saveName = this.report?.title || 'Saved report';
|
||||||
|
|||||||
Reference in New Issue
Block a user