diff --git a/migrations/Version20261105100000.php b/migrations/Version20261105100000.php new file mode 100644 index 0000000..83a4dc1 --- /dev/null +++ b/migrations/Version20261105100000.php @@ -0,0 +1,39 @@ +addSql('ALTER TABLE staffing_rule ADD split_beginner TINYINT NOT NULL, ADD split_advanced TINYINT NOT NULL'); + $this->addSql('UPDATE staffing_rule SET split_beginner = split AND ski_beginner, split_advanced = split AND ski_advanced'); + $this->addSql('ALTER TABLE staffing_rule DROP split'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE staffing_rule ADD split TINYINT NOT NULL'); + $this->addSql('UPDATE staffing_rule SET split = split_beginner OR split_advanced'); + $this->addSql('ALTER TABLE staffing_rule DROP split_beginner, DROP split_advanced'); + } +} diff --git a/src/Entity/StaffingRule.php b/src/Entity/StaffingRule.php index 60e7ed5..2c6a5d7 100644 --- a/src/Entity/StaffingRule.php +++ b/src/Entity/StaffingRule.php @@ -55,9 +55,13 @@ class StaffingRule implements BlameableEntityInterface, TimestampableEntityInter #[ORM\Column] private bool $skiAdvanced = false; - /** The Reiseleitungen take over the ski levels, one level per place. */ + /** A Reiseleitung takes over the beginners' course (Reise- und Kursleitung), one place. */ #[ORM\Column] - private bool $split = false; + private bool $splitBeginner = false; + + /** A Reiseleitung takes over the advanced course (Reise- und Kursleitung), one place. */ + #[ORM\Column] + private bool $splitAdvanced = false; #[ORM\Column] private bool $guiding = false; @@ -74,20 +78,26 @@ class StaffingRule implements BlameableEntityInterface, TimestampableEntityInter #[Assert\Callback] public function validateCombination(ExecutionContextInterface $context): void { - $levels = $this->getSkiLevelCount(); - - if (true === $this->split && 0 === $levels) { + if (true === $this->splitBeginner && false === $this->skiBeginner) { $context - ->buildViolation('Ein Split braucht mindestens einen Skikurs') - ->atPath('split') + ->buildViolation('Die Reiseleitung kann nur einen Kurs übernehmen, der angeboten wird') + ->atPath('splitBeginner') ->addViolation() ; } - if (true === $this->split && $levels > (int) $this->guides) { + if (true === $this->splitAdvanced && false === $this->skiAdvanced) { $context - ->buildViolation('Bei einem Split braucht jedes Kursniveau eine eigene Reiseleitung') - ->atPath('split') + ->buildViolation('Die Reiseleitung kann nur einen Kurs übernehmen, der angeboten wird') + ->atPath('splitAdvanced') + ->addViolation() + ; + } + + if ($this->getSplitLevelCount() > (int) $this->guides) { + $context + ->buildViolation('Jeder übernommene Kurs braucht eine eigene Reiseleitung') + ->atPath('guides') ->addViolation() ; } @@ -101,9 +111,9 @@ class StaffingRule implements BlameableEntityInterface, TimestampableEntityInter } } - public function getSkiLevelCount(): int + public function getSplitLevelCount(): int { - return (int) $this->skiBeginner + (int) $this->skiAdvanced; + return (int) $this->splitBeginner + (int) $this->splitAdvanced; } public function getId(): ?int @@ -165,14 +175,26 @@ class StaffingRule implements BlameableEntityInterface, TimestampableEntityInter return $this; } - public function isSplit(): bool + public function isSplitBeginner(): bool { - return $this->split; + return $this->splitBeginner; } - public function setSplit(bool $split): static + public function setSplitBeginner(bool $splitBeginner): static { - $this->split = $split; + $this->splitBeginner = $splitBeginner; + + return $this; + } + + public function isSplitAdvanced(): bool + { + return $this->splitAdvanced; + } + + public function setSplitAdvanced(bool $splitAdvanced): static + { + $this->splitAdvanced = $splitAdvanced; return $this; } diff --git a/src/Form/StaffingRuleType.php b/src/Form/StaffingRuleType.php index 2fd19d6..99afe8c 100644 --- a/src/Form/StaffingRuleType.php +++ b/src/Form/StaffingRuleType.php @@ -31,9 +31,14 @@ class StaffingRuleType extends AbstractType 'label' => 'Ski Fortgeschrittene', 'required' => false, ]) - ->add('split', CheckboxType::class, [ - 'label' => 'Split', - 'help' => 'Die Reiseleitungen übernehmen die Kurse (Reise- und Kursleitung)', + ->add('splitBeginner', CheckboxType::class, [ + 'label' => 'Reiseleitung übernimmt Ski Anfänger:innen', + 'help' => 'Reise- und Kursleitung statt einer eigenen Kursleitung', + 'required' => false, + ]) + ->add('splitAdvanced', CheckboxType::class, [ + 'label' => 'Reiseleitung übernimmt Ski Fortgeschrittene', + 'help' => 'Reise- und Kursleitung statt einer eigenen Kursleitung', 'required' => false, ]) ->add('guiding', CheckboxType::class, [ diff --git a/src/Service/Assignment/AssignmentPlanner.php b/src/Service/Assignment/AssignmentPlanner.php index 828ad07..b740212 100644 --- a/src/Service/Assignment/AssignmentPlanner.php +++ b/src/Service/Assignment/AssignmentPlanner.php @@ -14,9 +14,10 @@ use App\Service\Common\PickupOptions; * * The rules, agreed with the stakeholders on 2026-10-15: * - one assignment per role, the role's head count as its places; - * - without split, each ski level is a Kursleitung of its own, one place; - * - with split, a Reiseleitung takes over each level through the combined profile, one place - * per level, and the plain Reiseleitung keeps the remaining places; + * - each ski level is a Kursleitung of its own, one place, unless the matrix has a Reiseleitung + * take it over: then it is the combined profile, one place, and the plain Reiseleitung keeps + * the remaining places. Decided per level (2026-11-05), so one Reiseleitung can take over one + * level while the other stays a Kursleitung; * - a date with bus travel (boarding points) gets its first boarding point pre-selected as * Busbegleitung on exactly one assignment, the first guide-type one - the plain Reiseleitung * where there is one. Taken from the date, not the matrix: bus travel varies between the @@ -32,39 +33,33 @@ final class AssignmentPlanner */ public function plan(StaffingRule $rule, Destination $destination): array { - $roles = []; - $guides = (int) $rule->getGuides(); + $combined = []; + $courses = []; - if (true === $rule->isSplit()) { - $combined = []; - - if (true === $rule->isSkiBeginner()) { - $combined[] = StaffingRole::GUIDE_SKI_BEGINNER; - } - - if (true === $rule->isSkiAdvanced()) { - $combined[] = StaffingRole::GUIDE_SKI_ADVANCED; - } - - // The matrix validation refuses more levels than guides; max() keeps a row that - // slipped past it from planning a negative head count. - $roles[] = [StaffingRole::GUIDE, max(0, $guides - count($combined))]; - - foreach ($combined as $role) { - $roles[] = [$role, 1]; - } - } else { - $roles[] = [StaffingRole::GUIDE, $guides]; - - if (true === $rule->isSkiBeginner()) { - $roles[] = [StaffingRole::SKI_BEGINNER, 1]; - } - - if (true === $rule->isSkiAdvanced()) { - $roles[] = [StaffingRole::SKI_ADVANCED, 1]; + if (true === $rule->isSkiBeginner()) { + if (true === $rule->isSplitBeginner()) { + $combined[] = [StaffingRole::GUIDE_SKI_BEGINNER, 1]; + } else { + $courses[] = [StaffingRole::SKI_BEGINNER, 1]; } } + if (true === $rule->isSkiAdvanced()) { + if (true === $rule->isSplitAdvanced()) { + $combined[] = [StaffingRole::GUIDE_SKI_ADVANCED, 1]; + } else { + $courses[] = [StaffingRole::SKI_ADVANCED, 1]; + } + } + + // The matrix validation refuses more taken-over levels than guides; max() keeps a row + // that slipped past it from planning a negative head count. + $roles = [ + [StaffingRole::GUIDE, max(0, (int) $rule->getGuides() - count($combined))], + ...$combined, + ...$courses, + ]; + $pickup = $this->firstPickup($destination); $guiding = $rule->isGuiding(); $planned = []; diff --git a/templates/admin/system/staffing_rule/_form.html.twig b/templates/admin/system/staffing_rule/_form.html.twig index 76f88f0..402480a 100644 --- a/templates/admin/system/staffing_rule/_form.html.twig +++ b/templates/admin/system/staffing_rule/_form.html.twig @@ -3,8 +3,9 @@ {{ form_row(form.productCode) }} {{ form_row(form.guides) }} {{ form_row(form.skiBeginner) }} + {{ form_row(form.splitBeginner) }} {{ form_row(form.skiAdvanced) }} - {{ form_row(form.split) }} + {{ form_row(form.splitAdvanced) }} {{ form_row(form.guiding) }} {{ form_row(form.enabled) }} diff --git a/templates/admin/system/staffing_rule/index.html.twig b/templates/admin/system/staffing_rule/index.html.twig index 80d61ba..8c03730 100644 --- a/templates/admin/system/staffing_rule/index.html.twig +++ b/templates/admin/system/staffing_rule/index.html.twig @@ -40,7 +40,12 @@