const { all, audit, json, now, one, parseJson, run, tx } = require('../db'); const { assetPmRows } = require('./pm'); 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', '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 { ...row, listed_property: Boolean(row.listed_property), custom_fields: parseJson(row.custom_fields, {}), books: row.books ? parseJson(row.books, []) : undefined }; } 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; 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.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 LEFT JOIN users u ON u.id = n.created_by WHERE n.asset_id = ? ORDER BY n.id DESC`, [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) { 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 || 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); 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 : enabledBookCodes().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(() => { 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); 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 }); // 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( `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); run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), id]); }); 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); 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(', ')}, asset_class_number = ?, updated_at = ? WHERE id = ?`, [...assetColumns.map((column) => input[column] ?? null), classNumber, 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, []); run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [classNumber, result.lastInsertRowid]); } imported += 1; } }); return imported; } module.exports = { addAssetPhoto, assetColumns, assetExportRows, assetFromRow, createAsset, createBooks, defaultBooks, deleteAsset, deleteAssetPhoto, formatAssetId, hydrateAsset, updateAssetPhoto, listAssets, lookupAssetByCode, massUpdateAssets, normalizeAssetInput, upsertImportedAssets, updateAsset };