feat: decide the staffing split per ski level
This commit is contained in:
1 parent
7aefe88610
commit
f9d2ff084d
8 files changed
+158
-66
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user