diff --git a/config/services.yaml b/config/services.yaml index 9a5deb9..5117d1f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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 diff --git a/migrations/Version20261015154754.php b/migrations/Version20261015154754.php new file mode 100644 index 0000000..78566d0 --- /dev/null +++ b/migrations/Version20261015154754.php @@ -0,0 +1,38 @@ +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'); + } +} diff --git a/src/Command/AssignmentGenerateCommand.php b/src/Command/AssignmentGenerateCommand.php new file mode 100644 index 0000000..9a24ed4 --- /dev/null +++ b/src/Command/AssignmentGenerateCommand.php @@ -0,0 +1,115 @@ +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, + )), + ]; + } +} diff --git a/src/Controller/Admin/System/StaffingRule/CreateController.php b/src/Controller/Admin/System/StaffingRule/CreateController.php new file mode 100644 index 0000000..910863a --- /dev/null +++ b/src/Controller/Admin/System/StaffingRule/CreateController.php @@ -0,0 +1,49 @@ +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(), + ]); + } +} diff --git a/src/Controller/Admin/System/StaffingRule/DeleteController.php b/src/Controller/Admin/System/StaffingRule/DeleteController.php new file mode 100644 index 0000000..cf3cfda --- /dev/null +++ b/src/Controller/Admin/System/StaffingRule/DeleteController.php @@ -0,0 +1,44 @@ +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, + ]); + } +} diff --git a/src/Controller/Admin/System/StaffingRule/EditController.php b/src/Controller/Admin/System/StaffingRule/EditController.php new file mode 100644 index 0000000..7432b18 --- /dev/null +++ b/src/Controller/Admin/System/StaffingRule/EditController.php @@ -0,0 +1,47 @@ +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(), + ]); + } +} diff --git a/src/Controller/Admin/System/StaffingRule/ExportController.php b/src/Controller/Admin/System/StaffingRule/ExportController.php new file mode 100644 index 0000000..e55de06 --- /dev/null +++ b/src/Controller/Admin/System/StaffingRule/ExportController.php @@ -0,0 +1,64 @@ +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; + } +} diff --git a/src/Controller/Admin/System/StaffingRule/IndexController.php b/src/Controller/Admin/System/StaffingRule/IndexController.php new file mode 100644 index 0000000..d8644c9 --- /dev/null +++ b/src/Controller/Admin/System/StaffingRule/IndexController.php @@ -0,0 +1,25 @@ +render('admin/system/staffing_rule/index.html.twig', [ + 'rules' => $this->staffingRuleRepository->getList(), + ]); + } +} diff --git a/src/Controller/Admin/System/StaffingRule/PreviewController.php b/src/Controller/Admin/System/StaffingRule/PreviewController.php new file mode 100644 index 0000000..a9025ee --- /dev/null +++ b/src/Controller/Admin/System/StaffingRule/PreviewController.php @@ -0,0 +1,34 @@ +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, + ]); + } +} diff --git a/src/Entity/Destination.php b/src/Entity/Destination.php index b64d9ad..aadf8ce 100644 --- a/src/Entity/Destination.php +++ b/src/Entity/Destination.php @@ -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; + } } diff --git a/src/Entity/StaffingRule.php b/src/Entity/StaffingRule.php new file mode 100644 index 0000000..b28ba12 --- /dev/null +++ b/src/Entity/StaffingRule.php @@ -0,0 +1,226 @@ +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; + } +} diff --git a/src/Enum/StaffingFee.php b/src/Enum/StaffingFee.php new file mode 100644 index 0000000..63538f0 --- /dev/null +++ b/src/Enum/StaffingFee.php @@ -0,0 +1,13 @@ + true, + self::SKI_BEGINNER, self::SKI_ADVANCED => false, + }; + } + + public function isCourse(): bool + { + return self::GUIDE !== $this; + } +} diff --git a/src/Form/StaffingRuleType.php b/src/Form/StaffingRuleType.php new file mode 100644 index 0000000..e980701 --- /dev/null +++ b/src/Form/StaffingRuleType.php @@ -0,0 +1,61 @@ +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, + ]); + } +} diff --git a/src/Menu/AdminMenuBuilder.php b/src/Menu/AdminMenuBuilder.php index 84ff05d..19cc491 100644 --- a/src/Menu/AdminMenuBuilder.php +++ b/src/Menu/AdminMenuBuilder.php @@ -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' => [ diff --git a/src/Model/PlannedAssignment.php b/src/Model/PlannedAssignment.php new file mode 100644 index 0000000..ba7a5b0 --- /dev/null +++ b/src/Model/PlannedAssignment.php @@ -0,0 +1,25 @@ + $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 = [], + ) { + } +} diff --git a/src/Model/StaffingCandidate.php b/src/Model/StaffingCandidate.php new file mode 100644 index 0000000..6663668 --- /dev/null +++ b/src/Model/StaffingCandidate.php @@ -0,0 +1,24 @@ + $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, + ) { + } +} diff --git a/src/MyEp/Import/DestinationRow.php b/src/MyEp/Import/DestinationRow.php index 5380894..6999bc7 100644 --- a/src/MyEp/Import/DestinationRow.php +++ b/src/MyEp/Import/DestinationRow.php @@ -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; diff --git a/src/Repository/AssignmentRepository.php b/src/Repository/AssignmentRepository.php index 69d9df6..337f90d 100644 --- a/src/Repository/AssignmentRepository.php +++ b/src/Repository/AssignmentRepository.php @@ -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 $destinations + * + * @return list 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() + ); + } } diff --git a/src/Repository/DestinationRepository.php b/src/Repository/DestinationRepository.php index b908081..1cb26e7 100644 --- a/src/Repository/DestinationRepository.php +++ b/src/Repository/DestinationRepository.php @@ -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 $productCodes + * + * @return list + */ + 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() + ; + } } diff --git a/src/Repository/StaffingRuleRepository.php b/src/Repository/StaffingRuleRepository.php new file mode 100644 index 0000000..22046e5 --- /dev/null +++ b/src/Repository/StaffingRuleRepository.php @@ -0,0 +1,58 @@ + + * + * @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 + */ + 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 + */ + 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() + ; + } +} diff --git a/src/Service/Assignment/AssignmentGenerator.php b/src/Service/Assignment/AssignmentGenerator.php new file mode 100644 index 0000000..667461b --- /dev/null +++ b/src/Service/Assignment/AssignmentGenerator.php @@ -0,0 +1,114 @@ + + */ + 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 + * + * @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; + } +} diff --git a/src/Service/Assignment/AssignmentPlanner.php b/src/Service/Assignment/AssignmentPlanner.php new file mode 100644 index 0000000..6029973 --- /dev/null +++ b/src/Service/Assignment/AssignmentPlanner.php @@ -0,0 +1,101 @@ + + */ + 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; + } +} diff --git a/src/Service/Assignment/StaffingConfigurationException.php b/src/Service/Assignment/StaffingConfigurationException.php new file mode 100644 index 0000000..8423906 --- /dev/null +++ b/src/Service/Assignment/StaffingConfigurationException.php @@ -0,0 +1,10 @@ + */ + private array $jobProfiles = []; + + /** @var array */ + private array $fees = []; + + /** + * @param array $autoAssignmentJobProfiles + * @param array $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; + } +} diff --git a/src/Service/Export/StaffingPreviewExporter.php b/src/Service/Export/StaffingPreviewExporter.php new file mode 100644 index 0000000..3d640de --- /dev/null +++ b/src/Service/Export/StaffingPreviewExporter.php @@ -0,0 +1,121 @@ +}> + * + * @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; + } +} diff --git a/templates/admin/system/staffing_rule/_form.html.twig b/templates/admin/system/staffing_rule/_form.html.twig new file mode 100644 index 0000000..0278b1f --- /dev/null +++ b/templates/admin/system/staffing_rule/_form.html.twig @@ -0,0 +1,16 @@ +{{ form_start(form, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' } }) }} +
+ {{ 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) }} +
+ +{{ form_rest(form) }} +{{ form_end(form) }} diff --git a/templates/admin/system/staffing_rule/index.html.twig b/templates/admin/system/staffing_rule/index.html.twig new file mode 100644 index 0000000..0267cb1 --- /dev/null +++ b/templates/admin/system/staffing_rule/index.html.twig @@ -0,0 +1,86 @@ +{% extends 'admin/layout.html.twig' %} + +{% block title %}Einstellungen - Besetzungsmatrix{% endblock %} + +{% macro yes_no(value) %}{{ value ? 'ja' : '-' }}{% endmacro %} + +{% block content %} +
+

+ Besetzungsmatrix +

+ + Vorschau + +
+

+ 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. +

+
+
+ + + + + + + + + + + + + + + + {% for rule in rules %} + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
ProduktReiseleitungenSki Anfänger:innenSki FortgeschritteneSplitGuidingBusbegleitungaktiv
{{ rule.productCode }}{{ rule.guides }}{{ _self.yes_no(rule.skiBeginner) }}{{ _self.yes_no(rule.skiAdvanced) }}{{ _self.yes_no(rule.split) }}{{ _self.yes_no(rule.guiding) }}{{ _self.yes_no(rule.busEscort) }}{{ _self.yes_no(rule.enabled) }} +
+ + +
+
+ Keine Daten... +
+
+
+ +{% endblock %} diff --git a/templates/admin/system/staffing_rule/modal_create.html.twig b/templates/admin/system/staffing_rule/modal_create.html.twig new file mode 100644 index 0000000..e42ccd5 --- /dev/null +++ b/templates/admin/system/staffing_rule/modal_create.html.twig @@ -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 %} diff --git a/templates/admin/system/staffing_rule/modal_delete.html.twig b/templates/admin/system/staffing_rule/modal_delete.html.twig new file mode 100644 index 0000000..2284d38 --- /dev/null +++ b/templates/admin/system/staffing_rule/modal_delete.html.twig @@ -0,0 +1,8 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du die Zeile {{ rule.productCode }} wirklich aus der Besetzungsmatrix löschen? + Bereits angelegte Einsätze bleiben erhalten. +
+{% endblock %} diff --git a/templates/admin/system/staffing_rule/modal_edit.html.twig b/templates/admin/system/staffing_rule/modal_edit.html.twig new file mode 100644 index 0000000..1eae1fb --- /dev/null +++ b/templates/admin/system/staffing_rule/modal_edit.html.twig @@ -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 %} diff --git a/templates/admin/system/staffing_rule/preview.html.twig b/templates/admin/system/staffing_rule/preview.html.twig new file mode 100644 index 0000000..d64ecb7 --- /dev/null +++ b/templates/admin/system/staffing_rule/preview.html.twig @@ -0,0 +1,77 @@ +{% extends 'admin/layout.html.twig' %} + +{% block title %}Einstellungen - Besetzungsmatrix - Vorschau{% endblock %} + +{% block content %} +
+

+ Besetzungsmatrix – Vorschau +

+ +
+

+ Alle künftigen Termine der Produkte in der Matrix, auch inaktive Zeilen. Angelegt wird nur, + was hier als „wird angelegt" steht. +

+
+
+ + + + + + + + + + + + {% for row in rows %} + + + + + + + + {% else %} + + + + {% endfor %} + +
ProduktTerminReisedatumStatusEinsätze
{{ row.productCode }} + {{ row.code }} +
+ {{ row.product }} +
+ {{ row.dateFrom|date('d.m.Y') }} – {{ row.dateTo|date('d.m.Y') }} + {{ row.status }} +
    + {% for assignment in row.assignments %} +
  • + {{ assignment.places }}× {{ assignment.jobProfile }} + {% if assignment.pickup %} + – Busbegleitung ab {{ assignment.pickup }} + {% endif %} + {% if assignment.fees %} + – {{ assignment.fees }} + {% endif %} +
  • + {% else %} +
  • -
  • + {% endfor %} +
+
+ Keine Termine – die Matrix ist leer, oder keiner ihrer Produktcodes hat künftige Termine. +
+
+
+{% endblock %} diff --git a/tests/Command/AssignmentGenerateCommandTest.php b/tests/Command/AssignmentGenerateCommandTest.php new file mode 100644 index 0000000..090b52a --- /dev/null +++ b/tests/Command/AssignmentGenerateCommandTest.php @@ -0,0 +1,118 @@ +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 + */ + private function assignments(): array + { + $this->entityManager->clear(); + + return $this->entityManager->getRepository(Assignment::class)->findBy([], ['id' => 'ASC']); + } +} diff --git a/tests/Controller/Admin/System/StaffingRule/StaffingRuleControllerTest.php b/tests/Controller/Admin/System/StaffingRule/StaffingRuleControllerTest.php new file mode 100644 index 0000000..db88d63 --- /dev/null +++ b/tests/Controller/Admin/System/StaffingRule/StaffingRuleControllerTest.php @@ -0,0 +1,152 @@ +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('anna.admin@example.com') + ->setRoles(['ROLE_TEAM_ADMIN']) + ->setBusProAddressId(1) + ->setBusProPersonId(1) + ; + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + } +} diff --git a/tests/Entity/StaffingRuleTest.php b/tests/Entity/StaffingRuleTest.php new file mode 100644 index 0000000..7e57bd2 --- /dev/null +++ b/tests/Entity/StaffingRuleTest.php @@ -0,0 +1,64 @@ +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) + ; + } +} diff --git a/tests/MyEp/Import/DestinationRowTest.php b/tests/MyEp/Import/DestinationRowTest.php index 1b4d8c5..e1ba080 100644 --- a/tests/MyEp/Import/DestinationRowTest.php +++ b/tests/MyEp/Import/DestinationRowTest.php @@ -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', diff --git a/tests/Repository/StaffingCandidateQueryTest.php b/tests/Repository/StaffingCandidateQueryTest.php new file mode 100644 index 0000000..db9799d --- /dev/null +++ b/tests/Repository/StaffingCandidateQueryTest.php @@ -0,0 +1,105 @@ +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); + } +} diff --git a/tests/Service/Assignment/AssignmentGeneratorTest.php b/tests/Service/Assignment/AssignmentGeneratorTest.php new file mode 100644 index 0000000..39b41d9 --- /dev/null +++ b/tests/Service/Assignment/AssignmentGeneratorTest.php @@ -0,0 +1,174 @@ +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 $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; + } +} diff --git a/tests/Service/Assignment/AssignmentPlannerTest.php b/tests/Service/Assignment/AssignmentPlannerTest.php new file mode 100644 index 0000000..83e6d49 --- /dev/null +++ b/tests/Service/Assignment/AssignmentPlannerTest.php @@ -0,0 +1,169 @@ +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 + */ + 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 $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); + } +} diff --git a/tests/Service/Assignment/StaffingFixturesTrait.php b/tests/Service/Assignment/StaffingFixturesTrait.php new file mode 100644 index 0000000..a3e0a00 --- /dev/null +++ b/tests/Service/Assignment/StaffingFixturesTrait.php @@ -0,0 +1,93 @@ +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; + } +} diff --git a/tests/Service/Assignment/StaffingResolverTest.php b/tests/Service/Assignment/StaffingResolverTest.php new file mode 100644 index 0000000..df9372b --- /dev/null +++ b/tests/Service/Assignment/StaffingResolverTest.php @@ -0,0 +1,84 @@ +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 $jobProfiles + * @param array $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], + ); + } +}