Refactor booking system: Remove unused policies, packages, and related entities
- Removed package consumption flags and related properties from PriceQuote. - Eliminated unused domain event publishing for policies and waitlist in Schedule. - Cleaned up BookingEngineSeeder by removing package and policy related logic. - Updated SeedScenariosCommand to reflect removal of policies from output. - Dropped policy, package, treatment course, cancellation, waitlist, and domain event tables in migration. - Removed domain event assertions from tests related to resource blocking.
This commit is contained in:
@@ -40,7 +40,6 @@ class AvailabilityController extends BaseController
|
||||
private readonly WeeklyScheduleRepository $schedules,
|
||||
private readonly DoctorRepository $doctors,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly \App\Course\Repository\TreatmentCourseRepository $courses,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/appointment-availability', name: 'appointment_availability', methods: ['POST'])]
|
||||
@@ -111,7 +110,6 @@ class AvailabilityController extends BaseController
|
||||
$step,
|
||||
null,
|
||||
$this->strategyFor($data['doctor_uuid'] ?? null),
|
||||
$this->preferredResourceIds($user, $data['course_uuid'] ?? null),
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
@@ -194,37 +192,6 @@ class AvailabilityController extends BaseController
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* منبع ترجیحیِ یک دورهٔ درمان — «همان اپراتور جلسهٔ قبل».
|
||||
*
|
||||
* ترجیح است نه فیلتر: اگر آزاد نباشد، استراتژی به ترتیب پایه برمیگردد و رزرو
|
||||
* انجام میشود. اجبار یعنی بیمار دو هفته منتظر بماند.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function preferredResourceIds(User $user, mixed $courseUuid): array
|
||||
{
|
||||
if (!is_string($courseUuid) || $courseUuid === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$course = $this->courses->findByUuid($courseUuid);
|
||||
|
||||
if ($course === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
if ($course->getEntityType() !== $entityType || $course->getEntityId() !== $entityId) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'دوره یافت نشد', 404);
|
||||
}
|
||||
|
||||
$preferred = $course->getPreferredResource();
|
||||
|
||||
return $preferred === null ? [] : [(int) $preferred->getId()];
|
||||
}
|
||||
|
||||
private function assertResourceMode(mixed $doctorUuid, DoctorAddress $address): void
|
||||
{
|
||||
if (!is_string($doctorUuid) || $doctorUuid === '') {
|
||||
|
||||
@@ -10,8 +10,6 @@ use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -45,7 +43,6 @@ class ResourceBlockController extends BaseController
|
||||
private readonly ResourceOccupancyRepository $occupancy,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly DomainEventPublisher $domainEvents,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
@@ -99,17 +96,6 @@ class ResourceBlockController extends BaseController
|
||||
|
||||
$this->em->persist($block);
|
||||
|
||||
$this->domainEvents->record(
|
||||
$resource->getEntityType(),
|
||||
$resource->getEntityId(),
|
||||
DomainEvents::RESOURCE_BLOCKED,
|
||||
[
|
||||
'resource_uuid' => $resource->getUuid(),
|
||||
'block_uuid' => $block->getUuid(),
|
||||
'starts_at' => $startsAt,
|
||||
'ends_at' => $endsAt,
|
||||
],
|
||||
);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
@@ -136,18 +122,6 @@ class ResourceBlockController extends BaseController
|
||||
);
|
||||
}
|
||||
|
||||
// پیش از `remove` ثبت میشود چون بعد از آن، uuid و بازه فقط در حافظهاند و
|
||||
// خواندنشان از یک entity حذفشده به رفتار Doctrine وابسته میماند.
|
||||
$this->domainEvents->record(
|
||||
$entityType,
|
||||
$entityId,
|
||||
DomainEvents::RESOURCE_RELEASED,
|
||||
[
|
||||
'block_uuid' => $block->getUuid(),
|
||||
'starts_at' => $block->getStartsAt(),
|
||||
'ends_at' => $block->getEndsAt(),
|
||||
],
|
||||
);
|
||||
|
||||
$this->em->remove($block);
|
||||
$this->em->flush();
|
||||
|
||||
@@ -13,8 +13,6 @@ use App\Auth\Repository\UserRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Pricing\Entity\PriceSnapshot;
|
||||
use App\Pricing\Service\PriceSnapshotService;
|
||||
use App\Package\Service\PackageConsumptionService;
|
||||
use App\Policy\Service\BookingPolicyGuard;
|
||||
use App\Pricing\Service\PricingEngine;
|
||||
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
|
||||
use App\Auth\Entity\User;
|
||||
@@ -25,8 +23,6 @@ use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -56,38 +52,11 @@ class BookingController extends BaseController
|
||||
private readonly PricingEngine $pricing,
|
||||
private readonly PriceSnapshotService $snapshots,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly BookingPolicyGuard $guard,
|
||||
private readonly PackageConsumptionService $packages,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly AppointmentSegmentRepository $segments,
|
||||
private readonly DomainEventPublisher $domainEvents,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* پرچمهایی که فقط در همین درخواست وجود دارند و جایی ذخیره نمیشوند
|
||||
* (مثل رضایت والدین که اپراتور همان لحظه میگیرد).
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function requestFlags(array $data): array
|
||||
{
|
||||
$flags = [];
|
||||
|
||||
foreach (['has_parental_consent'] as $flag) {
|
||||
if (isset($data[$flag])) {
|
||||
$flags[$flag] = (bool) $data[$flag];
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($data['patient_gender'] ?? null)) {
|
||||
$flags['patient_gender'] = $data['patient_gender'];
|
||||
}
|
||||
|
||||
return $flags;
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-hold', name: 'appointment_hold_create', methods: ['POST'])]
|
||||
public function create(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
@@ -128,9 +97,6 @@ class BookingController extends BaseController
|
||||
is_string($data['patient_gender'] ?? null) ? $data['patient_gender'] : null,
|
||||
);
|
||||
|
||||
// قوانین وابسته به بیمار پیش از گرفتن صندلی اجرا میشوند، نه هنگام ثبت نهایی.
|
||||
$this->guard->assertEligible($user, $service, $selected, $address, $this->requestFlags($data));
|
||||
$this->guard->assertSpacing($user, $service, $address, (int) $data['start']);
|
||||
|
||||
$assignment = $this->resolveAssignment($user, $data['assignment']);
|
||||
$this->assertAssignmentCoversPlan($plan, $assignment);
|
||||
@@ -255,19 +221,6 @@ class BookingController extends BaseController
|
||||
$this->booking->confirm($hold, $appointment);
|
||||
$released = $this->booking->cancel($appointment);
|
||||
|
||||
// `confirm` و `cancel` هرکدام رویداد خودشان را ثبت کردهاند؛ این سومی میگوید آن دو
|
||||
// یک جابهجایی بودهاند نه یک لغو و یک رزروِ بیربط. مصرفکنندهای که فقط
|
||||
// `AppointmentCancelled` را بشنود، برای بیماری که هنوز نوبت دارد پیام لغو میفرستد.
|
||||
$this->domainEvents->recordAndFlush(
|
||||
$appointment->getEntityType(),
|
||||
$appointment->getEntityId(),
|
||||
DomainEvents::APPOINTMENT_RESCHEDULED,
|
||||
[
|
||||
'appointment_uuid' => $appointment->getUuid(),
|
||||
'previous_start' => $previousStart,
|
||||
'new_start' => $hold->getStartsAt(),
|
||||
],
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'appointment_uuid' => $appointment->getUuid(),
|
||||
@@ -305,8 +258,6 @@ class BookingController extends BaseController
|
||||
$address,
|
||||
$hold->getStartsAt(),
|
||||
is_array($data['policy'] ?? null) ? $data['policy'] : [],
|
||||
// پروندهٔ بیمار در همین محیط — پکیج کلینیک الف در کلینیک ب معنا ندارد.
|
||||
$this->packages->patientRecordFor($appointment),
|
||||
);
|
||||
|
||||
return $this->snapshots->record($appointment, $quote);
|
||||
|
||||
@@ -6,12 +6,7 @@ use App\Appointment\Availability\Entity\ResourceOccupancy;
|
||||
use App\Appointment\Booking\Entity\AppointmentHold;
|
||||
use App\Appointment\Booking\Entity\AppointmentSegment;
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Package\Service\CreditLedgerService;
|
||||
use App\Course\Service\CourseSessionLinker;
|
||||
use App\Package\Service\PackageConsumptionService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
@@ -26,10 +21,6 @@ final class BookingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HoldService $holds,
|
||||
private readonly PackageConsumptionService $packages,
|
||||
private readonly CreditLedgerService $credits,
|
||||
private readonly CourseSessionLinker $courseSessions,
|
||||
private readonly DomainEventPublisher $events,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
@@ -61,21 +52,9 @@ final class BookingService
|
||||
$this->writeSegments($hold, $appointment);
|
||||
$hold->markConfirmed($now);
|
||||
|
||||
// رویداد در همان flushِ ثبت نوبت میرود؛ اگر این تراکنش برگردد، رویدادی هم
|
||||
// نمیماند که کسی به آن واکنش نشان دهد.
|
||||
$this->events->record(
|
||||
$appointment->getEntityType(),
|
||||
$appointment->getEntityId(),
|
||||
DomainEvents::APPOINTMENT_BOOKED,
|
||||
['appointment_uuid' => $appointment->getUuid(), 'hold_uuid' => $hold->getUuid()],
|
||||
$now,
|
||||
);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
// مصرف اعتبار **اینجا**ست نه در پیشنمایش قیمت: تنها لحظهای که نوبت واقعاً
|
||||
// وجود دارد. کلید یکتای دفتر هم تضمین میکند اجرای دوباره جلسهٔ دوم نخورد.
|
||||
$this->packages->consumeFor($appointment);
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
@@ -119,18 +98,9 @@ final class BookingService
|
||||
|
||||
$this->holds->release($occupancies);
|
||||
|
||||
// ردیف `consume` **حذف نمیشود**؛ بازگشت یک ردیف تازه است تا تاریخچه بماند.
|
||||
$this->credits->refund($appointment);
|
||||
|
||||
// جلسهٔ دوره به `planned` برمیگردد؛ بقیهٔ جلسات دستنخورده میمانند.
|
||||
$this->courseSessions->unlink($appointment);
|
||||
|
||||
$this->events->recordAndFlush(
|
||||
$appointment->getEntityType(),
|
||||
$appointment->getEntityId(),
|
||||
DomainEvents::APPOINTMENT_CANCELLED,
|
||||
['appointment_uuid' => $appointment->getUuid(), 'released_resources' => count($occupancies)],
|
||||
);
|
||||
$this->em->flush();
|
||||
|
||||
return count($occupancies);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ use App\Appointment\Plan\ValueObject\AppointmentPlan;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -36,7 +34,6 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
final class HoldService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DomainEventPublisher $events,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
@@ -98,15 +95,6 @@ final class HoldService
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// بعد از اینکه **همهٔ** منابع گرفته شدند، نه پیش از آن: رزروی که وسط کار
|
||||
// شکسته، رویدادی هم ندارد.
|
||||
$this->events->recordAndFlush(
|
||||
$entityType,
|
||||
$entityId,
|
||||
DomainEvents::HOLD_CREATED,
|
||||
['hold_uuid' => $hold->getUuid(), 'starts_at' => $startsAt, 'resources' => count($taken)],
|
||||
$now,
|
||||
);
|
||||
|
||||
return $hold;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ 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\Shared\Event\DomainEventPublisher $domainEvents,
|
||||
private readonly \Psr\Log\LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
@@ -73,27 +72,6 @@ class AppointmentController extends BaseController
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* ثبت رویداد دامنهٔ «نوبت انجام شد».
|
||||
*
|
||||
* جدا از `AppointmentEvent` است و جایگزینش نمیشود: آن، تایملاینِ خواندهشده توسط
|
||||
* اپراتور است و این، صندوق خروجی برای مصرفکنندههای بیرونی. هر دو مسیرِ تغییر
|
||||
* وضعیت (اندپوینت اختصاصی و `PATCH`) بعد از ذخیرهٔ موفق به اینجا میرسند، چون
|
||||
* رویدادِ کاری که هنوز ذخیره نشده، دروغ است.
|
||||
*/
|
||||
private function recordCompletion(Appointment $appointment): void
|
||||
{
|
||||
$this->domainEvents->recordAndFlush(
|
||||
$appointment->getEntityType(),
|
||||
$appointment->getEntityId(),
|
||||
\App\Shared\Event\DomainEvents::APPOINTMENT_COMPLETED,
|
||||
[
|
||||
'appointment_uuid' => $appointment->getUuid(),
|
||||
'slot_start' => $appointment->getSlotStart(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Public: available slots ───────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
@@ -978,7 +956,6 @@ class AppointmentController extends BaseController
|
||||
}
|
||||
|
||||
if ($newStatus === Appointment::STATUS_COMPLETED) {
|
||||
$this->recordCompletion($appointment);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()]);
|
||||
@@ -1254,7 +1231,6 @@ class AppointmentController extends BaseController
|
||||
}
|
||||
|
||||
if ($completed) {
|
||||
$this->recordCompletion($appointment);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()]);
|
||||
|
||||
@@ -225,13 +225,6 @@ class Appointment
|
||||
#[ORM\Column(name: 'service_buffer_minutes', type: 'smallint', nullable: true)]
|
||||
private ?int $serviceBufferMinutes = null;
|
||||
|
||||
/**
|
||||
* پیوند به جلسهٔ دوره — عمداً دوطرفه است تا لیست نوبتها بدون JOIN بفهمد این نوبت
|
||||
* جزو یک دوره است. فقط `CourseSessionLinker` مینویسدش.
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: \App\Course\Entity\CourseSession::class)]
|
||||
#[ORM\JoinColumn(name: 'course_session_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?\App\Course\Entity\CourseSession $courseSession = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
@@ -295,8 +288,6 @@ class Appointment
|
||||
public function setClinic(?\App\Clinic\Entity\Clinic $v): self { $this->clinic = $v; return $this; }
|
||||
public function setPatientName(?string $v): self { $this->patientName = $v; return $this; }
|
||||
|
||||
public function getCourseSession(): ?\App\Course\Entity\CourseSession { return $this->courseSession; }
|
||||
public function setCourseSession(?\App\Course\Entity\CourseSession $v): self { $this->courseSession = $v; return $this; }
|
||||
public function setPatientMobile(?string $v): self { $this->patientMobile = $v; return $this; }
|
||||
public function setPatientNationalCode(?string $v): self { $this->patientNationalCode = $v; return $this; }
|
||||
public function setPatientGender(?string $v): self { $this->patientGender = $v; return $this; }
|
||||
|
||||
@@ -16,10 +16,6 @@ use App\Resource\Entity\ResourceType;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Resource\Repository\ResourceTypeRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
use App\Policy\Engine\ResourcePolicyEngine;
|
||||
use App\Policy\Engine\TimingPolicyEngine;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
@@ -31,8 +27,6 @@ use App\Shared\Exception\AppException;
|
||||
final class AppointmentPlanBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimingPolicyEngine $timingPolicies,
|
||||
private readonly ResourcePolicyEngine $resourcePolicies,
|
||||
private readonly SegmentTemplateRepository $templates,
|
||||
private readonly ClinicResourceRepository $resources,
|
||||
private readonly ResourceTypeRepository $types,
|
||||
@@ -111,14 +105,6 @@ final class AppointmentPlanBuilder
|
||||
array $segments,
|
||||
int $total,
|
||||
): AppointmentPlan {
|
||||
// ── قوانین دستهٔ «زمان» ────────────────────────────────────────────
|
||||
// اثرها روی **مجموع** نوبت اعمال میشوند نه روی یک بخش: «حداقل ۶۰ دقیقه»
|
||||
// یعنی کل جلسه، و کوتاه کردنِ یک بخش برای رسیدن به آن معنا ندارد.
|
||||
$total = $this->applyTimingPolicies($service, $selectedItems, $address, $segments, $total);
|
||||
|
||||
// ── قوانین دستهٔ «منبع» ─────────────────────────────────────────────
|
||||
$segments = $this->applyResourcePolicies($service, $selectedItems, $address, $segments);
|
||||
|
||||
if ($total > SegmentTemplate::MAX_TOTAL_MINUTES) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
@@ -131,61 +117,6 @@ final class AppointmentPlanBuilder
|
||||
return new AppointmentPlan($segments, $total);
|
||||
}
|
||||
|
||||
/**
|
||||
* قوانین «زمان»: حداقل مدت (بیشترین برنده) و افزودن مدت (جمع).
|
||||
*
|
||||
* @param ServiceItem[] $selectedItems
|
||||
* @param list<PlannedSegment> $segments بهصورت ارجاع تغییر میکند
|
||||
*/
|
||||
private function applyTimingPolicies(
|
||||
ServiceItem $service,
|
||||
array $selectedItems,
|
||||
DoctorAddress $address,
|
||||
array &$segments,
|
||||
int $total,
|
||||
): int {
|
||||
$outcome = $this->timingPolicies->evaluate(
|
||||
$address->tenantEntityType(),
|
||||
$address->tenantEntityId(),
|
||||
[
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'item_count' => count($selectedItems),
|
||||
'catalog_category' => $service->getCatalogCategory()?->getUuid(),
|
||||
],
|
||||
$address,
|
||||
$service,
|
||||
);
|
||||
|
||||
if ($outcome->effects === []) {
|
||||
return $total;
|
||||
}
|
||||
|
||||
$extra = (int) $outcome->effect(PolicySchema::EFFECT_ADD_DURATION, 0);
|
||||
$minimum = (int) $outcome->effect(PolicySchema::EFFECT_MIN_DURATION, 0);
|
||||
$target = max($total + $extra, $minimum);
|
||||
|
||||
if ($target === $total || $segments === []) {
|
||||
return $total;
|
||||
}
|
||||
|
||||
// مدت اضافه به **آخرین** بخش میرود: آفست بخشهای قبلی نباید عوض شود، وگرنه
|
||||
// برنامهای که کاربر تأیید کرده زیر پایش جابهجا میشود.
|
||||
$last = $segments[count($segments) - 1];
|
||||
$grown = $last->durationMinutes + ($target - $total);
|
||||
|
||||
$segments[count($segments) - 1] = new PlannedSegment(
|
||||
sequence: $last->sequence,
|
||||
name: $last->name,
|
||||
offsetMinutes: $last->offsetMinutes,
|
||||
durationMinutes: $grown,
|
||||
patientPresent: $last->patientPresent,
|
||||
mergeable: $last->mergeable,
|
||||
requirements: $last->requirements,
|
||||
);
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* الگوهای سرویس اصلی **بهعلاوهٔ** الگوهای آیتمهای انتخابشده.
|
||||
*
|
||||
@@ -291,158 +222,10 @@ final class AppointmentPlanBuilder
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* قوانین «منبع»: نقشی که قانون لازم میداند، اگر الگو نداشته باشد، اضافه میشود.
|
||||
*
|
||||
* نقشِ اضافهشده به **اولین بخشی که بیمار حاضر است** میچسبد، نه به همهٔ بخشها:
|
||||
* «سرپرست لازم است» یعنی سرپرست در جلسه حضور داشته باشد، نه اینکه تمام مدتِ
|
||||
* آمادهسازی هم اشغال شود.
|
||||
*
|
||||
* ممنوعیت هم اینجا خوانده میشود: قانونی که میگوید این ترکیب در این شعبه انجام
|
||||
* نمیشود، پیش از رسیدن به موتور دسترسپذیری جلوی کار را میگیرد.
|
||||
*
|
||||
* @param ServiceItem[] $selectedItems
|
||||
* @param list<PlannedSegment> $segments
|
||||
* @return list<PlannedSegment>
|
||||
*/
|
||||
private function applyResourcePolicies(
|
||||
ServiceItem $service,
|
||||
array $selectedItems,
|
||||
DoctorAddress $address,
|
||||
array $segments,
|
||||
): array {
|
||||
if ($segments === []) {
|
||||
return $segments;
|
||||
}
|
||||
|
||||
$outcome = $this->resourcePolicies->evaluate(
|
||||
$address->tenantEntityType(),
|
||||
$address->tenantEntityId(),
|
||||
[
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'catalog_category' => $service->getCatalogCategory()?->getUuid(),
|
||||
'item_count' => count($selectedItems),
|
||||
],
|
||||
$address,
|
||||
$service,
|
||||
);
|
||||
|
||||
if ($outcome->isForbidden()) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
implode(' ', $outcome->forbidReasons),
|
||||
422,
|
||||
'service_uuid',
|
||||
);
|
||||
}
|
||||
|
||||
$required = (array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_RESOURCE, []);
|
||||
|
||||
if ($required === []) {
|
||||
return $segments;
|
||||
}
|
||||
|
||||
$present = [];
|
||||
foreach ($segments as $segment) {
|
||||
foreach ($segment->requirements as $requirement) {
|
||||
$present[$requirement->role] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$targetIndex = $this->firstPatientPresentIndex($segments);
|
||||
$extra = [];
|
||||
|
||||
foreach ($required as $code) {
|
||||
if (!is_string($code) || isset($present[$code])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extra[] = $this->requirementForRole($code, $address, $outcome->appliedPolicies);
|
||||
}
|
||||
|
||||
if ($extra === []) {
|
||||
return $segments;
|
||||
}
|
||||
|
||||
$target = $segments[$targetIndex];
|
||||
|
||||
$segments[$targetIndex] = new PlannedSegment(
|
||||
sequence: $target->sequence,
|
||||
name: $target->name,
|
||||
offsetMinutes: $target->offsetMinutes,
|
||||
durationMinutes: $target->durationMinutes,
|
||||
patientPresent: $target->patientPresent,
|
||||
mergeable: $target->mergeable,
|
||||
requirements: [...$target->requirements, ...$extra],
|
||||
);
|
||||
|
||||
return array_values($segments);
|
||||
}
|
||||
|
||||
/** @param list<PlannedSegment> $segments */
|
||||
private function firstPatientPresentIndex(array $segments): int
|
||||
{
|
||||
foreach ($segments as $index => $segment) {
|
||||
if ($segment->patientPresent) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* قانونی که نقشِ ناشناخته یا بیمنبع میخواهد **خطاست، نه بیاثر**: در سکوت رد
|
||||
* کردنش یعنی کلینیک فکر کند قانونش اجرا میشود در حالی که هیچوقت نشده.
|
||||
*/
|
||||
/**
|
||||
* @param list<array<string, mixed>> $appliedPolicies برای اینکه پیام بگوید **کدام** قانون
|
||||
*/
|
||||
private function requirementForRole(string $code, DoctorAddress $address, array $appliedPolicies = []): PlannedRequirement
|
||||
{
|
||||
// بدون نام قانون، اپراتور میداند چه چیزی کم است ولی نه چرا لازم شده — و بین ده
|
||||
// قانون فعال باید حدس بزند کدام را خاموش کند.
|
||||
$names = array_values(array_filter(array_map(
|
||||
static fn (array $p): ?string => is_string($p['name'] ?? null) ? $p['name'] : null,
|
||||
$appliedPolicies,
|
||||
)));
|
||||
$because = $names === [] ? '' : sprintf(' (قانون: %s)', implode('، ', $names));
|
||||
|
||||
$type = $this->types->findByCode($address->tenantEntityType(), $address->tenantEntityId(), $code);
|
||||
|
||||
if ($type === null) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('قانون منبعی نقش «%s» را لازم دارد که در این محیط تعریف نشده است%s', $code, $because),
|
||||
422,
|
||||
'requirements',
|
||||
);
|
||||
}
|
||||
|
||||
$eligible = array_values($this->resources->findEligible($address, $type, []));
|
||||
|
||||
if ($eligible === []) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_NO_ELIGIBLE_RESOURCE,
|
||||
sprintf('هیچ %s در شعبهٔ «%s» موجود نیست%s', $type->getName(), $address->getName() ?? '—', $because),
|
||||
422,
|
||||
'requirements',
|
||||
);
|
||||
}
|
||||
|
||||
return new PlannedRequirement(
|
||||
role: $type->getCode(),
|
||||
roleName: $type->getName(),
|
||||
count: 1,
|
||||
occupancy: SegmentRequirement::OCCUPANCY_EXCLUSIVE,
|
||||
constraints: [],
|
||||
eligible: $eligible,
|
||||
skillName: null,
|
||||
setupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getSetupMinutes()),
|
||||
cleanupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getCleanupMinutes()),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return list<PlannedRequirement> */
|
||||
private function planRequirements(
|
||||
SegmentTemplate $template,
|
||||
|
||||
Reference in New Issue
Block a user