feat: generate draft assignments from a staffing matrix
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user