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:
1 parent
209bf05f36
commit
6811142299
45 files changed
+8795
-3075
No files matched your search
@@ -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 ?? '';
|
||||
|
||||
Reference in new issue
Block a user