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

+16
View File
@@ -96,6 +96,20 @@ parameters:
# the first. Per-entry overrides are stored on the task row; this is what they override.
season_task_deadline_offset: '-1 month'
# What the staffing matrix generates (App\Service\Assignment\AssignmentPlanner), keyed by
# App\Enum\StaffingRole / StaffingFee value. Ids and not names, so renaming a job profile
# or fee in the admin cannot silently change what the drafts get. A missing or deleted id
# makes App\Service\Assignment\StaffingResolver refuse rather than skip.
auto_assignment_job_profiles:
guide: 5 # Reiseleitung
ski_beginner: 3 # Kursleitung Ski Anfänger:innen
ski_advanced: 14 # Kursleitung Ski Fortgeschrittene
guide_ski_beginner: 20 # Reise- und Kursleitung Ski Anfänger:innen
guide_ski_advanced: 21 # Reise- und Kursleitung Ski Fortgeschrittene
auto_assignment_fees:
guiding: 3 # Skigebietsguiding (pro Einheit)
course: 4 # Ski-/Snowboardkurs (pro Stunde)
# Houses, hotel code: name. The name is used both as a label and to match
# destination.hotel, the code to match destination.hotelCode.
houses:
@@ -141,6 +155,8 @@ services:
$seasonStart: '%season_start%'
$seasonEnd: '%season_end%'
$seasonTaskDeadlineOffset: '%season_task_deadline_offset%'
$autoAssignmentJobProfiles: '%auto_assignment_job_profiles%'
$autoAssignmentFees: '%auto_assignment_fees%'
# Tagged by interface rather than listed by hand: order between collectors is
# irrelevant, so the only thing an explicit list could add here is the chance to
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds the staffing matrix and what the assignment generator needs from the travel import.
*
* destination.product_code is the travel code MyE&P reports per date; destination.imported_at
* tells the dates the latest import reported from ones cancelled upstream, which the import
* never deletes. Both stay NULL until the next app:myep-import.
*
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
* pre-existing drift of the dev database.
*/
final class Version20261015154754 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add the staffing_rule table and destination.product_code / imported_at';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE staffing_rule (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, product_code VARCHAR(32) NOT NULL, guides INT NOT NULL, ski_beginner TINYINT NOT NULL, ski_advanced TINYINT NOT NULL, split TINYINT NOT NULL, guiding TINYINT NOT NULL, bus_escort TINYINT NOT NULL, enabled TINYINT NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, deleted_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_91DFB1A2D17F50A6 (uuid), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE destination ADD product_code VARCHAR(32) DEFAULT NULL, ADD imported_at DATETIME DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE staffing_rule');
$this->addSql('ALTER TABLE destination DROP product_code, DROP imported_at');
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Command;
use App\Model\PlannedAssignment;
use App\Model\StaffingCandidate;
use App\Service\Assignment\AssignmentGenerator;
use App\Service\Assignment\StaffingConfigurationException;
use App\Service\Assignment\StaffingResolver;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Creates draft assignments for the future dates of every enabled staffing matrix row.
*
* Safe to repeat: a date that has ever had an assignment is skipped, so drafts the office
* edited or deleted are never recreated. Run it after app:myep-import - it only considers
* the dates the latest import reported.
*/
#[AsCommand(
name: 'app:assignment-generate',
description: 'Creates draft assignments from the staffing matrix',
)]
class AssignmentGenerateCommand extends Command
{
public function __construct(
private readonly AssignmentGenerator $generator,
private readonly StaffingResolver $resolver,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only list what would be created');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = true === $input->getOption('dry-run');
try {
$this->resolver->assertConfigured();
} catch (StaffingConfigurationException $e) {
$io->error('Jobprofil oder Honorar der Besetzungsmatrix nicht gefunden – abgebrochen: '.$e->getMessage());
$this->logger->error('Assignment generation aborted', ['reason' => $e->getMessage()]);
return Command::FAILURE;
}
$candidates = $this->generator->getCandidates(enabledOnly: true);
$open = array_values(array_filter($candidates, static fn (StaffingCandidate $c): bool => false === $c->skipped));
$io->info(sprintf(
'%d Reisetermine aus der Besetzungsmatrix, davon %d schon mit Einsätzen (übersprungen)',
count($candidates),
count($candidates) - count($open),
));
if ([] === $open) {
return Command::SUCCESS;
}
$io->table(
['Termin', 'Produkt', 'Beginn', 'Einsätze'],
array_map($this->describe(...), $open),
);
if (true === $dryRun) {
$io->note('Probelauf – es wurde nichts angelegt.');
return Command::SUCCESS;
}
$created = 0;
foreach ($open as $candidate) {
$created += count($this->generator->generate($candidate));
}
$io->success(sprintf('%d Einsätze für %d Reisetermine als Entwurf angelegt', $created, count($open)));
$this->logger->info('Assignment generation finished', [
'destinations' => count($open),
'assignments' => $created,
'skipped' => count($candidates) - count($open),
]);
return Command::SUCCESS;
}
private function describe(StaffingCandidate $candidate): array
{
return [
$candidate->destination->getCode(),
$candidate->rule->getProductCode(),
$candidate->destination->getDateFrom()->format('d.m.Y'),
implode("\n", array_map(
fn (PlannedAssignment $planned): string => sprintf(
'%d× %s%s',
$planned->places,
$this->resolver->jobProfile($planned->role)->getName(),
null === $planned->pickup ? '' : ' (Busbegleitung)',
),
$candidate->planned,
)),
];
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Controller\Admin\System\StaffingRule;
use App\Entity\StaffingRule;
use App\Form\StaffingRuleType;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class CreateController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/system/staffing-rule/create', name: 'app_admin_system_staffing_rule_create')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$rule = new StaffingRule();
$form = $this->createForm(StaffingRuleType::class, $rule);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($rule);
$this->entityManager->flush();
$this->addFlash('success', 'Die Zeile der Besetzungsmatrix wurde angelegt');
$this->logger->info('Create staffing rule', [
'staffing_rule_id' => $rule->getId(),
'product_code' => $rule->getProductCode(),
]);
return new HxRedirectResponse($this->generateUrl('app_admin_system_staffing_rule_index'));
}
return $this->render('admin/system/staffing_rule/modal_create.html.twig', [
'form' => $form->createView(),
]);
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Controller\Admin\System\StaffingRule;
use App\Entity\StaffingRule;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class DeleteController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/system/staffing-rule/delete/{uuid}', name: 'app_admin_system_staffing_rule_delete')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(StaffingRule $rule, Request $request): Response
{
if (true === $request->isMethod('POST')) {
$rule->setDeleted();
$this->entityManager->flush();
$this->addFlash('success', 'Die Zeile der Besetzungsmatrix wurde gelöscht');
$this->logger->info('Delete staffing rule', [
'staffing_rule_id' => $rule->getId(),
'product_code' => $rule->getProductCode(),
]);
return new HxRedirectResponse($this->generateUrl('app_admin_system_staffing_rule_index'));
}
return $this->render('admin/system/staffing_rule/modal_delete.html.twig', [
'rule' => $rule,
]);
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Controller\Admin\System\StaffingRule;
use App\Entity\StaffingRule;
use App\Form\StaffingRuleType;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class EditController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/system/staffing-rule/edit/{uuid}', name: 'app_admin_system_staffing_rule_edit')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(StaffingRule $rule, Request $request): Response
{
$form = $this->createForm(StaffingRuleType::class, $rule);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->flush();
$this->addFlash('success', 'Die Zeile der Besetzungsmatrix wurde aktualisiert');
$this->logger->info('Edit staffing rule', [
'staffing_rule_id' => $rule->getId(),
'product_code' => $rule->getProductCode(),
]);
return new HxRedirectResponse($this->generateUrl('app_admin_system_staffing_rule_index'));
}
return $this->render('admin/system/staffing_rule/modal_edit.html.twig', [
'form' => $form->createView(),
]);
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Controller\Admin\System\StaffingRule;
use App\Service\Assignment\StaffingConfigurationException;
use App\Service\Export\StaffingPreviewExporter;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ExportController extends AbstractController
{
public function __construct(
private readonly StaffingPreviewExporter $previewExporter,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/system/staffing-rule/export', name: 'app_admin_system_staffing_rule_export')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
try {
$rows = $this->previewExporter->rows();
} catch (StaffingConfigurationException $e) {
$this->addFlash('error', 'Ein Jobprofil oder Honorar der Besetzungsmatrix ist nicht (mehr) vorhanden: '.$e->getMessage());
return $this->redirectToRoute('app_admin_system_staffing_rule_index');
}
if ([] === $rows) {
$this->addFlash('error', 'Keine Daten für den Export vorhanden');
return $this->redirectToRoute('app_admin_system_staffing_rule_preview');
}
$spreadsheet = $this->previewExporter->export($rows);
$filename = sprintf('besetzungsmatrix-vorschau_%s.xlsx', date('YmdHi'));
$response = new StreamedResponse(static function () use ($spreadsheet): void {
(new Xlsx($spreadsheet))->save('php://output');
});
$response->headers->set(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
);
$response->headers->set(
'Content-Disposition',
HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename, md5($filename))
);
$this->logger->info('Export staffing preview', [
'count' => count($rows),
]);
return $response;
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Controller\Admin\System\StaffingRule;
use App\Repository\StaffingRuleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController
{
public function __construct(private readonly StaffingRuleRepository $staffingRuleRepository)
{
}
#[Route('/admin/system/staffing-rule', name: 'app_admin_system_staffing_rule_index')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
return $this->render('admin/system/staffing_rule/index.html.twig', [
'rules' => $this->staffingRuleRepository->getList(),
]);
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Controller\Admin\System\StaffingRule;
use App\Service\Assignment\StaffingConfigurationException;
use App\Service\Export\StaffingPreviewExporter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class PreviewController extends AbstractController
{
public function __construct(private readonly StaffingPreviewExporter $previewExporter)
{
}
#[Route('/admin/system/staffing-rule/preview', name: 'app_admin_system_staffing_rule_preview')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
try {
$rows = $this->previewExporter->rows();
} catch (StaffingConfigurationException $e) {
$this->addFlash('error', 'Ein Jobprofil oder Honorar der Besetzungsmatrix ist nicht (mehr) vorhanden: '.$e->getMessage());
return $this->redirectToRoute('app_admin_system_staffing_rule_index');
}
return $this->render('admin/system/staffing_rule/preview.html.twig', [
'rows' => $rows,
]);
}
}
+38
View File
@@ -55,6 +55,20 @@ class Destination implements TimestampableEntityInterface, SoftDeletableEntityIn
#[ORM\Column(length: 2, nullable: true)]
private ?string $country = null;
/**
* The travel this date belongs to, as MyE&P reports it. Not the date code minus its date
* suffix - see App\MyEp\Import\DestinationRow::readProductCode().
*/
#[ORM\Column(length: 32, nullable: true)]
private ?string $productCode = null;
/**
* When app:myep-import last saw this row. Not updatedAt: a destination is editable in the
* UI, so updatedAt cannot tell a row the latest import touched from one it no longer reports.
*/
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $importedAt = null;
public function __toString(): string
{
return sprintf(
@@ -234,4 +248,28 @@ class Destination implements TimestampableEntityInterface, SoftDeletableEntityIn
return $this;
}
public function getProductCode(): ?string
{
return $this->productCode;
}
public function setProductCode(?string $productCode): static
{
$this->productCode = $productCode;
return $this;
}
public function getImportedAt(): ?\DateTimeImmutable
{
return $this->importedAt;
}
public function setImportedAt(?\DateTimeImmutable $importedAt): static
{
$this->importedAt = $importedAt;
return $this;
}
}
+226
View File
@@ -0,0 +1,226 @@
<?php
namespace App\Entity;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\SoftDeletableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\StaffingRuleRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* One row of the stakeholders' staffing matrix: how every date of a product is staffed.
* App\Service\Assignment\AssignmentPlanner turns it into draft assignments.
*
* Unique among live rows only, so no database index: a soft-deleted row keeps its code.
*/
#[ORM\Entity(repositoryClass: StaffingRuleRepository::class)]
#[UniqueEntity(
fields: ['productCode'],
message: 'Für dieses Produkt gibt es bereits eine Zeile',
repositoryMethod: 'findLiveByProductCode'
)]
class StaffingRule implements BlameableEntityInterface, TimestampableEntityInterface, SoftDeletableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
use SoftDeletableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
/** The travel code MyE&P reports as productCode, not a date-code prefix. */
#[ORM\Column(length: 32)]
#[Assert\NotBlank(message: 'Bitte gib den Produktcode an')]
#[Assert\Length(max: 32, maxMessage: 'Der Produktcode darf höchstens {{ limit }} Zeichen lang sein')]
private ?string $productCode = null;
#[ORM\Column]
#[Assert\NotNull(message: 'Bitte gib die Anzahl der Reiseleitungen an')]
#[Assert\PositiveOrZero(message: 'Die Anzahl darf nicht negativ sein')]
private ?int $guides = 1;
#[ORM\Column]
private bool $skiBeginner = false;
#[ORM\Column]
private bool $skiAdvanced = false;
/** The Reiseleitungen take over the ski levels, one level per place. */
#[ORM\Column]
private bool $split = false;
#[ORM\Column]
private bool $guiding = false;
#[ORM\Column]
private bool $busEscort = false;
/** Previewed either way; only enabled rows generate. */
#[ORM\Column]
private bool $enabled = false;
public function __construct()
{
$this->uuid = Uuid::v4();
}
#[Assert\Callback]
public function validateCombination(ExecutionContextInterface $context): void
{
$levels = $this->getSkiLevelCount();
if (true === $this->split && 0 === $levels) {
$context
->buildViolation('Ein Split braucht mindestens einen Skikurs')
->atPath('split')
->addViolation()
;
}
if (true === $this->split && $levels > (int) $this->guides) {
$context
->buildViolation('Bei einem Split braucht jedes Kursniveau eine eigene Reiseleitung')
->atPath('split')
->addViolation()
;
}
if (true === $this->guiding && 0 === (int) $this->guides) {
$context
->buildViolation('Guiding braucht mindestens eine Reiseleitung')
->atPath('guiding')
->addViolation()
;
}
if (true === $this->busEscort && 0 === (int) $this->guides) {
$context
->buildViolation('Busbegleitung braucht mindestens eine Reiseleitung')
->atPath('busEscort')
->addViolation()
;
}
}
public function getSkiLevelCount(): int
{
return (int) $this->skiBeginner + (int) $this->skiAdvanced;
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getProductCode(): ?string
{
return $this->productCode;
}
public function setProductCode(?string $productCode): static
{
$productCode = strtoupper(trim((string) $productCode));
$this->productCode = '' === $productCode ? null : $productCode;
return $this;
}
public function getGuides(): ?int
{
return $this->guides;
}
public function setGuides(?int $guides): static
{
$this->guides = $guides;
return $this;
}
public function isSkiBeginner(): bool
{
return $this->skiBeginner;
}
public function setSkiBeginner(bool $skiBeginner): static
{
$this->skiBeginner = $skiBeginner;
return $this;
}
public function isSkiAdvanced(): bool
{
return $this->skiAdvanced;
}
public function setSkiAdvanced(bool $skiAdvanced): static
{
$this->skiAdvanced = $skiAdvanced;
return $this;
}
public function isSplit(): bool
{
return $this->split;
}
public function setSplit(bool $split): static
{
$this->split = $split;
return $this;
}
public function isGuiding(): bool
{
return $this->guiding;
}
public function setGuiding(bool $guiding): static
{
$this->guiding = $guiding;
return $this;
}
public function isBusEscort(): bool
{
return $this->busEscort;
}
public function setBusEscort(bool $busEscort): static
{
$this->busEscort = $busEscort;
return $this;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Enum;
/**
* A fee the staffing matrix puts on a generated assignment. Each case maps to one Fee row
* through the auto_assignment_fees parameter; the values are its keys.
*/
enum StaffingFee: string
{
case GUIDING = 'guiding';
case COURSE = 'course';
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Enum;
/**
* A role the staffing matrix generates an assignment for. Each case maps to one job profile
* through the auto_assignment_job_profiles parameter; the values are its keys.
*/
enum StaffingRole: string
{
case GUIDE = 'guide';
case SKI_BEGINNER = 'ski_beginner';
case SKI_ADVANCED = 'ski_advanced';
case GUIDE_SKI_BEGINNER = 'guide_ski_beginner';
case GUIDE_SKI_ADVANCED = 'guide_ski_advanced';
/** A Reiseleitung, alone or combined with a course: the roles that can guide and escort the bus. */
public function isGuide(): bool
{
return match ($this) {
self::GUIDE, self::GUIDE_SKI_BEGINNER, self::GUIDE_SKI_ADVANCED => true,
self::SKI_BEGINNER, self::SKI_ADVANCED => false,
};
}
public function isCourse(): bool
{
return self::GUIDE !== $this;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Form;
use App\Entity\StaffingRule;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class StaffingRuleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('productCode', TextType::class, [
'label' => 'Produktcode',
'help' => 'Der Code der Reise in BusPro, nicht der eines Termins (z. B. DWWMP, nicht DWWMW)',
])
->add('guides', IntegerType::class, [
'label' => 'Anzahl Reiseleitungen',
'attr' => ['min' => 0],
])
->add('skiBeginner', CheckboxType::class, [
'label' => 'Ski Anfänger:innen',
'required' => false,
])
->add('skiAdvanced', CheckboxType::class, [
'label' => 'Ski Fortgeschrittene',
'required' => false,
])
->add('split', CheckboxType::class, [
'label' => 'Split',
'help' => 'Die Reiseleitungen übernehmen die Kurse (Reise- und Kursleitung)',
'required' => false,
])
->add('guiding', CheckboxType::class, [
'label' => 'Guiding',
'required' => false,
])
->add('busEscort', CheckboxType::class, [
'label' => 'Busbegleitung',
'required' => false,
])
->add('enabled', CheckboxType::class, [
'label' => 'aktiv',
'help' => 'Nur aktive Zeilen legen Einsätze an; die Vorschau zeigt alle',
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => StaffingRule::class,
]);
}
}
+11
View File
@@ -266,6 +266,17 @@ class AdminMenuBuilder extends AbstractMenuBuilder
],
],
]);
$settingsMenu->addChild('Besetzungsmatrix', [
'route' => 'app_admin_system_staffing_rule_index',
'linkAttributes' => [
'title' => 'Besetzungsmatrix',
],
'extras' => [
'routes' => [
['pattern' => '/^app_admin_system_staffing_rule_/'],
],
],
]);
$settingsMenu->addChild('Reisen', [
'route' => 'app_administrative_system_destination_index',
'linkAttributes' => [
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Model;
use App\Enum\StaffingFee;
use App\Enum\StaffingRole;
/**
* One assignment App\Service\Assignment\AssignmentPlanner would create, before its role and
* fees are resolved to entities.
*/
final readonly class PlannedAssignment
{
/**
* @param list<StaffingFee> $fees
*/
public function __construct(
public StaffingRole $role,
public int $places,
/** busProId of the pre-selected boarding point ("Busbegleitung ab"), null for none */
public ?int $pickup = null,
public array $fees = [],
) {
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Model;
use App\Entity\Destination;
use App\Entity\StaffingRule;
/**
* A travel date the staffing matrix applies to, with what would be created for it.
*/
final readonly class StaffingCandidate
{
/**
* @param list<PlannedAssignment> $planned
*/
public function __construct(
public Destination $destination,
public StaffingRule $rule,
public array $planned,
/** It has had an assignment before, so it is never generated again. */
public bool $skipped,
) {
}
}
+19
View File
@@ -23,6 +23,8 @@ class DestinationRow
/** lowercase ISO-3166-1 alpha-2, null when MyE&P could not resolve one */
public readonly ?string $country,
public readonly array $pickups,
/** the enclosing travel's code, null for one-off dates that have none */
public readonly ?string $productCode = null,
) {
}
@@ -56,6 +58,7 @@ class DestinationRow
self::readNullableString($hotel, 'costUnit'),
CountryCode::normalize($hotel['countryCode'] ?? null),
self::readPickups($data['pickups'] ?? []),
self::readProductCode($data),
);
}
@@ -76,6 +79,8 @@ class DestinationRow
'pickups' => json_encode($this->pickups),
'cost_unit' => $this->costUnit,
'country' => $this->country,
'product_code' => $this->productCode,
'imported_at' => $now->format('Y-m-d H:i:s'),
'updated_at' => $now->format('Y-m-d H:i:s'),
];
}
@@ -94,6 +99,8 @@ class DestinationRow
'pickups' => json_encode($this->pickups),
'cost_unit' => $this->costUnit,
'country' => $this->country,
'product_code' => $this->productCode,
'imported_at' => $now->format('Y-m-d H:i:s'),
'created_at' => $now->format('Y-m-d H:i:s'),
];
}
@@ -152,6 +159,18 @@ class DestinationRow
return $mapped;
}
/**
* Not derivable from the date code: a third of the dates run under a code that differs from
* their travel's (DWWMP runs DWWMW..., group travels run their own), so MyE&P reports the
* enclosing travel's code. The staffing matrix (App\Entity\StaffingRule) is keyed on it.
*/
private static function readProductCode(array $data): ?string
{
$code = trim((string) ($data['productCode'] ?? ''));
return '' === $code ? null : $code;
}
private static function readNullableString(array $data, string $key): ?string
{
$value = $data[$key] ?? null;
+26
View File
@@ -4,6 +4,7 @@ namespace App\Repository;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\Disposition;
use App\Entity\JobProfile;
use App\Entity\Teamer;
@@ -405,4 +406,29 @@ class AssignmentRepository extends ServiceEntityRepository
->getQuery()
;
}
/**
* Which of these destinations have ever had an assignment - soft-deleted and called-off ones
* included, because a draft the office deleted must not be generated again.
*
* @param list<Destination> $destinations
*
* @return list<int> destination ids
*/
public function getDestinationIdsWithAnyAssignment(array $destinations): array
{
if ([] === $destinations) {
return [];
}
$qb = $this->createQueryBuilder('assignment');
return array_map('intval', $qb
->select('DISTINCT IDENTITY(assignment.destination)')
->where($qb->expr()->in('assignment.destination', ':destinations'))
->setParameter('destinations', $destinations)
->getQuery()
->getSingleColumnResult()
);
}
}
+37
View File
@@ -124,4 +124,41 @@ class DestinationRepository extends ServiceEntityRepository
->getArrayResult()
;
}
/**
* The dates the staffing matrix applies to: live, starting after $today, of one of the
* matrix's products, and reported by the latest import. The last condition keeps out dates
* cancelled upstream - the import never deletes, so a vanished date keeps its row and its
* product code, but not a current importedAt.
*
* @param list<string> $productCodes
*
* @return list<Destination>
*/
public function getStaffingCandidates(array $productCodes, \DateTimeImmutable $today): array
{
if ([] === $productCodes) {
return [];
}
$qb = $this->createQueryBuilder('destination');
return $qb
->where($qb->expr()->andX(
$qb->expr()->isNull('destination.deletedAt'),
$qb->expr()->in('destination.productCode', ':productCodes'),
$qb->expr()->gt('destination.dateFrom', ':today'),
$qb->expr()->eq(
'destination.importedAt',
'(SELECT MAX(latest.importedAt) FROM '.Destination::class.' latest)',
),
))
->setParameter('productCodes', $productCodes)
->setParameter('today', $today->format('Y-m-d'))
->orderBy('destination.dateFrom', \SortDirection::Ascending)
->addOrderBy('destination.productCode', \SortDirection::Ascending)
->getQuery()
->getResult()
;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Repository;
use App\Entity\StaffingRule;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<StaffingRule>
*
* @method StaffingRule|null find($id, $lockMode = null, $lockVersion = null)
* @method StaffingRule|null findOneBy(array $criteria, array $orderBy = null)
* @method StaffingRule[] findAll()
* @method StaffingRule[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class StaffingRuleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, StaffingRule::class);
}
/**
* @return list<StaffingRule>
*/
public function getList(): array
{
$qb = $this->createQueryBuilder('staffing_rule');
return $qb
->where($qb->expr()->isNull('staffing_rule.deletedAt'))
->orderBy('staffing_rule.productCode', \SortDirection::Ascending)
->getQuery()
->getResult()
;
}
/**
* For UniqueEntity: a soft-deleted row keeps its code and must not block a new one.
*
* @return list<StaffingRule>
*/
public function findLiveByProductCode(array $fields): array
{
$qb = $this->createQueryBuilder('staffing_rule');
return $qb
->where($qb->expr()->andX(
$qb->expr()->eq('staffing_rule.productCode', ':productCode'),
$qb->expr()->isNull('staffing_rule.deletedAt'),
))
->setParameter('productCode', $fields['productCode'])
->getQuery()
->getResult()
;
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Service\Assignment;
use App\Entity\Assignment;
use App\Model\StaffingCandidate;
use App\Repository\AssignmentRepository;
use App\Repository\DestinationRepository;
use App\Repository\StaffingRuleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Creates the draft assignments the staffing matrix calls for. Shared by app:assignment-generate
* and the admin preview, so what the stakeholders confirm is what gets generated.
*/
class AssignmentGenerator
{
public function __construct(
private readonly StaffingRuleRepository $staffingRuleRepository,
private readonly DestinationRepository $destinationRepository,
private readonly AssignmentRepository $assignmentRepository,
private readonly AssignmentPlanner $planner,
private readonly StaffingResolver $resolver,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
/**
* @param bool $enabledOnly false for the preview, which shows rows not yet switched on
*
* @return list<StaffingCandidate>
*/
public function getCandidates(bool $enabledOnly, ?\DateTimeImmutable $today = null): array
{
$rules = [];
foreach ($this->staffingRuleRepository->getList() as $rule) {
if (false === $enabledOnly || true === $rule->isEnabled()) {
$rules[$rule->getProductCode()] = $rule;
}
}
$destinations = $this->destinationRepository->getStaffingCandidates(
array_map('strval', array_keys($rules)),
$today ?? new \DateTimeImmutable('today'),
);
$skipped = array_flip($this->assignmentRepository->getDestinationIdsWithAnyAssignment($destinations));
$candidates = [];
foreach ($destinations as $destination) {
$rule = $rules[$destination->getProductCode()];
$candidates[] = new StaffingCandidate(
$destination,
$rule,
$this->planner->plan($rule, $destination),
isset($skipped[$destination->getId()]),
);
}
return $candidates;
}
/**
* Persists and flushes the candidate's drafts.
*
* @return list<Assignment>
*
* @throws StaffingConfigurationException when a configured job profile or fee is missing
*/
public function generate(StaffingCandidate $candidate): array
{
if (true === $candidate->skipped) {
return [];
}
$assignments = [];
foreach ($candidate->planned as $planned) {
$assignment = (new Assignment())
->setDestination($candidate->destination)
->setJobProfile($this->resolver->jobProfile($planned->role))
->setAvailableDispositions($planned->places)
->setPickup($planned->pickup)
;
foreach ($planned->fees as $fee) {
$assignment->addFee($this->resolver->fee($fee));
}
$this->entityManager->persist($assignment);
$assignments[] = $assignment;
}
$this->entityManager->flush();
foreach ($assignments as $assignment) {
$this->logger->info('Generate assignment', [
'assignment_id' => $assignment->getId(),
'destination_id' => $candidate->destination->getId(),
'destination_code' => $candidate->destination->getCode(),
'product_code' => $candidate->rule->getProductCode(),
'job_profile' => $assignment->getJobProfile()->getName(),
'places' => $assignment->getAvailableDispositions(),
'pickup' => $assignment->getPickup(),
]);
}
return $assignments;
}
}
@@ -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;
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Service\Assignment;
/**
* A job profile or fee the staffing matrix is configured to use does not exist (any more).
*/
class StaffingConfigurationException extends \RuntimeException
{
}
@@ -0,0 +1,93 @@
<?php
namespace App\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;
/**
* Resolves the matrix's roles and fees to the job profiles and fees configured in
* auto_assignment_job_profiles and auto_assignment_fees.
*
* Refuses instead of skipping: a draft with a missing course profile or fee looks complete and
* would get published as it is.
*/
class StaffingResolver
{
/** @var array<string, JobProfile> */
private array $jobProfiles = [];
/** @var array<string, Fee> */
private array $fees = [];
/**
* @param array<string, int> $autoAssignmentJobProfiles
* @param array<string, int> $autoAssignmentFees
*/
public function __construct(
private readonly JobProfileRepository $jobProfileRepository,
private readonly FeeRepository $feeRepository,
private readonly array $autoAssignmentJobProfiles,
private readonly array $autoAssignmentFees,
) {
}
/**
* Resolves every role and fee once, so a broken id stops a run before it writes anything
* rather than halfway through.
*
* @throws StaffingConfigurationException
*/
public function assertConfigured(): void
{
foreach (StaffingRole::cases() as $role) {
$this->jobProfile($role);
}
foreach (StaffingFee::cases() as $fee) {
$this->fee($fee);
}
}
/**
* @throws StaffingConfigurationException when the role has no live job profile configured
*/
public function jobProfile(StaffingRole $role): JobProfile
{
if (isset($this->jobProfiles[$role->value])) {
return $this->jobProfiles[$role->value];
}
$id = $this->autoAssignmentJobProfiles[$role->value] ?? null;
$jobProfile = null === $id ? null : $this->jobProfileRepository->find($id);
if (null === $jobProfile || null !== $jobProfile->getDeletedAt()) {
throw new StaffingConfigurationException(sprintf('auto_assignment_job_profiles.%s: no live job profile with id %s', $role->value, $id ?? 'null'));
}
return $this->jobProfiles[$role->value] = $jobProfile;
}
/**
* @throws StaffingConfigurationException when the fee has no live fee row configured
*/
public function fee(StaffingFee $staffingFee): Fee
{
if (isset($this->fees[$staffingFee->value])) {
return $this->fees[$staffingFee->value];
}
$id = $this->autoAssignmentFees[$staffingFee->value] ?? null;
$fee = null === $id ? null : $this->feeRepository->find($id);
if (null === $fee || null !== $fee->getDeletedAt()) {
throw new StaffingConfigurationException(sprintf('auto_assignment_fees.%s: no live fee with id %s', $staffingFee->value, $id ?? 'null'));
}
return $this->fees[$staffingFee->value] = $fee;
}
}
@@ -0,0 +1,121 @@
<?php
namespace App\Service\Export;
use App\Model\PlannedAssignment;
use App\Model\StaffingCandidate;
use App\Service\Assignment\AssignmentGenerator;
use App\Service\Assignment\StaffingConfigurationException;
use App\Service\Assignment\StaffingResolver;
use App\Service\Common\PickupOptions;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
/**
* What the staffing matrix would create, for the stakeholders to confirm: one row per travel
* date and planned assignment. The admin page and the .xlsx download both read rows(), so
* they cannot disagree.
*/
final class StaffingPreviewExporter
{
public const STATUS_CREATE = 'wird angelegt';
public const STATUS_DISABLED = 'Zeile inaktiv';
public const STATUS_SKIPPED = 'hat schon Einsätze';
private const HEADERS = [
'Produkt',
'Termin',
'Beginn',
'Ende',
'Reise',
'Status',
'Jobprofil',
'Plätze',
'Busbegleitung ab',
'Honorare',
];
public function __construct(
private readonly AssignmentGenerator $generator,
private readonly StaffingResolver $resolver,
) {
}
/**
* @return list<array{productCode: string, code: ?string, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable, product: ?string, status: string, assignments: list<array{jobProfile: string, places: int, pickup: ?string, fees: string}>}>
*
* @throws StaffingConfigurationException when a configured job profile or fee is missing
*/
public function rows(): array
{
return array_map(fn (StaffingCandidate $candidate): array => [
'productCode' => (string) $candidate->rule->getProductCode(),
'code' => $candidate->destination->getCode(),
'dateFrom' => $candidate->destination->getDateFrom(),
'dateTo' => $candidate->destination->getDateTo(),
'product' => $candidate->destination->getProduct(),
'status' => match (true) {
$candidate->skipped => self::STATUS_SKIPPED,
false === $candidate->rule->isEnabled() => self::STATUS_DISABLED,
default => self::STATUS_CREATE,
},
'assignments' => array_map(fn (PlannedAssignment $planned): array => [
'jobProfile' => (string) $this->resolver->jobProfile($planned->role)->getName(),
'places' => $planned->places,
'pickup' => $this->pickupLabel($candidate, $planned),
'fees' => implode(', ', array_map(
fn ($fee): string => (string) $this->resolver->fee($fee)->getName(),
$planned->fees,
)),
], $candidate->planned),
], $this->generator->getCandidates(enabledOnly: false));
}
public function export(array $rows): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Besetzungsmatrix');
$sheet->fromArray(self::HEADERS);
$sheet->getStyle('A1:J1')->getFont()->setBold(true);
$sheet->freezePane('A2');
$line = 2;
foreach ($rows as $row) {
// a date without planned assignments still gets its line
foreach ($row['assignments'] ?: [null] as $assignment) {
$sheet->fromArray([
$row['productCode'],
$row['code'],
$row['dateFrom']->format('d.m.Y'),
$row['dateTo']->format('d.m.Y'),
$row['product'],
$row['status'],
$assignment['jobProfile'] ?? null,
$assignment['places'] ?? null,
$assignment['pickup'] ?? null,
$assignment['fees'] ?? null,
], null, 'A'.$line++, true);
}
}
foreach (range('A', 'J') as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
return $spreadsheet;
}
private function pickupLabel(StaffingCandidate $candidate, PlannedAssignment $planned): ?string
{
foreach (PickupOptions::inDepartureOrder($candidate->destination) as $option) {
if ($option['busProId'] === $planned->pickup) {
return null === $option['departure']
? $option['label']
: sprintf('%s, %s Uhr', $option['label'], $option['departure']);
}
}
return null;
}
}
@@ -0,0 +1,16 @@
{{ form_start(form, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' } }) }}
<div class="flex flex-col space-y-4 pb-8">
{{ form_row(form.productCode) }}
{{ form_row(form.guides) }}
{{ form_row(form.skiBeginner) }}
{{ form_row(form.skiAdvanced) }}
{{ form_row(form.split) }}
{{ form_row(form.guiding) }}
{{ form_row(form.busEscort) }}
{{ form_row(form.enabled) }}
</div>
<button type="submit" class="btn">
Speichern
</button>
{{ form_rest(form) }}
{{ form_end(form) }}
@@ -0,0 +1,86 @@
{% extends 'admin/layout.html.twig' %}
{% block title %}Einstellungen - Besetzungsmatrix{% endblock %}
{% macro yes_no(value) %}{{ value ? 'ja' : '-' }}{% endmacro %}
{% block content %}
<div class="flex items-center justify-between pb-8">
<h1 class="text-2xl font-bold">
Besetzungsmatrix
</h1>
<a href="{{ path('app_admin_system_staffing_rule_preview') }}" class="btn">
Vorschau
</a>
</div>
<p class="pb-4">
Für jeden künftigen Termin eines aktiven Produkts legt der Einsatz-Generator die Einsätze als
Entwurf an. Termine, die schon einmal Einsätze hatten, bleiben unberührt.
</p>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner">
<table class="data-table">
<thead>
<tr>
<th>Produkt</th>
<th>Reiseleitungen</th>
<th>Ski Anfänger:innen</th>
<th>Ski Fortgeschrittene</th>
<th>Split</th>
<th>Guiding</th>
<th>Busbegleitung</th>
<th>aktiv</th>
<th></th>
</tr>
</thead>
<tbody>
{% for rule in rules %}
<tr>
<td>{{ rule.productCode }}</td>
<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>{{ _self.yes_no(rule.guiding) }}</td>
<td>{{ _self.yes_no(rule.busEscort) }}</td>
<td>{{ _self.yes_no(rule.enabled) }}</td>
<td>
<div class="flex items-center space-x-2 justify-end">
<button type="button"
class="text-red-500"
title="Zeile löschen"
hx-get="{{ path('app_admin_system_staffing_rule_delete', { 'uuid': rule.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('delete') }}
</button>
<button type="button"
title="Zeile bearbeiten"
hx-get="{{ path('app_admin_system_staffing_rule_edit', { 'uuid': rule.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('edit') }}
</button>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="9">
Keine Daten...
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<button type="button"
class="btn"
title="Zeile hinzufügen"
hx-get="{{ path('app_admin_system_staffing_rule_create') }}"
hx-target="body"
hx-swap="beforeend">
Zeile hinzufügen
</button>
{% endblock %}
@@ -0,0 +1,7 @@
{% extends 'htmx_modal.html.twig' %}
{% block title %}Zeile hinzufügen{% endblock %}
{% block content %}
{% include 'admin/system/staffing_rule/_form.html.twig' %}
{% endblock %}
@@ -0,0 +1,8 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block content %}
<div>
Möchtest du die Zeile <em>{{ rule.productCode }}</em> wirklich aus der Besetzungsmatrix löschen?
Bereits angelegte Einsätze bleiben erhalten.
</div>
{% endblock %}
@@ -0,0 +1,7 @@
{% extends 'htmx_modal.html.twig' %}
{% block title %}Zeile bearbeiten{% endblock %}
{% block content %}
{% include 'admin/system/staffing_rule/_form.html.twig' %}
{% endblock %}
@@ -0,0 +1,77 @@
{% extends 'admin/layout.html.twig' %}
{% block title %}Einstellungen - Besetzungsmatrix - Vorschau{% endblock %}
{% block content %}
<div class="flex items-center justify-between pb-8">
<h1 class="text-2xl font-bold">
Besetzungsmatrix – Vorschau
</h1>
<div class="flex items-center space-x-2">
<a href="{{ path('app_admin_system_staffing_rule_index') }}" class="btn">
Zurück zur Matrix
</a>
<a href="{{ path('app_admin_system_staffing_rule_export') }}" class="btn">
Excel-Export
</a>
</div>
</div>
<p class="pb-4">
Alle künftigen Termine der Produkte in der Matrix, auch inaktive Zeilen. Angelegt wird nur,
was hier als „wird angelegt" steht.
</p>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner">
<table class="data-table">
<thead>
<tr>
<th>Produkt</th>
<th>Termin</th>
<th>Reisedatum</th>
<th>Status</th>
<th>Einsätze</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>{{ row.productCode }}</td>
<td>
{{ row.code }}
<br>
<span class="text-sm text-gray-500">{{ row.product }}</span>
</td>
<td class="whitespace-nowrap">
{{ row.dateFrom|date('d.m.Y') }} – {{ row.dateTo|date('d.m.Y') }}
</td>
<td class="whitespace-nowrap">{{ row.status }}</td>
<td>
<ul>
{% for assignment in row.assignments %}
<li>
{{ assignment.places }}× {{ assignment.jobProfile }}
{% if assignment.pickup %}
<span class="text-sm text-gray-500">– Busbegleitung ab {{ assignment.pickup }}</span>
{% endif %}
{% if assignment.fees %}
<span class="text-sm text-gray-500">– {{ assignment.fees }}</span>
{% endif %}
</li>
{% else %}
<li>-</li>
{% endfor %}
</ul>
</td>
</tr>
{% else %}
<tr>
<td colspan="5">
Keine Termine – die Matrix ist leer, oder keiner ihrer Produktcodes hat künftige Termine.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -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],
);
}
}