/** * Pure scope-to-effort expansion engine. * Takes scope items and effort rules, produces demand line drafts. * No DB or IO dependencies. */ export type EffortUnitMode = "per_frame" | "per_item" | "flat"; export interface EffortRuleInput { scopeType: string; discipline: string; chapter?: string | null; unitMode: EffortUnitMode; hoursPerUnit: number; sortOrder: number; } export interface ScopeItemInput { name: string; scopeType: string; frameCount?: number | null; itemCount?: number | null; unitMode?: string | null; } export interface ExpandedDemandLine { scopeItemName: string; scopeType: string; discipline: string; chapter?: string | null; hours: number; unitMode: EffortUnitMode; unitCount: number; hoursPerUnit: number; } export interface EffortExpansionResult { lines: ExpandedDemandLine[]; warnings: string[]; unmatchedScopeItems: string[]; } function getUnitCount( item: ScopeItemInput, unitMode: EffortUnitMode, ): number { switch (unitMode) { case "per_frame": return item.frameCount ?? 1; case "per_item": return item.itemCount ?? 1; case "flat": return 1; } } /** * Expand scope items into demand line drafts using effort rules. * * For each scope item, finds all matching rules by scopeType and generates * one demand line per matching rule. Hours = unitCount * hoursPerUnit. * * Rules are matched case-insensitively on scopeType. */ export function expandScopeToEffort( scopeItems: ScopeItemInput[], rules: EffortRuleInput[], ): EffortExpansionResult { const lines: ExpandedDemandLine[] = []; const warnings: string[] = []; const unmatchedScopeItems: string[] = []; if (rules.length === 0) { warnings.push("No effort rules provided."); return { lines, warnings, unmatchedScopeItems }; } // Index rules by normalized scopeType for fast lookup const rulesByScopeType = new Map(); for (const rule of rules) { const key = rule.scopeType.toLowerCase().trim(); const existing = rulesByScopeType.get(key) ?? []; existing.push(rule); rulesByScopeType.set(key, existing); } for (const item of scopeItems) { if (!item.name.trim()) continue; const normalizedType = item.scopeType.toLowerCase().trim(); const matchingRules = rulesByScopeType.get(normalizedType); if (!matchingRules || matchingRules.length === 0) { unmatchedScopeItems.push(item.name); continue; } // Sort by sortOrder for deterministic output const sortedRules = [...matchingRules].sort((a, b) => a.sortOrder - b.sortOrder); for (const rule of sortedRules) { const unitCount = getUnitCount(item, rule.unitMode); const hours = Math.round(unitCount * rule.hoursPerUnit * 100) / 100; if (hours <= 0) { warnings.push( `Skipped "${rule.discipline}" for "${item.name}": computed hours is 0 (unitCount=${unitCount}, hoursPerUnit=${rule.hoursPerUnit}).`, ); continue; } lines.push({ scopeItemName: item.name, scopeType: item.scopeType, discipline: rule.discipline, ...(rule.chapter != null ? { chapter: rule.chapter } : {}), hours, unitMode: rule.unitMode, unitCount, hoursPerUnit: rule.hoursPerUnit, }); } } if (unmatchedScopeItems.length > 0) { warnings.push( `${unmatchedScopeItems.length} scope item(s) had no matching rules: ${unmatchedScopeItems.slice(0, 5).join(", ")}${unmatchedScopeItems.length > 5 ? "..." : ""}.`, ); } return { lines, warnings, unmatchedScopeItems }; } /** * Aggregate expanded lines by discipline, summing hours. * Useful for creating one demand line per discipline instead of per scope item. */ export function aggregateByDiscipline( lines: ExpandedDemandLine[], ): Array<{ discipline: string; chapter?: string | null; totalHours: number; lineCount: number }> { const map = new Map(); for (const line of lines) { const key = `${line.discipline}::${line.chapter ?? ""}`; const existing = map.get(key); if (existing) { existing.totalHours += line.hours; existing.lineCount++; } else { map.set(key, { discipline: line.discipline, ...(line.chapter != null ? { chapter: line.chapter } : {}), totalHours: line.hours, lineCount: 1, }); } } return [...map.values()].sort((a, b) => b.totalHours - a.totalHours); }