Files
HOA_Financial_Platform/backend/src/modules/projects/projects.service.ts
olsch01 05e241c792 fix: allow null planned_date when updating projects
Empty string date values from the frontend were being passed directly
to PostgreSQL, which cannot cast "" to DATE. Normalize empty strings
to null for all date columns in the update method and the dedicated
updatePlannedDate endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:05:29 -05:00

288 lines
12 KiB
TypeScript

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<any[]> {
// 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<string, { funded_percentage: number; current_fund_balance: number }>();
// 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<string> {
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];
}
}