Compare commits
7
Commits
f08d9a9f40
...
5d7ffd7d58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d7ffd7d58 | ||
|
|
6a8328a564 | ||
|
|
019de4e705 | ||
|
|
a1d1fdde14 | ||
|
|
1d14f00bb7 | ||
|
|
042037867b | ||
|
|
5f6ff13ce7 |
@@ -96,6 +96,8 @@ APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
|
|||||||
APP_DEFAULT_EMAIL_FROM=[email protected]
|
APP_DEFAULT_EMAIL_FROM=[email protected]
|
||||||
APP_DEFAULT_EMAIL_TO=[email protected]
|
APP_DEFAULT_EMAIL_TO=[email protected]
|
||||||
ACCOMMODATION_INQUIRY_EMAIL=[email protected]
|
ACCOMMODATION_INQUIRY_EMAIL=[email protected]
|
||||||
|
# Comma-separated; recipients of the role nomination notification. Empty means disable notification.
|
||||||
|
APP_ROLE_NOMINATION_EMAILS=
|
||||||
|
|
||||||
# Global common defaults
|
# Global common defaults
|
||||||
APP_SEASON_WINTER_FROM=2026-10-01
|
APP_SEASON_WINTER_FROM=2026-10-01
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ security:
|
|||||||
roles: [ ROLE_MAILJET_WEBHOOK ]
|
roles: [ ROLE_MAILJET_WEBHOOK ]
|
||||||
|
|
||||||
role_hierarchy:
|
role_hierarchy:
|
||||||
|
# ROLE_CUSTOMER_EXPERT gates the booking-draft and log surfaces, which used to be
|
||||||
|
# ROLE_ADMIN only. Inheriting it here is what keeps those surfaces open to administrators
|
||||||
|
# after the #[IsGranted] attributes moved to the narrower role.
|
||||||
ROLE_ADMIN:
|
ROLE_ADMIN:
|
||||||
- ROLE_GROUPS_ADMIN
|
- ROLE_GROUPS_ADMIN
|
||||||
|
- ROLE_CUSTOMER_EXPERT
|
||||||
ROLE_GROUPS_ADMIN:
|
ROLE_GROUPS_ADMIN:
|
||||||
- ROLE_GROUPS_MANAGER
|
- ROLE_GROUPS_MANAGER
|
||||||
firewalls:
|
firewalls:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ parameters:
|
|||||||
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
||||||
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
||||||
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
|
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
|
||||||
|
role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%'
|
||||||
|
|
||||||
# MailJet list ids and their labels
|
# MailJet list ids and their labels
|
||||||
mailjet_lists:
|
mailjet_lists:
|
||||||
@@ -266,6 +267,7 @@ services:
|
|||||||
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
|
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
|
||||||
|
|
||||||
# Dashboard Widgets
|
# Dashboard Widgets
|
||||||
|
App\Dashboard\Widget\StuckBookingDraftsWidgetProvider: ~
|
||||||
App\Dashboard\Widget\OpenGroupBookingsWidgetProvider: ~
|
App\Dashboard\Widget\OpenGroupBookingsWidgetProvider: ~
|
||||||
App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider: ~
|
App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider: ~
|
||||||
App\Dashboard\Widget\RecentLogEntriesWidgetProvider: ~
|
App\Dashboard\Widget\RecentLogEntriesWidgetProvider: ~
|
||||||
@@ -274,6 +276,7 @@ services:
|
|||||||
App\Dashboard\DashboardWidgetRegistry:
|
App\Dashboard\DashboardWidgetRegistry:
|
||||||
arguments:
|
arguments:
|
||||||
$providers:
|
$providers:
|
||||||
|
- '@App\Dashboard\Widget\StuckBookingDraftsWidgetProvider'
|
||||||
- '@App\Dashboard\Widget\OpenGroupBookingsWidgetProvider'
|
- '@App\Dashboard\Widget\OpenGroupBookingsWidgetProvider'
|
||||||
- '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider'
|
- '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider'
|
||||||
- '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider'
|
- '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider'
|
||||||
@@ -309,6 +312,10 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$domains: '%employee_email_domains%'
|
$domains: '%employee_email_domains%'
|
||||||
|
|
||||||
|
App\MessageHandler\RoleNominationHandler:
|
||||||
|
arguments:
|
||||||
|
$notificationRecipients: '%role_nomination_notification_emails%'
|
||||||
|
|
||||||
App\Service\DomainConfigProvider:
|
App\Service\DomainConfigProvider:
|
||||||
arguments:
|
arguments:
|
||||||
$domainConfig: '%domain_config%'
|
$domainConfig: '%domain_config%'
|
||||||
|
|||||||
+32
-15
@@ -9,7 +9,6 @@ require 'recipe/symfony.php';
|
|||||||
add('shared_dirs', [
|
add('shared_dirs', [
|
||||||
'config/secret',
|
'config/secret',
|
||||||
'var/bpn',
|
'var/bpn',
|
||||||
'var/log',
|
|
||||||
'var/sessions',
|
'var/sessions',
|
||||||
'var/jsonexport',
|
'var/jsonexport',
|
||||||
'var/xmlexport',
|
'var/xmlexport',
|
||||||
@@ -35,6 +34,8 @@ $rsyncOptions = [
|
|||||||
'.jj',
|
'.jj',
|
||||||
'node_modules',
|
'node_modules',
|
||||||
'.editorconfig',
|
'.editorconfig',
|
||||||
|
'.env.local',
|
||||||
|
'.env.local.php',
|
||||||
'.env.dev.local',
|
'.env.dev.local',
|
||||||
'.env.dev.local.php',
|
'.env.dev.local.php',
|
||||||
'.env.test',
|
'.env.test',
|
||||||
@@ -76,17 +77,24 @@ $rsyncOptions = [
|
|||||||
'timeout' => 300,
|
'timeout' => 300,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Shared by all hosts. The deploy path only differs by the host alias.
|
||||||
|
set('deploy_path', '/usr/home/myepsf/public_html/{{alias}}');
|
||||||
|
set('bin/php', '/usr/bin/php');
|
||||||
|
set('http_user', 'myepsf');
|
||||||
|
// Must stay here: contrib/rsync.php sets the same key, but its __DIR__ is the vendor dir.
|
||||||
|
set('rsync_src', __DIR__);
|
||||||
|
set('rsync', $rsyncOptions);
|
||||||
|
// The web adapter fetches its probe file back through the live docroot, so --web-path must
|
||||||
|
// be the current symlink, never {{release_or_current_path}}: that one resolves (and memoizes)
|
||||||
|
// to the new release dir, which the FPM worker's realpath cache does not see yet.
|
||||||
|
set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url={{web_url}}');
|
||||||
|
|
||||||
host('prod')
|
host('prod')
|
||||||
->setHostname('dedi10193.your-server.de')
|
->setHostname('dedi10193.your-server.de')
|
||||||
->setRemoteUser('myepsf')
|
->setRemoteUser('myepsf')
|
||||||
->setForwardAgent(true)
|
->setForwardAgent(true)
|
||||||
->setSshMultiplexing(true)
|
->setSshMultiplexing(true)
|
||||||
->setDeployPath('/usr/home/myepsf/public_html/prod')
|
->set('web_url', 'https://my.ep-reisen.de')
|
||||||
->set('bin/php', '/usr/bin/php')
|
|
||||||
->set('http_user', 'myepsf')
|
|
||||||
->set('rsync_src', __DIR__)
|
|
||||||
->set('rsync', $rsyncOptions)
|
|
||||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
|
||||||
;
|
;
|
||||||
|
|
||||||
host('staging')
|
host('staging')
|
||||||
@@ -94,12 +102,7 @@ host('staging')
|
|||||||
->setRemoteUser('myepsf')
|
->setRemoteUser('myepsf')
|
||||||
->setForwardAgent(true)
|
->setForwardAgent(true)
|
||||||
->setSshMultiplexing(true)
|
->setSshMultiplexing(true)
|
||||||
->setDeployPath('/usr/home/myepsf/public_html/staging')
|
->set('web_url', 'https://my.ep-reisen.net')
|
||||||
->set('bin/php', '/usr/bin/php')
|
|
||||||
->set('http_user', 'myepsf')
|
|
||||||
->set('rsync_src', __DIR__)
|
|
||||||
->set('rsync', $rsyncOptions)
|
|
||||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net')
|
|
||||||
->add('shared_files', [
|
->add('shared_files', [
|
||||||
'public/.htpasswd',
|
'public/.htpasswd',
|
||||||
])
|
])
|
||||||
@@ -107,25 +110,39 @@ host('staging')
|
|||||||
|
|
||||||
task('deploy', [
|
task('deploy', [
|
||||||
'deploy:info',
|
'deploy:info',
|
||||||
|
'deploy:assets',
|
||||||
'deploy:setup',
|
'deploy:setup',
|
||||||
'deploy:lock',
|
'deploy:lock',
|
||||||
'deploy:release',
|
'deploy:release',
|
||||||
'deploy:assets',
|
|
||||||
'rsync',
|
'rsync',
|
||||||
'deploy:shared',
|
'deploy:shared',
|
||||||
'deploy:writable',
|
'deploy:writable',
|
||||||
'deploy:cache:clear',
|
'deploy:cache:warmup',
|
||||||
'database:migrate',
|
'database:migrate',
|
||||||
|
// Must run before the symlink flip: the cachetool probe file is only reachable through
|
||||||
|
// the release the docroot currently resolves to. It still pays off, because it drops the
|
||||||
|
// opcache entries keyed under current/public/*.
|
||||||
'cachetool:clear:opcache',
|
'cachetool:clear:opcache',
|
||||||
'deploy:publish',
|
'deploy:publish',
|
||||||
'deploy:stop-workers',
|
'deploy:stop-workers',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Purely local, so it runs before anything is created on the remote. once(), so
|
||||||
|
// deploying multiple hosts at the same time builds the bundle only once.
|
||||||
task('deploy:assets', function () {
|
task('deploy:assets', function () {
|
||||||
runLocally('ddev exec npm ci');
|
runLocally('ddev exec npm ci');
|
||||||
runLocally('ddev exec npm run build');
|
runLocally('ddev exec npm run build');
|
||||||
|
})->once();
|
||||||
|
|
||||||
|
// The recipe's deploy:cache:clear only does something when composer ran with
|
||||||
|
// --no-scripts, which never happens here: vendor/ is rsynced, composer never runs
|
||||||
|
// remotely. A fresh release has no var/cache to clear, so warm it up directly.
|
||||||
|
// Runs after deploy:writable so the default ACLs are already in place.
|
||||||
|
task('deploy:cache:warmup', function () {
|
||||||
|
run('{{bin/console}} cache:warmup {{console_options}}');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Workers keep running the previous release's code until they are told to stop.
|
||||||
task('deploy:stop-workers', function () {
|
task('deploy:stop-workers', function () {
|
||||||
run('{{bin/console}} messenger:stop-workers');
|
run('{{bin/console}} messenger:stop-workers');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Symfony roles:
|
|||||||
| `1293` | `ROLE_MANAGER` |
|
| `1293` | `ROLE_MANAGER` |
|
||||||
| `1477` | `ROLE_GROUPS_MANAGER` |
|
| `1477` | `ROLE_GROUPS_MANAGER` |
|
||||||
| `1478` | `ROLE_GROUPS_ADMIN` |
|
| `1478` | `ROLE_GROUPS_ADMIN` |
|
||||||
|
| `1483` | `ROLE_CUSTOMER_EXPERT` |
|
||||||
| label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` |
|
| label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` |
|
||||||
| nothing matched | `ROLE_CUSTOMER` |
|
| nothing matched | `ROLE_CUSTOMER` |
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,21 @@ class CrmAttributesResponseParser
|
|||||||
{
|
{
|
||||||
use TypeConversionTrait;
|
use TypeConversionTrait;
|
||||||
|
|
||||||
private const BPN_CRM_ID_ADMIN = 1292;
|
/**
|
||||||
private const BPN_CRM_ID_MANAGER = 1293;
|
* The CRM selections that stand for a role, by selection id. "Hausleitung" is deliberately not
|
||||||
private const BPN_CRM_ID_TEAMER = 1070;
|
* in here: its ids are deployment configuration (see $houseManagerIds) and carry a hotel code
|
||||||
private const BPN_CRM_ID_GROUPS_MANAGER = 1477;
|
* on top of the role.
|
||||||
private const BPN_CRM_ID_GROUPS_ADMIN = 1478;
|
*
|
||||||
|
* @var array<int, string> selection id => role
|
||||||
|
*/
|
||||||
|
private const ROLE_BY_CRM_ID = [
|
||||||
|
1070 => Role::TEAMER,
|
||||||
|
1292 => Role::ADMIN,
|
||||||
|
1293 => Role::MANAGER,
|
||||||
|
1477 => Role::GROUPS_MANAGER,
|
||||||
|
1478 => Role::GROUPS_ADMIN,
|
||||||
|
1483 => Role::CUSTOMER_EXPERT,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int|string, string> $houseManagerIds "Hausleitung" selection id => hotel code
|
* @param array<int|string, string> $houseManagerIds "Hausleitung" selection id => hotel code
|
||||||
@@ -71,20 +81,11 @@ class CrmAttributesResponseParser
|
|||||||
$roles[] = Role::HOUSE_MANAGER;
|
$roles[] = Role::HOUSE_MANAGER;
|
||||||
$hotelCodes[] = $hotelCode;
|
$hotelCodes[] = $hotelCode;
|
||||||
}
|
}
|
||||||
if (self::BPN_CRM_ID_ADMIN === $attribute->id) {
|
|
||||||
$roles[] = Role::ADMIN;
|
$role = self::ROLE_BY_CRM_ID[$attribute->id] ?? null;
|
||||||
}
|
|
||||||
if (self::BPN_CRM_ID_MANAGER === $attribute->id) {
|
if (null !== $role) {
|
||||||
$roles[] = Role::MANAGER;
|
$roles[] = $role;
|
||||||
}
|
|
||||||
if (self::BPN_CRM_ID_TEAMER === $attribute->id) {
|
|
||||||
$roles[] = Role::TEAMER;
|
|
||||||
}
|
|
||||||
if (self::BPN_CRM_ID_GROUPS_MANAGER === $attribute->id) {
|
|
||||||
$roles[] = Role::GROUPS_MANAGER;
|
|
||||||
}
|
|
||||||
if (self::BPN_CRM_ID_GROUPS_ADMIN === $attribute->id) {
|
|
||||||
$roles[] = Role::GROUPS_ADMIN;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,489 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\BusProNet\ApiClient;
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||||
|
use App\BusProNet\Model\Booking;
|
||||||
|
use App\BusProNet\Model\Notification;
|
||||||
|
use App\BusProNet\Model\Service;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\Entity\BookingEditDraft;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\BookingEditDraftRepository;
|
||||||
|
use App\Security\Crypt;
|
||||||
|
use App\Service\BookingChangeTracker;
|
||||||
|
use App\Service\BookingEditDraftMerger;
|
||||||
|
use App\Service\TravelDataProvider;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
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;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repairs a booking edit draft that BusPro keeps rejecting.
|
||||||
|
*
|
||||||
|
* A draft survives every failed submission and is replayed on each re-entry into the edit flow.
|
||||||
|
* When one selection inside it has become unacceptable to BusPro - a Leistung whose status has
|
||||||
|
* drifted to "Anfrage" because its contingent ran out, say - the whole change is refused, nothing
|
||||||
|
* persists, and the customer is stuck in a loop they cannot edit their way out of. Booking 98787
|
||||||
|
* accumulated 37 such failures over two months.
|
||||||
|
*
|
||||||
|
* The valuable half of that draft is the personal data: names, dates of birth, contact details,
|
||||||
|
* addresses, room remarks. Re-entering it by hand for eighty participants is not a reasonable ask.
|
||||||
|
* So this command rebuilds the draft on top of the booking as BusPro currently holds it, keeps
|
||||||
|
* every field the customer typed, and reverts only the service selections that BusPro will not
|
||||||
|
* accept - naming each one, so the office can tell the customer what to pick again.
|
||||||
|
*
|
||||||
|
* Dry run by default. --apply writes a JSON backup of the original form data first.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:booking:repair-draft',
|
||||||
|
description: 'Rebuild a rejected booking edit draft, keeping typed data and reverting unacceptable service selections',
|
||||||
|
)]
|
||||||
|
class BookingRepairDraftCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Draft `services` keys holding a single service id.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Date of birth the office writes into the empty slots of a template booking.
|
||||||
|
*/
|
||||||
|
private const PLACEHOLDER_DATE_OF_BIRTH = '2000-01-01';
|
||||||
|
|
||||||
|
private const SINGLE_SERVICE_KEYS = ['skiPass', 'veg', 'insurance', 'rentalInsurance'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draft `services` keys holding a list of service ids.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const LIST_SERVICE_KEYS = ['courses', 'board', 'rentals', 'additionalServices'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transport is reverted as a unit: a participant put back on their own arrival must lose the
|
||||||
|
* pickup and drop-off that only make sense on a coach.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const TRANSPORT_KEYS = ['transportationOutbound', 'transportationInbound', 'pickup', 'dropOff'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly BookingEditDraftRepository $draftRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly ApiClient $apiClient,
|
||||||
|
private readonly Crypt $crypt,
|
||||||
|
private readonly TravelDataProvider $travelDataProvider,
|
||||||
|
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||||
|
private readonly BookingEditDraftMerger $draftMerger,
|
||||||
|
private readonly BookingChangeTracker $changeTracker,
|
||||||
|
#[Autowire('%kernel.project_dir%')]
|
||||||
|
private readonly string $projectDir,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('booking', 'b', InputOption::VALUE_REQUIRED, 'idbuchung whose draft should be repaired')
|
||||||
|
->addOption('apply', null, InputOption::VALUE_NONE, 'Write the repaired draft. Without this the command only reports')
|
||||||
|
->addOption('backup-dir', null, InputOption::VALUE_REQUIRED, 'Where to write the backup of the original form data', 'var/draft-backups')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$bookingId = $input->getOption('booking');
|
||||||
|
if (null === $bookingId) {
|
||||||
|
$io->error('--booking is required.');
|
||||||
|
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft = $this->findSingleDraft($io, (int) $bookingId);
|
||||||
|
if (null === $draft) {
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $draft->getUser();
|
||||||
|
$booking = $this->fetchLiveBooking($io, $user, (int) $bookingId);
|
||||||
|
if (null === $booking) {
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$travel = $this->travelDataProvider->getTravelData((int) $booking->dateId, $booking->hotelId, true);
|
||||||
|
if (null === $travel) {
|
||||||
|
$io->error(sprintf('No travel data for date id %d.', (int) $booking->dateId));
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$formData = $draft->getFormData();
|
||||||
|
$draftParticipants = $formData['participants'] ?? [];
|
||||||
|
|
||||||
|
if (false === $this->assertAlignment($io, $booking, $draftParticipants)) {
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$blocked = $this->collectUnbookableServices($io, $travel);
|
||||||
|
|
||||||
|
// The booking as BusPro currently holds it is the only state known to be acceptable, so
|
||||||
|
// it is the base everything is rebuilt on.
|
||||||
|
$repairedDto = $this->bookingDataProcessor->createBookingDtoFromBooking(
|
||||||
|
$booking,
|
||||||
|
$travel,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$liveSelections = $this->changeTracker->extractUserData($repairedDto)['participants'] ?? [];
|
||||||
|
|
||||||
|
$reverted = [];
|
||||||
|
foreach ($draftParticipants as $index => $participantData) {
|
||||||
|
if (false === isset($repairedDto->participants[$index])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filtered = $this->filterServiceSelections(
|
||||||
|
$participantData,
|
||||||
|
$liveSelections[$index]['services'] ?? [],
|
||||||
|
$blocked,
|
||||||
|
$index,
|
||||||
|
$reverted,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->draftMerger->apply($repairedDto, $index, $repairedDto->participants[$index], $filtered, $travel);
|
||||||
|
}
|
||||||
|
|
||||||
|
$repaired = $this->changeTracker->extractUserData($repairedDto);
|
||||||
|
|
||||||
|
$this->report($io, $formData, $repaired, $reverted, $blocked);
|
||||||
|
|
||||||
|
if (false === $input->getOption('apply')) {
|
||||||
|
$io->note('Dry run. Re-run with --apply to write the repaired draft.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupPath = $this->writeBackup($draft, (string) $input->getOption('backup-dir'));
|
||||||
|
$io->success(sprintf('Original form data backed up to %s', $backupPath));
|
||||||
|
|
||||||
|
$draft->setFormData($repaired);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$io->success(sprintf('Draft %d repaired.', (int) $draft->getId()));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findSingleDraft(SymfonyStyle $io, int $bookingId): ?BookingEditDraft
|
||||||
|
{
|
||||||
|
$drafts = $this->draftRepository->findBy(['bookingId' => $bookingId]);
|
||||||
|
|
||||||
|
if ([] === $drafts) {
|
||||||
|
$io->error(sprintf('No draft found for booking %d.', $bookingId));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($drafts) > 1) {
|
||||||
|
$io->error(sprintf('Booking %d has %d drafts; resolve by hand.', $bookingId, count($drafts)));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft = $drafts[0];
|
||||||
|
$io->definitionList(
|
||||||
|
['Draft' => sprintf('%d (user %d)', (int) $draft->getId(), (int) $draft->getUser()->getId())],
|
||||||
|
['Created' => $draft->getCreatedAt()->format('Y-m-d H:i')],
|
||||||
|
['Last saved' => $draft->getUpdatedAt()->format('Y-m-d H:i')],
|
||||||
|
['Participants' => (string) count($draft->getFormData()['participants'] ?? [])],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchLiveBooking(SymfonyStyle $io, User $user, int $bookingId): ?Booking
|
||||||
|
{
|
||||||
|
$result = $this->apiClient->getBooking(
|
||||||
|
(string) $user->getEmail(),
|
||||||
|
$this->crypt->decrypt((string) $user->getPassword()),
|
||||||
|
$bookingId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result instanceof Notification) {
|
||||||
|
$io->error(sprintf('Vorgang_Details failed: %d %s', (int) $result->code, (string) $result->message));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses to repair a draft whose positions no longer line up with the live booking.
|
||||||
|
*
|
||||||
|
* Drafts are merged positionally, by array index, with no identity check. Empty template
|
||||||
|
* slots are interchangeable, so they prove nothing; the participants BusPro already knows by
|
||||||
|
* date of birth are the only anchors available. If one of those has moved, every later
|
||||||
|
* position is suspect and rebuilding would quietly graft data onto the wrong people.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $draftParticipants
|
||||||
|
*/
|
||||||
|
private function assertAlignment(SymfonyStyle $io, Booking $booking, array $draftParticipants): bool
|
||||||
|
{
|
||||||
|
$liveCount = count($booking->participants);
|
||||||
|
$draftCount = count($draftParticipants);
|
||||||
|
|
||||||
|
if ($liveCount !== $draftCount) {
|
||||||
|
$io->warning(sprintf(
|
||||||
|
'Live booking has %d participants, the draft has %d. Seats were added or removed since the draft was written.',
|
||||||
|
$liveCount,
|
||||||
|
$draftCount,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$anchors = 0;
|
||||||
|
$mismatches = [];
|
||||||
|
|
||||||
|
foreach (array_values($booking->participants) as $index => $participant) {
|
||||||
|
$liveDob = $participant->dateOfBirth?->format('Y-m-d');
|
||||||
|
if (null === $liveDob || self::PLACEHOLDER_DATE_OF_BIRTH === $liveDob) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
++$anchors;
|
||||||
|
$draftDob = $draftParticipants[$index]['personalData']['dateOfBirth'] ?? null;
|
||||||
|
|
||||||
|
if (null !== $draftDob && $draftDob !== $liveDob) {
|
||||||
|
$mismatches[] = sprintf('position %d: live %s, draft %s', $index + 1, $liveDob, $draftDob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $mismatches) {
|
||||||
|
$io->error('Draft positions no longer match the live booking:');
|
||||||
|
$io->listing($mismatches);
|
||||||
|
$io->comment('Repairing would move typed data onto the wrong participants. Resolve by hand.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->text(sprintf('Alignment verified against %d participant(s) BusPro already knows.', $anchors));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects the services BusPro will not accept as an addition.
|
||||||
|
*
|
||||||
|
* A Leistung only takes new participants while its own status is "Frei"; anything else is the
|
||||||
|
* condition behind "Status der Leistung (A) ist unterschiedlich zum Status des Teilnehmers".
|
||||||
|
* Live availability is preferred over the travel data, which lags behind it.
|
||||||
|
*
|
||||||
|
* @return array<int, string> service id => status
|
||||||
|
*/
|
||||||
|
private function collectUnbookableServices(SymfonyStyle $io, Travel $travel): array
|
||||||
|
{
|
||||||
|
$statuses = [];
|
||||||
|
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
|
||||||
|
if (null !== $service->id && null !== $service->status) {
|
||||||
|
$statuses[$service->id] = $service->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$live = $this->apiClient->getAvailabilitiesExtended((int) $travel->id);
|
||||||
|
if ($live instanceof Notification) {
|
||||||
|
$io->warning(sprintf(
|
||||||
|
'VERFUEGBARKEIT2 failed (%d %s); falling back to travel data, which may be stale.',
|
||||||
|
(int) $live->code,
|
||||||
|
(string) $live->message,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
foreach ($live->getServices() as $availability) {
|
||||||
|
if (null !== $availability->serviceId && null !== $availability->status && '' !== trim($availability->status)) {
|
||||||
|
$statuses[$availability->serviceId] = $availability->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$blocked = array_filter($statuses, static fn (string $status): bool => Constants::STATUS_AVAILABLE !== $status);
|
||||||
|
|
||||||
|
if ([] === $blocked) {
|
||||||
|
$io->text('Every service on this travel is currently "Frei".');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($blocked as $serviceId => $status) {
|
||||||
|
$service = $travel->additionalServices[$serviceId] ?? $travel->transportationServices[$serviceId] ?? null;
|
||||||
|
$label = $service instanceof Service ? (string) $service->label : '?';
|
||||||
|
$rows[] = [$serviceId, $label, $status];
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->section('Services that cannot take new participants');
|
||||||
|
$io->table(['id', 'Leistung', 'status'], $rows);
|
||||||
|
|
||||||
|
return $blocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the draft's selections that would add a participant to a service BusPro has closed.
|
||||||
|
*
|
||||||
|
* Removing a key leaves the live booking's own value in place, because the merger applies
|
||||||
|
* only the keys it is given. Selections BusPro still accepts - a different ski pass, a meal
|
||||||
|
* preference - are kept, so the customer loses as little as possible.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $participantData
|
||||||
|
* @param array<string, mixed> $liveServices
|
||||||
|
* @param array<int, string> $blocked
|
||||||
|
* @param list<string> $reverted
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function filterServiceSelections(
|
||||||
|
array $participantData,
|
||||||
|
array $liveServices,
|
||||||
|
array $blocked,
|
||||||
|
int $index,
|
||||||
|
array &$reverted,
|
||||||
|
): array {
|
||||||
|
if (false === isset($participantData['services']) || [] === $blocked) {
|
||||||
|
return $participantData;
|
||||||
|
}
|
||||||
|
|
||||||
|
$services = $participantData['services'];
|
||||||
|
$position = $index + 1;
|
||||||
|
|
||||||
|
foreach (self::SINGLE_SERVICE_KEYS as $key) {
|
||||||
|
$selected = $services[$key] ?? null;
|
||||||
|
if (null !== $selected && isset($blocked[$selected]) && ($liveServices[$key] ?? null) !== $selected) {
|
||||||
|
unset($services[$key]);
|
||||||
|
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::LIST_SERVICE_KEYS as $key) {
|
||||||
|
$selected = $services[$key] ?? null;
|
||||||
|
if (false === is_array($selected)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$liveList = $liveServices[$key] ?? [];
|
||||||
|
$kept = [];
|
||||||
|
foreach ($selected as $serviceId) {
|
||||||
|
if (isset($blocked[$serviceId]) && false === in_array($serviceId, $liveList, true)) {
|
||||||
|
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $serviceId, $blocked[$serviceId]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$kept[] = $serviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$services[$key] = $kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['transportationOutbound', 'transportationInbound'] as $key) {
|
||||||
|
$selected = $services[$key] ?? null;
|
||||||
|
if (null === $selected || false === isset($blocked[$selected]) || ($liveServices[$key] ?? null) === $selected) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]);
|
||||||
|
|
||||||
|
// Transport reverts as a unit, pickup and drop-off included.
|
||||||
|
foreach (self::TRANSPORT_KEYS as $transportKey) {
|
||||||
|
unset($services[$transportKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$participantData['services'] = $services;
|
||||||
|
|
||||||
|
return $participantData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $original
|
||||||
|
* @param array<string, mixed> $repaired
|
||||||
|
* @param list<string> $reverted
|
||||||
|
* @param array<int, string> $blocked
|
||||||
|
*/
|
||||||
|
private function report(SymfonyStyle $io, array $original, array $repaired, array $reverted, array $blocked): void
|
||||||
|
{
|
||||||
|
$io->section('Typed data preserved');
|
||||||
|
|
||||||
|
$fields = ['firstName', 'lastName', 'dateOfBirth', 'email', 'mobile'];
|
||||||
|
$kept = 0;
|
||||||
|
$lost = [];
|
||||||
|
|
||||||
|
foreach ($original['participants'] ?? [] as $index => $participantData) {
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
$before = $participantData['personalData'][$field] ?? null;
|
||||||
|
if (null === $before || '' === $before) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$after = $repaired['participants'][$index]['personalData'][$field] ?? null;
|
||||||
|
if ($before === $after) {
|
||||||
|
++$kept;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lost[] = sprintf('participant %d: %s', $index + 1, $field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->text(sprintf('<info>%d</info> personal-data field(s) carried over.', $kept));
|
||||||
|
|
||||||
|
if ([] !== $lost) {
|
||||||
|
$io->warning(sprintf('%d field(s) could NOT be carried over:', count($lost)));
|
||||||
|
$io->listing(array_slice($lost, 0, 25));
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->section('Service selections reverted');
|
||||||
|
|
||||||
|
if ([] === $reverted) {
|
||||||
|
$io->text([] === $blocked
|
||||||
|
? 'None - no service on this travel is closed.'
|
||||||
|
: 'None - the draft selects no closed service.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->warning(sprintf('%d selection(s) reverted to the booked state. The customer must choose again:', count($reverted)));
|
||||||
|
$io->listing($reverted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeBackup(BookingEditDraft $draft, string $backupDir): string
|
||||||
|
{
|
||||||
|
$directory = $this->projectDir.'/'.trim($backupDir, '/');
|
||||||
|
if (false === is_dir($directory)) {
|
||||||
|
mkdir($directory, 0o775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = sprintf(
|
||||||
|
'%s/draft-%d-booking-%d-%s.json',
|
||||||
|
$directory,
|
||||||
|
(int) $draft->getId(),
|
||||||
|
$draft->getBookingId(),
|
||||||
|
(new \DateTimeImmutable())->format('Ymd-His'),
|
||||||
|
);
|
||||||
|
|
||||||
|
file_put_contents($path, json_encode($draft->getFormData(), \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class DeleteController extends AbstractController
|
class DeleteController extends AbstractController
|
||||||
{
|
{
|
||||||
use ReturnUrlTrait;
|
use ReturnUrlTrait;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class ExportController extends AbstractController
|
class ExportController extends AbstractController
|
||||||
{
|
{
|
||||||
use ReturnUrlTrait;
|
use ReturnUrlTrait;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class FilterController extends AbstractController
|
class FilterController extends AbstractController
|
||||||
{
|
{
|
||||||
use ListFilterTrait;
|
use ListFilterTrait;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class IndexController extends AbstractController
|
class IndexController extends AbstractController
|
||||||
{
|
{
|
||||||
use ListFilterTrait;
|
use ListFilterTrait;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class ShowController extends AbstractController
|
class ShowController extends AbstractController
|
||||||
{
|
{
|
||||||
use ReturnUrlTrait;
|
use ReturnUrlTrait;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class DownloadController extends AbstractController
|
class DownloadController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
|
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class FilterController extends AbstractController
|
class FilterController extends AbstractController
|
||||||
{
|
{
|
||||||
use ListFilterTrait;
|
use ListFilterTrait;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class IndexController extends AbstractController
|
class IndexController extends AbstractController
|
||||||
{
|
{
|
||||||
use ListFilterTrait;
|
use ListFilterTrait;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class XmlDumpController extends AbstractController
|
class XmlDumpController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
|
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class RecentLogEntriesWidgetProvider implements DashboardWidgetProviderInterface
|
|||||||
|
|
||||||
public function getRequiredRole(): string
|
public function getRequiredRole(): string
|
||||||
{
|
{
|
||||||
return Role::ADMIN;
|
return Role::CUSTOMER_EXPERT;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPriority(): int
|
public function getPriority(): int
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dashboard\Widget;
|
||||||
|
|
||||||
|
use App\Dashboard\Contract\DashboardWidgetProviderInterface;
|
||||||
|
use App\Entity\BookingEditDraft;
|
||||||
|
use App\Model\DashboardWidget;
|
||||||
|
use App\Model\DashboardWidgetEntry;
|
||||||
|
use App\Repository\BookingEditDraftRepository;
|
||||||
|
use App\Security\Role;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customers whose changes cannot be saved.
|
||||||
|
*
|
||||||
|
* A draft is deleted as soon as an update succeeds, so one that has been around for days belongs to
|
||||||
|
* somebody BusPro keeps refusing — and nothing else tells anyone. One booking accumulated
|
||||||
|
* thirty-seven failed attempts across two months before it was noticed by accident; the customer
|
||||||
|
* had entered seventy-nine participants, none of which ever reached BusPro. The point of this card
|
||||||
|
* is that the next one gets noticed in the first week instead.
|
||||||
|
*
|
||||||
|
* Each entry links to the draft, since the age alone does not say what is going wrong.
|
||||||
|
*/
|
||||||
|
class StuckBookingDraftsWidgetProvider implements DashboardWidgetProviderInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* How long a draft must have survived to be worth reporting.
|
||||||
|
*
|
||||||
|
* Short enough to catch a customer inside their first week of trying, long enough that an edit
|
||||||
|
* somebody merely abandoned over a weekend does not fill the card.
|
||||||
|
*/
|
||||||
|
private const MIN_AGE_DAYS = 7;
|
||||||
|
|
||||||
|
private const LIMIT = 10;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly BookingEditDraftRepository $draftRepository,
|
||||||
|
private readonly UrlGeneratorInterface $urlGenerator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequiredRole(): string
|
||||||
|
{
|
||||||
|
return Role::CUSTOMER_EXPERT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriority(): int
|
||||||
|
{
|
||||||
|
return 95;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): ?DashboardWidget
|
||||||
|
{
|
||||||
|
$drafts = $this->draftRepository->findStuck(self::MIN_AGE_DAYS, self::LIMIT);
|
||||||
|
|
||||||
|
return new DashboardWidget(
|
||||||
|
'Festhängende Buchungsänderungen',
|
||||||
|
array_map(fn (BookingEditDraft $draft): DashboardWidgetEntry => new DashboardWidgetEntry(
|
||||||
|
$this->label($draft),
|
||||||
|
$this->urlGenerator->generate('app_admin_bookingeditdraft_show', ['id' => $draft->getId()]),
|
||||||
|
'edit',
|
||||||
|
), $drafts),
|
||||||
|
'Keine festhängenden Buchungsänderungen.',
|
||||||
|
$this->urlGenerator->generate('app_admin_bookingeditdraft'),
|
||||||
|
'Alle Buchungsentwürfe',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names the booking, how long it has been stuck, and when the customer last tried.
|
||||||
|
*
|
||||||
|
* The gap between the two is what distinguishes an abandoned edit from somebody still trying
|
||||||
|
* every few days and getting nowhere.
|
||||||
|
*/
|
||||||
|
private function label(BookingEditDraft $draft): string
|
||||||
|
{
|
||||||
|
$days = $draft->getCreatedAt()->diff(new \DateTimeImmutable())->days ?? 0;
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Vorgang %s (%s) — seit %d Tagen, zuletzt %s',
|
||||||
|
$draft->getBookingNumber() ?? $draft->getBookingId(),
|
||||||
|
(string) $draft->getUser()->getEmail(),
|
||||||
|
$days,
|
||||||
|
$draft->getUpdatedAt()->format('d.m.Y'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Menu;
|
namespace App\Menu;
|
||||||
|
|
||||||
|
use App\Security\Voter\AdministrativeAccessVoter;
|
||||||
use Knp\Menu\FactoryInterface;
|
use Knp\Menu\FactoryInterface;
|
||||||
use Knp\Menu\ItemInterface;
|
use Knp\Menu\ItemInterface;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
@@ -39,7 +40,7 @@ abstract class AbstractMenuBuilder
|
|||||||
|
|
||||||
protected function addAdminItem(ItemInterface $menu): void
|
protected function addAdminItem(ItemInterface $menu): void
|
||||||
{
|
{
|
||||||
if ($this->security->isGranted('ROLE_ADMIN')) {
|
if ($this->security->isGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)) {
|
||||||
$this->addDivider($menu);
|
$this->addDivider($menu);
|
||||||
$menu->addChild('zum Adminbereich', [
|
$menu->addChild('zum Adminbereich', [
|
||||||
'route' => 'app_admin_index',
|
'route' => 'app_admin_index',
|
||||||
|
|||||||
@@ -22,16 +22,18 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
|||||||
'icon' => 'chart',
|
'icon' => 'chart',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
$menu->addChild('Buchungsentwürfe', [
|
if ($this->security->isGranted('ROLE_CUSTOMER_EXPERT')) {
|
||||||
'route' => 'app_admin_bookingeditdraft',
|
$menu->addChild('Buchungsentwürfe', [
|
||||||
'linkAttributes' => [
|
'route' => 'app_admin_bookingeditdraft',
|
||||||
'title' => 'Buchungsentwürfe',
|
'linkAttributes' => [
|
||||||
],
|
'title' => 'Buchungsentwürfe',
|
||||||
'extras' => [
|
],
|
||||||
'icon' => 'edit',
|
'extras' => [
|
||||||
'routes' => [['pattern' => '/^app_admin_bookingeditdraft/']],
|
'icon' => 'edit',
|
||||||
],
|
'routes' => [['pattern' => '/^app_admin_bookingeditdraft/']],
|
||||||
]);
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->security->isGranted('ROLE_GROUPS_ADMIN')) {
|
if ($this->security->isGranted('ROLE_GROUPS_ADMIN')) {
|
||||||
$menu->addChild('Gruppenbuchungen', [
|
$menu->addChild('Gruppenbuchungen', [
|
||||||
@@ -56,25 +58,29 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$menu->addChild('Benutzer', [
|
if ($this->security->isGranted('ROLE_ADMIN')) {
|
||||||
'route' => 'app_admin_user',
|
$menu->addChild('Benutzer', [
|
||||||
'linkAttributes' => [
|
'route' => 'app_admin_user',
|
||||||
'title' => 'Benutzer',
|
'linkAttributes' => [
|
||||||
],
|
'title' => 'Benutzer',
|
||||||
'extras' => [
|
],
|
||||||
'icon' => 'users',
|
'extras' => [
|
||||||
],
|
'icon' => 'users',
|
||||||
]);
|
],
|
||||||
$menu->addChild('Logs', [
|
]);
|
||||||
'route' => 'app_admin_log',
|
}
|
||||||
'linkAttributes' => [
|
if ($this->security->isGranted('ROLE_CUSTOMER_EXPERT')) {
|
||||||
'title' => 'Logs',
|
$menu->addChild('Logs', [
|
||||||
],
|
'route' => 'app_admin_log',
|
||||||
'extras' => [
|
'linkAttributes' => [
|
||||||
'icon' => 'list',
|
'title' => 'Logs',
|
||||||
'routes' => [['pattern' => '/^app_admin_log/']],
|
],
|
||||||
],
|
'extras' => [
|
||||||
]);
|
'icon' => 'list',
|
||||||
|
'routes' => [['pattern' => '/^app_admin_log/']],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->addLogoutItem($menu);
|
$this->addLogoutItem($menu);
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
|||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tells the administrators that an account is waiting for a role to be approved.
|
* Tells the configured recipients that an account is waiting for a role to be approved.
|
||||||
*
|
*
|
||||||
* Runs off the request: mail is routed sync in this application, so sending it inline would put
|
* Runs off the request: mail is routed sync in this application, so sending it inline would put
|
||||||
* SMTP latency and SMTP failures into the login path.
|
* SMTP latency and SMTP failures into the login path.
|
||||||
@@ -22,11 +22,15 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
|||||||
#[AsMessageHandler]
|
#[AsMessageHandler]
|
||||||
final class RoleNominationHandler
|
final class RoleNominationHandler
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param string[] $notificationRecipients
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserRepository $userRepository,
|
private readonly UserRepository $userRepository,
|
||||||
private readonly Mailer $mailer,
|
private readonly Mailer $mailer,
|
||||||
private readonly RoleApprovalUrlGenerator $approvalUrlGenerator,
|
private readonly RoleApprovalUrlGenerator $approvalUrlGenerator,
|
||||||
private readonly LoggerInterface $authLogger,
|
private readonly LoggerInterface $authLogger,
|
||||||
|
private readonly array $notificationRecipients,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,19 +47,9 @@ final class RoleNominationHandler
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$recipients = array_values(array_filter(array_map(
|
$recipients = $this->notificationRecipients;
|
||||||
static fn ($admin): ?string => $admin->getEmail(),
|
|
||||||
$this->userRepository->findAdministrators(),
|
|
||||||
)));
|
|
||||||
|
|
||||||
if ([] === $recipients) {
|
if ([] === $recipients) {
|
||||||
// Worth a warning rather than a silent return: nobody can approve the nomination, and
|
|
||||||
// without this line nobody would find out that the notification goes nowhere.
|
|
||||||
$this->authLogger->warning('No administrator to notify about a role nomination', [
|
|
||||||
'userId' => $message->userId,
|
|
||||||
'roles' => $message->roles,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,4 +123,35 @@ class BookingEditDraftRepository extends ServiceEntityRepository
|
|||||||
->getQuery()
|
->getQuery()
|
||||||
->execute();
|
->execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds drafts whose owner has been unable to save for a while.
|
||||||
|
*
|
||||||
|
* A draft is deleted the moment an update succeeds (see BookingEditSubmitter), so its mere age
|
||||||
|
* is the signal: one that has survived for days belongs to somebody whose changes BusPro keeps
|
||||||
|
* refusing. Drafts for departed travels are excluded - nothing can be done about those, and the
|
||||||
|
* nightly cleanup removes them anyway.
|
||||||
|
*
|
||||||
|
* Ordered by the most recent attempt rather than by age: somebody who tried again yesterday is
|
||||||
|
* still stuck and still waiting, while the oldest drafts are mostly edits abandoned months ago.
|
||||||
|
* Sorting by age alone fills the list with the latter and buries the people to help.
|
||||||
|
*
|
||||||
|
* @param int $minAgeDays How long a draft must have existed to count as stuck
|
||||||
|
*
|
||||||
|
* @return BookingEditDraft[] Most recently attempted first
|
||||||
|
*/
|
||||||
|
public function findStuck(int $minAgeDays = 7, int $limit = 10): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('d')
|
||||||
|
->join('d.user', 'u')
|
||||||
|
->addSelect('u')
|
||||||
|
->where('d.createdAt < :cutoff')
|
||||||
|
->andWhere('d.travelDate >= :today')
|
||||||
|
->setParameter('cutoff', new \DateTimeImmutable(sprintf('-%d days', $minAgeDays)))
|
||||||
|
->setParameter('today', new \DateTimeImmutable('today'))
|
||||||
|
->orderBy('d.updatedAt', 'DESC')
|
||||||
|
->setMaxResults($limit)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,32 +71,6 @@ class UserRepository extends ServiceEntityRepository
|
|||||||
->getResult();
|
->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The administrators who can approve a role nomination.
|
|
||||||
*
|
|
||||||
* The quote-anchored needle is load-bearing. ROLE_ADMIN_PENDING lives in the same JSON column,
|
|
||||||
* and an unanchored '%ROLE_ADMIN%' would match it — which would mail the very people whose own
|
|
||||||
* nomination is unapproved about other people's nominations. The result is filtered through
|
|
||||||
* Role::effectiveOnly() as well, so the guarantee does not rest on the LIKE alone.
|
|
||||||
*
|
|
||||||
* @return User[]
|
|
||||||
*/
|
|
||||||
public function findAdministrators(): array
|
|
||||||
{
|
|
||||||
/** @var User[] $candidates */
|
|
||||||
$candidates = $this->createQueryBuilder('u')
|
|
||||||
->andWhere('u.roles LIKE :admin')
|
|
||||||
->setParameter('admin', '%"'.Role::ADMIN.'"%')
|
|
||||||
->orderBy('u.email', 'ASC')
|
|
||||||
->getQuery()
|
|
||||||
->getResult();
|
|
||||||
|
|
||||||
return array_values(array_filter(
|
|
||||||
$candidates,
|
|
||||||
static fn (User $user): bool => \in_array(Role::ADMIN, Role::effectiveOnly($user->getRoles()), true),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everyone worth filtering an accommodation booking by: current groups staff, plus whoever
|
* Everyone worth filtering an accommodation booking by: current groups staff, plus whoever
|
||||||
* a booking is still assigned to even after losing the role — otherwise a booking assigned
|
* a booking is still assigned to even after losing the role — otherwise a booking assigned
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ final class Role
|
|||||||
public const HOUSE_MANAGER = 'ROLE_HOUSE_MANAGER';
|
public const HOUSE_MANAGER = 'ROLE_HOUSE_MANAGER';
|
||||||
public const GROUPS_ADMIN = 'ROLE_GROUPS_ADMIN';
|
public const GROUPS_ADMIN = 'ROLE_GROUPS_ADMIN';
|
||||||
public const GROUPS_MANAGER = 'ROLE_GROUPS_MANAGER';
|
public const GROUPS_MANAGER = 'ROLE_GROUPS_MANAGER';
|
||||||
|
public const CUSTOMER_EXPERT = 'ROLE_CUSTOMER_EXPERT';
|
||||||
public const EMPLOYEE = 'ROLE_EMPLOYEE';
|
public const EMPLOYEE = 'ROLE_EMPLOYEE';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +64,7 @@ final class Role
|
|||||||
self::HOUSE_MANAGER,
|
self::HOUSE_MANAGER,
|
||||||
self::GROUPS_ADMIN,
|
self::GROUPS_ADMIN,
|
||||||
self::GROUPS_MANAGER,
|
self::GROUPS_MANAGER,
|
||||||
|
self::CUSTOMER_EXPERT,
|
||||||
self::EMPLOYEE,
|
self::EMPLOYEE,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -92,6 +94,7 @@ final class Role
|
|||||||
self::HOUSE_MANAGER,
|
self::HOUSE_MANAGER,
|
||||||
self::GROUPS_ADMIN,
|
self::GROUPS_ADMIN,
|
||||||
self::GROUPS_MANAGER,
|
self::GROUPS_MANAGER,
|
||||||
|
self::CUSTOMER_EXPERT,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -287,6 +290,7 @@ final class Role
|
|||||||
self::HOUSE_MANAGER => 'Hausleitung',
|
self::HOUSE_MANAGER => 'Hausleitung',
|
||||||
self::GROUPS_ADMIN => 'Preisrechner Admin',
|
self::GROUPS_ADMIN => 'Preisrechner Admin',
|
||||||
self::GROUPS_MANAGER => 'Preisrechner',
|
self::GROUPS_MANAGER => 'Preisrechner',
|
||||||
|
self::CUSTOMER_EXPERT => 'KO-Experte',
|
||||||
self::EMPLOYEE => 'Mitarbeiter:in',
|
self::EMPLOYEE => 'Mitarbeiter:in',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class AdministrativeAccessVoter extends Voter
|
|||||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||||
{
|
{
|
||||||
return $this->accessDecisionManager->decide($token, ['ROLE_ADMIN'])
|
return $this->accessDecisionManager->decide($token, ['ROLE_ADMIN'])
|
||||||
|| $this->accessDecisionManager->decide($token, ['ROLE_GROUPS_MANAGER']);
|
|| $this->accessDecisionManager->decide($token, ['ROLE_GROUPS_MANAGER'])
|
||||||
|
|| $this->accessDecisionManager->decide($token, ['ROLE_CUSTOMER_EXPERT']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ class CrmAttributesResponseParserTest extends TestCase
|
|||||||
self::assertNotContains('ROLE_GROUPS_MANAGER', $roles);
|
self::assertNotContains('ROLE_GROUPS_MANAGER', $roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testParseAssignsCustomerExpertRoleWhenSelected(): void
|
||||||
|
{
|
||||||
|
$roles = $this->parseRoles($this->selectionXml(1483, true));
|
||||||
|
|
||||||
|
self::assertContains('ROLE_CUSTOMER_EXPERT', $roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParseAssignsNoCustomerExpertRoleWhenNotSelected(): void
|
||||||
|
{
|
||||||
|
$roles = $this->parseRoles($this->selectionXml(1483, false));
|
||||||
|
|
||||||
|
self::assertNotContains('ROLE_CUSTOMER_EXPERT', $roles);
|
||||||
|
}
|
||||||
|
|
||||||
public function testParseAssignsNoGroupsRolesWhenNotSelected(): void
|
public function testParseAssignsNoGroupsRolesWhenNotSelected(): void
|
||||||
{
|
{
|
||||||
$roles = $this->parseRoles($this->selectionXml(1477, false));
|
$roles = $this->parseRoles($this->selectionXml(1477, false));
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class RecentLogEntriesWidgetProviderTest extends TestCase
|
|||||||
$this->assertStringEndsWith(str_repeat('a', 60).'…', $widget->entries[0]->label);
|
$this->assertStringEndsWith(str_repeat('a', 60).'…', $widget->entries[0]->label);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAsksForFiveEntriesAndRequiresAdmin(): void
|
public function testAsksForFiveEntriesAndRequiresCustomerExpert(): void
|
||||||
{
|
{
|
||||||
$repository = $this->createMock(LogEntryRepository::class);
|
$repository = $this->createMock(LogEntryRepository::class);
|
||||||
$repository->expects($this->once())
|
$repository->expects($this->once())
|
||||||
@@ -57,7 +57,7 @@ class RecentLogEntriesWidgetProviderTest extends TestCase
|
|||||||
|
|
||||||
$provider = new RecentLogEntriesWidgetProvider($repository, $this->urlGenerator());
|
$provider = new RecentLogEntriesWidgetProvider($repository, $this->urlGenerator());
|
||||||
|
|
||||||
$this->assertSame(Role::ADMIN, $provider->getRequiredRole());
|
$this->assertSame(Role::CUSTOMER_EXPERT, $provider->getRequiredRole());
|
||||||
$this->assertNotNull($provider->build());
|
$this->assertNotNull($provider->build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Dashboard;
|
||||||
|
|
||||||
|
use App\Dashboard\Widget\StuckBookingDraftsWidgetProvider;
|
||||||
|
use App\Entity\BookingEditDraft;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\BookingEditDraftRepository;
|
||||||
|
use App\Security\Role;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
class StuckBookingDraftsWidgetProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testAsksForDraftsOlderThanAWeekAndRequiresCustomerExpert(): void
|
||||||
|
{
|
||||||
|
$repository = $this->createMock(BookingEditDraftRepository::class);
|
||||||
|
$repository->expects($this->once())
|
||||||
|
->method('findStuck')
|
||||||
|
->with(7, 10)
|
||||||
|
->willReturn([])
|
||||||
|
;
|
||||||
|
|
||||||
|
$provider = new StuckBookingDraftsWidgetProvider($repository, $this->urlGenerator());
|
||||||
|
|
||||||
|
$this->assertSame(Role::CUSTOMER_EXPERT, $provider->getRequiredRole());
|
||||||
|
$this->assertNotNull($provider->build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSaysSoWhenNothingIsStuck(): void
|
||||||
|
{
|
||||||
|
$widget = $this->provider([])->build();
|
||||||
|
|
||||||
|
$this->assertNotNull($widget);
|
||||||
|
$this->assertSame([], $widget->entries);
|
||||||
|
$this->assertSame('Keine festhängenden Buchungsänderungen.', $widget->emptyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLabelNamesTheBookingTheCustomerAndHowLongItHasBeenStuck(): void
|
||||||
|
{
|
||||||
|
$widget = $this->provider([$this->draft(111564, '[email protected]', 61)])->build();
|
||||||
|
|
||||||
|
$this->assertNotNull($widget);
|
||||||
|
$this->assertStringContainsString('Vorgang 111564', $widget->entries[0]->label);
|
||||||
|
$this->assertStringContainsString('[email protected]', $widget->entries[0]->label);
|
||||||
|
$this->assertStringContainsString('seit 61 Tagen', $widget->entries[0]->label);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEntriesLinkToTheDraft(): void
|
||||||
|
{
|
||||||
|
$widget = $this->provider([$this->draft(111564, '[email protected]', 8)])->build();
|
||||||
|
|
||||||
|
$this->assertNotNull($widget);
|
||||||
|
$this->assertNotNull($widget->entries[0]->url);
|
||||||
|
$this->assertSame('Alle Buchungsentwürfe', $widget->actionLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param BookingEditDraft[] $drafts
|
||||||
|
*/
|
||||||
|
private function provider(array $drafts): StuckBookingDraftsWidgetProvider
|
||||||
|
{
|
||||||
|
$repository = $this->createStub(BookingEditDraftRepository::class);
|
||||||
|
$repository->method('findStuck')->willReturn($drafts);
|
||||||
|
|
||||||
|
return new StuckBookingDraftsWidgetProvider($repository, $this->urlGenerator());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function urlGenerator(): UrlGeneratorInterface
|
||||||
|
{
|
||||||
|
$urlGenerator = $this->createStub(UrlGeneratorInterface::class);
|
||||||
|
$urlGenerator->method('generate')->willReturnCallback(
|
||||||
|
static fn (string $route): string => '/'.str_replace('_', '/', substr($route, \strlen('app_'))),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $urlGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function draft(int $bookingNumber, string $email, int $ageDays): BookingEditDraft
|
||||||
|
{
|
||||||
|
$draft = new BookingEditDraft(new User($email), 98787, new \DateTimeImmutable('+60 days'), []);
|
||||||
|
$draft->setBookingNumber($bookingNumber);
|
||||||
|
|
||||||
|
// createdAt is stamped by the constructor and has no setter - the age is the whole point of
|
||||||
|
// this widget, so it is set directly rather than asserted away.
|
||||||
|
$createdAt = new \ReflectionProperty(BookingEditDraft::class, 'createdAt');
|
||||||
|
$createdAt->setValue($draft, new \DateTimeImmutable(sprintf('-%d days', $ageDays)));
|
||||||
|
|
||||||
|
return $draft;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,8 +16,8 @@ use Psr\Log\LoggerInterface;
|
|||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The notification goes to the people who can act on it, and to nobody else: an account merely
|
* The notification goes to the addresses configured in config/services.yaml, and to nobody else:
|
||||||
* nominated for ROLE_ADMIN must not be told about other people's nominations.
|
* the audience does not follow whoever currently holds ROLE_ADMIN.
|
||||||
*/
|
*/
|
||||||
class RoleNominationHandlerTest extends TestCase
|
class RoleNominationHandlerTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -31,14 +31,11 @@ class RoleNominationHandlerTest extends TestCase
|
|||||||
|
|
||||||
private ?int $generatedReferenceType = null;
|
private ?int $generatedReferenceType = null;
|
||||||
|
|
||||||
public function testMailsEveryAdministrator(): void
|
public function testMailsTheConfiguredRecipients(): void
|
||||||
{
|
{
|
||||||
$handler = $this->handler(
|
$handler = $this->handler(
|
||||||
$this->user(7, '[email protected]'),
|
$this->user(7, '[email protected]'),
|
||||||
[
|
['[email protected]', '[email protected]'],
|
||||||
$this->user(1, '[email protected]', [Role::ADMIN]),
|
|
||||||
$this->user(2, '[email protected]', [Role::ADMIN]),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$handler(new RoleNominationMessage(7, [Role::ADMIN]));
|
$handler(new RoleNominationMessage(7, [Role::ADMIN]));
|
||||||
@@ -57,14 +54,14 @@ class RoleNominationHandlerTest extends TestCase
|
|||||||
|
|
||||||
public function testAMissingAccountIsANoOp(): void
|
public function testAMissingAccountIsANoOp(): void
|
||||||
{
|
{
|
||||||
$handler = $this->handler(null, [$this->user(1, '[email protected]', [Role::ADMIN])]);
|
$handler = $this->handler(null, ['[email protected]']);
|
||||||
|
|
||||||
$handler(new RoleNominationMessage(7, [Role::ADMIN]));
|
$handler(new RoleNominationMessage(7, [Role::ADMIN]));
|
||||||
|
|
||||||
self::assertNull($this->sentOptions);
|
self::assertNull($this->sentOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testWithoutAnAdministratorNothingIsSent(): void
|
public function testWithoutAConfiguredRecipientNothingIsSent(): void
|
||||||
{
|
{
|
||||||
$handler = $this->handler($this->user(7, '[email protected]'), []);
|
$handler = $this->handler($this->user(7, '[email protected]'), []);
|
||||||
|
|
||||||
@@ -88,13 +85,12 @@ class RoleNominationHandlerTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param User[] $administrators
|
* @param string[] $recipients
|
||||||
*/
|
*/
|
||||||
private function handler(?User $nominee, array $administrators): RoleNominationHandler
|
private function handler(?User $nominee, array $recipients): RoleNominationHandler
|
||||||
{
|
{
|
||||||
$userRepository = $this->createStub(UserRepository::class);
|
$userRepository = $this->createStub(UserRepository::class);
|
||||||
$userRepository->method('find')->willReturn($nominee);
|
$userRepository->method('find')->willReturn($nominee);
|
||||||
$userRepository->method('findAdministrators')->willReturn($administrators);
|
|
||||||
|
|
||||||
$mailer = $this->createStub(Mailer::class);
|
$mailer = $this->createStub(Mailer::class);
|
||||||
$mailer
|
$mailer
|
||||||
@@ -123,6 +119,7 @@ class RoleNominationHandlerTest extends TestCase
|
|||||||
$mailer,
|
$mailer,
|
||||||
new RoleApprovalUrlGenerator($urlGenerator),
|
new RoleApprovalUrlGenerator($urlGenerator),
|
||||||
$this->createStub(LoggerInterface::class),
|
$this->createStub(LoggerInterface::class),
|
||||||
|
$recipients,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,25 @@ class RoleTest extends TestCase
|
|||||||
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
|
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCustomerExpertClaimOnlyProducesANomination(): void
|
||||||
|
{
|
||||||
|
$roles = Role::sync([], [Role::CUSTOMER_EXPERT]);
|
||||||
|
|
||||||
|
self::assertSame([Role::pending(Role::CUSTOMER_EXPERT), Role::CUSTOMER], $roles);
|
||||||
|
self::assertSame([Role::CUSTOMER], Role::effectiveOnly($roles));
|
||||||
|
self::assertSame(
|
||||||
|
[Role::CUSTOMER_EXPERT => 'KO-Experte'],
|
||||||
|
Role::nominatedFrom($roles),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApprovedCustomerExpertDisplacesTheCustomerFallback(): void
|
||||||
|
{
|
||||||
|
$roles = Role::approve([Role::pending(Role::CUSTOMER_EXPERT), Role::CUSTOMER], Role::CUSTOMER_EXPERT);
|
||||||
|
|
||||||
|
self::assertSame([Role::CUSTOMER_EXPERT], $roles);
|
||||||
|
}
|
||||||
|
|
||||||
public function testApprovedRoleSurvivesTheNextSyncAndIsNotMarkedAgain(): void
|
public function testApprovedRoleSurvivesTheNextSyncAndIsNotMarkedAgain(): void
|
||||||
{
|
{
|
||||||
$roles = Role::sync([Role::TEAMER, Role::GROUPS_ADMIN], [Role::GROUPS_ADMIN, Role::TEAMER]);
|
$roles = Role::sync([Role::TEAMER, Role::GROUPS_ADMIN], [Role::GROUPS_ADMIN, Role::TEAMER]);
|
||||||
|
|||||||
Reference in New Issue
Block a user