import { Injectable, NotFoundException } from '@nestjs/common'; import { TenantService } from '../../database/tenant.service'; @Injectable() export class ProjectsService { constructor(private tenant: TenantService) {} async findAll() { const projects = await this.tenant.query( 'SELECT * FROM projects WHERE is_active = true ORDER BY planned_date NULLS LAST, target_year NULLS LAST, target_month NULLS LAST, name', ); return this.computeFunding(projects); } async findOne(id: string) { const rows = await this.tenant.query('SELECT * FROM projects WHERE id = $1', [id]); if (!rows.length) throw new NotFoundException('Project not found'); return rows[0]; } async findForPlanning() { const projects = await this.tenant.query( 'SELECT * FROM projects WHERE is_active = true ORDER BY target_year NULLS LAST, target_month NULLS LAST, priority', ); return this.computeFunding(projects); } /** * Priority-based funding allocation for reserve projects. * * 1. Projects with is_funding_locked = true keep their stored funded_percentage * and current_fund_balance values as-is. * 2. Remaining reserve balance (after deducting locked amounts) is allocated * sequentially to unlocked reserve projects sorted by target_year, target_month, * priority — near-term items get fully funded first. */ private async computeFunding(projects: any[]): Promise { // Get total reserve balance (equity + investments) const [balanceRow] = await this.tenant.query(` SELECT COALESCE(( SELECT SUM(sub.balance) FROM ( SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance FROM accounts a LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false WHERE a.fund_type = 'reserve' AND a.account_type = 'equity' AND a.is_active = true GROUP BY a.id ) sub ), 0) + COALESCE(( SELECT SUM(current_value) FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true ), 0) as total `); const totalReserve = parseFloat(balanceRow?.total || '0'); // Separate locked and unlocked reserve projects const lockedReserve: any[] = []; const unlockedReserve: any[] = []; for (const p of projects) { if (p.fund_source === 'reserve' && !p.is_funding_locked) { unlockedReserve.push(p); } else if (p.fund_source === 'reserve' && p.is_funding_locked) { lockedReserve.push(p); } } // Deduct locked amounts from available reserve balance const lockedTotal = lockedReserve.reduce((sum, p) => sum + parseFloat(p.current_fund_balance || '0'), 0); let remaining = Math.max(totalReserve - lockedTotal, 0); // Sort unlocked by target_year, target_month, priority for sequential allocation unlockedReserve.sort((a, b) => { const ya = a.target_year || 9999; const yb = b.target_year || 9999; if (ya !== yb) return ya - yb; const ma = a.target_month || 13; const mb = b.target_month || 13; if (ma !== mb) return ma - mb; return (a.priority || 3) - (b.priority || 3); }); // Allocate remaining balance sequentially: near-term items first const fundingMap = new Map(); // Locked projects keep their stored values for (const p of lockedReserve) { fundingMap.set(p.id, { funded_percentage: parseFloat(p.funded_percentage || '0'), current_fund_balance: parseFloat(p.current_fund_balance || '0'), }); } // Unlocked projects get sequential allocation for (const p of unlockedReserve) { const cost = parseFloat(p.estimated_cost || '0'); if (cost <= 0) { fundingMap.set(p.id, { funded_percentage: 0, current_fund_balance: 0 }); continue; } const allocated = Math.min(cost, remaining); remaining -= allocated; const pct = Math.min((allocated / cost) * 100, 100); fundingMap.set(p.id, { funded_percentage: Math.round(pct * 100) / 100, current_fund_balance: Math.round(allocated * 100) / 100, }); } // Apply computed funding to all projects return projects.map((p) => { const funding = fundingMap.get(p.id); if (funding) { return { ...p, funded_percentage: funding.funded_percentage, current_fund_balance: funding.current_fund_balance }; } return p; // non-reserve projects keep stored values }); } async create(dto: any) { // Default planned_date to next_replacement_date if not provided const plannedDate = dto.planned_date || dto.next_replacement_date || null; // If fund_source is not 'reserve', funded_percentage stays 0 const fundedPct = dto.fund_source === 'reserve' ? (dto.funded_percentage || 0) : 0; const rows = await this.tenant.query( `INSERT INTO projects ( name, description, category, estimated_cost, actual_cost, current_fund_balance, annual_contribution, fund_source, funded_percentage, useful_life_years, remaining_life_years, condition_rating, last_replacement_date, next_replacement_date, planned_date, target_year, target_month, status, priority, account_id, notes, is_funding_locked ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) RETURNING *`, [ dto.name, dto.description || null, dto.category || null, dto.estimated_cost || 0, dto.actual_cost || null, dto.current_fund_balance || 0, dto.annual_contribution || 0, dto.fund_source || 'reserve', fundedPct, dto.useful_life_years || null, dto.remaining_life_years || null, dto.condition_rating || null, dto.last_replacement_date || null, dto.next_replacement_date || null, plannedDate, dto.target_year || null, dto.target_month || null, dto.status || 'planned', dto.priority || 3, dto.account_id || null, dto.notes || null, dto.is_funding_locked || false, ], ); return rows[0]; } async update(id: string, dto: any) { await this.findOne(id); const sets: string[] = []; const params: any[] = []; let idx = 1; // Date columns must be null (not empty string) for PostgreSQL DATE type const dateFields = new Set(['last_replacement_date', 'next_replacement_date', 'planned_date']); // Build dynamic SET clause const fields: [string, string][] = [ ['name', 'name'], ['description', 'description'], ['category', 'category'], ['estimated_cost', 'estimated_cost'], ['actual_cost', 'actual_cost'], ['current_fund_balance', 'current_fund_balance'], ['annual_contribution', 'annual_contribution'], ['fund_source', 'fund_source'], ['funded_percentage', 'funded_percentage'], ['useful_life_years', 'useful_life_years'], ['remaining_life_years', 'remaining_life_years'], ['condition_rating', 'condition_rating'], ['last_replacement_date', 'last_replacement_date'], ['next_replacement_date', 'next_replacement_date'], ['planned_date', 'planned_date'], ['target_year', 'target_year'], ['target_month', 'target_month'], ['status', 'status'], ['priority', 'priority'], ['account_id', 'account_id'], ['notes', 'notes'], ['is_active', 'is_active'], ['is_funding_locked', 'is_funding_locked'], ]; for (const [dtoKey, dbCol] of fields) { if (dto[dtoKey] !== undefined) { sets.push(`${dbCol} = $${idx++}`); const val = dateFields.has(dtoKey) && dto[dtoKey] === '' ? null : dto[dtoKey]; params.push(val); } } if (!sets.length) return this.findOne(id); sets.push('updated_at = NOW()'); params.push(id); const rows = await this.tenant.query( `UPDATE projects SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`, params, ); return rows[0]; } async exportCSV(): Promise { const rows = await this.tenant.query( `SELECT name, description, category, estimated_cost, actual_cost, fund_source, useful_life_years, remaining_life_years, condition_rating, last_replacement_date, next_replacement_date, planned_date, target_year, target_month, status, priority, notes FROM projects WHERE is_active = true ORDER BY name`, ); const headers = ['*name', 'description', '*category', '*estimated_cost', 'actual_cost', 'fund_source', 'useful_life_years', 'remaining_life_years', 'condition_rating', 'last_replacement_date', 'next_replacement_date', 'planned_date', 'target_year', 'target_month', 'status', 'priority', 'notes']; const keys = headers.map(h => h.replace(/^\*/, '')); const lines = [headers.join(',')]; for (const r of rows) { lines.push(keys.map((k) => { let v = r[k] ?? ''; if (v instanceof Date) v = v.toISOString().split('T')[0]; const s = String(v); return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s; }).join(',')); } return lines.join('\n'); } async importCSV(rows: any[]) { let created = 0, updated = 0; const errors: string[] = []; for (let i = 0; i < rows.length; i++) { const row = rows[i]; const name = (row.name || '').trim(); if (!name) { errors.push(`Row ${i + 1}: missing name (required)`); continue; } if (!row.category) { errors.push(`Row ${i + 1}: missing category (required)`); continue; } if (!row.estimated_cost) { errors.push(`Row ${i + 1}: missing estimated_cost (required)`); continue; } try { const existing = await this.tenant.query('SELECT id FROM projects WHERE name = $1 AND is_active = true', [name]); if (existing.length) { const sets: string[] = []; const params: any[] = [existing[0].id]; let idx = 2; const fields = ['description', 'category', 'estimated_cost', 'actual_cost', 'fund_source', 'useful_life_years', 'remaining_life_years', 'condition_rating', 'last_replacement_date', 'next_replacement_date', 'planned_date', 'target_year', 'target_month', 'status', 'priority', 'notes']; for (const f of fields) { if (row[f] !== undefined && row[f] !== '') { sets.push(`${f} = $${idx++}`); params.push(row[f]); } } if (sets.length) { sets.push('updated_at = NOW()'); await this.tenant.query(`UPDATE projects SET ${sets.join(', ')} WHERE id = $1`, params); } updated++; } else { await this.tenant.query( `INSERT INTO projects (name, description, category, estimated_cost, actual_cost, fund_source, useful_life_years, remaining_life_years, condition_rating, last_replacement_date, next_replacement_date, planned_date, target_year, target_month, status, priority, notes) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, [name, row.description || null, row.category, parseFloat(row.estimated_cost) || 0, row.actual_cost || null, row.fund_source || 'reserve', row.useful_life_years || null, row.remaining_life_years || null, row.condition_rating || null, row.last_replacement_date || null, row.next_replacement_date || null, row.planned_date || null, row.target_year || null, row.target_month || null, row.status || 'planned', row.priority || 3, row.notes || null], ); created++; } } catch (err: any) { errors.push(`Row ${i + 1} (${name}): ${err.message}`); } } return { imported: created + updated, created, updated, errors }; } async updatePlannedDate(id: string, planned_date: string) { await this.findOne(id); const rows = await this.tenant.query( 'UPDATE projects SET planned_date = $2, updated_at = NOW() WHERE id = $1 RETURNING *', [id, planned_date || null], ); return rows[0]; } }