feat: decide the staffing split per ski level

This commit is contained in:
fromme committed 2026-11-05 17:04:24 +01:00
1 parent 7aefe88610
commit f9d2ff084d
8 files changed
+158 -66

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Replaces the staffing matrix's split flag with one flag per ski level.
*
* A split row takes over every level it has, so it keeps planning the same assignments. The way
* back folds the two flags into one, which loses a row that takes over one level of two.
*
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
* pre-existing drift of the dev database.
*/
final class Version20261105100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace staffing_rule.split with split_beginner / split_advanced';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+38 -16
View File
@@ -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;
}
+8 -3
View File
@@ -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, [
+27 -32
View File
@@ -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 = [];
@@ -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) }}
</div>
@@ -40,7 +40,12 @@
<td>{{ rule.guides }}</td>
<td>{{ _self.yes_no(rule.skiBeginner) }}</td>
<td>{{ _self.yes_no(rule.skiAdvanced) }}</td>
<td>{{ _self.yes_no(rule.split) }}</td>
<td>
{%- set split = [] -%}
{%- if rule.splitBeginner %}{% set split = split|merge(['Anfänger:innen']) %}{% endif -%}
{%- if rule.splitAdvanced %}{% set split = split|merge(['Fortgeschrittene']) %}{% endif -%}
{{- split is empty ? _self.yes_no(false) : split|join(', ') -}}
</td>
<td>{{ _self.yes_no(rule.guiding) }}</td>
<td>{{ _self.yes_no(rule.enabled) }}</td>
<td>
+8 -5
View File
@@ -35,9 +35,10 @@ class StaffingRuleTest extends TestCase
public static function combinations(): iterable
{
yield 'guides only' => [self::rule(2), []];
yield 'split with a level per guide' => [self::rule(2, beginner: true, advanced: true, split: true), []];
yield 'split with more levels than guides' => [self::rule(1, beginner: true, advanced: true, split: true), ['split']];
yield 'split without a level' => [self::rule(2, split: true), ['split']];
yield 'both levels taken over, a guide each' => [self::rule(2, beginner: true, advanced: true, splitBeginner: true, splitAdvanced: true), []];
yield 'one guide takes over one of two levels' => [self::rule(1, beginner: true, advanced: true, splitAdvanced: true), []];
yield 'more levels taken over than guides' => [self::rule(1, beginner: true, advanced: true, splitBeginner: true, splitAdvanced: true), ['guides']];
yield 'taking over a level that is off' => [self::rule(2, advanced: true, splitBeginner: true), ['splitBeginner']];
yield 'guiding without a guide' => [self::rule(0, beginner: true, guiding: true), ['guiding']];
yield 'courses without guides' => [self::rule(0, beginner: true), []];
}
@@ -46,7 +47,8 @@ class StaffingRuleTest extends TestCase
int $guides,
bool $beginner = false,
bool $advanced = false,
bool $split = false,
bool $splitBeginner = false,
bool $splitAdvanced = false,
bool $guiding = false,
): StaffingRule {
return (new StaffingRule())
@@ -54,7 +56,8 @@ class StaffingRuleTest extends TestCase
->setGuides($guides)
->setSkiBeginner($beginner)
->setSkiAdvanced($advanced)
->setSplit($split)
->setSplitBeginner($splitBeginner)
->setSplitAdvanced($splitAdvanced)
->setGuiding($guiding)
;
}
@@ -37,7 +37,7 @@ class AssignmentPlannerTest extends TestCase
public function testSplitWithOneLevelKeepsTheRemainingGuides(): void
{
$planned = $this->plan(self::rule(guides: 2, advanced: true, split: true));
$planned = $this->plan(self::rule(guides: 2, advanced: true, splitAdvanced: true));
$this->assertSame([
[StaffingRole::GUIDE, 1, null, []],
@@ -48,7 +48,7 @@ class AssignmentPlannerTest extends TestCase
public function testSplitWithBothLevelsUsesUpTwoGuides(): void
{
// decision 3: two single-level combined profiles, never the A+F one
$planned = $this->plan(self::rule(guides: 2, beginner: true, advanced: true, split: true));
$planned = $this->plan(self::rule(guides: 2, beginner: true, advanced: true, splitBeginner: true, splitAdvanced: true));
$this->assertSame([
[StaffingRole::GUIDE_SKI_BEGINNER, 1, null, [StaffingFee::COURSE]],
@@ -56,9 +56,29 @@ class AssignmentPlannerTest extends TestCase
], self::summarize($planned));
}
public function testOneGuideTakesOverOneLevelAndTheOtherStaysAKursleitung(): void
{
$planned = $this->plan(self::rule(guides: 1, beginner: true, advanced: true, splitAdvanced: true, guiding: true), self::busTravel());
$this->assertSame([
[StaffingRole::GUIDE_SKI_ADVANCED, 1, 5, [StaffingFee::GUIDING, StaffingFee::COURSE]],
[StaffingRole::SKI_BEGINNER, 1, null, [StaffingFee::COURSE]],
], self::summarize($planned));
}
public function testATakeOverOfALevelThatIsOffIsIgnored(): void
{
$planned = $this->plan(self::rule(guides: 1, advanced: true, splitBeginner: true));
$this->assertSame([
[StaffingRole::GUIDE, 1, null, []],
[StaffingRole::SKI_ADVANCED, 1, null, [StaffingFee::COURSE]],
], self::summarize($planned));
}
public function testGuidingIsAFeeOnTheFirstGuideTypeAssignmentOnly(): void
{
$planned = $this->plan(self::rule(guides: 3, beginner: true, advanced: true, split: true, guiding: true));
$planned = $this->plan(self::rule(guides: 3, beginner: true, advanced: true, splitBeginner: true, splitAdvanced: true, guiding: true));
$this->assertSame([
[StaffingRole::GUIDE, 1, null, [StaffingFee::GUIDING]],
@@ -79,7 +99,7 @@ class AssignmentPlannerTest extends TestCase
public function testWithoutAPlainReiseleitungTheFirstCombinedOneGuides(): void
{
$planned = $this->plan(self::rule(guides: 2, beginner: true, advanced: true, split: true, guiding: true));
$planned = $this->plan(self::rule(guides: 2, beginner: true, advanced: true, splitBeginner: true, splitAdvanced: true, guiding: true));
$this->assertSame([
[StaffingRole::GUIDE_SKI_BEGINNER, 1, null, [StaffingFee::GUIDING, StaffingFee::COURSE]],
@@ -112,7 +132,7 @@ class AssignmentPlannerTest extends TestCase
public function testTheBusEscortIsSetOnExactlyOneAssignment(): void
{
$planned = $this->plan(self::rule(guides: 3, beginner: true, split: true), self::busTravel());
$planned = $this->plan(self::rule(guides: 3, beginner: true, splitBeginner: true), self::busTravel());
$this->assertSame([
[StaffingRole::GUIDE, 2, 5, []],
@@ -122,7 +142,7 @@ class AssignmentPlannerTest extends TestCase
public function testWithoutAPlainReiseleitungTheFirstCombinedOneEscortsTheBus(): void
{
$planned = $this->plan(self::rule(guides: 2, beginner: true, advanced: true, split: true), self::busTravel());
$planned = $this->plan(self::rule(guides: 2, beginner: true, advanced: true, splitBeginner: true, splitAdvanced: true), self::busTravel());
$this->assertSame(5, $planned[0]->pickup);
$this->assertNull($planned[1]->pickup);
@@ -172,7 +192,8 @@ class AssignmentPlannerTest extends TestCase
int $guides,
bool $beginner = false,
bool $advanced = false,
bool $split = false,
bool $splitBeginner = false,
bool $splitAdvanced = false,
bool $guiding = false,
): StaffingRule {
return (new StaffingRule())
@@ -180,7 +201,8 @@ class AssignmentPlannerTest extends TestCase
->setGuides($guides)
->setSkiBeginner($beginner)
->setSkiAdvanced($advanced)
->setSplit($split)
->setSplitBeginner($splitBeginner)
->setSplitAdvanced($splitAdvanced)
->setGuiding($guiding)
;
}