Added Multi-Company Support
This commit is contained in:
@@ -78,20 +78,22 @@ function scanAlerts(userId) {
|
||||
|
||||
for (const c of cands) {
|
||||
seen.add(`${c.reference_type}:${c.reference_id}:${c.type}`);
|
||||
// Every candidate derives from an asset; the alert inherits that asset's company for filtering.
|
||||
const entityId = c.asset_id ? (one('SELECT entity_id FROM assets WHERE id = ?', [c.asset_id])?.entity_id ?? null) : null;
|
||||
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 = ?,
|
||||
`UPDATE alerts SET asset_id = ?, entity_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]
|
||||
[c.asset_id, entityId, 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]
|
||||
`INSERT INTO alerts (type, reference_type, reference_id, asset_id, entity_id, severity, title, message, due_date, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
|
||||
[c.type, c.reference_type, c.reference_id, c.asset_id, entityId, c.severity, c.title, c.message, c.due_date, ts, ts]
|
||||
);
|
||||
created.push(one('SELECT * FROM alerts WHERE id = ?', [result.lastInsertRowid]));
|
||||
}
|
||||
@@ -113,14 +115,17 @@ function listAlerts(query = {}) {
|
||||
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 = ?)
|
||||
WHERE (? IS NULL OR al.status = ?) AND (? IS NULL OR al.type = ?) AND (? IS NULL OR al.entity_id = ?)
|
||||
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]
|
||||
[query.status || null, query.status || null, query.type || null, query.type || null, query.entity_id || null, query.entity_id || null]
|
||||
);
|
||||
}
|
||||
|
||||
function summary() {
|
||||
const rows = all("SELECT status, severity, COUNT(*) AS count FROM alerts GROUP BY status, severity");
|
||||
function summary(companyId) {
|
||||
const rows = all(
|
||||
"SELECT status, severity, COUNT(*) AS count FROM alerts WHERE (? IS NULL OR entity_id = ?) GROUP BY status, severity",
|
||||
[companyId || null, companyId || null]
|
||||
);
|
||||
const open = rows.filter((r) => r.status === 'open');
|
||||
return {
|
||||
open: open.reduce((s, r) => s + r.count, 0),
|
||||
|
||||
@@ -44,9 +44,12 @@ const assetColumns = [
|
||||
const defaultBooks = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
|
||||
|
||||
// Depreciation books currently enabled in the registry (excludes the maintenance/PM book).
|
||||
function enabledBookCodes() {
|
||||
function enabledBookCodes(companyId) {
|
||||
try {
|
||||
const rows = all("SELECT code FROM books WHERE enabled = 1 AND book_type != 'maintenance' ORDER BY sort_order, id");
|
||||
const rows = all(
|
||||
"SELECT code FROM books WHERE enabled = 1 AND book_type != 'maintenance' AND (? IS NULL OR entity_id = ?) ORDER BY sort_order, id",
|
||||
[companyId || null, companyId || null]
|
||||
);
|
||||
if (rows.length) return rows.map((row) => row.code);
|
||||
} catch {
|
||||
// books table may not exist on a very old database
|
||||
@@ -121,17 +124,18 @@ function hydrateAsset(id) {
|
||||
return asset;
|
||||
}
|
||||
|
||||
function lookupAssetByCode(code) {
|
||||
function lookupAssetByCode(code, companyId) {
|
||||
const value = String(code || '').trim();
|
||||
if (!value) return null;
|
||||
const row = one(
|
||||
`SELECT id FROM assets
|
||||
WHERE barcode_value = ? COLLATE NOCASE
|
||||
WHERE (? IS NULL OR entity_id = ?)
|
||||
AND (barcode_value = ? COLLATE NOCASE
|
||||
OR asset_id = ? COLLATE NOCASE
|
||||
OR serial_number = ? COLLATE NOCASE
|
||||
OR serial_number = ? COLLATE NOCASE)
|
||||
ORDER BY (barcode_value = ? COLLATE NOCASE) DESC, id
|
||||
LIMIT 1`,
|
||||
[value, value, value, value]
|
||||
[companyId || null, companyId || null, value, value, value, value]
|
||||
);
|
||||
return row ? hydrateAsset(row.id) : null;
|
||||
}
|
||||
@@ -212,8 +216,8 @@ function normalizeAssetInput(body) {
|
||||
return input;
|
||||
}
|
||||
|
||||
function createBooks(assetId, asset, books = []) {
|
||||
const selectedBooks = books.length ? books : enabledBookCodes().map((book) => ({ book_type: book }));
|
||||
function createBooks(assetId, asset, books = [], companyId) {
|
||||
const selectedBooks = books.length ? books : enabledBookCodes(companyId).map((book) => ({ book_type: book }));
|
||||
for (const book of selectedBooks) {
|
||||
run(
|
||||
`INSERT OR REPLACE INTO asset_books (
|
||||
@@ -262,9 +266,10 @@ function listAssets(query = {}) {
|
||||
]).map(assetFromRow);
|
||||
}
|
||||
|
||||
function createAsset(body, userId) {
|
||||
function createAsset(body, userId, companyId) {
|
||||
const created = now();
|
||||
const input = normalizeAssetInput(body);
|
||||
if (companyId) input.entity_id = companyId; // new assets belong to the active company
|
||||
const result = tx(() => {
|
||||
input.asset_id = resolveNewAssetId(input); // category ID template, else default sequence
|
||||
const insert = run(
|
||||
@@ -272,7 +277,7 @@ function createAsset(body, userId) {
|
||||
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||
[...assetColumns.map((column) => input[column] ?? null), userId, created, created]
|
||||
);
|
||||
createBooks(insert.lastInsertRowid, input, body.books || []);
|
||||
createBooks(insert.lastInsertRowid, input, body.books || [], input.entity_id);
|
||||
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), insert.lastInsertRowid]);
|
||||
return insert;
|
||||
});
|
||||
@@ -281,11 +286,13 @@ function createAsset(body, userId) {
|
||||
return asset;
|
||||
}
|
||||
|
||||
function updateAsset(id, body, userId) {
|
||||
function updateAsset(id, body, userId, companyId) {
|
||||
const before = hydrateAsset(id);
|
||||
if (!before) return null;
|
||||
if (companyId && before.entity_id !== companyId) return null; // belongs to another company
|
||||
|
||||
const input = normalizeAssetInput({ ...before, ...body });
|
||||
if (companyId) input.entity_id = companyId; // assets stay within their company in phase 1
|
||||
// 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).
|
||||
@@ -296,7 +303,7 @@ function updateAsset(id, body, userId) {
|
||||
`UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
|
||||
[...assetColumns.map((column) => input[column] ?? null), updated, id]
|
||||
);
|
||||
if (Array.isArray(body.books)) createBooks(id, input, body.books);
|
||||
if (Array.isArray(body.books)) createBooks(id, input, body.books, input.entity_id);
|
||||
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), id]);
|
||||
});
|
||||
|
||||
@@ -305,17 +312,23 @@ function updateAsset(id, body, userId) {
|
||||
return asset;
|
||||
}
|
||||
|
||||
function deleteAsset(id, userId) {
|
||||
function deleteAsset(id, userId, companyId) {
|
||||
const before = hydrateAsset(id);
|
||||
if (!before) return false;
|
||||
if (companyId && before.entity_id !== companyId) return false; // belongs to another company
|
||||
run('DELETE FROM assets WHERE id = ?', [id]);
|
||||
audit(userId, 'delete', 'asset', id, before, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function massUpdateAssets(ids, changes, userId) {
|
||||
function massUpdateAssets(ids, changes, userId, companyId) {
|
||||
const allowed = ['status', 'location', 'department', 'group_name', 'in_service_date', 'useful_life_months', 'condition', 'disposal_date'];
|
||||
const columns = allowed.filter((column) => Object.prototype.hasOwnProperty.call(changes, column));
|
||||
// Restrict to assets in the active company so a forged id list can't touch another company's data.
|
||||
if (companyId && ids.length) {
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
ids = all(`SELECT id FROM assets WHERE entity_id = ? AND id IN (${placeholders})`, [companyId, ...ids]).map((r) => r.id);
|
||||
}
|
||||
if (!ids.length || !columns.length) return 0;
|
||||
|
||||
tx(() => {
|
||||
@@ -330,26 +343,29 @@ function massUpdateAssets(ids, changes, userId) {
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
function assetExportRows() {
|
||||
function assetExportRows(companyId) {
|
||||
return 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 = ?)
|
||||
ORDER BY a.asset_id
|
||||
`).map((asset) => ({
|
||||
`, [companyId || null, companyId || null]).map((asset) => ({
|
||||
...asset,
|
||||
listed_property: Boolean(asset.listed_property),
|
||||
custom_fields: parseJson(asset.custom_fields, {})
|
||||
}));
|
||||
}
|
||||
|
||||
function upsertImportedAssets(rows, userId) {
|
||||
function upsertImportedAssets(rows, userId, companyId) {
|
||||
let imported = 0;
|
||||
tx(() => {
|
||||
for (const row of rows) {
|
||||
const input = normalizeAssetInput(row);
|
||||
if (companyId) input.entity_id = companyId; // imports land in the active company
|
||||
if (!input.asset_id) input.asset_id = resolveNewAssetId(input);
|
||||
const existing = one('SELECT id FROM assets WHERE asset_id = ?', [input.asset_id]);
|
||||
// Match within the active company so imports don't collide with another company's asset ids.
|
||||
const existing = one('SELECT id FROM assets WHERE asset_id = ? AND (? IS NULL OR entity_id = ?)', [input.asset_id, companyId || null, companyId || null]);
|
||||
const classNumber = assetClassNumberForCategory(input.category);
|
||||
if (existing) {
|
||||
run(
|
||||
@@ -362,7 +378,7 @@ function upsertImportedAssets(rows, userId) {
|
||||
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||
[...assetColumns.map((column) => input[column] ?? null), userId, now(), now()]
|
||||
);
|
||||
createBooks(result.lastInsertRowid, input, []);
|
||||
createBooks(result.lastInsertRowid, input, [], input.entity_id);
|
||||
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [classNumber, result.lastInsertRowid]);
|
||||
}
|
||||
imported += 1;
|
||||
|
||||
@@ -87,19 +87,21 @@ function upsertEmployee(employee) {
|
||||
return one('SELECT * FROM employees WHERE id = ?', [result.lastInsertRowid]);
|
||||
}
|
||||
|
||||
function assignmentRows(query = {}) {
|
||||
function assignmentRows(query = {}, companyId) {
|
||||
return all(`
|
||||
SELECT aa.*, a.asset_id AS asset_code, a.description AS asset_description,
|
||||
e.name AS employee_name, e.email AS employee_email, e.employee_number
|
||||
FROM asset_assignments aa
|
||||
JOIN assets a ON a.id = aa.asset_id
|
||||
LEFT JOIN employees e ON e.id = aa.employee_id
|
||||
WHERE (? IS NULL OR aa.asset_id = ?)
|
||||
WHERE (? IS NULL OR a.entity_id = ?)
|
||||
AND (? IS NULL OR aa.asset_id = ?)
|
||||
AND (? IS NULL OR aa.employee_id = ?)
|
||||
AND (? IS NULL OR aa.released_at IS NULL)
|
||||
ORDER BY aa.assigned_at DESC, aa.id DESC
|
||||
LIMIT 500
|
||||
`, [
|
||||
companyId || null, companyId || null,
|
||||
query.asset_id || null,
|
||||
query.asset_id || null,
|
||||
query.employee_id || null,
|
||||
@@ -108,9 +110,10 @@ function assignmentRows(query = {}) {
|
||||
]).map(assignmentFromRow);
|
||||
}
|
||||
|
||||
function assignAsset(body, userId) {
|
||||
function assignAsset(body, userId, companyId) {
|
||||
const timestamp = now();
|
||||
const asset = one('SELECT * FROM assets WHERE id = ?', [body.asset_id]);
|
||||
// The asset must belong to the active company so assignments can't cross companies.
|
||||
const asset = one('SELECT * FROM assets WHERE id = ? AND (? IS NULL OR entity_id = ?)', [body.asset_id, companyId || null, companyId || null]);
|
||||
if (!asset) throw Object.assign(new Error('Asset not found'), { status: 404 });
|
||||
|
||||
const employee = body.employee_id ? one('SELECT * FROM employees WHERE id = ?', [body.employee_id]) : null;
|
||||
@@ -154,15 +157,18 @@ function assignAsset(body, userId) {
|
||||
[employee?.name || asset.custodian || null, department, location, timestamp, body.asset_id]
|
||||
);
|
||||
|
||||
const assignment = assignmentRows({ asset_id: body.asset_id, active: true })[0];
|
||||
const assignment = assignmentRows({ asset_id: body.asset_id, active: true }, companyId)[0];
|
||||
audit(userId, 'assign', 'asset', body.asset_id, null, assignment);
|
||||
return assignment || one('SELECT * FROM asset_assignments WHERE id = ?', [result.lastInsertRowid]);
|
||||
});
|
||||
}
|
||||
|
||||
function releaseAssignment(id, body, userId) {
|
||||
const before = one('SELECT * FROM asset_assignments WHERE id = ?', [id]);
|
||||
if (!before) return null;
|
||||
function releaseAssignment(id, body, userId, companyId) {
|
||||
const before = one(
|
||||
'SELECT aa.*, a.entity_id AS asset_entity_id FROM asset_assignments aa JOIN assets a ON a.id = aa.asset_id WHERE aa.id = ?',
|
||||
[id]
|
||||
);
|
||||
if (!before || (companyId && before.asset_entity_id !== companyId)) return null;
|
||||
const timestamp = now();
|
||||
run(
|
||||
`UPDATE asset_assignments
|
||||
@@ -171,26 +177,28 @@ function releaseAssignment(id, body, userId) {
|
||||
[timestamp, body.release_reason || 'Released', body.notes || null, timestamp, id]
|
||||
);
|
||||
audit(userId, 'release_assignment', 'asset_assignment', id, before, { released_at: timestamp, ...body });
|
||||
return assignmentRows({ asset_id: before.asset_id }).find((assignment) => assignment.id === Number(id));
|
||||
return assignmentRows({ asset_id: before.asset_id }, companyId).find((assignment) => assignment.id === Number(id));
|
||||
}
|
||||
|
||||
function assignmentSummary() {
|
||||
function assignmentSummary(companyId) {
|
||||
const stats = one(`
|
||||
SELECT
|
||||
COUNT(*) AS active_assignments,
|
||||
COUNT(DISTINCT employee_id) AS assigned_employees,
|
||||
COUNT(DISTINCT location) AS assigned_locations
|
||||
FROM asset_assignments
|
||||
WHERE released_at IS NULL
|
||||
`);
|
||||
COUNT(DISTINCT aa.employee_id) AS assigned_employees,
|
||||
COUNT(DISTINCT aa.location) AS assigned_locations
|
||||
FROM asset_assignments aa
|
||||
JOIN assets a ON a.id = aa.asset_id
|
||||
WHERE aa.released_at IS NULL AND (? IS NULL OR a.entity_id = ?)
|
||||
`, [companyId || null, companyId || null]);
|
||||
const unassigned = one(`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM assets a
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM asset_assignments aa
|
||||
WHERE aa.asset_id = a.id AND aa.released_at IS NULL
|
||||
)
|
||||
`);
|
||||
WHERE (? IS NULL OR a.entity_id = ?)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM asset_assignments aa
|
||||
WHERE aa.asset_id = a.id AND aa.released_at IS NULL
|
||||
)
|
||||
`, [companyId || null, companyId || null]);
|
||||
return { ...stats, unassigned_assets: unassigned.count };
|
||||
}
|
||||
|
||||
|
||||
@@ -19,35 +19,39 @@ function bookFromRow(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function listBooks() {
|
||||
function listBooks(companyId) {
|
||||
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
|
||||
(SELECT COUNT(*) FROM asset_books ab JOIN assets a ON a.id = ab.asset_id
|
||||
WHERE ab.book_type = b.code AND ab.active = 1 AND a.entity_id = b.entity_id) 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`
|
||||
WHERE (? IS NULL OR b.entity_id = ?)
|
||||
ORDER BY b.sort_order, b.id`,
|
||||
[companyId || null, companyId || null]
|
||||
).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 getBook(code, companyId) {
|
||||
return bookFromRow(one('SELECT * FROM books WHERE (code = ? OR id = ?) AND (? IS NULL OR entity_id = ?)', [code, code, companyId || null, companyId || null]) || null) || null;
|
||||
}
|
||||
|
||||
function createBook(body, userId) {
|
||||
function createBook(body, userId, companyId) {
|
||||
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])) {
|
||||
if (one('SELECT id FROM books WHERE code = ? AND entity_id = ?', [code, companyId])) {
|
||||
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 next = one('SELECT MAX(sort_order) AS max FROM books WHERE entity_id = ?', [companyId]);
|
||||
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, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO books (entity_id, 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, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
companyId,
|
||||
code,
|
||||
body.name || code,
|
||||
body.description || null,
|
||||
@@ -65,20 +69,20 @@ function createBook(body, userId) {
|
||||
return bookFromRow(one('SELECT * FROM books WHERE id = ?', [result.lastInsertRowid]));
|
||||
}
|
||||
|
||||
function deleteBook(id, userId) {
|
||||
const before = one('SELECT * FROM books WHERE id = ?', [id]);
|
||||
function deleteBook(id, userId, companyId) {
|
||||
const before = one('SELECT * FROM books WHERE id = ? AND (? IS NULL OR entity_id = ?)', [id, companyId || null, companyId || null]);
|
||||
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;
|
||||
const used = one('SELECT COUNT(*) AS count FROM asset_books ab JOIN assets a ON a.id = ab.asset_id WHERE ab.book_type = ? AND a.entity_id = ?', [before.code, before.entity_id]).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]);
|
||||
function updateBook(id, body, userId, companyId) {
|
||||
const before = one('SELECT * FROM books WHERE id = ? AND (? IS NULL OR entity_id = ?)', [id, companyId || null, companyId || null]);
|
||||
if (!before) return null;
|
||||
run(
|
||||
`UPDATE books SET
|
||||
@@ -104,11 +108,11 @@ function updateBook(id, body, userId) {
|
||||
|
||||
// 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);
|
||||
function bookLedger(code, year, companyId) {
|
||||
const book = getBook(code, companyId);
|
||||
if (!book) return null;
|
||||
if (book.book_type === 'maintenance') {
|
||||
return { ...pmBookLedger(year), book };
|
||||
return { ...pmBookLedger(year, companyId), book };
|
||||
}
|
||||
const rules = ruleSetForBook(code);
|
||||
const rows = all(
|
||||
@@ -116,9 +120,9 @@ function bookLedger(code, year) {
|
||||
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
|
||||
WHERE b.book_type = ? AND b.active = 1 AND (? IS NULL OR a.entity_id = ?)
|
||||
ORDER BY a.asset_id`,
|
||||
[code]
|
||||
[code, companyId || null, companyId || null]
|
||||
);
|
||||
|
||||
const accountMap = new Map();
|
||||
@@ -195,8 +199,8 @@ function bookLedger(code, year) {
|
||||
}
|
||||
|
||||
// Shape the ledger account rollup as a generic report for PDF/XLSX/CSV export.
|
||||
function ledgerReport(code, year) {
|
||||
const ledger = bookLedger(code, year);
|
||||
function ledgerReport(code, year, companyId) {
|
||||
const ledger = bookLedger(code, year, companyId);
|
||||
if (!ledger) return null;
|
||||
return {
|
||||
title: `${ledger.book.name} general ledger — ${year}`,
|
||||
|
||||
128
server/services/companies.js
Normal file
128
server/services/companies.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const { all, audit, now, one, run, seedBooksForCompany, seedReferenceValuesForCompany } = require('../db');
|
||||
|
||||
// Companies are stored in the `entities` table. Each gets its own books/GL, PM plans, alerts, and
|
||||
// (in phase 2) reference data. Disposing a company is a soft status change that preserves all history.
|
||||
|
||||
function listCompanies({ includeDisposed = false } = {}) {
|
||||
const where = includeDisposed ? '' : "WHERE status = 'active'";
|
||||
return all(`SELECT * FROM entities ${where} ORDER BY status, name`);
|
||||
}
|
||||
|
||||
function getCompany(id) {
|
||||
return one('SELECT * FROM entities WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
function activeCount() {
|
||||
return one("SELECT COUNT(*) AS c FROM entities WHERE status = 'active'").c;
|
||||
}
|
||||
|
||||
function createCompany(body, userId) {
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) throw Object.assign(new Error('A company name is required'), { status: 400 });
|
||||
const ts = now();
|
||||
const result = run(
|
||||
`INSERT INTO entities (name, legal_name, tax_id, fiscal_year_end_month, base_currency, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`,
|
||||
[name, body.legal_name || null, body.tax_id || null, Number(body.fiscal_year_end_month || 12), body.base_currency || 'USD', ts, ts]
|
||||
);
|
||||
// A new company starts with the standard book set + reference data (categories/locations/depts).
|
||||
seedBooksForCompany(result.lastInsertRowid);
|
||||
seedReferenceValuesForCompany(result.lastInsertRowid);
|
||||
audit(userId, 'create', 'entity', result.lastInsertRowid, null, { name });
|
||||
return getCompany(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
function updateCompany(id, body, userId) {
|
||||
const before = getCompany(id);
|
||||
if (!before) return null;
|
||||
run(
|
||||
`UPDATE entities SET name = ?, legal_name = ?, tax_id = ?, fiscal_year_end_month = ?, base_currency = ?, updated_at = ? WHERE id = ?`,
|
||||
[
|
||||
body.name !== undefined ? String(body.name).trim() || before.name : before.name,
|
||||
body.legal_name !== undefined ? body.legal_name : before.legal_name,
|
||||
body.tax_id !== undefined ? body.tax_id : before.tax_id,
|
||||
body.fiscal_year_end_month !== undefined ? Number(body.fiscal_year_end_month) : before.fiscal_year_end_month,
|
||||
body.base_currency !== undefined ? body.base_currency : before.base_currency,
|
||||
now(), id
|
||||
]
|
||||
);
|
||||
audit(userId, 'update', 'entity', id, before, body);
|
||||
return getCompany(id);
|
||||
}
|
||||
|
||||
// Archive/retire a company: it stays in the database (assets, books, GL preserved) but is hidden from
|
||||
// active selection and can no longer be the active company. Guards the last active company.
|
||||
function disposeCompany(id, body, userId) {
|
||||
const before = getCompany(id);
|
||||
if (!before) return { ok: false, status: 404, error: 'Company not found' };
|
||||
if (before.status === 'disposed') return { ok: false, status: 400, error: 'Company is already disposed' };
|
||||
if (activeCount() <= 1) return { ok: false, status: 400, error: 'At least one active company is required' };
|
||||
run("UPDATE entities SET status = 'disposed', disposed_at = ?, disposed_reason = ?, updated_at = ? WHERE id = ?",
|
||||
[now(), body.reason || null, now(), id]);
|
||||
audit(userId, 'dispose', 'entity', id, before, { reason: body.reason || null });
|
||||
return { ok: true, company: getCompany(id) };
|
||||
}
|
||||
|
||||
function restoreCompany(id, userId) {
|
||||
const before = getCompany(id);
|
||||
if (!before) return null;
|
||||
run("UPDATE entities SET status = 'active', disposed_at = NULL, disposed_reason = NULL, updated_at = ? WHERE id = ?", [now(), id]);
|
||||
audit(userId, 'restore', 'entity', id, before, null);
|
||||
return getCompany(id);
|
||||
}
|
||||
|
||||
// ---- Per-company user access ------------------------------------------------
|
||||
|
||||
// A user who can manage users (admin.users / wildcard) sees every company; this prevents an admin
|
||||
// from locking themselves out of any company. Required lazily to avoid a load-time cycle.
|
||||
function isCompanyAdmin(user) {
|
||||
const { capabilitiesForRole } = require('./roles');
|
||||
const { capabilitySetIncludes } = require('./accessControl');
|
||||
return capabilitySetIncludes(capabilitiesForRole(user.role), 'admin.users');
|
||||
}
|
||||
|
||||
// Active companies a user may see/switch to. Admins → all; others → their assigned companies, falling
|
||||
// back to the default company when they have none (so a user is never locked out).
|
||||
function companiesForUser(user) {
|
||||
if (isCompanyAdmin(user)) {
|
||||
return all("SELECT * FROM entities WHERE status = 'active' ORDER BY name");
|
||||
}
|
||||
const rows = all(
|
||||
`SELECT e.* FROM entities e JOIN user_companies uc ON uc.entity_id = e.id
|
||||
WHERE uc.user_id = ? AND e.status = 'active' ORDER BY e.name`,
|
||||
[user.id]
|
||||
);
|
||||
if (rows.length) return rows;
|
||||
const def = one("SELECT * FROM entities WHERE status = 'active' ORDER BY id LIMIT 1");
|
||||
return def ? [def] : [];
|
||||
}
|
||||
|
||||
function companyIdsForUser(user) {
|
||||
return companiesForUser(user).map((c) => c.id);
|
||||
}
|
||||
|
||||
// Explicit assignments stored for a user (independent of the admin bypass).
|
||||
function assignedCompanyIds(userId) {
|
||||
return all('SELECT entity_id FROM user_companies WHERE user_id = ?', [userId]).map((r) => r.entity_id);
|
||||
}
|
||||
|
||||
function setUserCompanies(userId, ids, actorId) {
|
||||
const valid = (Array.isArray(ids) ? ids : []).filter((id) => one('SELECT id FROM entities WHERE id = ?', [id]));
|
||||
run('DELETE FROM user_companies WHERE user_id = ?', [userId]);
|
||||
for (const id of valid) run('INSERT OR IGNORE INTO user_companies (user_id, entity_id) VALUES (?, ?)', [userId, id]);
|
||||
if (actorId) audit(actorId, 'set_companies', 'user', userId, null, { company_ids: valid });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assignedCompanyIds,
|
||||
companiesForUser,
|
||||
companyIdsForUser,
|
||||
createCompany,
|
||||
disposeCompany,
|
||||
getCompany,
|
||||
isCompanyAdmin,
|
||||
listCompanies,
|
||||
restoreCompany,
|
||||
setUserCompanies,
|
||||
updateCompany
|
||||
};
|
||||
@@ -47,10 +47,11 @@ function normalize(body) {
|
||||
return input;
|
||||
}
|
||||
|
||||
function listContacts(query = {}) {
|
||||
function listContacts(query = {}, companyId) {
|
||||
return all(
|
||||
`SELECT * FROM contacts
|
||||
WHERE (? IS NULL OR type = ?)
|
||||
WHERE (? IS NULL OR entity_id = ?)
|
||||
AND (? IS NULL OR type = ?)
|
||||
AND (? IS NULL OR status = ?)
|
||||
AND (? IS NULL OR (
|
||||
first_name LIKE '%' || ? || '%' OR last_name LIKE '%' || ? || '%' OR
|
||||
@@ -59,6 +60,7 @@ function listContacts(query = {}) {
|
||||
))
|
||||
ORDER BY type, organization, last_name, first_name`,
|
||||
[
|
||||
companyId || null, companyId || null,
|
||||
query.type || null, query.type || null,
|
||||
query.status || null, query.status || null,
|
||||
query.search || null, query.search || null, query.search || null,
|
||||
@@ -67,51 +69,52 @@ function listContacts(query = {}) {
|
||||
).map((row) => fromRow(row));
|
||||
}
|
||||
|
||||
function getContact(id) {
|
||||
return fromRow(one('SELECT * FROM contacts WHERE id = ?', [id]), true);
|
||||
function getContact(id, companyId) {
|
||||
return fromRow(one('SELECT * FROM contacts WHERE id = ? AND (? IS NULL OR entity_id = ?)', [id, companyId || null, companyId || null]), true);
|
||||
}
|
||||
|
||||
function createContact(body, userId) {
|
||||
function createContact(body, userId, companyId) {
|
||||
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]
|
||||
`INSERT INTO contacts (entity_id, ${cols.join(', ')}, created_by, created_at, updated_at)
|
||||
VALUES (?, ${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||
[companyId || null, ...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);
|
||||
return getContact(result.lastInsertRowid, companyId);
|
||||
}
|
||||
|
||||
function updateContact(id, body, userId) {
|
||||
function updateContact(id, body, userId, companyId) {
|
||||
const before = one('SELECT * FROM contacts WHERE id = ?', [id]);
|
||||
if (!before) return null;
|
||||
if (!before || (companyId && before.entity_id !== companyId)) 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);
|
||||
return getContact(id, companyId);
|
||||
}
|
||||
|
||||
function deleteContact(id, userId) {
|
||||
function deleteContact(id, userId, companyId) {
|
||||
const before = one('SELECT * FROM contacts WHERE id = ?', [id]);
|
||||
if (!before) return false;
|
||||
if (!before || (companyId && before.entity_id !== companyId)) 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;
|
||||
function addNote(contactId, bodyText, userId, companyId) {
|
||||
if (!one('SELECT id FROM contacts WHERE id = ? AND (? IS NULL OR entity_id = ?)', [contactId, companyId || null, companyId || null])) 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) {
|
||||
function deleteNote(contactId, noteId, userId, companyId) {
|
||||
if (companyId && !one('SELECT id FROM contacts WHERE id = ? AND entity_id = ?', [contactId, companyId])) return false;
|
||||
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 });
|
||||
@@ -152,10 +155,12 @@ function upsertWorkdayContact(worker) {
|
||||
);
|
||||
return existing.id;
|
||||
}
|
||||
// Workday sync is global (no request company context); new workers land in the default company.
|
||||
const defaultCompany = one("SELECT id FROM entities WHERE status = 'active' ORDER BY id LIMIT 1");
|
||||
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]
|
||||
`INSERT INTO contacts (entity_id, ${cols.join(', ')}, last_synced_at, created_at, updated_at) VALUES (?, ${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||
[defaultCompany ? defaultCompany.id : null, ...cols.map((c) => fields[c]), ts, ts, ts]
|
||||
);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ async function rowsFromImport(format, buffer) {
|
||||
return Papa.parse(text, { header: true, skipEmptyLines: true }).data;
|
||||
}
|
||||
|
||||
async function assetExport(format) {
|
||||
const rows = assetExportRows();
|
||||
async function assetExport(format, companyId) {
|
||||
const rows = assetExportRows(companyId);
|
||||
if (format === 'csv') {
|
||||
return {
|
||||
contentType: 'text/csv',
|
||||
|
||||
@@ -100,7 +100,7 @@ function decorate(row, totals) {
|
||||
};
|
||||
}
|
||||
|
||||
function listParts(query = {}) {
|
||||
function listParts(query = {}, companyId) {
|
||||
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,
|
||||
@@ -111,7 +111,8 @@ function listParts(query = {}) {
|
||||
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 = ?)
|
||||
WHERE (? IS NULL OR p.entity_id = ?)
|
||||
AND (? IS NULL OR p.status = ?)
|
||||
AND (? IS NULL OR p.category = ?)
|
||||
AND (? IS NULL OR (
|
||||
p.part_number LIKE '%' || ? || '%' OR p.name LIKE '%' || ? || '%' OR
|
||||
@@ -120,6 +121,7 @@ function listParts(query = {}) {
|
||||
))
|
||||
ORDER BY p.name`,
|
||||
[
|
||||
companyId || null, companyId || null,
|
||||
query.status || null, query.status || null,
|
||||
query.category || null, query.category || null,
|
||||
query.search || null, query.search || null, query.search || null,
|
||||
@@ -134,11 +136,11 @@ function listParts(query = {}) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
function getPart(id) {
|
||||
function getPart(id, companyId) {
|
||||
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]
|
||||
FROM parts p LEFT JOIN contacts sup ON sup.id = p.supplier_contact_id WHERE p.id = ? AND (? IS NULL OR p.entity_id = ?)`,
|
||||
[id, companyId || null, companyId || null]
|
||||
);
|
||||
if (!row) return null;
|
||||
const stock = stockRows(id);
|
||||
@@ -163,28 +165,28 @@ function normalize(body) {
|
||||
return input;
|
||||
}
|
||||
|
||||
function createPart(body, userId) {
|
||||
function createPart(body, userId, companyId) {
|
||||
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])) {
|
||||
if (one('SELECT id FROM parts WHERE part_number = ? AND entity_id = ?', [input.part_number, companyId])) {
|
||||
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]
|
||||
`INSERT INTO parts (entity_id, ${cols.join(', ')}, created_by, created_at, updated_at)
|
||||
VALUES (?, ${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||
[companyId || null, ...cols.map((c) => input[c]), userId, ts, ts]
|
||||
);
|
||||
audit(userId, 'create', 'part', result.lastInsertRowid, null, { part_number: input.part_number });
|
||||
return getPart(result.lastInsertRowid);
|
||||
return getPart(result.lastInsertRowid, companyId);
|
||||
}
|
||||
|
||||
function updatePart(id, body, userId) {
|
||||
function updatePart(id, body, userId, companyId) {
|
||||
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
|
||||
if (!before) return null;
|
||||
if (!before || (companyId && before.entity_id !== companyId)) 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])) {
|
||||
if (input.part_number !== before.part_number && one('SELECT id FROM parts WHERE part_number = ? AND entity_id = ? AND id <> ?', [input.part_number, before.entity_id, id])) {
|
||||
throw Object.assign(new Error('That part number already exists'), { status: 400 });
|
||||
}
|
||||
run(
|
||||
@@ -192,12 +194,12 @@ function updatePart(id, body, userId) {
|
||||
[...COLUMNS.map((c) => input[c] ?? null), now(), id]
|
||||
);
|
||||
audit(userId, 'update', 'part', id, before, body);
|
||||
return getPart(id);
|
||||
return getPart(id, companyId);
|
||||
}
|
||||
|
||||
function deletePart(id, userId) {
|
||||
function deletePart(id, userId, companyId) {
|
||||
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
|
||||
if (!before) return false;
|
||||
if (!before || (companyId && before.entity_id !== companyId)) return false;
|
||||
run('DELETE FROM parts WHERE id = ?', [id]);
|
||||
audit(userId, 'delete', 'part', id, before, null);
|
||||
return true;
|
||||
@@ -205,8 +207,12 @@ function deletePart(id, userId) {
|
||||
|
||||
// ---- Stock locations -------------------------------------------------------
|
||||
|
||||
function saveStock(partId, body, userId) {
|
||||
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
|
||||
function partInCompany(partId, companyId) {
|
||||
return Boolean(one('SELECT id FROM parts WHERE id = ? AND (? IS NULL OR entity_id = ?)', [partId, companyId || null, companyId || null]));
|
||||
}
|
||||
|
||||
function saveStock(partId, body, userId, companyId) {
|
||||
if (!partInCompany(partId, companyId)) return null;
|
||||
const ts = now();
|
||||
const location = body.location_contact_id || null;
|
||||
const aisle = body.aisle || null;
|
||||
@@ -228,20 +234,21 @@ function saveStock(partId, body, userId) {
|
||||
);
|
||||
audit(userId, 'create', 'part_stock', result.lastInsertRowid, null, { part_id: partId });
|
||||
}
|
||||
return getPart(partId);
|
||||
return getPart(partId, companyId);
|
||||
}
|
||||
|
||||
function deleteStock(partId, stockId, userId) {
|
||||
function deleteStock(partId, stockId, userId, companyId) {
|
||||
if (!partInCompany(partId, companyId)) return null;
|
||||
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);
|
||||
return getPart(partId, companyId);
|
||||
}
|
||||
|
||||
// ---- Stock movements -------------------------------------------------------
|
||||
|
||||
function recordTransaction(partId, body, userId) {
|
||||
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
|
||||
function recordTransaction(partId, body, userId, companyId) {
|
||||
if (!partInCompany(partId, companyId)) 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 });
|
||||
@@ -272,28 +279,29 @@ function recordTransaction(partId, body, userId) {
|
||||
]
|
||||
);
|
||||
audit(userId, 'movement', 'part', partId, null, { type, quantity: qty, stock_id: stock.id });
|
||||
return getPart(partId);
|
||||
return getPart(partId, companyId);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Photos ----------------------------------------------------------------
|
||||
|
||||
function addPhoto(partId, body, userId) {
|
||||
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
|
||||
function addPhoto(partId, body, userId, companyId) {
|
||||
if (!partInCompany(partId, companyId)) 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);
|
||||
return getPart(partId, companyId);
|
||||
}
|
||||
|
||||
function deletePhoto(partId, photoId, userId) {
|
||||
function deletePhoto(partId, photoId, userId, companyId) {
|
||||
if (!partInCompany(partId, companyId)) return null;
|
||||
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);
|
||||
return getPart(partId, companyId);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -99,8 +99,9 @@ function planFromRow(row, { withPhotoData = true } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function listPlans() {
|
||||
return all('SELECT * FROM pm_plans ORDER BY name').map((row) => planFromRow(row, { withPhotoData: false }));
|
||||
function listPlans(companyId) {
|
||||
return all('SELECT * FROM pm_plans WHERE (? IS NULL OR entity_id = ?) ORDER BY name', [companyId || null, companyId || null])
|
||||
.map((row) => planFromRow(row, { withPhotoData: false }));
|
||||
}
|
||||
|
||||
function getPlan(id) {
|
||||
@@ -162,13 +163,14 @@ function resolveEstimatedMinutes(body, fallback) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
function createPlan(body, userId) {
|
||||
function createPlan(body, userId, companyId) {
|
||||
const ts = now();
|
||||
return tx(() => {
|
||||
const result = run(
|
||||
`INSERT INTO pm_plans (name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, lifespan_adjust, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO pm_plans (entity_id, name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, lifespan_adjust, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
companyId || null,
|
||||
body.name || 'PM plan', body.description || null, body.category || null,
|
||||
Number(body.frequency_value) || 1, FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : 'months',
|
||||
resolveEstimatedMinutes(body, null), body.instructions || null, body.lifespan_adjust ? 1 : 0,
|
||||
@@ -609,12 +611,14 @@ function runScheduledRecompute() {
|
||||
}
|
||||
|
||||
// Maintenance-cost "ledger" for the PM book: actual PM spend per asset for a year.
|
||||
function pmBookLedger(year) {
|
||||
function pmBookLedger(year, companyId) {
|
||||
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`
|
||||
JOIN assets a ON a.id = ap.asset_id
|
||||
WHERE (? IS NULL OR a.entity_id = ?)`,
|
||||
[companyId || null, companyId || null]
|
||||
);
|
||||
const byAsset = {};
|
||||
let total = 0;
|
||||
|
||||
@@ -4,7 +4,7 @@ const { annualSchedule, money, monthlyFromAnnual } = require('../depreciation');
|
||||
const { assetExportRows, assetFromRow } = require('./assets');
|
||||
const { ruleSetForBook } = require('./ruleSets');
|
||||
|
||||
function depreciationReport(year, bookType) {
|
||||
function depreciationReport(year, bookType, companyId) {
|
||||
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,
|
||||
@@ -13,9 +13,9 @@ function depreciationReport(year, bookType) {
|
||||
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.active = 1 AND b.book_type = ?
|
||||
WHERE b.active = 1 AND b.book_type = ? AND (? IS NULL OR a.entity_id = ?)
|
||||
ORDER BY a.asset_id
|
||||
`, [bookType]);
|
||||
`, [bookType, companyId || null, companyId || null]);
|
||||
|
||||
const reportRows = rows.map((row) => {
|
||||
const asset = assetFromRow(row);
|
||||
@@ -54,9 +54,9 @@ function depreciationReport(year, bookType) {
|
||||
};
|
||||
}
|
||||
|
||||
function monthlyDepreciationReport(year, bookType) {
|
||||
function monthlyDepreciationReport(year, bookType, companyId) {
|
||||
const rules = ruleSetForBook(bookType);
|
||||
const assets = all('SELECT * FROM assets ORDER BY asset_id').map(assetFromRow);
|
||||
const assets = all('SELECT * FROM assets WHERE (? IS NULL OR entity_id = ?) ORDER BY asset_id', [companyId || null, companyId || null]).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]);
|
||||
if (!book) return [];
|
||||
@@ -72,8 +72,8 @@ function monthlyDepreciationReport(year, bookType) {
|
||||
return { year, book: bookType, months };
|
||||
}
|
||||
|
||||
function writeDepreciationPdf(res, year, book) {
|
||||
const rows = assetExportRows();
|
||||
function writeDepreciationPdf(res, year, book, companyId) {
|
||||
const rows = assetExportRows(companyId);
|
||||
const doc = new PDFDocument({ margin: 36, size: 'LETTER' });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="deprecore-${book}-${year}.pdf"`);
|
||||
|
||||
Reference in New Issue
Block a user