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:
hamed
2026-08-01 20:50:47 +03:30
parent 9486721fa3
commit c4f1f25c80
27 changed files with 129 additions and 1510 deletions
@@ -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()]);
-9
View File
@@ -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,
@@ -9,8 +9,6 @@ use App\ClinicService\Entity\ServiceItemRelation;
use App\ClinicService\Repository\ItemGroupMemberRepository;
use App\ClinicService\Repository\ServiceItemRelationRepository;
use App\Doctor\Entity\DoctorAddress;
use App\Policy\Entity\Policy;
use App\Policy\Engine\SelectionPolicyEngine;
use App\ClinicService\Repository\ServiceBranchOverrideRepository;
/**
@@ -30,7 +28,6 @@ final class ServiceSelectionValidator
private readonly ServiceItemRelationRepository $relations,
private readonly ServiceBranchOverrideRepository $overrides,
private readonly DurationCalculator $durations,
private readonly SelectionPolicyEngine $selectionPolicies,
) {}
/**
@@ -43,7 +40,6 @@ final class ServiceSelectionValidator
$errors = [
...$this->groupErrors($selected, $groups),
...$this->relationErrors($selected),
...$this->policyErrors($selected, $address),
];
$overrides = $address === null
@@ -62,52 +58,6 @@ final class ServiceSelectionValidator
];
}
/**
* ممنوعیت‌های دستهٔ «انتخاب» — لایه‌ای روی گروه و رابطه، نه جایگزینشان.
*
* گروه و رابطه ساختار ثابتِ کاتالوگ‌اند؛ قانون چیزی است که کلینیک بدون دست زدن
* به کاتالوگ روشن و خاموش می‌کند. بدون شعبه اجرا نمی‌شود چون محیط از آدرس
* می‌آید و بی‌آن هیچ محیطی برای جست‌وجو نیست.
*
* @param ServiceItem[] $selected
* @return list<array<string, mixed>>
*/
private function policyErrors(array $selected, ?DoctorAddress $address): array
{
if ($address === null || $selected === []) {
return [];
}
$facts = [
'item_count' => count($selected),
'item_uuids' => array_map(static fn (ServiceItem $i): string => $i->getUuid(), $selected),
];
$errors = [];
// هر آیتم جداگانه حل می‌شود: قانونی که دامنه‌اش یک سرویس خاص است فقط وقتی
// معنا دارد که همان سرویس در انتخاب باشد، و پیام خطا باید بگوید کدام.
foreach ($selected as $item) {
$outcome = $this->selectionPolicies->evaluate(
$address->tenantEntityType(),
$address->tenantEntityId(),
$facts + ['catalog_category' => $item->getCatalogCategory()?->getUuid()],
$address,
$item,
);
foreach ($outcome->forbidReasons as $reason) {
$errors[] = [
'code' => 'policy_forbidden',
'items' => [$item->getUuid()],
'message' => $reason,
];
}
}
return $errors;
}
/**
* @param ServiceItem[] $selected
* @param ItemGroup[] $groups
+1 -22
View File
@@ -36,7 +36,6 @@ class PricingController extends BaseController
private readonly PriceSnapshotRepository $snapshots,
private readonly ServiceItemRepository $items,
private readonly PricingEngine $engine,
private readonly PatientRecordRepository $patients,
private readonly BranchResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
@@ -212,27 +211,7 @@ class PricingController extends BaseController
$at = is_numeric($data['at'] ?? null) ? (int) $data['at'] : time();
$policy = is_array($data['policy'] ?? null) ? $data['policy'] : [];
// بیمار اختیاری است: بدون او پکیج معنا ندارد و قیمت همان قیمت کامل است.
$patient = is_string($data['patient_uuid'] ?? null)
? $this->requirePatient($user, $data['patient_uuid'])
: null;
return $this->success($this->engine->quote($service, $items, $address, $at, $policy, $patient)->toArray());
}
private function requirePatient(User $user, string $uuid): \App\Patient\Entity\PatientRecord
{
$patient = $this->patients->findOneBy(['uuid' => $uuid]);
[$entityType, $entityId] = $this->branches->pair($user);
if ($patient === null
|| $patient->getEntityType() !== $entityType
|| $patient->getEntityId() !== $entityId
) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمار یافت نشد', 404);
}
return $patient;
return $this->success($this->engine->quote($service, $items, $address, $at, $policy)->toArray());
}
/**
-82
View File
@@ -6,14 +6,10 @@ use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceBranchOverrideRepository;
use App\ClinicService\Repository\TariffRepository;
use App\Doctor\Entity\DoctorAddress;
use App\Package\Service\PackageConsumptionService;
use App\Patient\Entity\PatientRecord;
use App\Pricing\Repository\PriceListItemRepository;
use App\Pricing\Repository\PriceListRepository;
use App\Pricing\ValueObject\PriceQuote;
use App\Policy\Entity\Policy;
use App\Policy\Engine\PricingPolicyEngine;
use App\Policy\Service\PolicySchema;
use App\Representation\Service\JalaliDateService;
/**
@@ -38,8 +34,6 @@ use App\Representation\Service\JalaliDateService;
final class PricingEngine
{
public function __construct(
private readonly PricingPolicyEngine $pricingPolicies,
private readonly PackageConsumptionService $packages,
private readonly PriceListRepository $priceLists,
private readonly PriceListItemRepository $priceListItems,
private readonly ServiceBranchOverrideRepository $overrides,
@@ -62,7 +56,6 @@ final class PricingEngine
DoctorAddress $address,
int $at,
array $policy = [],
?PatientRecord $patient = null,
): PriceQuote {
$entityType = $address->tenantEntityType();
$entityId = $address->tenantEntityId();
@@ -80,22 +73,6 @@ final class PricingEngine
$subtotal = $base + $itemsTotal;
// ── پکیج ──────────────────────────────────────────────────────────────
// پکیج **قیمت پایهٔ سرویس** را می‌پوشاند، نه آیتم‌های اضافه: «شش جلسه لیزر»
// یعنی شش بار خودِ لیزر، نه هر چیزی که کنارش انتخاب شود.
$usable = $patient === null ? null : $this->packages->firstUsable($patient, $service, $at);
$covered = 0;
if ($usable !== null) {
$covered = min($base, $subtotal);
$subtotal -= $covered;
}
// ── تخفیف ─────────────────────────────────────────────────────────────
// قوانین دستهٔ «قیمت» کنار سیاست دستیِ درخواست می‌نشینند، نه به‌جایش: تخفیفی
// که اپراتور دستی می‌دهد و تخفیفی که قانون می‌دهد هر دو واقعی‌اند.
$policy = $this->mergePolicyDiscounts($service, $items, $address, $at, $subtotal, $policy, $sources);
[$discount, $discounts] = $this->discountFor($subtotal, $policy);
// تخفیف بیشتر از مبلغ، مبلغ را **صفر** می‌کند نه منفی: بدهی منفی یعنی کلینیک
@@ -125,13 +102,6 @@ final class PricingEngine
$deposit = max(0, min($deposit, $final));
if ($covered > 0) {
$discounts[] = [
'label' => sprintf('پوشش پکیج «%s»', $usable?->getPackage()->getName() ?? '—'),
'rials' => $covered,
'kind' => 'package',
];
}
return new PriceQuote(
baseRials: $base,
@@ -144,61 +114,9 @@ final class PricingEngine
depositRials: $deposit,
discounts: $discounts,
sources: $sources,
packageWillBeConsumed: $usable !== null,
packageUuid: $usable?->getUuid(),
);
}
/**
* اثر قوانین «قیمت» را به سیاست درخواست اضافه می‌کند.
*
* شناسه و **نسخهٔ** هر قانون در `sources` ثبت می‌شود تا فاکتور بتواند سه ماه بعد
* بگوید کدام نسخه رویش اعمال شده بود.
*
* @param ServiceItem[] $items
* @param array<string, mixed> $policy
* @param array<string, mixed> $sources
* @return array<string, mixed>
*/
private function mergePolicyDiscounts(
ServiceItem $service,
array $items,
DoctorAddress $address,
int $at,
int $subtotal,
array $policy,
array &$sources,
): array {
$outcome = $this->pricingPolicies->evaluate(
$address->tenantEntityType(),
$address->tenantEntityId(),
[
'item_count' => count($items),
'subtotal_rials' => $subtotal,
'patient_tags' => $policy['patient_tags'] ?? [],
'visit_count' => $policy['visit_count'] ?? 0,
],
$address,
$service,
$at,
);
if ($outcome->appliedPolicies === []) {
return $policy;
}
$sources['applied_policies'] = $outcome->appliedPolicies;
$policy['discount_percent'] = (float) ($policy['discount_percent'] ?? 0)
+ (float) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_PERCENT, 0);
$policy['discount_rials'] = (int) ($policy['discount_rials'] ?? 0)
+ (int) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_RIALS, 0);
$policy['discount_label'] ??= $outcome->appliedPolicies[0]['name'];
return $policy;
}
/**
* @param array<string, string> $sources
-8
View File
@@ -22,12 +22,6 @@ final readonly class PriceQuote
public int $depositRials,
public array $discounts = [],
public array $sources = [],
/**
* پکیج در پیش‌نمایش **مصرف نمی‌شود** — فقط اعلام می‌شود. مصرف واقعی هنگام
* ثبت نهایی است، وگرنه هر رفرش صفحه یک جلسه از بیمار می‌گرفت.
*/
public bool $packageWillBeConsumed = false,
public ?string $packageUuid = null,
) {}
public function breakdown(): array
@@ -46,8 +40,6 @@ final readonly class PriceQuote
'tax_rials' => $this->taxRials,
'final_rials' => $this->finalRials,
'deposit_rials' => $this->depositRials,
'package_will_be_consumed' => $this->packageWillBeConsumed,
'package_uuid' => $this->packageUuid,
'breakdown' => $this->breakdown(),
];
}
-12
View File
@@ -4,9 +4,7 @@ namespace App;
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;
@@ -34,16 +32,6 @@ class Schedule implements ScheduleProviderInterface
)
->add(
RecurringMessage::every('1 minute', new PublishScheduledBlogsMessage())
)
// صندوق خروجی رویدادها. `stateful` بالا یعنی تیکِ ازدست‌رفته بعد از ری‌استارت
// جبران می‌شود، و چون خودِ publisher از جدول می‌خواند، یک اجرا کافی است تا
// هرچه در فاصله جمع شده برود.
->add(
RecurringMessage::every('1 minute', new PublishDomainEventsMessage())
)
// انتظارهای مرده — پاکسازیِ نمایش، نه اصلاح رفتار (تطبیق از قبل ردشان می‌کرد).
->add(
RecurringMessage::every('1 day', new ExpireWaitlistMessage())
);
}
}
-184
View File
@@ -10,25 +10,14 @@ use App\Appointment\Plan\Entity\SegmentRequirement;
use App\Appointment\Plan\Entity\SegmentTemplate;
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
use App\Auth\Entity\User;
use App\Cancellation\Entity\CancellationPolicy;
use App\Cancellation\Entity\NoShowRecord;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Entity\ItemGroup;
use App\ClinicService\Entity\ItemGroupMember;
use App\ClinicService\Entity\ServiceBranchOverride;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceItemRelation;
use App\Course\Entity\CourseProtocol;
use App\Course\Entity\CourseProtocolStep;
use App\Course\Entity\TreatmentCourse;
use App\Doctor\Entity\DoctorAddress;
use App\Package\Entity\Package;
use App\Package\Entity\PackageService;
use App\Package\Entity\PatientPackage;
use App\Package\Entity\SessionCreditLedger;
use App\Package\Service\CreditLedgerService;
use App\Patient\Entity\PatientRecord;
use App\Policy\Entity\Policy;
use App\Pricing\Entity\PriceList;
use App\Pricing\Entity\PriceListItem;
use App\Pricing\Service\PriceSnapshotService;
@@ -40,7 +29,6 @@ use App\Resource\Entity\ResourcePoolMember;
use App\Resource\Entity\ResourceSkill;
use App\Resource\Entity\ResourceType;
use App\Resource\Entity\Skill;
use App\Waitlist\Entity\WaitlistEntry;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -64,7 +52,6 @@ final class BookingEngineSeeder
private readonly HoldService $holds,
private readonly BookingService $booking,
private readonly PriceSnapshotService $snapshots,
private readonly CreditLedgerService $credits,
) {}
private function em(): \Doctrine\ORM\EntityManagerInterface
@@ -101,10 +88,7 @@ final class BookingEngineSeeder
$counts['resources'] = $this->skillsPoolsAndExceptions($entityType, $entityId, $address, $devices, $deviceType);
$counts['segments'] = $this->multiSegmentPlan($flagship, $deviceType, $entityType, $entityId, $address);
$counts['pricing'] = $this->priceList($entityType, $entityId, $services);
$counts['policies'] = $this->policies($entityType, $entityId, $services);
$counts['booked'] = $this->realBookings($flagship, $address, $patients, $doctor, $clinic, $entityType, $entityId);
$counts['packages'] = $this->packagesAndCourses($entityType, $entityId, $services, $patients);
$counts['aftercare'] = $this->cancellationAndWaitlist($entityType, $entityId, $services, $patients, $doctor);
return $counts;
}
@@ -258,50 +242,6 @@ final class BookingEngineSeeder
// ── تسک ۰۹: سیاست‌ها، یکی از هر دسته ────────────────────────────────────
private function policies(string $entityType, int $entityId, array $services): int
{
$service = $services[0];
$rows = [
[Policy::CATEGORY_SELECTION, 'حداکثر سه ناحیه در یک نوبت',
[['field' => 'item_count', 'operator' => 'greater_than', 'value' => 3]],
[['type' => 'forbid']]],
[Policy::CATEGORY_ELIGIBILITY, 'زیر ۱۸ سال بدون رضایت والدین ممنوع',
[['field' => 'patient_age', 'operator' => 'less_than', 'value' => 18]],
[['type' => 'require_flag', 'value' => 'parental_consent']]],
[Policy::CATEGORY_RESOURCE, 'لیزر بدنِ کامل اپراتور ارشد می‌خواهد',
[['field' => 'service_uuid', 'operator' => 'equals', 'value' => $service->getUuid()]],
[['type' => 'require_resource', 'value' => 'laser']]],
[Policy::CATEGORY_TIMING, 'سبد بزرگ ۱۵ دقیقه وقت بیشتر می‌گیرد',
[['field' => 'item_count', 'operator' => 'greater_or_equal', 'value' => 3]],
[['type' => 'add_duration_minutes', 'value' => 15]]],
[Policy::CATEGORY_SPACING, 'حداقل ۲۸ روز فاصله بین دو جلسهٔ لیزر',
[['field' => 'service_uuid', 'operator' => 'equals', 'value' => $service->getUuid()]],
[['type' => 'min_days_between', 'value' => 28]]],
[Policy::CATEGORY_PRICING, 'تخفیف ۱۰٪ برای سبد بالای ۲۰ میلیون ریال',
[['field' => 'subtotal_rials', 'operator' => 'greater_than', 'value' => 20_000_000]],
[['type' => 'discount_percent', 'value' => 10]]],
];
foreach ($rows as $i => [$category, $name, $conditions, $effects]) {
$policy = new Policy($entityType, $entityId, $category, $name);
$policy->setCondition(['match' => 'all', 'conditions' => $conditions])
->setEffects($effects)
->setPriority(10 * ($i + 1))
->setActive(true);
$this->em()->persist($policy);
}
$this->em()->flush();
return count($rows);
}
// ── تسک ۰۶ و ۰۷: رزرو واقعی روی تقویم منابع ─────────────────────────────
/**
@@ -374,130 +314,6 @@ final class BookingEngineSeeder
// ── تسک ۱۱ و ۱۲: پکیج، دفتر اعتبار، دورهٔ درمان ─────────────────────────
private function packagesAndCourses(string $entityType, int $entityId, array $services, array $patients): int
{
$service = $services[0];
$package = new Package($entityType, $entityId, 'پکیج ۶ جلسه لیزر', 6);
$package->setPriceRials(60_000_000)->setValidityDays(365)->setActive(true);
$this->em()->persist($package);
$this->em()->persist(new PackageService($package, $service));
$this->em()->flush();
$records = $this->em()->getRepository(PatientRecord::class)
->findBy(['entityType' => $entityType, 'entityId' => $entityId], null, 3);
$made = 0;
foreach ($records as $i => $record) {
if ($i >= 2) {
break;
}
$patientPackage = new PatientPackage($package, $record);
$this->em()->persist($patientPackage);
$this->em()->flush();
// یک جلسه مصرف‌شده تا دفتر خالی نباشد؛ دفتر append-only است، پس مصرف هم
// یک ردیف است نه کم‌کردن یک عدد.
$this->credits->record($patientPackage, SessionCreditLedger::KIND_CONSUME, -1, null, $service, 'مصرف جلسهٔ اول');
$made++;
}
// دورهٔ درمان: پروتکل با پارامتر هر جلسه (انرژی لیزر بالا می‌رود) و یک دورهٔ فعال.
$protocol = new CourseProtocol($service, 6, 21, 28, 45);
$this->em()->persist($protocol);
foreach (range(1, 6) as $n) {
$this->em()->persist(new CourseProtocolStep($protocol, $n, ['energy' => 10 + $n * 2, 'spot_size' => 18]));
}
$this->em()->flush();
if ($records !== []) {
$course = new TreatmentCourse($records[0], $protocol);
$this->em()->persist($course);
// جلسه‌های دوره از روی گام‌های پروتکل ساخته می‌شوند. بدون این ردیف‌ها دوره
// «۶ جلسه‌ای» است ولی هیچ جلسه‌ای ندارد، و پیشنهاد جلسهٔ بعد می‌گوید همه‌چیز
// برنامه‌ریزی شده — یعنی دقیقاً برعکس واقعیت.
foreach ($protocol->getSteps() as $step) {
$this->em()->persist(new \App\Course\Entity\CourseSession(
$course,
$step->getSessionNumber(),
$step->getParams(),
));
}
$this->em()->flush();
$made++;
}
return $made;
}
// ── تسک ۱۳: لغو، عدم حضور، لیست انتظار ──────────────────────────────────
private function cancellationAndWaitlist(string $entityType, int $entityId, array $services, array $patients, \App\Doctor\Entity\Doctor $doctor): int
{
// سیاست عمومیِ محیط + یک سیاست سخت‌گیرانه‌تر روی سرویس گران — تا انتخابِ
// «اختصاصی‌ترین سیاست» چیزی برای انتخاب داشته باشد.
$general = new CancellationPolicy($entityType, $entityId);
$general->setFreeWindowHours(24)->setPenalty(CancellationPolicy::MODE_PERCENT, 30)
->setDepositRefundable(true)->setCreditRefundable(true)->setNoShowThreshold(3)->setActive(true);
$this->em()->persist($general);
$strict = new CancellationPolicy($entityType, $entityId, $services[0]);
$strict->setFreeWindowHours(48)->setPenalty(CancellationPolicy::MODE_PERCENT, 50)
->setDepositRefundable(false)->setCreditRefundable(false)->setNoShowThreshold(2)->setActive(true);
$this->em()->persist($strict);
$this->em()->flush();
$records = $this->em()->getRepository(PatientRecord::class)
->findBy(['entityType' => $entityType, 'entityId' => $entityId], null, 4);
// عدم حضورِ ثبت‌شده روی نوبت‌هایی که وضعیتشان no_show است — بدون این ردیف،
// شمارندهٔ عدم حضور بیمار همیشه صفر می‌ماند و آستانهٔ سیاست هرگز فعال نمی‌شود.
$noShows = $this->em()->getRepository(Appointment::class)
->findBy(['doctor' => $doctor, 'status' => Appointment::STATUS_NO_SHOW], null, 2);
foreach ($noShows as $appointment) {
$record = $this->recordFor($records, $appointment->getUser());
if ($record !== null) {
$this->em()->persist(new NoShowRecord($record, $appointment));
}
}
$made = 2 + count($noShows);
// لیست انتظار: دو نفر منتظرِ بازهٔ هفتهٔ آینده، یکی با ترجیح صبح.
foreach (array_slice($records, 0, 2) as $i => $record) {
$entry = new WaitlistEntry(
$record,
$services[min($i, count($services) - 1)],
strtotime('+2 days 00:00'),
strtotime('+9 days 00:00'),
$this->em()->getRepository(\App\Doctor\Entity\DoctorAddress::class)->findOneBy([])?->getId(),
);
$entry->setPriority(10 - $i);
if ($i === 0) {
$entry->setPreferredDayParts(['morning']);
}
$this->em()->persist($entry);
$made++;
}
$this->em()->flush();
return $made;
}
/** @param PatientRecord[] $records */
private function recordFor(array $records, User $user): ?PatientRecord
{
foreach ($records as $record) {
if ($record->getUser()->getId() === $user->getId()) {
return $record;
}
}
return $records[0] ?? null;
}
}
+1 -1
View File
@@ -176,7 +176,7 @@ class SeedScenariosCommand extends Command
foreach ($this->engineCounts as $scenario => $counts) {
$rows[] = array_merge(['سناریو ' . $scenario], array_values($counts));
}
$io->table(['سناریو', 'کاتالوگ', 'منابع', 'بخش‌ها', 'قیمت', 'سیاست', 'رزرو واقعی', 'پکیج/دوره', 'لغو/انتظار'], $rows);
$io->table(['سناریو', 'کاتالوگ', 'منابع', 'بخش‌ها', 'قیمت', 'رزرو واقعی'], $rows);
return Command::SUCCESS;
}
-5
View File
@@ -110,7 +110,6 @@ final class GlobalTables
\App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\ItemGroupMember::class => \App\ClinicService\Entity\ItemGroup::class,
\App\Pricing\Entity\PriceListItem::class => \App\Pricing\Entity\PriceList::class,
\App\Policy\Entity\PolicyVersionLog::class => \App\Policy\Entity\Policy::class,
\App\Billing\Entity\ClaimItem::class => \App\Billing\Entity\Claim::class,
\App\Billing\Entity\ClaimStatusLog::class => \App\Billing\Entity\Claim::class,
@@ -118,11 +117,7 @@ final class GlobalTables
\App\Inventory\Entity\InventoryPackageItem::class => \App\Inventory\Entity\InventoryPackage::class,
// سرویس‌های یک پکیج جزئی از تعریف همان پکیج‌اند، نه دادهٔ مستقل.
\App\Package\Entity\PackageService::class => \App\Package\Entity\Package::class,
// پارامترهای هر جلسه جزئی از تعریف همان پروتکل‌اند.
\App\Course\Entity\CourseProtocolStep::class => \App\Course\Entity\CourseProtocol::class,
\App\Insurance\Entity\TenantInsuranceCategoryCoverage::class => \App\Insurance\Entity\TenantInsurance::class,
\App\Insurance\Entity\TenantServiceCoverage::class => \App\Insurance\Entity\TenantInsurance::class,