feat(planning): make optimization rules composable

Evaluate explicit, ordered optimization configurations through one shared
planner model. Add six editable presets, durable one-time legacy migration,
and consistent completed and cancelled plan provenance.

Include functional regressions and the agreed trace-removal and benchmark
follow-up specs. Performance acceptance remains in #78 under unchanged gates.

Refs: #70, #71, #72, #73, #74, #75, #76, #77, #78
This commit is contained in:
nessar committed 2026-10-10 16:07:34 +02:00
1 parent 209bf05f36
commit 6811142299
45 files changed
+8795 -3075

No files matched your search

+106 -5
View File
@@ -11,6 +11,7 @@ import {
RuleType,
IfThenRule,
TimeWindowRule,
OptimizationRule,
VacationRule,
PlacementStrategy,
} from '../../services/rule-service';
@@ -25,7 +26,15 @@ import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { FormsModule } from '@angular/forms';
import { NgTemplateOutlet } from '@angular/common';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import {
OPTIMIZATION_CATALOG,
OPTIMIZATION_PRESETS,
validateOptimizationConfiguration,
type OptimizationElements,
type OptimizationMeasure,
} from '../../services/planning-objectives';
import { DateAdapter } from '@angular/material/core';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
@@ -43,6 +52,7 @@ import { CdkDragDrop, CdkDropList, CdkDrag, CdkDragHandle } from '@angular/cdk/d
MatSelectModule,
MatDatepickerModule,
FormsModule,
NgTemplateOutlet,
CdkDropList,
CdkDrag,
CdkDragHandle,
@@ -74,10 +84,67 @@ export class RulesPanel {
protected readonly rewardDayTypes = Object.values(SelectableDayType).filter(
(t) => t !== SelectableDayType.ANY,
);
protected readonly forceOptions = [
{ value: 'obligation' as const, label: () => this.tr().rules.obligation },
{ value: 'preference' as const, label: () => this.tr().rules.preference },
protected get forceOptions() {
const preference = {
value: 'preference' as const,
label: () => this.tr().rules.preference,
};
return this.editBuffer.type === RuleType.OPTIMIZATION
? [preference]
: [
{ value: 'obligation' as const, label: () => this.tr().rules.obligation },
preference,
];
}
protected readonly optimizationElements = [
...new Set(OPTIMIZATION_CATALOG.map((row) => row.elements)),
];
protected get optimizationConfiguration() {
return this.editBuffer.type === RuleType.OPTIMIZATION
? this.editBuffer.configuration
: undefined;
}
protected get optimizationMeasures() {
return OPTIMIZATION_CATALOG.filter(
(row) => row.elements === this.optimizationConfiguration?.elements,
);
}
protected get optimizationConditions() {
return (
this.optimizationMeasures.find(
(row) => row.measure === this.optimizationConfiguration?.measure,
)?.conditions ?? []
);
}
protected readonly optimizationAggregations = ['sum', 'count', 'maximum'] as const;
protected readonly optimizationDirections = ['minimize', 'maximize'] as const;
protected validationError = signal('');
public changeOptimizationElements(elements: OptimizationElements) {
this.changeOptimizationMeasure(
OPTIMIZATION_CATALOG.find((row) => row.elements === elements)!.measure,
);
}
public changeOptimizationMeasure(measure: OptimizationMeasure) {
if (this.editBuffer.type !== RuleType.OPTIMIZATION) return;
const row = OPTIMIZATION_CATALOG.find((item) => item.measure === measure)!;
const previous = this.editBuffer.configuration;
const conditions: readonly string[] = row.conditions;
this.editBuffer.configuration = {
elements: row.elements,
conditions:
previous?.conditions.filter((condition) => conditions.includes(condition)) ?? [],
aggregation: previous?.aggregation ?? 'sum',
direction: previous?.direction ?? 'minimize',
...(measure === 'work-sequence-deficit'
? {
measure,
thresholdDays: previous?.measure === measure ? previous.thresholdDays : 5,
}
: { measure }),
};
}
protected get daysOfWeek() {
const tr = this.tr();
@@ -128,9 +195,20 @@ export class RulesPanel {
public moveRule(index: number, offset: -1 | 1, event: Event) {
event.stopPropagation();
const trigger = event.currentTarget;
const destination = index + offset;
if (destination >= 0 && destination < this.rules.length)
if (destination >= 0 && destination < this.rules.length) {
this.reorderRule(index, destination);
this.changeDetector.detectChanges();
if (trigger instanceof HTMLButtonElement) {
const next = trigger.disabled
? trigger.parentElement?.querySelector<HTMLButtonElement>(
'button:not([disabled])',
)
: trigger;
next?.focus();
}
}
}
private reorderRule(from: number, to: number) {
@@ -152,16 +230,29 @@ export class RulesPanel {
event.stopPropagation();
this.editingRuleId.set(rule.id);
this.isAddingNew.set(false);
this.editBuffer = { ...rule };
this.editBuffer = structuredClone(rule);
this.validationError.set('');
}
public cancelEdit() {
this.editingRuleId.set(null);
this.isAddingNew.set(false);
this.editBuffer = {};
this.validationError.set('');
}
public saveEdit() {
if (!this.editBuffer.name?.trim()) {
this.validationError.set(this.tr().rules.optimization.nameRequired);
return;
}
if (this.editBuffer.type === RuleType.OPTIMIZATION) {
if (!validateOptimizationConfiguration(this.editBuffer.configuration).valid) {
this.validationError.set(this.tr().rules.optimization.invalidConfiguration);
return;
}
this.editBuffer.force = 'preference';
}
if (this.editBuffer.id) {
this.ruleService.updateRule(this.editBuffer as VacationRule);
} else {
@@ -183,6 +274,7 @@ export class RulesPanel {
this.isAddingNew.set(true);
this.editingRuleId.set(null);
this.newRuleType.set(type);
this.validationError.set('');
if (type === RuleType.IF_THEN) {
this.editBuffer = {
@@ -196,6 +288,13 @@ export class RulesPanel {
rewardDays: 1,
rewardType: SelectableDayType.OTHER,
} as Partial<IfThenRule>;
} else if (type === RuleType.OPTIMIZATION) {
this.editBuffer = {
name: this.tr().rules.optimization.defaultName,
type,
force: 'preference',
configuration: structuredClone(OPTIMIZATION_PRESETS.unplacedAtRisk),
} satisfies Partial<OptimizationRule>;
} else {
this.editBuffer = {
name: this.tr().rules.defaultNewPeriodName,
@@ -216,6 +315,8 @@ export class RulesPanel {
public getRuleSummary(rule: VacationRule): string {
const tr = this.tr();
if (rule.type === RuleType.OPTIMIZATION)
return this.translationService.optimizationSummary(rule.configuration);
if (rule.type === RuleType.IF_THEN) {
const ifThen = rule as IfThenRule;
const dayLabel = this.daysOfWeek.find((d) => d.value === ifThen.dayOfWeek)?.label ?? '';