chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user