Initial
This commit is contained in:
260
server/services/assets.js
Normal file
260
server/services/assets.js
Normal file
@@ -0,0 +1,260 @@
|
||||
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
|
||||
|
||||
const assetColumns = [
|
||||
'asset_id',
|
||||
'entity_id',
|
||||
'template_id',
|
||||
'description',
|
||||
'category',
|
||||
'status',
|
||||
'acquisition_cost',
|
||||
'acquired_date',
|
||||
'in_service_date',
|
||||
'useful_life_months',
|
||||
'salvage_value',
|
||||
'land_value',
|
||||
'prior_depreciation',
|
||||
'location',
|
||||
'department',
|
||||
'group_name',
|
||||
'custodian',
|
||||
'vendor',
|
||||
'serial_number',
|
||||
'invoice_number',
|
||||
'gl_asset_account',
|
||||
'gl_expense_account',
|
||||
'gl_accumulated_account',
|
||||
'barcode_value',
|
||||
'condition',
|
||||
'new_or_used',
|
||||
'listed_property',
|
||||
'business_use_percent',
|
||||
'investment_use_percent',
|
||||
'disposal_date',
|
||||
'disposal_price',
|
||||
'disposal_expense',
|
||||
'disposal_type',
|
||||
'property_type',
|
||||
'notes',
|
||||
'custom_fields'
|
||||
];
|
||||
|
||||
const defaultBooks = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
|
||||
|
||||
function assetFromRow(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
listed_property: Boolean(row.listed_property),
|
||||
custom_fields: parseJson(row.custom_fields, {}),
|
||||
books: row.books ? parseJson(row.books, []) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function hydrateAsset(id) {
|
||||
const asset = assetFromRow(one('SELECT * FROM assets WHERE id = ?', [id]));
|
||||
if (!asset) return null;
|
||||
asset.books = all('SELECT * FROM asset_books WHERE asset_id = ? ORDER BY book_type', [id]).map((book) => ({
|
||||
...book,
|
||||
active: Boolean(book.active),
|
||||
manual_depreciation: parseJson(book.manual_depreciation, {})
|
||||
}));
|
||||
asset.leases = all('SELECT * FROM leases WHERE asset_id = ? ORDER BY start_date DESC', [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]);
|
||||
return asset;
|
||||
}
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
function normalizeAssetInput(body) {
|
||||
const input = {};
|
||||
for (const column of assetColumns) {
|
||||
if (Object.prototype.hasOwnProperty.call(body, column)) input[column] = body[column];
|
||||
}
|
||||
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.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);
|
||||
input.salvage_value = Number(input.salvage_value || 0);
|
||||
input.land_value = Number(input.land_value || 0);
|
||||
input.prior_depreciation = Number(input.prior_depreciation || 0);
|
||||
input.business_use_percent = Number(input.business_use_percent || 100);
|
||||
input.investment_use_percent = Number(input.investment_use_percent || 0);
|
||||
input.custom_fields = json(input.custom_fields || {});
|
||||
input.listed_property = input.listed_property ? 1 : 0;
|
||||
return input;
|
||||
}
|
||||
|
||||
function createBooks(assetId, asset, books = []) {
|
||||
const selectedBooks = books.length ? books : defaultBooks.map((book) => ({ book_type: book }));
|
||||
for (const book of selectedBooks) {
|
||||
run(
|
||||
`INSERT OR REPLACE INTO asset_books (
|
||||
asset_id, book_type, active, cost, depreciation_method, convention, useful_life_months,
|
||||
section_179_amount, bonus_percent, business_use_percent, investment_use_percent, manual_depreciation
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
assetId,
|
||||
book.book_type || book.book || 'GAAP',
|
||||
book.active === false ? 0 : 1,
|
||||
book.cost ?? asset.acquisition_cost,
|
||||
book.depreciation_method || asset.depreciation_method || 'straight_line',
|
||||
book.convention || asset.convention || 'half_year',
|
||||
book.useful_life_months || asset.useful_life_months,
|
||||
Number(book.section_179_amount || 0),
|
||||
Number(book.bonus_percent || 0),
|
||||
book.business_use_percent ?? asset.business_use_percent ?? 100,
|
||||
book.investment_use_percent ?? asset.investment_use_percent ?? 0,
|
||||
json(book.manual_depreciation || {})
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function listAssets(query = {}) {
|
||||
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.status = ?)
|
||||
AND (? IS NULL OR a.entity_id = ?)
|
||||
AND (? IS NULL OR a.category = ?)
|
||||
AND (? IS NULL OR a.description LIKE '%' || ? || '%' OR a.asset_id LIKE '%' || ? || '%')
|
||||
ORDER BY a.updated_at DESC
|
||||
LIMIT 500
|
||||
`, [
|
||||
query.status || null,
|
||||
query.status || null,
|
||||
query.entity_id || null,
|
||||
query.entity_id || null,
|
||||
query.category || null,
|
||||
query.category || null,
|
||||
query.search || null,
|
||||
query.search || null,
|
||||
query.search || null
|
||||
]).map(assetFromRow);
|
||||
}
|
||||
|
||||
function createAsset(body, userId) {
|
||||
const created = now();
|
||||
const input = normalizeAssetInput(body);
|
||||
const result = tx(() => {
|
||||
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 || []);
|
||||
return insert;
|
||||
});
|
||||
const asset = hydrateAsset(result.lastInsertRowid);
|
||||
audit(userId, 'create', 'asset', asset.id, null, asset);
|
||||
return asset;
|
||||
}
|
||||
|
||||
function updateAsset(id, body, userId) {
|
||||
const before = hydrateAsset(id);
|
||||
if (!before) return null;
|
||||
|
||||
const input = normalizeAssetInput({ ...before, ...body });
|
||||
const updated = now();
|
||||
tx(() => {
|
||||
run(
|
||||
`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);
|
||||
});
|
||||
|
||||
const asset = hydrateAsset(id);
|
||||
audit(userId, 'update', 'asset', asset.id, before, asset);
|
||||
return asset;
|
||||
}
|
||||
|
||||
function deleteAsset(id, userId) {
|
||||
const before = hydrateAsset(id);
|
||||
if (!before) return false;
|
||||
run('DELETE FROM assets WHERE id = ?', [id]);
|
||||
audit(userId, 'delete', 'asset', id, before, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function massUpdateAssets(ids, changes, userId) {
|
||||
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));
|
||||
if (!ids.length || !columns.length) return 0;
|
||||
|
||||
tx(() => {
|
||||
for (const id of ids) {
|
||||
run(
|
||||
`UPDATE assets SET ${columns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
|
||||
[...columns.map((column) => changes[column]), now(), id]
|
||||
);
|
||||
}
|
||||
});
|
||||
audit(userId, 'mass_update', 'asset', ids.join(','), null, { ids, changes });
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
function assetExportRows() {
|
||||
return all(`
|
||||
SELECT a.*, e.name AS entity_name
|
||||
FROM assets a
|
||||
LEFT JOIN entities e ON e.id = a.entity_id
|
||||
ORDER BY a.asset_id
|
||||
`).map((asset) => ({
|
||||
...asset,
|
||||
listed_property: Boolean(asset.listed_property),
|
||||
custom_fields: parseJson(asset.custom_fields, {})
|
||||
}));
|
||||
}
|
||||
|
||||
function upsertImportedAssets(rows, userId) {
|
||||
let imported = 0;
|
||||
tx(() => {
|
||||
for (const row of rows) {
|
||||
const input = normalizeAssetInput(row);
|
||||
const existing = one('SELECT id FROM assets WHERE asset_id = ?', [input.asset_id]);
|
||||
if (existing) {
|
||||
run(
|
||||
`UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
|
||||
[...assetColumns.map((column) => input[column] ?? null), now(), existing.id]
|
||||
);
|
||||
} else {
|
||||
const result = run(
|
||||
`INSERT INTO assets (${assetColumns.join(', ')}, created_by, created_at, updated_at)
|
||||
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||
[...assetColumns.map((column) => input[column] ?? null), userId, now(), now()]
|
||||
);
|
||||
createBooks(result.lastInsertRowid, input, []);
|
||||
}
|
||||
imported += 1;
|
||||
}
|
||||
});
|
||||
return imported;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assetColumns,
|
||||
assetExportRows,
|
||||
assetFromRow,
|
||||
createAsset,
|
||||
createBooks,
|
||||
defaultBooks,
|
||||
deleteAsset,
|
||||
hydrateAsset,
|
||||
listAssets,
|
||||
massUpdateAssets,
|
||||
normalizeAssetInput,
|
||||
upsertImportedAssets,
|
||||
updateAsset
|
||||
};
|
||||
Reference in New Issue
Block a user