feat: generate draft assignments from a staffing matrix

This commit is contained in:
fromme committed 2026-10-15 18:11:51 +02:00
1 parent 3116d23199
commit 2fc9806c69
41 files changed
+2618

No files matched your search

@@ -0,0 +1,101 @@
<?php
namespace App\Service\Assignment;
use App\Entity\Destination;
use App\Entity\StaffingRule;
use App\Enum\StaffingFee;
use App\Enum\StaffingRole;
use App\Model\PlannedAssignment;
use App\Service\Common\PickupOptions;
/**
* Turns a staffing matrix row into the draft assignments for one travel date.
*
* The rules, agreed 2026-10-15 (see .ddev/plans/teamer-auto-assignments-plan.md):
* - 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;
* - Guiding is the guiding fee on every guide-type assignment, not an assignment of its own;
* - Busbegleitung pre-selects the first boarding point on exactly one assignment, the first
* guide-type one - the plain Reiseleitung where there is one.
*/
final class AssignmentPlanner
{
/**
* @return list<PlannedAssignment>
*/
public function plan(StaffingRule $rule, Destination $destination): array
{
$roles = [];
$guides = (int) $rule->getGuides();
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];
}
}
$pickup = true === $rule->isBusEscort() ? $this->firstPickup($destination) : null;
$planned = [];
foreach ($roles as [$role, $places]) {
if (0 === $places) {
continue;
}
$fees = [];
if (true === $role->isGuide() && true === $rule->isGuiding()) {
$fees[] = StaffingFee::GUIDING;
}
if (true === $role->isCourse()) {
$fees[] = StaffingFee::COURSE;
}
$planned[] = new PlannedAssignment(
$role,
$places,
true === $role->isGuide() ? $pickup : null,
$fees,
);
if (true === $role->isGuide()) {
$pickup = null;
}
}
return $planned;
}
private function firstPickup(Destination $destination): ?int
{
return PickupOptions::inDepartureOrder($destination)[0]['busProId'] ?? null;
}
}