feat: generate draft assignments from a staffing matrix

This commit is contained in:
fromme committed 2026-10-15 18:11:51 +02:00
1 parent 3116d23199
commit 2fc9806c69
41 files changed
+2618

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Command;
use App\Model\PlannedAssignment;
use App\Model\StaffingCandidate;
use App\Service\Assignment\AssignmentGenerator;
use App\Service\Assignment\StaffingConfigurationException;
use App\Service\Assignment\StaffingResolver;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Creates draft assignments for the future dates of every enabled staffing matrix row.
*
* Safe to repeat: a date that has ever had an assignment is skipped, so drafts the office
* edited or deleted are never recreated. Run it after app:myep-import - it only considers
* the dates the latest import reported.
*/
#[AsCommand(
name: 'app:assignment-generate',
description: 'Creates draft assignments from the staffing matrix',
)]
class AssignmentGenerateCommand extends Command
{
public function __construct(
private readonly AssignmentGenerator $generator,
private readonly StaffingResolver $resolver,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only list what would be created');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = true === $input->getOption('dry-run');
try {
$this->resolver->assertConfigured();
} catch (StaffingConfigurationException $e) {
$io->error('Jobprofil oder Honorar der Besetzungsmatrix nicht gefunden – abgebrochen: '.$e->getMessage());
$this->logger->error('Assignment generation aborted', ['reason' => $e->getMessage()]);
return Command::FAILURE;
}
$candidates = $this->generator->getCandidates(enabledOnly: true);
$open = array_values(array_filter($candidates, static fn (StaffingCandidate $c): bool => false === $c->skipped));
$io->info(sprintf(
'%d Reisetermine aus der Besetzungsmatrix, davon %d schon mit Einsätzen (übersprungen)',
count($candidates),
count($candidates) - count($open),
));
if ([] === $open) {
return Command::SUCCESS;
}
$io->table(
['Termin', 'Produkt', 'Beginn', 'Einsätze'],
array_map($this->describe(...), $open),
);
if (true === $dryRun) {
$io->note('Probelauf – es wurde nichts angelegt.');
return Command::SUCCESS;
}
$created = 0;
foreach ($open as $candidate) {
$created += count($this->generator->generate($candidate));
}
$io->success(sprintf('%d Einsätze für %d Reisetermine als Entwurf angelegt', $created, count($open)));
$this->logger->info('Assignment generation finished', [
'destinations' => count($open),
'assignments' => $created,
'skipped' => count($candidates) - count($open),
]);
return Command::SUCCESS;
}
private function describe(StaffingCandidate $candidate): array
{
return [
$candidate->destination->getCode(),
$candidate->rule->getProductCode(),
$candidate->destination->getDateFrom()->format('d.m.Y'),
implode("\n", array_map(
fn (PlannedAssignment $planned): string => sprintf(
'%d× %s%s',
$planned->places,
$this->resolver->jobProfile($planned->role)->getName(),
null === $planned->pickup ? '' : ' (Busbegleitung)',
),
$candidate->planned,
)),
];
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Controller\Admin\System\StaffingRule;
use App\Entity\StaffingRule;
use App\Form\StaffingRuleType;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class CreateController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/system/staffing-rule/create', name: 'app_admin_system_staffing_rule_create')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$rule = new StaffingRule();
$form = $this->createForm(StaffingRuleType::class, $rule);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($rule);
$this->entityManager->flush();
$this->addFlash('success', 'Die Zeile der Besetzungsmatrix wurde angelegt');
$this->logger->info('Create staffing rule', [
'staffing_rule_id' => $rule->getId(),
'product_code' => $rule->getProductCode(),
]);
return new HxRedirectResponse($this->generateUrl('app_admin_system_staffing_rule_index'));
}
return $this->render('admin/system/staffing_rule/modal_create.html.twig', [
'form' => $form->createView(),
]);
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Controller\Admin\System\StaffingRule;
use App\Entity\StaffingRule;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class DeleteController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/system/staffing-rule/delete/{uuid}', name: 'app_admin_system_staffing_rule_delete')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(StaffingRule $rule, Request $request): Response
{
if (true === $request->isMethod('POST')) {
$rule->setDeleted();
$this->entityManager->flush();
$this->addFlash('success', 'Die Zeile der Besetzungsmatrix wurde gelöscht');
$this->logger->info('Delete staffing rule', [
'staffing_rule_id' => $rule->getId(),
'product_code' => $rule->getProductCode(),
]);
return new HxRedirectResponse($this->generateUrl('app_admin_system_staffing_rule_index'));
}
return $this->render('admin/system/staffing_rule/modal_delete.html.twig', [
'rule' => $rule,
]);
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Controller\Admin\System\StaffingRule;
use App\Entity\StaffingRule;
use App\Form\StaffingRuleType;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class EditController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/system/staffing-rule/edit/{uuid}', name: 'app_admin_system_staffing_rule_edit')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(StaffingRule $rule, Request $request): Response
{
$form = $this->createForm(StaffingRuleType::class, $rule);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->flush();
$this->addFlash('success', 'Die Zeile der Besetzungsmatrix wurde aktualisiert');
$this->logger->info('Edit staffing rule', [
'staffing_rule_id' => $rule->getId(),
'product_code' => $rule->getProductCode(),
]);
return new HxRedirectResponse($this->generateUrl('app_admin_system_staffing_rule_index'));
}
return $this->render('admin/system/staffing_rule/modal_edit.html.twig', [
'form' => $form->createView(),
]);
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Controller\Admin\System\StaffingRule;
use App\Service\Assignment\StaffingConfigurationException;
use App\Service\Export\StaffingPreviewExporter;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ExportController extends AbstractController
{
public function __construct(
private readonly StaffingPreviewExporter $previewExporter,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/system/staffing-rule/export', name: 'app_admin_system_staffing_rule_export')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
try {
$rows = $this->previewExporter->rows();
} catch (StaffingConfigurationException $e) {
$this->addFlash('error', 'Ein Jobprofil oder Honorar der Besetzungsmatrix ist nicht (mehr) vorhanden: '.$e->getMessage());
return $this->redirectToRoute('app_admin_system_staffing_rule_index');
}
if ([] === $rows) {
$this->addFlash('error', 'Keine Daten für den Export vorhanden');
return $this->redirectToRoute('app_admin_system_staffing_rule_preview');
}
$spreadsheet = $this->previewExporter->export($rows);
$filename = sprintf('besetzungsmatrix-vorschau_%s.xlsx', date('YmdHi'));
$response = new StreamedResponse(static function () use ($spreadsheet): void {
(new Xlsx($spreadsheet))->save('php://output');
});
$response->headers->set(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
);
$response->headers->set(
'Content-Disposition',
HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename, md5($filename))
);
$this->logger->info('Export staffing preview', [
'count' => count($rows),
]);
return $response;
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Controller\Admin\System\StaffingRule;
use App\Repository\StaffingRuleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController
{
public function __construct(private readonly StaffingRuleRepository $staffingRuleRepository)
{
}
#[Route('/admin/system/staffing-rule', name: 'app_admin_system_staffing_rule_index')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
return $this->render('admin/system/staffing_rule/index.html.twig', [
'rules' => $this->staffingRuleRepository->getList(),
]);
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Controller\Admin\System\StaffingRule;
use App\Service\Assignment\StaffingConfigurationException;
use App\Service\Export\StaffingPreviewExporter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class PreviewController extends AbstractController
{
public function __construct(private readonly StaffingPreviewExporter $previewExporter)
{
}
#[Route('/admin/system/staffing-rule/preview', name: 'app_admin_system_staffing_rule_preview')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
try {
$rows = $this->previewExporter->rows();
} catch (StaffingConfigurationException $e) {
$this->addFlash('error', 'Ein Jobprofil oder Honorar der Besetzungsmatrix ist nicht (mehr) vorhanden: '.$e->getMessage());
return $this->redirectToRoute('app_admin_system_staffing_rule_index');
}
return $this->render('admin/system/staffing_rule/preview.html.twig', [
'rows' => $rows,
]);
}
}
+38
View File
@@ -55,6 +55,20 @@ class Destination implements TimestampableEntityInterface, SoftDeletableEntityIn
#[ORM\Column(length: 2, nullable: true)]
private ?string $country = null;
/**
* The travel this date belongs to, as MyE&P reports it. Not the date code minus its date
* suffix - see App\MyEp\Import\DestinationRow::readProductCode().
*/
#[ORM\Column(length: 32, nullable: true)]
private ?string $productCode = null;
/**
* When app:myep-import last saw this row. Not updatedAt: a destination is editable in the
* UI, so updatedAt cannot tell a row the latest import touched from one it no longer reports.
*/
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $importedAt = null;
public function __toString(): string
{
return sprintf(
@@ -234,4 +248,28 @@ class Destination implements TimestampableEntityInterface, SoftDeletableEntityIn
return $this;
}
public function getProductCode(): ?string
{
return $this->productCode;
}
public function setProductCode(?string $productCode): static
{
$this->productCode = $productCode;
return $this;
}
public function getImportedAt(): ?\DateTimeImmutable
{
return $this->importedAt;
}
public function setImportedAt(?\DateTimeImmutable $importedAt): static
{
$this->importedAt = $importedAt;
return $this;
}
}
+226
View File
@@ -0,0 +1,226 @@
<?php
namespace App\Entity;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\SoftDeletableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\StaffingRuleRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* One row of the stakeholders' staffing matrix: how every date of a product is staffed.
* App\Service\Assignment\AssignmentPlanner turns it into draft assignments.
*
* Unique among live rows only, so no database index: a soft-deleted row keeps its code.
*/
#[ORM\Entity(repositoryClass: StaffingRuleRepository::class)]
#[UniqueEntity(
fields: ['productCode'],
message: 'Für dieses Produkt gibt es bereits eine Zeile',
repositoryMethod: 'findLiveByProductCode'
)]
class StaffingRule implements BlameableEntityInterface, TimestampableEntityInterface, SoftDeletableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
use SoftDeletableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
/** The travel code MyE&P reports as productCode, not a date-code prefix. */
#[ORM\Column(length: 32)]
#[Assert\NotBlank(message: 'Bitte gib den Produktcode an')]
#[Assert\Length(max: 32, maxMessage: 'Der Produktcode darf höchstens {{ limit }} Zeichen lang sein')]
private ?string $productCode = null;
#[ORM\Column]
#[Assert\NotNull(message: 'Bitte gib die Anzahl der Reiseleitungen an')]
#[Assert\PositiveOrZero(message: 'Die Anzahl darf nicht negativ sein')]
private ?int $guides = 1;
#[ORM\Column]
private bool $skiBeginner = false;
#[ORM\Column]
private bool $skiAdvanced = false;
/** The Reiseleitungen take over the ski levels, one level per place. */
#[ORM\Column]
private bool $split = false;
#[ORM\Column]
private bool $guiding = false;
#[ORM\Column]
private bool $busEscort = false;
/** Previewed either way; only enabled rows generate. */
#[ORM\Column]
private bool $enabled = false;
public function __construct()
{
$this->uuid = Uuid::v4();
}
#[Assert\Callback]
public function validateCombination(ExecutionContextInterface $context): void
{
$levels = $this->getSkiLevelCount();
if (true === $this->split && 0 === $levels) {
$context
->buildViolation('Ein Split braucht mindestens einen Skikurs')
->atPath('split')
->addViolation()
;
}
if (true === $this->split && $levels > (int) $this->guides) {
$context
->buildViolation('Bei einem Split braucht jedes Kursniveau eine eigene Reiseleitung')
->atPath('split')
->addViolation()
;
}
if (true === $this->guiding && 0 === (int) $this->guides) {
$context
->buildViolation('Guiding braucht mindestens eine Reiseleitung')
->atPath('guiding')
->addViolation()
;
}
if (true === $this->busEscort && 0 === (int) $this->guides) {
$context
->buildViolation('Busbegleitung braucht mindestens eine Reiseleitung')
->atPath('busEscort')
->addViolation()
;
}
}
public function getSkiLevelCount(): int
{
return (int) $this->skiBeginner + (int) $this->skiAdvanced;
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getProductCode(): ?string
{
return $this->productCode;
}
public function setProductCode(?string $productCode): static
{
$productCode = strtoupper(trim((string) $productCode));
$this->productCode = '' === $productCode ? null : $productCode;
return $this;
}
public function getGuides(): ?int
{
return $this->guides;
}
public function setGuides(?int $guides): static
{
$this->guides = $guides;
return $this;
}
public function isSkiBeginner(): bool
{
return $this->skiBeginner;
}
public function setSkiBeginner(bool $skiBeginner): static
{
$this->skiBeginner = $skiBeginner;
return $this;
}
public function isSkiAdvanced(): bool
{
return $this->skiAdvanced;
}
public function setSkiAdvanced(bool $skiAdvanced): static
{
$this->skiAdvanced = $skiAdvanced;
return $this;
}
public function isSplit(): bool
{
return $this->split;
}
public function setSplit(bool $split): static
{
$this->split = $split;
return $this;
}
public function isGuiding(): bool
{
return $this->guiding;
}
public function setGuiding(bool $guiding): static
{
$this->guiding = $guiding;
return $this;
}
public function isBusEscort(): bool
{
return $this->busEscort;
}
public function setBusEscort(bool $busEscort): static
{
$this->busEscort = $busEscort;
return $this;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Enum;
/**
* A fee the staffing matrix puts on a generated assignment. Each case maps to one Fee row
* through the auto_assignment_fees parameter; the values are its keys.
*/
enum StaffingFee: string
{
case GUIDING = 'guiding';
case COURSE = 'course';
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Enum;
/**
* A role the staffing matrix generates an assignment for. Each case maps to one job profile
* through the auto_assignment_job_profiles parameter; the values are its keys.
*/
enum StaffingRole: string
{
case GUIDE = 'guide';
case SKI_BEGINNER = 'ski_beginner';
case SKI_ADVANCED = 'ski_advanced';
case GUIDE_SKI_BEGINNER = 'guide_ski_beginner';
case GUIDE_SKI_ADVANCED = 'guide_ski_advanced';
/** A Reiseleitung, alone or combined with a course: the roles that can guide and escort the bus. */
public function isGuide(): bool
{
return match ($this) {
self::GUIDE, self::GUIDE_SKI_BEGINNER, self::GUIDE_SKI_ADVANCED => true,
self::SKI_BEGINNER, self::SKI_ADVANCED => false,
};
}
public function isCourse(): bool
{
return self::GUIDE !== $this;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Form;
use App\Entity\StaffingRule;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class StaffingRuleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('productCode', TextType::class, [
'label' => 'Produktcode',
'help' => 'Der Code der Reise in BusPro, nicht der eines Termins (z. B. DWWMP, nicht DWWMW)',
])
->add('guides', IntegerType::class, [
'label' => 'Anzahl Reiseleitungen',
'attr' => ['min' => 0],
])
->add('skiBeginner', CheckboxType::class, [
'label' => 'Ski Anfänger:innen',
'required' => false,
])
->add('skiAdvanced', CheckboxType::class, [
'label' => 'Ski Fortgeschrittene',
'required' => false,
])
->add('split', CheckboxType::class, [
'label' => 'Split',
'help' => 'Die Reiseleitungen übernehmen die Kurse (Reise- und Kursleitung)',
'required' => false,
])
->add('guiding', CheckboxType::class, [
'label' => 'Guiding',
'required' => false,
])
->add('busEscort', CheckboxType::class, [
'label' => 'Busbegleitung',
'required' => false,
])
->add('enabled', CheckboxType::class, [
'label' => 'aktiv',
'help' => 'Nur aktive Zeilen legen Einsätze an; die Vorschau zeigt alle',
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => StaffingRule::class,
]);
}
}
+11
View File
@@ -266,6 +266,17 @@ class AdminMenuBuilder extends AbstractMenuBuilder
],
],
]);
$settingsMenu->addChild('Besetzungsmatrix', [
'route' => 'app_admin_system_staffing_rule_index',
'linkAttributes' => [
'title' => 'Besetzungsmatrix',
],
'extras' => [
'routes' => [
['pattern' => '/^app_admin_system_staffing_rule_/'],
],
],
]);
$settingsMenu->addChild('Reisen', [
'route' => 'app_administrative_system_destination_index',
'linkAttributes' => [
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Model;
use App\Enum\StaffingFee;
use App\Enum\StaffingRole;
/**
* One assignment App\Service\Assignment\AssignmentPlanner would create, before its role and
* fees are resolved to entities.
*/
final readonly class PlannedAssignment
{
/**
* @param list<StaffingFee> $fees
*/
public function __construct(
public StaffingRole $role,
public int $places,
/** busProId of the pre-selected boarding point ("Busbegleitung ab"), null for none */
public ?int $pickup = null,
public array $fees = [],
) {
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Model;
use App\Entity\Destination;
use App\Entity\StaffingRule;
/**
* A travel date the staffing matrix applies to, with what would be created for it.
*/
final readonly class StaffingCandidate
{
/**
* @param list<PlannedAssignment> $planned
*/
public function __construct(
public Destination $destination,
public StaffingRule $rule,
public array $planned,
/** It has had an assignment before, so it is never generated again. */
public bool $skipped,
) {
}
}
+19
View File
@@ -23,6 +23,8 @@ class DestinationRow
/** lowercase ISO-3166-1 alpha-2, null when MyE&P could not resolve one */
public readonly ?string $country,
public readonly array $pickups,
/** the enclosing travel's code, null for one-off dates that have none */
public readonly ?string $productCode = null,
) {
}
@@ -56,6 +58,7 @@ class DestinationRow
self::readNullableString($hotel, 'costUnit'),
CountryCode::normalize($hotel['countryCode'] ?? null),
self::readPickups($data['pickups'] ?? []),
self::readProductCode($data),
);
}
@@ -76,6 +79,8 @@ class DestinationRow
'pickups' => json_encode($this->pickups),
'cost_unit' => $this->costUnit,
'country' => $this->country,
'product_code' => $this->productCode,
'imported_at' => $now->format('Y-m-d H:i:s'),
'updated_at' => $now->format('Y-m-d H:i:s'),
];
}
@@ -94,6 +99,8 @@ class DestinationRow
'pickups' => json_encode($this->pickups),
'cost_unit' => $this->costUnit,
'country' => $this->country,
'product_code' => $this->productCode,
'imported_at' => $now->format('Y-m-d H:i:s'),
'created_at' => $now->format('Y-m-d H:i:s'),
];
}
@@ -152,6 +159,18 @@ class DestinationRow
return $mapped;
}
/**
* Not derivable from the date code: a third of the dates run under a code that differs from
* their travel's (DWWMP runs DWWMW..., group travels run their own), so MyE&P reports the
* enclosing travel's code. The staffing matrix (App\Entity\StaffingRule) is keyed on it.
*/
private static function readProductCode(array $data): ?string
{
$code = trim((string) ($data['productCode'] ?? ''));
return '' === $code ? null : $code;
}
private static function readNullableString(array $data, string $key): ?string
{
$value = $data[$key] ?? null;
+26
View File
@@ -4,6 +4,7 @@ namespace App\Repository;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\Disposition;
use App\Entity\JobProfile;
use App\Entity\Teamer;
@@ -405,4 +406,29 @@ class AssignmentRepository extends ServiceEntityRepository
->getQuery()
;
}
/**
* Which of these destinations have ever had an assignment - soft-deleted and called-off ones
* included, because a draft the office deleted must not be generated again.
*
* @param list<Destination> $destinations
*
* @return list<int> destination ids
*/
public function getDestinationIdsWithAnyAssignment(array $destinations): array
{
if ([] === $destinations) {
return [];
}
$qb = $this->createQueryBuilder('assignment');
return array_map('intval', $qb
->select('DISTINCT IDENTITY(assignment.destination)')
->where($qb->expr()->in('assignment.destination', ':destinations'))
->setParameter('destinations', $destinations)
->getQuery()
->getSingleColumnResult()
);
}
}
+37
View File
@@ -124,4 +124,41 @@ class DestinationRepository extends ServiceEntityRepository
->getArrayResult()
;
}
/**
* The dates the staffing matrix applies to: live, starting after $today, of one of the
* matrix's products, and reported by the latest import. The last condition keeps out dates
* cancelled upstream - the import never deletes, so a vanished date keeps its row and its
* product code, but not a current importedAt.
*
* @param list<string> $productCodes
*
* @return list<Destination>
*/
public function getStaffingCandidates(array $productCodes, \DateTimeImmutable $today): array
{
if ([] === $productCodes) {
return [];
}
$qb = $this->createQueryBuilder('destination');
return $qb
->where($qb->expr()->andX(
$qb->expr()->isNull('destination.deletedAt'),
$qb->expr()->in('destination.productCode', ':productCodes'),
$qb->expr()->gt('destination.dateFrom', ':today'),
$qb->expr()->eq(
'destination.importedAt',
'(SELECT MAX(latest.importedAt) FROM '.Destination::class.' latest)',
),
))
->setParameter('productCodes', $productCodes)
->setParameter('today', $today->format('Y-m-d'))
->orderBy('destination.dateFrom', \SortDirection::Ascending)
->addOrderBy('destination.productCode', \SortDirection::Ascending)
->getQuery()
->getResult()
;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Repository;
use App\Entity\StaffingRule;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<StaffingRule>
*
* @method StaffingRule|null find($id, $lockMode = null, $lockVersion = null)
* @method StaffingRule|null findOneBy(array $criteria, array $orderBy = null)
* @method StaffingRule[] findAll()
* @method StaffingRule[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class StaffingRuleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, StaffingRule::class);
}
/**
* @return list<StaffingRule>
*/
public function getList(): array
{
$qb = $this->createQueryBuilder('staffing_rule');
return $qb
->where($qb->expr()->isNull('staffing_rule.deletedAt'))
->orderBy('staffing_rule.productCode', \SortDirection::Ascending)
->getQuery()
->getResult()
;
}
/**
* For UniqueEntity: a soft-deleted row keeps its code and must not block a new one.
*
* @return list<StaffingRule>
*/
public function findLiveByProductCode(array $fields): array
{
$qb = $this->createQueryBuilder('staffing_rule');
return $qb
->where($qb->expr()->andX(
$qb->expr()->eq('staffing_rule.productCode', ':productCode'),
$qb->expr()->isNull('staffing_rule.deletedAt'),
))
->setParameter('productCode', $fields['productCode'])
->getQuery()
->getResult()
;
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Service\Assignment;
use App\Entity\Assignment;
use App\Model\StaffingCandidate;
use App\Repository\AssignmentRepository;
use App\Repository\DestinationRepository;
use App\Repository\StaffingRuleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Creates the draft assignments the staffing matrix calls for. Shared by app:assignment-generate
* and the admin preview, so what the stakeholders confirm is what gets generated.
*/
class AssignmentGenerator
{
public function __construct(
private readonly StaffingRuleRepository $staffingRuleRepository,
private readonly DestinationRepository $destinationRepository,
private readonly AssignmentRepository $assignmentRepository,
private readonly AssignmentPlanner $planner,
private readonly StaffingResolver $resolver,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
/**
* @param bool $enabledOnly false for the preview, which shows rows not yet switched on
*
* @return list<StaffingCandidate>
*/
public function getCandidates(bool $enabledOnly, ?\DateTimeImmutable $today = null): array
{
$rules = [];
foreach ($this->staffingRuleRepository->getList() as $rule) {
if (false === $enabledOnly || true === $rule->isEnabled()) {
$rules[$rule->getProductCode()] = $rule;
}
}
$destinations = $this->destinationRepository->getStaffingCandidates(
array_map('strval', array_keys($rules)),
$today ?? new \DateTimeImmutable('today'),
);
$skipped = array_flip($this->assignmentRepository->getDestinationIdsWithAnyAssignment($destinations));
$candidates = [];
foreach ($destinations as $destination) {
$rule = $rules[$destination->getProductCode()];
$candidates[] = new StaffingCandidate(
$destination,
$rule,
$this->planner->plan($rule, $destination),
isset($skipped[$destination->getId()]),
);
}
return $candidates;
}
/**
* Persists and flushes the candidate's drafts.
*
* @return list<Assignment>
*
* @throws StaffingConfigurationException when a configured job profile or fee is missing
*/
public function generate(StaffingCandidate $candidate): array
{
if (true === $candidate->skipped) {
return [];
}
$assignments = [];
foreach ($candidate->planned as $planned) {
$assignment = (new Assignment())
->setDestination($candidate->destination)
->setJobProfile($this->resolver->jobProfile($planned->role))
->setAvailableDispositions($planned->places)
->setPickup($planned->pickup)
;
foreach ($planned->fees as $fee) {
$assignment->addFee($this->resolver->fee($fee));
}
$this->entityManager->persist($assignment);
$assignments[] = $assignment;
}
$this->entityManager->flush();
foreach ($assignments as $assignment) {
$this->logger->info('Generate assignment', [
'assignment_id' => $assignment->getId(),
'destination_id' => $candidate->destination->getId(),
'destination_code' => $candidate->destination->getCode(),
'product_code' => $candidate->rule->getProductCode(),
'job_profile' => $assignment->getJobProfile()->getName(),
'places' => $assignment->getAvailableDispositions(),
'pickup' => $assignment->getPickup(),
]);
}
return $assignments;
}
}
@@ -0,0 +1,101 @@
<?php
namespace App\Service\Assignment;
use App\Entity\Destination;
use App\Entity\StaffingRule;
use App\Enum\StaffingFee;
use App\Enum\StaffingRole;
use App\Model\PlannedAssignment;
use App\Service\Common\PickupOptions;
/**
* Turns a staffing matrix row into the draft assignments for one travel date.
*
* The rules, agreed 2026-10-15 (see .ddev/plans/teamer-auto-assignments-plan.md):
* - one assignment per role, the role's head count as its places;
* - without split, each ski level is a Kursleitung of its own, one place;
* - with split, a Reiseleitung takes over each level through the combined profile, one place
* per level, and the plain Reiseleitung keeps the remaining places;
* - Guiding is the guiding fee on every guide-type assignment, not an assignment of its own;
* - Busbegleitung pre-selects the first boarding point on exactly one assignment, the first
* guide-type one - the plain Reiseleitung where there is one.
*/
final class AssignmentPlanner
{
/**
* @return list<PlannedAssignment>
*/
public function plan(StaffingRule $rule, Destination $destination): array
{
$roles = [];
$guides = (int) $rule->getGuides();
if (true === $rule->isSplit()) {
$combined = [];
if (true === $rule->isSkiBeginner()) {
$combined[] = StaffingRole::GUIDE_SKI_BEGINNER;
}
if (true === $rule->isSkiAdvanced()) {
$combined[] = StaffingRole::GUIDE_SKI_ADVANCED;
}
// The matrix validation refuses more levels than guides; max() keeps a row that
// slipped past it from planning a negative head count.
$roles[] = [StaffingRole::GUIDE, max(0, $guides - count($combined))];
foreach ($combined as $role) {
$roles[] = [$role, 1];
}
} else {
$roles[] = [StaffingRole::GUIDE, $guides];
if (true === $rule->isSkiBeginner()) {
$roles[] = [StaffingRole::SKI_BEGINNER, 1];
}
if (true === $rule->isSkiAdvanced()) {
$roles[] = [StaffingRole::SKI_ADVANCED, 1];
}
}
$pickup = true === $rule->isBusEscort() ? $this->firstPickup($destination) : null;
$planned = [];
foreach ($roles as [$role, $places]) {
if (0 === $places) {
continue;
}
$fees = [];
if (true === $role->isGuide() && true === $rule->isGuiding()) {
$fees[] = StaffingFee::GUIDING;
}
if (true === $role->isCourse()) {
$fees[] = StaffingFee::COURSE;
}
$planned[] = new PlannedAssignment(
$role,
$places,
true === $role->isGuide() ? $pickup : null,
$fees,
);
if (true === $role->isGuide()) {
$pickup = null;
}
}
return $planned;
}
private function firstPickup(Destination $destination): ?int
{
return PickupOptions::inDepartureOrder($destination)[0]['busProId'] ?? null;
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Service\Assignment;
/**
* A job profile or fee the staffing matrix is configured to use does not exist (any more).
*/
class StaffingConfigurationException extends \RuntimeException
{
}
@@ -0,0 +1,93 @@
<?php
namespace App\Service\Assignment;
use App\Entity\Fee;
use App\Entity\JobProfile;
use App\Enum\StaffingFee;
use App\Enum\StaffingRole;
use App\Repository\FeeRepository;
use App\Repository\JobProfileRepository;
/**
* Resolves the matrix's roles and fees to the job profiles and fees configured in
* auto_assignment_job_profiles and auto_assignment_fees.
*
* Refuses instead of skipping: a draft with a missing course profile or fee looks complete and
* would get published as it is.
*/
class StaffingResolver
{
/** @var array<string, JobProfile> */
private array $jobProfiles = [];
/** @var array<string, Fee> */
private array $fees = [];
/**
* @param array<string, int> $autoAssignmentJobProfiles
* @param array<string, int> $autoAssignmentFees
*/
public function __construct(
private readonly JobProfileRepository $jobProfileRepository,
private readonly FeeRepository $feeRepository,
private readonly array $autoAssignmentJobProfiles,
private readonly array $autoAssignmentFees,
) {
}
/**
* Resolves every role and fee once, so a broken id stops a run before it writes anything
* rather than halfway through.
*
* @throws StaffingConfigurationException
*/
public function assertConfigured(): void
{
foreach (StaffingRole::cases() as $role) {
$this->jobProfile($role);
}
foreach (StaffingFee::cases() as $fee) {
$this->fee($fee);
}
}
/**
* @throws StaffingConfigurationException when the role has no live job profile configured
*/
public function jobProfile(StaffingRole $role): JobProfile
{
if (isset($this->jobProfiles[$role->value])) {
return $this->jobProfiles[$role->value];
}
$id = $this->autoAssignmentJobProfiles[$role->value] ?? null;
$jobProfile = null === $id ? null : $this->jobProfileRepository->find($id);
if (null === $jobProfile || null !== $jobProfile->getDeletedAt()) {
throw new StaffingConfigurationException(sprintf('auto_assignment_job_profiles.%s: no live job profile with id %s', $role->value, $id ?? 'null'));
}
return $this->jobProfiles[$role->value] = $jobProfile;
}
/**
* @throws StaffingConfigurationException when the fee has no live fee row configured
*/
public function fee(StaffingFee $staffingFee): Fee
{
if (isset($this->fees[$staffingFee->value])) {
return $this->fees[$staffingFee->value];
}
$id = $this->autoAssignmentFees[$staffingFee->value] ?? null;
$fee = null === $id ? null : $this->feeRepository->find($id);
if (null === $fee || null !== $fee->getDeletedAt()) {
throw new StaffingConfigurationException(sprintf('auto_assignment_fees.%s: no live fee with id %s', $staffingFee->value, $id ?? 'null'));
}
return $this->fees[$staffingFee->value] = $fee;
}
}
@@ -0,0 +1,121 @@
<?php
namespace App\Service\Export;
use App\Model\PlannedAssignment;
use App\Model\StaffingCandidate;
use App\Service\Assignment\AssignmentGenerator;
use App\Service\Assignment\StaffingConfigurationException;
use App\Service\Assignment\StaffingResolver;
use App\Service\Common\PickupOptions;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
/**
* What the staffing matrix would create, for the stakeholders to confirm: one row per travel
* date and planned assignment. The admin page and the .xlsx download both read rows(), so
* they cannot disagree.
*/
final class StaffingPreviewExporter
{
public const STATUS_CREATE = 'wird angelegt';
public const STATUS_DISABLED = 'Zeile inaktiv';
public const STATUS_SKIPPED = 'hat schon Einsätze';
private const HEADERS = [
'Produkt',
'Termin',
'Beginn',
'Ende',
'Reise',
'Status',
'Jobprofil',
'Plätze',
'Busbegleitung ab',
'Honorare',
];
public function __construct(
private readonly AssignmentGenerator $generator,
private readonly StaffingResolver $resolver,
) {
}
/**
* @return list<array{productCode: string, code: ?string, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable, product: ?string, status: string, assignments: list<array{jobProfile: string, places: int, pickup: ?string, fees: string}>}>
*
* @throws StaffingConfigurationException when a configured job profile or fee is missing
*/
public function rows(): array
{
return array_map(fn (StaffingCandidate $candidate): array => [
'productCode' => (string) $candidate->rule->getProductCode(),
'code' => $candidate->destination->getCode(),
'dateFrom' => $candidate->destination->getDateFrom(),
'dateTo' => $candidate->destination->getDateTo(),
'product' => $candidate->destination->getProduct(),
'status' => match (true) {
$candidate->skipped => self::STATUS_SKIPPED,
false === $candidate->rule->isEnabled() => self::STATUS_DISABLED,
default => self::STATUS_CREATE,
},
'assignments' => array_map(fn (PlannedAssignment $planned): array => [
'jobProfile' => (string) $this->resolver->jobProfile($planned->role)->getName(),
'places' => $planned->places,
'pickup' => $this->pickupLabel($candidate, $planned),
'fees' => implode(', ', array_map(
fn ($fee): string => (string) $this->resolver->fee($fee)->getName(),
$planned->fees,
)),
], $candidate->planned),
], $this->generator->getCandidates(enabledOnly: false));
}
public function export(array $rows): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Besetzungsmatrix');
$sheet->fromArray(self::HEADERS);
$sheet->getStyle('A1:J1')->getFont()->setBold(true);
$sheet->freezePane('A2');
$line = 2;
foreach ($rows as $row) {
// a date without planned assignments still gets its line
foreach ($row['assignments'] ?: [null] as $assignment) {
$sheet->fromArray([
$row['productCode'],
$row['code'],
$row['dateFrom']->format('d.m.Y'),
$row['dateTo']->format('d.m.Y'),
$row['product'],
$row['status'],
$assignment['jobProfile'] ?? null,
$assignment['places'] ?? null,
$assignment['pickup'] ?? null,
$assignment['fees'] ?? null,
], null, 'A'.$line++, true);
}
}
foreach (range('A', 'J') as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
return $spreadsheet;
}
private function pickupLabel(StaffingCandidate $candidate, PlannedAssignment $planned): ?string
{
foreach (PickupOptions::inDepartureOrder($candidate->destination) as $option) {
if ($option['busProId'] === $planned->pickup) {
return null === $option['departure']
? $option['label']
: sprintf('%s, %s Uhr', $option['label'], $option['departure']);
}
}
return null;
}
}