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
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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 = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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