fix(appointment): make resource bookings independent of the doctor's calendar
Booking a device is not booking its doctor: the operator runs it and the doctor only supervises. But bookAtomically locked the doctor row and isSlotTaken checked overlap against the doctor alone, ignoring which resource was chosen, so a clinic whose devices share one supervisor could not run two of them at once. Every tenant in the database is in that position — clinic 2's six resources all point at doctor 6. Resource bookings now skip the doctor lock and carry no active_slot_key; their guarantee comes from resource_occupancy, which understands capacity and seats. Both direct paths write occupancy rows the way the hold engine already did, so ResourceBookingSlotService stops being the only thing holding two sources of truth together, and cancelling releases the seat. Occupancy is bucketed in five-minute slices, which is coarser than a booking time: a booking ending 12:35:04 spilled four seconds into the 12:35 bucket and collided with the next one starting at that same second, despite zero real overlap. This surfaced on real rows 76 and 77 during backfill. Resource bookings now snap both ends of their window down to the bucket grid — schedule-driven slots are already aligned, so only manually entered times move. The seat is claimed after persist because it needs the appointment id; losing the race removes the appointment rather than leaving a booking with no device behind it. app:appointment:backfill-resource-occupancy gives existing resource-backed appointments their missing occupancy and clears the doctor keys that no longer mean anything. It reports conflicts between two old bookings instead of picking a loser. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Availability\Service;
|
||||
|
||||
use App\Appointment\Availability\Entity\ResourceOccupancy;
|
||||
use App\Appointment\Booking\Entity\OccupancyBucket;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* گرفتن و آزادکردنِ زمانِ یک منبع — تنها جایی که صندلیها ادعا میشوند.
|
||||
*
|
||||
* پیش از این فقط مسیر رزرو موقت ردیف اشغال میساخت و مسیرهای مستقیم (پنل و سایت)
|
||||
* روی `appointments.resource_id` تکیه میکردند؛ نتیجهاش دو منبعِ حقیقت بود که
|
||||
* `ResourceBookingSlotService::busyIntervals` باید دستی با هم جمعشان میکرد، و
|
||||
* تضمینِ دیتابیسی فقط پشت یکیشان بود.
|
||||
*/
|
||||
final class ResourceOccupier
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em) {}
|
||||
|
||||
/**
|
||||
* اولین صندلی آزادِ منبع را برای این بازه میگیرد.
|
||||
*
|
||||
* سطلها با DBAL خام نوشته میشوند نه با ORM: برخورد کلید یکتا در `flush()` خودِ
|
||||
* EntityManager را میبندد و تلاش صندلی بعدی هم با «EntityManager is closed»
|
||||
* میشکست. با DBAL، استثنا فقط یک استثناست و حلقه ادامه مییابد.
|
||||
*
|
||||
* @throws AppException ۴۰۹ وقتی همهٔ صندلیها گرفتهاند
|
||||
*/
|
||||
public function occupy(
|
||||
ClinicResource $resource,
|
||||
int $startsAt,
|
||||
int $endsAt,
|
||||
string $status = ResourceOccupancy::STATUS_BOOKED,
|
||||
?int $appointmentId = null,
|
||||
?int $holdId = null,
|
||||
?string $segmentName = null,
|
||||
): ResourceOccupancy {
|
||||
$buckets = OccupancyBucket::bucketsFor($startsAt, $endsAt);
|
||||
$connection = $this->em->getConnection();
|
||||
|
||||
for ($seat = 0; $seat < $resource->getCapacity(); $seat++) {
|
||||
$occupancy = new ResourceOccupancy($resource, $startsAt, $endsAt, $status);
|
||||
$occupancy->setSegmentName($segmentName);
|
||||
$occupancy->setHoldId($holdId);
|
||||
$occupancy->setAppointmentId($appointmentId);
|
||||
|
||||
$this->em->persist($occupancy);
|
||||
$this->em->flush();
|
||||
|
||||
try {
|
||||
foreach ($buckets as $bucketAt) {
|
||||
$connection->insert('resource_occupancy_buckets', [
|
||||
'resource_id' => $resource->getId(),
|
||||
'occupancy_id' => $occupancy->getId(),
|
||||
'bucket_at' => $bucketAt,
|
||||
'seat' => $seat,
|
||||
]);
|
||||
}
|
||||
|
||||
return $occupancy;
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
// این صندلی همین حالا گرفته شد. سطلهای نیمهنوشته و خودِ ردیف اشغال
|
||||
// پاک میشوند تا صندلی بعدی از صفر شروع کند.
|
||||
$connection->delete('resource_occupancy_buckets', ['occupancy_id' => $occupancy->getId()]);
|
||||
$this->em->remove($occupancy);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_SLOT_TAKEN,
|
||||
sprintf('«%s» در این زمان آزاد نیست', $resource->getName()),
|
||||
409,
|
||||
'resource_uuid',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* آزادسازی: وضعیت `released` و پاک کردن سطلها.
|
||||
*
|
||||
* خودِ ردیف اشغال میماند چون تاریخچهٔ بهرهوری است؛ ولی سطلها باید بروند وگرنه
|
||||
* کلید یکتا آن زمان را برای همیشه قفل نگه میدارد.
|
||||
*
|
||||
* @param iterable<ResourceOccupancy> $occupancies
|
||||
*/
|
||||
public function release(iterable $occupancies): void
|
||||
{
|
||||
$connection = $this->em->getConnection();
|
||||
$released = false;
|
||||
|
||||
foreach ($occupancies as $occupancy) {
|
||||
$connection->delete('resource_occupancy_buckets', ['occupancy_id' => $occupancy->getId()]);
|
||||
$occupancy->markReleased();
|
||||
$released = true;
|
||||
}
|
||||
|
||||
if ($released) {
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* آزادکردنِ هرچه این نوبت گرفته بود — هنگام لغو، انقضا یا جابهجایی.
|
||||
*
|
||||
* @return int تعداد ردیفهای آزادشده
|
||||
*/
|
||||
public function releaseForAppointment(int $appointmentId): int
|
||||
{
|
||||
$occupancies = $this->em->getRepository(ResourceOccupancy::class)->findBy([
|
||||
'appointmentId' => $appointmentId,
|
||||
'status' => ResourceOccupancy::BLOCKING_STATUSES,
|
||||
]);
|
||||
|
||||
$this->release($occupancies);
|
||||
|
||||
return count($occupancies);
|
||||
}
|
||||
}
|
||||
@@ -75,4 +75,34 @@ class OccupancyBucket
|
||||
|
||||
return $buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* تراز کردن یک بازه با مرزهای سطل — **هر دو سر رو به پایین**.
|
||||
*
|
||||
* دقتِ اشغال در حدِ یک سطل است، پس دو نوبتِ پشتسرهم که ثانیهشان وسط یک سطل
|
||||
* میافتد متعارض دیده میشوند حتی وقتی یک ثانیه هم روی هم نیستند: نوبتی که
|
||||
* ۱۲:۳۵:۰۴ تمام میشود چهار ثانیه وارد سطلِ ۱۲:۳۵ میشود و نوبت بعدی هم از همان
|
||||
* سطل شروع میکند.
|
||||
*
|
||||
* پایان هم رو به پایین گرد میشود، نه رو به بالا: با گرد کردن به بالا، پایانِ هر
|
||||
* نوبت وارد سطلِ شروعِ نوبت بعدی میشد و همان تعارض از سمت دیگر برمیگشت.
|
||||
*
|
||||
* نوبتهایی که از برنامهٔ هفتگی میآیند از قبل تراز هستند و این تابع رویشان بیاثر
|
||||
* است؛ فقط ساعتِ دستیِ «ثبت خارج از برنامه» را سرِ جایش مینشاند.
|
||||
*
|
||||
* @return array{0: int, 1: int} بازهٔ ترازشده
|
||||
*/
|
||||
public static function alignWindow(int $start, int $end): array
|
||||
{
|
||||
$alignedStart = intdiv($start, self::BUCKET_SECONDS) * self::BUCKET_SECONDS;
|
||||
$alignedEnd = intdiv($end, self::BUCKET_SECONDS) * self::BUCKET_SECONDS;
|
||||
|
||||
// نوبتی کوتاهتر از یک سطل نباید به صفر جمع شود، وگرنه هیچ سطلی نمیگیرد و
|
||||
// بیمحافظت میماند.
|
||||
if ($alignedEnd <= $alignedStart) {
|
||||
$alignedEnd = $alignedStart + self::BUCKET_SECONDS;
|
||||
}
|
||||
|
||||
return [$alignedStart, $alignedEnd];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
namespace App\Appointment\Booking\Service;
|
||||
|
||||
use App\Appointment\Availability\Entity\ResourceOccupancy;
|
||||
use App\Appointment\Availability\Service\ResourceOccupier;
|
||||
use App\Appointment\Booking\Entity\AppointmentHold;
|
||||
use App\Appointment\Booking\Entity\OccupancyBucket;
|
||||
use App\Appointment\Plan\ValueObject\AppointmentPlan;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
@@ -35,6 +34,7 @@ final class HoldService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ResourceOccupier $occupier,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -108,6 +108,14 @@ final class HoldService
|
||||
*
|
||||
* @throws AppException ۴۰۹ وقتی همهٔ صندلیها گرفتهاند
|
||||
*/
|
||||
/**
|
||||
* یک بازه را برای یک منبع میگیرد، با اولین صندلی آزاد.
|
||||
*
|
||||
* منطقِ ادعای صندلی در {@see ResourceOccupier} است چون مسیرهای مستقیمِ رزرو هم
|
||||
* همان را لازم دارند؛ دو نسخه یعنی دو رفتار متفاوت در برابر برخورد کلید یکتا.
|
||||
*
|
||||
* @throws AppException ۴۰۹ وقتی همهٔ صندلیها گرفتهاند
|
||||
*/
|
||||
private function reserve(
|
||||
ClinicResource $resource,
|
||||
int $start,
|
||||
@@ -115,42 +123,13 @@ final class HoldService
|
||||
AppointmentHold $hold,
|
||||
?string $segmentName,
|
||||
): ResourceOccupancy {
|
||||
$buckets = OccupancyBucket::bucketsFor($start, $end);
|
||||
$connection = $this->em->getConnection();
|
||||
|
||||
for ($seat = 0; $seat < $resource->getCapacity(); $seat++) {
|
||||
$occupancy = new ResourceOccupancy($resource, $start, $end, ResourceOccupancy::STATUS_HOLD);
|
||||
$occupancy->setSegmentName($segmentName);
|
||||
$occupancy->setHoldId($hold->getId());
|
||||
|
||||
$this->em->persist($occupancy);
|
||||
$this->em->flush();
|
||||
|
||||
try {
|
||||
foreach ($buckets as $bucketAt) {
|
||||
$connection->insert('resource_occupancy_buckets', [
|
||||
'resource_id' => $resource->getId(),
|
||||
'occupancy_id' => $occupancy->getId(),
|
||||
'bucket_at' => $bucketAt,
|
||||
'seat' => $seat,
|
||||
]);
|
||||
}
|
||||
|
||||
return $occupancy;
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
// این صندلی همین حالا گرفته شد. سطلهای نیمهنوشته و خودِ ردیف اشغال
|
||||
// پاک میشوند تا صندلی بعدی از صفر شروع کند.
|
||||
$connection->delete('resource_occupancy_buckets', ['occupancy_id' => $occupancy->getId()]);
|
||||
$this->em->remove($occupancy);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_SLOT_TAKEN,
|
||||
sprintf('«%s» در این زمان ظرفیت خالی ندارد', $resource->getName()),
|
||||
409,
|
||||
'assignment',
|
||||
return $this->occupier->occupy(
|
||||
$resource,
|
||||
$start,
|
||||
$end,
|
||||
ResourceOccupancy::STATUS_HOLD,
|
||||
holdId: $hold->getId(),
|
||||
segmentName: $segmentName,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,18 +143,7 @@ final class HoldService
|
||||
*/
|
||||
public function release(array $occupancies): void
|
||||
{
|
||||
if ($occupancies === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$connection = $this->em->getConnection();
|
||||
|
||||
foreach ($occupancies as $occupancy) {
|
||||
$connection->delete('resource_occupancy_buckets', ['occupancy_id' => $occupancy->getId()]);
|
||||
$occupancy->markReleased();
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
$this->occupier->release($occupancies);
|
||||
}
|
||||
|
||||
/** @return list<ResourceOccupancy> */
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Command;
|
||||
|
||||
use App\Appointment\Availability\Entity\ResourceOccupancy;
|
||||
use App\Appointment\Availability\Service\ResourceOccupier;
|
||||
use App\Appointment\Booking\Entity\OccupancyBucket;
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Shared\Exception\AppException;
|
||||
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;
|
||||
|
||||
/**
|
||||
* ردیف اشغال برای نوبتهای منبعدارِ قدیمی.
|
||||
*
|
||||
* تا پیش از این، مسیرهای مستقیمِ رزرو فقط `appointments.resource_id` را مینوشتند و
|
||||
* ردیف اشغال نمیساختند؛ حالا که تضمینِ یکتاییِ منبع روی `resource_occupancy` است،
|
||||
* آن نوبتها بدون این backfill نامرئی میمانند و همان ساعت دوباره فروخته میشود.
|
||||
*
|
||||
* قابل اجرای چندباره: نوبتی که از قبل ردیف دارد رد میشود.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:appointment:backfill-resource-occupancy',
|
||||
description: 'Create occupancy rows for live resource-backed appointments that predate them',
|
||||
)]
|
||||
final class BackfillResourceOccupancyCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ResourceOccupier $occupier,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'فقط گزارش بده، چیزی ننویس');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
/** @var Appointment[] $appointments */
|
||||
$appointments = $this->em->createQuery('
|
||||
SELECT a FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.resource IS NOT NULL
|
||||
AND a.isReserve = false
|
||||
AND a.status IN (:live)
|
||||
ORDER BY a.id ASC
|
||||
')->setParameter('live', [
|
||||
Appointment::STATUS_PENDING,
|
||||
Appointment::STATUS_CONFIRMED,
|
||||
Appointment::STATUS_FOLLOWING_UP,
|
||||
Appointment::STATUS_SALON,
|
||||
])->getResult();
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($appointments as $appointment) {
|
||||
$existing = $this->em->getRepository(ResourceOccupancy::class)->count([
|
||||
'appointmentId' => $appointment->getId(),
|
||||
'status' => ResourceOccupancy::BLOCKING_STATUSES,
|
||||
]);
|
||||
|
||||
if ($existing > 0) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$io->text(sprintf('نوبت %d → منبع %s', $appointment->getId(), $appointment->getResource()->getName()));
|
||||
$created++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// نوبتهای قدیمیِ «ورود دستی ساعت» تراز نیستند و بدون این، دو نوبتِ
|
||||
// پشتسرهم سطل مشترک پیدا میکنند و دومی جا نمیشود.
|
||||
[$start, $end] = OccupancyBucket::alignWindow(
|
||||
$appointment->getSlotStart(),
|
||||
$appointment->getSlotEnd(),
|
||||
);
|
||||
|
||||
try {
|
||||
$this->occupier->occupy(
|
||||
$appointment->getResource(),
|
||||
$start,
|
||||
$end,
|
||||
appointmentId: (int) $appointment->getId(),
|
||||
);
|
||||
$created++;
|
||||
} catch (AppException) {
|
||||
// دو نوبتِ قدیمی روی یک دستگاه و یک ساعت — تعارضی که تا امروز دیده
|
||||
// نمیشد. گزارش میشود تا آدمی تصمیم بگیرد، نه اینکه یکی بیصدا بپرد.
|
||||
$io->warning(sprintf(
|
||||
'نوبت %d روی «%s» جا نشد؛ احتمالاً با نوبت دیگری تداخل دارد',
|
||||
$appointment->getId(),
|
||||
$appointment->getResource()->getName(),
|
||||
));
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// کلید پزشک روی نوبت منبعدار دیگر معنا ندارد و جای رزروهای بیمنبعِ همان پزشک
|
||||
// را اشغال نگه میدارد.
|
||||
$clearedKeys = $dryRun ? 0 : $this->em->getConnection()->executeStatement(
|
||||
'UPDATE appointments SET active_slot_key = NULL WHERE resource_id IS NOT NULL AND active_slot_key IS NOT NULL',
|
||||
);
|
||||
|
||||
$io->success(sprintf(
|
||||
'%d ردیف اشغال ساخته شد، %d از قبل داشت، %d ناموفق، %d کلید اسلات پاک شد%s',
|
||||
$created,
|
||||
$skipped,
|
||||
$failed,
|
||||
$clearedKeys,
|
||||
$dryRun ? ' (dry-run)' : '',
|
||||
));
|
||||
|
||||
return $failed > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ class AppointmentController extends BaseController
|
||||
private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance,
|
||||
private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator,
|
||||
private readonly \App\Appointment\Service\ServiceRescheduleService $rescheduleService,
|
||||
private readonly \App\Appointment\Availability\Service\ResourceOccupier $occupier,
|
||||
private readonly \Psr\Log\LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
@@ -505,6 +506,13 @@ class AppointmentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'زمان این اسلات گذشته است', 422);
|
||||
}
|
||||
|
||||
// ساعتِ نوبتِ منبعدار با مرز سطلهای اشغال تراز میشود. اسلاتهای برنامهٔ
|
||||
// هفتگی از قبل ترازند و این بیاثر است؛ ساعتِ دستی وگرنه با نوبتِ چسبیدهٔ
|
||||
// بعدی سطل مشترک پیدا میکرد و بیدلیل تعارض میساخت.
|
||||
if ($resource !== null && $slotEnd > $slotStart) {
|
||||
[$slotStart, $slotEnd] = \App\Appointment\Booking\Entity\OccupancyBucket::alignWindow($slotStart, $slotEnd);
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
@@ -633,6 +641,23 @@ class AppointmentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
|
||||
}
|
||||
|
||||
// بعد از persist چون شناسهٔ نوبت لازم است. اینجاست که واقعاً صندلیِ منبع ادعا
|
||||
// میشود؛ شکستش یعنی همین لحظه پر شد و نوبت باید برگردد.
|
||||
if ($resource !== null) {
|
||||
try {
|
||||
$this->occupier->occupy(
|
||||
$resource,
|
||||
$appointment->getSlotStart(),
|
||||
$appointment->getSlotEnd(),
|
||||
appointmentId: (int) $appointment->getId(),
|
||||
);
|
||||
} catch (\App\Shared\Exception\AppException) {
|
||||
$this->appointmentRepo->remove($appointment);
|
||||
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این منبع در این زمان آزاد نیست', 409, 'resource_uuid');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()], 201);
|
||||
}
|
||||
|
||||
@@ -1036,6 +1061,12 @@ class AppointmentController extends BaseController
|
||||
if (in_array($newStatus, self::CANCEL_STATUSES, true)) {
|
||||
$reason = isset($data['cancel_reason']) ? trim((string) $data['cancel_reason']) : '';
|
||||
$this->recordCancellation($appointment, $newStatus, $reason !== '' ? $reason : null, $user);
|
||||
|
||||
// بدون آزادسازی، سطلهای اشغال میمانند و آن دستگاه برای همیشه در آن ساعت
|
||||
// پر به نظر میرسد — نوبتی که لغو شده ولی جایش را پس نداده.
|
||||
if ($appointment->getResource() !== null) {
|
||||
$this->occupier->releaseForAppointment((int) $appointment->getId());
|
||||
}
|
||||
}
|
||||
|
||||
if ($newStatus === Appointment::STATUS_COMPLETED) {
|
||||
|
||||
@@ -49,6 +49,7 @@ class MyAppointmentsController extends BaseController
|
||||
private readonly \App\Appointment\Repository\WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly \App\Resource\Repository\ClinicResourceRepository $resourceRepo,
|
||||
private readonly \App\Resource\Service\ResourceBookingSlotService $resourceSlots,
|
||||
private readonly \App\Appointment\Availability\Service\ResourceOccupier $occupier,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -226,6 +227,13 @@ class MyAppointmentsController extends BaseController
|
||||
return $this->error(ErrorCodes::VALIDATION, 'هزینه ویزیت الزامی است', 422, 'visit_price_rials');
|
||||
}
|
||||
|
||||
// ساعتِ نوبتِ منبعدار با مرز سطلهای اشغال تراز میشود. اسلاتهای برنامهٔ
|
||||
// هفتگی از قبل ترازند و این بیاثر است؛ ساعتِ دستیِ «ثبت خارج از برنامه»
|
||||
// وگرنه با نوبتِ چسبیدهٔ بعدی سطل مشترک پیدا میکرد.
|
||||
if ($resource !== null && !$isReserve && $slotEnd > $slotStart) {
|
||||
[$slotStart, $slotEnd] = \App\Appointment\Booking\Entity\OccupancyBucket::alignWindow($slotStart, $slotEnd);
|
||||
}
|
||||
|
||||
// Identity is keyed on the national code (unique) so the case-file stays
|
||||
// single per person even when booked under a different mobile.
|
||||
$patient = $this->patientResolver->resolveForBooking($nationalCode, $mobile, $patientName);
|
||||
@@ -277,14 +285,19 @@ class MyAppointmentsController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* تداخل روی خودِ منبع جدا سنجیده میشود: `bookAtomically` فقط اسلاتِ پزشک
|
||||
* را قفل میکند و دو پزشکِ متفاوت میتوانند یک دستگاه را همزمان بگیرند.
|
||||
* پیشبررسیِ زودهنگام تا کاربر پیام روشن بگیرد؛ تضمین واقعی پایینتر با
|
||||
* گرفتنِ صندلی در `resource_occupancy` انجام میشود، چون این بررسی
|
||||
* بهتنهایی در برابر دو درخواست همزمان محافظت نمیکند.
|
||||
*/
|
||||
if (!$isReserve && !$this->resourceSlots->isFree($resource, $slotStart, $slotEnd)) {
|
||||
return $this->error(ErrorCodes::SLOT_TAKEN, 'این منبع در این زمان آزاد نیست', 409, 'resource_uuid');
|
||||
}
|
||||
|
||||
$appointment->setResource($resource);
|
||||
|
||||
// شعبهٔ نوبتِ منبعدار از خودِ منبع میآید، نه از برنامهٔ هفتگی پزشک: دستگاه
|
||||
// در برنامهٔ پزشک اسلاتی ندارد و `resolveSlotLocationId` برایش null میداد.
|
||||
$appointment->setAddressId($resource->getAddress()->getId());
|
||||
}
|
||||
|
||||
// پیوستِ همهٔ سرویسهای انتخابشده؛ سرویسِ اصلی = اولین سرویس (addServiceItem).
|
||||
@@ -322,6 +335,24 @@ class MyAppointmentsController extends BaseController
|
||||
} catch (SlotTakenException) {
|
||||
return $this->error(ErrorCodes::SLOT_TAKEN, 'این نوبت قبلاً رزرو شده است', 409);
|
||||
}
|
||||
|
||||
// بعد از persist چون شناسهٔ نوبت لازم است. شکستِ گرفتن صندلی یعنی منبع
|
||||
// همین حالا پر شد؛ نوبت باید برگردد وگرنه رزروی میماند که هیچ دستگاهی
|
||||
// پشتش نیست.
|
||||
if ($resource !== null) {
|
||||
try {
|
||||
$this->occupier->occupy(
|
||||
$resource,
|
||||
$slotStart,
|
||||
$slotEnd,
|
||||
appointmentId: (int) $appointment->getId(),
|
||||
);
|
||||
} catch (\App\Shared\Exception\AppException) {
|
||||
$this->appointmentRepo->remove($appointment);
|
||||
|
||||
return $this->error(ErrorCodes::SLOT_TAKEN, 'این منبع در این زمان آزاد نیست', 409, 'resource_uuid');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
|
||||
@@ -279,9 +279,15 @@ class Appointment
|
||||
{
|
||||
// Reserve-list entries are day-level wishes, not slot bookings — they
|
||||
// never occupy a slot, so several reserves may share the same day.
|
||||
$this->activeSlotKey = !$this->isReserve && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
|
||||
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
|
||||
: null;
|
||||
// نوبتِ منبعدار کلید نمیگیرد: تضمینش از `resource_occupancy` میآید که ظرفیت و
|
||||
// بافر را میفهمد، در حالی که این کلید فقط پزشک را میشناسد. نگهداشتنِ هر دو
|
||||
// یعنی کلینیکی که چند دستگاه زیر نظر یک پزشک دارد، در هر ساعت فقط یکیشان را
|
||||
// میتواند رزرو کند.
|
||||
$this->activeSlotKey = !$this->isReserve
|
||||
&& $this->resource === null
|
||||
&& in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
|
||||
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
|
||||
: null;
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
@@ -323,6 +329,9 @@ class Appointment
|
||||
{
|
||||
$this->resource = $v;
|
||||
$this->updatedAt = time();
|
||||
// منبع در کلید اسلات اثر دارد، و نوبت معمولاً اول ساخته و بعد منبعش ست
|
||||
// میشود — بدون این، کلیدِ ساختهشده در سازنده باقی میماند.
|
||||
$this->refreshActiveSlotKey();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,20 @@ class AppointmentRepository extends ServiceEntityRepository
|
||||
$start = $appointment->getSlotStart();
|
||||
$end = $appointment->getSlotEnd();
|
||||
|
||||
/**
|
||||
* رزروِ منبع از تقویم پزشک مستقل است: دستگاه را اپراتور کار میکند و
|
||||
* پزشک فقط ناظر است. قفل و بررسیِ per-doctor اینجا اعمال نمیشود،
|
||||
* وگرنه کلینیکی که چند دستگاه زیر نظر یک پزشک دارد نمیتواند دوتایشان
|
||||
* را همزمان رزرو کند. یکتاییِ خودِ منبع را
|
||||
* `uniq_bucket_resource_seat` تضمین میکند که ظرفیت و صندلی میفهمد.
|
||||
*/
|
||||
if ($appointment->getResource() !== null) {
|
||||
$em->persist($appointment);
|
||||
$em->flush();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// قفلِ per-doctor (SELECT ... FOR UPDATE روی ردیف پزشک): رزروهای
|
||||
// همزمانِ یک پزشک را سریالایز میکند. در حالت نوبتدهی سرویسی که
|
||||
// نوبتها طول متغیر و شروعِ متفاوت دارند، unique-keyِ (doctor,slot_start)
|
||||
|
||||
Reference in New Issue
Block a user