feat: generate draft assignments from a staffing matrix
This commit is contained in:
1 parent
3116d23199
commit
2fc9806c69
41 files changed
+2618
No files matched your search
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user