161 lines
4.5 KiB
TypeScript
161 lines
4.5 KiB
TypeScript
/**
|
|
* 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<string, EffortRuleInput[]>();
|
|
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<string, { discipline: string; chapter?: string | null; totalHours: number; lineCount: number }>();
|
|
|
|
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);
|
|
}
|