feat(waitlist): make the day-part preference, the conversion and the expiry real
Three rows of task 13 were storing data nothing ever read. `preferred_day_parts` was saved and displayed but never applied when matching. It was deferred because "evening" has no fixed meaning — but branches already carry a timezone (DoctorAddress::getTimezone), so the boundaries can be pinned: morning [6,12), afternoon [12,17), evening [17,22), in the branch's local hour. The list is now closed and validated; an unknown part is a 422 rather than a preference that silently matches nothing. The filter runs *before* the cut to ten recipients — otherwise the first ten slots go to people who did not want that hour and the real eleventh person is never told. `markConverted()` was dead code: nothing called it. It now runs off the AppointmentBooked domain event rather than from inside BookingService, because converting is a side effect of booking — inside the booking transaction a waitlist error could roll back the patient's actual appointment. The match is deliberately narrow (same patient, same service, start inside the window); a loose match closes a row the patient is still waiting on. It is idempotent, so redelivery is harmless. Expiry now exists as a service, a daily scheduled message and `app:waitlist:expire`. Expired rows were already excluded from matching, so this is display hygiene, not a behaviour fix: without it the waitlist page fills with dead entries and the operator cannot tell which are still live. It sets a status rather than deleting — who waited and never got a slot is data. Also: a waitlist window is capped at 90 days, matching the booking horizon. An unbounded window is a row that never expires and shows up in every match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,16 @@ import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useWaitlist } from '../hooks/useCancellation';
|
||||
import type { WaitlistEntry } from '../types';
|
||||
|
||||
/**
|
||||
* مرزهای ساعتش در بکاند است (`WaitlistEntry::DAY_PARTS`)؛ اینجا فقط ترجمهٔ نمایش.
|
||||
* تعریف مرز در دو جا یعنی روزی که یکی عوض شود، بیمار برای ساعتی خبر شود که رد کرده بود.
|
||||
*/
|
||||
const DAY_PART_LABELS: Record<string, string> = {
|
||||
morning: 'صبح',
|
||||
afternoon: 'بعدازظهر',
|
||||
evening: 'عصر',
|
||||
};
|
||||
|
||||
const STATUS: Record<WaitlistEntry['status'], { label: string; className: string }> = {
|
||||
waiting: { label: 'در انتظار', className: 'badge' },
|
||||
notified: { label: 'خبر داده شد', className: 'badge amber' },
|
||||
@@ -53,7 +63,9 @@ export default function WaitlistPage() {
|
||||
header: 'زمان ترجیحی',
|
||||
render: (e) => (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
|
||||
{e.preferred_day_parts.length === 0 ? 'بیتفاوت' : e.preferred_day_parts.join('، ')}
|
||||
{e.preferred_day_parts.length === 0
|
||||
? 'بیتفاوت'
|
||||
: e.preferred_day_parts.map((p) => DAY_PART_LABELS[p] ?? p).join('، ')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ framework:
|
||||
'App\Appointment\Message\ExpireAppointmentsMessage': scheduler_default
|
||||
'App\Shared\Logging\Message\PruneLogsMessage': scheduler_default
|
||||
'App\Shared\Event\Message\PublishDomainEventsMessage': scheduler_default
|
||||
'App\Waitlist\Message\ExpireWaitlistMessage': scheduler_default
|
||||
# مصرفکنندهٔ رویداد async است، وگرنه یک consumer کند خودِ تخلیهٔ صندوق را
|
||||
# کند میکند و شکستش ردیفی را «ناموفق» علامت میزند که در واقع تحویل شده بود.
|
||||
'App\Shared\Event\Message\DomainEventMessage': async
|
||||
|
||||
+44
-4
@@ -57,15 +57,15 @@
|
||||
|---|---|---|---|
|
||||
| `patient_uuid` | string | ✅ | |
|
||||
| `service_uuid` | string | ✅ | |
|
||||
| `desired_from` / `desired_to` | int | ✅ | Unix؛ بازه باید در آینده باشد |
|
||||
| `desired_from` / `desired_to` | int | ✅ | Unix؛ بازه باید در آینده و حداکثر **۹۰ روز** باشد |
|
||||
| `branch_uuid` | string | — | نبودنش یعنی «هر شعبه» |
|
||||
| `preferred_day_parts` | string[] | — | `["morning","evening"]` |
|
||||
| `preferred_day_parts` | string[] | — | فهرست **بسته**: `morning` · `afternoon` · `evening` |
|
||||
| `priority` | int | — | بزرگتر زودتر خبر میشود |
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|---|---|---|
|
||||
| `ERR_VALIDATION_001` | 422 | بازهٔ گذشته یا پایانِ قبل از شروع |
|
||||
| `ERR_VALIDATION_001` | 422 | بازهٔ گذشته · پایانِ قبل از شروع · بازهٔ بیش از ۹۰ روز · بخش روز ناشناخته |
|
||||
| `ERR_VALIDATION_002` | 422 | فیلد الزامی غایب |
|
||||
| `ERR_NOT_FOUND_001` | 404 | بیمار یا سرویس خارج از محیط جاری |
|
||||
|
||||
@@ -84,6 +84,46 @@
|
||||
|
||||
---
|
||||
|
||||
## بخش روز — کجا معنا میشود
|
||||
|
||||
مرزها در `WaitlistEntry::DAY_PARTS` است و **در تطبیق اعمال میشود**، نه فقط ذخیره:
|
||||
|
||||
| کلید | برچسب | ساعت محلی |
|
||||
|---|---|---|
|
||||
| `morning` | صبح | `[۶, ۱۲)` |
|
||||
| `afternoon` | بعدازظهر | `[۱۲, ۱۷)` |
|
||||
| `evening` | عصر | `[۱۷, ۲۲)` |
|
||||
|
||||
ساعت به وقت **محلی شعبه** (`DoctorAddress::getTimezone()`) حساب میشود؛ بیمار «عصر» را
|
||||
با ساعت خودش میفهمد نه با UTC. شعبهٔ نامشخص به `Asia/Tehran` برمیگردد.
|
||||
|
||||
فیلتر **پیش از** بریدن به ده نفر اجرا میشود — وگرنه ده جای اول را کسانی پر میکنند که
|
||||
این ساعت را نمیخواستند و نفر یازدهمِ واقعی خبر نمیشود. نداشتن ترجیح یعنی «هر ساعتی».
|
||||
|
||||
## چرخهٔ عمر ردیف
|
||||
|
||||
| گذار | کِی |
|
||||
|---|---|
|
||||
| `waiting → notified` | اطلاعرسانی هنگام آزاد شدن ظرفیت (سقف ۳ بار) |
|
||||
| `* → converted` | همان بیمار **همان سرویس** را در بازهٔ خواستهشده رزرو کرد |
|
||||
| `* → expired` | `desired_to` گذشت |
|
||||
|
||||
`converted` از رویداد `AppointmentBooked` میآید، نه از داخل `BookingService`: تبدیل اثر
|
||||
جانبیِ رزرو است و اگر داخل تراکنش رزرو مینشست، یک خطای لیست انتظار میتوانست نوبت واقعی
|
||||
بیمار را برگرداند. تطبیق عمداً تنگ است (همان بیمار + همان سرویس + زمان داخل بازه)؛ تطبیق
|
||||
شل، ردیفی را میبندد که برای خدمت دیگری بود و بیمار دیگر هرگز خبر نمیشود. **idempotent**
|
||||
است، پس تحویل دوبارهٔ پیام بیخطر است.
|
||||
|
||||
انقضا هر روز با زمانبند (`ExpireWaitlistMessage`) یا دستی:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console app:waitlist:expire
|
||||
```
|
||||
|
||||
ردیف منقضی از قبل هم در تطبیق نمیآمد (`desiredTo >= now`)، پس این پاکسازیِ **نمایش**
|
||||
است: بدون آن صفحهٔ لیست انتظار پر میشود از انتظارهای مرده. حذف نمیکند، وضعیت را عوض
|
||||
میکند — چه کسی منتظر ماند و به نتیجه نرسید، خودش داده است.
|
||||
|
||||
## اطلاع خودکار هنگام لغو
|
||||
|
||||
`POST /api/v1/appointment/{uuid}/cancel` بعد از آزادسازی ظرفیت، لیست انتظار را خبر
|
||||
@@ -93,5 +133,5 @@
|
||||
## تستها
|
||||
|
||||
```bash
|
||||
ddev exec php bin/phpunit tests/Waitlist # ۹ تست
|
||||
ddev exec php bin/phpunit tests/Waitlist # ۱۶ تست
|
||||
```
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Appointment\Message\ExpireAppointmentsMessage;
|
||||
use App\Blog\Message\PublishScheduledBlogsMessage;
|
||||
use App\Shared\Event\Message\PublishDomainEventsMessage;
|
||||
use App\Shared\Logging\Message\PruneLogsMessage;
|
||||
use App\Waitlist\Message\ExpireWaitlistMessage;
|
||||
use Symfony\Component\Scheduler\Attribute\AsSchedule;
|
||||
use Symfony\Component\Scheduler\RecurringMessage;
|
||||
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
|
||||
@@ -39,6 +40,10 @@ class Schedule implements ScheduleProviderInterface
|
||||
// هرچه در فاصله جمع شده برود.
|
||||
->add(
|
||||
RecurringMessage::every('1 minute', new PublishDomainEventsMessage())
|
||||
)
|
||||
// انتظارهای مرده — پاکسازیِ نمایش، نه اصلاح رفتار (تطبیق از قبل ردشان میکرد).
|
||||
->add(
|
||||
RecurringMessage::every('1 day', new ExpireWaitlistMessage())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\Command;
|
||||
|
||||
use App\Waitlist\Service\WaitlistExpirer;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* اجرای دستیِ انقضای لیست انتظار؛ زمانبند هر روز همین کار را میکند.
|
||||
*/
|
||||
#[AsCommand(name: 'app:waitlist:expire', description: 'Close waitlist entries whose desired window has passed.')]
|
||||
class ExpireWaitlistCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly WaitlistExpirer $expirer)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$count = $this->expirer->expire();
|
||||
|
||||
$io->success(sprintf('%d ردیف لیست انتظار منقضی شد.', $count));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class WaitlistController extends BaseController
|
||||
{
|
||||
/** همان افق رزرو تسک ۱۲؛ بازهٔ بلندتر یعنی ردیفی که هرگز خودش را پاک نمیکند. */
|
||||
private const MAX_RANGE_DAYS = 90;
|
||||
|
||||
public function __construct(
|
||||
private readonly WaitlistEntryRepository $entries,
|
||||
private readonly ServiceItemRepository $items,
|
||||
@@ -79,6 +82,17 @@ class WaitlistController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پایان بازه باید بعد از شروع آن باشد', 422, 'desired_to');
|
||||
}
|
||||
|
||||
// بازهٔ باز تا ابد یعنی ردیفی که هرگز منقضی نمیشود و برای همیشه در هر تطبیقی
|
||||
// میآید؛ سقف همان افق رزرو است.
|
||||
if ($to - $from > self::MAX_RANGE_DAYS * 86400) {
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('بازهٔ انتظار حداکثر %d روز است', self::MAX_RANGE_DAYS),
|
||||
422,
|
||||
'desired_to',
|
||||
);
|
||||
}
|
||||
|
||||
$patient = $this->requirePatient($user, $data['patient_uuid']);
|
||||
$service = $this->requireItem($user, $data['service_uuid']);
|
||||
|
||||
@@ -91,7 +105,16 @@ class WaitlistController extends BaseController
|
||||
$entry = new WaitlistEntry($patient, $service, $from, $to, $branchId);
|
||||
|
||||
if (is_array($data['preferred_day_parts'] ?? null)) {
|
||||
$entry->setPreferredDayParts($data['preferred_day_parts']);
|
||||
try {
|
||||
$entry->setPreferredDayParts($data['preferred_day_parts']);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('بخش روز باید یکی از اینها باشد: %s', implode('، ', array_keys(WaitlistEntry::DAY_PARTS))),
|
||||
422,
|
||||
'preferred_day_parts',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_numeric($data['priority'] ?? null)) {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Waitlist\Entity;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use App\Waitlist\Repository\WaitlistEntryRepository;
|
||||
@@ -33,6 +34,21 @@ class WaitlistEntry
|
||||
/** سقف اطلاعرسانی — بدون آن، یک بازهٔ پرلغو به منبع اسپم تبدیل میشود. */
|
||||
public const MAX_NOTIFICATIONS = 3;
|
||||
|
||||
/**
|
||||
* بخشهای روز — فهرست **بسته**، با مرز ساعت محلیِ شعبه.
|
||||
*
|
||||
* مرزها اینجاست نه در UI: «عصر» باید در تطبیق و در نمایش یک معنا داشته باشد،
|
||||
* وگرنه بیمار برای ساعتی خبر میشود که خودش رد کرده بود. `[start, end)` است تا
|
||||
* ساعت ۱۲ دقیقاً یکبار شمرده شود.
|
||||
*
|
||||
* @var array<string, array{label: string, from: int, to: int}>
|
||||
*/
|
||||
public const DAY_PARTS = [
|
||||
'morning' => ['label' => 'صبح', 'from' => 6, 'to' => 12],
|
||||
'afternoon' => ['label' => 'بعدازظهر', 'from' => 12, 'to' => 17],
|
||||
'evening' => ['label' => 'عصر', 'from' => 17, 'to' => 22],
|
||||
];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
@@ -120,10 +136,21 @@ class WaitlistEntry
|
||||
public function getNotifiedAt(): ?int { return $this->notifiedAt; }
|
||||
public function getNotifyCount(): int { return $this->notifyCount; }
|
||||
|
||||
/** @param list<string> $parts */
|
||||
/**
|
||||
* @param list<string> $parts
|
||||
* @throws \InvalidArgumentException روی بخشی که در فهرست بسته نیست
|
||||
*/
|
||||
public function setPreferredDayParts(array $parts): self
|
||||
{
|
||||
$this->preferredDayParts = $parts === [] ? null : array_values(array_filter($parts, 'is_string'));
|
||||
$clean = array_values(array_unique(array_filter($parts, 'is_string')));
|
||||
|
||||
foreach ($clean as $part) {
|
||||
if (!isset(self::DAY_PARTS[$part])) {
|
||||
throw new \InvalidArgumentException(sprintf('Unknown day part "%s".', $part));
|
||||
}
|
||||
}
|
||||
|
||||
$this->preferredDayParts = $clean === [] ? null : $clean;
|
||||
|
||||
return $this->touch();
|
||||
}
|
||||
@@ -169,6 +196,33 @@ class WaitlistEntry
|
||||
return $start >= $this->desiredFrom && $start <= $this->desiredTo;
|
||||
}
|
||||
|
||||
/**
|
||||
* آیا این زمان در یکی از بخشهای روزِ خواستهشده میافتد؟
|
||||
*
|
||||
* نداشتنِ ترجیح یعنی «هر ساعتی» — نه «هیچ ساعتی». ساعت به وقت **محلی شعبه**
|
||||
* حساب میشود، چون بیمار «عصر» را با ساعت خودش میفهمد نه با UTC.
|
||||
*/
|
||||
public function coversDayPart(int $start, string $timezone = DoctorAddress::DEFAULT_TIMEZONE): bool
|
||||
{
|
||||
if ($this->preferredDayParts === null || $this->preferredDayParts === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$hour = (int) (new \DateTimeImmutable('@' . $start))
|
||||
->setTimezone(new \DateTimeZone($timezone))
|
||||
->format('G');
|
||||
|
||||
foreach ($this->preferredDayParts as $part) {
|
||||
$range = self::DAY_PARTS[$part] ?? null;
|
||||
|
||||
if ($range !== null && $hour >= $range['from'] && $hour < $range['to']) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function touch(): self
|
||||
{
|
||||
$this->updatedAt = time();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\Message;
|
||||
|
||||
/**
|
||||
* پیام نشانهای روزانه — «چه ردیفهایی منقضیاند» از جدول خوانده میشود، نه از پیام.
|
||||
*/
|
||||
final class ExpireWaitlistMessage
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\MessageHandler;
|
||||
|
||||
use App\Waitlist\Message\ExpireWaitlistMessage;
|
||||
use App\Waitlist\Service\WaitlistExpirer;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
final class ExpireWaitlistHandler
|
||||
{
|
||||
public function __construct(private readonly WaitlistExpirer $expirer) {}
|
||||
|
||||
public function __invoke(ExpireWaitlistMessage $message): void
|
||||
{
|
||||
$this->expirer->expire();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\MessageHandler;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Event\Message\DomainEventMessage;
|
||||
use App\Waitlist\Service\WaitlistConverter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* وقتی بیمار بالاخره رزرو کرد، ردیف انتظارش `converted` میشود.
|
||||
*
|
||||
* از رویداد میآید نه از خودِ `BookingService`: تبدیل، اثر جانبیِ رزرو است نه بخشی از
|
||||
* آن، و اگر داخل تراکنش رزرو مینشست یک خطای لیست انتظار میتوانست نوبت واقعی بیمار
|
||||
* را برگرداند.
|
||||
*
|
||||
* **idempotent** است: `WaitlistConverter` ردیفِ از قبل تبدیلشده را رد میکند، پس تحویل
|
||||
* دوبارهٔ پیام چیزی را خراب نمیکند.
|
||||
*/
|
||||
#[AsMessageHandler]
|
||||
final class WaitlistConversionHandler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WaitlistConverter $converter,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function __invoke(DomainEventMessage $message): void
|
||||
{
|
||||
if ($message->name !== DomainEvents::APPOINTMENT_BOOKED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uuid = $message->payload['appointment_uuid'] ?? null;
|
||||
|
||||
if (!is_string($uuid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$appointment = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]);
|
||||
|
||||
// نوبتِ لغوشده بین انتشار و مصرف: چیزی برای تبدیل نمانده.
|
||||
if ($appointment !== null) {
|
||||
$this->converter->convertFor($appointment);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Waitlist\Entity\WaitlistEntry;
|
||||
use App\Waitlist\Repository\WaitlistEntryRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* بستن ردیف انتظار وقتی همان بیمار همان سرویس را رزرو کرد.
|
||||
*
|
||||
* تطبیق عمداً **تنگ** است: همان بیمار، همان سرویس، و زمان نوبت داخل بازهٔ خواستهشده.
|
||||
* تطبیق شل («هر انتظاری از این بیمار») ردیفی را میبندد که برای خدمت دیگری بود و بیمار
|
||||
* هنوز منتظرش است — و او دیگر هرگز خبر نمیشود.
|
||||
*/
|
||||
final class WaitlistConverter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WaitlistEntryRepository $entries,
|
||||
private readonly PatientRecordRepository $patients,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return int تعداد ردیفهایی که بسته شد
|
||||
*/
|
||||
public function convertFor(Appointment $appointment): int
|
||||
{
|
||||
$service = $appointment->getServiceItem();
|
||||
|
||||
if ($service === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$patient = $this->patients->findOneBy([
|
||||
'user' => $appointment->getUser(),
|
||||
'entityType' => $appointment->getEntityType(),
|
||||
'entityId' => $appointment->getEntityId(),
|
||||
]);
|
||||
|
||||
if ($patient === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$converted = 0;
|
||||
|
||||
foreach ($this->entries->findForPatient($patient) as $entry) {
|
||||
// ردیفِ بسته دوباره بسته نمیشود — همین idempotency تحویل دوبارهٔ پیام است.
|
||||
if (!in_array($entry->getStatus(), [WaitlistEntry::STATUS_WAITING, WaitlistEntry::STATUS_NOTIFIED], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($entry->getServiceItem()->getId() !== $service->getId()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$entry->covers($appointment->getSlotStart())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry->markConverted($appointment);
|
||||
$converted++;
|
||||
}
|
||||
|
||||
if ($converted > 0) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
return $converted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\Service;
|
||||
|
||||
use App\Waitlist\Entity\WaitlistEntry;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* بستن ردیفهایی که بازهٔ خواستهشدهشان گذشته است.
|
||||
*
|
||||
* ردیف منقضی از قبل هم در تطبیق نمیآمد (`desiredTo >= now`)، پس این پاکسازیِ **نمایش**
|
||||
* است نه اصلاح رفتار: بدون آن، صفحهٔ لیست انتظار پر میشود از انتظارهای مرده و اپراتور
|
||||
* نمیفهمد کدامشان هنوز زنده است.
|
||||
*
|
||||
* حذف نمیکند، وضعیت را عوض میکند — چه کسی منتظر ماند و به نتیجه نرسید، خودش داده است.
|
||||
*/
|
||||
final class WaitlistExpirer
|
||||
{
|
||||
public function __construct(private readonly Connection $connection) {}
|
||||
|
||||
/**
|
||||
* @return int تعداد ردیفهای منقضیشده
|
||||
*/
|
||||
public function expire(?int $now = null): int
|
||||
{
|
||||
return (int) $this->connection->executeStatement(
|
||||
'UPDATE waitlist_entries
|
||||
SET status = :expired, updated_at = :now
|
||||
WHERE status IN (:open)
|
||||
AND desired_to < :now',
|
||||
[
|
||||
'expired' => WaitlistEntry::STATUS_EXPIRED,
|
||||
'now' => $now ?? time(),
|
||||
'open' => [WaitlistEntry::STATUS_WAITING, WaitlistEntry::STATUS_NOTIFIED],
|
||||
],
|
||||
['open' => \Doctrine\DBAL\ArrayParameterType::STRING],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Waitlist\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Doctor\Repository\DoctorAddressRepository;
|
||||
use App\Representation\Service\JalaliDateService;
|
||||
use App\Sms\Service\SmsService;
|
||||
use App\Waitlist\Entity\WaitlistEntry;
|
||||
@@ -27,6 +29,7 @@ final class WaitlistNotifier
|
||||
|
||||
public function __construct(
|
||||
private readonly WaitlistEntryRepository $entries,
|
||||
private readonly DoctorAddressRepository $addresses,
|
||||
private readonly SmsService $sms,
|
||||
private readonly JalaliDateService $jalali,
|
||||
private readonly EntityManagerInterface $em,
|
||||
@@ -50,6 +53,15 @@ final class WaitlistNotifier
|
||||
$now,
|
||||
);
|
||||
|
||||
$timezone = $this->timezoneOf($appointment->getAddressId());
|
||||
|
||||
// فیلتر بخش روز **قبل از** بریدن به ده نفر اعمال میشود، وگرنه ده جای اول را
|
||||
// کسانی پر میکنند که این ساعت را نمیخواستند و نفر یازدهمِ واقعی خبر نمیشود.
|
||||
$matches = array_values(array_filter(
|
||||
$matches,
|
||||
static fn (WaitlistEntry $e): bool => $e->coversDayPart($appointment->getSlotStart(), $timezone),
|
||||
));
|
||||
|
||||
$notified = 0;
|
||||
|
||||
foreach (array_slice($matches, 0, self::MAX_RECIPIENTS) as $entry) {
|
||||
@@ -68,6 +80,16 @@ final class WaitlistNotifier
|
||||
return $notified;
|
||||
}
|
||||
|
||||
/** شعبهٔ ناشناخته به منطقهٔ زمانی پیشفرض برمیگردد؛ نبودِ شعبه نباید تطبیق را بخواباند. */
|
||||
private function timezoneOf(?int $addressId): string
|
||||
{
|
||||
if ($addressId === null) {
|
||||
return DoctorAddress::DEFAULT_TIMEZONE;
|
||||
}
|
||||
|
||||
return $this->addresses->find($addressId)?->getTimezone() ?? DoctorAddress::DEFAULT_TIMEZONE;
|
||||
}
|
||||
|
||||
private function notify(WaitlistEntry $entry, int $slotStart): void
|
||||
{
|
||||
$mobile = $entry->getPatientRecord()->getUser()->getMobileNumber();
|
||||
|
||||
@@ -297,4 +297,167 @@ class WaitlistTest extends ApiTestCase
|
||||
$this->authJson('DELETE', "/api/v1/waitlist/{$entry['uuid']}", $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── بخش روز ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ⭐ ترجیح روز باید در **تطبیق** اعمال شود، نه فقط ذخیره.
|
||||
*
|
||||
* ذخیرهکردنِ «عصر» و بعد خبر دادن برای ساعت ۹ صبح، بدتر از نپرسیدن است: بیمار
|
||||
* فکر میکند سیستم حرفش را شنیده.
|
||||
*/
|
||||
public function testAnEntryIsNotNotifiedOutsideItsPreferredDayPart(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$morning = $this->localHour(9);
|
||||
|
||||
$this->join($user, $patient, $service, $morning - 86400, $morning + 86400, [
|
||||
'preferred_day_parts' => ['evening'],
|
||||
]);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, $address, $morning);
|
||||
|
||||
self::assertSame(0, $this->notifier()->notifyForFreedSlot($appointment));
|
||||
}
|
||||
|
||||
public function testAnEntryIsNotifiedInsideItsPreferredDayPart(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$evening = $this->localHour(19);
|
||||
|
||||
$this->join($user, $patient, $service, $evening - 86400, $evening + 86400, [
|
||||
'preferred_day_parts' => ['evening'],
|
||||
]);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, $address, $evening);
|
||||
|
||||
self::assertSame(1, $this->notifier()->notifyForFreedSlot($appointment));
|
||||
}
|
||||
|
||||
/** نداشتن ترجیح یعنی «هر ساعتی» — نه «هیچ ساعتی». */
|
||||
public function testAnEntryWithoutAPreferenceMatchesAnyHour(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$dawn = $this->localHour(5);
|
||||
|
||||
$this->join($user, $patient, $service, $dawn - 86400, $dawn + 86400);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, $address, $dawn);
|
||||
|
||||
self::assertSame(1, $this->notifier()->notifyForFreedSlot($appointment));
|
||||
}
|
||||
|
||||
public function testAnUnknownDayPartIsRejected(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$this->authJson('POST', '/api/v1/waitlist', $user, [
|
||||
'patient_uuid' => $patient->getUuid(),
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'desired_from' => time() + 86400,
|
||||
'desired_to' => time() + 3 * 86400,
|
||||
'preferred_day_parts' => ['midnight'],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── مرزها و چرخهٔ عمر ───────────────────────────────────────────────────
|
||||
|
||||
public function testARangeLongerThanNinetyDaysIsRejected(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$from = time() + 86400;
|
||||
|
||||
$this->authJson('POST', '/api/v1/waitlist', $user, [
|
||||
'patient_uuid' => $patient->getUuid(),
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'desired_from' => $from,
|
||||
'desired_to' => $from + 91 * 86400,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ ردیفِ منقضی از قبل هم در تطبیق نمیآمد؛ این پاکسازیِ **نمایش** است تا اپراتور
|
||||
* بفهمد کدام انتظار هنوز زنده است.
|
||||
*/
|
||||
public function testExpiringClosesPassedEntriesAndLeavesLiveOnes(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$live = $this->join($user, $patient, $service, time() + 86400, time() + 5 * 86400);
|
||||
$dead = $this->join($user, $this->extraPatient((int) $patient->getEntityId()), $service, time() + 86400, time() + 2 * 86400);
|
||||
|
||||
// بازهٔ ردیف دوم را به گذشته میبریم — API عمداً بازهٔ گذشته را نمیپذیرد.
|
||||
$this->em->getConnection()->executeStatement(
|
||||
'UPDATE waitlist_entries SET desired_from = ?, desired_to = ? WHERE uuid = ?',
|
||||
[time() - 10 * 86400, time() - 86400, $dead['uuid']],
|
||||
);
|
||||
|
||||
$expired = static::getContainer()->get(\App\Waitlist\Service\WaitlistExpirer::class)->expire();
|
||||
self::assertSame(1, $expired);
|
||||
|
||||
// خواندن مستقیم از دیتابیس: `expire()` با SQL خام مینویسد، پس هر نقشهٔ هویتِ
|
||||
// باز، نسخهٔ کهنه را برمیگرداند.
|
||||
self::assertSame(WaitlistEntry::STATUS_EXPIRED, $this->statusOf($dead['uuid']));
|
||||
self::assertSame(WaitlistEntry::STATUS_WAITING, $this->statusOf($live['uuid']));
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ تبدیل باید **تنگ** باشد: ردیفِ خدمت دیگر نباید بسته شود، وگرنه بیمار برای
|
||||
* چیزی که هنوز منتظرش است دیگر هرگز خبر نمیشود.
|
||||
*/
|
||||
public function testBookingConvertsOnlyTheMatchingEntry(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
$other = $this->service($section, 'بوتاکس');
|
||||
|
||||
$start = time() + 2 * 86400;
|
||||
|
||||
$mine = $this->join($user, $patient, $service, $start - 86400, $start + 86400);
|
||||
$unrelated = $this->join($user, $patient, $other, $start - 86400, $start + 86400);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, $address, $start);
|
||||
|
||||
$converter = static::getContainer()->get(\App\Waitlist\Service\WaitlistConverter::class);
|
||||
self::assertSame(1, $converter->convertFor($appointment));
|
||||
|
||||
// اجرای دوباره چیزی را دوباره نمیبندد — تحویل دوبارهٔ پیام بیخطر است.
|
||||
self::assertSame(0, $converter->convertFor($appointment));
|
||||
|
||||
$this->em->clear();
|
||||
$repo = static::getContainer()->get(WaitlistEntryRepository::class);
|
||||
|
||||
self::assertSame(WaitlistEntry::STATUS_CONVERTED, $repo->findByUuid($mine['uuid'])->getStatus());
|
||||
self::assertSame(WaitlistEntry::STATUS_WAITING, $repo->findByUuid($unrelated['uuid'])->getStatus());
|
||||
}
|
||||
|
||||
private function statusOf(string $uuid): string
|
||||
{
|
||||
return (string) $this->em->getConnection()->fetchOne(
|
||||
'SELECT status FROM waitlist_entries WHERE uuid = ?',
|
||||
[$uuid],
|
||||
);
|
||||
}
|
||||
|
||||
/** ساعت محلیِ شعبه روی فردا — تست نباید به ساعت اجرا وابسته باشد. */
|
||||
private function localHour(int $hour): int
|
||||
{
|
||||
return (new \DateTimeImmutable('tomorrow', new \DateTimeZone(DoctorAddress::DEFAULT_TIMEZONE)))
|
||||
->setTime($hour, 0)
|
||||
->getTimestamp();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user