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,118 @@
<?php
declare(strict_types=1);
namespace App\Tests\Command;
use App\Entity\Assignment;
use App\Tests\Service\Assignment\StaffingFixturesTrait;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
/**
* End to end against db_test: the matrix, the import's columns, the configured ids and the
* "never twice" rule have to agree, which no unit test can show.
*/
class AssignmentGenerateCommandTest extends KernelTestCase
{
use StaffingFixturesTrait;
private EntityManagerInterface $entityManager;
private CommandTester $tester;
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->resetStaffingTables(self::getContainer()->get(Connection::class));
$this->tester = new CommandTester((new Application(self::$kernel))->find('app:assignment-generate'));
}
public function testDraftsAreCreatedForEnabledRowsOnly(): void
{
$this->staffingRule($this->entityManager, 'DKIMP');
$this->staffingRule($this->entityManager, 'DGSMP', enabled: false);
$destination = $this->staffingDestination($this->entityManager, 'DKIMP110127', 'DKIMP');
$this->staffingDestination($this->entityManager, 'DGSMP110127', 'DGSMP');
$this->entityManager->flush();
$this->assertSame(Command::SUCCESS, $this->tester->execute([]));
$assignments = $this->assignments();
$this->assertCount(2, $assignments);
[$guide, $course] = $assignments;
$this->assertSame($destination->getId(), $guide->getDestination()->getId());
$this->assertSame('Reiseleitung', $guide->getJobProfile()->getName());
$this->assertSame(2, $guide->getAvailableDispositions());
$this->assertSame(5, $guide->getPickup());
$this->assertSame(['Skigebietsguiding (pro Einheit)'], $guide->getFees()->map(static fn ($fee) => $fee->getName())->toArray());
$this->assertSame(Assignment::STATUS_DRAFT, $guide->getStatus());
$this->assertSame('system', $guide->getCreatedBy());
$this->assertSame('Kursleitung Ski Anfänger:innen', $course->getJobProfile()->getName());
$this->assertSame(0, $course->getPickup());
}
public function testADeletedDraftIsNotCreatedAgain(): void
{
$this->staffingRule($this->entityManager, 'DKIMP');
$this->staffingDestination($this->entityManager, 'DKIMP110127', 'DKIMP');
$this->entityManager->flush();
$this->tester->execute([]);
foreach ($this->assignments() as $assignment) {
$assignment->setDeleted();
}
$this->entityManager->flush();
$this->tester->execute([]);
$this->assertStringContainsString('davon 1 schon mit Einsätzen', $this->tester->getDisplay());
$this->assertCount(2, $this->assignments());
}
public function testADryRunWritesNothing(): void
{
$this->staffingRule($this->entityManager, 'DKIMP');
$this->staffingDestination($this->entityManager, 'DKIMP110127', 'DKIMP');
$this->entityManager->flush();
$this->assertSame(Command::SUCCESS, $this->tester->execute(['--dry-run' => true]));
$this->assertStringContainsString('2× Reiseleitung (Busbegleitung)', $this->tester->getDisplay());
$this->assertCount(0, $this->assignments());
}
public function testAMissingJobProfileStopsTheRunBeforeItWrites(): void
{
$this->staffingRule($this->entityManager, 'DKIMP');
$this->staffingDestination($this->entityManager, 'DKIMP110127', 'DKIMP');
$this->entityManager->flush();
self::getContainer()->get(Connection::class)->executeStatement('UPDATE job_profile SET deleted_at = NOW() WHERE id = 21');
$this->assertSame(Command::FAILURE, $this->tester->execute([]));
$this->assertCount(0, $this->assignments());
}
/**
* @return list<Assignment>
*/
private function assignments(): array
{
$this->entityManager->clear();
return $this->entityManager->getRepository(Assignment::class)->findBy([], ['id' => 'ASC']);
}
}
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin\System\StaffingRule;
use App\Entity\User;
use App\Repository\StaffingRuleRepository;
use App\Tests\Service\Assignment\StaffingFixturesTrait;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
class StaffingRuleControllerTest extends WebTestCase
{
use StaffingFixturesTrait;
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = static::createClient(server: ['HTTPS' => true]);
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->resetStaffingTables(self::getContainer()->get(Connection::class));
$this->entityManager->createQuery(sprintf('DELETE FROM %s', User::class))->execute();
$this->client->loginUser($this->admin());
}
public function testARuleIsCreatedWithItsCodeNormalized(): void
{
$this->submitCreate(' dkimp ');
$rules = $this->repository()->getList();
$this->assertCount(1, $rules);
$this->assertSame('DKIMP', $rules[0]->getProductCode());
$this->assertSame(2, $rules[0]->getGuides());
$this->assertTrue($rules[0]->isSkiBeginner());
$this->assertFalse($rules[0]->isEnabled());
$this->client->request('GET', '/admin/system/staffing-rule');
$this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertStringContainsString('DKIMP', (string) $this->client->getResponse()->getContent());
}
public function testASecondRowForTheSameProductIsRefused(): void
{
$this->submitCreate('DKIMP');
$this->submitCreate('dkimp');
$this->assertStringContainsString('Für dieses Produkt gibt es bereits eine Zeile', (string) $this->client->getResponse()->getContent());
$this->assertCount(1, $this->repository()->getList());
}
public function testADeletedRowFreesItsCode(): void
{
$this->submitCreate('DKIMP');
$this->repository()->getList()[0]->setDeleted();
$this->entityManager->flush();
$this->submitCreate('DKIMP');
$this->assertCount(1, $this->repository()->getList());
}
public function testThePreviewRendersWithoutCandidates(): void
{
$this->submitCreate('DKIMP');
$this->client->request('GET', '/admin/system/staffing-rule/preview');
$this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertStringContainsString('Keine Termine', (string) $this->client->getResponse()->getContent());
}
public function testThePreviewShowsWhatWouldBeCreated(): void
{
$this->staffingRule($this->entityManager, 'DKIMP', enabled: false);
$this->staffingDestination($this->entityManager, 'DKIMP110127', 'DKIMP');
$this->entityManager->flush();
$this->client->request('GET', '/admin/system/staffing-rule/preview');
$content = (string) $this->client->getResponse()->getContent();
$this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertStringContainsString('DKIMP110127', $content);
$this->assertStringContainsString('Zeile inaktiv', $content);
$this->assertStringContainsString('2× Reiseleitung', $content);
$this->assertStringContainsString('Busbegleitung ab Göttingen, 22:00 Uhr', $content);
$this->assertStringContainsString('1× Kursleitung Ski Anfänger:innen', $content);
$this->assertStringContainsString('Ski-/Snowboardkurs (pro Stunde)', $content);
}
public function testThePreviewDownloadsAsXlsx(): void
{
$this->staffingRule($this->entityManager, 'DKIMP');
$this->staffingDestination($this->entityManager, 'DKIMP110127', 'DKIMP');
$this->entityManager->flush();
$this->client->request('GET', '/admin/system/staffing-rule/export');
$this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertSame(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
$this->client->getResponse()->headers->get('Content-Type'),
);
}
private function submitCreate(string $productCode): void
{
$crawler = $this->client->request('GET', '/admin/system/staffing-rule/create');
$form = $crawler->filter('form')->form([
'staffing_rule[productCode]' => $productCode,
'staffing_rule[guides]' => '2',
]);
$form['staffing_rule[skiBeginner]']->tick();
$this->client->submit($form);
}
private function repository(): StaffingRuleRepository
{
$this->entityManager->clear();
return self::getContainer()->get(StaffingRuleRepository::class);
}
private function admin(): User
{
$user = (new User())
->setFirstName('Anna')
->setLastName('Admin')
->setEmail('[email protected]')
->setRoles(['ROLE_TEAM_ADMIN'])
->setBusProAddressId(1)
->setBusProPersonId(1)
;
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity;
use App\Entity\StaffingRule;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Validation;
class StaffingRuleTest extends TestCase
{
public function testTheProductCodeIsNormalized(): void
{
$this->assertSame('DPWAST', (new StaffingRule())->setProductCode(' dpwast ')->getProductCode());
$this->assertNull((new StaffingRule())->setProductCode(' ')->getProductCode());
}
#[DataProvider('combinations')]
public function testTheCombinationIsValidated(StaffingRule $rule, array $expectedPaths): void
{
$violations = Validation::createValidator()->validate($rule, new Callback('validateCombination'));
$paths = [];
foreach ($violations as $violation) {
$paths[] = $violation->getPropertyPath();
}
$this->assertSame($expectedPaths, $paths);
}
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 'guiding without a guide' => [self::rule(0, beginner: true, guiding: true), ['guiding']];
yield 'bus escort without a guide' => [self::rule(0, beginner: true, busEscort: true), ['busEscort']];
yield 'courses without guides' => [self::rule(0, beginner: true), []];
}
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)
;
}
}
+19
View File
@@ -155,6 +155,8 @@ class DestinationRowTest extends TestCase
'pickups' => '[{"busProId":4,"city":"Karlsruhe","street":"A5 - Autohof Bruchsal","time":"23:59","datetime":"2026-04-04T23:59:00+02:00"}]',
'cost_unit' => '4711',
'country' => 'ch',
'product_code' => 'LPJPGJ',
'imported_at' => '2026-08-13 03:00:00',
'created_at' => '2026-08-13 03:00:00',
], $payload);
}
@@ -169,16 +171,33 @@ class DestinationRowTest extends TestCase
$this->assertEquals('Sportclub Jenatsch', $payload['hotel']);
$this->assertEquals('LPJGR', $payload['hotel_code']);
$this->assertEquals('2026-08-13 03:00:00', $payload['updated_at']);
$this->assertEquals('2026-08-13 03:00:00', $payload['imported_at']);
$this->assertEquals('LPJPGJ', $payload['product_code']);
$this->assertArrayNotHasKey('created_at', $payload);
$this->assertArrayNotHasKey('bus_pro_id', $payload);
$this->assertArrayNotHasKey('hotel_bus_pro_id', $payload);
}
public function testTheProductCodeIsTrimmed(): void
{
// upstream carries "DPWAST " with a trailing blank
$row = DestinationRow::fromArray($this->getRowData(['productCode' => 'DPWAST ']));
$this->assertSame('DPWAST', $row->productCode);
}
public function testAMissingOrEmptyProductCodeIsNull(): void
{
$this->assertNull(DestinationRow::fromArray($this->getRowData(['productCode' => null]))->productCode);
$this->assertNull(DestinationRow::fromArray($this->getRowData(['productCode' => ' ']))->productCode);
}
private function getRowData(array $overrides = []): array
{
return array_merge([
'busProId' => 11886,
'code' => 'LPJPGJ040426',
'productCode' => 'LPJPGJ',
'product' => 'Lenzerheide - Sportclub Jenatsch',
'dateFrom' => '2026-04-04',
'dateTo' => '2026-04-11',
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Repository\AssignmentRepository;
use App\Repository\DestinationRepository;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Which dates the staffing matrix applies to, and which of them it must leave alone. Both are
* "does it return the right rows" questions, so they run against real rows.
*/
class StaffingCandidateQueryTest extends KernelTestCase
{
private const LATEST_IMPORT = '2026-10-15 03:20:00';
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$connection = self::getContainer()->get(Connection::class);
$connection->executeStatement('DELETE FROM assignment_fee');
$connection->executeStatement('DELETE FROM assignment');
$connection->executeStatement('DELETE FROM destination');
}
public function testOnlyLiveFutureDatesOfMatrixProductsFromTheLatestImport(): void
{
$match = $this->destination('DKIMP110127', 'DKIMP');
$this->destination('DKIMP040127', 'DKIMP', dateFrom: '2026-10-15'); // starts today
$this->destination('DKIMP180127', 'DKIMP', importedAt: '2026-10-14 03:20:00'); // cancelled upstream
$this->destination('DKIMP250127', 'DKIMP')->setDeleted();
$this->destination('DGSMP110127', 'DGSMP'); // not in the matrix
$this->destination('XYZ110127', null); // one-off date
$this->entityManager->flush();
$candidates = $this->destinationRepository()->getStaffingCandidates(['DKIMP'], new \DateTimeImmutable('2026-10-15'));
$this->assertSame([$match], $candidates);
}
public function testAnEmptyMatrixMatchesNothing(): void
{
$this->destination('DKIMP110127', 'DKIMP');
$this->entityManager->flush();
$this->assertSame([], $this->destinationRepository()->getStaffingCandidates([], new \DateTimeImmutable('2026-10-15')));
}
public function testADeletedAssignmentStillCounts(): void
{
$untouched = $this->destination('DKIMP110127', 'DKIMP');
$live = $this->destination('DKIMP180127', 'DKIMP');
$deleted = $this->destination('DKIMP250127', 'DKIMP');
$this->entityManager->persist((new Assignment())->setDestination($live));
$this->entityManager->persist((new Assignment())->setDestination($deleted)->setDeleted());
$this->entityManager->flush();
/** @var AssignmentRepository $repository */
$repository = self::getContainer()->get(AssignmentRepository::class);
$ids = $repository->getDestinationIdsWithAnyAssignment([$untouched, $live, $deleted]);
sort($ids);
$this->assertSame([$live->getId(), $deleted->getId()], $ids);
}
private function destination(
string $code,
?string $productCode,
string $dateFrom = '2027-01-11',
string $importedAt = self::LATEST_IMPORT,
): Destination {
$destination = (new Destination())
->setCode($code)
->setProductCode($productCode)
->setDateFrom(new \DateTimeImmutable($dateFrom))
->setDateTo((new \DateTimeImmutable($dateFrom))->modify('+5 days'))
->setProduct('Davos Klosters - Schwendi')
->setHotel('Schwendi')
->setHotelCode('DKI')
->setHotelBusProId(1)
->setImportedAt(new \DateTimeImmutable($importedAt))
;
$this->entityManager->persist($destination);
return $destination;
}
private function destinationRepository(): DestinationRepository
{
return self::getContainer()->get(DestinationRepository::class);
}
}
@@ -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],
);
}
}