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>
288 lines
12 KiB
TypeScript
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];
|
|
}
|
|
}
|