Fix bugs: monthly actuals month filter, unit assessments, project funding logic, UI cleanup
- Fix monthly actuals showing same data for all months (SQL JOIN excluded month filter from SUM — added je.id IS NOT NULL guard) - Fix units displaying $0 assessment by reading from assessment group instead of stale unit field; add special assessment column - Replace proportional project funding with priority-based sequential allocation — near-term items get fully funded first; add is_funding_locked flag so users can manually lock a project's fund balance - Remove post-creation opening balance UI (keep only initial balance on account creation); remove redundant Fund filter dropdown from Accounts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,50 +6,10 @@ export class ProjectsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll() {
|
||||
// Return all active projects with dynamically computed reserve fund balance
|
||||
// The total reserve fund balance (from reserve EQUITY accounts = fund balance) is distributed
|
||||
// proportionally across reserve projects based on their estimated_cost
|
||||
return this.tenant.query(`
|
||||
WITH reserve_equity AS (
|
||||
SELECT COALESCE(SUM(sub.balance), 0) as total 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
|
||||
),
|
||||
reserve_investments AS (
|
||||
SELECT COALESCE(SUM(current_value), 0) as total
|
||||
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
|
||||
),
|
||||
reserve_balance AS (
|
||||
SELECT re.total + ri.total as total
|
||||
FROM reserve_equity re, reserve_investments ri
|
||||
),
|
||||
reserve_total_cost AS (
|
||||
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
||||
FROM projects
|
||||
WHERE is_active = true AND fund_source = 'reserve' AND estimated_cost > 0
|
||||
)
|
||||
SELECT p.*,
|
||||
CASE
|
||||
WHEN p.fund_source = 'reserve' AND p.estimated_cost > 0 AND rtc.total > 0 THEN
|
||||
LEAST(ROUND((rb.total * (p.estimated_cost / rtc.total)) / p.estimated_cost * 100, 2), 100)
|
||||
ELSE p.funded_percentage
|
||||
END as funded_percentage,
|
||||
CASE
|
||||
WHEN p.fund_source = 'reserve' AND rtc.total > 0 THEN
|
||||
ROUND(rb.total * (p.estimated_cost / rtc.total), 2)
|
||||
ELSE p.current_fund_balance
|
||||
END as current_fund_balance
|
||||
FROM projects p
|
||||
CROSS JOIN reserve_balance rb
|
||||
CROSS JOIN reserve_total_cost rtc
|
||||
WHERE p.is_active = true
|
||||
ORDER BY p.name
|
||||
`);
|
||||
const projects = await this.tenant.query(
|
||||
'SELECT * FROM projects WHERE is_active = true ORDER BY name',
|
||||
);
|
||||
return this.computeFunding(projects);
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
@@ -59,49 +19,103 @@ export class ProjectsService {
|
||||
}
|
||||
|
||||
async findForPlanning() {
|
||||
// Only return projects that have target_year set (for the Capital Planning kanban)
|
||||
// Uses the same dynamic reserve fund balance computation as findAll()
|
||||
return this.tenant.query(`
|
||||
WITH reserve_equity AS (
|
||||
SELECT COALESCE(SUM(sub.balance), 0) as total 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
|
||||
),
|
||||
reserve_investments AS (
|
||||
SELECT COALESCE(SUM(current_value), 0) as total
|
||||
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
|
||||
),
|
||||
reserve_balance AS (
|
||||
SELECT re.total + ri.total as total
|
||||
FROM reserve_equity re, reserve_investments ri
|
||||
),
|
||||
reserve_total_cost AS (
|
||||
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
||||
FROM projects
|
||||
WHERE is_active = true AND fund_source = 'reserve' AND estimated_cost > 0
|
||||
)
|
||||
SELECT p.*,
|
||||
CASE
|
||||
WHEN p.fund_source = 'reserve' AND p.estimated_cost > 0 AND rtc.total > 0 THEN
|
||||
LEAST(ROUND((rb.total * (p.estimated_cost / rtc.total)) / p.estimated_cost * 100, 2), 100)
|
||||
ELSE p.funded_percentage
|
||||
END as funded_percentage,
|
||||
CASE
|
||||
WHEN p.fund_source = 'reserve' AND rtc.total > 0 THEN
|
||||
ROUND(rb.total * (p.estimated_cost / rtc.total), 2)
|
||||
ELSE p.current_fund_balance
|
||||
END as current_fund_balance
|
||||
FROM projects p
|
||||
CROSS JOIN reserve_balance rb
|
||||
CROSS JOIN reserve_total_cost rtc
|
||||
WHERE p.is_active = true AND p.target_year IS NOT NULL
|
||||
ORDER BY p.target_year, p.target_month NULLS LAST, p.priority
|
||||
const projects = await this.tenant.query(
|
||||
'SELECT * FROM projects WHERE is_active = true AND target_year IS NOT NULL ORDER BY target_year, 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) {
|
||||
@@ -116,8 +130,8 @@ export class ProjectsService {
|
||||
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
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
|
||||
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,
|
||||
@@ -131,6 +145,7 @@ export class ProjectsService {
|
||||
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];
|
||||
@@ -154,7 +169,7 @@ export class ProjectsService {
|
||||
['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'],
|
||||
['account_id', 'account_id'], ['notes', 'notes'], ['is_active', 'is_active'], ['is_funding_locked', 'is_funding_locked'],
|
||||
];
|
||||
|
||||
for (const [dtoKey, dbCol] of fields) {
|
||||
|
||||
Reference in New Issue
Block a user