Updated A LOT
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user