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,174 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Assignment;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\Fee;
use App\Entity\JobProfile;
use App\Entity\StaffingRule;
use App\Enum\StaffingFee;
use App\Enum\StaffingRole;
use App\Model\PlannedAssignment;
use App\Model\StaffingCandidate;
use App\Repository\AssignmentRepository;
use App\Repository\DestinationRepository;
use App\Repository\StaffingRuleRepository;
use App\Service\Assignment\AssignmentGenerator;
use App\Service\Assignment\AssignmentPlanner;
use App\Service\Assignment\StaffingResolver;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
class AssignmentGeneratorTest extends TestCase
{
public function testThePreviewSeesDisabledRowsAndTheCommandDoesNot(): void
{
$enabled = self::rule('DKIMP', enabled: true);
$disabled = self::rule('DGSMP', enabled: false);
$asked = [];
$destinationRepository = $this->createStub(DestinationRepository::class);
$destinationRepository->method('getStaffingCandidates')->willReturnCallback(
static function (array $productCodes) use (&$asked): array {
$asked[] = $productCodes;
return [];
}
);
$generator = $this->generator([$enabled, $disabled], $destinationRepository);
$generator->getCandidates(enabledOnly: false);
$generator->getCandidates(enabledOnly: true);
$this->assertSame([['DKIMP', 'DGSMP'], ['DKIMP']], $asked);
}
public function testADateWithAnAssignmentIsMarkedSkipped(): void
{
$staffed = self::destination(1, 'DKIMP');
$open = self::destination(2, 'DKIMP');
$destinationRepository = $this->createStub(DestinationRepository::class);
$destinationRepository->method('getStaffingCandidates')->willReturn([$staffed, $open]);
$assignmentRepository = $this->createStub(AssignmentRepository::class);
$assignmentRepository->method('getDestinationIdsWithAnyAssignment')->willReturn([1]);
$candidates = $this
->generator([self::rule('DKIMP', enabled: true)], $destinationRepository, $assignmentRepository)
->getCandidates(enabledOnly: true)
;
$this->assertTrue($candidates[0]->skipped);
$this->assertFalse($candidates[1]->skipped);
}
public function testGenerateBuildsDraftsFromThePlan(): void
{
$destination = self::destination(1, 'DKIMP');
$guide = new JobProfile();
$course = new JobProfile();
$guidingFee = new Fee();
$courseFee = new Fee();
$resolver = $this->createStub(StaffingResolver::class);
$resolver->method('jobProfile')->willReturnCallback(
static fn (StaffingRole $role): JobProfile => StaffingRole::GUIDE === $role ? $guide : $course
);
$resolver->method('fee')->willReturnCallback(
static fn (StaffingFee $fee): Fee => StaffingFee::GUIDING === $fee ? $guidingFee : $courseFee
);
$persisted = [];
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
$persisted[] = $entity;
});
$entityManager->expects($this->once())->method('flush');
$generator = $this->generator([], resolver: $resolver, entityManager: $entityManager);
$assignments = $generator->generate(new StaffingCandidate($destination, self::rule('DKIMP', enabled: true), [
new PlannedAssignment(StaffingRole::GUIDE, 2, 5, [StaffingFee::GUIDING]),
new PlannedAssignment(StaffingRole::SKI_BEGINNER, 1, null, [StaffingFee::COURSE]),
], false));
$this->assertSame($persisted, $assignments);
$this->assertCount(2, $assignments);
$this->assertSame($destination, $assignments[0]->getDestination());
$this->assertSame($guide, $assignments[0]->getJobProfile());
$this->assertSame(2, $assignments[0]->getAvailableDispositions());
$this->assertSame(5, $assignments[0]->getPickup());
$this->assertSame([$guidingFee], $assignments[0]->getFees()->toArray());
$this->assertSame(Assignment::STATUS_DRAFT, $assignments[0]->getStatus());
$this->assertSame($course, $assignments[1]->getJobProfile());
$this->assertSame(0, $assignments[1]->getPickup());
$this->assertSame([$courseFee], $assignments[1]->getFees()->toArray());
}
public function testASkippedCandidateGeneratesNothing(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects($this->never())->method('persist');
$entityManager->expects($this->never())->method('flush');
$assignments = $this->generator([], entityManager: $entityManager)->generate(new StaffingCandidate(
self::destination(1, 'DKIMP'),
self::rule('DKIMP', enabled: true),
[new PlannedAssignment(StaffingRole::GUIDE, 1)],
true,
));
$this->assertSame([], $assignments);
}
/**
* @param list<StaffingRule> $rules
*/
private function generator(
array $rules,
?DestinationRepository $destinationRepository = null,
?AssignmentRepository $assignmentRepository = null,
?StaffingResolver $resolver = null,
?EntityManagerInterface $entityManager = null,
): AssignmentGenerator {
$staffingRuleRepository = $this->createStub(StaffingRuleRepository::class);
$staffingRuleRepository->method('getList')->willReturn($rules);
if (null === $assignmentRepository) {
$assignmentRepository = $this->createStub(AssignmentRepository::class);
$assignmentRepository->method('getDestinationIdsWithAnyAssignment')->willReturn([]);
}
return new AssignmentGenerator(
$staffingRuleRepository,
$destinationRepository ?? $this->createStub(DestinationRepository::class),
$assignmentRepository,
new AssignmentPlanner(),
$resolver ?? $this->createStub(StaffingResolver::class),
$entityManager ?? $this->createStub(EntityManagerInterface::class),
new NullLogger(),
);
}
private static function rule(string $productCode, bool $enabled): StaffingRule
{
return (new StaffingRule())->setProductCode($productCode)->setGuides(1)->setEnabled($enabled);
}
private static function destination(int $id, string $productCode): Destination
{
$destination = (new Destination())->setProductCode($productCode)->setCode($productCode.'110127');
(new \ReflectionProperty(Destination::class, 'id'))->setValue($destination, $id);
return $destination;
}
}
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Tests\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\Assignment\AssignmentPlanner;
use PHPUnit\Framework\TestCase;
/**
* One test per row of the planner table in .ddev/plans/teamer-auto-assignments-plan.md.
*/
class AssignmentPlannerTest extends TestCase
{
public function testGuidesOnlyIsOneAssignmentWithAPlacePerGuide(): void
{
$planned = $this->plan(self::rule(guides: 2));
$this->assertSame([[StaffingRole::GUIDE, 2, null, []]], self::summarize($planned));
}
public function testWithoutSplitEachLevelIsAKursleitungOfItsOwn(): void
{
$planned = $this->plan(self::rule(guides: 2, beginner: true, advanced: true));
$this->assertSame([
[StaffingRole::GUIDE, 2, null, []],
[StaffingRole::SKI_BEGINNER, 1, null, [StaffingFee::COURSE]],
[StaffingRole::SKI_ADVANCED, 1, null, [StaffingFee::COURSE]],
], self::summarize($planned));
}
public function testSplitWithOneLevelKeepsTheRemainingGuides(): void
{
$planned = $this->plan(self::rule(guides: 2, advanced: true, split: true));
$this->assertSame([
[StaffingRole::GUIDE, 1, null, []],
[StaffingRole::GUIDE_SKI_ADVANCED, 1, null, [StaffingFee::COURSE]],
], self::summarize($planned));
}
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));
$this->assertSame([
[StaffingRole::GUIDE_SKI_BEGINNER, 1, null, [StaffingFee::COURSE]],
[StaffingRole::GUIDE_SKI_ADVANCED, 1, null, [StaffingFee::COURSE]],
], self::summarize($planned));
}
public function testGuidingIsAFeeOnEveryGuideTypeAssignment(): void
{
$planned = $this->plan(self::rule(guides: 3, beginner: true, advanced: true, split: true, guiding: true));
$this->assertSame([
[StaffingRole::GUIDE, 1, null, [StaffingFee::GUIDING]],
[StaffingRole::GUIDE_SKI_BEGINNER, 1, null, [StaffingFee::GUIDING, StaffingFee::COURSE]],
[StaffingRole::GUIDE_SKI_ADVANCED, 1, null, [StaffingFee::GUIDING, StaffingFee::COURSE]],
], self::summarize($planned));
}
public function testGuidingStaysOffThePlainKursleitung(): void
{
$planned = $this->plan(self::rule(guides: 1, beginner: true, guiding: true));
$this->assertSame([
[StaffingRole::GUIDE, 1, null, [StaffingFee::GUIDING]],
[StaffingRole::SKI_BEGINNER, 1, null, [StaffingFee::COURSE]],
], self::summarize($planned));
}
public function testTheBusEscortTakesTheFirstBoardingPointInDepartureOrder(): void
{
// stored order is not departure order: Göttingen leaves before Kassel
$destination = self::destination([
['busProId' => 7, 'city' => 'Kassel', 'street' => null, 'time' => '00:30'],
['busProId' => 5, 'city' => 'Göttingen', 'street' => null, 'time' => '08.03.2024 22:00:00'],
]);
$planned = $this->plan(self::rule(guides: 2, busEscort: true), $destination);
$this->assertSame(5, $planned[0]->pickup);
}
public function testTheBusEscortIsSetOnExactlyOneAssignment(): void
{
$planned = $this->plan(self::rule(guides: 3, beginner: true, split: true, busEscort: true));
$this->assertSame([
[StaffingRole::GUIDE, 2, 5, []],
[StaffingRole::GUIDE_SKI_BEGINNER, 1, null, [StaffingFee::COURSE]],
], self::summarize($planned));
}
public function testWithoutAPlainReiseleitungTheFirstCombinedOneEscortsTheBus(): void
{
$planned = $this->plan(self::rule(guides: 2, beginner: true, advanced: true, split: true, busEscort: true));
$this->assertSame(5, $planned[0]->pickup);
$this->assertNull($planned[1]->pickup);
}
public function testNoBusEscortWithoutBoardingPoints(): void
{
$planned = $this->plan(self::rule(guides: 1, busEscort: true), self::destination([]));
$this->assertNull($planned[0]->pickup);
}
public function testNoBusEscortWhenTheMatrixSaysNo(): void
{
$planned = $this->plan(self::rule(guides: 1));
$this->assertNull($planned[0]->pickup);
}
/**
* @return list<PlannedAssignment>
*/
private function plan(StaffingRule $rule, ?Destination $destination = null): array
{
return (new AssignmentPlanner())->plan($rule, $destination ?? self::destination([
['busProId' => 5, 'city' => 'Göttingen', 'street' => null, 'time' => '08.03.2024 22:00:00'],
]));
}
/**
* @param list<PlannedAssignment> $planned
*/
private static function summarize(array $planned): array
{
return array_map(
static fn (PlannedAssignment $p): array => [$p->role, $p->places, $p->pickup, $p->fees],
$planned,
);
}
private static function rule(
int $guides,
bool $beginner = false,
bool $advanced = false,
bool $split = false,
bool $guiding = false,
bool $busEscort = false,
): StaffingRule {
return (new StaffingRule())
->setProductCode('DKIMP')
->setGuides($guides)
->setSkiBeginner($beginner)
->setSkiAdvanced($advanced)
->setSplit($split)
->setGuiding($guiding)
->setBusEscort($busEscort)
;
}
private static function destination(array $pickups): Destination
{
return (new Destination())->setPickups($pickups);
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Assignment;
use App\Entity\Destination;
use App\Entity\StaffingRule;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
/**
* Rows for running the staffing matrix against db_test. The job profiles and fees are written
* with the ids config/services.yaml configures, because StaffingResolver looks them up by id.
*/
trait StaffingFixturesTrait
{
private function resetStaffingTables(Connection $connection): void
{
$connection->executeStatement('DELETE FROM assignment_fee');
$connection->executeStatement('DELETE FROM assignment');
$connection->executeStatement('DELETE FROM destination');
$connection->executeStatement('DELETE FROM staffing_rule');
$connection->executeStatement('DELETE FROM job_profile_training WHERE job_profile_id IN (3, 5, 14, 20, 21)');
$connection->executeStatement('DELETE FROM job_profile WHERE id IN (3, 5, 14, 20, 21)');
$connection->executeStatement('DELETE FROM fee WHERE id IN (3, 4)');
foreach ([
5 => 'Reiseleitung',
3 => 'Kursleitung Ski Anfänger:innen',
14 => 'Kursleitung Ski Fortgeschrittene',
20 => 'Reise- und Kursleitung Ski Anfänger:innen',
21 => 'Reise- und Kursleitung Ski Fortgeschrittene',
] as $id => $name) {
$connection->insert('job_profile', [
'id' => $id,
'uuid' => sprintf('00000000-0000-4000-8000-%012d', $id),
'name' => $name,
'required_licenses' => '[]',
'created_at' => '2026-01-01 00:00:00',
]);
}
foreach ([3 => 'Skigebietsguiding (pro Einheit)', 4 => 'Ski-/Snowboardkurs (pro Stunde)'] as $id => $name) {
$connection->insert('fee', [
'id' => $id,
'value' => 10000,
'name' => $name,
'name_internal' => $name,
'created_at' => '2026-01-01 00:00:00',
]);
}
}
private function staffingRule(EntityManagerInterface $entityManager, string $productCode, bool $enabled = true): StaffingRule
{
$rule = (new StaffingRule())
->setProductCode($productCode)
->setGuides(2)
->setSkiBeginner(true)
->setGuiding(true)
->setBusEscort(true)
->setEnabled($enabled)
;
$entityManager->persist($rule);
return $rule;
}
private function staffingDestination(EntityManagerInterface $entityManager, string $code, string $productCode): Destination
{
$destination = (new Destination())
->setCode($code)
->setProductCode($productCode)
->setDateFrom(new \DateTimeImmutable('+1 month'))
->setDateTo(new \DateTimeImmutable('+1 month +5 days'))
->setProduct('Davos Klosters - Schwendi')
->setHotel('Schwendi')
->setHotelCode('DKI')
->setHotelBusProId(1)
->setPickups([
['busProId' => 7, 'city' => 'Kassel', 'street' => null, 'time' => '00:30', 'datetime' => null],
['busProId' => 5, 'city' => 'Göttingen', 'street' => null, 'time' => '22:00', 'datetime' => '2026-11-15T22:00:00+01:00'],
])
->setImportedAt(new \DateTimeImmutable('today 03:20'))
;
$entityManager->persist($destination);
return $destination;
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Assignment;
use App\Entity\Fee;
use App\Entity\JobProfile;
use App\Enum\StaffingFee;
use App\Enum\StaffingRole;
use App\Repository\FeeRepository;
use App\Repository\JobProfileRepository;
use App\Service\Assignment\StaffingConfigurationException;
use App\Service\Assignment\StaffingResolver;
use PHPUnit\Framework\TestCase;
class StaffingResolverTest extends TestCase
{
public function testAConfiguredRoleResolvesToItsJobProfile(): void
{
$jobProfile = new JobProfile();
$resolver = $this->resolver(jobProfiles: [5 => $jobProfile]);
$this->assertSame($jobProfile, $resolver->jobProfile(StaffingRole::GUIDE));
}
public function testAnUnconfiguredRoleIsRefused(): void
{
$this->expectException(StaffingConfigurationException::class);
$this->expectExceptionMessage('auto_assignment_job_profiles.ski_beginner');
$this->resolver()->jobProfile(StaffingRole::SKI_BEGINNER);
}
public function testADeletedJobProfileIsRefused(): void
{
$this->expectException(StaffingConfigurationException::class);
$this->resolver(jobProfiles: [5 => (new JobProfile())->setDeleted()])->jobProfile(StaffingRole::GUIDE);
}
public function testAMissingFeeIsRefused(): void
{
$this->expectException(StaffingConfigurationException::class);
$this->expectExceptionMessage('auto_assignment_fees.course');
$this->resolver(fees: [3 => new Fee()])->fee(StaffingFee::COURSE);
}
public function testAssertConfiguredChecksEveryRoleAndFee(): void
{
$jobProfiles = [];
foreach ([5, 3, 14, 20] as $id) {
$jobProfiles[$id] = new JobProfile();
}
// guide_ski_advanced (21) is missing
$this->expectException(StaffingConfigurationException::class);
$this->expectExceptionMessage('guide_ski_advanced');
$this->resolver(jobProfiles: $jobProfiles, fees: [3 => new Fee(), 4 => new Fee()])->assertConfigured();
}
/**
* @param array<int, JobProfile> $jobProfiles
* @param array<int, Fee> $fees
*/
private function resolver(array $jobProfiles = [], array $fees = []): StaffingResolver
{
$jobProfileRepository = $this->createStub(JobProfileRepository::class);
$jobProfileRepository->method('find')->willReturnCallback(static fn ($id) => $jobProfiles[$id] ?? null);
$feeRepository = $this->createStub(FeeRepository::class);
$feeRepository->method('find')->willReturnCallback(static fn ($id) => $fees[$id] ?? null);
return new StaffingResolver(
$jobProfileRepository,
$feeRepository,
['guide' => 5, 'ski_beginner' => 3, 'ski_advanced' => 14, 'guide_ski_beginner' => 20, 'guide_ski_advanced' => 21],
['guiding' => 3, 'course' => 4],
);
}
}