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:
@@ -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> */
|
||||
|
||||
Reference in New Issue
Block a user