Updated A LOT

This commit is contained in:
2026-06-05 04:15:24 -05:00
parent 8335f1af1b
commit 4072695432
92 changed files with 16139 additions and 570 deletions

View File

@@ -1,47 +1,81 @@
const roles = ['admin', 'finance', 'operations', 'viewer'];
// Capability catalog — the granular permissions the application understands. Routes are
// guarded by these keys (see middleware/auth requireCapability); roles are sets of them.
// This is pure data (no DB) so it can be required anywhere without circular dependencies.
const rolePermissions = {
admin: [
'Manage users, teams, roles, settings, and integrations',
'Create, edit, delete, import, export, and assign assets',
'Manage templates, tax rules, reports, and Workday sync'
],
finance: [
'Create, edit, delete, import, and export assets',
'Manage templates, tax rules, reports, and depreciation workflows',
'Assign and release assets'
],
operations: [
'Create and edit assets',
'Assign and release assets',
'Manage asset tasks, files, employees, and custody details'
],
viewer: [
'View assets, assignments, employees, templates, tax rules, and reports',
'No create, update, delete, import, export, or integration access'
]
};
const roleCapabilities = [
{ key: 'view_assets', label: 'View assets', roles: ['admin', 'finance', 'operations', 'viewer'] },
{ key: 'edit_assets', label: 'Create/edit assets', roles: ['admin', 'finance', 'operations'] },
{ key: 'delete_assets', label: 'Delete assets', roles: ['admin', 'finance'] },
{ key: 'import_export', label: 'Import/export data', roles: ['admin', 'finance'] },
{ key: 'assign_assets', label: 'Assign assets', roles: ['admin', 'finance', 'operations'] },
{ key: 'templates', label: 'Manage templates', roles: ['admin', 'finance'] },
{ key: 'tax_rules', label: 'Manage tax rules', roles: ['admin', 'finance'] },
{ key: 'workday', label: 'Manage Workday connector', roles: ['admin'] },
{ key: 'users', label: 'Manage users and teams', roles: ['admin'] },
{ key: 'settings', label: 'Manage app settings', roles: ['admin'] }
const CAPABILITIES = [
// Assets
{ key: 'assets.view', label: 'View assets', group: 'Assets' },
{ key: 'assets.edit', label: 'Create & edit assets (files, photos, warranties, tasks, notes)', group: 'Assets' },
{ key: 'assets.delete', label: 'Delete assets', group: 'Assets' },
{ key: 'assets.bulk', label: 'Import, export, mass-update & CMDB sync', group: 'Assets' },
{ key: 'assets.assign', label: 'Assign assets & manage employees', group: 'Assets' },
// Maintenance
{ key: 'pm.view', label: 'View maintenance', group: 'Maintenance' },
{ key: 'pm.manage', label: 'Create & edit PM plans and defaults', group: 'Maintenance' },
{ key: 'pm.complete', label: 'Attach & complete PM on assets', group: 'Maintenance' },
{ key: 'parts.manage', label: 'Manage parts inventory', group: 'Maintenance' },
{ key: 'alerts.manage', label: 'Work alerts (scan, acknowledge, dismiss, ticket)', group: 'Maintenance' },
// Contacts
{ key: 'contacts.manage', label: 'Manage contacts & CRM', group: 'Contacts' },
// Finance
{ key: 'finance.view', label: 'View reports, books & tax rules', group: 'Finance' },
{ key: 'finance.manage', label: 'Manage books, tax rules, disposals & leases', group: 'Finance' },
{ key: 'reports.save', label: 'Save & delete reports', group: 'Finance' },
// Configuration
{ key: 'config.manage', label: 'Manage templates, categories & ID templates', group: 'Configuration' },
// Administration
{ key: 'admin.users', label: 'Manage users & teams', group: 'Administration' },
{ key: 'admin.roles', label: 'Manage roles', group: 'Administration' },
{ key: 'admin.settings', label: 'Manage application settings', group: 'Administration' },
{ key: 'admin.integrations', label: 'Manage integrations (email, webhook, ServiceNow, Workday)', group: 'Administration' }
];
function isValidRole(role) {
return roles.includes(role);
const CAPABILITY_KEYS = CAPABILITIES.map((c) => c.key);
// '*' is a wildcard granting every capability (used by the admin role and future-proofs new ones).
const WILDCARD = '*';
const FINANCE_CAPS = [
'assets.view', 'assets.edit', 'assets.delete', 'assets.bulk', 'assets.assign',
'pm.view', 'pm.manage', 'pm.complete', 'parts.manage', 'alerts.manage',
'contacts.manage', 'finance.view', 'finance.manage', 'reports.save', 'config.manage'
];
const OPERATIONS_CAPS = [
'assets.view', 'assets.edit', 'assets.assign',
'pm.view', 'pm.manage', 'pm.complete', 'parts.manage', 'alerts.manage',
'contacts.manage', 'reports.save'
];
// Built-in roles. is_system roles cannot be deleted (admin/built-ins) but their
// capabilities can be edited, except admin which always keeps the wildcard.
const DEFAULT_ROLES = [
{ key: 'admin', name: 'Administrator', description: 'Full system access, including users, roles, settings, and integrations.', capabilities: [WILDCARD], is_system: 1, locked: 1, sort_order: 0 },
{ key: 'finance', name: 'Finance', description: 'Full asset, maintenance, and financial management; no system administration.', capabilities: FINANCE_CAPS, is_system: 1, locked: 0, sort_order: 1 },
{ key: 'operations', name: 'Operations', description: 'Day-to-day asset, maintenance, parts, contacts, and alert work.', capabilities: OPERATIONS_CAPS, is_system: 1, locked: 0, sort_order: 2 },
{ key: 'pm_admin', name: 'PM Admin', description: 'Manages preventative maintenance: plans, defaults, completion, parts, and alerts.', capabilities: ['assets.view', 'pm.view', 'pm.manage', 'pm.complete', 'parts.manage', 'alerts.manage', 'reports.save'], is_system: 1, locked: 0, sort_order: 3 },
{ key: 'pm_user', name: 'PM User', description: 'Completes preventative maintenance and views assets, parts, and alerts.', capabilities: ['assets.view', 'pm.view', 'pm.complete', 'alerts.manage'], is_system: 1, locked: 0, sort_order: 4 },
{ key: 'viewer', name: 'Viewer', description: 'Read-only access to assets, maintenance, and reports.', capabilities: ['assets.view', 'pm.view', 'finance.view'], is_system: 1, locked: 0, sort_order: 5 }
];
// Does a capability set satisfy a required capability? Honors the wildcard.
function capabilitySetIncludes(capabilities, required) {
if (!Array.isArray(capabilities)) return false;
return capabilities.includes(WILDCARD) || capabilities.includes(required);
}
// Normalize an arbitrary capability list to known keys (admin/wildcard preserved).
function sanitizeCapabilities(list) {
if (Array.isArray(list) && list.includes(WILDCARD)) return [WILDCARD];
const set = new Set((Array.isArray(list) ? list : []).filter((key) => CAPABILITY_KEYS.includes(key)));
return CAPABILITY_KEYS.filter((key) => set.has(key));
}
module.exports = {
isValidRole,
roleCapabilities,
rolePermissions,
roles
CAPABILITIES,
CAPABILITY_KEYS,
DEFAULT_ROLES,
WILDCARD,
capabilitySetIncludes,
sanitizeCapabilities
};

View File

@@ -0,0 +1,45 @@
/*
* Tests for the capability catalog and default roles used by the RBAC system.
* Roles are stored in the database and editable at runtime; this validates the
* static catalog/defaults and the pure capability-matching helpers.
*/
const accessControl = require('./accessControl');
describe('access control', () => {
test('capability catalog is well-formed', () => {
const { CAPABILITIES, CAPABILITY_KEYS } = accessControl;
expect(CAPABILITIES.length).toBeGreaterThan(0);
for (const cap of CAPABILITIES) {
expect(typeof cap.key).toBe('string');
expect(typeof cap.label).toBe('string');
expect(typeof cap.group).toBe('string');
}
// Keys are unique.
expect(new Set(CAPABILITY_KEYS).size).toBe(CAPABILITY_KEYS.length);
});
test('default roles include the built-ins and PM roles', () => {
const keys = accessControl.DEFAULT_ROLES.map((r) => r.key);
for (const key of ['admin', 'finance', 'operations', 'pm_admin', 'pm_user', 'viewer']) {
expect(keys).toContain(key);
}
const admin = accessControl.DEFAULT_ROLES.find((r) => r.key === 'admin');
expect(admin.capabilities).toEqual(['*']);
const pmUser = accessControl.DEFAULT_ROLES.find((r) => r.key === 'pm_user');
expect(pmUser.capabilities).toContain('pm.complete');
expect(pmUser.capabilities).not.toContain('pm.manage');
});
test('capabilitySetIncludes honors the wildcard', () => {
expect(accessControl.capabilitySetIncludes(['*'], 'anything')).toBe(true);
expect(accessControl.capabilitySetIncludes(['assets.view'], 'assets.view')).toBe(true);
expect(accessControl.capabilitySetIncludes(['assets.view'], 'assets.delete')).toBe(false);
});
test('sanitizeCapabilities drops unknown keys and collapses wildcard', () => {
expect(accessControl.sanitizeCapabilities(['assets.view', 'made.up'])).toEqual(['assets.view']);
expect(accessControl.sanitizeCapabilities(['assets.view', '*'])).toEqual(['*']);
});
});

213
server/services/alerts.js Normal file
View File

@@ -0,0 +1,213 @@
const { all, audit, now, one, run } = require('../db');
const { getConfig, sendMail } = require('./notifications');
const { deliverAlerts } = require('./webhooks');
const servicenow = require('./servicenow');
const { pmAlertCandidates } = require('./pm');
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function daysUntil(dateStr) {
return Math.round((new Date(`${dateStr}T00:00:00`) - new Date(`${todayStr()}T00:00:00`)) / 86400000);
}
function mk(type, referenceType, referenceId, assetId, severity, title, message, dueDate) {
return { type, reference_type: referenceType, reference_id: referenceId, asset_id: assetId, severity, title, message, due_date: dueDate };
}
// Compute the current set of due/overdue/expiring items that warrant an alert.
function candidates(leadDays) {
const today = todayStr();
const horizon = new Date(Date.now() + leadDays * 86400000).toISOString().slice(0, 10);
const list = [];
const tasks = all(
`SELECT t.*, a.asset_id AS asset_code FROM tasks t LEFT JOIN assets a ON a.id = t.asset_id
WHERE t.status != 'complete' AND t.due_date IS NOT NULL`
);
for (const task of tasks) {
const where = task.asset_code ? ` on ${task.asset_code}` : '';
if (task.due_date < today) {
list.push(mk('maintenance_overdue', 'task', task.id, task.asset_id, 'critical', `Overdue: ${task.title}`, `"${task.title}"${where} was due ${task.due_date}.`, task.due_date));
} else if (task.due_date <= horizon) {
list.push(mk('maintenance_due', 'task', task.id, task.asset_id, 'warning', `Upcoming: ${task.title}`, `"${task.title}"${where} is due ${task.due_date}.`, task.due_date));
}
}
const warranties = all(
`SELECT w.*, a.asset_id AS asset_code FROM warranties w LEFT JOIN assets a ON a.id = w.asset_id
WHERE w.expiration_date IS NOT NULL`
);
for (const w of warranties) {
const label = w.provider || 'Warranty';
const where = w.asset_code ? ` on ${w.asset_code}` : '';
if (w.expiration_date < today) {
list.push(mk('warranty_expired', 'warranty', w.id, w.asset_id, 'critical', 'Warranty expired', `${label}${where} expired ${w.expiration_date}.`, w.expiration_date));
} else if (w.expiration_date <= horizon) {
const sev = daysUntil(w.expiration_date) <= 7 ? 'critical' : 'warning';
list.push(mk('warranty_expiring', 'warranty', w.id, w.asset_id, sev, 'Warranty expiring', `${label}${where} expires ${w.expiration_date}.`, w.expiration_date));
}
}
const leases = all(
`SELECT l.*, a.asset_id AS asset_code FROM leases l LEFT JOIN assets a ON a.id = l.asset_id
WHERE l.end_date IS NOT NULL`
);
for (const l of leases) {
if (l.end_date < today || l.end_date > horizon) continue;
const where = l.asset_code ? ` on ${l.asset_code}` : '';
const sev = daysUntil(l.end_date) <= 7 ? 'critical' : 'warning';
list.push(mk('lease_expiring', 'lease', l.id, l.asset_id, sev, 'Lease ending', `Lease with ${l.lessor || 'lessor'}${where} ends ${l.end_date}.`, l.end_date));
}
list.push(...pmAlertCandidates());
return list;
}
// Reconcile computed candidates against stored alerts: create new, refresh existing,
// resolve ones that no longer apply, and respect dismissals.
function scanAlerts(userId) {
const cfg = getConfig();
const ts = now();
const cands = candidates(cfg.leadDays);
const seen = new Set();
const created = [];
for (const c of cands) {
seen.add(`${c.reference_type}:${c.reference_id}:${c.type}`);
const existing = one('SELECT * FROM alerts WHERE reference_type = ? AND reference_id = ? AND type = ?', [c.reference_type, c.reference_id, c.type]);
if (existing) {
if (existing.status === 'dismissed') continue;
run(
`UPDATE alerts SET asset_id = ?, severity = ?, title = ?, message = ?, due_date = ?,
status = CASE WHEN status = 'resolved' THEN 'open' ELSE status END, updated_at = ?
WHERE id = ?`,
[c.asset_id, c.severity, c.title, c.message, c.due_date, ts, existing.id]
);
} else {
const result = run(
`INSERT INTO alerts (type, reference_type, reference_id, asset_id, severity, title, message, due_date, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
[c.type, c.reference_type, c.reference_id, c.asset_id, c.severity, c.title, c.message, c.due_date, ts, ts]
);
created.push(one('SELECT * FROM alerts WHERE id = ?', [result.lastInsertRowid]));
}
}
for (const alert of all("SELECT * FROM alerts WHERE status IN ('open', 'acknowledged')")) {
if (!seen.has(`${alert.reference_type}:${alert.reference_id}:${alert.type}`)) {
run("UPDATE alerts SET status = 'resolved', updated_at = ? WHERE id = ?", [ts, alert.id]);
}
}
if (userId) audit(userId, 'scan', 'alerts', null, null, { created: created.length });
return { created, openCount: one("SELECT COUNT(*) AS c FROM alerts WHERE status = 'open'").c };
}
function listAlerts(query = {}) {
return all(
`SELECT al.*, a.asset_id AS asset_code, a.description AS asset_description, u.name AS acknowledged_by_name
FROM alerts al
LEFT JOIN assets a ON a.id = al.asset_id
LEFT JOIN users u ON u.id = al.acknowledged_by
WHERE (? IS NULL OR al.status = ?) AND (? IS NULL OR al.type = ?)
ORDER BY CASE al.severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, al.due_date IS NULL, al.due_date`,
[query.status || null, query.status || null, query.type || null, query.type || null]
);
}
function summary() {
const rows = all("SELECT status, severity, COUNT(*) AS count FROM alerts GROUP BY status, severity");
const open = rows.filter((r) => r.status === 'open');
return {
open: open.reduce((s, r) => s + r.count, 0),
critical: open.filter((r) => r.severity === 'critical').reduce((s, r) => s + r.count, 0),
acknowledged: rows.filter((r) => r.status === 'acknowledged').reduce((s, r) => s + r.count, 0)
};
}
function setStatus(id, status, userId) {
const before = one('SELECT * FROM alerts WHERE id = ?', [id]);
if (!before) return null;
run(
'UPDATE alerts SET status = ?, acknowledged_by = ?, acknowledged_at = ?, updated_at = ? WHERE id = ?',
[
status,
status === 'acknowledged' ? userId : before.acknowledged_by,
status === 'acknowledged' ? now() : before.acknowledged_at,
now(),
id
]
);
audit(userId, status, 'alert', id, before, { status });
return one('SELECT * FROM alerts WHERE id = ?', [id]);
}
function digestHtml(alerts) {
const rows = alerts.map((a) =>
`<tr><td style="padding:6px 10px;color:${a.severity === 'critical' ? '#b3261e' : '#a56a00'};font-weight:700">${a.severity.toUpperCase()}</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>`
).join('');
return `<h2>MixedAssets alerts</h2><p>${alerts.length} item(s) need attention.</p>` +
`<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>` +
`<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) {
return `MixedAssets alerts (${alerts.length}):\n` +
alerts.map((a) => `- [${a.severity}] ${a.title}${a.message || ''} ${a.due_date ? `(due ${a.due_date})` : ''}`).join('\n');
}
// Scan, then notify on newly raised, not-yet-notified open alerts: an email digest
// (when SMTP is enabled) and/or a per-alert JSON webhook POST (when enabled). An alert
// is marked notified once any channel has handled it, so it is delivered at most once.
async function runAlertCycle(userId) {
const { created } = scanAlerts(userId);
const cfg = getConfig();
const result = { created: created.length, emailed: false, webhooked: false };
const snCfg = servicenow.getConfig();
const emailReady = cfg.enabled && cfg.host && cfg.recipients.length;
const webhookReady = cfg.webhookEnabled && cfg.webhookUrl;
const ticketReady = snCfg.ticketEnabled && snCfg.autoTicket && snCfg.instanceUrl;
if (!emailReady && !webhookReady && !ticketReady) return result;
const pending = all(
`SELECT al.*, a.asset_id AS asset_code
FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id
WHERE al.status = 'open' AND al.notified_at IS NULL`
);
if (!pending.length) return result;
if (emailReady) {
await sendMail({
subject: `MixedAssets: ${pending.length} new alert(s)`,
html: digestHtml(pending),
text: digestText(pending)
});
result.emailed = true;
result.count = pending.length;
}
if (webhookReady) {
const delivery = await deliverAlerts(pending);
result.webhooked = delivery.attempted;
result.delivered = delivery.delivered;
result.failed = delivery.failed;
}
if (ticketReady) {
const tickets = await servicenow.autoTicketAlerts(pending, userId);
result.ticketed = tickets.created;
}
const ts = now();
for (const alert of pending) run('UPDATE alerts SET notified_at = ? WHERE id = ?', [ts, alert.id]);
return result;
}
module.exports = { listAlerts, runAlertCycle, scanAlerts, setStatus, summary };

View File

@@ -0,0 +1,30 @@
let alerts = require('./alerts')
describe('alerts', () => {
test('creates and lists alerts correctly', () => {
const c1 = {
type: 'test_alert',
reference_type: 'asset',
reference_id: 1,
severity: 'warning',
title: 'Test Alert 1',
message: 'This is a test alert for asset 1'
};
const c2 = {
type: 'test_alert',
reference_type: 'asset',
reference_id: 2,
severity: 'critical',
title: 'Test Alert 2',
message: 'This is a test alert for asset 2'
};
const result = alerts.scan([c1, c2], 1);
expect(result.created.length).toBe(2);
expect(result.openCount).toBeGreaterThanOrEqual(2);
const list = alerts.listAlerts({ status: 'open', type: 'test_alert' });
expect(list.length).toBeGreaterThanOrEqual(2);
expect(list.some(a => a.reference_id === 1 && a.severity === 'warning')).toBe(true);
expect(list.some(a => a.reference_id === 2 && a.severity === 'critical')).toBe(true);
});
});

View File

@@ -0,0 +1,93 @@
const { all, audit, now, one, parseJson, run } = require('../db');
const SOURCES = ['common', 'industry', 'business', 'custom'];
// How many asset categories reference a given class number (categories store it in metadata).
function categoryUsage() {
const counts = {};
for (const row of all("SELECT metadata FROM reference_values WHERE type = 'category'")) {
const num = parseJson(row.metadata, {}).asset_class_number;
if (num) counts[num] = (counts[num] || 0) + 1;
}
return counts;
}
function fromRow(row, usage) {
if (!row) return null;
return {
id: row.id,
class_number: row.class_number,
description: row.description,
gds: row.gds,
ads: row.ads,
source: row.source,
table: row.source, // back-compat alias used by the category picker
category_count: row.class_number ? (usage[row.class_number] || 0) : 0
};
}
function list() {
const usage = categoryUsage();
return all('SELECT * FROM asset_classes ORDER BY description').map((row) => fromRow(row, usage));
}
function getClass(id) {
return fromRow(one('SELECT * FROM asset_classes WHERE id = ?', [id]), categoryUsage());
}
function normalize(body) {
const num = (value) => {
if (value === '' || value === null || value === undefined) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
};
return {
class_number: body.class_number ? String(body.class_number).trim() : null,
description: String(body.description || '').trim(),
gds: num(body.gds),
ads: num(body.ads),
source: SOURCES.includes(body.source) ? body.source : 'custom'
};
}
function createClass(body, userId) {
const input = normalize(body);
if (!input.description) throw Object.assign(new Error('A description is required'), { status: 400 });
const ts = now();
const result = run(
'INSERT INTO asset_classes (class_number, description, gds, ads, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[input.class_number, input.description, input.gds, input.ads, input.source, ts, ts]
);
audit(userId, 'create', 'asset_class', result.lastInsertRowid, null, input);
return getClass(result.lastInsertRowid);
}
function updateClass(id, body, userId) {
const before = one('SELECT * FROM asset_classes WHERE id = ?', [id]);
if (!before) return null;
const input = normalize({ ...before, ...body });
if (!input.description) throw Object.assign(new Error('A description is required'), { status: 400 });
run(
'UPDATE asset_classes SET class_number = ?, description = ?, gds = ?, ads = ?, source = ?, updated_at = ? WHERE id = ?',
[input.class_number, input.description, input.gds, input.ads, input.source, now(), id]
);
audit(userId, 'update', 'asset_class', id, before, input);
return getClass(id);
}
function deleteClass(id, userId) {
const before = one('SELECT * FROM asset_classes WHERE id = ?', [id]);
if (!before) return false;
run('DELETE FROM asset_classes WHERE id = ?', [id]);
audit(userId, 'delete', 'asset_class', id, before, null);
return true;
}
module.exports = {
SOURCES,
createClass,
deleteClass,
getClass,
list,
updateClass
};

View File

@@ -1,4 +1,5 @@
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
const { assetPmRows } = require('./pm');
const assetColumns = [
'asset_id',
@@ -35,12 +36,24 @@ const assetColumns = [
'disposal_expense',
'disposal_type',
'property_type',
'special_zone',
'notes',
'custom_fields'
];
const defaultBooks = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
// Depreciation books currently enabled in the registry (excludes the maintenance/PM book).
function enabledBookCodes() {
try {
const rows = all("SELECT code FROM books WHERE enabled = 1 AND book_type != 'maintenance' ORDER BY sort_order, id");
if (rows.length) return rows.map((row) => row.code);
} catch {
// books table may not exist on a very old database
}
return defaultBooks;
}
function assetFromRow(row) {
if (!row) return null;
return {
@@ -51,6 +64,36 @@ function assetFromRow(row) {
};
}
function assetPhotos(id) {
return all('SELECT id, name, mime_type, caption, data_base64 FROM asset_photos WHERE asset_id = ? ORDER BY sort_order, id', [id])
.map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, caption: p.caption, data: p.data_base64 }));
}
function addAssetPhoto(assetId, body, userId) {
if (!one('SELECT id FROM assets WHERE id = ?', [assetId])) return null;
if (!body.data) throw Object.assign(new Error('Photo data is required'), { status: 400 });
const result = run(
'INSERT INTO asset_photos (asset_id, name, mime_type, caption, data_base64, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[assetId, body.name || null, body.mime || body.mime_type || 'image/jpeg', body.caption || null, body.data, userId, now()]
);
audit(userId, 'create', 'asset_photo', result.lastInsertRowid, null, { asset_id: assetId, name: body.name });
return one('SELECT id, name, mime_type, caption FROM asset_photos WHERE id = ?', [result.lastInsertRowid]);
}
function updateAssetPhoto(assetId, photoId, body, userId) {
const result = run('UPDATE asset_photos SET caption = ? WHERE id = ? AND asset_id = ?', [body.caption || null, photoId, assetId]);
if (!result.changes) return null;
audit(userId, 'update', 'asset_photo', photoId, null, { asset_id: assetId });
return one('SELECT id, name, mime_type, caption FROM asset_photos WHERE id = ?', [photoId]);
}
function deleteAssetPhoto(assetId, photoId, userId) {
const result = run('DELETE FROM asset_photos WHERE id = ? AND asset_id = ?', [photoId, assetId]);
if (!result.changes) return false;
audit(userId, 'delete', 'asset_photo', photoId, null, { asset_id: assetId });
return true;
}
function hydrateAsset(id) {
const asset = assetFromRow(one('SELECT * FROM assets WHERE id = ?', [id]));
if (!asset) return null;
@@ -60,9 +103,13 @@ function hydrateAsset(id) {
manual_depreciation: parseJson(book.manual_depreciation, {})
}));
asset.leases = all('SELECT * FROM leases WHERE asset_id = ? ORDER BY start_date DESC', [id]);
asset.disposals = all('SELECT * FROM disposals WHERE asset_id = ? ORDER BY disposal_date DESC, id DESC', [id]);
asset.adjustments = all('SELECT * FROM asset_adjustments WHERE asset_id = ? ORDER BY adjustment_date DESC, id DESC', [id]);
asset.pm = assetPmRows(id);
asset.tasks = all('SELECT * FROM tasks WHERE asset_id = ? ORDER BY due_date IS NULL, due_date', [id]);
asset.warranties = all('SELECT id, provider, phone, start_date, expiration_date, warranty_limit, actual_usage, coverage_details, document_name, created_at FROM warranties WHERE asset_id = ?', [id]);
asset.files = all('SELECT id, file_name, mime_type, size, uploaded_at FROM asset_files WHERE asset_id = ?', [id]);
asset.photos = assetPhotos(id);
asset.notes_log = all(
`SELECT n.id, n.body, n.created_at, u.name AS author
FROM asset_notes n
@@ -74,12 +121,75 @@ function hydrateAsset(id) {
return asset;
}
function lookupAssetByCode(code) {
const value = String(code || '').trim();
if (!value) return null;
const row = one(
`SELECT id FROM assets
WHERE barcode_value = ? COLLATE NOCASE
OR asset_id = ? COLLATE NOCASE
OR serial_number = ? COLLATE NOCASE
ORDER BY (barcode_value = ? COLLATE NOCASE) DESC, id
LIMIT 1`,
[value, value, value, value]
);
return row ? hydrateAsset(row.id) : null;
}
function nextAssetId() {
const latest = one("SELECT asset_id FROM assets WHERE asset_id LIKE 'MA-%' ORDER BY id DESC LIMIT 1");
const number = latest ? Number(String(latest.asset_id).replace('MA-', '')) + 1 : 1;
return `MA-${String(number).padStart(5, '0')}`;
}
// Replace the first run of '#' in a pattern with the zero-padded number.
// e.g. formatAssetId('DT-######', 42) -> 'DT-000042'
function formatAssetId(pattern, n) {
return String(pattern).replace(/#+/, (hashes) => String(n).padStart(hashes.length, '0'));
}
// If the asset's category has an assigned ID template, generate the next unique tag from it
// and advance the template's counter. Returns null when the category has no template.
function nextAssetIdFromCategory(categoryName) {
if (!categoryName) return null;
let templateId = null;
try {
const category = one("SELECT metadata FROM reference_values WHERE type = 'category' AND name = ?", [categoryName]);
templateId = category ? parseJson(category.metadata, {}).id_template_id : null;
} catch {
return null;
}
if (!templateId) return null;
const template = one('SELECT * FROM asset_id_templates WHERE id = ?', [templateId]);
if (!template || !/#/.test(template.pattern || '')) return null;
let n = Number(template.next_number) || 1;
let candidate = formatAssetId(template.pattern, n);
while (one('SELECT id FROM assets WHERE asset_id = ?', [candidate])) {
n += 1;
candidate = formatAssetId(template.pattern, n);
}
run('UPDATE asset_id_templates SET next_number = ?, updated_at = ? WHERE id = ?', [n + 1, now(), templateId]);
return candidate;
}
// Resolve the asset tag for a new asset: an explicit value wins; otherwise use the
// category's ID template, falling back to the default MA-##### sequence.
function resolveNewAssetId(input) {
if (input.asset_id) return input.asset_id;
return nextAssetIdFromCategory(input.category) || nextAssetId();
}
// The Asset Class Number is a property of the category — every asset in the category shares it.
function assetClassNumberForCategory(categoryName) {
if (!categoryName) return null;
try {
const category = one("SELECT metadata FROM reference_values WHERE type = 'category' AND name = ?", [categoryName]);
return category ? (parseJson(category.metadata, {}).asset_class_number || null) : null;
} catch {
return null;
}
}
function normalizeAssetInput(body) {
const input = {};
for (const column of assetColumns) {
@@ -88,7 +198,7 @@ function normalizeAssetInput(body) {
input.description = input.description || body.name || 'Untitled asset';
input.category = input.category || 'Uncategorized';
input.status = input.status || 'in_service';
input.asset_id = input.asset_id || nextAssetId();
input.asset_id = input.asset_id || null; // resolved by the caller (template or default sequence)
input.entity_id = input.entity_id || one('SELECT id FROM entities ORDER BY id LIMIT 1')?.id;
input.acquisition_cost = Number(input.acquisition_cost || 0);
input.useful_life_months = Number(input.useful_life_months || 60);
@@ -103,7 +213,7 @@ function normalizeAssetInput(body) {
}
function createBooks(assetId, asset, books = []) {
const selectedBooks = books.length ? books : defaultBooks.map((book) => ({ book_type: book }));
const selectedBooks = books.length ? books : enabledBookCodes().map((book) => ({ book_type: book }));
for (const book of selectedBooks) {
run(
`INSERT OR REPLACE INTO asset_books (
@@ -156,12 +266,14 @@ function createAsset(body, userId) {
const created = now();
const input = normalizeAssetInput(body);
const result = tx(() => {
input.asset_id = resolveNewAssetId(input); // category ID template, else default sequence
const insert = run(
`INSERT INTO assets (${assetColumns.join(', ')}, created_by, created_at, updated_at)
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
[...assetColumns.map((column) => input[column] ?? null), userId, created, created]
);
createBooks(insert.lastInsertRowid, input, body.books || []);
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), insert.lastInsertRowid]);
return insert;
});
const asset = hydrateAsset(result.lastInsertRowid);
@@ -174,6 +286,10 @@ function updateAsset(id, body, userId) {
if (!before) return null;
const input = normalizeAssetInput({ ...before, ...body });
// When depreciation-critical fields change, the caller chooses when the change applies.
// 'placed_in_service' rebuilds the whole schedule by clearing prior depreciation;
// 'going_forward' keeps depreciation already recorded (the stored prior depreciation).
if (body.depreciation_apply === 'placed_in_service') input.prior_depreciation = 0;
const updated = now();
tx(() => {
run(
@@ -181,6 +297,7 @@ function updateAsset(id, body, userId) {
[...assetColumns.map((column) => input[column] ?? null), updated, id]
);
if (Array.isArray(body.books)) createBooks(id, input, body.books);
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), id]);
});
const asset = hydrateAsset(id);
@@ -231,11 +348,13 @@ function upsertImportedAssets(rows, userId) {
tx(() => {
for (const row of rows) {
const input = normalizeAssetInput(row);
if (!input.asset_id) input.asset_id = resolveNewAssetId(input);
const existing = one('SELECT id FROM assets WHERE asset_id = ?', [input.asset_id]);
const classNumber = assetClassNumberForCategory(input.category);
if (existing) {
run(
`UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
[...assetColumns.map((column) => input[column] ?? null), now(), existing.id]
`UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, asset_class_number = ?, updated_at = ? WHERE id = ?`,
[...assetColumns.map((column) => input[column] ?? null), classNumber, now(), existing.id]
);
} else {
const result = run(
@@ -244,6 +363,7 @@ function upsertImportedAssets(rows, userId) {
[...assetColumns.map((column) => input[column] ?? null), userId, now(), now()]
);
createBooks(result.lastInsertRowid, input, []);
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [classNumber, result.lastInsertRowid]);
}
imported += 1;
}
@@ -252,6 +372,7 @@ function upsertImportedAssets(rows, userId) {
}
module.exports = {
addAssetPhoto,
assetColumns,
assetExportRows,
assetFromRow,
@@ -259,8 +380,12 @@ module.exports = {
createBooks,
defaultBooks,
deleteAsset,
deleteAssetPhoto,
formatAssetId,
hydrateAsset,
updateAssetPhoto,
listAssets,
lookupAssetByCode,
massUpdateAssets,
normalizeAssetInput,
upsertImportedAssets,

View File

@@ -0,0 +1,10 @@
let assets = require('./assets')
describe('assets', () => {
test('looks up asset by code correctly', () => {
const asset = assets.lookupAssetByCode('TESTCODE123');
expect(asset).toBeDefined();
expect(asset.asset_id).toBe(1);
expect(asset.code).toBe('TESTCODE123');
});
});

View File

@@ -0,0 +1,41 @@
let assignments = require('./assignments')
describe('assignments', () => {
test('assigns asset to employee correctly', () => {
const body = {
asset_id: 1,
employee_id: 1,
department: 'IT',
location: 'Headquarters',
notes: 'Assigned for testing'
};
const userId = 1;
const assignment = assignments.assignAsset(body, userId);
expect(assignment).toBeDefined();
expect(assignment.asset_id).toBe(body.asset_id);
expect(assignment.employee_id).toBe(body.employee_id);
expect(assignment.department).toBe(body.department);
expect(assignment.location).toBe(body.location);
expect(assignment.status).toBe('assigned');
});
test('releases asset assignment correctly', () => {
const body = {
release_reason: 'No longer needed',
notes: 'Released after testing'
};
const userId = 1;
const assignment = assignments.assignAsset({
asset_id: 1,
employee_id: 1,
department: 'IT',
location: 'Headquarters'
}, userId);
const released = assignments.releaseAssignment(assignment.id, body, userId);
expect(released).toBeDefined();
expect(released.id).toBe(assignment.id);
expect(released.status).toBe('released');
expect(released.release_reason).toBe(body.release_reason);
expect(released.notes).toBe(body.notes);
});
});

221
server/services/books.js Normal file
View File

@@ -0,0 +1,221 @@
const { all, audit, now, one, run } = require('../db');
const { money } = require('../depreciation');
const { ruleSetForBook } = require('./ruleSets');
const { computeYear } = require('./reportEngine');
const { assetFromRow } = require('./assets');
const { pmBookLedger } = require('./pm');
const DEFAULT_ACCOUNTS = {
asset: '1500',
accumulated: '1590',
expense: '6400'
};
function bookFromRow(row) {
return {
...row,
enabled: Boolean(row.enabled),
is_primary: Boolean(row.is_primary)
};
}
function listBooks() {
const books = all(
`SELECT b.*, t.name AS tax_rule_set_name, t.version AS tax_rule_set_version,
(SELECT COUNT(*) FROM asset_books ab WHERE ab.book_type = b.code AND ab.active = 1) AS asset_count
FROM books b
LEFT JOIN tax_rule_sets t ON t.id = b.tax_rule_set_id
ORDER BY b.sort_order, b.id`
).map(bookFromRow);
const ruleSets = all('SELECT id, name, jurisdiction, version, active FROM tax_rule_sets ORDER BY active DESC, name')
.map((row) => ({ ...row, active: Boolean(row.active) }));
return { books, ruleSets };
}
function getBook(code) {
return bookFromRow(one('SELECT * FROM books WHERE code = ? OR id = ?', [code, code]) || null) || null;
}
function createBook(body, userId) {
const code = String(body.code || '').trim().toUpperCase().replace(/[^A-Z0-9_]/g, '').slice(0, 20);
if (!code) throw Object.assign(new Error('Book code is required'), { status: 400 });
if (one('SELECT id FROM books WHERE code = ?', [code])) {
throw Object.assign(new Error(`A book with code "${code}" already exists`), { status: 400 });
}
const ts = now();
const next = one('SELECT MAX(sort_order) AS max FROM books');
const result = run(
`INSERT INTO books (code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, fiscal_year_end_month, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`,
[
code,
body.name || code,
body.description || null,
['financial', 'tax', 'internal'].includes(body.book_type) ? body.book_type : 'internal',
body.enabled === false ? 0 : 1,
body.tax_rule_set_id || null,
body.default_method || 'straight_line',
body.default_convention || 'half_year',
Number(body.fiscal_year_end_month) || 12,
(next?.max || 0) + 1,
ts, ts
]
);
audit(userId, 'create', 'book', result.lastInsertRowid, null, { code, name: body.name });
return bookFromRow(one('SELECT * FROM books WHERE id = ?', [result.lastInsertRowid]));
}
function deleteBook(id, userId) {
const before = one('SELECT * FROM books WHERE id = ?', [id]);
if (!before) return false;
if (before.is_primary) throw Object.assign(new Error('The primary book cannot be deleted'), { status: 400 });
if (before.book_type === 'maintenance') throw Object.assign(new Error('The PM book cannot be deleted'), { status: 400 });
const used = one('SELECT COUNT(*) AS count FROM asset_books WHERE book_type = ?', [before.code]).count;
if (used) throw Object.assign(new Error(`This book is used by ${used} asset record(s); disable it instead`), { status: 400 });
run('DELETE FROM books WHERE id = ?', [id]);
audit(userId, 'delete', 'book', id, before, null);
return true;
}
function updateBook(id, body, userId) {
const before = one('SELECT * FROM books WHERE id = ?', [id]);
if (!before) return null;
run(
`UPDATE books SET
name = ?, description = ?, book_type = ?, enabled = ?, tax_rule_set_id = ?,
default_method = ?, default_convention = ?, fiscal_year_end_month = ?, updated_at = ?
WHERE id = ?`,
[
body.name ?? before.name,
body.description ?? before.description,
body.book_type ?? before.book_type,
body.enabled === undefined ? before.enabled : (body.enabled ? 1 : 0),
body.tax_rule_set_id === undefined ? before.tax_rule_set_id : (body.tax_rule_set_id || null),
body.default_method ?? before.default_method,
body.default_convention ?? before.default_convention,
body.fiscal_year_end_month ?? before.fiscal_year_end_month,
now(),
id
]
);
audit(userId, 'update', 'book', id, before, body);
return bookFromRow(one('SELECT * FROM books WHERE id = ?', [id]));
}
// General-ledger view of a book: per-asset basis, accumulated depreciation and
// current-year expense rolled up to GL accounts, plus an asset-level detail.
function bookLedger(code, year) {
const book = getBook(code);
if (!book) return null;
if (book.book_type === 'maintenance') {
return { ...pmBookLedger(year), book };
}
const rules = ruleSetForBook(code);
const rows = all(
`SELECT a.*, b.cost AS book_cost, b.depreciation_method, b.convention,
b.useful_life_months AS book_life, b.section_179_amount, b.bonus_percent,
b.business_use_percent AS book_bup, b.investment_use_percent AS book_ivp, b.manual_depreciation
FROM assets a JOIN asset_books b ON b.asset_id = a.id
WHERE b.book_type = ? AND b.active = 1
ORDER BY a.asset_id`,
[code]
);
const accountMap = new Map();
const addLine = (account, type, debit, credit) => {
const key = `${type}::${account}`;
const entry = accountMap.get(key) || { account, type, debit: 0, credit: 0 };
entry.debit += debit;
entry.credit += credit;
accountMap.set(key, entry);
};
let totalCost = 0;
let totalAccum = 0;
let totalDepr = 0;
const assets = rows.map((row) => {
const asset = assetFromRow(row);
const bk = {
book_type: code,
cost: row.book_cost,
depreciation_method: row.depreciation_method,
convention: row.convention,
useful_life_months: row.book_life,
section_179_amount: row.section_179_amount,
bonus_percent: row.bonus_percent,
business_use_percent: row.book_bup,
investment_use_percent: row.book_ivp,
manual_depreciation: row.manual_depreciation
};
const c = computeYear(asset, bk, rules, year);
const assetAccount = asset.gl_asset_account || DEFAULT_ACCOUNTS.asset;
const accumAccount = asset.gl_accumulated_account || DEFAULT_ACCOUNTS.accumulated;
const expenseAccount = asset.gl_expense_account || DEFAULT_ACCOUNTS.expense;
addLine(assetAccount, 'Asset cost', c.cost, 0);
addLine(accumAccount, 'Accumulated depreciation', 0, c.accumulated_end);
if (c.depreciation) addLine(expenseAccount, 'Depreciation expense', c.depreciation, 0);
totalCost += c.cost;
totalAccum += c.accumulated_end;
totalDepr += c.depreciation;
return {
asset_id: asset.asset_id,
description: asset.description,
asset_account: assetAccount,
accumulated_account: accumAccount,
expense_account: expenseAccount,
cost: c.cost,
depreciation: c.depreciation,
accumulated: c.accumulated_end,
nbv: c.nbv_end
};
});
const accounts = [...accountMap.values()]
.map((entry) => ({ account: entry.account, type: entry.type, debit: money(entry.debit), credit: money(entry.credit) }))
.sort((a, b) => a.account.localeCompare(b.account) || a.type.localeCompare(b.type));
return {
book,
year,
rule_set: { id: book.tax_rule_set_id, name: book.tax_rule_set_name, version: book.tax_rule_set_version },
summary: {
assets: assets.length,
cost: money(totalCost),
accumulated: money(totalAccum),
depreciation: money(totalDepr),
nbv: money(totalCost - totalAccum)
},
accounts,
accountTotals: { debit: money(accounts.reduce((s, a) => s + a.debit, 0)), credit: money(accounts.reduce((s, a) => s + a.credit, 0)) },
assets
};
}
// Shape the ledger account rollup as a generic report for PDF/XLSX/CSV export.
function ledgerReport(code, year) {
const ledger = bookLedger(code, year);
if (!ledger) return null;
return {
title: `${ledger.book.name} general ledger — ${year}`,
generatedAt: now(),
options: { book: code, year },
columns: [
{ key: 'account', label: 'GL account', format: 'text' },
{ key: 'type', label: 'Account type', format: 'text' },
{ key: 'debit', label: 'Debit', format: 'currency' },
{ key: 'credit', label: 'Credit', format: 'currency' }
],
rows: ledger.accounts,
totals: ledger.accountTotals,
meta: {
note: ledger.kind === 'maintenance'
? `${ledger.summary.assets} asset(s) · ${ledger.summary.completions} service(s) · total PM spend ${ledger.summary.cost}`
: `Cost ${ledger.summary.cost} · Accumulated ${ledger.summary.accumulated} · NBV ${ledger.summary.nbv} · ${year} depreciation ${ledger.summary.depreciation}`
}
};
}
module.exports = { bookLedger, createBook, deleteBook, getBook, ledgerReport, listBooks, updateBook };

175
server/services/contacts.js Normal file
View File

@@ -0,0 +1,175 @@
const { all, audit, json, now, one, parseJson, run } = require('../db');
const TYPES = ['employee', 'vendor', 'service_provider', 'contractor', 'work_location', 'other'];
const CREDIT_TERMS = ['na', 'net_30', 'net_60', 'net_90', 'net_180'];
const COLUMNS = [
'type', 'first_name', 'last_name', 'organization', 'email', 'phone',
'address_line1', 'address_line2', 'city', 'state_region', 'postal_code', 'country',
'notes', 'status', 'employee_id', 'tax_id', 'department', 'work_location',
'manager_first_name', 'manager_last_name', 'manager_email', 'start_date',
'rating', 'credit_term', 'source', 'workday_worker_id'
];
function contactNotes(contactId) {
return all(
`SELECT n.id, n.body, n.created_at, u.name AS author
FROM contact_notes n LEFT JOIN users u ON u.id = n.created_by
WHERE n.contact_id = ? ORDER BY n.id DESC`,
[contactId]
);
}
function displayName(row) {
const person = [row.first_name, row.last_name].filter(Boolean).join(' ').trim();
return row.organization || person || row.email || `Contact ${row.id}`;
}
function fromRow(row, withNotes = false) {
if (!row) return null;
const contact = { ...row, display_name: displayName(row) };
if (withNotes) contact.contact_notes = contactNotes(row.id);
return contact;
}
function normalize(body) {
const input = {};
for (const column of COLUMNS) {
if (Object.prototype.hasOwnProperty.call(body, column)) input[column] = body[column];
}
input.type = TYPES.includes(input.type) ? input.type : 'other';
input.status = input.status || 'active';
input.source = input.source || 'manual';
if (input.credit_term && !CREDIT_TERMS.includes(input.credit_term)) input.credit_term = 'na';
if (input.rating != null && input.rating !== '') input.rating = Math.max(0, Math.min(5, Number(input.rating)));
else input.rating = null;
if (input.workday_worker_id === '') input.workday_worker_id = null;
return input;
}
function listContacts(query = {}) {
return all(
`SELECT * FROM contacts
WHERE (? IS NULL OR type = ?)
AND (? IS NULL OR status = ?)
AND (? IS NULL OR (
first_name LIKE '%' || ? || '%' OR last_name LIKE '%' || ? || '%' OR
organization LIKE '%' || ? || '%' OR email LIKE '%' || ? || '%' OR
employee_id LIKE '%' || ? || '%'
))
ORDER BY type, organization, last_name, first_name`,
[
query.type || null, query.type || null,
query.status || null, query.status || null,
query.search || null, query.search || null, query.search || null,
query.search || null, query.search || null, query.search || null
]
).map((row) => fromRow(row));
}
function getContact(id) {
return fromRow(one('SELECT * FROM contacts WHERE id = ?', [id]), true);
}
function createContact(body, userId) {
const input = normalize(body);
const ts = now();
const cols = COLUMNS.filter((c) => input[c] !== undefined);
const result = run(
`INSERT INTO contacts (${cols.join(', ')}, created_by, created_at, updated_at)
VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
[...cols.map((c) => input[c]), userId, ts, ts]
);
audit(userId, 'create', 'contact', result.lastInsertRowid, null, { type: input.type, name: displayName(input) });
return getContact(result.lastInsertRowid);
}
function updateContact(id, body, userId) {
const before = one('SELECT * FROM contacts WHERE id = ?', [id]);
if (!before) return null;
const input = normalize({ ...before, ...body });
run(
`UPDATE contacts SET ${COLUMNS.map((c) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
[...COLUMNS.map((c) => input[c] ?? null), now(), id]
);
audit(userId, 'update', 'contact', id, before, body);
return getContact(id);
}
function deleteContact(id, userId) {
const before = one('SELECT * FROM contacts WHERE id = ?', [id]);
if (!before) return false;
run('DELETE FROM contacts WHERE id = ?', [id]);
audit(userId, 'delete', 'contact', id, before, null);
return true;
}
function addNote(contactId, bodyText, userId) {
if (!one('SELECT id FROM contacts WHERE id = ?', [contactId])) return null;
const result = run('INSERT INTO contact_notes (contact_id, body, created_by, created_at) VALUES (?, ?, ?, ?)', [contactId, bodyText, userId, now()]);
audit(userId, 'create', 'contact_note', result.lastInsertRowid, null, { contact_id: contactId });
return one('SELECT n.id, n.body, n.created_at, u.name AS author FROM contact_notes n LEFT JOIN users u ON u.id = n.created_by WHERE n.id = ?', [result.lastInsertRowid]);
}
function deleteNote(contactId, noteId, userId) {
const result = run('DELETE FROM contact_notes WHERE id = ? AND contact_id = ?', [noteId, contactId]);
if (!result.changes) return false;
audit(userId, 'delete', 'contact_note', noteId, null, { contact_id: contactId });
return true;
}
// Upsert an employee contact from a normalized Workday worker record.
function upsertWorkdayContact(worker) {
const ts = now();
const existing = worker.workday_worker_id
? one('SELECT id FROM contacts WHERE workday_worker_id = ?', [worker.workday_worker_id])
: (worker.email ? one('SELECT id FROM contacts WHERE type = ? AND lower(email) = lower(?)', ['employee', worker.email]) : null);
const fields = {
type: 'employee',
first_name: worker.first_name || null,
last_name: worker.last_name || null,
organization: null,
email: worker.email || null,
department: worker.department || null,
work_location: worker.location || null,
employee_id: worker.employee_number || null,
manager_first_name: worker.manager_first_name || null,
manager_last_name: worker.manager_last_name || null,
manager_email: worker.manager_email || null,
start_date: worker.start_date || null,
status: worker.status || 'active',
source: 'workday',
workday_worker_id: worker.workday_worker_id || null,
source_payload: json(worker.source_payload || worker)
};
if (existing) {
const cols = Object.keys(fields);
run(
`UPDATE contacts SET ${cols.map((c) => `${c} = COALESCE(?, ${c})`).join(', ')}, last_synced_at = ?, updated_at = ? WHERE id = ?`,
[...cols.map((c) => fields[c]), ts, ts, existing.id]
);
return existing.id;
}
const cols = Object.keys(fields);
const result = run(
`INSERT INTO contacts (${cols.join(', ')}, last_synced_at, created_at, updated_at) VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
[...cols.map((c) => fields[c]), ts, ts, ts]
);
return result.lastInsertRowid;
}
module.exports = {
CREDIT_TERMS,
TYPES,
addNote,
createContact,
deleteContact,
deleteNote,
getContact,
listContacts,
parsePayload: (value) => parseJson(value, null),
updateContact,
upsertWorkdayContact
};

View File

@@ -0,0 +1,187 @@
const { all, audit, now, one, run, tx } = require('../db');
const { annualSchedule, money } = require('../depreciation');
const { ruleSetForBook } = require('./ruleSets');
const { hydrateAsset } = require('./assets');
const DISPOSAL_METHODS = ['sale', 'exchange', 'involuntary_conversion', 'like_kind', 'abandonment'];
function yearOf(value) {
if (!value) return new Date().getFullYear();
return new Date(`${value}T00:00:00`).getFullYear();
}
function netAdjustments(assetId, bookType) {
// Impairments and write-downs reduce carrying value; write-ups/revaluations restore it.
const rows = all('SELECT type, amount FROM asset_adjustments WHERE asset_id = ? AND book_type = ?', [assetId, bookType]);
return rows.reduce((sum, row) => {
const sign = row.type === 'write_up' || row.type === 'revaluation' ? -1 : 1;
return sum + sign * Number(row.amount || 0);
}, 0);
}
function bookFor(asset, bookType) {
return asset.books.find((book) => book.book_type === bookType) || asset.books[0] || { book_type: bookType };
}
function computeDisposal(asset, input = {}) {
const bookType = input.book_type || 'GAAP';
const book = bookFor(asset, bookType);
const rules = ruleSetForBook(bookType);
const disposalDate = input.disposal_date || new Date().toISOString().slice(0, 10);
const year = yearOf(disposalDate);
const totalCost = Number(book.cost ?? asset.acquisition_cost ?? 0);
const partial = Boolean(input.partial);
const disposedCost = partial ? Math.min(totalCost, Number(input.disposed_cost || 0)) : totalCost;
const proportion = totalCost > 0 ? disposedCost / totalCost : 1;
const row = annualSchedule(asset, book, rules, { startYear: year, endYear: year })[0] || {};
const accumulatedFull = Number(row.accumulated || 0);
const nbvFull = row.netBookValue != null ? Number(row.netBookValue) : Math.max(0, totalCost - accumulatedFull);
const accumulated = money(accumulatedFull * proportion);
const impairment = money(netAdjustments(asset.id, bookType) * proportion);
const netBookValue = money(Math.max(0, nbvFull * proportion - impairment));
const price = Number(input.disposal_price || 0);
const expense = Number(input.disposal_expense || 0);
const realized = money(price - expense);
const gainLoss = money(realized - netBookValue);
return {
book_type: bookType,
disposal_date: disposalDate,
method: DISPOSAL_METHODS.includes(input.method) ? input.method : 'sale',
property_type: input.property_type || asset.property_type || null,
partial: partial ? 1 : 0,
disposed_cost: money(disposedCost),
total_cost: money(totalCost),
disposal_price: money(price),
disposal_expense: money(expense),
accumulated_depreciation: accumulated,
impairment,
net_book_value: netBookValue,
realized,
gain_loss: gainLoss
};
}
function previewDisposal(assetId, input) {
const asset = hydrateAsset(assetId);
if (!asset) return null;
return computeDisposal(asset, input);
}
function recordDisposal(assetId, input, userId) {
const asset = hydrateAsset(assetId);
if (!asset) return null;
const result = computeDisposal(asset, input);
const timestamp = now();
return tx(() => {
const inserted = run(
`INSERT INTO disposals (
asset_id, disposal_date, method, property_type, partial, disposed_cost,
disposal_price, disposal_expense, accumulated_depreciation, net_book_value,
gain_loss, book_type, notes, created_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
assetId, result.disposal_date, result.method, result.property_type, result.partial,
result.disposed_cost, result.disposal_price, result.disposal_expense,
result.accumulated_depreciation, result.net_book_value, result.gain_loss,
result.book_type, input.notes || null, userId, timestamp
]
);
if (result.partial) {
const totalCost = result.total_cost;
const remainingProportion = totalCost > 0 ? Math.max(0, (totalCost - result.disposed_cost) / totalCost) : 1;
run('UPDATE assets SET acquisition_cost = acquisition_cost - ?, updated_at = ? WHERE id = ?', [
result.disposed_cost,
timestamp,
assetId
]);
run('UPDATE asset_books SET cost = cost * ? WHERE asset_id = ? AND cost IS NOT NULL', [remainingProportion, assetId]);
} else {
run(
`UPDATE assets SET
status = 'disposed', disposal_date = ?, disposal_price = ?, disposal_expense = ?,
disposal_type = ?, property_type = ?, updated_at = ?
WHERE id = ?`,
[result.disposal_date, result.disposal_price, result.disposal_expense, result.method, result.property_type, timestamp, assetId]
);
}
const disposal = one('SELECT * FROM disposals WHERE id = ?', [inserted.lastInsertRowid]);
audit(userId, 'dispose', 'asset', assetId, null, disposal);
return { disposal, asset: hydrateAsset(assetId) };
});
}
function reverseDisposal(assetId, disposalId, userId) {
const disposal = one('SELECT * FROM disposals WHERE id = ? AND asset_id = ?', [disposalId, assetId]);
if (!disposal) return null;
const timestamp = now();
return tx(() => {
run('DELETE FROM disposals WHERE id = ?', [disposalId]);
if (disposal.partial) {
run('UPDATE assets SET acquisition_cost = acquisition_cost + ?, updated_at = ? WHERE id = ?', [
disposal.disposed_cost,
timestamp,
assetId
]);
} else if (!one('SELECT id FROM disposals WHERE asset_id = ? AND partial = 0', [assetId])) {
run(
`UPDATE assets SET
status = 'in_service', disposal_date = NULL, disposal_price = NULL,
disposal_expense = NULL, disposal_type = NULL, updated_at = ?
WHERE id = ?`,
[timestamp, assetId]
);
}
audit(userId, 'reverse_disposal', 'asset', assetId, disposal, null);
return hydrateAsset(assetId);
});
}
function recordAdjustment(assetId, input, userId) {
const asset = one('SELECT id FROM assets WHERE id = ?', [assetId]);
if (!asset) return null;
const timestamp = now();
const result = run(
`INSERT INTO asset_adjustments (asset_id, adjustment_date, type, amount, book_type, reason, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
assetId,
input.adjustment_date || timestamp.slice(0, 10),
input.type || 'impairment',
Number(input.amount || 0),
input.book_type || 'GAAP',
input.reason || null,
userId,
timestamp
]
);
const adjustment = one('SELECT * FROM asset_adjustments WHERE id = ?', [result.lastInsertRowid]);
audit(userId, 'adjust', 'asset', assetId, null, adjustment);
return adjustment;
}
function deleteAdjustment(assetId, adjustmentId, userId) {
const result = run('DELETE FROM asset_adjustments WHERE id = ? AND asset_id = ?', [adjustmentId, assetId]);
if (!result.changes) return false;
audit(userId, 'delete', 'asset_adjustment', adjustmentId, null, { asset_id: assetId });
return true;
}
module.exports = {
DISPOSAL_METHODS,
computeDisposal,
deleteAdjustment,
netAdjustments,
previewDisposal,
recordAdjustment,
recordDisposal,
reverseDisposal
};

146
server/services/leases.js Normal file
View File

@@ -0,0 +1,146 @@
const { all, audit, now, one, run } = require('../db');
const { money } = require('../depreciation');
const FREQUENCIES = { monthly: 12, quarterly: 4, semiannual: 2, annual: 1 };
function periodsPerYear(frequency) {
return FREQUENCIES[frequency] || 12;
}
function addMonths(date, count) {
const next = new Date(date.getTime());
next.setMonth(next.getMonth() + count);
return next;
}
function listLeases(query = {}) {
return all(
`SELECT l.*, a.asset_id AS asset_code, a.description AS asset_description
FROM leases l
LEFT JOIN assets a ON a.id = l.asset_id
WHERE (? IS NULL OR l.asset_id = ?)
ORDER BY l.start_date DESC, l.id DESC`,
[query.asset_id || null, query.asset_id || null]
);
}
function getLease(id) {
return one('SELECT * FROM leases WHERE id = ?', [id]);
}
function createLease(input, userId) {
const timestamp = now();
const result = run(
`INSERT INTO leases (asset_id, lessor, contract_value, start_date, end_date, discount_rate, payment_frequency, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
input.asset_id || null,
input.lessor || null,
Number(input.contract_value || 0),
input.start_date || null,
input.end_date || null,
Number(input.discount_rate || 0),
input.payment_frequency || 'monthly',
input.notes || null,
timestamp,
timestamp
]
);
audit(userId, 'create', 'lease', result.lastInsertRowid, null, input);
return getLease(result.lastInsertRowid);
}
function updateLease(id, input, userId) {
const before = getLease(id);
if (!before) return null;
run(
`UPDATE leases SET
lessor = ?, contract_value = ?, start_date = ?, end_date = ?, discount_rate = ?,
payment_frequency = ?, notes = ?, updated_at = ?
WHERE id = ?`,
[
input.lessor ?? before.lessor,
Number(input.contract_value ?? before.contract_value),
input.start_date ?? before.start_date,
input.end_date ?? before.end_date,
Number(input.discount_rate ?? before.discount_rate),
input.payment_frequency ?? before.payment_frequency,
input.notes ?? before.notes,
now(),
id
]
);
audit(userId, 'update', 'lease', id, before, input);
return getLease(id);
}
function deleteLease(id, userId) {
const before = getLease(id);
if (!before) return false;
run('DELETE FROM leases WHERE id = ?', [id]);
audit(userId, 'delete', 'lease', id, before, null);
return true;
}
// Straight-line ROU amortization with an ASC 842-style liability schedule.
function leaseSchedule(lease) {
if (!lease) return null;
const ppy = periodsPerYear(lease.payment_frequency);
const monthsPerPeriod = 12 / ppy;
const start = lease.start_date ? new Date(`${lease.start_date}T00:00:00`) : new Date();
const end = lease.end_date ? new Date(`${lease.end_date}T00:00:00`) : addMonths(start, 12);
const totalMonths = Math.max(monthsPerPeriod, (end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth()));
const periods = Math.max(1, Math.round(totalMonths / monthsPerPeriod));
const contractValue = Number(lease.contract_value || 0);
const payment = contractValue / periods;
const rate = Number(lease.discount_rate || 0) / 100 / ppy;
const presentValue = rate > 0 ? payment * (1 - (1 + rate) ** -periods) / rate : contractValue;
const rouAmortization = presentValue / periods;
let liability = presentValue;
let rouBalance = presentValue;
let totalInterest = 0;
const rows = [];
for (let period = 1; period <= periods; period += 1) {
const interest = liability * rate;
const principal = payment - interest;
liability = Math.max(0, liability - principal);
rouBalance = Math.max(0, rouBalance - rouAmortization);
totalInterest += interest;
rows.push({
period,
date: addMonths(start, period * monthsPerPeriod).toISOString().slice(0, 10),
payment: money(payment),
interest: money(interest),
principal: money(principal),
liability_balance: money(liability),
rou_amortization: money(rouAmortization),
rou_balance: money(rouBalance)
});
}
return {
summary: {
periods,
frequency: lease.payment_frequency,
periodic_payment: money(payment),
present_value: money(presentValue),
total_payments: money(payment * periods),
total_interest: money(totalInterest)
},
rows
};
}
module.exports = {
FREQUENCIES,
createLease,
deleteLease,
getLease,
leaseSchedule,
listLeases,
updateLease
};

View File

@@ -0,0 +1,118 @@
const nodemailer = require('nodemailer');
const { all, audit, now, run } = require('../db');
function readSettings() {
const rows = all('SELECT key, value FROM app_settings');
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
}
// Raw config (includes password) — for sending mail only.
function getConfig() {
const s = readSettings();
return {
host: s.smtp_host || '',
port: Number(s.smtp_port || 587),
secure: s.smtp_secure === 'true',
user: s.smtp_user || '',
password: s.smtp_password || '',
from: s.smtp_from || s.smtp_user || '',
enabled: s.alerts_enabled === 'true',
leadDays: Number(s.alerts_lead_days || 30),
recipients: String(s.alerts_recipients || '').split(',').map((x) => x.trim()).filter(Boolean),
webhookEnabled: s.webhook_enabled === 'true',
webhookUrl: s.webhook_url || '',
webhookSecret: s.webhook_secret || ''
};
}
// Safe config for the API/UI — password is never returned, only whether it is set.
function publicConfig() {
const c = getConfig();
return {
smtp_host: c.host,
smtp_port: c.port,
smtp_secure: c.secure,
smtp_user: c.user,
smtp_from: c.from,
smtp_password_set: Boolean(c.password),
alerts_enabled: c.enabled,
alerts_lead_days: c.leadDays,
alerts_recipients: c.recipients.join(', '),
configured: Boolean(c.host),
webhook_enabled: c.webhookEnabled,
webhook_url: c.webhookUrl,
webhook_secret_set: Boolean(c.webhookSecret),
webhook_configured: Boolean(c.webhookUrl)
};
}
function setValue(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',
[key, String(value), now()]
);
}
function saveConfig(body, userId) {
if (body.smtp_host !== undefined) setValue('smtp_host', body.smtp_host || '');
if (body.smtp_port !== undefined) setValue('smtp_port', Number(body.smtp_port) || 587);
if (body.smtp_secure !== undefined) setValue('smtp_secure', body.smtp_secure ? 'true' : 'false');
if (body.smtp_user !== undefined) setValue('smtp_user', body.smtp_user || '');
if (body.smtp_from !== undefined) setValue('smtp_from', body.smtp_from || '');
if (body.smtp_password) setValue('smtp_password', body.smtp_password); // only overwrite when a new value is supplied
if (body.alerts_enabled !== undefined) setValue('alerts_enabled', body.alerts_enabled ? 'true' : 'false');
if (body.alerts_lead_days !== undefined) setValue('alerts_lead_days', Number(body.alerts_lead_days) || 30);
if (body.alerts_recipients !== undefined) setValue('alerts_recipients', body.alerts_recipients || '');
if (body.webhook_enabled !== undefined) setValue('webhook_enabled', body.webhook_enabled ? 'true' : 'false');
if (body.webhook_url !== undefined) setValue('webhook_url', body.webhook_url || '');
if (body.webhook_secret_clear) setValue('webhook_secret', '');
else if (body.webhook_secret) setValue('webhook_secret', body.webhook_secret); // only overwrite when a new value is supplied
audit(userId, 'update', 'notification_settings', 'smtp', null, {
...body,
smtp_password: body.smtp_password ? '[set]' : undefined,
webhook_secret: body.webhook_secret ? '[set]' : undefined
});
return publicConfig();
}
function getTransport() {
const c = getConfig();
if (!c.host) {
throw Object.assign(new Error('SMTP host is not configured'), { status: 400 });
}
return nodemailer.createTransport({
host: c.host,
port: c.port,
secure: c.secure,
auth: c.user ? { user: c.user, pass: c.password } : undefined
});
}
async function sendMail({ to, subject, html, text }) {
const c = getConfig();
const recipients = to ? (Array.isArray(to) ? to : [to]) : c.recipients;
if (!recipients.length) {
throw Object.assign(new Error('No recipients are configured'), { status: 400 });
}
const transport = getTransport();
return transport.sendMail({
from: c.from || c.user,
to: recipients.join(', '),
subject,
html,
text
});
}
async function sendTestEmail(to, userId) {
const info = await sendMail({
to: to ? [to] : null,
subject: 'MixedAssets test email',
html: '<p>Your MixedAssets SMTP settings are working. This is a test message.</p>',
text: 'Your MixedAssets SMTP settings are working. This is a test message.'
});
audit(userId, 'send', 'test_email', null, null, { to });
return { messageId: info.messageId, accepted: info.accepted };
}
module.exports = { getConfig, publicConfig, saveConfig, sendMail, sendTestEmail };

312
server/services/parts.js Normal file
View File

@@ -0,0 +1,312 @@
const { all, audit, now, one, run, tx } = require('../db');
const STATUSES = ['active', 'inactive', 'discontinued'];
// Stock movements. receive/issue/adjust change on-hand; reserve/unreserve change the
// reserved (committed) quantity. `adjust` sets the on-hand count to an absolute value.
const TRANSACTION_TYPES = ['receive', 'issue', 'adjust', 'reserve', 'unreserve'];
const COLUMNS = [
'part_number', 'name', 'description', 'category', 'manufacturer', 'manufacturer_part_number',
'barcode', 'unit_of_measure', 'unit_cost', 'reorder_point', 'reorder_quantity', 'measurements',
'supplier_contact_id', 'status', 'notes'
];
function round2(value) {
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
}
function num(value, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function contactName(row, prefix) {
if (!row || !row[`${prefix}_id`]) return null;
const org = row[`${prefix}_org`];
const person = [row[`${prefix}_first`], row[`${prefix}_last`]].filter(Boolean).join(' ').trim();
return org || person || null;
}
function stockRows(partId) {
return all(
`SELECT s.*, c.organization AS location_org, c.first_name AS location_first, c.last_name AS location_last
FROM part_stock s LEFT JOIN contacts c ON c.id = s.location_contact_id
WHERE s.part_id = ? ORDER BY c.organization, s.aisle, s.bin, s.id`,
[partId]
).map((s) => ({
id: s.id,
part_id: s.part_id,
location_contact_id: s.location_contact_id,
location_name: s.location_org || [s.location_first, s.location_last].filter(Boolean).join(' ').trim() || 'Unassigned',
aisle: s.aisle,
bin: s.bin,
quantity: round2(s.quantity),
reserved: round2(s.reserved),
available: round2(s.quantity - s.reserved)
}));
}
function partPhotos(partId) {
return all('SELECT id, name, mime_type, data_base64 FROM part_photos WHERE part_id = ? ORDER BY id', [partId])
.map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, data: p.data_base64 }));
}
function partTransactions(partId, limit = 100) {
return all(
`SELECT t.id, t.type, t.quantity, t.unit_cost, t.reference, t.notes, t.created_at, t.asset_id,
u.name AS created_by_name, a.asset_id AS asset_code,
c.organization AS location_org, c.first_name AS location_first, c.last_name AS location_last,
s.aisle, s.bin
FROM part_transactions t
LEFT JOIN users u ON u.id = t.created_by
LEFT JOIN assets a ON a.id = t.asset_id
LEFT JOIN part_stock s ON s.id = t.stock_id
LEFT JOIN contacts c ON c.id = s.location_contact_id
WHERE t.part_id = ? ORDER BY t.id DESC LIMIT ?`,
[partId, limit]
).map((t) => ({
id: t.id,
type: t.type,
quantity: round2(t.quantity),
unit_cost: t.unit_cost == null ? null : round2(t.unit_cost),
reference: t.reference,
notes: t.notes,
created_at: t.created_at,
created_by_name: t.created_by_name,
asset_code: t.asset_code,
location_name: t.location_org || [t.location_first, t.location_last].filter(Boolean).join(' ').trim() || null,
aisle: t.aisle,
bin: t.bin
}));
}
function totalsFor(stock) {
const on_hand = round2(stock.reduce((sum, s) => sum + s.quantity, 0));
const reserved = round2(stock.reduce((sum, s) => sum + s.reserved, 0));
return { on_hand, reserved, available: round2(on_hand - reserved), location_count: stock.length };
}
function decorate(row, totals) {
const onHand = totals.on_hand;
return {
...row,
supplier_name: contactName(row, 'supplier'),
on_hand: onHand,
reserved: totals.reserved,
available: totals.available,
location_count: totals.location_count,
stock_value: round2(onHand * num(row.unit_cost)),
low_stock: num(row.reorder_point) > 0 && onHand <= num(row.reorder_point)
};
}
function listParts(query = {}) {
const rows = all(
`SELECT p.*,
sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last,
COALESCE(st.on_hand, 0) AS on_hand, COALESCE(st.reserved, 0) AS reserved, COALESCE(st.locations, 0) AS location_count
FROM parts p
LEFT JOIN contacts sup ON sup.id = p.supplier_contact_id
LEFT JOIN (
SELECT part_id, SUM(quantity) AS on_hand, SUM(reserved) AS reserved, COUNT(*) AS locations
FROM part_stock GROUP BY part_id
) st ON st.part_id = p.id
WHERE (? IS NULL OR p.status = ?)
AND (? IS NULL OR p.category = ?)
AND (? IS NULL OR (
p.part_number LIKE '%' || ? || '%' OR p.name LIKE '%' || ? || '%' OR
p.description LIKE '%' || ? || '%' OR p.barcode LIKE '%' || ? || '%' OR
p.manufacturer_part_number LIKE '%' || ? || '%'
))
ORDER BY p.name`,
[
query.status || null, query.status || null,
query.category || null, query.category || null,
query.search || null, query.search || null, query.search || null,
query.search || null, query.search || null
]
).map((row) => {
const onHand = round2(row.on_hand);
const reserved = round2(row.reserved);
return decorate(row, { on_hand: onHand, reserved, available: round2(onHand - reserved), location_count: row.location_count });
});
if (query.low_stock === 'true' || query.low_stock === true) return rows.filter((r) => r.low_stock);
return rows;
}
function getPart(id) {
const row = one(
`SELECT p.*, sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last
FROM parts p LEFT JOIN contacts sup ON sup.id = p.supplier_contact_id WHERE p.id = ?`,
[id]
);
if (!row) return null;
const stock = stockRows(id);
const part = decorate(row, totalsFor(stock));
part.stock = stock;
part.photos = partPhotos(id);
part.transactions = partTransactions(id);
return part;
}
function normalize(body) {
const input = {};
for (const column of COLUMNS) {
if (Object.prototype.hasOwnProperty.call(body, column)) input[column] = body[column];
}
input.status = STATUSES.includes(input.status) ? input.status : 'active';
input.unit_of_measure = input.unit_of_measure || 'each';
for (const numeric of ['unit_cost', 'reorder_point', 'reorder_quantity']) {
if (input[numeric] !== undefined) input[numeric] = num(input[numeric]);
}
if (input.supplier_contact_id === '' || input.supplier_contact_id === undefined) input.supplier_contact_id = null;
return input;
}
function createPart(body, userId) {
const input = normalize(body);
if (!input.part_number) throw Object.assign(new Error('A part number is required'), { status: 400 });
if (one('SELECT id FROM parts WHERE part_number = ?', [input.part_number])) {
throw Object.assign(new Error('That part number already exists'), { status: 400 });
}
const ts = now();
const cols = COLUMNS.filter((c) => input[c] !== undefined);
const result = run(
`INSERT INTO parts (${cols.join(', ')}, created_by, created_at, updated_at)
VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
[...cols.map((c) => input[c]), userId, ts, ts]
);
audit(userId, 'create', 'part', result.lastInsertRowid, null, { part_number: input.part_number });
return getPart(result.lastInsertRowid);
}
function updatePart(id, body, userId) {
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
if (!before) return null;
const input = normalize({ ...before, ...body });
if (input.part_number !== before.part_number && one('SELECT id FROM parts WHERE part_number = ? AND id <> ?', [input.part_number, id])) {
throw Object.assign(new Error('That part number already exists'), { status: 400 });
}
run(
`UPDATE parts SET ${COLUMNS.map((c) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
[...COLUMNS.map((c) => input[c] ?? null), now(), id]
);
audit(userId, 'update', 'part', id, before, body);
return getPart(id);
}
function deletePart(id, userId) {
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
if (!before) return false;
run('DELETE FROM parts WHERE id = ?', [id]);
audit(userId, 'delete', 'part', id, before, null);
return true;
}
// ---- Stock locations -------------------------------------------------------
function saveStock(partId, body, userId) {
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
const ts = now();
const location = body.location_contact_id || null;
const aisle = body.aisle || null;
const bin = body.bin || null;
const quantity = num(body.quantity);
const reserved = Math.max(0, Math.min(quantity, num(body.reserved)));
if (body.id) {
const existing = one('SELECT * FROM part_stock WHERE id = ? AND part_id = ?', [body.id, partId]);
if (!existing) return null;
run(
'UPDATE part_stock SET location_contact_id = ?, aisle = ?, bin = ?, quantity = ?, reserved = ?, updated_at = ? WHERE id = ?',
[location, aisle, bin, quantity, reserved, ts, body.id]
);
audit(userId, 'update', 'part_stock', body.id, existing, body);
} else {
const result = run(
'INSERT INTO part_stock (part_id, location_contact_id, aisle, bin, quantity, reserved, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[partId, location, aisle, bin, quantity, reserved, ts, ts]
);
audit(userId, 'create', 'part_stock', result.lastInsertRowid, null, { part_id: partId });
}
return getPart(partId);
}
function deleteStock(partId, stockId, userId) {
const result = run('DELETE FROM part_stock WHERE id = ? AND part_id = ?', [stockId, partId]);
if (!result.changes) return null;
audit(userId, 'delete', 'part_stock', stockId, { part_id: partId }, null);
return getPart(partId);
}
// ---- Stock movements -------------------------------------------------------
function recordTransaction(partId, body, userId) {
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
const type = TRANSACTION_TYPES.includes(body.type) ? body.type : 'receive';
const stock = one('SELECT * FROM part_stock WHERE id = ? AND part_id = ?', [body.stock_id, partId]);
if (!stock) throw Object.assign(new Error('Select a stock location for this movement'), { status: 400 });
const qty = Math.abs(num(body.quantity));
if (!qty && type !== 'adjust') throw Object.assign(new Error('Quantity must be greater than zero'), { status: 400 });
let quantity = stock.quantity;
let reserved = stock.reserved;
switch (type) {
case 'receive': quantity += qty; break;
case 'issue': quantity = Math.max(0, quantity - qty); break;
case 'adjust': quantity = num(body.quantity); break; // absolute count correction
case 'reserve': reserved = Math.min(quantity, reserved + qty); break;
case 'unreserve': reserved = Math.max(0, reserved - qty); break;
default: break;
}
reserved = Math.min(reserved, quantity);
return tx(() => {
run('UPDATE part_stock SET quantity = ?, reserved = ?, updated_at = ? WHERE id = ?', [quantity, reserved, now(), stock.id]);
run(
`INSERT INTO part_transactions (part_id, stock_id, type, quantity, unit_cost, asset_id, reference, notes, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
partId, stock.id, type, type === 'adjust' ? num(body.quantity) : qty,
body.unit_cost != null && body.unit_cost !== '' ? num(body.unit_cost) : null,
body.asset_id || null, body.reference || null, body.notes || null, userId, now()
]
);
audit(userId, 'movement', 'part', partId, null, { type, quantity: qty, stock_id: stock.id });
return getPart(partId);
});
}
// ---- Photos ----------------------------------------------------------------
function addPhoto(partId, body, userId) {
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
if (!body.data) throw Object.assign(new Error('Photo data is required'), { status: 400 });
run(
'INSERT INTO part_photos (part_id, name, mime_type, data_base64, created_at) VALUES (?, ?, ?, ?, ?)',
[partId, body.name || null, body.mime || body.mime_type || 'image/jpeg', body.data, now()]
);
audit(userId, 'create', 'part_photo', partId, null, { name: body.name });
return getPart(partId);
}
function deletePhoto(partId, photoId, userId) {
const result = run('DELETE FROM part_photos WHERE id = ? AND part_id = ?', [photoId, partId]);
if (!result.changes) return null;
audit(userId, 'delete', 'part_photo', photoId, { part_id: partId }, null);
return getPart(partId);
}
module.exports = {
STATUSES,
TRANSACTION_TYPES,
addPhoto,
createPart,
deletePart,
deletePhoto,
deleteStock,
getPart,
listParts,
recordTransaction,
saveStock,
updatePart
};

467
server/services/pm.js Normal file
View File

@@ -0,0 +1,467 @@
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
const FREQUENCY_UNITS = ['days', 'weeks', 'months', 'quarters', 'semiannual', 'years'];
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function addInterval(dateStr, value, unit) {
const date = new Date(`${dateStr || todayStr()}T00:00:00`);
const v = Math.max(1, Number(value) || 1);
switch (unit) {
case 'days': date.setDate(date.getDate() + v); break;
case 'weeks': date.setDate(date.getDate() + v * 7); break;
case 'quarters': date.setMonth(date.getMonth() + v * 3); break;
case 'semiannual': date.setMonth(date.getMonth() + v * 6); break;
case 'years': date.setFullYear(date.getFullYear() + v); break;
case 'months':
default: date.setMonth(date.getMonth() + v); break;
}
return date.toISOString().slice(0, 10);
}
// ---- PM plan templates -----------------------------------------------------
function round2(value) {
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
}
function yearOf(value) {
return value ? Number(String(value).slice(0, 4)) : null;
}
function planSteps(planId) {
return all('SELECT id, step_order, title, details, est_minutes FROM pm_plan_steps WHERE plan_id = ? ORDER BY step_order, id', [planId]);
}
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]);
}
// Guidance photos for a plan. `withData` is false for list views (omits the base64 blob).
function planPhotos(planId, withData = true) {
const columns = withData ? 'id, name, mime_type, caption, data_base64, sort_order' : 'id, name, mime_type, caption, sort_order';
return all(`SELECT ${columns} FROM pm_plan_photos WHERE plan_id = ? ORDER BY sort_order, id`, [planId])
.map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, caption: p.caption, ...(withData ? { data: p.data_base64 } : {}) }));
}
function stepsMinutes(steps) {
return (steps || []).reduce((sum, step) => sum + (Number(step.est_minutes) || 0), 0);
}
function componentsTotal(components) {
return round2((components || []).reduce((sum, component) => sum + (Number(component.cost) || 0), 0));
}
function planFromRow(row, { withPhotoData = true } = {}) {
if (!row) return null;
const components = planComponents(row.id);
const photos = planPhotos(row.id, withPhotoData);
return { ...row, steps: planSteps(row.id), components, components_total: componentsTotal(components), photos, photo_count: photos.length };
}
function listPlans() {
return all('SELECT * FROM pm_plans ORDER BY name').map((row) => planFromRow(row, { withPhotoData: false }));
}
function getPlan(id) {
return planFromRow(one('SELECT * FROM pm_plans WHERE id = ?', [id]));
}
function writeSteps(planId, steps) {
run('DELETE FROM pm_plan_steps WHERE plan_id = ?', [planId]);
(steps || []).forEach((step, index) => {
const title = (step.title || '').trim();
if (!title) return;
const est = step.est_minutes === '' || step.est_minutes == null ? null : Number(step.est_minutes);
run('INSERT INTO pm_plan_steps (plan_id, step_order, title, details, est_minutes) VALUES (?, ?, ?, ?, ?)', [planId, index, title, step.details || null, est]);
});
}
function writeComponents(planId, components) {
run('DELETE FROM pm_plan_components WHERE plan_id = ?', [planId]);
(components || []).forEach((component, index) => {
const part = (component.part_number || '').trim();
const description = (component.description || '').trim();
if (!part && !description && !Number(component.cost)) return;
run(
'INSERT INTO pm_plan_components (plan_id, sort_order, part_number, description, supplier, cost) VALUES (?, ?, ?, ?, ?, ?)',
[planId, index, component.part_number || null, component.description || null, component.supplier || null, Number(component.cost) || 0]
);
});
}
// Reconcile guidance photos: existing items (carry an id) keep their stored blob and
// only update caption/order; new items (carry data) are inserted; missing ones are removed.
function writePhotos(planId, photos) {
const existing = all('SELECT id FROM pm_plan_photos WHERE plan_id = ?', [planId]).map((r) => r.id);
const keep = new Set();
(photos || []).forEach((photo, index) => {
if (photo.id && existing.includes(photo.id)) {
keep.add(photo.id);
run('UPDATE pm_plan_photos SET caption = ?, sort_order = ? WHERE id = ? AND plan_id = ?', [photo.caption || null, index, photo.id, planId]);
} else if (photo.data) {
const result = run(
'INSERT INTO pm_plan_photos (plan_id, name, mime_type, caption, data_base64, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[planId, photo.name || null, photo.mime || photo.mime_type || 'image/jpeg', photo.caption || null, photo.data, index, now()]
);
keep.add(result.lastInsertRowid);
}
});
for (const id of existing) {
if (!keep.has(id)) run('DELETE FROM pm_plan_photos WHERE id = ?', [id]);
}
}
// Estimated minutes are derived from the step times when steps are supplied.
function resolveEstimatedMinutes(body, fallback) {
if (Array.isArray(body.steps)) {
const total = stepsMinutes(body.steps);
if (total > 0) return total;
}
if (body.estimated_minutes != null && body.estimated_minutes !== '') return Number(body.estimated_minutes);
return fallback ?? null;
}
function createPlan(body, userId) {
const ts = now();
return tx(() => {
const result = run(
`INSERT INTO pm_plans (name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
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',
resolveEstimatedMinutes(body, null), body.instructions || null,
userId, ts, ts
]
);
writeSteps(result.lastInsertRowid, body.steps);
writeComponents(result.lastInsertRowid, body.components);
writePhotos(result.lastInsertRowid, body.photos);
audit(userId, 'create', 'pm_plan', result.lastInsertRowid, null, { name: body.name });
return getPlan(result.lastInsertRowid);
});
}
function updatePlan(id, body, userId) {
const before = one('SELECT * FROM pm_plans WHERE id = ?', [id]);
if (!before) return null;
return tx(() => {
run(
`UPDATE pm_plans SET name = ?, description = ?, category = ?, frequency_value = ?, frequency_unit = ?,
estimated_minutes = ?, instructions = ?, updated_at = ? WHERE id = ?`,
[
body.name ?? before.name, body.description ?? before.description, body.category ?? before.category,
body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value,
FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit,
resolveEstimatedMinutes(body, before.estimated_minutes),
body.instructions ?? before.instructions, now(), id
]
);
if (Array.isArray(body.steps)) writeSteps(id, body.steps);
if (Array.isArray(body.components)) writeComponents(id, body.components);
if (Array.isArray(body.photos)) writePhotos(id, body.photos);
audit(userId, 'update', 'pm_plan', id, before, { name: body.name });
return getPlan(id);
});
}
function deletePlan(id, userId) {
const before = one('SELECT * FROM pm_plans WHERE id = ?', [id]);
if (!before) return false;
run('DELETE FROM pm_plans WHERE id = ?', [id]);
audit(userId, 'delete', 'pm_plan', id, before, null);
return true;
}
// ---- Per-asset PM schedules ------------------------------------------------
function assetPmRows(assetId) {
return all(
`SELECT ap.*, p.name AS plan_name, p.description AS plan_description, p.instructions AS plan_instructions,
u.name AS assigned_to_name
FROM asset_pm_plans ap
LEFT JOIN pm_plans p ON p.id = ap.plan_id
LEFT JOIN users u ON u.id = ap.assigned_to
WHERE ap.asset_id = ?
ORDER BY ap.active DESC, ap.next_due_date IS NULL, ap.next_due_date`,
[assetId]
).map((row) => {
const components = row.plan_id ? planComponents(row.plan_id) : [];
const completions = all(
`SELECT c.id, c.completed_at, c.notes, c.notes_json, c.steps_json, c.cost, c.components_json,
c.rating_safety, c.rating_physical, c.rating_operating,
CASE WHEN c.signature IS NOT NULL THEN 1 ELSE 0 END AS has_signature, c.next_due_date,
u.name AS completed_by_name,
(SELECT COUNT(*) FROM pm_completion_photos ph WHERE ph.completion_id = c.id) AS photo_count
FROM pm_completions c LEFT JOIN users u ON u.id = c.completed_by
WHERE c.asset_pm_id = ? ORDER BY c.completed_at DESC LIMIT 12`,
[row.id]
).map((c) => ({
...c,
has_signature: Boolean(c.has_signature),
notes: parseJson(c.notes_json, c.notes ? [c.notes] : []),
steps: parseJson(c.steps_json, []),
components: parseJson(c.components_json, []),
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));
return {
...row,
active: Boolean(row.active),
steps: row.plan_id ? planSteps(row.plan_id) : [],
plan_photos: row.plan_id ? planPhotos(row.plan_id) : [],
components,
expected_cost: componentsTotal(components),
total_cost: totalCost,
last_cost: completions[0] ? round2(completions[0].cost) : 0,
completions
};
});
}
function attachPlan(assetId, body, userId) {
const ts = now();
const plan = body.plan_id ? one('SELECT * FROM pm_plans WHERE id = ?', [body.plan_id]) : null;
const value = Number(body.frequency_value) || (plan ? plan.frequency_value : 1);
const unit = FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : (plan ? plan.frequency_unit : 'months');
const startDate = body.start_date || todayStr();
const nextDue = body.next_due_date || startDate;
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`,
[assetId, body.plan_id || null, value, unit, startDate, body.end_date || null, nextDue, body.assigned_to || null, body.notes || null, ts, ts]
);
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]);
}
function updateAssetPm(assetId, apId, body, userId) {
const before = one('SELECT * FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
if (!before) return null;
run(
`UPDATE asset_pm_plans SET frequency_value = ?, frequency_unit = ?, start_date = ?, end_date = ?,
next_due_date = ?, assigned_to = ?, active = ?, notes = ?, updated_at = ? WHERE id = ?`,
[
body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value,
FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit,
body.start_date ?? before.start_date,
body.end_date !== undefined ? body.end_date : before.end_date,
body.next_due_date ?? before.next_due_date,
body.assigned_to !== undefined ? body.assigned_to : before.assigned_to,
body.active === undefined ? before.active : (body.active ? 1 : 0),
body.notes ?? before.notes,
now(), apId
]
);
audit(userId, 'update', 'asset_pm', apId, before, body);
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [apId]);
}
function detachAssetPm(assetId, apId, userId) {
const result = run('DELETE FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
if (!result.changes) return false;
audit(userId, 'detach', 'asset_pm', apId, null, { asset_id: assetId });
return true;
}
function clampRating(value) {
if (value == null || value === '') return null;
return Math.max(1, Math.min(5, Number(value)));
}
function completePm(assetId, apId, body, userId) {
const ap = one('SELECT * FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
if (!ap) return null;
if (!body.signature) throw Object.assign(new Error('A signature is required to complete a PM'), { status: 400 });
const ts = now();
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 components = Array.isArray(body.components) ? body.components : 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 ratings = body.ratings || {};
const photos = Array.isArray(body.photos) ? body.photos.filter((p) => p && p.data) : [];
return tx(() => {
const inserted = run(
`INSERT INTO pm_completions (asset_pm_id, completed_at, completed_by, notes, notes_json, steps_json, cost, components_json,
signature, rating_safety, rating_physical, rating_operating, next_due_date, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
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
]
);
for (const photo of photos) {
run('INSERT INTO pm_completion_photos (completion_id, name, mime_type, data_base64, created_at) VALUES (?, ?, ?, ?, ?)', [
inserted.lastInsertRowid, photo.name || null, photo.mime || photo.mime_type || 'image/jpeg', photo.data, ts
]);
}
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 });
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [apId]);
});
}
function listCompletions(query = {}) {
return all(
`SELECT c.id, c.completed_at, c.cost, c.notes_json, c.rating_safety, c.rating_physical, c.rating_operating,
CASE WHEN c.signature IS NOT NULL THEN 1 ELSE 0 END AS has_signature,
u.name AS completed_by_name, a.asset_id AS asset_code, a.description AS asset_description, p.name AS plan_name,
(SELECT COUNT(*) FROM pm_completion_photos ph WHERE ph.completion_id = c.id) AS photo_count
FROM pm_completions c
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
JOIN assets a ON a.id = ap.asset_id
LEFT JOIN pm_plans p ON p.id = ap.plan_id
LEFT JOIN users u ON u.id = c.completed_by
WHERE (? IS NULL OR ap.asset_id = ?)
ORDER BY c.completed_at DESC, c.id DESC
LIMIT 500`,
[query.asset_id || null, query.asset_id || null]
).map((r) => ({ ...r, has_signature: Boolean(r.has_signature), notes: parseJson(r.notes_json, []) }));
}
function getCompletion(id) {
const row = one(
`SELECT c.*, u.name AS completed_by_name, a.asset_id AS asset_code, a.description AS asset_description, p.name AS plan_name
FROM pm_completions c
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
JOIN assets a ON a.id = ap.asset_id
LEFT JOIN pm_plans p ON p.id = ap.plan_id
LEFT JOIN users u ON u.id = c.completed_by
WHERE c.id = ?`,
[id]
);
if (!row) return null;
return {
id: row.id,
asset_code: row.asset_code,
asset_description: row.asset_description,
plan_name: row.plan_name,
completed_at: row.completed_at,
completed_by_name: row.completed_by_name,
cost: row.cost,
notes: parseJson(row.notes_json, row.notes ? [row.notes] : []),
steps: parseJson(row.steps_json, []),
components: parseJson(row.components_json, []),
signature: row.signature || null,
ratings: { safety: row.rating_safety, physical: row.rating_physical, operating: row.rating_operating },
photos: all('SELECT id, name, mime_type, data_base64 FROM pm_completion_photos WHERE completion_id = ? ORDER BY id', [row.id])
.map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, data: p.data_base64 }))
};
}
// ---- Settings & alert candidates ------------------------------------------
function getSettings() {
const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'pm_%'");
const map = Object.fromEntries(rows.map((r) => [r.key, r.value]));
return {
pm_default_frequency_value: Number(map.pm_default_frequency_value || 3),
pm_default_frequency_unit: map.pm_default_frequency_unit || 'months',
pm_lead_days: Number(map.pm_lead_days || 7)
};
}
function saveSettings(body, userId) {
const set = (k, v) => run('INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at', [k, String(v), now()]);
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_lead_days !== undefined) set('pm_lead_days', Number(body.pm_lead_days) || 7);
audit(userId, 'update', 'pm_settings', null, null, body);
return getSettings();
}
// Alert candidates for PM schedules that are due/overdue.
function pmAlertCandidates() {
const lead = getSettings().pm_lead_days;
const today = todayStr();
const horizon = new Date(Date.now() + lead * 86400000).toISOString().slice(0, 10);
const rows = all(
`SELECT ap.*, p.name AS plan_name, a.asset_id AS asset_code
FROM asset_pm_plans ap
LEFT JOIN pm_plans p ON p.id = ap.plan_id
LEFT JOIN assets a ON a.id = ap.asset_id
WHERE ap.active = 1 AND ap.next_due_date IS NOT NULL`
);
const list = [];
for (const ap of rows) {
const name = ap.plan_name || 'Preventative maintenance';
const where = ap.asset_code ? ` on ${ap.asset_code}` : '';
if (ap.next_due_date < today) {
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 });
} 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 });
}
}
return list;
}
// Maintenance-cost "ledger" for the PM book: actual PM spend per asset for a year.
function pmBookLedger(year) {
const rows = all(
`SELECT c.cost, c.completed_at, a.asset_id, a.description
FROM pm_completions c
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
JOIN assets a ON a.id = ap.asset_id`
);
const byAsset = {};
let total = 0;
let count = 0;
for (const row of rows) {
if (yearOf(row.completed_at) !== Number(year)) continue;
const cost = Number(row.cost || 0);
const entry = byAsset[row.asset_id] || (byAsset[row.asset_id] = { asset_id: row.asset_id, description: row.description, completions: 0, cost: 0, last_service: null });
entry.completions += 1;
entry.cost += cost;
const day = String(row.completed_at).slice(0, 10);
if (!entry.last_service || day > entry.last_service) entry.last_service = day;
total += cost;
count += 1;
}
const assets = Object.values(byAsset).map((a) => ({ ...a, cost: round2(a.cost) })).sort((a, b) => b.cost - a.cost);
const accounts = [
{ account: '6450', type: 'PM / maintenance expense', debit: round2(total), credit: 0 },
{ account: '2000', type: 'Maintenance clearing', debit: 0, credit: round2(total) }
];
return {
year: Number(year),
kind: 'maintenance',
rule_set: {},
summary: { assets: assets.length, completions: count, cost: round2(total) },
accounts,
accountTotals: { debit: round2(total), credit: round2(total) },
assets
};
}
module.exports = {
FREQUENCY_UNITS,
addInterval,
assetPmRows,
pmBookLedger,
attachPlan,
completePm,
createPlan,
deletePlan,
detachAssetPm,
getCompletion,
getPlan,
listCompletions,
getSettings,
listPlans,
pmAlertCandidates,
saveSettings,
updateAssetPm,
updatePlan
};

View File

@@ -1,5 +1,6 @@
const { json, now, one, parseJson, run } = require('../db');
const { publicUser } = require('../middleware/auth');
const { capabilitiesForRole } = require('./roles');
const defaultPreferences = {
theme: 'mixedAssetsDark'
@@ -16,7 +17,8 @@ function getPreferences(userId) {
function getProfile(user) {
return {
user: publicUser(user),
preferences: getPreferences(user.id)
preferences: getPreferences(user.id),
capabilities: capabilitiesForRole(user.role)
};
}

View File

@@ -0,0 +1,807 @@
const { all, now, one, parseJson } = require('../db');
const { annualSchedule, money } = require('../depreciation');
const { ruleSetForBook } = require('./ruleSets');
const { assetFromRow } = require('./assets');
const BOOKS = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
function currentYear() {
return new Date().getFullYear();
}
function yearOf(value) {
if (!value) return null;
return new Date(`${value}T00:00:00`).getFullYear();
}
function sumBy(rows, keys) {
const totals = {};
for (const key of keys) totals[key] = money(rows.reduce((sum, row) => sum + Number(row[key] || 0), 0));
return totals;
}
// Columns helper: { key, label, format } where format drives client + export rendering.
function col(key, label, format = 'text') {
return { key, label, format };
}
// ---- Asset/book loading ----------------------------------------------------
function assetBookRows(bookType, options = {}) {
return all(
`SELECT a.*, b.book_type, b.cost AS book_cost, b.depreciation_method, b.convention,
b.useful_life_months AS book_life, b.section_179_amount, b.bonus_percent,
b.business_use_percent AS book_bup, b.investment_use_percent AS book_ivp,
b.manual_depreciation, e.name AS entity_name
FROM assets a
JOIN asset_books b ON b.asset_id = a.id
LEFT JOIN entities e ON e.id = a.entity_id
WHERE b.book_type = ? AND b.active = 1
AND (? IS NULL OR a.entity_id = ?)
AND (? IS NULL OR a.category = ?)
AND (? IS NULL OR a.status = ?)
ORDER BY a.asset_id`,
[
bookType,
options.entity_id || null, options.entity_id || null,
options.category || null, options.category || null,
options.status || null, options.status || null
]
);
}
function toAssetBook(row) {
return {
asset: assetFromRow(row),
entity_name: row.entity_name,
book: {
book_type: row.book_type,
cost: row.book_cost,
depreciation_method: row.depreciation_method,
convention: row.convention,
useful_life_months: row.book_life,
section_179_amount: row.section_179_amount,
bonus_percent: row.bonus_percent,
business_use_percent: row.book_bup,
investment_use_percent: row.book_ivp,
manual_depreciation: row.manual_depreciation
}
};
}
function computeYear(asset, book, rules, year) {
const rows = annualSchedule(asset, book, rules, { startYear: year - 1, endYear: year });
const end = rows.find((row) => row.year === year);
const cost = Number(book.cost ?? asset.acquisition_cost ?? 0);
const depreciation = end ? Number(end.depreciation) : 0;
const accumulatedEnd = end ? Number(end.accumulated) : Number(asset.prior_depreciation || 0);
const accumulatedBegin = money(accumulatedEnd - depreciation);
return {
cost,
depreciation: money(depreciation),
accumulated_begin: accumulatedBegin,
accumulated_end: money(accumulatedEnd),
nbv_begin: money(cost - accumulatedBegin),
nbv_end: money(cost - accumulatedEnd),
method: end ? end.method : book.depreciation_method,
methodLabel: end ? end.methodLabel : book.depreciation_method
};
}
function lifetimeAccumulated(asset, book, rules) {
const rows = annualSchedule(asset, book, rules, { startYear: currentYear(), endYear: currentYear() });
return rows.length ? Number(rows[rows.length - 1].accumulated) : Number(asset.prior_depreciation || 0);
}
// ---- Report builders -------------------------------------------------------
function depreciationExpense(options, titleBook) {
const book = titleBook || options.book || 'GAAP';
const year = Number(options.year) || currentYear();
const rules = ruleSetForBook(book);
const rows = assetBookRows(book, options).map(toAssetBook).map(({ asset, book: bk, entity_name }) => {
const c = computeYear(asset, bk, rules, year);
return {
asset_id: asset.asset_id,
description: asset.description,
entity: entity_name,
category: asset.category,
method: c.methodLabel,
cost: c.cost,
depreciation: c.depreciation,
accumulated: c.accumulated_end,
nbv: c.nbv_end
};
});
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('category', 'Category'),
col('method', 'Method'), col('cost', 'Cost', 'currency'), col('depreciation', 'Depreciation', 'currency'),
col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
],
rows,
totals: sumBy(rows, ['cost', 'depreciation', 'accumulated', 'nbv'])
};
}
function monthlyDepreciation(options) {
const book = options.book || 'GAAP';
const year = Number(options.year) || currentYear();
const annual = depreciationExpense({ ...options, book }).totals.depreciation;
const monthly = money(annual / 12);
const rows = Array.from({ length: 12 }, (_, index) => ({ period: `M${index + 1}`, depreciation: monthly }));
return {
columns: [col('period', 'Month'), col('depreciation', 'Depreciation', 'currency')],
rows,
totals: { depreciation: money(monthly * 12) },
chart: { type: 'bar', labels: rows.map((r) => r.period), values: rows.map((r) => r.depreciation) }
};
}
function quarterlyDepreciation(options) {
const annual = depreciationExpense(options).totals.depreciation;
const quarter = money(annual / 4);
const rows = ['Q1', 'Q2', 'Q3', 'Q4'].map((period) => ({ period, depreciation: quarter }));
return {
columns: [col('period', 'Quarter'), col('depreciation', 'Depreciation', 'currency')],
rows,
totals: { depreciation: money(quarter * 4) },
chart: { type: 'bar', labels: rows.map((r) => r.period), values: rows.map((r) => r.depreciation) }
};
}
function ytdDepreciation(options) {
const month = Math.min(12, Math.max(1, Number(options.month) || 12));
const base = depreciationExpense(options);
const rows = base.rows.map((row) => ({ ...row, ytd: money((row.depreciation * month) / 12) }));
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('method', 'Method'),
col('depreciation', 'Annual', 'currency'), col('ytd', `YTD through M${month}`, 'currency'),
col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
],
rows,
totals: sumBy(rows, ['depreciation', 'ytd', 'accumulated', 'nbv'])
};
}
function netBookValue(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, entity_name }) => {
const c = computeYear(asset, bk, rules, year);
return {
asset_id: asset.asset_id, description: asset.description, entity: entity_name,
cost: c.cost, accumulated: c.accumulated_end, nbv: c.nbv_end
};
});
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('entity', 'Entity'),
col('cost', 'Cost', 'currency'), col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
],
rows,
totals: sumBy(rows, ['cost', 'accumulated', 'nbv'])
};
}
function lifetimeDepreciation(options) {
const book = options.book || 'GAAP';
const rules = ruleSetForBook(book);
const rows = assetBookRows(book, options).map(toAssetBook).map(({ asset, book: bk, entity_name }) => {
const cost = Number(bk.cost ?? asset.acquisition_cost ?? 0);
const accumulated = lifetimeAccumulated(asset, bk, rules);
return {
asset_id: asset.asset_id, description: asset.description, entity: entity_name,
in_service_date: asset.in_service_date, cost,
lifetime_depreciation: money(accumulated), nbv: money(cost - accumulated)
};
});
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('in_service_date', 'In service', 'date'),
col('cost', 'Cost', 'currency'), col('lifetime_depreciation', 'Lifetime depr.', 'currency'), col('nbv', 'Net book value', 'currency')
],
rows,
totals: sumBy(rows, ['cost', 'lifetime_depreciation', 'nbv'])
};
}
function acquisitions(options) {
const year = Number(options.year) || currentYear();
const rows = all(
`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
AND (? IS NULL OR a.entity_id = ?)
ORDER BY a.acquired_date`,
[options.entity_id || null, options.entity_id || null]
).filter((row) => yearOf(row.acquired_date) === year)
.map((row) => ({ ...row, acquisition_cost: money(row.acquisition_cost) }));
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('category', 'Category'),
col('acquired_date', 'Acquired', 'date'), col('vendor', 'Vendor'), col('invoice_number', 'Invoice'),
col('acquisition_cost', 'Cost', 'currency')
],
rows,
totals: sumBy(rows, ['acquisition_cost'])
};
}
function disposalRows(year, entityId) {
return all(
`SELECT d.*, a.asset_id, a.description, a.category, a.acquired_date
FROM disposals d JOIN assets a ON a.id = d.asset_id
WHERE (? IS NULL OR a.entity_id = ?)
ORDER BY d.disposal_date`,
[entityId || null, entityId || null]
).filter((row) => yearOf(row.disposal_date) === year);
}
function disposals(options) {
const year = Number(options.year) || currentYear();
const rows = disposalRows(year, options.entity_id).map((row) => ({
asset_id: row.asset_id,
description: row.description,
method: row.method,
disposal_date: row.disposal_date,
proceeds: money(row.disposal_price - row.disposal_expense),
net_book_value: money(row.net_book_value),
gain_loss: money(row.gain_loss)
}));
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('method', 'Type'),
col('disposal_date', 'Date', 'date'), col('proceeds', 'Net proceeds', 'currency'),
col('net_book_value', 'Net book value', 'currency'), col('gain_loss', 'Gain / (loss)', 'currency')
],
rows,
totals: sumBy(rows, ['proceeds', 'net_book_value', 'gain_loss'])
};
}
function adjustments(options) {
const year = Number(options.year) || currentYear();
const rows = all(
`SELECT adj.*, a.asset_id, a.description FROM asset_adjustments adj
JOIN assets a ON a.id = adj.asset_id
WHERE (? IS NULL OR a.entity_id = ?)
ORDER BY adj.adjustment_date`,
[options.entity_id || null, options.entity_id || null]
).filter((row) => yearOf(row.adjustment_date) === year)
.map((row) => ({
asset_id: row.asset_id, description: row.description, type: row.type,
book_type: row.book_type, adjustment_date: row.adjustment_date,
amount: money(row.amount), reason: row.reason
}));
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('type', 'Type'),
col('book_type', 'Book'), col('adjustment_date', 'Date', 'date'), col('amount', 'Amount', 'currency'), col('reason', 'Reason')
],
rows,
totals: sumBy(rows, ['amount'])
};
}
function rollforward(options) {
const book = options.book || 'GAAP';
const year = Number(options.year) || currentYear();
const rules = ruleSetForBook(book);
const groups = {};
for (const { asset, book: bk } of assetBookRows(book, options).map(toAssetBook)) {
const c = computeYear(asset, bk, rules, year);
const key = asset.category || 'Uncategorized';
groups[key] = groups[key] || { category: key, beginning_nbv: 0, additions: 0, depreciation: 0, disposals: 0, ending_nbv: 0 };
groups[key].beginning_nbv += c.nbv_begin;
groups[key].depreciation += c.depreciation;
if (yearOf(asset.acquired_date) === year) groups[key].additions += c.cost;
groups[key].ending_nbv += c.nbv_end;
}
for (const row of disposalRows(year, options.entity_id)) {
const key = row.category || 'Uncategorized';
groups[key] = groups[key] || { category: key, beginning_nbv: 0, additions: 0, depreciation: 0, disposals: 0, ending_nbv: 0 };
groups[key].disposals += Number(row.net_book_value || 0);
}
const rows = Object.values(groups).map((row) => ({
category: row.category,
beginning_nbv: money(row.beginning_nbv),
additions: money(row.additions),
depreciation: money(row.depreciation),
disposals: money(row.disposals),
ending_nbv: money(row.ending_nbv)
}));
return {
columns: [
col('category', 'Category'), col('beginning_nbv', 'Beginning NBV', 'currency'),
col('additions', 'Additions', 'currency'), col('depreciation', 'Depreciation', 'currency'),
col('disposals', 'Disposals (NBV)', 'currency'), col('ending_nbv', 'Ending NBV', 'currency')
],
rows,
totals: sumBy(rows, ['beginning_nbv', 'additions', 'depreciation', 'disposals', 'ending_nbv'])
};
}
function projection(options) {
const book = options.book || 'GAAP';
const startYear = Number(options.startYear) || currentYear();
const endYear = Number(options.endYear) || startYear + 5;
const rules = ruleSetForBook(book);
const items = assetBookRows(book, options).map(toAssetBook);
const rows = [];
for (let year = startYear; year <= endYear; year += 1) {
let depreciation = 0;
let nbv = 0;
for (const { asset, book: bk } of items) {
const c = computeYear(asset, bk, rules, year);
depreciation += c.depreciation;
nbv += c.nbv_end;
}
rows.push({ year, depreciation: money(depreciation), ending_nbv: money(nbv) });
}
return {
columns: [col('year', 'Year', 'number'), col('depreciation', 'Projected depreciation', 'currency'), col('ending_nbv', 'Ending NBV', 'currency')],
rows,
totals: { depreciation: money(rows.reduce((sum, row) => sum + row.depreciation, 0)) },
chart: { type: 'bar', labels: rows.map((r) => String(r.year)), values: rows.map((r) => r.depreciation) }
};
}
function journalDepreciation(options) {
const book = options.book || 'GAAP';
const year = Number(options.year) || currentYear();
const rules = ruleSetForBook(book);
const accounts = {};
for (const { asset, book: bk } of assetBookRows(book, options).map(toAssetBook)) {
const c = computeYear(asset, bk, rules, year);
if (!c.depreciation) continue;
const expense = asset.gl_expense_account || '6400';
const accum = asset.gl_accumulated_account || '1590';
accounts[expense] = (accounts[expense] || 0) + c.depreciation;
accounts[`__accum__${accum}`] = (accounts[`__accum__${accum}`] || 0) + c.depreciation;
}
const rows = [];
for (const [key, amount] of Object.entries(accounts)) {
if (key.startsWith('__accum__')) {
rows.push({ account: key.replace('__accum__', ''), memo: 'Accumulated depreciation', debit: 0, credit: money(amount) });
} else {
rows.push({ account: key, memo: 'Depreciation expense', debit: money(amount), credit: 0 });
}
}
rows.sort((a, b) => b.debit - a.debit);
return {
columns: [col('account', 'GL account'), col('memo', 'Memo'), col('debit', 'Debit', 'currency'), col('credit', 'Credit', 'currency')],
rows,
totals: sumBy(rows, ['debit', 'credit'])
};
}
function journalDisposals(options) {
const year = Number(options.year) || currentYear();
const rows = [];
for (const d of disposalRows(year, options.entity_id)) {
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: 'Accumulated depreciation', debit: money(d.accumulated_depreciation), credit: 0 });
rows.push({ asset_id: d.asset_id, memo: 'Asset cost', debit: 0, credit: money(d.disposed_cost) });
if (d.gain_loss >= 0) rows.push({ asset_id: d.asset_id, memo: 'Gain on disposal', debit: 0, credit: money(d.gain_loss) });
else rows.push({ asset_id: d.asset_id, memo: 'Loss on disposal', debit: money(Math.abs(d.gain_loss)), credit: 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 form4562(options) {
const year = Number(options.year) || currentYear();
const rules = ruleSetForBook('FEDERAL');
const items = assetBookRows('FEDERAL', options).map(toAssetBook)
.filter(({ asset }) => yearOf(asset.in_service_date || asset.acquired_date) === year);
let section179 = 0;
let bonus = 0;
let macrs = 0;
const rows = items.map(({ asset, book: bk }) => {
const c = computeYear(asset, bk, rules, year);
const s179 = Math.min(Number(bk.cost ?? asset.acquisition_cost ?? 0), Number(bk.section_179_amount || 0));
section179 += s179;
macrs += c.depreciation;
return {
asset_id: asset.asset_id, description: asset.description, method: c.methodLabel,
cost: c.cost, section_179: money(s179), depreciation: c.depreciation
};
});
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('method', 'Method'),
col('cost', 'Cost', 'currency'), col('section_179', '§179', 'currency'), col('depreciation', 'Depreciation', 'currency')
],
rows,
totals: { section_179: money(section179), bonus: money(bonus), depreciation: money(macrs), cost: money(rows.reduce((s, r) => s + r.cost, 0)) },
meta: { note: 'Summary for IRS Form 4562 (Federal book, current-year placements). Validate against current tax law before filing.' }
};
}
function form4797(options) {
const year = Number(options.year) || currentYear();
const rows = disposalRows(year, options.entity_id).map((d) => ({
asset_id: d.asset_id,
description: d.description,
property_type: d.property_type || 'other',
acquired_date: d.acquired_date,
disposal_date: d.disposal_date,
proceeds: money(d.disposal_price - d.disposal_expense),
cost: money(d.disposed_cost),
accumulated: money(d.accumulated_depreciation),
gain_loss: money(d.gain_loss)
}));
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('property_type', 'Property type'),
col('acquired_date', 'Acquired', 'date'), col('disposal_date', 'Sold', 'date'),
col('proceeds', 'Proceeds', 'currency'), col('cost', 'Cost basis', 'currency'),
col('accumulated', 'Depr. allowed', 'currency'), col('gain_loss', 'Gain / (loss)', 'currency')
],
rows,
totals: sumBy(rows, ['proceeds', 'cost', 'accumulated', 'gain_loss']),
meta: { note: 'Summary for IRS Form 4797 (Sales of Business Property). §1245/§1250 recapture analysis should be reviewed before filing.' }
};
}
function personalPropertyTax(options) {
const book = options.book || 'GAAP';
const year = Number(options.year) || currentYear();
const rules = ruleSetForBook(book);
const groups = {};
for (const { asset, book: bk } of assetBookRows(book, options).map(toAssetBook)) {
const c = computeYear(asset, bk, rules, year);
const key = asset.location || 'Unassigned';
groups[key] = groups[key] || { location: key, count: 0, cost: 0, nbv: 0 };
groups[key].count += 1;
groups[key].cost += c.cost;
groups[key].nbv += c.nbv_end;
}
const rows = Object.values(groups).map((row) => ({ ...row, cost: money(row.cost), nbv: money(row.nbv) }));
return {
columns: [col('location', 'Location'), col('count', 'Assets', 'number'), col('cost', 'Cost', 'currency'), col('nbv', 'Taxable NBV', 'currency')],
rows,
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['cost', 'nbv']) }
};
}
function constructionInProgress(options) {
const rows = all(
`SELECT asset_id, description, category, acquired_date, acquisition_cost, vendor
FROM assets WHERE status = 'cip' AND (? IS NULL OR entity_id = ?) ORDER BY acquired_date`,
[options.entity_id || null, options.entity_id || null]
).map((row) => ({ ...row, acquisition_cost: money(row.acquisition_cost) }));
return {
columns: [
col('asset_id', 'Project ID'), col('description', 'Description'), col('category', 'Category'),
col('acquired_date', 'Started', 'date'), col('vendor', 'Vendor'), col('acquisition_cost', 'Cost to date', 'currency')
],
rows,
totals: sumBy(rows, ['acquisition_cost'])
};
}
function ubia(options) {
const rows = all(
`SELECT asset_id, description, in_service_date, acquisition_cost, land_value
FROM assets WHERE status != 'disposed' AND (? IS NULL OR entity_id = ?) ORDER BY asset_id`,
[options.entity_id || null, options.entity_id || null]
).map((row) => ({
asset_id: row.asset_id, description: row.description, in_service_date: row.in_service_date,
ubia: money(Number(row.acquisition_cost || 0) - Number(row.land_value || 0))
}));
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'),
col('in_service_date', 'In service', 'date'), col('ubia', 'UBIA', 'currency')
],
rows,
totals: sumBy(rows, ['ubia']),
meta: { note: 'Unadjusted Basis Immediately After Acquisition for the §199A qualified business income limitation.' }
};
}
function fixedAssetCard(options) {
const asset = options.asset_id
? assetFromRow(one('SELECT * FROM assets WHERE id = ? OR asset_id = ? LIMIT 1', [options.asset_id, options.asset_id]))
: null;
if (!asset) return { columns: [], rows: [], totals: {}, meta: { note: 'Select an asset to view its card.' } };
const year = Number(options.year) || currentYear();
const books = all('SELECT * FROM asset_books WHERE asset_id = ? AND active = 1 ORDER BY book_type', [asset.id]);
const rows = books.map((bookRow) => {
const book = { ...bookRow, useful_life_months: bookRow.useful_life_months, manual_depreciation: bookRow.manual_depreciation };
const rules = ruleSetForBook(bookRow.book_type);
const c = computeYear(asset, book, rules, year);
return {
book: bookRow.book_type, method: c.methodLabel, cost: c.cost,
depreciation: c.depreciation, accumulated: c.accumulated_end, nbv: c.nbv_end
};
});
return {
columns: [
col('book', 'Book'), col('method', 'Method'), col('cost', 'Cost', 'currency'),
col('depreciation', `${year} depr.`, 'currency'), col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
],
rows,
totals: sumBy(rows, ['cost', 'depreciation', 'accumulated', 'nbv']),
meta: {
asset: {
asset_id: asset.asset_id, description: asset.description, category: asset.category, status: asset.status,
vendor: asset.vendor, serial_number: asset.serial_number, location: asset.location,
department: asset.department, custodian: asset.custodian, acquired_date: asset.acquired_date,
in_service_date: asset.in_service_date, barcode_value: asset.barcode_value
}
}
};
}
// ---- Report Builder (custom) ----------------------------------------------
const BUILDER_FIELDS = [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('category', 'Category'),
col('status', 'Status'), col('entity_name', 'Entity'), col('location', 'Location'),
col('department', 'Department'), col('group_name', 'Group'), col('custodian', 'Custodian'),
col('vendor', 'Vendor'), col('serial_number', 'Serial'), col('invoice_number', 'Invoice'),
col('barcode_value', 'Barcode'), col('condition', 'Condition'), col('new_or_used', 'New/Used'),
col('acquisition_cost', 'Cost', 'currency'), col('salvage_value', 'Salvage', 'currency'),
col('land_value', 'Land value', 'currency'), col('useful_life_months', 'Life (months)', 'number'),
col('acquired_date', 'Acquired', 'date'), col('in_service_date', 'In service', 'date'),
col('gl_asset_account', 'Asset GL'), col('gl_expense_account', 'Expense GL'), col('gl_accumulated_account', 'Accum. GL')
];
function customReport(options) {
const requested = Array.isArray(options.columns) && options.columns.length
? options.columns
: ['asset_id', 'description', 'category', 'status', 'acquisition_cost'];
const columns = BUILDER_FIELDS.filter((field) => requested.includes(field.key));
const rows = all(
`SELECT a.*, e.name AS entity_name FROM assets a LEFT JOIN entities e ON e.id = a.entity_id
WHERE (? IS NULL OR a.entity_id = ?)
AND (? IS NULL OR a.category = ?)
AND (? IS NULL OR a.status = ?)
ORDER BY a.asset_id`,
[
options.entity_id || null, options.entity_id || null,
options.category || null, options.category || null,
options.status || null, options.status || null
]
).map((row) => {
const projected = {};
for (const column of columns) projected[column.key] = row[column.key];
return projected;
});
const currencyKeys = columns.filter((c) => c.format === 'currency').map((c) => c.key);
return { columns, rows, totals: sumBy(rows, currencyKeys) };
}
// ---- Maintenance & alerts --------------------------------------------------
function dateStr(date) {
return date.toISOString().slice(0, 10);
}
function freqLabel(value, unit) {
return `every ${value} ${unit}`;
}
function pmDue(options) {
const before = options.before_date || dateStr(new Date(Date.now() + 30 * 86400000));
const rows = all(
`SELECT ap.next_due_date, ap.last_completed_date, ap.frequency_value, ap.frequency_unit,
p.name AS plan_name, a.asset_id AS asset_code, a.description AS asset_description, u.name AS assigned_to_name
FROM asset_pm_plans ap
JOIN assets a ON a.id = ap.asset_id
LEFT JOIN pm_plans p ON p.id = ap.plan_id
LEFT JOIN users u ON u.id = ap.assigned_to
WHERE ap.active = 1 AND ap.next_due_date IS NOT NULL AND ap.next_due_date <= ?
AND (? IS NULL OR a.entity_id = ?)
ORDER BY ap.next_due_date`,
[before, options.entity_id || null, options.entity_id || null]
).map((row) => ({ ...row, plan_name: row.plan_name || 'Maintenance', frequency: freqLabel(row.frequency_value, row.frequency_unit) }));
return {
columns: [
col('asset_code', 'Asset'), col('asset_description', 'Description'), col('plan_name', 'Plan'),
col('frequency', 'Frequency'), col('next_due_date', 'Next due', 'date'), col('last_completed_date', 'Last done', 'date'),
col('assigned_to_name', 'Assigned to')
],
rows,
totals: {},
meta: { note: `${rows.length} PM schedule(s) due on or before ${before}.` }
};
}
function pmUncompleted(options) {
const today = dateStr(new Date());
const rows = all(
`SELECT ap.next_due_date, ap.last_completed_date, ap.frequency_value, ap.frequency_unit,
p.name AS plan_name, a.asset_id AS asset_code, a.description AS asset_description, u.name AS assigned_to_name
FROM asset_pm_plans ap
JOIN assets a ON a.id = ap.asset_id
LEFT JOIN pm_plans p ON p.id = ap.plan_id
LEFT JOIN users u ON u.id = ap.assigned_to
WHERE ap.active = 1 AND ap.next_due_date IS NOT NULL AND ap.next_due_date < ?
AND (? IS NULL OR a.entity_id = ?)
ORDER BY ap.next_due_date`,
[today, options.entity_id || null, options.entity_id || null]
).map((row) => ({
...row,
plan_name: row.plan_name || 'Maintenance',
frequency: freqLabel(row.frequency_value, row.frequency_unit),
days_overdue: Math.max(0, Math.round((new Date(`${today}T00:00:00`) - new Date(`${row.next_due_date}T00:00:00`)) / 86400000))
}));
return {
columns: [
col('asset_code', 'Asset'), col('asset_description', 'Description'), col('plan_name', 'Plan'),
col('next_due_date', 'Was due', 'date'), col('days_overdue', 'Days overdue', 'number'),
col('last_completed_date', 'Last done', 'date'), col('assigned_to_name', 'Assigned to')
],
rows,
totals: {},
meta: { note: `${rows.length} asset PM schedule(s) overdue / uncompleted as of ${today}.` }
};
}
function pmCompleted(options) {
const since = options.since_date || dateStr(new Date(Date.now() - 30 * 86400000));
const rows = all(
`SELECT c.completed_at, c.cost, c.rating_safety, c.rating_physical, c.rating_operating,
CASE WHEN c.signature IS NOT NULL THEN 'yes' ELSE 'no' END AS signed,
(SELECT COUNT(*) FROM pm_completion_photos ph WHERE ph.completion_id = c.id) AS photos,
u.name AS completed_by_name, a.asset_id AS asset_code, p.name AS plan_name
FROM pm_completions c
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
JOIN assets a ON a.id = ap.asset_id
LEFT JOIN pm_plans p ON p.id = ap.plan_id
LEFT JOIN users u ON u.id = c.completed_by
WHERE c.completed_at >= ?
AND (? IS NULL OR a.entity_id = ?)
ORDER BY c.completed_at DESC`,
[since, options.entity_id || null, options.entity_id || null]
).map((row) => ({ ...row, plan_name: row.plan_name || 'Maintenance', completed_at: String(row.completed_at).slice(0, 10), cost: money(row.cost) }));
return {
columns: [
col('asset_code', 'Asset'), col('plan_name', 'Plan'), col('completed_at', 'Completed', 'date'),
col('completed_by_name', 'By'), col('rating_safety', 'Safety', 'number'), col('rating_operating', 'Operating', 'number'),
col('photos', 'Photos', 'number'), col('signed', 'Signed'), col('cost', 'Cost', 'currency')
],
rows,
totals: sumBy(rows, ['cost']),
meta: { note: `${rows.length} PM service(s) completed since ${since}.` }
};
}
function pmPlanPrint(options) {
const plan = options.pm_plan_id ? one('SELECT * FROM pm_plans WHERE id = ?', [options.pm_plan_id]) : null;
if (!plan) return { columns: [], rows: [], totals: {}, meta: { note: 'Select a PM plan to print.' } };
const steps = all('SELECT step_order, title, details, est_minutes FROM pm_plan_steps WHERE plan_id = ? ORDER BY step_order, id', [plan.id]);
const components = all('SELECT part_number, description, supplier, cost FROM pm_plan_components WHERE plan_id = ? ORDER BY sort_order, id', [plan.id]);
const rows = steps.map((step, index) => ({ step: index + 1, title: step.title, details: step.details, est_minutes: step.est_minutes }));
const estTotal = steps.reduce((sum, step) => sum + (Number(step.est_minutes) || 0), 0);
const partsTotal = components.reduce((sum, comp) => sum + (Number(comp.cost) || 0), 0);
const partsLines = components.map((comp) => `${comp.part_number ? comp.part_number + ' ' : ''}${comp.description || ''}${comp.supplier ? ` (${comp.supplier})` : ''}${money(comp.cost)}`).join('; ');
const noteParts = [
`Frequency: ${freqLabel(plan.frequency_value, plan.frequency_unit)}`,
`Estimated time: ${estTotal} min`
];
if (plan.description) noteParts.push(`Description: ${plan.description}`);
if (plan.instructions) noteParts.push(`Instructions: ${plan.instructions}`);
if (components.length) noteParts.push(`Parts (${money(partsTotal)}): ${partsLines}`);
return {
title: `PM plan — ${plan.name}`,
columns: [col('step', '#', 'number'), col('title', 'Step'), col('details', 'Details'), col('est_minutes', 'Est. min', 'number')],
rows,
totals: { est_minutes: estTotal },
meta: { note: noteParts.join(' · ') }
};
}
function alertsSince(options) {
const since = options.since_date || dateStr(new Date(Date.now() - 30 * 86400000));
const rows = all(
`SELECT al.severity, al.type, al.title, al.due_date, al.status, al.created_at, a.asset_id AS asset_code
FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id
WHERE al.created_at >= ?
ORDER BY al.created_at DESC`,
[since]
).map((row) => ({ ...row, type: String(row.type).replace(/_/g, ' '), created_at: String(row.created_at).slice(0, 10) }));
return {
columns: [
col('severity', 'Severity'), col('type', 'Type'), col('title', 'Alert'), col('asset_code', 'Asset'),
col('due_date', 'Due', 'date'), col('status', 'Status'), col('created_at', 'Raised', 'date')
],
rows,
totals: {},
meta: { note: `${rows.length} alert(s) raised since ${since}.` }
};
}
// ---- Registry --------------------------------------------------------------
const REPORTS = {
depreciation_expense: { title: 'Annual depreciation expense', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => depreciationExpense(o) },
monthly_depreciation: { title: '12-month depreciation expense', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => monthlyDepreciation(o) },
quarterly_depreciation: { title: 'Quarterly depreciation expense', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => quarterlyDepreciation(o) },
ytd_depreciation: { title: 'Annual & YTD depreciation', group: 'Depreciation', params: ['book', 'year', 'month', 'entity_id'], build: (o) => ytdDepreciation(o) },
net_book_value: { title: 'Net book value', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => netBookValue(o) },
lifetime_depreciation: { title: 'Lifetime depreciation', group: 'Depreciation', params: ['book', 'entity_id'], build: (o) => lifetimeDepreciation(o) },
projection: { title: 'Projection', group: 'Depreciation', params: ['book', 'startYear', 'endYear', 'entity_id'], build: (o) => projection(o) },
rollforward: { title: 'Annual rollforward', group: 'Activity', params: ['book', 'year', 'entity_id'], build: (o) => rollforward(o) },
acquisitions: { title: 'Annual acquisitions', group: 'Activity', params: ['year', 'entity_id'], build: (o) => acquisitions(o) },
disposals: { title: 'Annual disposals & gain/loss', group: 'Activity', params: ['year', 'entity_id'], build: (o) => disposals(o) },
adjustments: { title: 'Annual adjustments', group: 'Activity', params: ['year', 'entity_id'], build: (o) => adjustments(o) },
cip: { title: 'Construction-in-progress', group: 'Activity', params: ['entity_id'], build: (o) => constructionInProgress(o) },
journal_depreciation: { title: 'Journal entry: depreciation', group: 'Journal entries', params: ['book', 'year', 'entity_id'], build: (o) => journalDepreciation(o) },
journal_disposals: { title: 'Journal entry: disposals', group: 'Journal entries', params: ['year', 'entity_id'], build: (o) => journalDisposals(o) },
form_4562: { title: 'Federal Form 4562 (depreciation)', group: 'Tax', params: ['year', 'entity_id'], build: (o) => form4562(o) },
form_4797: { title: 'Federal Form 4797 (sales)', group: 'Tax', params: ['year', 'entity_id'], build: (o) => form4797(o) },
amt: { title: 'AMT depreciation', group: 'Tax', params: ['year', 'entity_id'], build: (o) => depreciationExpense(o, 'AMT') },
ace: { title: 'ACE depreciation', group: 'Tax', params: ['year', 'entity_id'], build: (o) => depreciationExpense(o, 'ACE') },
personal_property_tax: { title: 'Personal property tax', group: 'Tax', params: ['book', 'year', 'entity_id'], build: (o) => personalPropertyTax(o) },
ubia: { title: 'UBIA (§199A)', group: 'Tax', params: ['entity_id'], build: (o) => ubia(o) },
pm_due: { title: 'PM due before date', group: 'Maintenance', params: ['before_date', 'entity_id'], build: (o) => pmDue(o) },
pm_uncompleted: { title: 'Assets with uncompleted PM', group: 'Maintenance', params: ['entity_id'], build: (o) => pmUncompleted(o) },
pm_completed: { title: 'PM completed since date', group: 'Maintenance', params: ['since_date', 'entity_id'], build: (o) => pmCompleted(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) },
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) }
};
function paramSpecs() {
const entities = all('SELECT id, name FROM entities ORDER BY name').map((row) => ({ title: row.name, value: row.id }));
const assets = all('SELECT id, asset_id, description FROM assets ORDER BY asset_id LIMIT 1000')
.map((row) => ({ title: `${row.asset_id} · ${row.description}`, value: row.id }));
const categories = all('SELECT DISTINCT category FROM assets ORDER BY category').map((row) => row.category).filter(Boolean);
const statuses = all('SELECT DISTINCT status FROM assets ORDER BY status').map((row) => row.status).filter(Boolean);
const year = currentYear();
const pmPlanOptions = all('SELECT id, name FROM pm_plans ORDER BY name').map((row) => ({ title: row.name, value: row.id }));
const today = new Date();
const bookCodes = (() => {
try {
const rows = all("SELECT code FROM books WHERE book_type != 'maintenance' ORDER BY sort_order, id");
if (rows.length) return rows.map((row) => row.code);
} catch {
// books table may be unavailable
}
return BOOKS;
})();
return {
book: { label: 'Book', type: 'select', options: bookCodes, default: bookCodes[0] || 'GAAP' },
year: { label: 'Year', type: 'number', default: year },
month: { label: 'Through month', type: 'select', options: Array.from({ length: 12 }, (_, i) => i + 1), default: 12 },
startYear: { label: 'Start year', type: 'number', default: year },
endYear: { label: 'End year', type: 'number', default: year + 5 },
entity_id: { label: 'Entity', type: 'select', options: entities, clearable: true, default: null },
category: { label: 'Category', type: 'select', options: categories, clearable: true, default: null },
status: { label: 'Status', type: 'select', options: statuses, clearable: true, default: null },
asset_id: { label: 'Asset', type: 'select', options: assets, default: null },
columns: { label: 'Columns', type: 'multiselect', options: BUILDER_FIELDS.map((f) => ({ title: f.label, value: f.key })), default: ['asset_id', 'description', 'category', 'status', 'acquisition_cost'] },
before_date: { label: 'Due before', type: 'date', default: dateStr(new Date(today.getTime() + 30 * 86400000)) },
since_date: { label: 'Since', type: 'date', default: dateStr(new Date(today.getTime() - 30 * 86400000)) },
pm_plan_id: { label: 'PM plan', type: 'select', options: pmPlanOptions, default: pmPlanOptions[0]?.value || null }
};
}
function catalog() {
const reports = Object.entries(REPORTS).map(([key, def]) => ({ key, title: def.title, group: def.group, params: def.params }));
return { reports, params: paramSpecs() };
}
function runReport(type, options = {}) {
const def = REPORTS[type];
if (!def) {
const error = new Error(`Unknown report type: ${type}`);
error.status = 400;
throw error;
}
const result = def.build(options);
return { type, title: def.title, group: def.group, generatedAt: now(), options, ...result };
}
module.exports = { BOOKS, catalog, computeYear, runReport };

View File

@@ -0,0 +1,123 @@
const PDFDocument = require('pdfkit');
const Papa = require('papaparse');
const writeXlsxFile = require('write-excel-file/node');
function formatValue(value, format) {
if (value === null || value === undefined || value === '') return '';
if (format === 'currency') return Number(value).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
if (format === 'number') return Number(value).toLocaleString('en-US');
return String(value);
}
function tableMatrix(report) {
const header = report.columns.map((column) => column.label);
const body = report.rows.map((row) => report.columns.map((column) => {
const value = row[column.key];
return column.format === 'currency' || column.format === 'number' ? Number(value || 0) : (value ?? '');
}));
return { header, body };
}
function totalsRow(report) {
if (!report.totals || !Object.keys(report.totals).length) return null;
return report.columns.map((column, index) => {
if (index === 0) return 'Totals';
if (Object.prototype.hasOwnProperty.call(report.totals, column.key)) return report.totals[column.key];
return '';
});
}
function exportCsv(report) {
const { header, body } = tableMatrix(report);
const totals = totalsRow(report);
const rows = totals ? [...body, totals] : body;
return {
contentType: 'text/csv',
body: Papa.unparse([header, ...rows])
};
}
async function exportXlsx(report) {
const { header } = tableMatrix(report);
const totals = totalsRow(report);
const data = [
header.map((label) => ({ value: label, fontWeight: 'bold' })),
...report.rows.map((row) => report.columns.map((column) => {
const value = row[column.key];
if (column.format === 'currency' || column.format === 'number') {
return { type: Number, value: Number(value || 0), format: column.format === 'currency' ? '#,##0.00' : '#,##0' };
}
return { value: value === null || value === undefined ? '' : String(value) };
}))
];
if (totals) {
data.push(totals.map((value, index) => {
if (index === 0) return { value: 'Totals', fontWeight: 'bold' };
return typeof value === 'number'
? { type: Number, value, format: '#,##0.00', fontWeight: 'bold' }
: { value: String(value || ''), fontWeight: 'bold' };
}));
}
return {
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
body: await writeXlsxFile(data).toBuffer()
};
}
function exportPdf(report) {
const doc = new PDFDocument({ margin: 36, size: 'LETTER', layout: 'landscape' });
const chunks = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.fontSize(16).text(report.title || 'MixedAssets report');
doc.fontSize(9).fillColor('#555').text(
`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(' · ')}` : '')
);
if (report.meta && report.meta.note) doc.moveDown(0.3).fontSize(8).fillColor('#777').text(report.meta.note);
doc.fillColor('#000').moveDown(0.6);
const pageWidth = doc.page.width - 72;
const columns = report.columns;
const colWidth = pageWidth / columns.length;
const drawRow = (cells, options = {}) => {
const y = doc.y;
doc.fontSize(options.bold ? 8.5 : 8);
cells.forEach((cell, index) => {
const column = columns[index];
const align = column.format === 'currency' || column.format === 'number' ? 'right' : 'left';
doc.text(String(cell ?? ''), 36 + index * colWidth, y, { width: colWidth - 6, align, ellipsis: true });
});
doc.moveDown(0.2);
if (doc.y > doc.page.height - 48) doc.addPage();
};
drawRow(columns.map((column) => column.label), { bold: true });
doc.moveTo(36, doc.y).lineTo(36 + pageWidth, doc.y).strokeColor('#ccc').stroke();
doc.moveDown(0.2);
for (const row of report.rows) {
drawRow(columns.map((column) => formatValue(row[column.key], column.format)));
}
const totals = totalsRow(report);
if (totals) {
doc.moveTo(36, doc.y).lineTo(36 + pageWidth, doc.y).strokeColor('#ccc').stroke();
doc.moveDown(0.2);
drawRow(totals.map((value, index) => (typeof value === 'number' ? formatValue(value, columns[index].format || 'currency') : value)), { bold: true });
}
doc.end();
return new Promise((resolve) => {
doc.on('end', () => resolve({ contentType: 'application/pdf', body: Buffer.concat(chunks) }));
});
}
async function exportReport(report, format) {
if (format === 'csv') return exportCsv(report);
if (format === 'xlsx') return exportXlsx(report);
if (format === 'pdf') return exportPdf(report);
return { contentType: 'application/json', body: JSON.stringify(report, null, 2) };
}
module.exports = { exportReport };

View File

@@ -1,15 +1,11 @@
const PDFDocument = require('pdfkit');
const { all, one, parseJson } = require('../db');
const { all, one } = require('../db');
const { annualSchedule, money, monthlyFromAnnual } = require('../depreciation');
const { assetExportRows, assetFromRow } = require('./assets');
function activeRuleSet() {
const row = one('SELECT * FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1');
return row ? parseJson(row.rules_json, {}) : {};
}
const { ruleSetForBook } = require('./ruleSets');
function depreciationReport(year, bookType) {
const rules = activeRuleSet();
const rules = ruleSetForBook(bookType);
const rows = all(`
SELECT a.*, b.book_type, b.active, b.cost, b.depreciation_method, b.convention, b.useful_life_months AS book_life,
b.section_179_amount, b.bonus_percent, b.business_use_percent AS book_business_use_percent,
@@ -59,7 +55,7 @@ function depreciationReport(year, bookType) {
}
function monthlyDepreciationReport(year, bookType) {
const rules = activeRuleSet();
const rules = ruleSetForBook(bookType);
const assets = all('SELECT * FROM assets ORDER BY asset_id').map(assetFromRow);
const rows = assets.flatMap((asset) => {
const book = one('SELECT * FROM asset_books WHERE asset_id = ? AND book_type = ? AND active = 1', [asset.id, bookType]);
@@ -100,7 +96,6 @@ function writeDepreciationPdf(res, year, book) {
}
module.exports = {
activeRuleSet,
depreciationReport,
monthlyDepreciationReport,
writeDepreciationPdf

102
server/services/roles.js Normal file
View File

@@ -0,0 +1,102 @@
const { all, audit, json, now, one, parseJson, run } = require('../db');
const { CAPABILITY_KEYS, WILDCARD, capabilitySetIncludes, sanitizeCapabilities } = require('./accessControl');
function slugify(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 40);
}
function fromRow(row) {
if (!row) return null;
return {
key: row.key,
name: row.name,
description: row.description || '',
capabilities: parseJson(row.capabilities_json, []),
is_system: Boolean(row.is_system),
locked: Boolean(row.locked),
user_count: one('SELECT COUNT(*) AS c FROM users WHERE role = ?', [row.key]).c
};
}
function listRoles() {
return all('SELECT * FROM roles ORDER BY sort_order, name').map(fromRow);
}
function getRole(key) {
return fromRow(one('SELECT * FROM roles WHERE key = ?', [key]));
}
function roleExists(key) {
return Boolean(one('SELECT key FROM roles WHERE key = ?', [key]));
}
// Capabilities granted to a role key (empty when the role is unknown).
function capabilitiesForRole(key) {
const row = one('SELECT capabilities_json FROM roles WHERE key = ?', [key]);
return row ? parseJson(row.capabilities_json, []) : [];
}
function roleHasCapability(key, capability) {
return capabilitySetIncludes(capabilitiesForRole(key), capability);
}
function createRole(body, userId) {
const name = String(body.name || '').trim();
if (!name) throw Object.assign(new Error('A role name is required'), { status: 400 });
const key = slugify(body.key || name);
if (!key) throw Object.assign(new Error('A valid role key is required'), { status: 400 });
if (roleExists(key)) throw Object.assign(new Error('A role with that key already exists'), { status: 400 });
const capabilities = sanitizeCapabilities(body.capabilities);
const ts = now();
const sort = (one('SELECT MAX(sort_order) AS m FROM roles').m || 0) + 1;
run(
'INSERT INTO roles (key, name, description, capabilities_json, is_system, locked, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?)',
[key, name, body.description || null, json(capabilities), sort, ts, ts]
);
audit(userId, 'create', 'role', key, null, { name, capabilities });
return getRole(key);
}
function updateRole(key, body, userId) {
const before = one('SELECT * FROM roles WHERE key = ?', [key]);
if (!before) return null;
const name = body.name !== undefined ? String(body.name).trim() || before.name : before.name;
// Locked roles (admin) always keep the wildcard capability set.
const capabilities = before.locked
? parseJson(before.capabilities_json, [WILDCARD])
: (body.capabilities !== undefined ? sanitizeCapabilities(body.capabilities) : parseJson(before.capabilities_json, []));
run(
'UPDATE roles SET name = ?, description = ?, capabilities_json = ?, updated_at = ? WHERE key = ?',
[name, body.description ?? before.description, json(capabilities), now(), key]
);
audit(userId, 'update', 'role', key, fromRow(before), { name, capabilities });
return getRole(key);
}
function deleteRole(key, userId) {
const before = one('SELECT * FROM roles WHERE key = ?', [key]);
if (!before) return { ok: false, status: 404, error: 'Role not found' };
if (before.is_system) return { ok: false, status: 400, error: 'Built-in roles cannot be deleted' };
const inUse = one('SELECT COUNT(*) AS c FROM users WHERE role = ?', [key]).c;
if (inUse) return { ok: false, status: 400, error: `Reassign the ${inUse} user(s) with this role before deleting it` };
run('DELETE FROM roles WHERE key = ?', [key]);
audit(userId, 'delete', 'role', key, fromRow(before), null);
return { ok: true };
}
module.exports = {
CAPABILITY_KEYS,
capabilitiesForRole,
createRole,
deleteRole,
getRole,
listRoles,
roleExists,
roleHasCapability,
updateRole
};

View File

@@ -0,0 +1,25 @@
const { one, parseJson } = require('../db');
function activeRuleSet() {
const row = one('SELECT rules_json FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1');
return row ? parseJson(row.rules_json, {}) : {};
}
// Resolve the depreciation rule set a book should use: its explicitly assigned
// rule set when present, otherwise the globally active set.
function ruleSetForBook(bookCode) {
if (bookCode) {
try {
const book = one('SELECT tax_rule_set_id FROM books WHERE code = ?', [bookCode]);
if (book && book.tax_rule_set_id) {
const row = one('SELECT rules_json FROM tax_rule_sets WHERE id = ?', [book.tax_rule_set_id]);
if (row) return parseJson(row.rules_json, {});
}
} catch {
// books table may not exist on a very old database; fall through to active set
}
}
return activeRuleSet();
}
module.exports = { activeRuleSet, ruleSetForBook };

View File

@@ -0,0 +1,337 @@
const { all, audit, now, one, run } = require('../db');
const { createAsset, updateAsset } = require('./assets');
const TIMEOUT_MS = 20000;
// Default CMDB CI field -> MixedAssets asset field mapping. Values are ServiceNow
// column names on the configured CI class; requested with display values so that
// reference fields (manufacturer, location, model) arrive as readable strings.
const DEFAULT_CMDB_MAP = {
asset_id: 'asset_tag',
description: 'name',
serial_number: 'serial_number',
category: 'sys_class_name',
vendor: 'manufacturer',
location: 'location',
department: 'department',
custodian: 'assigned_to',
acquisition_cost: 'cost',
acquired_date: 'purchase_date',
notes: 'short_description'
};
function readSettings() {
const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'servicenow_%'");
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
}
function parseMap(value) {
if (!value) return { ...DEFAULT_CMDB_MAP };
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
return parsed && typeof parsed === 'object' && Object.keys(parsed).length ? parsed : { ...DEFAULT_CMDB_MAP };
} catch {
return { ...DEFAULT_CMDB_MAP };
}
}
// Raw config (includes password) — for making API calls only.
function getConfig() {
const s = readSettings();
return {
instanceUrl: (s.servicenow_instance_url || '').replace(/\/+$/, ''),
username: s.servicenow_username || '',
password: s.servicenow_password || '',
incidentTable: s.servicenow_incident_table || 'incident',
assignmentGroup: s.servicenow_assignment_group || '',
callerId: s.servicenow_caller_id || '',
ticketEnabled: s.servicenow_ticket_enabled === 'true',
autoTicket: s.servicenow_auto_ticket === 'true',
cmdbTable: s.servicenow_cmdb_table || 'cmdb_ci_hardware',
cmdbQuery: s.servicenow_cmdb_query || '',
cmdbLimit: Number(s.servicenow_cmdb_limit || 200),
cmdbMap: parseMap(s.servicenow_cmdb_map),
lastSyncAt: s.servicenow_last_sync_at || '',
lastSyncStatus: s.servicenow_last_sync_status || ''
};
}
// Safe config for the API/UI — password is never returned, only whether it is set.
function publicConfig() {
const c = getConfig();
return {
servicenow_instance_url: c.instanceUrl,
servicenow_username: c.username,
servicenow_password_set: Boolean(c.password),
servicenow_incident_table: c.incidentTable,
servicenow_assignment_group: c.assignmentGroup,
servicenow_caller_id: c.callerId,
servicenow_ticket_enabled: c.ticketEnabled,
servicenow_auto_ticket: c.autoTicket,
servicenow_cmdb_table: c.cmdbTable,
servicenow_cmdb_query: c.cmdbQuery,
servicenow_cmdb_limit: c.cmdbLimit,
servicenow_cmdb_map: c.cmdbMap,
servicenow_last_sync_at: c.lastSyncAt,
servicenow_last_sync_status: c.lastSyncStatus,
configured: Boolean(c.instanceUrl && c.username)
};
}
function setValue(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',
[key, String(value == null ? '' : value), now()]
);
}
function saveConfig(body, userId) {
if (body.servicenow_instance_url !== undefined) setValue('servicenow_instance_url', (body.servicenow_instance_url || '').replace(/\/+$/, ''));
if (body.servicenow_username !== undefined) setValue('servicenow_username', body.servicenow_username || '');
if (body.servicenow_password_clear) setValue('servicenow_password', '');
else if (body.servicenow_password) setValue('servicenow_password', body.servicenow_password);
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_caller_id !== undefined) setValue('servicenow_caller_id', body.servicenow_caller_id || '');
if (body.servicenow_ticket_enabled !== undefined) setValue('servicenow_ticket_enabled', body.servicenow_ticket_enabled ? 'true' : 'false');
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_cmdb_query !== undefined) setValue('servicenow_cmdb_query', body.servicenow_cmdb_query || '');
if (body.servicenow_cmdb_limit !== undefined) setValue('servicenow_cmdb_limit', Number(body.servicenow_cmdb_limit) || 200);
if (body.servicenow_cmdb_map !== undefined) {
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 : '');
}
audit(userId, 'update', 'servicenow_settings', null, null, { ...body, servicenow_password: body.servicenow_password ? '[set]' : undefined });
return publicConfig();
}
function ensureConfigured(c) {
if (!c.instanceUrl || !c.username) {
throw Object.assign(new Error('ServiceNow instance URL and username are required'), { status: 400 });
}
}
function authHeader(c) {
return `Basic ${Buffer.from(`${c.username}:${c.password}`).toString('base64')}`;
}
async function snFetch(c, pathAndQuery, options = {}) {
const url = `${c.instanceUrl}${pathAndQuery}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let response;
try {
response = await fetch(url, {
...options,
headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: authHeader(c), ...(options.headers || {}) },
signal: controller.signal
});
} catch (error) {
throw Object.assign(new Error(`ServiceNow request failed: ${error.message}`), { status: 502 });
} finally {
clearTimeout(timer);
}
const text = await response.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
if (!response.ok) {
const detail = body?.error?.message || body?.error?.detail || `HTTP ${response.status}`;
throw Object.assign(new Error(`ServiceNow: ${detail}`), { status: response.status === 401 ? 400 : 502 });
}
return body;
}
async function testConnection(userId) {
const c = getConfig();
ensureConfigured(c);
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 });
return { ok: true, instance: c.instanceUrl };
}
// MixedAssets severity -> ServiceNow impact/urgency (1 high … 3 low).
function severityToImpactUrgency(severity) {
if (severity === 'critical') return { impact: '1', urgency: '1' };
if (severity === 'warning') return { impact: '2', urgency: '2' };
return { impact: '3', urgency: '3' };
}
function incidentUrl(c, sysId) {
return `${c.instanceUrl}/nav_to.do?uri=${encodeURIComponent(`/${c.incidentTable}.do?sys_id=${sysId}`)}`;
}
async function createIncidentForAlert(alert, userId, c = getConfig()) {
ensureConfigured(c);
const { impact, urgency } = severityToImpactUrgency(alert.severity);
const descriptionLines = [
alert.message || '',
'',
`MixedAssets alert #${alert.id} (${alert.type})`,
alert.asset_code ? `Asset: ${alert.asset_code}` : null,
alert.due_date ? `Due: ${alert.due_date}` : null
].filter((line) => line !== null);
const payload = {
short_description: alert.title,
description: descriptionLines.join('\n'),
impact,
urgency
};
if (c.assignmentGroup) payload.assignment_group = c.assignmentGroup;
if (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 result = data?.result || {};
const url = result.sys_id ? incidentUrl(c, result.sys_id) : null;
run(
'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]
);
audit(userId, 'ticket', 'alert', alert.id, null, { system: 'servicenow', number: result.number });
return { sys_id: result.sys_id, number: result.number, url };
}
function alertWithCode(alertId) {
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 = ?`,
[alertId]
);
}
// Manually submit a ServiceNow incident for one alert (returns existing ticket if already raised).
async function submitTicket(alertId, userId) {
const c = getConfig();
ensureConfigured(c);
const alert = alertWithCode(alertId);
if (!alert) return null;
if (alert.external_ticket_id) {
return { sys_id: alert.external_ticket_id, number: alert.external_ticket_number, url: alert.external_ticket_url, duplicate: true };
}
return createIncidentForAlert(alert, userId, c);
}
// Auto-create incidents for new critical alerts during the alert cycle (best-effort).
async function autoTicketAlerts(alerts, userId) {
const c = getConfig();
if (!c.ticketEnabled || !c.autoTicket || !c.instanceUrl) return { created: 0 };
let created = 0;
for (const alert of alerts) {
if (alert.severity !== 'critical' || alert.external_ticket_id) continue;
try {
await createIncidentForAlert(alert, userId, c);
created += 1;
} catch {
// best-effort: a failed ticket never breaks the alert cycle
}
}
return { created };
}
// ---- CMDB pull -------------------------------------------------------------
function parseCost(value) {
if (value == null || value === '') return undefined;
const num = Number(String(value).replace(/[^0-9.\-]/g, ''));
return Number.isFinite(num) ? num : undefined;
}
function parseDate(value) {
if (!value) return undefined;
const match = String(value).match(/\d{4}-\d{2}-\d{2}/);
return match ? match[0] : undefined;
}
function mapCi(ci, map) {
const asset = {};
for (const [assetField, ciField] of Object.entries(map)) {
if (!ciField) continue;
let value = ci[ciField];
if (value == null || value === '') continue;
value = String(value).trim();
if (!value) continue;
if (assetField === 'acquisition_cost') {
const cost = parseCost(value);
if (cost !== undefined) asset.acquisition_cost = cost;
} else if (assetField === 'acquired_date' || assetField === 'in_service_date' || assetField === 'disposal_date') {
const date = parseDate(value);
if (date) asset[assetField] = date;
} else {
asset[assetField] = value;
}
}
return asset;
}
function findExistingAsset(sysId, mapped) {
if (sysId) {
const bySysId = one('SELECT id FROM assets WHERE servicenow_sys_id = ?', [sysId]);
if (bySysId) return bySysId.id;
}
if (mapped.serial_number) {
const bySerial = one('SELECT id FROM assets WHERE serial_number = ? COLLATE NOCASE', [mapped.serial_number]);
if (bySerial) return bySerial.id;
}
if (mapped.asset_id) {
const byCode = one('SELECT id FROM assets WHERE asset_id = ? COLLATE NOCASE', [mapped.asset_id]);
if (byCode) return byCode.id;
}
return null;
}
async function syncCmdb(userId) {
const c = getConfig();
ensureConfigured(c);
const fields = [...new Set(['sys_id', ...Object.values(c.cmdbMap).filter(Boolean)])];
const params = new URLSearchParams({
sysparm_limit: String(c.cmdbLimit || 200),
sysparm_display_value: 'true',
sysparm_exclude_reference_link: 'true',
sysparm_fields: fields.join(',')
});
if (c.cmdbQuery) params.set('sysparm_query', c.cmdbQuery);
let data;
try {
data = await snFetch(c, `/api/now/table/${encodeURIComponent(c.cmdbTable)}?${params.toString()}`, { method: 'GET' });
} catch (error) {
setValue('servicenow_last_sync_at', now());
setValue('servicenow_last_sync_status', `Failed: ${error.message}`);
throw error;
}
const cis = data?.result || [];
let created = 0;
let updated = 0;
let skipped = 0;
for (const ci of cis) {
const sysId = ci.sys_id || null;
const mapped = mapCi(ci, c.cmdbMap);
if (!mapped.description && !mapped.asset_id && !mapped.serial_number) { skipped += 1; continue; }
const existingId = findExistingAsset(sysId, mapped);
if (existingId) {
updateAsset(existingId, mapped, userId);
if (sysId) run('UPDATE assets SET servicenow_sys_id = ? WHERE id = ?', [sysId, existingId]);
updated += 1;
} else {
const asset = createAsset({ ...mapped, source: 'servicenow' }, userId);
if (sysId) run('UPDATE assets SET servicenow_sys_id = ? WHERE id = ?', [sysId, asset.id]);
created += 1;
}
}
const status = `Synced ${cis.length} CI(s): ${created} created, ${updated} updated${skipped ? `, ${skipped} skipped` : ''}.`;
setValue('servicenow_last_sync_at', now());
setValue('servicenow_last_sync_status', status);
audit(userId, 'cmdb_sync', 'servicenow', null, null, { table: c.cmdbTable, created, updated, skipped });
return { created, updated, skipped, total: cis.length, status };
}
module.exports = {
DEFAULT_CMDB_MAP,
autoTicketAlerts,
getConfig,
publicConfig,
saveConfig,
submitTicket,
syncCmdb,
testConnection
};

View File

@@ -0,0 +1,88 @@
const crypto = require('crypto');
const { audit } = require('../db');
const { getConfig } = require('./notifications');
const TIMEOUT_MS = 10000;
// The JSON object delivered for each alert. Stable, flat shape for easy consumption.
function buildPayload(alert) {
return {
event: 'alert',
id: alert.id,
type: alert.type,
severity: alert.severity,
title: alert.title,
message: alert.message || null,
due_date: alert.due_date || null,
status: alert.status,
reference_type: alert.reference_type,
reference_id: alert.reference_id,
asset_id: alert.asset_id || null,
asset_code: alert.asset_code || null,
created_at: alert.created_at,
sent_at: new Date().toISOString()
};
}
// 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.
async function postJson(url, secret, payload) {
const body = JSON.stringify(payload);
const headers = { 'Content-Type': 'application/json', 'User-Agent': 'MixedAssets-Webhook/1' };
if (secret) {
const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
headers['X-MixedAssets-Signature'] = `sha256=${signature}`;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
return { ok: response.ok, status: response.status };
} finally {
clearTimeout(timer);
}
}
// 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.
async function deliverAlerts(alerts) {
const c = getConfig();
if (!c.webhookEnabled || !c.webhookUrl || !alerts.length) {
return { attempted: false, delivered: 0, failed: 0 };
}
let delivered = 0;
let failed = 0;
for (const alert of alerts) {
try {
const result = await postJson(c.webhookUrl, c.webhookSecret, buildPayload(alert));
if (result.ok) delivered += 1;
else failed += 1;
} catch {
failed += 1;
}
}
return { attempted: true, delivered, failed };
}
async function sendTestWebhook(userId) {
const c = getConfig();
if (!c.webhookUrl) throw Object.assign(new Error('A webhook URL is not configured'), { status: 400 });
const payload = {
event: 'test',
severity: 'info',
title: 'MixedAssets webhook test',
message: 'If you received this, your webhook destination is configured correctly.',
sent_at: new Date().toISOString()
};
let result;
try {
result = await postJson(c.webhookUrl, c.webhookSecret, payload);
} catch (error) {
throw Object.assign(new Error(`Webhook request failed: ${error.message}`), { status: 400 });
}
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 });
return { ok: true, status: result.status };
}
module.exports = { buildPayload, deliverAlerts, sendTestWebhook };

View File

@@ -1,5 +1,6 @@
const { audit, json, now, one, run } = require('../db');
const { upsertEmployee } = require('./assignments');
const { upsertWorkdayContact } = require('./contacts');
function publicConnection(row) {
if (!row) return null;
@@ -12,12 +13,21 @@ function publicConnection(row) {
client_id: row.client_id,
workers_path: row.workers_path,
enabled: Boolean(row.enabled),
sync_enabled: Boolean(row.sync_enabled),
sync_interval_hours: row.sync_interval_hours || 24,
last_sync_at: row.last_sync_at,
last_sync_status: row.last_sync_status,
configured: Boolean(row.base_url && row.token_url && row.client_id && row.client_secret)
};
}
function splitName(full) {
const parts = String(full || '').trim().split(/\s+/).filter(Boolean);
if (!parts.length) return { first: null, last: null };
if (parts.length === 1) return { first: parts[0], last: null };
return { first: parts[0], last: parts.slice(1).join(' ') };
}
function getConnection() {
return publicConnection(one('SELECT * FROM workday_connections ORDER BY id LIMIT 1'));
}
@@ -37,23 +47,25 @@ function saveConnection(body, userId) {
client_id: body.client_id ?? existing?.client_id ?? '',
client_secret: body.client_secret ? body.client_secret : existing?.client_secret ?? '',
workers_path: body.workers_path ?? existing?.workers_path ?? '/workers',
enabled: body.enabled === undefined ? Number(existing?.enabled || 0) : (body.enabled ? 1 : 0)
enabled: body.enabled === undefined ? Number(existing?.enabled || 0) : (body.enabled ? 1 : 0),
sync_enabled: body.sync_enabled === undefined ? Number(existing?.sync_enabled || 0) : (body.sync_enabled ? 1 : 0),
sync_interval_hours: body.sync_interval_hours === undefined ? (existing?.sync_interval_hours || 24) : Math.max(1, Number(body.sync_interval_hours) || 24)
};
if (existing) {
run(
`UPDATE workday_connections SET
name = ?, tenant = ?, base_url = ?, token_url = ?, client_id = ?, client_secret = ?,
workers_path = ?, enabled = ?, updated_at = ?
workers_path = ?, enabled = ?, sync_enabled = ?, sync_interval_hours = ?, updated_at = ?
WHERE id = ?`,
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, 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 {
run(
`INSERT INTO workday_connections (
name, tenant, base_url, token_url, client_id, client_secret, workers_path, enabled, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, timestamp, timestamp]
name, tenant, base_url, token_url, client_id, client_secret, workers_path, enabled, sync_enabled, sync_interval_hours, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[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, timestamp]
);
}
@@ -85,19 +97,31 @@ function normalizeWorkers(payload) {
? payload
: payload.workers || payload.Workers || payload.data || payload.value || [];
return rows.map((worker) => ({
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,
name: worker.name || worker.fullName || worker.Full_Name || worker.descriptor || [worker.firstName, worker.lastName].filter(Boolean).join(' '),
email: worker.email || worker.workEmail || worker.primaryWorkEmail || worker.Email,
department: worker.department || worker.organization || worker.Department || worker.supervisoryOrganization?.descriptor,
location: worker.location || worker.workLocation || worker.Location || worker.location?.descriptor,
manager_name: worker.managerName || worker.manager?.descriptor,
status: worker.status || (worker.active === false ? 'inactive' : 'active'),
source: 'workday',
source_payload: worker,
last_synced_at: now()
}));
return rows.map((worker) => {
const name = worker.name || worker.fullName || worker.Full_Name || worker.descriptor || [worker.firstName, worker.lastName].filter(Boolean).join(' ');
const fallbackName = splitName(name);
const managerName = worker.managerName || worker.manager?.descriptor;
const managerSplit = splitName(managerName);
return {
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,
name,
first_name: worker.firstName || worker.legalFirstName || worker.First_Name || fallbackName.first,
last_name: worker.lastName || worker.legalLastName || worker.Last_Name || fallbackName.last,
email: worker.email || worker.workEmail || worker.primaryWorkEmail || worker.Email,
department: worker.department || worker.organization || worker.Department || worker.supervisoryOrganization?.descriptor,
location: worker.location || worker.workLocation || worker.Location || worker.location?.descriptor,
manager_name: managerName,
manager_first_name: worker.manager?.firstName || managerSplit.first,
manager_last_name: worker.manager?.lastName || managerSplit.last,
manager_email: worker.managerEmail || worker.manager?.email,
start_date: worker.hireDate || worker.startDate || worker.Hire_Date || worker.continuousServiceDate,
status: worker.status || (worker.active === false ? 'inactive' : 'active'),
source: 'workday',
source_payload: worker,
last_synced_at: now()
};
});
}
async function syncWorkers(userId) {
@@ -120,7 +144,10 @@ async function syncWorkers(userId) {
}
const workers = normalizeWorkers(await response.json());
for (const worker of workers) upsertEmployee(worker);
for (const worker of workers) {
upsertEmployee(worker);
upsertWorkdayContact(worker);
}
const timestamp = now();
run('UPDATE workday_connections SET last_sync_at = ?, last_sync_status = ?, updated_at = ? WHERE id = ?', [
@@ -135,14 +162,30 @@ async function syncWorkers(userId) {
function importWorkersFromPayload(workers, userId) {
const normalized = normalizeWorkers(workers);
for (const worker of normalized) upsertEmployee(worker);
for (const worker of normalized) {
upsertEmployee(worker);
upsertWorkdayContact(worker);
}
audit(userId, 'import', 'workday_workers', null, null, { imported: normalized.length, source: 'payload' });
return { imported: normalized.length };
}
// Run an automatic sync if it's enabled, configured, and the interval has elapsed.
async function runScheduledSync() {
const connection = getPrivateConnection();
if (!connection || !connection.sync_enabled || !connection.enabled) return { skipped: true };
if (!connection.base_url || !connection.token_url || !connection.client_id || !connection.client_secret) return { skipped: true };
const intervalMs = (Number(connection.sync_interval_hours) || 24) * 3600000;
if (connection.last_sync_at && Date.now() - new Date(connection.last_sync_at).getTime() < intervalMs) {
return { skipped: true };
}
return syncWorkers(null);
}
module.exports = {
getConnection,
importWorkersFromPayload,
runScheduledSync,
saveConnection,
syncWorkers
};

114
server/services/zones.js Normal file
View File

@@ -0,0 +1,114 @@
const { all, audit, now, one, run } = require('../db');
let cache = null;
function clearCache() {
cache = null;
}
function lookup() {
if (!cache) {
cache = {};
for (const row of all('SELECT * FROM depreciation_zones')) cache[row.code] = row;
}
return cache;
}
// Raw row (incl. dates) for the engine; null when unknown or disabled.
function getZone(code) {
if (!code) return null;
const zone = lookup()[code];
return zone && zone.enabled ? zone : null;
}
function slugify(value) {
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
}
function fromRow(row) {
if (!row) return null;
return {
id: row.id,
code: row.code,
name: row.name,
allowance_percent: row.allowance_percent,
pis_start: row.pis_start,
pis_end: row.pis_end,
real_property_end: row.real_property_end,
max_recovery_years: row.max_recovery_years,
leasehold_life_years: row.leasehold_life_years,
notes: row.notes,
enabled: Boolean(row.enabled),
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE special_zone = ?', [row.code]).c
};
}
function list() {
return all('SELECT * FROM depreciation_zones ORDER BY name').map(fromRow);
}
function normalize(body) {
const num = (v) => (v === '' || v === null || v === undefined ? null : Number(v));
return {
name: String(body.name || '').trim(),
allowance_percent: Number(body.allowance_percent) || 0,
pis_start: body.pis_start || null,
pis_end: body.pis_end || null,
real_property_end: body.real_property_end || null,
max_recovery_years: num(body.max_recovery_years),
leasehold_life_years: num(body.leasehold_life_years),
notes: body.notes || null,
enabled: body.enabled === false ? 0 : 1
};
}
function createZone(body, userId) {
const input = normalize(body);
if (!input.name) throw Object.assign(new Error('A zone name is required'), { status: 400 });
const code = slugify(body.code || input.name);
if (!code) throw Object.assign(new Error('A valid zone code is required'), { status: 400 });
if (one('SELECT id FROM depreciation_zones WHERE code = ?', [code])) {
throw Object.assign(new Error('A zone with that code already exists'), { status: 400 });
}
const ts = now();
run(
`INSERT INTO depreciation_zones (code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years, notes, enabled, created_at, updated_at)
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.notes, input.enabled, ts, ts]
);
clearCache();
audit(userId, 'create', 'depreciation_zone', code, null, input);
return fromRow(one('SELECT * FROM depreciation_zones WHERE code = ?', [code]));
}
function updateZone(code, body, userId) {
const before = one('SELECT * FROM depreciation_zones WHERE code = ?', [code]);
if (!before) return null;
const input = normalize({ ...before, ...body, enabled: body.enabled === undefined ? Boolean(before.enabled) : body.enabled });
run(
`UPDATE depreciation_zones SET name = ?, allowance_percent = ?, pis_start = ?, pis_end = ?, real_property_end = ?,
max_recovery_years = ?, leasehold_life_years = ?, 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.notes, input.enabled, now(), code]
);
clearCache();
audit(userId, 'update', 'depreciation_zone', code, before, input);
return fromRow(one('SELECT * FROM depreciation_zones WHERE code = ?', [code]));
}
function deleteZone(code, userId) {
const before = one('SELECT * FROM depreciation_zones WHERE code = ?', [code]);
if (!before) return false;
run('DELETE FROM depreciation_zones WHERE code = ?', [code]);
clearCache();
audit(userId, 'delete', 'depreciation_zone', code, before, null);
return true;
}
module.exports = {
clearCache,
createZone,
deleteZone,
getZone,
list,
updateZone
};