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 };