diff --git a/src/Cancellation/Controller/CancellationController.php b/src/Cancellation/Controller/CancellationController.php deleted file mode 100644 index 23e315e5..00000000 --- a/src/Cancellation/Controller/CancellationController.php +++ /dev/null @@ -1,258 +0,0 @@ -branches->pair($user); - - return $this->success([ - 'default' => $this->policies->findDefault($entityType, $entityId)?->toArray(), - 'overrides' => array_values(array_map( - static fn (CancellationPolicy $p): array => $p->toArray(), - array_filter( - $this->policies->findForPair($entityType, $entityId), - static fn (CancellationPolicy $p): bool => $p->getServiceItem() !== null, - ), - )), - ]); - } - - /** سیاست پیش‌فرض محیط — ساخته می‌شود اگر نبود. */ - #[Route('/api/v1/cancellation-policy', name: 'cancellation_policy_save', methods: ['PUT'])] - public function save(#[CurrentUser] User $user, Request $request): JsonResponse - { - [$entityType, $entityId] = $this->branches->pair($user); - - $policy = $this->policies->findDefault($entityType, $entityId) - ?? new CancellationPolicy($entityType, $entityId); - - return $this->applyAndSave($policy, $request); - } - - #[Route('/api/v1/service-item/{uuid}/cancellation-policy', name: 'cancellation_policy_service', methods: ['PUT'])] - public function saveForService(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse - { - [$entityType, $entityId] = $this->branches->pair($user); - $service = $this->requireItem($user, $uuid); - - $policy = $this->policies->findForService($entityType, $entityId, $service) - ?? new CancellationPolicy($entityType, $entityId, $service); - - return $this->applyAndSave($policy, $request); - } - - /** - * جریمه و بازگشت **پیش از** لغو. - * - * همان محاسبه‌ای که خودِ لغو انجام می‌دهد؛ بیمار نباید عددی ببیند که با آنچه کسر - * می‌شود فرق دارد. - */ - #[Route('/api/v1/appointment/{uuid}/cancellation-preview', name: 'cancellation_preview', methods: ['GET'])] - public function preview(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse - { - $appointment = $this->requireAppointment($user, $uuid); - - $by = $request->query->get('by') === 'doctor' - ? Appointment::STATUS_CANCELLED_BY_DOCTOR - : Appointment::STATUS_CANCELLED_BY_USER; - - return $this->success( - $this->calculator->calculate($appointment, $by)->toArray() - + ['paid_rials' => $this->calculator->paidRials($appointment)], - ); - } - - #[Route('/api/v1/appointment/{uuid}/cancel', name: 'cancellation_cancel', methods: ['POST'])] - public function cancel(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse - { - $appointment = $this->requireAppointment($user, $uuid); - $data = json_decode($request->getContent(), true); - - $by = is_array($data) && ($data['by'] ?? null) === 'doctor' - ? Appointment::STATUS_CANCELLED_BY_DOCTOR - : Appointment::STATUS_CANCELLED_BY_USER; - - $reason = is_array($data) && is_string($data['reason'] ?? null) && trim($data['reason']) !== '' - ? trim($data['reason']) - : null; - - return $this->success($this->cancellation->cancel($appointment, $by, $user, null, $reason)); - } - - /** ثبت عدم حضور — برچسب پرریسک اگر آستانه رد شود. */ - #[Route('/api/v1/appointment/{uuid}/no-show', name: 'cancellation_no_show', methods: ['POST'])] - public function markNoShow(#[CurrentUser] User $user, string $uuid): JsonResponse - { - $appointment = $this->requireAppointment($user, $uuid); - [$entityType, $entityId] = $this->branches->pair($user); - - $patient = $this->patients->findOneBy([ - 'user' => $appointment->getUser(), - 'entityType' => $entityType, - 'entityId' => $entityId, - ]); - - if ($patient === null) { - return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پروندهٔ بیمار در این محیط یافت نشد', 404); - } - - if ($appointment->getStatus() !== Appointment::STATUS_NO_SHOW) { - $appointment->transitionTo(Appointment::STATUS_NO_SHOW); - $this->appointments->save($appointment); - } - - return $this->success($this->noShow->record($appointment, $patient, $user)); - } - - /** - * خلاصهٔ عدم حضور یک بیمار — برای نشان دادن در پروندهٔ او. - * - * `at_risk` فقط یک **نشانه** است. مسدودسازی کارِ قانون `eligibility` تسک ۰۹ است؛ - * کلینیکی که می‌خواهد بیمار پرریسک را ببیند ولی بیعانه بگیرد، نباید مجبور شود این - * شمارش را خاموش کند. - */ - #[Route('/api/v1/patient/{uuid}/no-shows', name: 'patient_no_show_summary', methods: ['GET'])] - public function noShowSummary(#[CurrentUser] User $user, string $uuid): JsonResponse - { - [$entityType, $entityId] = $this->branches->pair($user); - - $patient = $this->patients->findOneBy([ - 'uuid' => $uuid, - 'entityType' => $entityType, - 'entityId' => $entityId, - ]); - - if ($patient === null) { - return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پروندهٔ بیمار یافت نشد', 404); - } - - $count = $this->noShowRecords->countRecent($patient); - $threshold = $this->policies->resolve($entityType, $entityId, null)?->getNoShowThreshold() ?? 3; - - return $this->success([ - 'count' => $count, - 'threshold' => $threshold, - 'window_days' => NoShowRecordRepository::WINDOW_DAYS, - 'at_risk' => $count >= $threshold, - ]); - } - - private function applyAndSave(CancellationPolicy $policy, Request $request): JsonResponse - { - $data = json_decode($request->getContent(), true); - - if (!is_array($data)) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); - } - - if (is_numeric($data['free_window_hours'] ?? null)) { - $policy->setFreeWindowHours((int) $data['free_window_hours']); - } - - if (isset($data['penalty_mode'])) { - try { - $policy->setPenalty( - (string) $data['penalty_mode'], - is_numeric($data['penalty_value'] ?? null) ? (int) $data['penalty_value'] : 0, - ); - } catch (\InvalidArgumentException $e) { - return $this->error( - ErrorCodes::ERR_VALIDATION_001, - str_contains($e->getMessage(), 'percentage') - ? 'درصد جریمه باید بین ۰ تا ۱۰۰ باشد' - : 'حالت جریمه نامعتبر است', - 422, - 'penalty_mode', - ); - } - } - - foreach (['deposit_refundable' => 'setDepositRefundable', 'credit_refundable' => 'setCreditRefundable', 'active' => 'setActive'] as $field => $setter) { - if (isset($data[$field])) { - $policy->{$setter}((bool) $data[$field]); - } - } - - if (is_numeric($data['no_show_threshold'] ?? null)) { - $policy->setNoShowThreshold((int) $data['no_show_threshold']); - } - - if (array_key_exists('risk_tag_uuid', $data)) { - $policy->setRiskTagUuid(is_string($data['risk_tag_uuid']) ? $data['risk_tag_uuid'] : null); - } - - $this->policies->save($policy); - - return $this->success($policy->toArray()); - } - - private function requireItem(User $user, string $uuid): ServiceItem - { - $item = $this->items->findByUuid($uuid); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($item === null - || $item->getSection()->getEntityType() !== $entityType - || $item->getSection()->getEntityId() !== $entityId - ) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); - } - - return $item; - } - - private function requireAppointment(User $user, string $uuid): Appointment - { - $appointment = $this->appointments->findByUuid($uuid); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($appointment === null || !$this->ownership->belongsToPair($entityType, $entityId, $appointment)) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'نوبت یافت نشد', 404); - } - - return $appointment; - } -} diff --git a/src/Cancellation/Entity/CancellationPolicy.php b/src/Cancellation/Entity/CancellationPolicy.php deleted file mode 100644 index 6537902b..00000000 --- a/src/Cancellation/Entity/CancellationPolicy.php +++ /dev/null @@ -1,157 +0,0 @@ - 24])] - private int $freeWindowHours = 24; - - #[ORM\Column(name: 'penalty_mode', type: 'string', length: 10, options: ['default' => self::MODE_NONE])] - private string $penaltyMode = self::MODE_NONE; - - /** درصد ۰..۱۰۰ یا مبلغ ریالی، بسته به `penaltyMode`. */ - #[ORM\Column(name: 'penalty_value', type: 'integer', options: ['default' => 0])] - private int $penaltyValue = 0; - - #[ORM\Column(name: 'deposit_refundable', type: 'boolean', options: ['default' => false])] - private bool $depositRefundable = false; - - #[ORM\Column(name: 'credit_refundable', type: 'boolean', options: ['default' => true])] - private bool $creditRefundable = true; - - #[ORM\Column(name: 'no_show_threshold', type: 'smallint', options: ['default' => 3])] - private int $noShowThreshold = 3; - - /** بدون FK — همان الگوی `DiscountRule.target_tag_uuid` موجود پروژه. */ - #[ORM\Column(name: 'risk_tag_uuid', type: 'string', length: 36, nullable: true)] - private ?string $riskTagUuid = null; - - #[ORM\Column(type: 'boolean', options: ['default' => true])] - private bool $active = true; - - #[ORM\Column(name: 'created_at', type: 'integer')] - private int $createdAt; - - #[ORM\Column(name: 'updated_at', type: 'integer')] - private int $updatedAt; - - public function __construct(string $entityType, int $entityId, ?ServiceItem $serviceItem = null) - { - $this->uuid = Uuid::v4()->toRfc4122(); - $this->serviceItem = $serviceItem; - $this->createdAt = time(); - $this->updatedAt = time(); - - $this->assignTenantPair($entityType, $entityId); - } - - public function getId(): ?int { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getServiceItem(): ?ServiceItem { return $this->serviceItem; } - public function getFreeWindowHours(): int { return $this->freeWindowHours; } - public function getPenaltyMode(): string { return $this->penaltyMode; } - public function getPenaltyValue(): int { return $this->penaltyValue; } - public function isDepositRefundable(): bool { return $this->depositRefundable; } - public function isCreditRefundable(): bool { return $this->creditRefundable; } - public function getNoShowThreshold(): int { return $this->noShowThreshold; } - public function getRiskTagUuid(): ?string { return $this->riskTagUuid; } - public function isActive(): bool { return $this->active; } - - public function setFreeWindowHours(int $v): self { $this->freeWindowHours = max(0, $v); return $this->touch(); } - public function setDepositRefundable(bool $v): self { $this->depositRefundable = $v; return $this->touch(); } - public function setCreditRefundable(bool $v): self { $this->creditRefundable = $v; return $this->touch(); } - public function setRiskTagUuid(?string $v): self { $this->riskTagUuid = $v; return $this->touch(); } - public function setActive(bool $v): self { $this->active = $v; return $this->touch(); } - - public function setNoShowThreshold(int $v): self - { - // آستانهٔ صفر یعنی هر بیماری از همان نوبت اول پرریسک است. - $this->noShowThreshold = max(1, $v); - - return $this->touch(); - } - - /** @throws \InvalidArgumentException روی حالت ناشناخته یا درصد بیرون بازه */ - public function setPenalty(string $mode, int $value): self - { - if (!in_array($mode, self::MODES, true)) { - throw new \InvalidArgumentException(sprintf('Unknown penalty mode "%s".', $mode)); - } - - if ($mode === self::MODE_PERCENT && ($value < 0 || $value > 100)) { - throw new \InvalidArgumentException('A percentage penalty must be between 0 and 100.'); - } - - $this->penaltyMode = $mode; - $this->penaltyValue = $mode === self::MODE_NONE ? 0 : max(0, $value); - - return $this->touch(); - } - - private function touch(): self - { - $this->updatedAt = time(); - - return $this; - } - - /** @return array */ - public function toArray(): array - { - return [ - 'uuid' => $this->uuid, - 'service_uuid' => $this->serviceItem?->getUuid(), - 'service_name' => $this->serviceItem?->getName(), - 'free_window_hours' => $this->freeWindowHours, - 'penalty_mode' => $this->penaltyMode, - 'penalty_value' => $this->penaltyValue, - 'deposit_refundable' => $this->depositRefundable, - 'credit_refundable' => $this->creditRefundable, - 'no_show_threshold' => $this->noShowThreshold, - 'risk_tag_uuid' => $this->riskTagUuid, - 'active' => $this->active, - 'created_at' => $this->createdAt, - ]; - } -} diff --git a/src/Cancellation/Entity/NoShowRecord.php b/src/Cancellation/Entity/NoShowRecord.php deleted file mode 100644 index 801c4930..00000000 --- a/src/Cancellation/Entity/NoShowRecord.php +++ /dev/null @@ -1,80 +0,0 @@ -uuid = Uuid::v4()->toRfc4122(); - $this->patientRecord = $patientRecord; - $this->appointment = $appointment; - $this->recordedBy = $recordedBy; - $this->recordedAt = $at ?? time(); - - $this->assignTenantPair($appointment->getEntityType(), $appointment->getEntityId()); - } - - public function getId(): ?int { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getPatientRecord(): PatientRecord { return $this->patientRecord; } - public function getAppointment(): Appointment { return $this->appointment; } - public function getRecordedAt(): int { return $this->recordedAt; } - public function getRecordedBy(): ?User { return $this->recordedBy; } - - /** @return array */ - public function toArray(): array - { - return [ - 'uuid' => $this->uuid, - 'appointment_uuid' => $this->appointment->getUuid(), - 'slot_start' => $this->appointment->getSlotStart(), - 'recorded_at' => $this->recordedAt, - ]; - } -} diff --git a/src/Cancellation/Repository/CancellationPolicyRepository.php b/src/Cancellation/Repository/CancellationPolicyRepository.php deleted file mode 100644 index aefa7269..00000000 --- a/src/Cancellation/Repository/CancellationPolicyRepository.php +++ /dev/null @@ -1,76 +0,0 @@ - */ -class CancellationPolicyRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, CancellationPolicy::class); - } - - public function findByUuid(string $uuid): ?CancellationPolicy - { - return $this->findOneBy(['uuid' => $uuid]); - } - - /** سیاست پیش‌فرض محیط — `serviceItem` تهی. */ - public function findDefault(string $entityType, int $entityId): ?CancellationPolicy - { - return $this->findOneBy(['entityType' => $entityType, 'entityId' => $entityId, 'serviceItem' => null]); - } - - public function findForService(string $entityType, int $entityId, ServiceItem $service): ?CancellationPolicy - { - return $this->findOneBy(['entityType' => $entityType, 'entityId' => $entityId, 'serviceItem' => $service]); - } - - /** - * سیاست حاکم: override سرویس، وگرنه پیش‌فرض محیط. - * - * ترکیب نمی‌شوند — «۲۴ ساعت از محیط ولی ۵۰٪ از سرویس» چیزی است که هیچ اپراتوری - * نمی‌تواند در ذهنش شبیه‌سازی کند. - */ - public function resolve(string $entityType, int $entityId, ?ServiceItem $service): ?CancellationPolicy - { - if ($service !== null) { - $override = $this->findForService($entityType, $entityId, $service); - - if ($override !== null && $override->isActive()) { - return $override; - } - } - - $default = $this->findDefault($entityType, $entityId); - - return $default?->isActive() === true ? $default : null; - } - - /** @return CancellationPolicy[] */ - public function findForPair(string $entityType, int $entityId): array - { - return $this->createQueryBuilder('p') - ->where('p.entityType = :type') - ->andWhere('p.entityId = :id') - ->setParameter('type', $entityType) - ->setParameter('id', $entityId) - ->orderBy('p.serviceItem', 'ASC') - ->getQuery() - ->getResult(); - } - - public function save(CancellationPolicy $policy, bool $flush = true): void - { - $this->getEntityManager()->persist($policy); - - if ($flush) { - $this->getEntityManager()->flush(); - } - } -} diff --git a/src/Cancellation/Repository/NoShowRecordRepository.php b/src/Cancellation/Repository/NoShowRecordRepository.php deleted file mode 100644 index 73317879..00000000 --- a/src/Cancellation/Repository/NoShowRecordRepository.php +++ /dev/null @@ -1,52 +0,0 @@ - */ -class NoShowRecordRepository extends ServiceEntityRepository -{ - /** پنجرهٔ شمارش — عدم حضورِ سه سال پیش امروز معنایی ندارد. */ - public const WINDOW_DAYS = 365; - - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, NoShowRecord::class); - } - - public function findForAppointment(Appointment $appointment): ?NoShowRecord - { - return $this->findOneBy(['appointment' => $appointment]); - } - - public function countRecent(PatientRecord $patient, ?int $now = null): int - { - $since = ($now ?? time()) - self::WINDOW_DAYS * 86400; - - return (int) $this->createQueryBuilder('r') - ->select('COUNT(r.id)') - ->where('r.patientRecord = :patient') - ->andWhere('r.recordedAt >= :since') - ->setParameter('patient', $patient) - ->setParameter('since', $since) - ->getQuery() - ->getSingleScalarResult(); - } - - /** @return NoShowRecord[] */ - public function historyFor(PatientRecord $patient, int $limit = 20): array - { - return $this->createQueryBuilder('r') - ->where('r.patientRecord = :patient') - ->setParameter('patient', $patient) - ->orderBy('r.recordedAt', 'DESC') - ->setMaxResults($limit) - ->getQuery() - ->getResult(); - } -} diff --git a/src/Cancellation/Service/CancellationService.php b/src/Cancellation/Service/CancellationService.php deleted file mode 100644 index 7cdb306f..00000000 --- a/src/Cancellation/Service/CancellationService.php +++ /dev/null @@ -1,178 +0,0 @@ - - * @throws AppException ۴۲۲ روی نوبت گذشته، ۴۰۹ روی نوبتِ از قبل لغوشده - */ - public function cancel(Appointment $appointment, string $status, ?User $actor = null, ?int $now = null, ?string $reason = null): array - { - $now = $now ?? time(); - - if (in_array($appointment->getStatus(), [ - Appointment::STATUS_CANCELLED_BY_USER, - Appointment::STATUS_CANCELLED_BY_DOCTOR, - ], true)) { - // idempotent: همان وضعیت برمی‌گردد، نه یک لغو دوباره. - throw new AppException(ErrorCodes::ERR_SLOT_TAKEN, 'این نوبت قبلاً لغو شده است', 409); - } - - if ($appointment->getSlotStart() < $now) { - // برای گذشته `no_show` یا `completed` معنا دارد، نه لغو. - throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'نوبت گذشته لغو نمی‌شود؛ وضعیت عدم حضور یا انجام‌شده را ثبت کنید', 422); - } - - $penalty = $this->calculator->calculate($appointment, $status, $now); - - /** - * همهٔ نوشتن‌های دیتابیس در **یک** تراکنش. - * - * وضعیت نوبت، آزادسازی ظرفیت، بازگشت اعتبار، جریمه و ردیف تایم‌لاین یک واقعه‌اند: - * نوبتی که «لغو» شده ولی ظرفیتش آزاد نشده، بدترین حالت ممکن است — هم بیمار نوبت - * ندارد هم کسی نمی‌تواند آن وقت را بگیرد. - * - * اطلاع‌رسانی **بیرون** این بلوک است و بعد از commit اجرا می‌شود: پیامک قابل - * برگرداندن نیست، پس نباید داخل چیزی باشد که ممکن است برگردد. - */ - [$released, $charged] = $this->em->wrapInTransaction( - function () use ($appointment, $status, $penalty, $actor, $reason): array { - $appointment->transitionTo($status); - $this->em->flush(); - - // آزادسازی منابع و بازگشت اعتبار پکیج و جلسهٔ دوره — همه در `cancel` بوکینگ. - $released = $this->booking->cancel($appointment); - - if (!$penalty->creditRefundable) { - $this->revokeRefundedCredit($appointment); - } - - $charged = $this->chargePenalty($appointment, $penalty, $actor); - - $this->recordTimelineEntry($appointment, $actor, $reason); - - return [$released, $charged]; - }, - ); - - $notified = $this->waitlist->notifyForFreedSlot($appointment); - - return [ - 'appointment_uuid' => $appointment->getUuid(), - 'status' => $appointment->getStatus(), - 'released_resources' => $released, - 'waitlist_notified' => $notified, - 'penalty_charged' => $charged, - ] + $penalty->toArray(); - } - - /** - * ردیف تایم‌لاین — همان چیزی که اپراتور در صفحهٔ نوبت می‌بیند. - * - * جدا از رویداد دامنه است و جایگزینش نمی‌شود: آن برای مصرف‌کنندهٔ بیرونی است و این - * برای آدمی که می‌خواهد بداند چه کسی و چرا لغو کرد. بدون این، لغو از مسیر سیاست - * هیچ ردی در تاریخچهٔ نوبت نمی‌گذاشت. - */ - private function recordTimelineEntry(Appointment $appointment, ?User $actor, ?string $reason): void - { - $event = new AppointmentEvent($appointment, AppointmentEvent::TYPE_CANCELLED, 'نوبت لغو شد'); - $event->setReason($reason); - - if ($actor !== null) { - $event->setActor($actor->getId(), $actor->getRealName() ?: $actor->getMobileNumber()); - } - - $this->em->persist($event); - $this->em->flush(); - } - - /** - * جریمه از کیف پول کسر می‌شود، و اگر موجودی نبود **کسر نمی‌شود**. - * - * موجودی ناکافی نباید لغو را شکست بدهد: نوبت باید آزاد شود حتی اگر پول بعداً - * وصول شود. بدهی مسئلهٔ صورتحساب است، نه یک عدد منفی پنهان در کیف پول. - */ - private function chargePenalty(Appointment $appointment, PenaltyResult $penalty, ?User $actor): bool - { - if ($penalty->penaltyRials <= 0) { - return false; - } - - try { - $this->wallet->withdraw( - $appointment->getUser(), - $penalty->penaltyRials, - $actor, - 'جریمهٔ لغو نوبت', - null, - $appointment->getUuid(), - // بدون این، کلینیک الف جریمهٔ ثبت‌شده در کلینیک ب را می‌بیند. - $appointment->getEntityType(), - $appointment->getEntityId(), - ); - } catch (AppException) { - return false; - } - - return true; - } - - /** - * سیاستی که اعتبار را برنمی‌گرداند: ردیف `refund` که `BookingService::cancel()` - * نوشته با یک `adjustment` منفی خنثی می‌شود. - * - * حذف ردیف قبلی ممنوع است — دفتر append-only می‌ماند و تاریخچه نشان می‌دهد - * اعتبار برگشت و بعد طبق سیاست پس گرفته شد. - */ - private function revokeRefundedCredit(Appointment $appointment): void - { - $refund = $this->em->getRepository(\App\Package\Entity\SessionCreditLedger::class) - ->findOneBy([ - 'appointment' => $appointment, - 'kind' => \App\Package\Entity\SessionCreditLedger::KIND_REFUND, - ]); - - if ($refund === null) { - return; - } - - $this->credits->record( - $refund->getPatientPackage(), - \App\Package\Entity\SessionCreditLedger::KIND_ADJUSTMENT, - -$refund->getDelta(), - null, - $refund->getServiceItem(), - 'سیاست لغو: اعتبار این جلسه برنمی‌گردد', - ); - } -} diff --git a/src/Cancellation/Service/NoShowService.php b/src/Cancellation/Service/NoShowService.php deleted file mode 100644 index 7ee3ffec..00000000 --- a/src/Cancellation/Service/NoShowService.php +++ /dev/null @@ -1,100 +0,0 @@ -records->findForAppointment($appointment); - - $policy = $this->policies->resolve( - $appointment->getEntityType(), - $appointment->getEntityId(), - $appointment->getServiceItem(), - ); - - $threshold = $policy?->getNoShowThreshold() ?? 3; - - if ($existing !== null) { - return [ - 'recorded' => false, - 'count' => $this->records->countRecent($patient, $now), - 'threshold' => $threshold, - 'tagged' => false, - ]; - } - - $this->em->persist(new NoShowRecord($patient, $appointment, $actor, $now)); - - $this->events->record( - $appointment->getEntityType(), - $appointment->getEntityId(), - DomainEvents::PATIENT_NO_SHOW, - ['appointment_uuid' => $appointment->getUuid(), 'patient_uuid' => $patient->getUuid()], - $now, - ); - - $this->em->flush(); - - $count = $this->records->countRecent($patient, $now); - $tagged = false; - - if ($count >= $threshold && $policy?->getRiskTagUuid() !== null) { - $tagged = $this->applyRiskTag($patient, $policy->getRiskTagUuid()); - } - - return ['recorded' => true, 'count' => $count, 'threshold' => $threshold, 'tagged' => $tagged]; - } - - /** @return bool `false` یعنی برچسب از قبل بود یا وجود ندارد */ - private function applyRiskTag(PatientRecord $patient, string $tagUuid): bool - { - $tag = $this->em->getRepository(TenantTag::class)->findOneBy(['uuid' => $tagUuid]); - - if ($tag === null || $patient->getTags()->contains($tag)) { - return false; - } - - $patient->getTags()->add($tag); - $this->em->flush(); - - return true; - } - - public function countFor(PatientRecord $patient, ?int $now = null): int - { - return $this->records->countRecent($patient, $now); - } -} diff --git a/src/Cancellation/Service/PenaltyCalculator.php b/src/Cancellation/Service/PenaltyCalculator.php deleted file mode 100644 index a271d043..00000000 --- a/src/Cancellation/Service/PenaltyCalculator.php +++ /dev/null @@ -1,104 +0,0 @@ -policies->resolve( - $appointment->getEntityType(), - $appointment->getEntityId(), - $appointment->getServiceItem(), - ); - - if ($policy === null) { - return PenaltyResult::free(true, ['برای این محیط سیاست لغو تعریف نشده است']); - } - - $hoursLeft = ($appointment->getSlotStart() - $now) / 3600; - - if ($hoursLeft >= $policy->getFreeWindowHours()) { - return PenaltyResult::free(true); - } - - $paid = $this->paidRials($appointment); - $penalty = $this->rawPenalty($policy, $appointment, $paid); - - $notes = []; - - // جریمهٔ بیشتر از پرداختی یعنی بدهی، و بدهی مسئلهٔ صورتحساب است نه لغو. - if ($penalty > $paid) { - $notes[] = $paid === 0 - ? 'این نوبت پرداختی نداشته، پس جریمه‌ای کسر نمی‌شود' - : 'جریمه تا سقف مبلغ پرداختی کاهش یافت'; - $penalty = $paid; - } - - return new PenaltyResult( - penaltyRials: $penalty, - depositRefundable: $policy->isDepositRefundable(), - creditRefundable: $policy->isCreditRefundable(), - withinFreeWindow: false, - notes: $notes, - ); - } - - private function rawPenalty(CancellationPolicy $policy, Appointment $appointment, int $paid): int - { - return match ($policy->getPenaltyMode()) { - CancellationPolicy::MODE_PERCENT => (int) floor($this->baseFor($appointment, $paid) * $policy->getPenaltyValue() / 100), - CancellationPolicy::MODE_FIXED => $policy->getPenaltyValue(), - default => 0, - }; - } - - /** - * مبنای درصد: مبلغ ثبت‌شدهٔ نوبت، و اگر نبود آنچه واقعاً پرداخت شده. - * - * درصدِ «قیمت امروزِ سرویس» غلط است: بیمار روی قیمت آن روز توافق کرده. - */ - private function baseFor(Appointment $appointment, int $paid): int - { - return (int) ($appointment->getVisitPriceRials() ?? $paid); - } - - /** جمع پرداخت‌های موفق همین نوبت. */ - public function paidRials(Appointment $appointment): int - { - return (int) $this->em->createQueryBuilder() - ->select('COALESCE(SUM(p.amountRials), 0)') - ->from(Payment::class, 'p') - ->where('p.appointment = :appointment') - ->andWhere('p.status = :status') - ->setParameter('appointment', $appointment) - ->setParameter('status', Payment::STATUS_SUCCESS) - ->getQuery() - ->getSingleScalarResult(); - } -} diff --git a/src/Cancellation/ValueObject/PenaltyResult.php b/src/Cancellation/ValueObject/PenaltyResult.php deleted file mode 100644 index 070cb154..00000000 --- a/src/Cancellation/ValueObject/PenaltyResult.php +++ /dev/null @@ -1,39 +0,0 @@ - $notes */ - public function __construct( - public int $penaltyRials, - public bool $depositRefundable, - public bool $creditRefundable, - public bool $withinFreeWindow, - public array $notes = [], - ) {} - - /** لغو توسط کلینیک، یا داخل پنجرهٔ رایگان. */ - public static function free(bool $withinFreeWindow = true, array $notes = []): self - { - return new self(0, true, true, $withinFreeWindow, $notes); - } - - /** @return array */ - public function toArray(): array - { - return [ - 'penalty_rials' => $this->penaltyRials, - 'deposit_refundable' => $this->depositRefundable, - 'credit_refundable' => $this->creditRefundable, - 'within_free_window' => $this->withinFreeWindow, - 'notes' => $this->notes, - ]; - } -} diff --git a/src/Course/Controller/CourseProtocolController.php b/src/Course/Controller/CourseProtocolController.php deleted file mode 100644 index d82084d2..00000000 --- a/src/Course/Controller/CourseProtocolController.php +++ /dev/null @@ -1,218 +0,0 @@ -branches->pair($user); - - return $this->success(array_map( - static fn (CourseProtocol $p): array => $p->toArray(), - $this->protocols->findForPair($entityType, $entityId), - )); - } - - #[Route('/api/v1/course-protocols', name: 'course_protocol_create', methods: ['POST'])] - public function create(#[CurrentUser] User $user, Request $request): JsonResponse - { - $data = json_decode($request->getContent(), true); - - if (!is_array($data) || !is_string($data['service_uuid'] ?? null)) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شناسهٔ سرویس الزامی است', 422, 'service_uuid'); - } - - $service = $this->requireItem($user, $data['service_uuid']); - - if ($this->protocols->findForService($service) !== null) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس از قبل پروتکل دارد', 422, 'service_uuid'); - } - - try { - $protocol = new CourseProtocol( - $service, - (int) ($data['session_count'] ?? 0), - (int) ($data['min_days'] ?? 0), - (int) ($data['ideal_days'] ?? 0), - (int) ($data['max_days'] ?? 0), - ); - } catch (\InvalidArgumentException $e) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, $this->explain($e), 422, 'session_count'); - } - - $this->applyFlags($protocol, $data); - $this->replaceSteps($protocol, $data['steps'] ?? null); - - $this->protocols->save($protocol); - - return $this->success($protocol->toArray(), 201); - } - - #[Route('/api/v1/course-protocol/{uuid}', name: 'course_protocol_show', methods: ['GET'])] - public function show(#[CurrentUser] User $user, string $uuid): JsonResponse - { - return $this->success($this->requireProtocol($user, $uuid)->toArray()); - } - - #[Route('/api/v1/course-protocol/{uuid}', name: 'course_protocol_update', methods: ['PATCH'])] - public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse - { - $protocol = $this->requireProtocol($user, $uuid); - $data = json_decode($request->getContent(), true); - - if (!is_array($data)) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); - } - - try { - $protocol->setShape( - (int) ($data['session_count'] ?? $protocol->getSessionCount()), - (int) ($data['min_days'] ?? $protocol->getMinDays()), - (int) ($data['ideal_days'] ?? $protocol->getIdealDays()), - (int) ($data['max_days'] ?? $protocol->getMaxDays()), - ); - } catch (\InvalidArgumentException $e) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, $this->explain($e), 422, 'session_count'); - } - - $this->applyFlags($protocol, $data); - - if (array_key_exists('steps', $data)) { - $this->replaceSteps($protocol, $data['steps']); - } - - $this->protocols->save($protocol); - - return $this->success($protocol->toArray()); - } - - /** - * حذف = غیرفعال کردن. - * - * دوره‌های در جریان به پروتکل ارجاع دارند؛ حذف واقعی یعنی پروندهٔ بیمار نتواند - * بگوید از کجا آمده. - */ - #[Route('/api/v1/course-protocol/{uuid}', name: 'course_protocol_delete', methods: ['DELETE'])] - public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse - { - $protocol = $this->requireProtocol($user, $uuid)->setActive(false); - $this->protocols->save($protocol); - - return $this->success($protocol->toArray()); - } - - private function explain(\InvalidArgumentException $e): string - { - return str_contains($e->getMessage(), 'two sessions') - ? 'دورهٔ کمتر از دو جلسه همان نوبت تکی است' - : 'ترتیب فاصله‌ها باید حداقل ≤ ایده‌آل ≤ حداکثر باشد'; - } - - /** @param array $data */ - private function applyFlags(CourseProtocol $protocol, array $data): void - { - if (isset($data['prefer_same_resource'])) { - $protocol->setPreferSameResource((bool) $data['prefer_same_resource']); - } - - if (isset($data['active'])) { - $protocol->setActive((bool) $data['active']); - } - } - - /** جایگزینی کامل — قرارداد `PUT` روی زیرمجموعه، همان الگوی ساعت کاری شعبه. */ - private function replaceSteps(CourseProtocol $protocol, mixed $steps): void - { - if (!is_array($steps)) { - return; - } - - foreach ($protocol->getSteps() as $existing) { - $this->em->remove($existing); - } - - $protocol->getSteps()->clear(); - - foreach ($steps as $step) { - if (!is_array($step) || !is_numeric($step['session_number'] ?? null)) { - continue; - } - - $number = (int) $step['session_number']; - - if ($number < 1 || $number > $protocol->getSessionCount()) { - throw new AppException( - ErrorCodes::ERR_VALIDATION_001, - sprintf('شمارهٔ جلسه باید بین ۱ و %d باشد', $protocol->getSessionCount()), - 422, - 'steps', - ); - } - - $this->em->persist(new CourseProtocolStep( - $protocol, - $number, - is_array($step['params'] ?? null) ? $step['params'] : [], - is_numeric($step['override_duration_minutes'] ?? null) ? (int) $step['override_duration_minutes'] : null, - )); - } - } - - private function requireItem(User $user, string $uuid): ServiceItem - { - $item = $this->items->findByUuid($uuid); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($item === null - || $item->getSection()->getEntityType() !== $entityType - || $item->getSection()->getEntityId() !== $entityId - ) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); - } - - return $item; - } - - private function requireProtocol(User $user, string $uuid): CourseProtocol - { - $protocol = $this->protocols->findByUuid($uuid); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($protocol === null || !$this->ownership->belongsToPair($entityType, $entityId, $protocol)) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پروتکل یافت نشد', 404); - } - - return $protocol; - } -} diff --git a/src/Course/Controller/TreatmentCourseController.php b/src/Course/Controller/TreatmentCourseController.php deleted file mode 100644 index 786c0a0d..00000000 --- a/src/Course/Controller/TreatmentCourseController.php +++ /dev/null @@ -1,223 +0,0 @@ -getContent(), true); - - if (!is_array($data) || !is_string($data['patient_uuid'] ?? null)) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شناسهٔ بیمار الزامی است', 422, 'patient_uuid'); - } - - if (!is_string($data['protocol_uuid'] ?? null)) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شناسهٔ پروتکل الزامی است', 422, 'protocol_uuid'); - } - - $patient = $this->requirePatient($user, $data['patient_uuid']); - $protocol = $this->protocols->findByUuid($data['protocol_uuid']); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($protocol === null || !$this->ownership->belongsToPair($entityType, $entityId, $protocol)) { - return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پروتکل یافت نشد', 404); - } - - $package = null; - - if (is_string($data['patient_package_uuid'] ?? null)) { - $package = $this->patientPackages->findByUuid($data['patient_package_uuid']); - - if ($package === null || !$this->ownership->belongsToPair($entityType, $entityId, $package)) { - return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج بیمار یافت نشد', 404); - } - } - - $course = $this->starter->start($patient, $protocol, $package); - - return $this->success($this->detail($course), 201); - } - - #[Route('/api/v1/treatment-course/{uuid}', name: 'treatment_course_show', methods: ['GET'])] - public function show(#[CurrentUser] User $user, string $uuid): JsonResponse - { - return $this->success($this->detail($this->requireCourse($user, $uuid))); - } - - #[Route('/api/v1/patient/{uuid}/courses', name: 'patient_course_index', methods: ['GET'])] - public function forPatient(#[CurrentUser] User $user, string $uuid): JsonResponse - { - $patient = $this->requirePatient($user, $uuid); - - return $this->success(array_map( - fn (TreatmentCourse $c): array => $c->toArray() + ['progress' => $this->progress->progressOf($c)], - $this->courses->findForPatient($patient), - )); - } - - /** - * پیشنهاد تاریخ جلسهٔ بعدی — بازهٔ مجاز، تاریخ ایده‌آل و چند وقت نزدیک به آن. - */ - #[Route('/api/v1/treatment-course/{uuid}/next-slot-suggestion', name: 'treatment_course_next_slot', methods: ['GET'])] - public function nextSlot(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse - { - $course = $this->requireCourse($user, $uuid); - $branch = $request->query->get('branch_uuid'); - - if (!is_string($branch)) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد branch_uuid الزامی است', 422, 'branch_uuid'); - } - - $address = $this->branches->resolve($user, $branch); - - return $this->success($this->scheduler->suggestNext($course, $address)); - } - - /** رزرو همهٔ جلسات باقی‌مانده — همه یا هیچ. */ - #[Route('/api/v1/treatment-course/{uuid}/book-all', name: 'treatment_course_book_all', methods: ['POST'])] - public function bookAll(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse - { - $course = $this->requireCourse($user, $uuid); - $data = json_decode($request->getContent(), true); - - if (!is_array($data) || !is_string($data['branch_uuid'] ?? null)) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد branch_uuid الزامی است', 422, 'branch_uuid'); - } - - if (!is_string($data['doctor_uuid'] ?? null)) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد doctor_uuid الزامی است', 422, 'doctor_uuid'); - } - - $address = $this->branches->resolve($user, $data['branch_uuid']); - $doctor = $this->doctors->findOneBy(['uuid' => $data['doctor_uuid']]); - - if ($doctor === null) { - return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404); - } - - $result = $this->booker->bookAll($course, $address, $doctor, $user); - - return $this->success($result + ['course' => $this->detail($course)]); - } - - #[Route('/api/v1/treatment-course/{uuid}/abandon', name: 'treatment_course_abandon', methods: ['POST'])] - public function abandon(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse - { - $course = $this->requireCourse($user, $uuid); - $data = json_decode($request->getContent(), true); - - if (!is_array($data) || !is_string($data['reason'] ?? null) || trim($data['reason']) === '') { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دلیل رهاکردن دوره الزامی است', 422, 'reason'); - } - - if (!$course->isActive()) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این دوره فعال نیست', 422); - } - - $course->abandon(trim($data['reason'])); - $this->courses->save($course); - - return $this->success($this->detail($course)); - } - - /** @return array */ - private function detail(TreatmentCourse $course): array - { - $sessions = $course->getSessions()->toArray(); - - usort($sessions, static fn (CourseSession $a, CourseSession $b): int - => $a->getSessionNumber() <=> $b->getSessionNumber()); - - // مانده در برابر جلسات باقی‌مانده: **هشدار** است نه خطا. دوره‌ای که پکیجش کفاف - // نمی‌دهد هنوز کاملاً معتبر است — بقیه‌اش نقدی می‌شود — ولی کسی باید بداند، - // ترجیحاً قبل از جلسهٔ ششم نه سرِ آن. - $package = $course->getPatientPackage(); - $balance = $package === null ? null : $this->credits->balance($package); - $needed = count(array_filter( - $sessions, - static fn (CourseSession $s): bool => $s->getStatus() !== CourseSession::STATUS_COMPLETED, - )); - - return $course->toArray() + [ - 'progress' => $this->progress->progressOf($course), - 'package_balance' => $balance, - 'package_shortfall' => $balance === null ? null : max(0, $needed - $balance), - 'sessions' => array_map( - static fn (CourseSession $s): array => $s->toArray(), - $sessions, - ), - ]; - } - - private function requirePatient(User $user, string $uuid): 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; - } - - private function requireCourse(User $user, string $uuid): TreatmentCourse - { - $course = $this->courses->findByUuid($uuid); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($course === null || !$this->ownership->belongsToPair($entityType, $entityId, $course)) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'دوره یافت نشد', 404); - } - - return $course; - } -} diff --git a/src/Course/Entity/CourseProtocol.php b/src/Course/Entity/CourseProtocol.php deleted file mode 100644 index 9581a695..00000000 --- a/src/Course/Entity/CourseProtocol.php +++ /dev/null @@ -1,180 +0,0 @@ - true])] - private bool $preferSameResource = true; - - #[ORM\Column(type: 'boolean', options: ['default' => true])] - private bool $active = true; - - /** @var Collection */ - #[ORM\OneToMany(targetEntity: CourseProtocolStep::class, mappedBy: 'protocol', cascade: ['persist', 'remove'], orphanRemoval: true)] - #[ORM\OrderBy(['sessionNumber' => 'ASC'])] - private Collection $steps; - - #[ORM\Column(name: 'created_at', type: 'integer')] - private int $createdAt; - - #[ORM\Column(name: 'updated_at', type: 'integer')] - private int $updatedAt; - - public function __construct(ServiceItem $serviceItem, int $sessionCount, int $minDays, int $idealDays, int $maxDays) - { - $this->uuid = Uuid::v4()->toRfc4122(); - $this->serviceItem = $serviceItem; - $this->steps = new ArrayCollection(); - $this->createdAt = time(); - $this->updatedAt = time(); - - $this->setShape($sessionCount, $minDays, $idealDays, $maxDays); - - $section = $serviceItem->getSection(); - $this->assignTenantPair($section->getEntityType(), $section->getEntityId()); - } - - /** - * @throws \InvalidArgumentException وقتی فاصله‌ها ناسازگارند - */ - public function setShape(int $sessionCount, int $minDays, int $idealDays, int $maxDays): self - { - // دورهٔ یک‌جلسه‌ای همان نوبت تکی است و به دوره نیازی ندارد. - if ($sessionCount < 2) { - throw new \InvalidArgumentException('A course needs at least two sessions.'); - } - - if (!($minDays <= $idealDays && $idealDays <= $maxDays)) { - throw new \InvalidArgumentException('Course spacing must satisfy min <= ideal <= max.'); - } - - $this->sessionCount = $sessionCount; - $this->minDays = $minDays; - $this->idealDays = $idealDays; - $this->maxDays = $maxDays; - - return $this->touch(); - } - - public function getId(): ?int { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getServiceItem(): ServiceItem { return $this->serviceItem; } - public function getSessionCount(): int { return $this->sessionCount; } - public function getMinDays(): int { return $this->minDays; } - public function getIdealDays(): int { return $this->idealDays; } - public function getMaxDays(): int { return $this->maxDays; } - public function prefersSameResource(): bool { return $this->preferSameResource; } - public function isActive(): bool { return $this->active; } - - /** @return Collection */ - public function getSteps(): Collection { return $this->steps; } - - public function setPreferSameResource(bool $v): self { $this->preferSameResource = $v; return $this->touch(); } - public function setActive(bool $v): self { $this->active = $v; return $this->touch(); } - - public function addStep(CourseProtocolStep $step): self - { - if (!$this->steps->contains($step)) { - $this->steps->add($step); - } - - return $this; - } - - /** پارامترهای جلسهٔ n — آرایهٔ خالی یعنی این جلسه پارامتری ندارد. */ - public function paramsFor(int $sessionNumber): array - { - foreach ($this->steps as $step) { - if ($step->getSessionNumber() === $sessionNumber) { - return $step->getParams(); - } - } - - return []; - } - - public function overrideDurationFor(int $sessionNumber): ?int - { - foreach ($this->steps as $step) { - if ($step->getSessionNumber() === $sessionNumber) { - return $step->getOverrideDurationMinutes(); - } - } - - return null; - } - - private function touch(): self - { - $this->updatedAt = time(); - - return $this; - } - - /** @return array */ - public function toArray(): array - { - return [ - 'uuid' => $this->uuid, - 'service_uuid' => $this->serviceItem->getUuid(), - 'service_name' => $this->serviceItem->getName(), - 'session_count' => $this->sessionCount, - 'min_days' => $this->minDays, - 'ideal_days' => $this->idealDays, - 'max_days' => $this->maxDays, - 'prefer_same_resource' => $this->preferSameResource, - 'active' => $this->active, - 'steps' => array_values(array_map( - static fn (CourseProtocolStep $s): array => $s->toArray(), - $this->steps->toArray(), - )), - 'created_at' => $this->createdAt, - ]; - } -} diff --git a/src/Course/Entity/CourseProtocolStep.php b/src/Course/Entity/CourseProtocolStep.php deleted file mode 100644 index cea989bc..00000000 --- a/src/Course/Entity/CourseProtocolStep.php +++ /dev/null @@ -1,70 +0,0 @@ - */ - #[ORM\Column(type: 'json', nullable: true)] - private ?array $params = null; - - #[ORM\Column(name: 'override_duration_minutes', type: 'smallint', nullable: true)] - private ?int $overrideDurationMinutes = null; - - /** @param array $params */ - public function __construct(CourseProtocol $protocol, int $sessionNumber, array $params = [], ?int $overrideDuration = null) - { - $this->protocol = $protocol; - $this->sessionNumber = $sessionNumber; - $this->params = self::scalarsOnly($params); - $this->overrideDurationMinutes = $overrideDuration; - - $protocol->addStep($this); - } - - /** تودرتویی پذیرفته نمی‌شود: پارامتری که ساختار دارد، منطق پنهان دارد. */ - private static function scalarsOnly(array $params): array - { - return array_filter($params, static fn (mixed $v): bool => is_scalar($v) || $v === null); - } - - public function getId(): ?int { return $this->id; } - public function getProtocol(): CourseProtocol { return $this->protocol; } - public function getSessionNumber(): int { return $this->sessionNumber; } - public function getParams(): array { return $this->params ?? []; } - public function getOverrideDurationMinutes(): ?int { return $this->overrideDurationMinutes; } - - /** @return array */ - public function toArray(): array - { - return [ - 'session_number' => $this->sessionNumber, - 'params' => (object) ($this->params ?? []), - 'override_duration_minutes' => $this->overrideDurationMinutes, - ]; - } -} diff --git a/src/Course/Entity/CourseSession.php b/src/Course/Entity/CourseSession.php deleted file mode 100644 index 4942b362..00000000 --- a/src/Course/Entity/CourseSession.php +++ /dev/null @@ -1,142 +0,0 @@ -|null */ - #[ORM\Column(type: 'json', nullable: true)] - private ?array $params = null; - - #[ORM\ManyToOne(targetEntity: Appointment::class)] - #[ORM\JoinColumn(name: 'appointment_id', nullable: true, onDelete: 'SET NULL')] - private ?Appointment $appointment = null; - - #[ORM\Column(type: 'string', length: 12, options: ['default' => self::STATUS_PLANNED])] - private string $status = self::STATUS_PLANNED; - - #[ORM\Column(name: 'completed_at', type: 'integer', nullable: true)] - private ?int $completedAt = null; - - #[ORM\Column(name: 'created_at', type: 'integer')] - private int $createdAt; - - #[ORM\Column(name: 'updated_at', type: 'integer')] - private int $updatedAt; - - /** @param array $params */ - public function __construct(TreatmentCourse $course, int $sessionNumber, array $params = []) - { - $this->uuid = Uuid::v4()->toRfc4122(); - $this->course = $course; - $this->sessionNumber = $sessionNumber; - $this->params = $params === [] ? null : $params; - $this->createdAt = time(); - $this->updatedAt = time(); - - $this->assignTenantPair($course->getEntityType(), $course->getEntityId()); - $course->addSession($this); - } - - public function getId(): ?int { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getCourse(): TreatmentCourse { return $this->course; } - public function getSessionNumber(): int { return $this->sessionNumber; } - public function getParams(): array { return $this->params ?? []; } - public function getAppointment(): ?Appointment { return $this->appointment; } - public function getStatus(): string { return $this->status; } - public function getCompletedAt(): ?int { return $this->completedAt; } - - public function markBooked(Appointment $appointment): self - { - $this->appointment = $appointment; - $this->status = self::STATUS_BOOKED; - - return $this->touch(); - } - - /** لغو نوبت جلسه را به `planned` برمی‌گرداند؛ بقیهٔ دوره دست‌نخورده می‌ماند. */ - public function unbook(): self - { - $this->appointment = null; - $this->status = self::STATUS_PLANNED; - - return $this->touch(); - } - - public function markCompleted(?int $at = null): self - { - $this->status = self::STATUS_COMPLETED; - $this->completedAt = $at ?? time(); - - return $this->touch(); - } - - public function markSkipped(): self - { - $this->status = self::STATUS_SKIPPED; - - return $this->touch(); - } - - private function touch(): self - { - $this->updatedAt = time(); - - return $this; - } - - /** @return array */ - public function toArray(): array - { - return [ - 'uuid' => $this->uuid, - 'session_number' => $this->sessionNumber, - 'params' => (object) ($this->params ?? []), - 'appointment_uuid' => $this->appointment?->getUuid(), - 'slot_start' => $this->appointment?->getSlotStart(), - 'status' => $this->status, - 'completed_at' => $this->completedAt, - ]; - } -} diff --git a/src/Course/Entity/TreatmentCourse.php b/src/Course/Entity/TreatmentCourse.php deleted file mode 100644 index f6c607e6..00000000 --- a/src/Course/Entity/TreatmentCourse.php +++ /dev/null @@ -1,243 +0,0 @@ - self::STATUS_ACTIVE])] - private string $status = self::STATUS_ACTIVE; - - /** `null` وقتی دوره فعال نیست — همین باعث می‌شود کلید یکتا فقط فعال‌ها را ببندد. */ - #[ORM\Column(name: 'active_course_key', type: 'string', length: 64, nullable: true, unique: true)] - private ?string $activeCourseKey = null; - - #[ORM\Column(name: 'abandon_reason', type: 'string', length: 255, nullable: true)] - private ?string $abandonReason = null; - - #[ORM\Column(name: 'started_at', type: 'integer')] - private int $startedAt; - - #[ORM\Column(name: 'completed_at', type: 'integer', nullable: true)] - private ?int $completedAt = null; - - /** @var Collection */ - #[ORM\OneToMany(targetEntity: CourseSession::class, mappedBy: 'course', cascade: ['persist', 'remove'], orphanRemoval: true)] - #[ORM\OrderBy(['sessionNumber' => 'ASC'])] - private Collection $sessions; - - #[ORM\Column(name: 'created_at', type: 'integer')] - private int $createdAt; - - #[ORM\Column(name: 'updated_at', type: 'integer')] - private int $updatedAt; - - public function __construct(PatientRecord $patientRecord, CourseProtocol $protocol) - { - $this->uuid = Uuid::v4()->toRfc4122(); - $this->patientRecord = $patientRecord; - $this->protocol = $protocol; - $this->serviceItem = $protocol->getServiceItem(); - $this->sessionCount = $protocol->getSessionCount(); - $this->minDays = $protocol->getMinDays(); - $this->idealDays = $protocol->getIdealDays(); - $this->maxDays = $protocol->getMaxDays(); - $this->sessions = new ArrayCollection(); - $this->startedAt = time(); - $this->createdAt = time(); - $this->updatedAt = time(); - - $this->assignTenantPair($protocol->getEntityType(), $protocol->getEntityId()); - $this->refreshActiveKey(); - } - - public function getId(): ?int { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getPatientRecord(): PatientRecord { return $this->patientRecord; } - public function getServiceItem(): ServiceItem { return $this->serviceItem; } - public function getProtocol(): CourseProtocol { return $this->protocol; } - public function getSessionCount(): int { return $this->sessionCount; } - public function getMinDays(): int { return $this->minDays; } - public function getIdealDays(): int { return $this->idealDays; } - public function getMaxDays(): int { return $this->maxDays; } - public function getPatientPackage(): ?PatientPackage { return $this->patientPackage; } - public function getPreferredResource(): ?ClinicResource { return $this->preferredResource; } - public function getStatus(): string { return $this->status; } - public function getAbandonReason(): ?string { return $this->abandonReason; } - public function getStartedAt(): int { return $this->startedAt; } - public function getCompletedAt(): ?int { return $this->completedAt; } - public function isActive(): bool { return $this->status === self::STATUS_ACTIVE; } - - /** @return Collection */ - public function getSessions(): Collection { return $this->sessions; } - - public function setPatientPackage(?PatientPackage $v): self { $this->patientPackage = $v; return $this->touch(); } - public function setPreferredResource(?ClinicResource $v): self { $this->preferredResource = $v; return $this->touch(); } - - public function addSession(CourseSession $session): self - { - if (!$this->sessions->contains($session)) { - $this->sessions->add($session); - } - - return $this; - } - - public function abandon(string $reason): self - { - $this->status = self::STATUS_ABANDONED; - $this->abandonReason = $reason; - - return $this->refreshActiveKey()->touch(); - } - - public function complete(?int $at = null): self - { - $this->status = self::STATUS_COMPLETED; - $this->completedAt = $at ?? time(); - - return $this->refreshActiveKey()->touch(); - } - - /** @return list جلساتی که هنوز رزرو نشده‌اند، به ترتیب شماره */ - public function plannedSessions(): array - { - return array_values(array_filter( - $this->sessions->toArray(), - static fn (CourseSession $s): bool => $s->getStatus() === CourseSession::STATUS_PLANNED, - )); - } - - /** آخرین جلسهٔ **انجام‌شده** — مبنای فاصلهٔ جلسهٔ بعدی. */ - public function lastCompletedAt(): ?int - { - $times = []; - - foreach ($this->sessions as $session) { - if ($session->getCompletedAt() !== null) { - $times[] = $session->getCompletedAt(); - } - } - - return $times === [] ? null : max($times); - } - - public function completedCount(): int - { - return count(array_filter( - $this->sessions->toArray(), - static fn (CourseSession $s): bool => $s->getStatus() === CourseSession::STATUS_COMPLETED, - )); - } - - private function refreshActiveKey(): self - { - $this->activeCourseKey = $this->status === self::STATUS_ACTIVE - ? sprintf('%d:%d', $this->patientRecord->getId(), $this->serviceItem->getId()) - : null; - - return $this; - } - - private function touch(): self - { - $this->updatedAt = time(); - - return $this; - } - - /** @return array */ - public function toArray(): array - { - return [ - 'uuid' => $this->uuid, - 'patient_uuid' => $this->patientRecord->getUuid(), - 'service_uuid' => $this->serviceItem->getUuid(), - 'service_name' => $this->serviceItem->getName(), - 'protocol_uuid' => $this->protocol->getUuid(), - 'session_count' => $this->sessionCount, - 'min_days' => $this->minDays, - 'ideal_days' => $this->idealDays, - 'max_days' => $this->maxDays, - 'patient_package_uuid' => $this->patientPackage?->getUuid(), - 'preferred_resource_uuid' => $this->preferredResource?->getUuid(), - // نامش هم می‌آید تا پنل بتواند «همان دستگاه قبلی» را روی دکمهٔ رزرو بنویسد - // بدون یک درخواست دیگر. ترجیح است نه الزام — موتور فقط جلوترش می‌آورد. - 'preferred_resource_name' => $this->preferredResource?->getName(), - 'status' => $this->status, - 'abandon_reason' => $this->abandonReason, - 'started_at' => $this->startedAt, - 'completed_at' => $this->completedAt, - ]; - } -} diff --git a/src/Course/Repository/CourseProtocolRepository.php b/src/Course/Repository/CourseProtocolRepository.php deleted file mode 100644 index d34cb30a..00000000 --- a/src/Course/Repository/CourseProtocolRepository.php +++ /dev/null @@ -1,52 +0,0 @@ - */ -class CourseProtocolRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, CourseProtocol::class); - } - - public function findByUuid(string $uuid): ?CourseProtocol - { - return $this->findOneBy(['uuid' => $uuid]); - } - - public function findForService(ServiceItem $service): ?CourseProtocol - { - return $this->findOneBy(['serviceItem' => $service]); - } - - /** @return CourseProtocol[] */ - public function findForPair(string $entityType, int $entityId): array - { - return $this->createQueryBuilder('p') - ->addSelect('s', 'i') - ->leftJoin('p.steps', 's') - ->leftJoin('p.serviceItem', 'i') - ->where('p.entityType = :type') - ->andWhere('p.entityId = :id') - ->setParameter('type', $entityType) - ->setParameter('id', $entityId) - ->orderBy('p.createdAt', 'DESC') - ->getQuery() - ->getResult(); - } - - public function save(CourseProtocol $protocol, bool $flush = true): void - { - $this->getEntityManager()->persist($protocol); - - if ($flush) { - $this->getEntityManager()->flush(); - } - } -} diff --git a/src/Course/Repository/CourseSessionRepository.php b/src/Course/Repository/CourseSessionRepository.php deleted file mode 100644 index b95e994e..00000000 --- a/src/Course/Repository/CourseSessionRepository.php +++ /dev/null @@ -1,27 +0,0 @@ - */ -class CourseSessionRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, CourseSession::class); - } - - public function findByUuid(string $uuid): ?CourseSession - { - return $this->findOneBy(['uuid' => $uuid]); - } - - public function findForAppointment(Appointment $appointment): ?CourseSession - { - return $this->findOneBy(['appointment' => $appointment]); - } -} diff --git a/src/Course/Repository/TreatmentCourseRepository.php b/src/Course/Repository/TreatmentCourseRepository.php deleted file mode 100644 index a8ac7219..00000000 --- a/src/Course/Repository/TreatmentCourseRepository.php +++ /dev/null @@ -1,54 +0,0 @@ - */ -class TreatmentCourseRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, TreatmentCourse::class); - } - - public function findByUuid(string $uuid): ?TreatmentCourse - { - return $this->findOneBy(['uuid' => $uuid]); - } - - public function findActiveFor(PatientRecord $patient, ServiceItem $service): ?TreatmentCourse - { - return $this->findOneBy([ - 'patientRecord' => $patient, - 'serviceItem' => $service, - 'status' => TreatmentCourse::STATUS_ACTIVE, - ]); - } - - /** @return TreatmentCourse[] */ - public function findForPatient(PatientRecord $patient): array - { - return $this->createQueryBuilder('c') - ->addSelect('s') - ->leftJoin('c.sessions', 's') - ->where('c.patientRecord = :patient') - ->setParameter('patient', $patient) - ->orderBy('c.startedAt', 'DESC') - ->getQuery() - ->getResult(); - } - - public function save(TreatmentCourse $course, bool $flush = true): void - { - $this->getEntityManager()->persist($course); - - if ($flush) { - $this->getEntityManager()->flush(); - } - } -} diff --git a/src/Course/Service/CourseBooker.php b/src/Course/Service/CourseBooker.php deleted file mode 100644 index b3a6045b..00000000 --- a/src/Course/Service/CourseBooker.php +++ /dev/null @@ -1,141 +0,0 @@ -em->wrapInTransaction(function () use ($course, $address, $doctor, $operator, $now): array { - $planned = $course->plannedSessions(); - - usort($planned, static fn (CourseSession $a, CourseSession $b): int - => $a->getSessionNumber() <=> $b->getSessionNumber()); - - $anchor = $course->lastCompletedAt() ?? $now; - $minDays = $this->scheduler->effectiveMinDays($course, $now); - $horizon = $now + CourseScheduler::SEARCH_HORIZON_DAYS * 86400; - - $booked = 0; - $skipped = 0; - - foreach ($planned as $session) { - $min = max($anchor + $minDays * 86400, $now); - $ideal = $anchor + max($course->getIdealDays(), $minDays) * 86400; - $max = $anchor + max($course->getMaxDays(), $minDays) * 86400; - - if ($min > $horizon) { - $skipped++; - continue; - } - - $slot = $this->scheduler->slotsFor($course, $address, $min, min($max, $horizon), $ideal, $now)[0] ?? null; - - if ($slot === null) { - throw new AppException( - ErrorCodes::ERR_VALIDATION_001, - sprintf('برای جلسهٔ %d هیچ وقت مناسبی در بازهٔ مجاز پیدا نشد', $session->getSessionNumber()), - 422, - 'session_number', - ); - } - - $this->bookOne($course, $session, $slot, $address, $doctor, $operator, $now); - - $booked++; - $anchor = $slot->start; - } - - return [ - 'booked' => $booked, - 'remaining' => $skipped, - 'message' => $skipped === 0 - ? null - : sprintf( - '%d جلسه بیرون از بازهٔ %d روزهٔ رزرو افتاد و برنامه‌ریزی‌شده ماند؛ نزدیک‌تر که شدیم رزروشان کنید.', - $skipped, - CourseScheduler::SEARCH_HORIZON_DAYS, - ), - ]; - }); - } - - private function bookOne( - TreatmentCourse $course, - CourseSession $session, - AvailableSlot $slot, - DoctorAddress $address, - Doctor $doctor, - User $operator, - int $now, - ): void { - $plan = $this->planner->build($course->getServiceItem(), [], $address); - - $hold = $this->holds->hold( - $operator, - $plan, - $this->assignmentOf($slot), - $slot->start, - $course->getEntityType(), - $course->getEntityId(), - $now, - ); - - $appointment = new Appointment($doctor, $course->getPatientRecord()->getUser(), $slot->start, $slot->end); - $appointment->assignTenantPair($course->getEntityType(), $course->getEntityId()); - $appointment->setServiceItem($course->getServiceItem()); - $appointment->setAddressId($address->getId()); - - $this->em->persist($appointment); - $this->em->flush(); - - $this->booking->confirm($hold, $appointment, $now); - $this->linker->link($session, $appointment); - } - - /** @return array> */ - private function assignmentOf(AvailableSlot $slot): array - { - return $slot->assignment->byRole; - } -} diff --git a/src/Course/Service/CourseProgressCalculator.php b/src/Course/Service/CourseProgressCalculator.php deleted file mode 100644 index 78ab50d4..00000000 --- a/src/Course/Service/CourseProgressCalculator.php +++ /dev/null @@ -1,49 +0,0 @@ - */ - public function progressOf(TreatmentCourse $course): array - { - $byStatus = [ - CourseSession::STATUS_PLANNED => 0, - CourseSession::STATUS_BOOKED => 0, - CourseSession::STATUS_COMPLETED => 0, - CourseSession::STATUS_SKIPPED => 0, - ]; - - $next = null; - - foreach ($course->getSessions() as $session) { - $byStatus[$session->getStatus()]++; - - if ($session->getStatus() === CourseSession::STATUS_PLANNED - && ($next === null || $session->getSessionNumber() < $next->getSessionNumber()) - ) { - $next = $session; - } - } - - return [ - 'completed' => $byStatus[CourseSession::STATUS_COMPLETED], - 'booked' => $byStatus[CourseSession::STATUS_BOOKED], - 'planned' => $byStatus[CourseSession::STATUS_PLANNED], - 'skipped' => $byStatus[CourseSession::STATUS_SKIPPED], - 'total' => $course->getSessionCount(), - 'next_session_number' => $next?->getSessionNumber(), - 'next_params' => (object) ($next?->getParams() ?? []), - 'last_completed_at' => $course->lastCompletedAt(), - ]; - } -} diff --git a/src/Course/Service/CourseScheduler.php b/src/Course/Service/CourseScheduler.php deleted file mode 100644 index 496fd4db..00000000 --- a/src/Course/Service/CourseScheduler.php +++ /dev/null @@ -1,138 +0,0 @@ -getServiceItem(); - - $outcome = $this->spacingPolicies->evaluate( - $course->getEntityType(), - $course->getEntityId(), - [ - 'service_uuid' => $service->getUuid(), - 'catalog_category' => $service->getCatalogCategory()?->getUuid(), - ], - null, - $service, - $at, - ); - - return max($course->getMinDays(), (int) $outcome->effect(PolicySchema::EFFECT_MIN_DAYS_BETWEEN, 0)); - } - - /** - * پیشنهاد برای جلسهٔ بعدی: بازهٔ مجاز، تاریخ ایده‌آل، چند وقت نزدیک به آن، و - * هشدار عبور از حداکثر فاصله. - * - * @return array - */ - public function suggestNext(TreatmentCourse $course, DoctorAddress $address, ?int $now = null): array - { - $now = $now ?? time(); - $session = $this->nextPlanned($course); - - if ($session === null) { - return ['session_number' => null, 'suggested_slots' => [], 'warning' => 'همهٔ جلسات این دوره برنامه‌ریزی شده‌اند']; - } - - $anchor = $course->lastCompletedAt() ?? $course->getStartedAt(); - $minDays = $this->effectiveMinDays($course, $now); - - $min = $anchor + $minDays * 86400; - $ideal = $anchor + max($course->getIdealDays(), $minDays) * 86400; - $max = $anchor + max($course->getMaxDays(), $minDays) * 86400; - - // زمان گذشته پیشنهاد نمی‌شود؛ بیمارِ دیرکرده باید از همین حالا وقت بگیرد. - $searchFrom = max($min, $now); - $searchTo = max($max, $searchFrom + 86400); - - $slots = $this->slotsFor($course, $address, $searchFrom, $searchTo, $ideal, $now); - - return [ - 'session_number' => $session->getSessionNumber(), - 'params' => (object) $session->getParams(), - 'ideal_at' => $ideal, - 'range' => ['min' => $min, 'max' => $max], - 'suggested_slots' => array_map( - static fn (AvailableSlot $s): array => ['start' => $s->start, 'end' => $s->end], - array_slice($slots, 0, 3), - ), - // هشدار وقتی معنا دارد که واقعاً دیر شده باشد، نه وقتی هنوز فرصت هست. - 'warning' => $now > $max - ? sprintf('از حداکثر فاصلهٔ مجاز (%d روز) عبور شده است. برای ادامهٔ دوره با پزشک مشورت کنید.', $course->getMaxDays()) - : null, - ]; - } - - /** - * نزدیک‌ترین وقت به ایده‌آل، داخل بازهٔ مجاز. - * - * @return AvailableSlot[] مرتب بر اساس فاصله تا ایده‌آل - */ - public function slotsFor( - TreatmentCourse $course, - DoctorAddress $address, - int $from, - int $to, - int $ideal, - ?int $now = null, - ): array { - $plan = $this->planner->build($course->getServiceItem(), [], $address); - $slots = $this->availability->search($plan, $address, $from, $to, now: $now); - - usort($slots, static fn (AvailableSlot $a, AvailableSlot $b): int - => abs($a->start - $ideal) <=> abs($b->start - $ideal)); - - return $slots; - } - - public function nextPlanned(TreatmentCourse $course): ?CourseSession - { - $planned = $course->plannedSessions(); - - usort($planned, static fn (CourseSession $a, CourseSession $b): int - => $a->getSessionNumber() <=> $b->getSessionNumber()); - - return $planned[0] ?? null; - } -} diff --git a/src/Course/Service/CourseSessionLinker.php b/src/Course/Service/CourseSessionLinker.php deleted file mode 100644 index 691cc2a3..00000000 --- a/src/Course/Service/CourseSessionLinker.php +++ /dev/null @@ -1,106 +0,0 @@ -markBooked($appointment); - $appointment->setCourseSession($session); - - $this->em->flush(); - } - - /** - * لغو نوبت: همان جلسه به `planned` برمی‌گردد و بقیهٔ دوره دست‌نخورده می‌ماند. - * - * @return bool `false` یعنی این نوبت اصلاً جزو دوره‌ای نبود - */ - public function unlink(Appointment $appointment): bool - { - $session = $this->sessions->findForAppointment($appointment); - - if ($session === null) { - return false; - } - - $session->unbook(); - $appointment->setCourseSession(null); - - $this->em->flush(); - - return true; - } - - /** - * جلسه انجام شد. دوره وقتی کامل می‌شود که **همهٔ** جلساتش تمام شده باشند — - * نه وقتی آخرین جلسه رزرو شد. - */ - public function complete(Appointment $appointment, ?int $at = null): bool - { - $session = $this->sessions->findForAppointment($appointment); - - if ($session === null) { - return false; - } - - $session->markCompleted($at); - - $course = $session->getCourse(); - - $this->events->record( - $course->getEntityType(), - $course->getEntityId(), - DomainEvents::COURSE_SESSION_COMPLETED, - [ - 'course_uuid' => $course->getUuid(), - 'session_uuid' => $session->getUuid(), - 'session_number' => $session->getSessionNumber(), - ], - $at, - ); - - if ($course->completedCount() >= $course->getSessionCount()) { - $course->complete($at); - - $this->events->record( - $course->getEntityType(), - $course->getEntityId(), - DomainEvents::COURSE_COMPLETED, - ['course_uuid' => $course->getUuid()], - $at, - ); - } - - $this->em->flush(); - - return true; - } - - public function courseOf(Appointment $appointment): ?TreatmentCourse - { - return $this->sessions->findForAppointment($appointment)?->getCourse(); - } -} diff --git a/src/Course/Service/CourseStarter.php b/src/Course/Service/CourseStarter.php deleted file mode 100644 index a0673006..00000000 --- a/src/Course/Service/CourseStarter.php +++ /dev/null @@ -1,95 +0,0 @@ -isActive()) { - throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این پروتکل غیرفعال است', 422, 'protocol_uuid'); - } - - if ($patient->getEntityType() !== $protocol->getEntityType() - || $patient->getEntityId() !== $protocol->getEntityId() - ) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمار یافت نشد', 404); - } - - $existing = $this->courses->findActiveFor($patient, $protocol->getServiceItem()); - - if ($existing !== null) { - // پیام شامل شناسهٔ دورهٔ موجود است تا اپراتور بتواند مستقیم برود سراغش، - // نه اینکه دنبالش بگردد. - throw new AppException( - ErrorCodes::ERR_VALIDATION_001, - sprintf('این بیمار یک دورهٔ فعال برای همین خدمت دارد (%s)', $existing->getUuid()), - 422, - 'course_uuid', - ); - } - - $course = new TreatmentCourse($patient, $protocol); - - if ($package !== null) { - $this->assertPackageCovers($package, $protocol); - $course->setPatientPackage($package); - } - - for ($number = 1; $number <= $protocol->getSessionCount(); $number++) { - new CourseSession($course, $number, $protocol->paramsFor($number)); - } - - $this->events->record( - $course->getEntityType(), - $course->getEntityId(), - DomainEvents::COURSE_STARTED, - ['course_uuid' => $course->getUuid(), 'session_count' => $course->getSessionCount()], - ); - - $this->courses->save($course); - - return $course; - } - - /** پکیجی که این خدمت را پوشش نمی‌دهد، به این دوره وصل نمی‌شود. */ - private function assertPackageCovers(PatientPackage $package, CourseProtocol $protocol): void - { - $serviceId = (int) $protocol->getServiceItem()->getId(); - - if (!in_array($serviceId, $package->getPackage()->serviceIds(), true)) { - throw new AppException( - ErrorCodes::ERR_VALIDATION_001, - 'پکیج انتخاب‌شده این خدمت را پوشش نمی‌دهد', - 422, - 'patient_package_uuid', - ); - } - } -} diff --git a/src/Package/Command/ExpirePackagesCommand.php b/src/Package/Command/ExpirePackagesCommand.php deleted file mode 100644 index acb38120..00000000 --- a/src/Package/Command/ExpirePackagesCommand.php +++ /dev/null @@ -1,68 +0,0 @@ -addOption('dry-run', null, InputOption::VALUE_NONE, 'Report without writing'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - $dryRun = (bool) $input->getOption('dry-run'); - $expired = 0; - - foreach ($this->packages->findExpiredSince(time()) as $package) { - $balance = $this->ledger->balance($package); - - if ($balance <= 0) { - continue; - } - - if (!$dryRun) { - $this->ledger->record( - $package, - SessionCreditLedger::KIND_EXPIRY, - -$balance, - reason: 'انقضای اعتبار پکیج', - ); - } - - $expired++; - } - - $io->success(sprintf( - $dryRun ? '%d پکیج منقضی می‌شد.' : '%d پکیج منقضی شد.', - $expired, - )); - - return Command::SUCCESS; - } -} diff --git a/src/Package/Controller/PackageController.php b/src/Package/Controller/PackageController.php deleted file mode 100644 index d404f4b9..00000000 --- a/src/Package/Controller/PackageController.php +++ /dev/null @@ -1,206 +0,0 @@ -branches->pair($user); - - $active = $request->query->has('active') - ? $request->query->getBoolean('active') - : null; - - return $this->success(array_map( - static fn (Package $p): array => $p->toArray(), - $this->packages->findForPair($entityType, $entityId, $active), - )); - } - - #[Route('/api/v1/packages', name: 'package_create', methods: ['POST'])] - public function create(#[CurrentUser] User $user, Request $request): JsonResponse - { - $data = json_decode($request->getContent(), true); - - if (!is_array($data) || !is_string($data['name'] ?? null) || trim($data['name']) === '') { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام پکیج الزامی است', 422, 'name'); - } - - if (!is_numeric($data['session_count'] ?? null) || (int) $data['session_count'] < 1) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعداد جلسه باید حداقل ۱ باشد', 422, 'session_count'); - } - - $services = $this->resolveServices($user, $data['service_uuids'] ?? []); - - // پکیجی که هیچ سرویسی را پوشش نمی‌دهد هرگز قابل مصرف نیست؛ ساختنش فقط - // یک تلهٔ خاموش برای اپراتور است. - if ($services === []) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پکیج باید حداقل یک سرویس داشته باشد', 422, 'service_uuids'); - } - - [$entityType, $entityId] = $this->branches->pair($user); - - $package = new Package($entityType, $entityId, trim($data['name']), (int) $data['session_count']); - $this->apply($package, $data); - - foreach ($services as $service) { - $this->em->persist(new PackageService($package, $service)); - } - - $this->packages->save($package); - - return $this->success($package->toArray(), 201); - } - - #[Route('/api/v1/package/{uuid}', name: 'package_show', methods: ['GET'])] - public function show(#[CurrentUser] User $user, string $uuid): JsonResponse - { - return $this->success($this->requirePackage($user, $uuid)->toArray()); - } - - #[Route('/api/v1/package/{uuid}', name: 'package_update', methods: ['PATCH'])] - public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse - { - $package = $this->requirePackage($user, $uuid); - $data = json_decode($request->getContent(), true); - - if (!is_array($data)) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); - } - - if (is_string($data['name'] ?? null) && trim($data['name']) !== '') { - $package->setName(trim($data['name'])); - } - - if (is_numeric($data['session_count'] ?? null)) { - $package->setSessionCount((int) $data['session_count']); - } - - $this->apply($package, $data); - - if (isset($data['service_uuids'])) { - $services = $this->resolveServices($user, $data['service_uuids']); - - if ($services === []) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پکیج باید حداقل یک سرویس داشته باشد', 422, 'service_uuids'); - } - - $package->getServices()->clear(); - - foreach ($services as $service) { - $this->em->persist(new PackageService($package, $service)); - } - } - - $this->packages->save($package); - - return $this->success($package->toArray()); - } - - /** - * حذف = غیرفعال کردن. - * - * پکیجی که فروخته شده حذف‌شدنی نیست؛ ردیف‌های دفتر به آن ارجاع دارند و حذفش - * یعنی تاریخچهٔ اعتبار بیماران بی‌معنا شود. - */ - #[Route('/api/v1/package/{uuid}', name: 'package_delete', methods: ['DELETE'])] - public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse - { - $package = $this->requirePackage($user, $uuid)->setActive(false); - $this->packages->save($package); - - return $this->success($package->toArray()); - } - - /** @param array $data */ - private function apply(Package $package, array $data): void - { - if (is_numeric($data['price_rials'] ?? null)) { - $package->setPriceRials((int) $data['price_rials']); - } - - if (array_key_exists('validity_days', $data)) { - $package->setValidityDays(is_numeric($data['validity_days']) ? (int) $data['validity_days'] : null); - } - - if (isset($data['active'])) { - $package->setActive((bool) $data['active']); - } - } - - /** - * @param mixed $uuids - * @return list - */ - private function resolveServices(User $user, mixed $uuids): array - { - if (!is_array($uuids)) { - return []; - } - - [$entityType, $entityId] = $this->branches->pair($user); - $services = []; - - foreach ($uuids as $uuid) { - if (!is_string($uuid)) { - continue; - } - - $item = $this->items->findByUuid($uuid); - - if ($item === null - || $item->getSection()->getEntityType() !== $entityType - || $item->getSection()->getEntityId() !== $entityId - ) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); - } - - $services[(int) $item->getId()] = $item; - } - - return array_values($services); - } - - private function requirePackage(User $user, string $uuid): Package - { - $package = $this->packages->findByUuid($uuid); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($package === null || !$this->ownership->belongsToPair($entityType, $entityId, $package)) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404); - } - - return $package; - } -} diff --git a/src/Package/Controller/PatientPackageController.php b/src/Package/Controller/PatientPackageController.php deleted file mode 100644 index 610980c9..00000000 --- a/src/Package/Controller/PatientPackageController.php +++ /dev/null @@ -1,202 +0,0 @@ -requirePatient($user, $uuid); - $data = json_decode($request->getContent(), true); - - if (!is_array($data) || !is_string($data['package_uuid'] ?? null)) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شناسهٔ پکیج الزامی است', 422, 'package_uuid'); - } - - $package = $this->packages->findByUuid($data['package_uuid']); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($package === null || !$this->ownership->belongsToPair($entityType, $entityId, $package)) { - return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404); - } - - $sold = $this->sales->sell( - $package, - $patient, - $user, - is_numeric($data['price_paid_rials'] ?? null) ? (int) $data['price_paid_rials'] : null, - ); - - return $this->success($sold->toArray($this->ledger->balance($sold)), 201); - } - - #[Route('/api/v1/patient/{uuid}/packages', name: 'patient_package_index', methods: ['GET'])] - public function index(#[CurrentUser] User $user, string $uuid): JsonResponse - { - $patient = $this->requirePatient($user, $uuid); - - return $this->success(array_map( - fn (PatientPackage $p): array => $p->toArray($this->ledger->balance($p)), - $this->patientPackages->findForPatient($patient), - )); - } - - /** - * دفتر تراکنش‌ها با ماندهٔ تجمعی. - * - * ماندهٔ تجمعی اینجا محاسبه می‌شود نه ذخیره — و همین به کاربر نشان می‌دهد عدد - * از کجا آمده. - */ - #[Route('/api/v1/patient-package/{uuid}/ledger', name: 'patient_package_ledger', methods: ['GET'])] - public function ledger(#[CurrentUser] User $user, string $uuid): JsonResponse - { - $package = $this->requirePatientPackage($user, $uuid); - - $running = 0; - $rows = []; - - foreach ($this->ledger->history($package) as $row) { - $running += $row->getDelta(); - $rows[] = $row->toArray() + ['running_balance' => $running]; - } - - return $this->success([ - 'package' => $package->toArray($running), - 'rows' => $rows, - ]); - } - - /** اصلاح دستی — فقط پزشک یا صاحب کلینیک، و همیشه با دلیل. */ - #[Route('/api/v1/patient-package/{uuid}/adjust', name: 'patient_package_adjust', methods: ['POST'])] - public function adjust(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse - { - $this->assertMayCorrectCredit(); - - $package = $this->requirePatientPackage($user, $uuid); - $data = json_decode($request->getContent(), true); - - if (!is_array($data) || !is_numeric($data['delta'] ?? null) || (int) $data['delta'] === 0) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'مقدار اصلاح باید عددی غیر صفر باشد', 422, 'delta'); - } - - if (!is_string($data['reason'] ?? null) || trim($data['reason']) === '') { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دلیل اصلاح الزامی است', 422, 'reason'); - } - - $delta = (int) $data['delta']; - - // اصلاحی که مانده را منفی کند یعنی دفتر دروغ بگوید. - if ($this->ledger->balance($package) + $delta < 0) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مانده نمی‌تواند منفی شود', 422, 'delta'); - } - - $this->ledger->record( - $package, - SessionCreditLedger::KIND_ADJUSTMENT, - $delta, - reason: trim($data['reason']), - by: $user, - ); - - return $this->success($package->toArray($this->ledger->balance($package)), 201); - } - - /** ابطال دستی — ماندهٔ باقی‌مانده با یک ردیف `expiry` صفر می‌شود. */ - #[Route('/api/v1/patient-package/{uuid}/expire', name: 'patient_package_expire', methods: ['POST'])] - public function expire(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse - { - $this->assertMayCorrectCredit(); - - $package = $this->requirePatientPackage($user, $uuid); - $balance = $this->ledger->balance($package); - - if ($balance <= 0) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پکیج ماندهٔ قابل ابطال ندارد', 422); - } - - $data = json_decode($request->getContent(), true); - $reason = is_array($data) && is_string($data['reason'] ?? null) && trim($data['reason']) !== '' - ? trim($data['reason']) - : 'ابطال دستی پکیج'; - - $this->ledger->record($package, SessionCreditLedger::KIND_EXPIRY, -$balance, reason: $reason, by: $user); - - return $this->success($package->toArray($this->ledger->balance($package))); - } - - /** - * اصلاح دستی اعتبار کارِ صاحب محیط است، نه منشی: ردیف `adjustment` تنها راهی است - * که می‌شود بدون نوبت، اعتبار ساخت. - */ - private function assertMayCorrectCredit(): void - { - foreach (['ROLE_DOCTOR', 'ROLE_CLINIC', 'ROLE_ADMIN'] as $role) { - if ($this->isGranted($role)) { - return; - } - } - - throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'اصلاح اعتبار در اختیار شما نیست', 403); - } - - private function requirePatient(User $user, string $uuid): 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; - } - - private function requirePatientPackage(User $user, string $uuid): PatientPackage - { - $package = $this->patientPackages->findByUuid($uuid); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($package === null || !$this->ownership->belongsToPair($entityType, $entityId, $package)) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج بیمار یافت نشد', 404); - } - - return $package; - } -} diff --git a/src/Package/Entity/Package.php b/src/Package/Entity/Package.php deleted file mode 100644 index 08c0dbe5..00000000 --- a/src/Package/Entity/Package.php +++ /dev/null @@ -1,142 +0,0 @@ - true])] - private bool $active = true; - - /** @var Collection */ - #[ORM\OneToMany(targetEntity: PackageService::class, mappedBy: 'package', cascade: ['persist', 'remove'], orphanRemoval: true)] - private Collection $services; - - #[ORM\Column(name: 'created_at', type: 'integer')] - private int $createdAt; - - #[ORM\Column(name: 'updated_at', type: 'integer')] - private int $updatedAt; - - public function __construct(string $entityType, int $entityId, string $name, int $sessionCount) - { - $this->uuid = Uuid::v4()->toRfc4122(); - $this->name = $name; - $this->sessionCount = max(1, $sessionCount); - $this->services = new ArrayCollection(); - $this->createdAt = time(); - $this->updatedAt = time(); - - $this->assignTenantPair($entityType, $entityId); - } - - public function getId(): ?int { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getName(): string { return $this->name; } - public function getSessionCount(): int { return $this->sessionCount; } - public function getPriceRials(): int { return (int) $this->priceRials; } - public function getValidityDays(): ?int { return $this->validityDays; } - public function isActive(): bool { return $this->active; } - public function getCreatedAt(): int { return $this->createdAt; } - - /** @return Collection */ - public function getServices(): Collection { return $this->services; } - - public function setName(string $v): self { $this->name = $v; return $this->touch(); } - public function setSessionCount(int $v): self { $this->sessionCount = max(1, $v); return $this->touch(); } - public function setPriceRials(int $v): self { $this->priceRials = max(0, $v); return $this->touch(); } - public function setValidityDays(?int $v): self { $this->validityDays = $v === null ? null : max(1, $v); return $this->touch(); } - public function setActive(bool $v): self { $this->active = $v; return $this->touch(); } - - public function addService(PackageService $service): self - { - if (!$this->services->contains($service)) { - $this->services->add($service); - } - - return $this; - } - - /** تاریخ انقضای یک خرید در این لحظه — `null` یعنی بی‌پایان. */ - public function expiryFor(int $purchasedAt): ?int - { - return $this->validityDays === null ? null : $purchasedAt + $this->validityDays * 86400; - } - - /** @return list شناسهٔ سرویس‌های پوشش‌داده‌شده */ - public function serviceIds(): array - { - return array_values(array_map( - static fn (PackageService $s): int => (int) $s->getServiceItem()->getId(), - $this->services->toArray(), - )); - } - - private function touch(): self - { - $this->updatedAt = time(); - - return $this; - } - - /** @return array */ - public function toArray(): array - { - return [ - 'uuid' => $this->uuid, - 'name' => $this->name, - 'session_count' => $this->sessionCount, - 'price_rials' => (int) $this->priceRials, - 'validity_days' => $this->validityDays, - 'active' => $this->active, - 'services' => array_values(array_map( - static fn (PackageService $s): array => [ - 'uuid' => $s->getServiceItem()->getUuid(), - 'name' => $s->getServiceItem()->getName(), - ], - $this->services->toArray(), - )), - 'created_at' => $this->createdAt, - ]; - } -} diff --git a/src/Package/Entity/PackageService.php b/src/Package/Entity/PackageService.php deleted file mode 100644 index 9365cdec..00000000 --- a/src/Package/Entity/PackageService.php +++ /dev/null @@ -1,43 +0,0 @@ -package = $package; - $this->serviceItem = $serviceItem; - - $package->addService($this); - } - - public function getId(): ?int { return $this->id; } - public function getPackage(): Package { return $this->package; } - public function getServiceItem(): ServiceItem { return $this->serviceItem; } -} diff --git a/src/Package/Entity/PatientPackage.php b/src/Package/Entity/PatientPackage.php deleted file mode 100644 index a77424ab..00000000 --- a/src/Package/Entity/PatientPackage.php +++ /dev/null @@ -1,123 +0,0 @@ -uuid = Uuid::v4()->toRfc4122(); - $this->package = $package; - $this->patientRecord = $patientRecord; - $this->purchasedAt = $purchasedAt ?? time(); - $this->sessionCount = $package->getSessionCount(); - $this->pricePaidRials = $package->getPriceRials(); - $this->validTo = $package->expiryFor($this->purchasedAt); - $this->createdAt = time(); - $this->updatedAt = time(); - - $this->assignTenantPair($package->getEntityType(), $package->getEntityId()); - } - - public function getId(): ?int { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getPackage(): Package { return $this->package; } - public function getPatientRecord(): PatientRecord { return $this->patientRecord; } - public function getSessionCount(): int { return $this->sessionCount; } - public function getPricePaidRials(): int { return (int) $this->pricePaidRials; } - public function getPayment(): ?Payment { return $this->payment; } - public function getPurchasedAt(): int { return $this->purchasedAt; } - public function getValidTo(): ?int { return $this->validTo; } - - public function setPricePaidRials(int $v): self { $this->pricePaidRials = max(0, $v); $this->updatedAt = time(); return $this; } - public function setPayment(?Payment $v): self { $this->payment = $v; $this->updatedAt = time(); return $this; } - - public function isExpired(?int $at = null): bool - { - return $this->validTo !== null && $this->validTo < ($at ?? time()); - } - - /** - * @param int $balance مانده‌ای که فراخوان از دفتر گرفته — عمداً پارامتر است، نه - * چیزی که این کلاس خودش بداند - * @return array - */ - public function toArray(int $balance): array - { - return [ - 'uuid' => $this->uuid, - 'package_uuid' => $this->package->getUuid(), - 'package_name' => $this->package->getName(), - 'patient_uuid' => $this->patientRecord->getUuid(), - 'session_count' => $this->sessionCount, - 'price_paid_rials' => (int) $this->pricePaidRials, - 'purchased_at' => $this->purchasedAt, - 'valid_to' => $this->validTo, - 'expired' => $this->isExpired(), - // مانده در نمایشِ پکیج منقضی صفر است، حتی اگر ردیف `expiry` هنوز ثبت - // نشده باشد؛ دفتر خودش دست‌نخورده می‌ماند. - 'balance' => $this->isExpired() ? 0 : $balance, - ]; - } -} diff --git a/src/Package/Entity/SessionCreditLedger.php b/src/Package/Entity/SessionCreditLedger.php deleted file mode 100644 index a7f461c6..00000000 --- a/src/Package/Entity/SessionCreditLedger.php +++ /dev/null @@ -1,140 +0,0 @@ -uuid = Uuid::v4()->toRfc4122(); - $this->patientPackage = $patientPackage; - $this->kind = $kind; - $this->delta = $delta; - $this->appointment = $appointment; - $this->serviceItem = $serviceItem; - $this->reason = $reason; - $this->createdBy = $createdBy; - $this->createdAt = time(); - - $this->assignTenantPair($patientPackage->getEntityType(), $patientPackage->getEntityId()); - } - - public function getId(): ?string { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getPatientPackage(): PatientPackage { return $this->patientPackage; } - public function getKind(): string { return $this->kind; } - public function getDelta(): int { return $this->delta; } - public function getAppointment(): ?Appointment { return $this->appointment; } - public function getServiceItem(): ?ServiceItem { return $this->serviceItem; } - public function getReason(): ?string { return $this->reason; } - public function getCreatedBy(): ?User { return $this->createdBy; } - public function getCreatedAt(): int { return $this->createdAt; } - - /** @return array */ - public function toArray(): array - { - return [ - 'uuid' => $this->uuid, - 'kind' => $this->kind, - 'delta' => $this->delta, - 'appointment_uuid' => $this->appointment?->getUuid(), - 'service_uuid' => $this->serviceItem?->getUuid(), - 'service_name' => $this->serviceItem?->getName(), - 'reason' => $this->reason, - 'created_by' => $this->createdBy?->getUuid(), - 'created_at' => $this->createdAt, - ]; - } -} diff --git a/src/Package/Repository/PackageRepository.php b/src/Package/Repository/PackageRepository.php deleted file mode 100644 index 4041e0f3..00000000 --- a/src/Package/Repository/PackageRepository.php +++ /dev/null @@ -1,50 +0,0 @@ - */ -class PackageRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, Package::class); - } - - public function findByUuid(string $uuid): ?Package - { - return $this->findOneBy(['uuid' => $uuid]); - } - - /** @return Package[] */ - public function findForPair(string $entityType, int $entityId, ?bool $active = null): array - { - $qb = $this->createQueryBuilder('p') - ->addSelect('s', 'i') - ->leftJoin('p.services', 's') - ->leftJoin('s.serviceItem', 'i') - ->where('p.entityType = :type') - ->andWhere('p.entityId = :id') - ->setParameter('type', $entityType) - ->setParameter('id', $entityId) - ->orderBy('p.createdAt', 'DESC'); - - if ($active !== null) { - $qb->andWhere('p.active = :active')->setParameter('active', $active); - } - - return $qb->getQuery()->getResult(); - } - - public function save(Package $package, bool $flush = true): void - { - $this->getEntityManager()->persist($package); - - if ($flush) { - $this->getEntityManager()->flush(); - } - } -} diff --git a/src/Package/Repository/PatientPackageRepository.php b/src/Package/Repository/PatientPackageRepository.php deleted file mode 100644 index 678f30da..00000000 --- a/src/Package/Repository/PatientPackageRepository.php +++ /dev/null @@ -1,81 +0,0 @@ - */ -class PatientPackageRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, PatientPackage::class); - } - - public function findByUuid(string $uuid): ?PatientPackage - { - return $this->findOneBy(['uuid' => $uuid]); - } - - /** @return PatientPackage[] جدیدترین خرید اول */ - public function findForPatient(PatientRecord $patient): array - { - return $this->createQueryBuilder('pp') - ->addSelect('p') - ->join('pp.package', 'p') - ->where('pp.patientRecord = :patient') - ->setParameter('patient', $patient) - ->orderBy('pp.purchasedAt', 'DESC') - ->getQuery() - ->getResult(); - } - - /** - * پکیج‌های معتبرِ این بیمار که سرویس داده‌شده را پوشش می‌دهند — **قدیمی‌ترین اول**. - * - * FIFO عمدی است: پکیج قدیمی‌تر به انقضا نزدیک‌تر است، و مصرف نکردنش یعنی بیمار - * پولش را از دست بدهد. - * - * @return PatientPackage[] - */ - public function findUsable(PatientRecord $patient, ServiceItem $service, int $at): array - { - return $this->createQueryBuilder('pp') - ->join('pp.package', 'p') - ->join('p.services', 'ps') - ->where('pp.patientRecord = :patient') - ->andWhere('ps.serviceItem = :service') - ->andWhere('pp.validTo IS NULL OR pp.validTo >= :now') - ->setParameter('patient', $patient) - ->setParameter('service', $service) - ->setParameter('now', $at) - ->orderBy('pp.purchasedAt', 'ASC') - ->addOrderBy('pp.id', 'ASC') - ->getQuery() - ->getResult(); - } - - /** @return PatientPackage[] پکیج‌هایی که تاریخشان گذشته */ - public function findExpiredSince(int $now): array - { - return $this->createQueryBuilder('pp') - ->where('pp.validTo IS NOT NULL') - ->andWhere('pp.validTo < :now') - ->setParameter('now', $now) - ->getQuery() - ->getResult(); - } - - public function save(PatientPackage $entity, bool $flush = true): void - { - $this->getEntityManager()->persist($entity); - - if ($flush) { - $this->getEntityManager()->flush(); - } - } -} diff --git a/src/Package/Repository/SessionCreditLedgerRepository.php b/src/Package/Repository/SessionCreditLedgerRepository.php deleted file mode 100644 index c8e09e31..00000000 --- a/src/Package/Repository/SessionCreditLedgerRepository.php +++ /dev/null @@ -1,46 +0,0 @@ - */ -class SessionCreditLedgerRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, SessionCreditLedger::class); - } - - /** مانده = جمع همهٔ delta ها. هیچ ستون ذخیره‌شده‌ای وجود ندارد. */ - public function sumDelta(PatientPackage $package): int - { - return (int) $this->createQueryBuilder('l') - ->select('COALESCE(SUM(l.delta), 0)') - ->where('l.patientPackage = :package') - ->setParameter('package', $package) - ->getQuery() - ->getSingleScalarResult(); - } - - /** @return SessionCreditLedger[] قدیمی‌ترین اول — دفتر به ترتیب زمان خوانده می‌شود */ - public function historyFor(PatientPackage $package): array - { - return $this->createQueryBuilder('l') - ->where('l.patientPackage = :package') - ->setParameter('package', $package) - ->orderBy('l.createdAt', 'ASC') - ->addOrderBy('l.id', 'ASC') - ->getQuery() - ->getResult(); - } - - public function findForAppointment(Appointment $appointment, string $kind): ?SessionCreditLedger - { - return $this->findOneBy(['appointment' => $appointment, 'kind' => $kind]); - } -} diff --git a/src/Package/Service/CreditLedgerService.php b/src/Package/Service/CreditLedgerService.php deleted file mode 100644 index 87d32cf6..00000000 --- a/src/Package/Service/CreditLedgerService.php +++ /dev/null @@ -1,174 +0,0 @@ -ledger->sumDelta($package); - } - - public function record( - PatientPackage $package, - string $kind, - int $delta, - ?Appointment $appointment = null, - ?ServiceItem $service = null, - ?string $reason = null, - ?User $by = null, - bool $flush = true, - ): SessionCreditLedger { - $row = new SessionCreditLedger($package, $kind, $delta, $appointment, $service, $reason, $by); - - $this->em->persist($row); - - if ($flush) { - $this->em->flush(); - } - - return $row; - } - - /** - * مصرف یک جلسه — `false` یعنی «اعتباری نبود»، نه خطا. - * - * بیمار بدون اعتبار باید بتواند نقدی بپردازد؛ استثنا پرتاب کردن اینجا یعنی - * رزروِ کاملاً معتبر شکست بخورد. - * - * قفل بدبینانه روی همان یک ردیف پکیج است. برخلاف اسلات‌های تسک ۰۷ — که نرخ رقابت - * بالا و ده‌ها ردیف درگیر دارند — اینجا یک بیمار و یک پکیج است، پس هزینهٔ قفل - * ناچیز و سادگی‌اش برنده است. - */ - public function consume(PatientPackage $package, Appointment $appointment, ?ServiceItem $service = null): bool - { - try { - return $this->consumeOnce($package, $appointment, $service); - } catch (UniqueConstraintViolationException) { - // دو درخواست هم‌زمان برای یک نوبت: کلید یکتا دومی را رد کرد و همین درست - // است — یک جلسه خورده شده. - // - // ولی Doctrine روی نقض کلید **خودِ EntityManager را می‌بندد**، و مدیرِ بسته - // بقیهٔ همین request را هم می‌سوزاند. بازنشانی رجیستری تنها راه زنده ماندن - // است؛ بدون آن، «مصرف تکراری» به یک خطای ۵۰۰ بی‌ربط تبدیل می‌شد. - $this->registry->resetManager(); - - return true; - } - } - - private function consumeOnce(PatientPackage $package, Appointment $appointment, ?ServiceItem $service): bool - { - // قفل بدون تراکنش معنا ندارد؛ خواندن و نوشتن باید در یک واحد اتمی باشند - // وگرنه دو درخواست هم‌زمان هر دو ماندهٔ ۱ را می‌بینند. - return $this->em->wrapInTransaction(function () use ($package, $appointment, $service): bool { - $locked = $this->em->find(PatientPackage::class, $package->getId(), LockMode::PESSIMISTIC_WRITE); - - if ($locked === null || $locked->isExpired()) { - return false; - } - - // `confirm` idempotent است و اجرای دومش نباید جلسهٔ دوم بخورد. - // - // بررسی پیش از درج **تنها** تکیه‌گاه نیست: بین این خواندن و آن نوشتن هنوز - // یک پنجرهٔ رقابت هست و تنها چیزی که واقعاً می‌بندد کلید یکتاست. پس هر دو - // را داریم — بررسی برای مسیر عادی، و `catch` برای رقابت واقعی. - if ($this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_CONSUME) !== null) { - return true; - } - - if ($this->balance($locked) <= 0) { - return false; - } - - $this->record($locked, SessionCreditLedger::KIND_CONSUME, -1, $appointment, $service); - - $this->events->recordAndFlush( - $locked->getEntityType(), - $locked->getEntityId(), - DomainEvents::CREDIT_CONSUMED, - ['patient_package_uuid' => $locked->getUuid(), 'appointment_uuid' => $appointment->getUuid()], - ); - - return true; - }); - } - - /** - * بازگشت اعتبار هنگام لغو — ردیف `consume` **حذف نمی‌شود**. - * - * فعلاً هر لغوی اعتبار را کامل برمی‌گرداند. سیاست واقعی (لغو دیرهنگام، جریمه، - * عدم‌حضور) کارِ تسک ۱۳ است و همان‌جا این متد یک پارامتر سیاست می‌گیرد؛ پرچم - * نیم‌کاره اینجا فقط رفتاری می‌ساخت که هیچ‌کس تنظیمش نمی‌کند. - * - * @return bool `false` یعنی این نوبت اصلاً از پکیج مصرف نکرده بود - */ - public function refund(Appointment $appointment, ?User $by = null): bool - { - $consumed = $this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_CONSUME); - - if ($consumed === null) { - return false; - } - - if ($this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_REFUND) !== null) { - return false; - } - - $this->record( - $consumed->getPatientPackage(), - SessionCreditLedger::KIND_REFUND, - -$consumed->getDelta(), - $appointment, - $consumed->getServiceItem(), - 'بازگشت اعتبار با لغو نوبت', - $by, - ); - - $this->events->recordAndFlush( - $consumed->getPatientPackage()->getEntityType(), - $consumed->getPatientPackage()->getEntityId(), - DomainEvents::CREDIT_REFUNDED, - [ - 'patient_package_uuid' => $consumed->getPatientPackage()->getUuid(), - 'appointment_uuid' => $appointment->getUuid(), - ], - ); - - return true; - } - - /** @return SessionCreditLedger[] */ - public function history(PatientPackage $package): array - { - return $this->ledger->historyFor($package); - } -} diff --git a/src/Package/Service/PackageConsumptionService.php b/src/Package/Service/PackageConsumptionService.php deleted file mode 100644 index a0671dca..00000000 --- a/src/Package/Service/PackageConsumptionService.php +++ /dev/null @@ -1,74 +0,0 @@ -patientPackages->findUsable($patient, $service, $at) as $candidate) { - if ($this->ledger->balance($candidate) > 0) { - return $candidate; - } - } - - return null; - } - - /** - * پروندهٔ بیمار در همین محیط — پکیج کلینیک الف در کلینیک ب معنا ندارد. - */ - public function patientRecordFor(Appointment $appointment): ?PatientRecord - { - return $this->patients->findOneBy([ - 'user' => $appointment->getUser(), - 'entityType' => $appointment->getEntityType(), - 'entityId' => $appointment->getEntityId(), - ]); - } - - /** - * مصرف واقعی هنگام ثبت نهایی. - * - * @return bool `true` یعنی یک جلسه کسر شد - */ - public function consumeFor(Appointment $appointment): bool - { - $service = $appointment->getServiceItem(); - $patient = $service === null ? null : $this->patientRecordFor($appointment); - - if ($service === null || $patient === null) { - return false; - } - - $package = $this->firstUsable($patient, $service, $appointment->getSlotStart()); - - if ($package === null) { - return false; - } - - return $this->ledger->consume($package, $appointment, $service); - } -} diff --git a/src/Package/Service/PackageSalesService.php b/src/Package/Service/PackageSalesService.php deleted file mode 100644 index c352b943..00000000 --- a/src/Package/Service/PackageSalesService.php +++ /dev/null @@ -1,73 +0,0 @@ -isActive()) { - throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این پکیج غیرفعال است', 422, 'package_uuid'); - } - - if ($package->getServices()->isEmpty()) { - throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'پکیج بدون سرویس قابل فروش نیست', 422, 'services'); - } - - if ($patient->getEntityType() !== $package->getEntityType() || $patient->getEntityId() !== $package->getEntityId()) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمار یافت نشد', 404); - } - - $sold = new PatientPackage($package, $patient); - - if ($pricePaid !== null) { - $sold->setPricePaidRials($pricePaid); - } - - $this->patientPackages->save($sold); - - $this->ledger->record( - $sold, - SessionCreditLedger::KIND_PURCHASE, - $sold->getSessionCount(), - reason: sprintf('خرید پکیج «%s»', $package->getName()), - by: $by, - ); - - $this->events->recordAndFlush( - $sold->getEntityType(), - $sold->getEntityId(), - DomainEvents::PACKAGE_PURCHASED, - [ - 'patient_package_uuid' => $sold->getUuid(), - 'package_uuid' => $package->getUuid(), - 'session_count' => $sold->getSessionCount(), - ], - ); - - return $sold; - } -} diff --git a/src/Policy/Command/PruneSimulationsCommand.php b/src/Policy/Command/PruneSimulationsCommand.php deleted file mode 100644 index c7d84ff7..00000000 --- a/src/Policy/Command/PruneSimulationsCommand.php +++ /dev/null @@ -1,77 +0,0 @@ -addOption('days', null, InputOption::VALUE_REQUIRED, 'Delete runs older than this many days', '90') - ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would be deleted without deleting'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - $days = max(1, (int) $input->getOption('days')); - $before = time() - $days * 86400; - - $sql = <<<'SQL' - SELECT r.id - FROM policy_simulation_runs r - WHERE r.created_at < :before - AND r.id NOT IN ( - SELECT keep_id FROM ( - SELECT MAX(id) AS keep_id - FROM policy_simulation_runs - GROUP BY policy_id, policy_version - ) AS keepers - ) - SQL; - - $ids = $this->connection->fetchFirstColumn($sql, ['before' => $before]); - - if ($ids === []) { - $io->success('هیچ اجرای آزمایشیِ قابل حذفی نیست.'); - - return Command::SUCCESS; - } - - if ($input->getOption('dry-run')) { - $io->note(sprintf('%d اجرای آزمایشی حذف می‌شد.', count($ids))); - - return Command::SUCCESS; - } - - $this->connection->executeStatement( - 'DELETE FROM policy_simulation_runs WHERE id IN (:ids)', - ['ids' => $ids], - ['ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER], - ); - - $io->success(sprintf('%d اجرای آزمایشی حذف شد.', count($ids))); - - return Command::SUCCESS; - } -} diff --git a/src/Policy/Controller/PolicyController.php b/src/Policy/Controller/PolicyController.php deleted file mode 100644 index e0fb7779..00000000 --- a/src/Policy/Controller/PolicyController.php +++ /dev/null @@ -1,288 +0,0 @@ -success($this->schema->describe()); - } - - #[Route('/api/v1/policies', name: 'policy_index', methods: ['GET'])] - public function index(#[CurrentUser] User $user, Request $request): JsonResponse - { - [$entityType, $entityId] = $this->branches->pair($user); - - $category = $request->query->get('category'); - - if (is_string($category) && $category !== '' && !in_array($category, Policy::CATEGORIES, true)) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'دستهٔ قانون نامعتبر است', 422, 'category'); - } - - return $this->success(array_map( - static fn (Policy $p): array => $p->toArray(), - $this->policies->findForPair($entityType, $entityId, is_string($category) && $category !== '' ? $category : null), - )); - } - - #[Route('/api/v1/policy', name: 'policy_create', methods: ['POST'])] - public function create(#[CurrentUser] User $user, Request $request): JsonResponse - { - $data = json_decode($request->getContent(), true); - - if (!is_array($data)) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); - } - - // الگو فقط `category`/`condition`/`effects` را از پیش پر می‌کند؛ اعتبارسنجی - // بعد از آن همان مسیر عادی است، پس الگو نمی‌تواند قانونِ نامعتبر بسازد. - if (is_string($data['template'] ?? null)) { - $data = array_merge($data, $this->templates->build($data['template'], $data['values'] ?? [])); - } - - if (!is_string($data['category'] ?? null) || !in_array($data['category'], Policy::CATEGORIES, true)) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'دستهٔ قانون نامعتبر است', 422, 'category'); - } - - if (!is_string($data['name'] ?? null) || trim($data['name']) === '') { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام قانون الزامی است', 422, 'name'); - } - - [$entityType, $entityId] = $this->branches->pair($user); - - $policy = new Policy($entityType, $entityId, $data['category'], trim($data['name'])); - $this->apply($user, $policy, $data); - - $this->em->persist($policy); - $this->em->flush(); - - $this->em->persist(new PolicyVersionLog($policy, $policy->getVersion(), $policy->toArray())); - $this->em->flush(); - - return $this->success($policy->toArray(), 201); - } - - #[Route('/api/v1/policy/{uuid}', name: 'policy_show', methods: ['GET'])] - public function show(#[CurrentUser] User $user, string $uuid): JsonResponse - { - $policy = $this->requirePolicy($user, $uuid); - - return $this->success($policy->toArray() + [ - 'versions' => array_map( - static fn (PolicyVersionLog $l): array => $l->toArray(), - $this->versions->findForPolicy($policy), - ), - ]); - } - - /** - * نسخهٔ جدید — قانون **ویرایش نمی‌شود**. - * - * نوبتی که دیروز ثبت شده نسخهٔ قبلی را در فاکتورش نگه داشته؛ بازنویسی درجا یعنی - * آن ارجاع به متنی اشاره کند که هرگز روی آن نوبت اعمال نشده بود. - */ - #[Route('/api/v1/policy/{uuid}/version', name: 'policy_version', methods: ['POST'])] - public function version(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse - { - $data = json_decode($request->getContent(), true); - $policy = $this->requirePolicy($user, $uuid); - - if (!is_array($data)) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); - } - - // شروعِ اعتبارِ عقب‌رونده روی نسخهٔ تازه یعنی قانونی که ادعا می‌کند از دیروز برقرار - // بوده، در حالی که نوبت‌های دیروز با متن قبلی حساب شده‌اند و ردپای قیمتشان به این - // نسخه اشاره می‌کند. روی نسخهٔ نخست آزاد است — هنوز چیزی بر اساسش تصمیم نگرفته‌ایم. - if (is_numeric($data['valid_from'] ?? null) && (int) $data['valid_from'] < time()) { - return $this->error( - ErrorCodes::ERR_VALIDATION_001, - 'شروع اعتبار نسخهٔ تازه نمی‌تواند در گذشته باشد؛ نوبت‌های گذشته با متن قبلی حساب شده‌اند', - 422, - 'valid_from', - ); - } - - $this->apply($user, $policy, $data); - $policy->bumpVersion(); - - $this->em->persist(new PolicyVersionLog($policy, $policy->getVersion(), $policy->toArray())); - $this->em->flush(); - - return $this->success($policy->toArray()); - } - - /** - * فعال‌سازی — فقط بعد از یک اجرای آزمایشیِ **همین نسخه**. - * - * آزمایش نسخهٔ ۱ اجازهٔ فعال‌سازی نسخهٔ ۲ را نمی‌دهد: کاربر متن قانون را عوض کرده و - * گزارشی که دیده دیگر توصیف این قانون نیست. - */ - #[Route('/api/v1/policy/{uuid}/activate', name: 'policy_activate', methods: ['POST'])] - public function activate(#[CurrentUser] User $user, string $uuid): JsonResponse - { - $policy = $this->requirePolicy($user, $uuid); - $run = $this->simulations->latestFor($policy); - - if ($run === null || $run->getPolicyVersion() !== $policy->getVersion()) { - return $this->error( - ErrorCodes::ERR_VALIDATION_001, - 'ابتدا قانون را آزمایش کنید و نتیجه را ببینید', - 422, - 'simulation', - ); - } - - $policy->setActive(true); - $this->em->flush(); - - return $this->success($policy->toArray()); - } - - #[Route('/api/v1/policy/{uuid}/deactivate', name: 'policy_deactivate', methods: ['POST'])] - public function deactivate(#[CurrentUser] User $user, string $uuid): JsonResponse - { - $policy = $this->requirePolicy($user, $uuid)->setActive(false); - $this->em->flush(); - - return $this->success($policy->toArray()); - } - - /** @param array $data */ - private function apply(User $user, Policy $policy, array $data): void - { - if (is_string($data['name'] ?? null) && trim($data['name']) !== '') { - $policy->setName(trim($data['name'])); - } - - if (is_array($data['condition'] ?? null)) { - // اعتبارسنجی در **زمان ساخت**: قانونی که موقع رزرو بیمار بترکد، بدترین - // جای ممکن برای شکستن است. - $this->evaluator->assertValid($policy->getCategory(), $data['condition']); - $policy->setCondition($data['condition']); - } - - if (is_array($data['effects'] ?? null)) { - $this->evaluator->assertEffectsValid($policy->getCategory(), $data['effects']); - $policy->setEffects(array_values($data['effects'])); - } - - if (is_numeric($data['priority'] ?? null)) { - $policy->setPriority((int) $data['priority']); - } - - if (array_key_exists('valid_from', $data) || array_key_exists('valid_to', $data)) { - $validFrom = is_numeric($data['valid_from'] ?? null) ? (int) $data['valid_from'] : null; - - try { - $policy->setValidity( - $validFrom, - is_numeric($data['valid_to'] ?? null) ? (int) $data['valid_to'] : null, - ); - } catch (\InvalidArgumentException) { - throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'پایان اعتبار باید بعد از شروع آن باشد', 422, 'valid_to'); - } - } - - if (array_key_exists('address_uuid', $data)) { - $policy->setAddress(is_string($data['address_uuid']) ? $this->branches->resolve($user, $data['address_uuid']) : null); - } - - if (array_key_exists('service_uuid', $data)) { - $policy->setServiceItem(is_string($data['service_uuid']) ? $this->requireItem($user, $data['service_uuid']) : null); - } - - if (array_key_exists('catalog_category_uuid', $data)) { - $policy->setCatalogCategory( - is_string($data['catalog_category_uuid']) ? $this->requireCategory($user, $data['catalog_category_uuid']) : null, - ); - } - } - - private function requirePolicy(User $user, string $uuid): Policy - { - $policy = $this->policies->findByUuid($uuid); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($policy === null || !$this->ownership->belongsToPair($entityType, $entityId, $policy)) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'قانون یافت نشد', 404); - } - - return $policy; - } - - private function requireItem(User $user, string $uuid): \App\ClinicService\Entity\ServiceItem - { - $item = $this->items->findByUuid($uuid); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($item === null - || $item->getSection()->getEntityType() !== $entityType - || $item->getSection()->getEntityId() !== $entityId - ) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); - } - - return $item; - } - - private function requireCategory(User $user, string $uuid): \App\ClinicService\Entity\CatalogCategory - { - $category = $this->categories->findByUuid($uuid); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($category === null || !$this->ownership->belongsToPair($entityType, $entityId, $category)) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'دسته یافت نشد', 404); - } - - return $category; - } -} diff --git a/src/Policy/Controller/PolicySimulationController.php b/src/Policy/Controller/PolicySimulationController.php deleted file mode 100644 index 2d7db945..00000000 --- a/src/Policy/Controller/PolicySimulationController.php +++ /dev/null @@ -1,96 +0,0 @@ -success($this->templates->describe()); - } - - /** - * اجرای آزمایشی روی نوبت‌های واقعی گذشته. هیچ چیزی جز خودِ نتیجه ثبت نمی‌شود. - */ - #[Route('/api/v1/policy/{uuid}/simulate', name: 'policy_simulate', methods: ['POST'])] - public function simulate(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse - { - $policy = $this->requirePolicy($user, $uuid); - $data = json_decode($request->getContent(), true); - - $size = is_array($data) && is_numeric($data['sample_size'] ?? null) - ? (int) $data['sample_size'] - : SimulationSampler::DEFAULT_SIZE; - - // سقف صریح است نه بی‌صدا: کاربری که ۵۰۰ خواسته باید بداند ۵۰ گرفته. - if ($size < 1 || $size > SimulationSampler::MAX_SIZE) { - return $this->error( - ErrorCodes::ERR_VALIDATION_001, - sprintf('اندازهٔ نمونه باید بین ۱ و %d باشد', SimulationSampler::MAX_SIZE), - 422, - 'sample_size', - ); - } - - $run = $this->simulator->simulate($policy, $size, $user); - - return $this->success($run->toArray(), 201); - } - - /** تاریخچهٔ اجراهای آزمایشی یک قانون. */ - #[Route('/api/v1/policy/{uuid}/simulations', name: 'policy_simulations', methods: ['GET'])] - public function history(#[CurrentUser] User $user, string $uuid): JsonResponse - { - $policy = $this->requirePolicy($user, $uuid); - - return $this->success(array_map( - static fn (PolicySimulationRun $r): array => $r->toArray(), - $this->runs->historyFor($policy), - )); - } - - private function requirePolicy(User $user, string $uuid): Policy - { - $policy = $this->policies->findByUuid($uuid); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($policy === null || !$this->ownership->belongsToPair($entityType, $entityId, $policy)) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'قانون یافت نشد', 404); - } - - return $policy; - } -} diff --git a/src/Policy/Engine/EligibilityPolicyEngine.php b/src/Policy/Engine/EligibilityPolicyEngine.php deleted file mode 100644 index 15ef02e5..00000000 --- a/src/Policy/Engine/EligibilityPolicyEngine.php +++ /dev/null @@ -1,14 +0,0 @@ - $facts - */ - public function evaluate( - string $entityType, - int $entityId, - array $facts, - ?DoctorAddress $address = null, - ?ServiceItem $service = null, - ?int $at = null, - ): PolicyOutcome { - return $this->resolver->resolve($this->category(), $entityType, $entityId, $facts, $address, $service, $at); - } - - /** - * فقط همین یک قانون، بدون بقیه — برای آزمایشگاه. - * - * @param array $facts - */ - public function evaluateIsolated(Policy $policy, array $facts): PolicyOutcome - { - if ($policy->getCategory() !== $this->category()) { - throw new \InvalidArgumentException(sprintf( - 'Policy "%s" belongs to category "%s", not "%s".', - $policy->getUuid(), - $policy->getCategory(), - $this->category(), - )); - } - - return $this->resolver->evaluateOne($policy, $facts); - } -} diff --git a/src/Policy/Engine/PricingPolicyEngine.php b/src/Policy/Engine/PricingPolicyEngine.php deleted file mode 100644 index ec341c70..00000000 --- a/src/Policy/Engine/PricingPolicyEngine.php +++ /dev/null @@ -1,14 +0,0 @@ ->} - */ - #[ORM\Column(name: 'condition_json', type: 'json')] - private array $condition = []; - - /** @var list> */ - #[ORM\Column(type: 'json')] - private array $effects = []; - - /** بزرگ‌تر یعنی مهم‌تر. اولین معیار حل تناقض. */ - #[ORM\Column(type: 'smallint', options: ['default' => 0])] - private int $priority = 0; - - /** دومین معیار حل تناقض — هنگام **ذخیره** حساب می‌شود، نه در هر رزرو. */ - #[ORM\Column(type: 'smallint', options: ['default' => 0])] - private int $specificity = 0; - - // ── دامنه: هرچه باریک‌تر، در تساویِ اولویت برنده‌تر ────────────────────── - #[ORM\ManyToOne(targetEntity: DoctorAddress::class)] - #[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] - private ?DoctorAddress $address = null; - - #[ORM\ManyToOne(targetEntity: ServiceItem::class)] - #[ORM\JoinColumn(name: 'service_item_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] - private ?ServiceItem $serviceItem = null; - - #[ORM\ManyToOne(targetEntity: CatalogCategory::class)] - #[ORM\JoinColumn(name: 'catalog_category_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] - private ?CatalogCategory $catalogCategory = null; - - #[ORM\Column(name: 'valid_from', type: 'integer', nullable: true)] - private ?int $validFrom = null; - - #[ORM\Column(name: 'valid_to', type: 'integer', nullable: true)] - private ?int $validTo = null; - - #[ORM\Column(type: 'smallint', options: ['default' => 1])] - private int $version = 1; - - #[ORM\Column(type: 'boolean', options: ['default' => false])] - private bool $active = false; - - #[ORM\Column(name: 'created_at', type: 'integer')] - private int $createdAt; - - #[ORM\Column(name: 'updated_at', type: 'integer')] - private int $updatedAt; - - public function __construct(string $entityType, int $entityId, string $category, string $name) - { - if (!in_array($category, self::CATEGORIES, true)) { - throw new \InvalidArgumentException(sprintf('Unknown policy category "%s".', $category)); - } - - $this->uuid = Uuid::v4()->toRfc4122(); - $this->category = $category; - $this->name = $name; - $this->createdAt = time(); - $this->recomputeSpecificity(); - $this->updatedAt = time(); - - $this->assignTenantPair($entityType, $entityId); - } - - public function getId(): ?int { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getCategory(): string { return $this->category; } - public function getName(): string { return $this->name; } - public function getCondition(): array { return $this->condition; } - public function getEffects(): array { return $this->effects; } - public function getPriority(): int { return $this->priority; } - public function getVersion(): int { return $this->version; } - public function isActive(): bool { return $this->active; } - public function getValidFrom(): ?int { return $this->validFrom; } - public function getValidTo(): ?int { return $this->validTo; } - public function getCreatedAt(): int { return $this->createdAt; } - - public function getAddress(): ?DoctorAddress { return $this->address; } - public function getServiceItem(): ?ServiceItem { return $this->serviceItem; } - public function getCatalogCategory(): ?CatalogCategory { return $this->catalogCategory; } - - public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; } - public function setPriority(int $v): self { $this->priority = $v; $this->touch(); return $this; } - public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } - public function setAddress(?DoctorAddress $v): self { $this->address = $v; $this->touch(); return $this; } - public function setServiceItem(?ServiceItem $v): self { $this->serviceItem = $v; $this->touch(); return $this; } - public function setCatalogCategory(?CatalogCategory $v): self { $this->catalogCategory = $v; $this->touch(); return $this; } - - /** @param array $condition */ - public function setCondition(array $condition): self { $this->condition = $condition; $this->touch(); return $this; } - - /** @param list> $effects */ - public function setEffects(array $effects): self { $this->effects = $effects; $this->touch(); return $this; } - - public function setValidity(?int $from, ?int $to): self - { - if ($from !== null && $to !== null && $to <= $from) { - throw new \InvalidArgumentException('Policy validity end must be after its start.'); - } - - $this->validFrom = $from; - $this->validTo = $to; - $this->touch(); - - return $this; - } - - public function bumpVersion(): self { $this->version++; $this->touch(); return $this; } - - public function appliesAt(int $at): bool - { - return $this->active - && ($this->validFrom === null || $at >= $this->validFrom) - && ($this->validTo === null || $at < $this->validTo); - } - - /** - * هرچه باریک‌تر، بزرگ‌تر. در تساویِ اولویت، اختصاصی‌تر برنده است — «این سرویس» باید - * بتواند «همهٔ سرویس‌ها» را کنار بزند، وگرنه استثنا غیرقابل بیان می‌شود. - * - * وزن‌ها از مستند: شعبه ۸ · سرویس ۴ · دسته ۲ · هر شرط اضافه ۱. شرط‌ها هم می‌شمارند - * چون قانونی با سه شرط از قانونِ بی‌قید باریک‌تر است، حتی اگر دامنه‌شان یکی باشد. - */ - public function specificity(): int - { - return $this->specificity; - } - - /** - * محاسبه **هنگام ذخیره**، نه هنگام اجرا. - * - * حل تناقض در هر رزرو روی همین عدد `usort` می‌زند؛ محاسبهٔ دوباره‌اش per قانون per - * درخواست یعنی کاری که یک بار در عمر قانون کافی بود، هزار بار در روز انجام شود. ضمناً - * ذخیره‌شدنش یعنی می‌شود روزی مرتب‌سازی را به SQL برد. - */ - public function recomputeSpecificity(): self - { - $this->specificity = ($this->address !== null ? 8 : 0) - + ($this->serviceItem !== null ? 4 : 0) - + ($this->catalogCategory !== null ? 2 : 0) - + count($this->condition['conditions'] ?? []); - - return $this; - } - - private function touch(): void - { - $this->updatedAt = time(); - $this->recomputeSpecificity(); - } - - public function toArray(): array - { - return [ - 'uuid' => $this->uuid, - 'category' => $this->category, - 'name' => $this->name, - 'condition' => (object) $this->condition, - 'effects' => $this->effects, - 'priority' => $this->priority, - 'version' => $this->version, - 'active' => $this->active, - 'valid_from' => $this->validFrom, - 'valid_to' => $this->validTo, - 'address_uuid' => $this->address?->getUuid(), - 'service_uuid' => $this->serviceItem?->getUuid(), - 'catalog_category_uuid' => $this->catalogCategory?->getUuid(), - 'specificity' => $this->specificity(), - ]; - } -} diff --git a/src/Policy/Entity/PolicySimulationRun.php b/src/Policy/Entity/PolicySimulationRun.php deleted file mode 100644 index 4f000ecb..00000000 --- a/src/Policy/Entity/PolicySimulationRun.php +++ /dev/null @@ -1,137 +0,0 @@ - */ - #[ORM\Column(type: 'json')] - private array $report; - - #[ORM\ManyToOne(targetEntity: User::class)] - #[ORM\JoinColumn(name: 'run_by', nullable: true, onDelete: 'SET NULL')] - private ?User $runBy = null; - - #[ORM\Column(name: 'created_at', type: 'integer')] - private int $createdAt; - - /** @param array $report */ - public function __construct( - Policy $policy, - int $sampleSize, - int $affectedCount, - string $severity, - array $report, - ?User $runBy = null, - ) { - $this->uuid = Uuid::v4()->toRfc4122(); - $this->policy = $policy; - $this->policyVersion = $policy->getVersion(); - $this->sampleSize = $sampleSize; - $this->affectedCount = $affectedCount; - $this->severity = $severity; - $this->report = $report; - $this->runBy = $runBy; - $this->createdAt = time(); - - $this->entityType = $policy->getEntityType(); - $this->entityId = $policy->getEntityId(); - } - - /** - * شدت از **نسبت** می‌آید نه از تعداد: ۷ نوبت از ۱۰ فاجعه است و ۷ از ۵۰۰ عادی. - * - * صفر هم هشدار است، نه موفقیت: قانونی که روی هیچ نوبتی اثر ندارد یا شرطش هرگز - * برقرار نمی‌شود یا نمونه اشتباه انتخاب شده — هر دو باید دیده شوند. - */ - public static function severityFor(int $sampleSize, int $affected): string - { - if ($affected === 0) { - return self::SEVERITY_NONE; - } - - $ratio = $sampleSize === 0 ? 0.0 : $affected / $sampleSize; - - return match (true) { - $ratio > 0.60 => self::SEVERITY_HIGH, - $ratio > 0.20 => self::SEVERITY_MEDIUM, - default => self::SEVERITY_LOW, - }; - } - - public function getId(): ?int { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getPolicy(): Policy { return $this->policy; } - public function getPolicyVersion(): int { return $this->policyVersion; } - public function getSampleSize(): int { return $this->sampleSize; } - public function getAffectedCount(): int { return $this->affectedCount; } - public function getSeverity(): string { return $this->severity; } - public function getCreatedAt(): int { return $this->createdAt; } - - /** @return array */ - public function getReport(): array { return $this->report; } - - /** @return array */ - public function toArray(): array - { - return [ - 'uuid' => $this->uuid, - 'policy_uuid' => $this->policy->getUuid(), - 'policy_version' => $this->policyVersion, - 'sample_size' => $this->sampleSize, - 'affected_count' => $this->affectedCount, - 'affected_percent' => $this->sampleSize === 0 - ? 0 - : (int) round($this->affectedCount * 100 / $this->sampleSize), - 'severity' => $this->severity, - 'created_at' => $this->createdAt, - ] + $this->report; - } -} diff --git a/src/Policy/Entity/PolicyVersionLog.php b/src/Policy/Entity/PolicyVersionLog.php deleted file mode 100644 index 6982cc72..00000000 --- a/src/Policy/Entity/PolicyVersionLog.php +++ /dev/null @@ -1,61 +0,0 @@ -policy = $policy; - $this->version = $version; - $this->snapshot = $snapshot; - $this->createdAt = time(); - } - - public function getId(): ?int { return $this->id; } - public function getPolicy(): Policy { return $this->policy; } - public function getVersion(): int { return $this->version; } - public function getSnapshot(): array { return $this->snapshot; } - - public function toArray(): array - { - return [ - 'version' => $this->version, - 'snapshot' => $this->snapshot, - 'created_at' => $this->createdAt, - ]; - } -} diff --git a/src/Policy/Repository/PolicyRepository.php b/src/Policy/Repository/PolicyRepository.php deleted file mode 100644 index a48eadb9..00000000 --- a/src/Policy/Repository/PolicyRepository.php +++ /dev/null @@ -1,64 +0,0 @@ - - */ -class PolicyRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, Policy::class); - } - - public function findByUuid(string $uuid): ?Policy - { - return $this->findOneBy(['uuid' => $uuid]); - } - - /** - * قانون‌های فعالِ یک دسته — یک کوئری per دسته، نه per قانون. - * - * @return Policy[] - */ - public function findForCategory(string $entityType, int $entityId, string $category): array - { - return $this->createQueryBuilder('p') - ->addSelect('a', 's', 'c') - ->leftJoin('p.address', 'a') - ->leftJoin('p.serviceItem', 's') - ->leftJoin('p.catalogCategory', 'c') - ->where('p.entityType = :type') - ->andWhere('p.entityId = :id') - ->andWhere('p.category = :category') - ->andWhere('p.active = true') - ->setParameter('type', $entityType) - ->setParameter('id', $entityId) - ->setParameter('category', $category) - ->getQuery() - ->getResult(); - } - - /** @return Policy[] */ - public function findForPair(string $entityType, int $entityId, ?string $category = null): array - { - $qb = $this->createQueryBuilder('p') - ->where('p.entityType = :type') - ->andWhere('p.entityId = :id') - ->setParameter('type', $entityType) - ->setParameter('id', $entityId) - ->orderBy('p.priority', 'DESC') - ->addOrderBy('p.createdAt', 'ASC'); - - if ($category !== null) { - $qb->andWhere('p.category = :category')->setParameter('category', $category); - } - - return $qb->getQuery()->getResult(); - } -} diff --git a/src/Policy/Repository/PolicySimulationRunRepository.php b/src/Policy/Repository/PolicySimulationRunRepository.php deleted file mode 100644 index 872165cb..00000000 --- a/src/Policy/Repository/PolicySimulationRunRepository.php +++ /dev/null @@ -1,52 +0,0 @@ - - */ -class PolicySimulationRunRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, PolicySimulationRun::class); - } - - /** آخرین اجرای آزمایشی این قانون، از هر نسخه‌ای. */ - public function latestFor(Policy $policy): ?PolicySimulationRun - { - return $this->createQueryBuilder('r') - ->where('r.policy = :policy') - ->setParameter('policy', $policy) - ->orderBy('r.createdAt', 'DESC') - ->addOrderBy('r.id', 'DESC') - ->setMaxResults(1) - ->getQuery() - ->getOneOrNullResult(); - } - - /** @return PolicySimulationRun[] */ - public function historyFor(Policy $policy, int $limit = 10): array - { - return $this->createQueryBuilder('r') - ->where('r.policy = :policy') - ->setParameter('policy', $policy) - ->orderBy('r.createdAt', 'DESC') - ->addOrderBy('r.id', 'DESC') - ->setMaxResults($limit) - ->getQuery() - ->getResult(); - } - - public function save(PolicySimulationRun $run): void - { - $em = $this->getEntityManager(); - $em->persist($run); - $em->flush(); - } -} diff --git a/src/Policy/Repository/PolicyVersionLogRepository.php b/src/Policy/Repository/PolicyVersionLogRepository.php deleted file mode 100644 index 4f6e5f67..00000000 --- a/src/Policy/Repository/PolicyVersionLogRepository.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ -class PolicyVersionLogRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, PolicyVersionLog::class); - } - - /** @return PolicyVersionLog[] */ - public function findForPolicy(Policy $policy): array - { - return $this->createQueryBuilder('l') - ->where('l.policy = :policy') - ->setParameter('policy', $policy) - ->orderBy('l.version', 'ASC') - ->getQuery() - ->getResult(); - } -} diff --git a/src/Policy/Service/BookingPolicyGuard.php b/src/Policy/Service/BookingPolicyGuard.php deleted file mode 100644 index f2b28d47..00000000 --- a/src/Policy/Service/BookingPolicyGuard.php +++ /dev/null @@ -1,232 +0,0 @@ - $extraFacts حقایقی که فقط در همین درخواست وجود دارند - * - * @throws AppException ۴۲۲ اگر قانونی این بیمار را ممنوع کند - */ - public function assertEligible( - User $patient, - ServiceItem $service, - array $items, - DoctorAddress $address, - array $extraFacts = [], - ?int $at = null, - ): PolicyOutcome { - $at = $at ?? time(); - - $outcome = $this->eligibilityPolicies->evaluate( - $address->tenantEntityType(), - $address->tenantEntityId(), - $this->patientFacts($patient, $address, $at) + $extraFacts + ['item_count' => count($items) + 1], - $address, - $service, - $at, - ); - - if ($outcome->isForbidden()) { - throw new AppException( - ErrorCodes::ERR_VALIDATION_001, - implode(' ', $outcome->forbidReasons), - 422, - ); - } - - // `require_flag` ممنوعیت نیست، شرط است: تا وقتی اپراتور آن پرچم را نفرستاده - // درخواست ناقص است، و بعد از فرستادنش قانون راضی است. - $missing = array_values(array_filter( - (array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_FLAG, []), - static fn (string $flag): bool => empty($extraFacts[$flag]), - )); - - if ($missing !== []) { - throw new AppException( - ErrorCodes::ERR_VALIDATION_002, - sprintf('برای این نوبت تأیید %s الزامی است', implode('، ', $missing)), - 422, - $missing[0], - ); - } - - return $outcome; - } - - /** - * حداقل فاصله تا نوبت قبلیِ **همان دسته** — بند «فاصلهٔ بین جلسات». - * - * مبنا نوبت قبلی است نه نوبت بعدی: قانون می‌گوید بعد از هر جلسه چقدر باید صبر - * کرد، پس رزرو آینده‌ای که هنوز انجام نشده معیار نیست. - * - * @throws AppException ۴۲۲ اگر فاصله کافی نباشد - */ - public function assertSpacing( - User $patient, - ServiceItem $service, - DoctorAddress $address, - int $startsAt, - ?int $at = null, - ): void { - $at = $at ?? time(); - - $outcome = $this->spacingPolicies->evaluate( - $address->tenantEntityType(), - $address->tenantEntityId(), - [ - 'service_uuid' => $service->getUuid(), - 'catalog_category' => $service->getCatalogCategory()?->getUuid(), - ], - $address, - $service, - $at, - ); - - $minDays = (int) $outcome->effect(PolicySchema::EFFECT_MIN_DAYS_BETWEEN, 0); - - if ($minDays <= 0) { - return; - } - - $last = $this->lastAppointmentAt($patient, $service, $startsAt); - - if ($last === null) { - return; - } - - $gapDays = (int) floor(($startsAt - $last) / 86400); - - if ($gapDays < $minDays) { - throw new AppException( - ErrorCodes::ERR_VALIDATION_001, - sprintf('بین دو جلسهٔ این خدمت باید حداقل %d روز فاصله باشد', $minDays), - 422, - ); - } - } - - /** - * @return array - */ - private function patientFacts(User $patient, DoctorAddress $address, int $at): array - { - $profile = $this->em->getRepository(UserProfile::class)->findOneBy(['user' => $patient]); - - return [ - 'patient_age' => $this->ageOf($profile?->getDateOfBirth(), $at), - 'patient_gender' => $profile?->getGender(), - 'patient_tags' => [], - 'visit_count' => $this->visitCount($patient, $address), - // عملگر `days_since` روی همین می‌نشیند: «بیش از N روز از آخرین ویزیت گذشته». - // صفر یعنی «هرگز» و هر شرط زمانی را رد می‌کند. - 'last_visit_at' => $this->lastVisitAt($patient, $address), - ]; - } - - /** سن با سال میانگین گریگوری حساب می‌شود؛ اختلافش با شمسی در مرز سن صفر است. */ - private function ageOf(?int $dateOfBirth, int $at): ?int - { - if ($dateOfBirth === null || $dateOfBirth <= 0) { - return null; - } - - return (int) floor(($at - $dateOfBirth) / 31556952); - } - - private function visitCount(User $patient, DoctorAddress $address): int - { - return (int) $this->em->createQueryBuilder() - ->select('COUNT(a.id)') - ->from(Appointment::class, 'a') - ->where('a.user = :user') - ->andWhere('a.status = :status') - ->setParameter('user', $patient) - ->setParameter('status', Appointment::STATUS_COMPLETED) - ->getQuery() - ->getSingleScalarResult(); - } - - /** - * آخرین نوبتِ گذشتهٔ بیمار از همان دستهٔ کاتالوگ — یا از همان سرویس اگر دسته ندارد. - */ - /** آخرین ویزیت بیمار در این محیط، بدون قید سرویس — `0` یعنی هرگز. */ - private function lastVisitAt(User $patient, DoctorAddress $address): int - { - return (int) $this->em->createQueryBuilder() - ->select('MAX(a.slotStart)') - ->from(\App\Appointment\Entity\Appointment::class, 'a') - ->where('a.user = :patient') - ->andWhere('a.entityType = :type') - ->andWhere('a.entityId = :id') - ->andWhere('a.status IN (:done)') - ->setParameter('patient', $patient) - ->setParameter('type', $address->tenantEntityType()) - ->setParameter('id', $address->tenantEntityId()) - ->setParameter('done', [ - \App\Appointment\Entity\Appointment::STATUS_COMPLETED, - \App\Appointment\Entity\Appointment::STATUS_CONFIRMED, - ]) - ->getQuery() - ->getSingleScalarResult(); - } - - private function lastAppointmentAt(User $patient, ServiceItem $service, int $before): ?int - { - $qb = $this->em->createQueryBuilder() - ->select('MAX(a.slotStart)') - ->from(Appointment::class, 'a') - ->join('a.serviceItem', 'si') - ->where('a.user = :user') - ->andWhere('a.slotStart < :before') - ->andWhere('a.status NOT IN (:dead)') - ->setParameter('user', $patient) - ->setParameter('before', $before) - ->setParameter('dead', [ - Appointment::STATUS_CANCELLED_BY_DOCTOR, - Appointment::STATUS_CANCELLED_BY_USER, - Appointment::STATUS_EXPIRED, - ]); - - $category = $service->getCatalogCategory(); - - if ($category !== null) { - $qb->andWhere('si.catalogCategory = :category')->setParameter('category', $category); - } else { - $qb->andWhere('si = :service')->setParameter('service', $service); - } - - $result = $qb->getQuery()->getSingleScalarResult(); - - return $result === null ? null : (int) $result; - } -} diff --git a/src/Policy/Service/ConditionEvaluator.php b/src/Policy/Service/ConditionEvaluator.php deleted file mode 100644 index ac741ef0..00000000 --- a/src/Policy/Service/ConditionEvaluator.php +++ /dev/null @@ -1,187 +0,0 @@ - $facts - */ - public function matches(Policy $policy, array $facts): bool - { - $this->policy = $policy; - - $condition = $policy->getCondition(); - $conditions = $condition['conditions'] ?? []; - - // شرط خالی یعنی «همیشه» — قانونِ بی‌قید و شرط کاملاً معتبر است. - if ($conditions === []) { - return true; - } - - $mode = ($condition['match'] ?? 'all') === 'any' ? 'any' : 'all'; - - foreach ($conditions as $clause) { - $result = $this->evaluateClause($clause, $facts); - - if ($mode === 'any' && $result) { - return true; - } - - if ($mode === 'all' && !$result) { - return false; - } - } - - return $mode === 'all'; - } - - /** @param array $facts */ - private function evaluateClause(mixed $clause, array $facts): bool - { - if (!is_array($clause) || !is_string($clause['field'] ?? null)) { - return false; - } - - $field = $clause['field']; - $operator = $clause['operator'] ?? PolicySchema::OP_EQUALS; - $expected = $clause['value'] ?? null; - - // فیلدی که در حقایق این درخواست نیست، شرط را **رد** می‌کند نه اینکه نادیده - // بگیرد: قانون «سن زیر ۱۸» وقتی سن نامشخص است نباید بی‌صدا صادق شود. - // - // ولی رد کردنِ خاموش هم بد است: قانونی که هر بار به این خط می‌رسد، عملاً - // خاموش است و کسی خبردار نمی‌شود. لاگ تنها چیزی است که این را قابل کشف می‌کند. - if (!$this->fields->supplies($field, $facts)) { - $this->logger->warning('policy condition skipped: fact missing', [ - 'policy_uuid' => $this->policy?->getUuid(), - 'category' => $this->policy?->getCategory(), - 'field' => $field, - 'known_facts' => array_keys($facts), - ]); - - return false; - } - - return $this->operators->evaluate($operator, $this->fields->extract($field, $facts), $expected); - } - - /** - * اعتبارسنجی ساختار شرط در **زمان ساخت**. - * - * @param array $condition - * @throws AppException - */ - public function assertValid(string $category, array $condition): void - { - // کلید ناشناس در ریشهٔ شرط **خطاست**: `{"all": [...]}` به‌جای - // `{"match": "all", "conditions": [...]}` شرطی خالی می‌سازد که همیشه صادق - // است — یعنی قانون روی همه‌چیز اجرا می‌شود بی‌آنکه کسی بفهمد. - $unknown = array_diff(array_keys($condition), ['match', 'conditions']); - - if ($unknown !== []) { - throw new AppException( - ErrorCodes::ERR_VALIDATION_001, - sprintf('کلید «%s» در شرط شناخته نمی‌شود؛ ساختار درست {match, conditions} است', (string) reset($unknown)), - 422, - 'condition', - ); - } - - if (isset($condition['match']) && !in_array($condition['match'], ['all', 'any'], true)) { - throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'مقدار match باید all یا any باشد', 422, 'condition'); - } - - if (isset($condition['conditions']) && !is_array($condition['conditions'])) { - throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'conditions باید فهرست باشد', 422, 'condition'); - } - - foreach (($condition['conditions'] ?? []) as $clause) { - if (!is_array($clause) || !is_string($clause['field'] ?? null)) { - throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'هر شرط باید فیلد داشته باشد', 422, 'condition'); - } - - if (!$this->schema->allowsField($category, $clause['field'])) { - throw new AppException( - ErrorCodes::ERR_VALIDATION_001, - sprintf( - 'فیلد «%s» برای دستهٔ «%s» مجاز نیست. مجازها: %s', - $clause['field'], - $category, - implode('، ', $this->schema->fieldsFor($category)), - ), - 422, - 'condition', - ); - } - - $operator = $clause['operator'] ?? PolicySchema::OP_EQUALS; - - if (!is_string($operator) || !$this->operators->has($operator)) { - throw new AppException( - ErrorCodes::ERR_VALIDATION_001, - sprintf('عملگر «%s» شناخته نمی‌شود', is_string($operator) ? $operator : '—'), - 422, - 'condition', - ); - } - } - } - - /** - * ورودی مستقیم از JSON کاربر می‌آید، پس نوعش `mixed` است نه آرایهٔ ساختاریافته — - * اعتبارسنجی همین‌جا همان چیزی است که ساختار را تضمین می‌کند. - * - * @param list $effects - * @throws AppException - */ - public function assertEffectsValid(string $category, array $effects): void - { - if ($effects === []) { - throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'قانون باید حداقل یک اثر داشته باشد', 422, 'effects'); - } - - foreach ($effects as $effect) { - if (!is_array($effect) || !is_string($effect['type'] ?? null)) { - throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'هر اثر باید نوع داشته باشد', 422, 'effects'); - } - - if (!$this->schema->allowsEffect($category, $effect['type'])) { - throw new AppException( - ErrorCodes::ERR_VALIDATION_001, - sprintf( - 'اثر «%s» با دستهٔ «%s» سازگار نیست. مجازها: %s', - $effect['type'], - $category, - implode('، ', PolicySchema::EFFECTS[$category] ?? []), - ), - 422, - 'effects', - ); - } - } - } -} diff --git a/src/Policy/Service/FieldRegistry.php b/src/Policy/Service/FieldRegistry.php deleted file mode 100644 index 1f10d7e5..00000000 --- a/src/Policy/Service/FieldRegistry.php +++ /dev/null @@ -1,158 +0,0 @@ -, categories: list}> - */ - private const FIELDS = [ - 'item_count' => [ - 'label' => 'تعداد موارد انتخابی', - 'type' => 'int', - 'categories' => [Policy::CATEGORY_SELECTION, Policy::CATEGORY_RESOURCE, Policy::CATEGORY_TIMING, Policy::CATEGORY_PRICING], - ], - 'item_uuids' => [ - 'label' => 'موارد انتخابی', - 'type' => 'list', - 'categories' => [Policy::CATEGORY_SELECTION], - ], - 'catalog_category' => [ - 'label' => 'دستهٔ کاتالوگ', - 'type' => 'uuid', - 'categories' => [Policy::CATEGORY_SELECTION, Policy::CATEGORY_RESOURCE, Policy::CATEGORY_TIMING, Policy::CATEGORY_SPACING], - ], - 'service_uuid' => [ - 'label' => 'سرویس', - 'type' => 'uuid', - 'categories' => [Policy::CATEGORY_RESOURCE, Policy::CATEGORY_TIMING, Policy::CATEGORY_SPACING], - ], - 'patient_age' => [ - 'label' => 'سن بیمار', - 'type' => 'int', - 'categories' => [Policy::CATEGORY_ELIGIBILITY, Policy::CATEGORY_TIMING], - ], - 'patient_gender' => [ - 'label' => 'جنسیت بیمار', - 'type' => 'enum', - 'values' => ['male', 'female'], - 'categories' => [Policy::CATEGORY_ELIGIBILITY], - ], - 'patient_tags' => [ - 'label' => 'برچسب‌های بیمار', - 'type' => 'list', - 'categories' => [Policy::CATEGORY_ELIGIBILITY, Policy::CATEGORY_PRICING], - ], - 'has_parental_consent' => [ - 'label' => 'رضایت والدین', - 'type' => 'bool', - 'categories' => [Policy::CATEGORY_ELIGIBILITY], - ], - 'visit_count' => [ - 'label' => 'تعداد ویزیت قبلی', - 'type' => 'int', - 'categories' => [Policy::CATEGORY_ELIGIBILITY, Policy::CATEGORY_PRICING], - ], - 'subtotal_rials' => [ - 'label' => 'جمع مبلغ (ریال)', - 'type' => 'int', - 'categories' => [Policy::CATEGORY_PRICING], - ], - 'last_visit_at' => [ - 'label' => 'آخرین ویزیت', - 'type' => 'timestamp', - 'categories' => [Policy::CATEGORY_ELIGIBILITY, Policy::CATEGORY_SPACING, Policy::CATEGORY_PRICING], - ], - ]; - - public function __construct(private readonly OperatorRegistry $operators) {} - - public function has(string $field): bool - { - return isset(self::FIELDS[$field]); - } - - public function allowedIn(string $field, string $category): bool - { - return in_array($category, self::FIELDS[$field]['categories'] ?? [], true); - } - - /** @return list */ - public function forCategory(string $category): array - { - $out = []; - - foreach (self::FIELDS as $name => $meta) { - if (in_array($category, $meta['categories'], true)) { - $out[] = $name; - } - } - - return $out; - } - - public function typeOf(string $field): string - { - return self::FIELDS[$field]['type'] ?? 'int'; - } - - /** - * فرادادهٔ فیلدهای یک دسته — همان چیزی که فرم ساخت قانون از آن ساخته می‌شود. - * - * عملگرها **فیلترشده per نوع** می‌آیند: اگر فرم همهٔ یازده عملگر را نشان بدهد، کاربر - * `patient_tags > 5` می‌سازد و ۴۲۲ می‌گیرد بدون اینکه بفهمد چرا. - * - * @return array> - */ - public function describeCategory(string $category): array - { - $out = []; - - foreach ($this->forCategory($category) as $field) { - $meta = self::FIELDS[$field]; - - $out[$field] = [ - 'label' => $meta['label'], - 'type' => $meta['type'], - 'operators' => $this->operators->forType($meta['type']), - ] + (isset($meta['values']) ? ['values' => $meta['values']] : []); - } - - return $out; - } - - /** - * مقدار یک فیلد از حقایق درخواست. - * - * `null` در آرایه با «غایب» فرق دارد: اولی یعنی «می‌دانیم که ندارد» (سنِ ثبت‌نشده) و - * دومی یعنی «این نقطه اصلاً این فیلد را نمی‌سازد». هر دو شرط را رد می‌کنند، ولی فقط - * دومی نشانهٔ خطای پیکربندی است و باید لاگ شود. - * - * @param array $facts - */ - public function extract(string $field, array $facts): mixed - { - return $facts[$field] ?? null; - } - - /** @param array $facts */ - public function supplies(string $field, array $facts): bool - { - return array_key_exists($field, $facts); - } -} diff --git a/src/Policy/Service/OperatorRegistry.php b/src/Policy/Service/OperatorRegistry.php deleted file mode 100644 index 244e232d..00000000 --- a/src/Policy/Service/OperatorRegistry.php +++ /dev/null @@ -1,176 +0,0 @@ - 'برابر است با', - self::OP_NOT_EQUALS => 'برابر نیست با', - self::OP_GREATER_THAN => 'بیشتر از', - self::OP_GREATER_EQUAL => 'بیشتر یا مساوی', - self::OP_LESS_THAN => 'کمتر از', - self::OP_LESS_EQUAL => 'کمتر یا مساوی', - self::OP_IN => 'یکی از', - self::OP_NOT_IN => 'هیچ‌کدام از', - self::OP_BETWEEN => 'بین', - self::OP_CONTAINS => 'شامل', - self::OP_DAYS_SINCE => 'روز گذشته از', - ]; - - /** عملگرهای معنادار per نوع فیلد — فرم فقط همین‌ها را نشان می‌دهد. */ - private const BY_TYPE = [ - 'int' => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_GREATER_THAN, self::OP_GREATER_EQUAL, self::OP_LESS_THAN, self::OP_LESS_EQUAL, self::OP_BETWEEN, self::OP_IN, self::OP_NOT_IN], - 'uuid' => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_IN, self::OP_NOT_IN], - 'enum' => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_IN, self::OP_NOT_IN], - 'bool' => [self::OP_EQUALS], - 'list' => [self::OP_CONTAINS], - 'timestamp' => [self::OP_DAYS_SINCE, self::OP_GREATER_THAN, self::OP_LESS_THAN], - ]; - - public function has(string $operator): bool - { - return in_array($operator, self::ALL, true); - } - - /** @return list */ - public function forType(string $type): array - { - return self::BY_TYPE[$type] ?? [self::OP_EQUALS, self::OP_NOT_EQUALS]; - } - - /** @return list */ - public function describe(): array - { - return array_map( - static fn (string $op): array => ['value' => $op, 'label' => self::LABELS[$op]], - self::ALL, - ); - } - - public function label(string $operator): string - { - return self::LABELS[$operator] ?? $operator; - } - - /** - * ارزیابی یک عملگر. `$now` تزریق می‌شود تا `days_since` در تست قطعی باشد. - */ - public function evaluate(string $operator, mixed $actual, mixed $expected, ?int $now = null): bool - { - return match ($operator) { - self::OP_EQUALS => $this->looselyEqual($actual, $expected), - self::OP_NOT_EQUALS => !$this->looselyEqual($actual, $expected), - self::OP_GREATER_THAN => is_numeric($actual) && is_numeric($expected) && $actual > $expected, - self::OP_GREATER_EQUAL => is_numeric($actual) && is_numeric($expected) && $actual >= $expected, - self::OP_LESS_THAN => is_numeric($actual) && is_numeric($expected) && $actual < $expected, - self::OP_LESS_EQUAL => is_numeric($actual) && is_numeric($expected) && $actual <= $expected, - self::OP_IN => is_array($expected) && $this->inList($actual, $expected), - self::OP_NOT_IN => is_array($expected) && !$this->inList($actual, $expected), - self::OP_BETWEEN => $this->between($actual, $expected), - self::OP_CONTAINS => is_array($actual) && $this->inList($expected, $actual), - self::OP_DAYS_SINCE => $this->daysSince($actual, $expected, $now), - default => false, - }; - } - - /** - * بازهٔ بسته: `[min, max]`. هر دو سر شمرده می‌شوند، چون «بین ۱۸ تا ۶۵ سال» در زبان - * فارسی هر دو سر را شامل می‌شود و کاربر همان را می‌نویسد. - */ - private function between(mixed $actual, mixed $expected): bool - { - if (!is_array($expected) || count($expected) !== 2 || !is_numeric($actual)) { - return false; - } - - [$min, $max] = array_values($expected); - - return is_numeric($min) && is_numeric($max) && $actual >= $min && $actual <= $max; - } - - /** «بیش از N روز از این زمان گذشته». مقدار غایب یعنی «هرگز» و شرط را رد می‌کند. */ - private function daysSince(mixed $actual, mixed $expected, ?int $now): bool - { - if (!is_numeric($actual) || $actual <= 0 || !is_numeric($expected)) { - return false; - } - - $days = ((($now ?? time()) - (int) $actual) / 86400); - - return $days >= (float) $expected; - } - - /** @param array $list */ - private function inList(mixed $needle, array $list): bool - { - foreach ($list as $candidate) { - if ($this->looselyEqual($needle, $candidate)) { - return true; - } - } - - return false; - } - - /** - * مقایسهٔ ملایم فقط بین عدد و رشتهٔ عددی — `"18" == 18` درست است ولی - * `"18 سال" == 18` نه. مقایسهٔ `==` خام PHP دومی را هم درست می‌گفت. - */ - private function looselyEqual(mixed $a, mixed $b): bool - { - if (is_numeric($a) && is_numeric($b)) { - return (float) $a === (float) $b; - } - - if (is_bool($a) || is_bool($b)) { - return (bool) $a === (bool) $b; - } - - return $a === $b; - } -} diff --git a/src/Policy/Service/PolicyResolver.php b/src/Policy/Service/PolicyResolver.php deleted file mode 100644 index 20ebeb13..00000000 --- a/src/Policy/Service/PolicyResolver.php +++ /dev/null @@ -1,178 +0,0 @@ - $facts - */ - public function resolve( - string $category, - string $entityType, - int $entityId, - array $facts, - ?DoctorAddress $address = null, - ?ServiceItem $service = null, - ?int $at = null, - ): PolicyOutcome { - $at = $at ?? time(); - $candidates = $this->policies->findForCategory($entityType, $entityId, $category); - - $matched = []; - - foreach ($candidates as $policy) { - if (!$policy->appliesAt($at) || !$this->inScope($policy, $address, $service)) { - continue; - } - - if ($this->evaluator->matches($policy, $facts)) { - $matched[] = $policy; - } - } - - if ($matched === []) { - return new PolicyOutcome(); - } - - usort($matched, $this->comparator(...)); - - return $this->combine($matched); - } - - /** - * ارزیابی **یک** قانون، بدون رقابت و بدون ترکیب با بقیه. - * - * سؤال آزمایشگاه این است که «این قانون چه می‌کند»، نه «نتیجهٔ نهایی با همهٔ قوانین - * چه می‌شود». دومی مفید است ولی چیزی نیست که کاربرِ در حال نوشتن قانون می‌پرسد. - * - * دامنه و اعتبار زمانی هم عمداً نادیده گرفته می‌شوند: کاربر دارد قانونِ **پیش‌نویس** - * را روی نمونهٔ گذشته می‌آزماید؛ رد کردنش به‌خاطر اینکه هنوز فعال نیست بی‌معناست. - * - * @param array $facts - */ - public function evaluateOne(Policy $policy, array $facts): PolicyOutcome - { - if (!$this->evaluator->matches($policy, $facts)) { - return new PolicyOutcome(); - } - - return $this->combine([$policy]); - } - - /** - * قانونی که دامنه‌اش با این درخواست نمی‌خواند اصلاً کاندید نیست. - * - * دامنهٔ تهی یعنی «همه» — قانون سطح محیط روی همه‌چیز اعمال می‌شود. - */ - private function inScope(Policy $policy, ?DoctorAddress $address, ?ServiceItem $service): bool - { - if ($policy->getAddress() !== null && $policy->getAddress()->getId() !== $address?->getId()) { - return false; - } - - if ($policy->getServiceItem() !== null && $policy->getServiceItem()->getId() !== $service?->getId()) { - return false; - } - - if ($policy->getCatalogCategory() !== null - && $policy->getCatalogCategory()->getId() !== $service?->getCatalogCategory()?->getId() - ) { - return false; - } - - return true; - } - - private function comparator(Policy $a, Policy $b): int - { - return [$b->getPriority(), $b->specificity(), $a->getCreatedAt()] - <=> [$a->getPriority(), $a->specificity(), $b->getCreatedAt()]; - } - - /** @param Policy[] $policies به ترتیب برنده‌ترین */ - private function combine(array $policies): PolicyOutcome - { - $effects = []; - $applied = []; - $forbids = []; - - foreach ($policies as $policy) { - $contributed = false; - - foreach ($policy->getEffects() as $effect) { - $type = $effect['type'] ?? null; - - if (!is_string($type)) { - continue; - } - - $contributed = true; - $mode = PolicySchema::COMBINATION[$type] ?? 'max'; - $value = $effect['value'] ?? true; - - if ($mode === 'veto') { - // متن دلخواه با کلید `reason` می‌آید؛ نبودنش خطا نیست چون نام - // خودِ قانون همیشه یک توضیح قابل‌فهم است. - $reason = $effect['reason'] ?? null; - $forbids[] = is_string($reason) && trim($reason) !== '' - ? $reason - : sprintf('قانون «%s» این عملیات را مجاز نمی‌داند', $policy->getName()); - continue; - } - - $effects[$type] = match ($mode) { - 'sum' => (float) ($effects[$type] ?? 0) + (float) $value, - 'union' => array_values(array_unique([...($effects[$type] ?? []), ...(array) $value])), - default => max($effects[$type] ?? $value, $value), // max - }; - } - - if ($contributed) { - $applied[] = [ - 'uuid' => $policy->getUuid(), - 'name' => $policy->getName(), - 'version' => $policy->getVersion(), - ]; - } - } - - // جمع‌ها به عدد صحیح برمی‌گردند: دقیقه و ریال هر دو صحیح‌اند. - foreach ($effects as $type => $value) { - if (is_float($value)) { - $effects[$type] = $value == (int) $value ? (int) $value : $value; - } - } - - return new PolicyOutcome($effects, $applied, $forbids); - } -} diff --git a/src/Policy/Service/PolicySchema.php b/src/Policy/Service/PolicySchema.php deleted file mode 100644 index 5ff96e12..00000000 --- a/src/Policy/Service/PolicySchema.php +++ /dev/null @@ -1,138 +0,0 @@ - [self::EFFECT_FORBID], - Policy::CATEGORY_ELIGIBILITY => [self::EFFECT_FORBID, self::EFFECT_REQUIRE_FLAG], - Policy::CATEGORY_RESOURCE => [self::EFFECT_REQUIRE_RESOURCE, self::EFFECT_FORBID], - Policy::CATEGORY_TIMING => [self::EFFECT_MIN_DURATION, self::EFFECT_ADD_DURATION], - Policy::CATEGORY_SPACING => [self::EFFECT_MIN_DAYS_BETWEEN], - Policy::CATEGORY_PRICING => [self::EFFECT_DISCOUNT_PERCENT, self::EFFECT_DISCOUNT_RIALS], - ]; - - /** - * چگونه چند اثرِ هم‌نوع با هم ترکیب می‌شوند — جدول بند ۸. - * - * `forbid` هیچ‌وقت ترکیب نمی‌شود: یک ممنوعیت کل عملیات را رد می‌کند، حتی اگر ده - * قانون مجازکننده باشند. - */ - public const COMBINATION = [ - self::EFFECT_FORBID => 'veto', - self::EFFECT_REQUIRE_RESOURCE => 'union', - self::EFFECT_REQUIRE_FLAG => 'union', - self::EFFECT_MIN_DURATION => 'max', - self::EFFECT_MIN_DAYS_BETWEEN => 'max', - self::EFFECT_ADD_DURATION => 'sum', - self::EFFECT_DISCOUNT_PERCENT => 'sum', - self::EFFECT_DISCOUNT_RIALS => 'sum', - ]; - - /** برچسب فارسیِ هر اثر — همان چیزی که در فرم دیده می‌شود. */ - private const EFFECT_META = [ - self::EFFECT_FORBID => ['label' => 'ممنوع کن', 'value_type' => 'none'], - self::EFFECT_REQUIRE_RESOURCE => ['label' => 'نیاز به نقش', 'value_type' => 'string'], - self::EFFECT_REQUIRE_FLAG => ['label' => 'نیاز به تأیید', 'value_type' => 'string'], - self::EFFECT_MIN_DURATION => ['label' => 'حداقل مدت (دقیقه)', 'value_type' => 'int'], - self::EFFECT_ADD_DURATION => ['label' => 'افزودن مدت (دقیقه)', 'value_type' => 'int'], - self::EFFECT_MIN_DAYS_BETWEEN => ['label' => 'حداقل فاصله (روز)', 'value_type' => 'int'], - self::EFFECT_DISCOUNT_PERCENT => ['label' => 'تخفیف درصدی', 'value_type' => 'int'], - self::EFFECT_DISCOUNT_RIALS => ['label' => 'تخفیف مبلغی (ریال)', 'value_type' => 'int'], - ]; - - private const CATEGORY_LABELS = [ - Policy::CATEGORY_SELECTION => 'انتخاب خدمات', - Policy::CATEGORY_ELIGIBILITY => 'صلاحیت بیمار', - Policy::CATEGORY_RESOURCE => 'منابع لازم', - Policy::CATEGORY_TIMING => 'مدت نوبت', - Policy::CATEGORY_SPACING => 'فاصلهٔ جلسات', - Policy::CATEGORY_PRICING => 'قیمت و تخفیف', - ]; - - public function __construct( - private readonly FieldRegistry $fields, - private readonly OperatorRegistry $operators, - ) {} - - /** @return array */ - public function describe(): array - { - $out = []; - - foreach (Policy::CATEGORIES as $category) { - $meta = $this->fields->describeCategory($category); - - $out[$category] = [ - 'label' => self::CATEGORY_LABELS[$category], - 'fields' => array_keys($meta), - 'operators' => $this->operators->describe(), - 'field_meta' => array_map( - static fn (string $key): array => $meta[$key] + ['key' => $key], - array_keys($meta), - ), - 'effects' => array_map( - static fn (string $effect): array => self::EFFECT_META[$effect] + [ - 'type' => $effect, - 'combination' => self::COMBINATION[$effect], - ], - self::EFFECTS[$category], - ), - ]; - } - - return $out; - } - - public function allowsField(string $category, string $field): bool - { - return $this->fields->has($field) && $this->fields->allowedIn($field, $category); - } - - /** @return list */ - public function fieldsFor(string $category): array - { - return $this->fields->forCategory($category); - } - - public function allowsEffect(string $category, string $effect): bool - { - return in_array($effect, self::EFFECTS[$category] ?? [], true); - } -} diff --git a/src/Policy/Simulation/PolicySimulator.php b/src/Policy/Simulation/PolicySimulator.php deleted file mode 100644 index bb549e78..00000000 --- a/src/Policy/Simulation/PolicySimulator.php +++ /dev/null @@ -1,232 +0,0 @@ -em->beginTransaction(); - - try { - $report = $this->runInternal($policy, $size); - } finally { - $this->em->rollback(); - // بدون `clear`، entity های لمس‌شده در identity map می‌مانند و اولین flushِ - // بعدی در همین request آن‌ها را ثبت می‌کند — باگی که پیدا کردنش روزها می‌برد. - $this->em->clear(); - } - - // `clear` ارجاع‌های قبلی را از EM جدا کرده؛ قانون باید دوباره خوانده شود. - $policy = $this->em->getRepository(Policy::class)->find($policy->getId()); - - if ($policy === null) { - throw new \LogicException('Policy vanished during simulation.'); - } - - $run = new PolicySimulationRun( - $policy, - $report['sample_size'], - count($report['rows']), - PolicySimulationRun::severityFor($report['sample_size'], count($report['rows'])), - ['rows' => $report['rows'], 'warning' => $report['warning']], - $runBy === null ? null : $this->em->getRepository(User::class)->find($runBy->getId()), - ); - - $this->runs->save($run); - - return $run; - } - - /** - * @return array{sample_size: int, rows: list>, warning: string|null} - */ - private function runInternal(Policy $policy, int $size): array - { - $sample = $this->sampler->recentAppointments($policy, $size); - - if ($sample === []) { - // کلینیک تازه هیچ نوبت گذشته‌ای ندارد؛ اگر این حالت خطا بود، هرگز - // نمی‌توانست قانونی فعال کند. - return ['sample_size' => 0, 'rows' => [], 'warning' => 'داده‌ای برای آزمایش نیست']; - } - - $rows = []; - - foreach ($sample as $appointment) { - $outcome = $this->policies->evaluateOne( - $policy, - $this->facts->forAppointment($appointment, $policy->getCategory()), - ); - - if ($outcome->appliedPolicies === []) { - continue; - } - - $row = $this->describe($policy, $appointment, $outcome); - - if ($row !== null) { - $rows[] = $row; - } - } - - return ['sample_size' => count($sample), 'rows' => $rows, 'warning' => null]; - } - - /** - * تفاوت «وضعیت فعلی → با این قانون» به زبان کاربر. - * - * تنها ستونی است که کاربر غیرفنی می‌فهمد، پس عمداً متن است نه ساختار خام اثر. - * - * @return array|null `null` یعنی این نوبت عملاً تغییری نمی‌کرد - */ - private function describe(Policy $policy, Appointment $appointment, PolicyOutcome $outcome): ?array - { - $base = [ - 'appointment_uuid' => $appointment->getUuid(), - 'patient_name' => $appointment->getPatientName() ?? '—', - 'slot_start' => $appointment->getSlotStart(), - ]; - - if ($outcome->isForbidden()) { - return $base + [ - 'before' => 'مجاز', - 'after' => 'رد می‌شد', - 'reason' => implode(' ', $outcome->forbidReasons), - ]; - } - - return match ($policy->getCategory()) { - Policy::CATEGORY_TIMING => $this->describeTiming($base, $appointment, $outcome), - Policy::CATEGORY_PRICING => $this->describePricing($base, $appointment, $outcome), - Policy::CATEGORY_RESOURCE => $this->describeList( - $base, - 'منبع لازم', - (array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_RESOURCE, []), - ), - Policy::CATEGORY_ELIGIBILITY => $this->describeList( - $base, - 'تأیید لازم', - (array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_FLAG, []), - ), - Policy::CATEGORY_SPACING => $this->describeSpacing($base, $outcome), - default => null, - }; - } - - /** - * @param array $base - * @return array|null - */ - private function describeTiming(array $base, Appointment $appointment, PolicyOutcome $outcome): ?array - { - $current = $this->facts->durationOf($appointment); - $target = max( - $current + (int) $outcome->effect(PolicySchema::EFFECT_ADD_DURATION, 0), - (int) $outcome->effect(PolicySchema::EFFECT_MIN_DURATION, 0), - ); - - if ($target === $current) { - return null; - } - - return $base + [ - 'before' => sprintf('%d دقیقه', $current), - 'after' => sprintf('%d دقیقه', $target), - 'reason' => sprintf('%+d دقیقه', $target - $current), - ]; - } - - /** - * @param array $base - * @return array|null - */ - private function describePricing(array $base, Appointment $appointment, PolicyOutcome $outcome): ?array - { - $subtotal = $this->facts->subtotalOf($appointment); - - $discount = (int) floor($subtotal * (float) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_PERCENT, 0) / 100) - + (int) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_RIALS, 0); - - $discount = min($discount, $subtotal); - - if ($discount <= 0) { - return null; - } - - return $base + [ - 'before' => sprintf('%s ریال', number_format($subtotal)), - 'after' => sprintf('%s ریال', number_format($subtotal - $discount)), - 'reason' => sprintf('%s ریال تخفیف', number_format($discount)), - ]; - } - - /** - * @param array $base - * @param array $values - * @return array|null - */ - private function describeList(array $base, string $label, array $values): ?array - { - $values = array_values(array_filter($values, 'is_string')); - - if ($values === []) { - return null; - } - - return $base + [ - 'before' => 'بدون قید', - 'after' => sprintf('%s: %s', $label, implode('، ', $values)), - 'reason' => $label, - ]; - } - - /** - * @param array $base - * @return array|null - */ - private function describeSpacing(array $base, PolicyOutcome $outcome): ?array - { - $days = (int) $outcome->effect(PolicySchema::EFFECT_MIN_DAYS_BETWEEN, 0); - - if ($days <= 0) { - return null; - } - - return $base + [ - 'before' => 'بدون حداقل فاصله', - 'after' => sprintf('حداقل %d روز فاصله', $days), - 'reason' => sprintf('%d روز', $days), - ]; - } -} diff --git a/src/Policy/Simulation/SimulationFacts.php b/src/Policy/Simulation/SimulationFacts.php deleted file mode 100644 index 0ead86ae..00000000 --- a/src/Policy/Simulation/SimulationFacts.php +++ /dev/null @@ -1,119 +0,0 @@ - */ - public function forAppointment(Appointment $appointment, string $category): array - { - $service = $appointment->getServiceItem(); - $items = $appointment->getServiceItems()->count(); - - $common = [ - 'service_uuid' => $service?->getUuid(), - 'catalog_category' => $service?->getCatalogCategory()?->getUuid(), - 'item_count' => max(1, $items), - ]; - - return match ($category) { - Policy::CATEGORY_SELECTION => $common + [ - 'item_uuids' => $this->itemUuids($appointment), - ], - Policy::CATEGORY_ELIGIBILITY => $common + $this->patientFacts($appointment), - Policy::CATEGORY_TIMING => $common + [ - 'patient_age' => $this->patientFacts($appointment)['patient_age'], - ], - Policy::CATEGORY_PRICING => $common + [ - 'subtotal_rials' => $this->subtotalOf($appointment), - 'patient_tags' => [], - 'visit_count' => $this->visitCount($appointment), - ], - default => $common, - }; - } - - /** @return list */ - private function itemUuids(Appointment $appointment): array - { - $uuids = []; - - foreach ($appointment->getServiceItems() as $item) { - $uuids[] = $item->getUuid(); - } - - if ($uuids === [] && $appointment->getServiceItem() !== null) { - $uuids[] = $appointment->getServiceItem()->getUuid(); - } - - return $uuids; - } - - /** @return array{patient_age: int|null, patient_gender: string|null, patient_tags: list, visit_count: int, has_parental_consent: bool} */ - private function patientFacts(Appointment $appointment): array - { - /** @var UserProfile|null $profile */ - $profile = $this->em->getRepository(UserProfile::class) - ->findOneBy(['user' => $appointment->getUser()]); - - $dob = $profile?->getDateOfBirth(); - - return [ - 'patient_age' => $dob === null || $dob <= 0 - ? null - : (int) floor(($appointment->getSlotStart() - $dob) / 31556952), - 'patient_gender' => $profile?->getGender() ?? $appointment->getPatientGender(), - 'patient_tags' => [], - 'visit_count' => $this->visitCount($appointment), - // نوبت گذشته پرچمِ لحظه‌ای ندارد؛ فرضِ «نگرفته» محافظه‌کارانه است و - // باعث می‌شود قانون `require_flag` در گزارش **دیده** شود نه پنهان. - 'has_parental_consent' => false, - ]; - } - - private function visitCount(Appointment $appointment): int - { - return (int) $this->em->createQueryBuilder() - ->select('COUNT(a.id)') - ->from(Appointment::class, 'a') - ->where('a.user = :user') - ->andWhere('a.slotStart < :before') - ->andWhere('a.status = :status') - ->setParameter('user', $appointment->getUser()) - ->setParameter('before', $appointment->getSlotStart()) - ->setParameter('status', Appointment::STATUS_COMPLETED) - ->getQuery() - ->getSingleScalarResult(); - } - - /** مبلغ ثبت‌شدهٔ همان نوبت؛ نه قیمت امروزِ سرویس. */ - public function subtotalOf(Appointment $appointment): int - { - return (int) ($appointment->getVisitPriceRials() - ?? $appointment->getServiceItem()?->getPriceRials() - ?? 0); - } - - /** مدت ثبت‌شدهٔ همان نوبت، با بازگشت به طول بازهٔ اسلات. */ - public function durationOf(Appointment $appointment): int - { - return (int) ($appointment->getServiceTotalMinutes() - ?? max(0, intdiv($appointment->getSlotEnd() - $appointment->getSlotStart(), 60))); - } -} diff --git a/src/Policy/Simulation/SimulationSampler.php b/src/Policy/Simulation/SimulationSampler.php deleted file mode 100644 index 882743ed..00000000 --- a/src/Policy/Simulation/SimulationSampler.php +++ /dev/null @@ -1,59 +0,0 @@ -em->createQueryBuilder() - ->select('a') - ->from(Appointment::class, 'a') - ->where('a.entityType = :type') - ->andWhere('a.entityId = :id') - ->andWhere('a.status IN (:statuses)') - ->setParameter('type', $policy->getEntityType()) - ->setParameter('id', $policy->getEntityId()) - ->setParameter('statuses', [Appointment::STATUS_CONFIRMED, Appointment::STATUS_COMPLETED]) - ->orderBy('a.slotStart', 'DESC') - ->setMaxResults($size); - - if ($policy->getAddress() !== null) { - $qb->andWhere('a.addressId = :address')->setParameter('address', $policy->getAddress()->getId()); - } - - if ($policy->getServiceItem() !== null) { - $qb->andWhere('a.serviceItem = :service')->setParameter('service', $policy->getServiceItem()); - } - - if ($policy->getCatalogCategory() !== null) { - $qb->join('a.serviceItem', 'si') - ->andWhere('si.catalogCategory = :category') - ->setParameter('category', $policy->getCatalogCategory()); - } - - return $qb->getQuery()->getResult(); - } -} diff --git a/src/Policy/Template/PolicyTemplateRegistry.php b/src/Policy/Template/PolicyTemplateRegistry.php deleted file mode 100644 index ac3eb7fe..00000000 --- a/src/Policy/Template/PolicyTemplateRegistry.php +++ /dev/null @@ -1,151 +0,0 @@ - [ - 'title' => 'حداقل فاصله بین جلسات', - 'description' => 'بین دو جلسهٔ یک خدمت، حداقل چند روز فاصله باشد.', - 'category' => Policy::CATEGORY_SPACING, - 'inputs' => [ - ['key' => 'days', 'type' => 'int', 'label' => 'حداقل روز', 'min' => 1, 'max' => 365], - ], - ], - 'complex_min_duration' => [ - 'title' => 'حداقل مدت نوبت', - 'description' => 'نوبت این خدمت کمتر از این مقدار نباشد.', - 'category' => Policy::CATEGORY_TIMING, - 'inputs' => [ - ['key' => 'minutes', 'type' => 'int', 'label' => 'حداقل دقیقه', 'min' => 5, 'max' => 480], - ], - ], - 'extra_time_for_many_items' => [ - 'title' => 'زمان اضافه برای انتخاب‌های پرتعداد', - 'description' => 'وقتی بیمار بیش از N مورد انتخاب کند، به مدت نوبت اضافه شود.', - 'category' => Policy::CATEGORY_TIMING, - 'inputs' => [ - ['key' => 'item_count', 'type' => 'int', 'label' => 'بیشتر از چند مورد', 'min' => 1, 'max' => 20], - ['key' => 'minutes', 'type' => 'int', 'label' => 'دقیقهٔ اضافه', 'min' => 5, 'max' => 120], - ], - ], - 'surgery_needs_surgeon' => [ - 'title' => 'نیاز به نقش خاص', - 'description' => 'این خدمت بدون حضور نقش مشخصی انجام نشود.', - 'category' => Policy::CATEGORY_RESOURCE, - 'inputs' => [ - ['key' => 'role', 'type' => 'resource_type_select', 'label' => 'نقش لازم'], - ], - ], - 'minor_needs_consent' => [ - 'title' => 'رضایت والدین برای زیر سن قانونی', - 'description' => 'بیمار زیر سن مشخص، بدون تأیید رضایت والدین نوبت نگیرد.', - 'category' => Policy::CATEGORY_ELIGIBILITY, - 'inputs' => [ - ['key' => 'age', 'type' => 'int', 'label' => 'سن مرزی', 'min' => 1, 'max' => 100], - ], - ], - 'vip_discount' => [ - 'title' => 'تخفیف بیمار وفادار', - 'description' => 'بیمارانی که بیش از N ویزیت داشته‌اند، درصدی تخفیف بگیرند.', - 'category' => Policy::CATEGORY_PRICING, - 'inputs' => [ - ['key' => 'visit_count', 'type' => 'int', 'label' => 'بیشتر از چند ویزیت', 'min' => 1, 'max' => 100], - ['key' => 'percent', 'type' => 'int', 'label' => 'درصد تخفیف', 'min' => 1, 'max' => 100], - ], - ], - ]; - - /** @return list> */ - public function describe(): array - { - $out = []; - - foreach (self::TEMPLATES as $key => $template) { - $out[] = ['key' => $key] + $template; - } - - return $out; - } - - /** - * @param array $values - * @return array{category: string, condition: array, effects: list>} - */ - public function build(string $key, array $values): array - { - $template = self::TEMPLATES[$key] ?? null; - - if ($template === null) { - throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'الگوی قانون شناخته نمی‌شود', 422, 'template'); - } - - foreach ($template['inputs'] as $input) { - if ($input['type'] === 'int' && !is_numeric($values[$input['key']] ?? null)) { - throw new AppException( - ErrorCodes::ERR_VALIDATION_002, - sprintf('مقدار «%s» الزامی است', $input['label']), - 422, - $input['key'], - ); - } - } - - return ['category' => $template['category']] + $this->contentFor($key, $values); - } - - /** - * @param array $v - * @return array{condition: array, effects: list>} - */ - private function contentFor(string $key, array $v): array - { - return match ($key) { - 'min_days_between_sessions' => [ - 'condition' => [], - 'effects' => [['type' => PolicySchema::EFFECT_MIN_DAYS_BETWEEN, 'value' => (int) $v['days']]], - ], - 'complex_min_duration' => [ - 'condition' => [], - 'effects' => [['type' => PolicySchema::EFFECT_MIN_DURATION, 'value' => (int) $v['minutes']]], - ], - 'extra_time_for_many_items' => [ - 'condition' => ['match' => 'all', 'conditions' => [ - ['field' => 'item_count', 'operator' => PolicySchema::OP_GREATER_THAN, 'value' => (int) $v['item_count']], - ]], - 'effects' => [['type' => PolicySchema::EFFECT_ADD_DURATION, 'value' => (int) $v['minutes']]], - ], - 'surgery_needs_surgeon' => [ - 'condition' => [], - 'effects' => [['type' => PolicySchema::EFFECT_REQUIRE_RESOURCE, 'value' => (string) ($v['role'] ?? '')]], - ], - 'minor_needs_consent' => [ - 'condition' => ['match' => 'all', 'conditions' => [ - ['field' => 'patient_age', 'operator' => PolicySchema::OP_LESS_THAN, 'value' => (int) $v['age']], - ]], - 'effects' => [['type' => PolicySchema::EFFECT_REQUIRE_FLAG, 'value' => 'has_parental_consent']], - ], - 'vip_discount' => [ - 'condition' => ['match' => 'all', 'conditions' => [ - ['field' => 'visit_count', 'operator' => PolicySchema::OP_GREATER_THAN, 'value' => (int) $v['visit_count']], - ]], - 'effects' => [['type' => PolicySchema::EFFECT_DISCOUNT_PERCENT, 'value' => (int) $v['percent']]], - ], - default => throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'الگوی قانون شناخته نمی‌شود', 422, 'template'), - }; - } -} diff --git a/src/Policy/ValueObject/PolicyOutcome.php b/src/Policy/ValueObject/PolicyOutcome.php deleted file mode 100644 index 925126d7..00000000 --- a/src/Policy/ValueObject/PolicyOutcome.php +++ /dev/null @@ -1,43 +0,0 @@ - $effects نوعِ اثر => مقدار ترکیب‌شده - * @param list> $appliedPolicies - * @param list $forbidReasons پیام‌های انسانیِ ممنوعیت - */ - public function __construct( - public array $effects = [], - public array $appliedPolicies = [], - public array $forbidReasons = [], - ) {} - - public function isForbidden(): bool - { - return $this->forbidReasons !== []; - } - - public function effect(string $type, mixed $default = null): mixed - { - return $this->effects[$type] ?? $default; - } - - public function toArray(): array - { - return [ - 'effects' => (object) $this->effects, - 'applied_policies' => $this->appliedPolicies, - 'forbidden' => $this->isForbidden(), - 'forbid_reasons' => $this->forbidReasons, - ]; - } -} diff --git a/src/Report/Controller/ReportController.php b/src/Report/Controller/ReportController.php deleted file mode 100644 index 2f0883bb..00000000 --- a/src/Report/Controller/ReportController.php +++ /dev/null @@ -1,126 +0,0 @@ -query->get('branch_uuid'); - - if (!is_string($branch)) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد branch_uuid الزامی است', 422, 'branch_uuid'); - } - - $range = $this->range($request); - - if ($range === null) { - return $this->rangeError(); - } - - [$from, $to] = $range; - - $address = $this->branches->resolve($user, $branch); - - return $this->success([ - 'from' => $from, - 'to' => $to, - 'rows' => $this->utilization->report( - $this->resources->findForAddress($address), - $address, - $from, - $to, - ), - ]); - } - - #[Route('/api/v1/reports/plan-accuracy', name: 'report_plan_accuracy', methods: ['GET'])] - public function planAccuracy(#[CurrentUser] User $user, Request $request): JsonResponse - { - $range = $this->range($request); - - if ($range === null) { - return $this->rangeError(); - } - - [$from, $to] = $range; - [$entityType, $entityId] = $this->branches->pair($user); - - return $this->success([ - 'from' => $from, - 'to' => $to, - 'rows' => $this->accuracy->report($entityType, $entityId, $from, $to), - ]); - } - - /** عیب‌یابی صندوق خروجی — فقط ادمین. */ - #[Route('/api/v1/domain-events', name: 'domain_events_index', methods: ['GET'])] - #[IsGranted('ROLE_ADMIN')] - public function domainEvents(Request $request): JsonResponse - { - $name = $request->query->get('name'); - - return $this->success(array_map( - static fn (DomainEventLog $e): array => $e->toArray(), - $this->events->search( - is_string($name) ? $name : null, - null, - null, - $request->query->getInt('limit', 100), - ), - )); - } - - /** @return array{0: int, 1: int}|null `null` یعنی بازه نامعتبر است */ - private function range(Request $request): ?array - { - $to = $request->query->has('to') ? $request->query->getInt('to') : time(); - $from = $request->query->has('from') ? $request->query->getInt('from') : $to - 7 * 86400; - - if ($to <= $from || ($to - $from) > self::MAX_RANGE_DAYS * 86400) { - return null; - } - - return [$from, $to]; - } - - private function rangeError(): JsonResponse - { - return $this->error( - ErrorCodes::ERR_VALIDATION_001, - sprintf('بازهٔ گزارش باید مثبت و حداکثر %d روز باشد', self::MAX_RANGE_DAYS), - 422, - 'from', - ); - } -} diff --git a/src/Report/Service/PlanAccuracyReporter.php b/src/Report/Service/PlanAccuracyReporter.php deleted file mode 100644 index 0a2cc7d9..00000000 --- a/src/Report/Service/PlanAccuracyReporter.php +++ /dev/null @@ -1,125 +0,0 @@ -> مرتب بر اساس شدت انحراف - */ - public function report(string $entityType, int $entityId, int $from, int $to): array - { - $rows = $this->em->createQueryBuilder() - ->select( - 'si.uuid AS service_uuid', - 'si.name AS service_name', - 'COUNT(a.id) AS sample_size', - 'AVG(a.serviceTotalMinutes) AS planned', - 'AVG((a.slotEnd - a.slotStart) / 60) AS actual', - ) - ->from(Appointment::class, 'a') - ->join('a.serviceItem', 'si') - ->where('a.entityType = :type') - ->andWhere('a.entityId = :id') - ->andWhere('a.slotStart >= :from') - ->andWhere('a.slotStart < :to') - ->andWhere('a.status = :status') - ->andWhere('a.serviceTotalMinutes IS NOT NULL') - ->setParameter('type', $entityType) - ->setParameter('id', $entityId) - ->setParameter('from', $from) - ->setParameter('to', $to) - // فقط نوبت‌های انجام‌شده: لغوشده چیزی دربارهٔ مدت واقعی نمی‌گوید. - ->setParameter('status', Appointment::STATUS_COMPLETED) - ->groupBy('si.uuid') - ->addGroupBy('si.name') - ->getQuery() - ->getArrayResult(); - - $out = []; - - foreach ($rows as $row) { - $sample = (int) $row['sample_size']; - $planned = (float) $row['planned']; - $actual = (float) $row['actual']; - - if ($planned <= 0) { - continue; - } - - // زیر آستانه، **حذف نمی‌شود بلکه بی‌شدت برمی‌گردد**. - // - // میانگینِ سه نمونه معنا ندارد و نباید کسی رویش تصمیم بگیرد؛ ولی حذف کاملش - // یعنی کلینیک کوچک یک گزارش خالی می‌بیند و فکر می‌کند همه‌چیز درست است. - // این‌طوری هم عدد را می‌بیند هم می‌داند که هنوز قابل استناد نیست. - if ($sample < self::MIN_SAMPLE) { - $out[] = [ - 'service_uuid' => $row['service_uuid'], - 'service_name' => $row['service_name'], - 'sample_size' => $sample, - 'planned_minutes' => (int) round($planned), - 'actual_minutes' => (int) round($actual), - 'deviation_percent' => (int) round(($actual - $planned) / $planned * 100), - 'severity' => null, - 'below_min_sample' => true, - ]; - - continue; - } - - $deviation = (int) round(($actual - $planned) / $planned * 100); - - $out[] = [ - 'service_uuid' => $row['service_uuid'], - 'service_name' => $row['service_name'], - 'sample_size' => $sample, - 'planned_minutes' => (int) round($planned), - 'actual_minutes' => (int) round($actual), - 'deviation_percent' => $deviation, - 'severity' => $this->severityFor($deviation), - 'below_min_sample' => false, - ]; - } - - // ردیف‌های قابل استناد اول؛ بین خودشان، بدترین انحراف بالاتر. - usort($out, static fn (array $a, array $b): int - => [$a['below_min_sample'], abs($b['deviation_percent'])] - <=> [$b['below_min_sample'], abs($a['deviation_percent'])]); - - return $out; - } - - /** - * شدت از **قدر مطلق** انحراف می‌آید: سرویسی که نصف زمان پیش‌بینی‌شده طول می‌کشد هم - * غلط تعریف شده — ظرفیتی که می‌شد فروخت، خالی مانده. - */ - private function severityFor(int $deviationPercent): string - { - return match (true) { - abs($deviationPercent) >= 30 => 'high', - abs($deviationPercent) >= 15 => 'medium', - abs($deviationPercent) >= 5 => 'low', - default => 'none', - }; - } -} diff --git a/src/Report/Service/ResourceUtilizationReporter.php b/src/Report/Service/ResourceUtilizationReporter.php deleted file mode 100644 index 918425a2..00000000 --- a/src/Report/Service/ResourceUtilizationReporter.php +++ /dev/null @@ -1,185 +0,0 @@ -> - */ - public function report(array $resources, DoctorAddress $address, int $from, int $to): array - { - $occupied = $this->occupiedMinutes($resources, $from, $to); - $active = $this->activeMinutes($resources, $from, $to); - - // تقویم همهٔ منابع هم دسته‌ای خوانده می‌شود؛ وگرنه هر منبع پنج کوئری اضافه - // می‌آورد و گزارشِ یک کلینیک متوسط دویست کوئری می‌شد. - $availability = $this->calendars->rawAvailabilityForAll($resources, $from, $to); - - $rows = []; - - foreach ($resources as $resource) { - $id = (int) $resource->getId(); - $available = $this->availableMinutes($resource, $availability[$id] ?? []); - - $rows[] = $this->row( - $resource, - $available, - $occupied[$id] ?? 0, - $active[$id] ?? 0, - ); - } - - return $rows; - } - - /** @return array */ - private function row(ClinicResource $resource, int $available, int $occupied, int $active): array - { - // تقسیم بر صفر معنای متفاوتی دارد: منبعی بدون تقویم «۰٪ بهره‌وری» ندارد، - // اصلاً بهره‌وری‌اش تعریف‌نشده است. - $utilization = $available > 0 ? round($occupied / $available, 2) : null; - $activeRatio = $occupied > 0 ? round($active / $occupied, 2) : null; - - return [ - 'resource_uuid' => $resource->getUuid(), - 'resource_name' => $resource->getName(), - 'role' => $resource->getType()->getCode(), - 'available_minutes' => $available, - 'occupied_minutes' => $occupied, - 'active_minutes' => $active, - 'utilization' => $utilization, - 'active_ratio' => $activeRatio, - 'wasted_capacity' => $activeRatio !== null && $activeRatio < self::WASTE_THRESHOLD, - ]; - } - - /** @param list<\App\Resource\ValueObject\DayAvailability> $days */ - private function availableMinutes(ClinicResource $resource, array $days): int - { - $minutes = 0; - - foreach ($days as $day) { - $minutes += $day->totalMinutes(); - } - - // ظرفیت ضرب می‌شود: اتاق سه‌تخته در یک ساعت، سه ساعت-منبع عرضه دارد. بدون آن، - // هر منبع چندظرفیتی همیشه «بیش از ۱۰۰٪ بهره‌وری» نشان می‌داد. - return $minutes * max(1, $resource->getCapacity()); - } - - /** - * دقایق اشغال از `resource_occupancy` — شامل setup/cleanup، چون منبع واقعاً - * اشغال بوده. - * - * @param ClinicResource[] $resources - * @return array - */ - private function occupiedMinutes(array $resources, int $from, int $to): array - { - if ($resources === []) { - return []; - } - - $rows = $this->em->createQueryBuilder() - ->select('IDENTITY(o.resource) AS resource_id', 'SUM(o.endsAt - o.startsAt) AS seconds') - ->from(ResourceOccupancy::class, 'o') - ->where('o.resource IN (:resources)') - ->andWhere('o.startsAt < :to') - ->andWhere('o.endsAt > :from') - ->andWhere('o.status IN (:statuses)') - ->setParameter('resources', $resources) - ->setParameter('from', $from) - ->setParameter('to', $to) - // ردیف آزادشده اشغال نبوده؛ آوردنش یعنی هر لغو، بهره‌وری را بالا ببرد. - ->setParameter('statuses', ResourceOccupancy::BLOCKING_STATUSES) - ->groupBy('resource_id') - ->getQuery() - ->getArrayResult(); - - $out = []; - - foreach ($rows as $row) { - $out[(int) $row['resource_id']] = (int) round(((int) $row['seconds']) / 60); - } - - return $out; - } - - /** - * دقایقی که بیمار حاضر بوده — بخش‌های `passive` عمداً نمی‌آیند. - * - * @param ClinicResource[] $resources - * @return array - */ - private function activeMinutes(array $resources, int $from, int $to): array - { - if ($resources === []) { - return []; - } - - $sql = <<<'SQL' - SELECT o.resource_id AS resource_id, - SUM(LEAST(o.ends_at, s.ends_at) - GREATEST(o.starts_at, s.starts_at)) AS seconds - FROM resource_occupancy o - JOIN appointment_segments s - ON s.appointment_id = o.appointment_id - AND s.patient_present = 1 - AND s.starts_at < o.ends_at - AND s.ends_at > o.starts_at - WHERE o.resource_id IN (:resources) - AND o.starts_at < :to - AND o.ends_at > :from - AND o.status IN (:statuses) - GROUP BY o.resource_id - SQL; - - $rows = $this->em->getConnection()->fetchAllAssociative($sql, [ - 'resources' => array_map(static fn (ClinicResource $r): int => (int) $r->getId(), $resources), - 'from' => $from, - 'to' => $to, - 'statuses' => ResourceOccupancy::BLOCKING_STATUSES, - ], [ - 'resources' => \Doctrine\DBAL\ArrayParameterType::INTEGER, - 'statuses' => \Doctrine\DBAL\ArrayParameterType::STRING, - ]); - - $out = []; - - foreach ($rows as $row) { - $out[(int) $row['resource_id']] = (int) round(((int) $row['seconds']) / 60); - } - - return $out; - } -} diff --git a/src/Shared/Event/Command/PruneDomainEventsCommand.php b/src/Shared/Event/Command/PruneDomainEventsCommand.php deleted file mode 100644 index da1bc910..00000000 --- a/src/Shared/Event/Command/PruneDomainEventsCommand.php +++ /dev/null @@ -1,65 +0,0 @@ -addOption('days', null, InputOption::VALUE_REQUIRED, 'Retention window in days', '180') - ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report without deleting'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - $before = time() - max(1, (int) $input->getOption('days')) * 86400; - - $count = (int) $this->connection->fetchOne( - 'SELECT COUNT(*) FROM domain_events WHERE published_at IS NOT NULL AND occurred_at < ?', - [$before], - ); - - if ($count === 0) { - $io->success('رویداد قابل حذفی نیست.'); - - return Command::SUCCESS; - } - - if ($input->getOption('dry-run')) { - $io->note(sprintf('%d رویداد حذف می‌شد.', $count)); - - return Command::SUCCESS; - } - - $this->connection->executeStatement( - 'DELETE FROM domain_events WHERE published_at IS NOT NULL AND occurred_at < ?', - [$before], - ); - - $io->success(sprintf('%d رویداد حذف شد.', $count)); - - return Command::SUCCESS; - } -} diff --git a/src/Shared/Event/Command/PublishDomainEventsCommand.php b/src/Shared/Event/Command/PublishDomainEventsCommand.php deleted file mode 100644 index 8a78b860..00000000 --- a/src/Shared/Event/Command/PublishDomainEventsCommand.php +++ /dev/null @@ -1,51 +0,0 @@ -addOption('limit', null, InputOption::VALUE_REQUIRED, 'How many events to publish per run', '100'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - $result = $this->publisher->publish((int) $input->getOption('limit')); - - $io->success(sprintf('%d رویداد منتشر شد، %d ناموفق.', $result['published'], $result['failed'])); - - return Command::SUCCESS; - } - - /** @return DomainEventLog[] */ - public function pending(int $limit = 100): array - { - return $this->events->findPending($limit); - } -} diff --git a/src/Shared/Event/DomainEventPublisher.php b/src/Shared/Event/DomainEventPublisher.php deleted file mode 100644 index 55262fb9..00000000 --- a/src/Shared/Event/DomainEventPublisher.php +++ /dev/null @@ -1,49 +0,0 @@ - $payload فقط uuid و اسکالر - */ - public function record(string $entityType, int $entityId, string $name, array $payload, ?int $occurredAt = null): DomainEventLog - { - if (!in_array($name, DomainEvents::ALL, true)) { - throw new \InvalidArgumentException(sprintf('Unknown domain event "%s".', $name)); - } - - $event = new DomainEventLog($entityType, $entityId, $name, $payload, $occurredAt); - - $this->em->persist($event); - - return $event; - } - - /** - * ثبت + flush — برای جاهایی که فراخوان تراکنش باز ندارد. - * - * @param array $payload - */ - public function recordAndFlush(string $entityType, int $entityId, string $name, array $payload, ?int $occurredAt = null): DomainEventLog - { - $event = $this->record($entityType, $entityId, $name, $payload, $occurredAt); - $this->em->flush(); - - return $event; - } -} diff --git a/src/Shared/Event/DomainEvents.php b/src/Shared/Event/DomainEvents.php deleted file mode 100644 index 75a025a9..00000000 --- a/src/Shared/Event/DomainEvents.php +++ /dev/null @@ -1,44 +0,0 @@ - */ - #[ORM\Column(type: 'json')] - private array $payload; - - /** زمان **وقوع**، نه انتشار. */ - #[ORM\Column(name: 'occurred_at', type: 'integer')] - private int $occurredAt; - - #[ORM\Column(name: 'published_at', type: 'integer', nullable: true)] - private ?int $publishedAt = null; - - #[ORM\Column(type: 'smallint', options: ['default' => 0])] - private int $attempts = 0; - - #[ORM\Column(name: 'last_error', type: 'string', length: 255, nullable: true)] - private ?string $lastError = null; - - /** @param array $payload */ - public function __construct(string $entityType, int $entityId, string $name, array $payload, ?int $occurredAt = null) - { - $this->uuid = Uuid::v4()->toRfc4122(); - $this->name = $name; - $this->payload = self::scalarsOnly($payload); - $this->occurredAt = $occurredAt ?? time(); - - $this->assignTenantPair($entityType, $entityId); - } - - /** - * هیچ entity ای در رویداد نیست — فقط uuid و اسکالر. - * - * entity در پیام async یعنی سریال‌سازی، detach شدن، و دادهٔ کهنه؛ مصرف‌کننده باید - * خودش با uuid واکشی کند تا همیشه تازه‌ترین حالت را ببیند. - * - * @param array $payload - * @return array - */ - private static function scalarsOnly(array $payload): array - { - return array_filter($payload, static fn (mixed $v): bool => is_scalar($v) || $v === null); - } - - public function getId(): ?string { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getName(): string { return $this->name; } - public function getPayload(): array { return $this->payload; } - public function getOccurredAt(): int { return $this->occurredAt; } - public function getPublishedAt(): ?int { return $this->publishedAt; } - public function getAttempts(): int { return $this->attempts; } - public function getLastError(): ?string { return $this->lastError; } - public function isPublished(): bool { return $this->publishedAt !== null; } - - public function markPublished(?int $at = null): self - { - $this->publishedAt = $at ?? time(); - $this->lastError = null; - - return $this; - } - - public function markFailed(string $error): self - { - $this->attempts++; - $this->lastError = mb_substr($error, 0, 255); - - return $this; - } - - /** @return array */ - public function toArray(): array - { - return [ - 'uuid' => $this->uuid, - 'name' => $this->name, - 'payload' => (object) $this->payload, - 'occurred_at' => $this->occurredAt, - 'published_at' => $this->publishedAt, - 'attempts' => $this->attempts, - 'last_error' => $this->lastError, - ]; - } -} diff --git a/src/Shared/Event/Message/DomainEventMessage.php b/src/Shared/Event/Message/DomainEventMessage.php deleted file mode 100644 index cbb91f9f..00000000 --- a/src/Shared/Event/Message/DomainEventMessage.php +++ /dev/null @@ -1,22 +0,0 @@ - $payload */ - public function __construct( - public string $uuid, - public string $name, - public string $entityType, - public int $entityId, - public array $payload, - public int $occurredAt, - ) {} -} diff --git a/src/Shared/Event/Message/PublishDomainEventsMessage.php b/src/Shared/Event/Message/PublishDomainEventsMessage.php deleted file mode 100644 index 53096f41..00000000 --- a/src/Shared/Event/Message/PublishDomainEventsMessage.php +++ /dev/null @@ -1,13 +0,0 @@ -logger->info('domain event published', [ - 'uuid' => $message->uuid, - 'name' => $message->name, - 'entity_type' => $message->entityType, - 'entity_id' => $message->entityId, - ]); - } -} diff --git a/src/Shared/Event/MessageHandler/PublishDomainEventsHandler.php b/src/Shared/Event/MessageHandler/PublishDomainEventsHandler.php deleted file mode 100644 index f811c910..00000000 --- a/src/Shared/Event/MessageHandler/PublishDomainEventsHandler.php +++ /dev/null @@ -1,18 +0,0 @@ -publisher->publish(); - } -} diff --git a/src/Shared/Event/Repository/DomainEventLogRepository.php b/src/Shared/Event/Repository/DomainEventLogRepository.php deleted file mode 100644 index 6e83a688..00000000 --- a/src/Shared/Event/Repository/DomainEventLogRepository.php +++ /dev/null @@ -1,58 +0,0 @@ - */ -class DomainEventLogRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, DomainEventLog::class); - } - - /** - * ردیف‌های منتشرنشده‌ای که هنوز سقف تلاش را رد نکرده‌اند. - * - * @return DomainEventLog[] - */ - public function findPending(int $limit = 100): array - { - return $this->createQueryBuilder('e') - ->where('e.publishedAt IS NULL') - ->andWhere('e.attempts < :max') - ->setParameter('max', DomainEventLog::MAX_ATTEMPTS) - ->orderBy('e.occurredAt', 'ASC') - ->addOrderBy('e.id', 'ASC') - ->setMaxResults($limit) - ->getQuery() - ->getResult(); - } - - /** - * @return DomainEventLog[] - */ - public function search(?string $name, ?string $entityType, ?int $entityId, int $limit = 100): array - { - $qb = $this->createQueryBuilder('e') - ->orderBy('e.occurredAt', 'DESC') - ->addOrderBy('e.id', 'DESC') - ->setMaxResults(min($limit, 500)); - - if ($name !== null && $name !== '') { - $qb->andWhere('e.name = :name')->setParameter('name', $name); - } - - if ($entityType !== null && $entityId !== null) { - $qb->andWhere('e.entityType = :type') - ->andWhere('e.entityId = :id') - ->setParameter('type', $entityType) - ->setParameter('id', $entityId); - } - - return $qb->getQuery()->getResult(); - } -} diff --git a/src/Shared/Event/Service/OutboxPublisher.php b/src/Shared/Event/Service/OutboxPublisher.php deleted file mode 100644 index 0efb16b5..00000000 --- a/src/Shared/Event/Service/OutboxPublisher.php +++ /dev/null @@ -1,62 +0,0 @@ -events->findPending(max(1, $limit)); - $published = 0; - $failed = 0; - - foreach ($pending as $event) { - try { - $this->bus->dispatch(new DomainEventMessage( - $event->getUuid(), - $event->getName(), - $event->getEntityType(), - $event->getEntityId(), - $event->getPayload(), - $event->getOccurredAt(), - )); - - $event->markPublished(); - $published++; - } catch (\Throwable $e) { - $event->markFailed($e->getMessage()); - $failed++; - } - } - - if ($pending !== []) { - $this->em->flush(); - } - - return ['published' => $published, 'failed' => $failed]; - } -} diff --git a/src/Waitlist/Command/ExpireWaitlistCommand.php b/src/Waitlist/Command/ExpireWaitlistCommand.php deleted file mode 100644 index f222d1b2..00000000 --- a/src/Waitlist/Command/ExpireWaitlistCommand.php +++ /dev/null @@ -1,32 +0,0 @@ -expirer->expire(); - - $io->success(sprintf('%d ردیف لیست انتظار منقضی شد.', $count)); - - return Command::SUCCESS; - } -} diff --git a/src/Waitlist/Controller/WaitlistController.php b/src/Waitlist/Controller/WaitlistController.php deleted file mode 100644 index 845825ca..00000000 --- a/src/Waitlist/Controller/WaitlistController.php +++ /dev/null @@ -1,204 +0,0 @@ -branches->pair($user); - - $status = $request->query->get('status'); - - return $this->success(array_map( - static fn (WaitlistEntry $e): array => $e->toArray(), - $this->entries->findForPair($entityType, $entityId, is_string($status) && $status !== '' ? $status : null), - )); - } - - #[Route('/api/v1/waitlist', name: 'waitlist_create', methods: ['POST'])] - public function create(#[CurrentUser] User $user, Request $request): JsonResponse - { - $data = json_decode($request->getContent(), true); - - if (!is_array($data) || !is_string($data['patient_uuid'] ?? null)) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شناسهٔ بیمار الزامی است', 422, 'patient_uuid'); - } - - if (!is_string($data['service_uuid'] ?? null)) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شناسهٔ سرویس الزامی است', 422, 'service_uuid'); - } - - foreach (['desired_from', 'desired_to'] as $field) { - if (!is_numeric($data[$field] ?? null)) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, sprintf('فیلد %s الزامی است', $field), 422, $field); - } - } - - $from = (int) $data['desired_from']; - $to = (int) $data['desired_to']; - - // بازهٔ گذشته یعنی انتظاری که هرگز به نتیجه نمی‌رسد. - if ($to <= time()) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بازهٔ انتظار باید در آینده باشد', 422, 'desired_to'); - } - - if ($to <= $from) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پایان بازه باید بعد از شروع آن باشد', 422, 'desired_to'); - } - - // بازهٔ باز تا ابد یعنی ردیفی که هرگز منقضی نمی‌شود و برای همیشه در هر تطبیقی - // می‌آید؛ سقف همان افق رزرو است. - if ($to - $from > self::MAX_RANGE_DAYS * 86400) { - return $this->error( - ErrorCodes::ERR_VALIDATION_001, - sprintf('بازهٔ انتظار حداکثر %d روز است', self::MAX_RANGE_DAYS), - 422, - 'desired_to', - ); - } - - $patient = $this->requirePatient($user, $data['patient_uuid']); - $service = $this->requireItem($user, $data['service_uuid']); - - $branchId = null; - - if (is_string($data['branch_uuid'] ?? null)) { - $branchId = $this->branches->resolve($user, $data['branch_uuid'])->getId(); - } - - $entry = new WaitlistEntry($patient, $service, $from, $to, $branchId); - - if (is_array($data['preferred_day_parts'] ?? null)) { - try { - $entry->setPreferredDayParts($data['preferred_day_parts']); - } catch (\InvalidArgumentException) { - return $this->error( - ErrorCodes::ERR_VALIDATION_001, - sprintf('بخش روز باید یکی از این‌ها باشد: %s', implode('، ', array_keys(WaitlistEntry::DAY_PARTS))), - 422, - 'preferred_day_parts', - ); - } - } - - if (is_numeric($data['priority'] ?? null)) { - $entry->setPriority((int) $data['priority']); - } - - $this->entries->save($entry); - - return $this->success($entry->toArray(), 201); - } - - #[Route('/api/v1/waitlist/{uuid}', name: 'waitlist_delete', methods: ['DELETE'])] - public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse - { - $entry = $this->requireEntry($user, $uuid); - - $this->em->remove($entry); - $this->em->flush(); - - return $this->success(null); - } - - /** - * درخواست‌هایی که با یک زمان مشخص می‌خوانند — ابزار پنل هنگام آزاد شدن ظرفیت. - */ - #[Route('/api/v1/waitlist/matches', name: 'waitlist_matches', methods: ['GET'])] - public function matches(#[CurrentUser] User $user, Request $request): JsonResponse - { - $serviceUuid = $request->query->get('service_uuid'); - $start = $request->query->get('start'); - - if (!is_string($serviceUuid) || !is_numeric($start)) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلدهای service_uuid و start الزامی‌اند', 422, 'service_uuid'); - } - - $service = $this->requireItem($user, $serviceUuid); - $branch = $request->query->get('branch_uuid'); - $branchId = is_string($branch) ? $this->branches->resolve($user, $branch)->getId() : null; - - return $this->success(array_map( - static fn (WaitlistEntry $e): array => $e->toArray(), - $this->entries->findMatching($service, (int) $start, $branchId), - )); - } - - private function requirePatient(User $user, string $uuid): 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; - } - - private function requireItem(User $user, string $uuid): ServiceItem - { - $item = $this->items->findByUuid($uuid); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($item === null - || $item->getSection()->getEntityType() !== $entityType - || $item->getSection()->getEntityId() !== $entityId - ) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); - } - - return $item; - } - - private function requireEntry(User $user, string $uuid): WaitlistEntry - { - $entry = $this->entries->findByUuid($uuid); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($entry === null || !$this->ownership->belongsToPair($entityType, $entityId, $entry)) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'درخواست لیست انتظار یافت نشد', 404); - } - - return $entry; - } -} diff --git a/src/Waitlist/Entity/WaitlistEntry.php b/src/Waitlist/Entity/WaitlistEntry.php deleted file mode 100644 index de549513..00000000 --- a/src/Waitlist/Entity/WaitlistEntry.php +++ /dev/null @@ -1,252 +0,0 @@ - - */ - public const DAY_PARTS = [ - 'morning' => ['label' => 'صبح', 'from' => 6, 'to' => 12], - 'afternoon' => ['label' => 'بعدازظهر', 'from' => 12, 'to' => 17], - 'evening' => ['label' => 'عصر', 'from' => 17, 'to' => 22], - ]; - - #[ORM\Id] - #[ORM\GeneratedValue] - #[ORM\Column(type: 'integer')] - private ?int $id = null; - - #[ORM\Column(type: 'string', length: 36, unique: true)] - private string $uuid; - - #[ORM\ManyToOne(targetEntity: PatientRecord::class)] - #[ORM\JoinColumn(name: 'patient_record_id', nullable: false, onDelete: 'CASCADE')] - private PatientRecord $patientRecord; - - #[ORM\ManyToOne(targetEntity: ServiceItem::class)] - #[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'CASCADE')] - private ServiceItem $serviceItem; - - #[ORM\Column(name: 'branch_id', type: 'integer', nullable: true)] - private ?int $branchId = null; - - #[ORM\Column(name: 'desired_from', type: 'integer')] - private int $desiredFrom; - - #[ORM\Column(name: 'desired_to', type: 'integer')] - private int $desiredTo; - - /** @var list|null `["morning","evening"]` */ - #[ORM\Column(name: 'preferred_day_parts', type: 'json', nullable: true)] - private ?array $preferredDayParts = null; - - #[ORM\Column(type: 'smallint', options: ['default' => 0])] - private int $priority = 0; - - #[ORM\Column(type: 'string', length: 12, options: ['default' => self::STATUS_WAITING])] - private string $status = self::STATUS_WAITING; - - #[ORM\Column(name: 'notified_at', type: 'integer', nullable: true)] - private ?int $notifiedAt = null; - - #[ORM\Column(name: 'notify_count', type: 'smallint', options: ['default' => 0])] - private int $notifyCount = 0; - - #[ORM\ManyToOne(targetEntity: Appointment::class)] - #[ORM\JoinColumn(name: 'converted_appointment_id', nullable: true, onDelete: 'SET NULL')] - private ?Appointment $convertedAppointment = null; - - #[ORM\Column(name: 'created_at', type: 'integer')] - private int $createdAt; - - #[ORM\Column(name: 'updated_at', type: 'integer')] - private int $updatedAt; - - public function __construct( - PatientRecord $patientRecord, - ServiceItem $serviceItem, - int $desiredFrom, - int $desiredTo, - ?int $branchId = null, - ) { - if ($desiredTo <= $desiredFrom) { - throw new \InvalidArgumentException('The waitlist window must end after it starts.'); - } - - $this->uuid = Uuid::v4()->toRfc4122(); - $this->patientRecord = $patientRecord; - $this->serviceItem = $serviceItem; - $this->desiredFrom = $desiredFrom; - $this->desiredTo = $desiredTo; - $this->branchId = $branchId; - $this->createdAt = time(); - $this->updatedAt = time(); - - $this->assignTenantPair($patientRecord->getEntityType(), $patientRecord->getEntityId()); - } - - public function getId(): ?int { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getPatientRecord(): PatientRecord { return $this->patientRecord; } - public function getServiceItem(): ServiceItem { return $this->serviceItem; } - public function getBranchId(): ?int { return $this->branchId; } - public function getDesiredFrom(): int { return $this->desiredFrom; } - public function getDesiredTo(): int { return $this->desiredTo; } - public function getPreferredDayParts(): array { return $this->preferredDayParts ?? []; } - public function getPriority(): int { return $this->priority; } - public function getStatus(): string { return $this->status; } - public function getNotifiedAt(): ?int { return $this->notifiedAt; } - public function getNotifyCount(): int { return $this->notifyCount; } - - /** - * @param list $parts - * @throws \InvalidArgumentException روی بخشی که در فهرست بسته نیست - */ - public function setPreferredDayParts(array $parts): self - { - $clean = array_values(array_unique(array_filter($parts, 'is_string'))); - - foreach ($clean as $part) { - if (!isset(self::DAY_PARTS[$part])) { - throw new \InvalidArgumentException(sprintf('Unknown day part "%s".', $part)); - } - } - - $this->preferredDayParts = $clean === [] ? null : $clean; - - return $this->touch(); - } - - public function setPriority(int $v): self { $this->priority = $v; return $this->touch(); } - - public function markNotified(?int $at = null): self - { - $this->status = self::STATUS_NOTIFIED; - $this->notifiedAt = $at ?? time(); - $this->notifyCount++; - - return $this->touch(); - } - - public function markConverted(Appointment $appointment): self - { - $this->status = self::STATUS_CONVERTED; - $this->convertedAppointment = $appointment; - - return $this->touch(); - } - - public function markExpired(): self - { - $this->status = self::STATUS_EXPIRED; - - return $this->touch(); - } - - /** هنوز منتظر است و سقف اطلاع‌رسانی را رد نکرده. */ - public function isNotifiable(?int $now = null): bool - { - $now = $now ?? time(); - - return in_array($this->status, [self::STATUS_WAITING, self::STATUS_NOTIFIED], true) - && $this->notifyCount < self::MAX_NOTIFICATIONS - && $this->desiredTo >= $now; - } - - public function covers(int $start): bool - { - return $start >= $this->desiredFrom && $start <= $this->desiredTo; - } - - /** - * آیا این زمان در یکی از بخش‌های روزِ خواسته‌شده می‌افتد؟ - * - * نداشتنِ ترجیح یعنی «هر ساعتی» — نه «هیچ ساعتی». ساعت به وقت **محلی شعبه** - * حساب می‌شود، چون بیمار «عصر» را با ساعت خودش می‌فهمد نه با UTC. - */ - public function coversDayPart(int $start, string $timezone = DoctorAddress::DEFAULT_TIMEZONE): bool - { - if ($this->preferredDayParts === null || $this->preferredDayParts === []) { - return true; - } - - $hour = (int) (new \DateTimeImmutable('@' . $start)) - ->setTimezone(new \DateTimeZone($timezone)) - ->format('G'); - - foreach ($this->preferredDayParts as $part) { - $range = self::DAY_PARTS[$part] ?? null; - - if ($range !== null && $hour >= $range['from'] && $hour < $range['to']) { - return true; - } - } - - return false; - } - - private function touch(): self - { - $this->updatedAt = time(); - - return $this; - } - - /** @return array */ - public function toArray(): array - { - return [ - 'uuid' => $this->uuid, - 'patient_uuid' => $this->patientRecord->getUuid(), - 'service_uuid' => $this->serviceItem->getUuid(), - 'service_name' => $this->serviceItem->getName(), - 'branch_id' => $this->branchId, - 'desired_from' => $this->desiredFrom, - 'desired_to' => $this->desiredTo, - 'preferred_day_parts' => $this->preferredDayParts ?? [], - 'priority' => $this->priority, - 'status' => $this->status, - 'notified_at' => $this->notifiedAt, - 'notify_count' => $this->notifyCount, - 'created_at' => $this->createdAt, - ]; - } -} diff --git a/src/Waitlist/Message/ExpireWaitlistMessage.php b/src/Waitlist/Message/ExpireWaitlistMessage.php deleted file mode 100644 index 780448ce..00000000 --- a/src/Waitlist/Message/ExpireWaitlistMessage.php +++ /dev/null @@ -1,10 +0,0 @@ -expirer->expire(); - } -} diff --git a/src/Waitlist/MessageHandler/WaitlistConversionHandler.php b/src/Waitlist/MessageHandler/WaitlistConversionHandler.php deleted file mode 100644 index 5c87c223..00000000 --- a/src/Waitlist/MessageHandler/WaitlistConversionHandler.php +++ /dev/null @@ -1,49 +0,0 @@ -name !== DomainEvents::APPOINTMENT_BOOKED) { - return; - } - - $uuid = $message->payload['appointment_uuid'] ?? null; - - if (!is_string($uuid)) { - return; - } - - $appointment = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]); - - // نوبتِ لغوشده بین انتشار و مصرف: چیزی برای تبدیل نمانده. - if ($appointment !== null) { - $this->converter->convertFor($appointment); - } - } -} diff --git a/src/Waitlist/Repository/WaitlistEntryRepository.php b/src/Waitlist/Repository/WaitlistEntryRepository.php deleted file mode 100644 index 60635fca..00000000 --- a/src/Waitlist/Repository/WaitlistEntryRepository.php +++ /dev/null @@ -1,92 +0,0 @@ - */ -class WaitlistEntryRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, WaitlistEntry::class); - } - - public function findByUuid(string $uuid): ?WaitlistEntry - { - return $this->findOneBy(['uuid' => $uuid]); - } - - /** - * چه کسانی منتظر این سرویس در این لحظه‌اند؟ — کوئری داغِ لحظهٔ لغو. - * - * شعبهٔ تهی یعنی «هر شعبه»؛ کسی که شعبه مشخص کرده فقط برای همان شعبه خبر می‌شود. - * - * @return WaitlistEntry[] مرتب بر اساس اولویت، بعد قدمت - */ - public function findMatching(ServiceItem $service, int $start, ?int $branchId, ?int $now = null): array - { - $now = $now ?? time(); - - $qb = $this->createQueryBuilder('w') - ->where('w.serviceItem = :service') - ->andWhere('w.status IN (:open)') - ->andWhere('w.desiredFrom <= :start') - ->andWhere('w.desiredTo >= :start') - ->andWhere('w.desiredTo >= :now') - ->andWhere('w.notifyCount < :maxNotifications') - ->setParameter('service', $service) - ->setParameter('open', [WaitlistEntry::STATUS_WAITING, WaitlistEntry::STATUS_NOTIFIED]) - ->setParameter('start', $start) - ->setParameter('now', $now) - ->setParameter('maxNotifications', WaitlistEntry::MAX_NOTIFICATIONS) - ->orderBy('w.priority', 'DESC') - ->addOrderBy('w.createdAt', 'ASC'); - - // شعبهٔ تهی روی خودِ ردیف یعنی «هر شعبه»؛ پس وقتی ظرفیت یک شعبهٔ مشخص آزاد - // می‌شود، هم بی‌قیدها خبر می‌شوند هم آن‌هایی که همان شعبه را خواسته‌اند. - if ($branchId !== null) { - $qb->andWhere('w.branchId IS NULL OR w.branchId = :branch') - ->setParameter('branch', $branchId); - } - - return $qb->getQuery()->getResult(); - } - - /** @return WaitlistEntry[] */ - public function findForPair(string $entityType, int $entityId, ?string $status = null): array - { - $qb = $this->createQueryBuilder('w') - ->where('w.entityType = :type') - ->andWhere('w.entityId = :id') - ->setParameter('type', $entityType) - ->setParameter('id', $entityId) - ->orderBy('w.priority', 'DESC') - ->addOrderBy('w.createdAt', 'DESC'); - - if ($status !== null) { - $qb->andWhere('w.status = :status')->setParameter('status', $status); - } - - return $qb->getQuery()->getResult(); - } - - /** @return WaitlistEntry[] */ - public function findForPatient(PatientRecord $patient): array - { - return $this->findBy(['patientRecord' => $patient], ['createdAt' => 'DESC']); - } - - public function save(WaitlistEntry $entry, bool $flush = true): void - { - $this->getEntityManager()->persist($entry); - - if ($flush) { - $this->getEntityManager()->flush(); - } - } -} diff --git a/src/Waitlist/Service/WaitlistConverter.php b/src/Waitlist/Service/WaitlistConverter.php deleted file mode 100644 index f7bfd894..00000000 --- a/src/Waitlist/Service/WaitlistConverter.php +++ /dev/null @@ -1,73 +0,0 @@ -getServiceItem(); - - if ($service === null) { - return 0; - } - - $patient = $this->patients->findOneBy([ - 'user' => $appointment->getUser(), - 'entityType' => $appointment->getEntityType(), - 'entityId' => $appointment->getEntityId(), - ]); - - if ($patient === null) { - return 0; - } - - $converted = 0; - - foreach ($this->entries->findForPatient($patient) as $entry) { - // ردیفِ بسته دوباره بسته نمی‌شود — همین idempotency تحویل دوبارهٔ پیام است. - if (!in_array($entry->getStatus(), [WaitlistEntry::STATUS_WAITING, WaitlistEntry::STATUS_NOTIFIED], true)) { - continue; - } - - if ($entry->getServiceItem()->getId() !== $service->getId()) { - continue; - } - - if (!$entry->covers($appointment->getSlotStart())) { - continue; - } - - $entry->markConverted($appointment); - $converted++; - } - - if ($converted > 0) { - $this->em->flush(); - } - - return $converted; - } -} diff --git a/src/Waitlist/Service/WaitlistExpirer.php b/src/Waitlist/Service/WaitlistExpirer.php deleted file mode 100644 index 92ff3a3c..00000000 --- a/src/Waitlist/Service/WaitlistExpirer.php +++ /dev/null @@ -1,39 +0,0 @@ -= now`)، پس این پاکسازیِ **نمایش** - * است نه اصلاح رفتار: بدون آن، صفحهٔ لیست انتظار پر می‌شود از انتظارهای مرده و اپراتور - * نمی‌فهمد کدام‌شان هنوز زنده است. - * - * حذف نمی‌کند، وضعیت را عوض می‌کند — چه کسی منتظر ماند و به نتیجه نرسید، خودش داده است. - */ -final class WaitlistExpirer -{ - public function __construct(private readonly Connection $connection) {} - - /** - * @return int تعداد ردیف‌های منقضی‌شده - */ - public function expire(?int $now = null): int - { - return (int) $this->connection->executeStatement( - 'UPDATE waitlist_entries - SET status = :expired, updated_at = :now - WHERE status IN (:open) - AND desired_to < :now', - [ - 'expired' => WaitlistEntry::STATUS_EXPIRED, - 'now' => $now ?? time(), - 'open' => [WaitlistEntry::STATUS_WAITING, WaitlistEntry::STATUS_NOTIFIED], - ], - ['open' => \Doctrine\DBAL\ArrayParameterType::STRING], - ); - } -} diff --git a/src/Waitlist/Service/WaitlistNotifier.php b/src/Waitlist/Service/WaitlistNotifier.php deleted file mode 100644 index f5547212..00000000 --- a/src/Waitlist/Service/WaitlistNotifier.php +++ /dev/null @@ -1,113 +0,0 @@ -getServiceItem(); - - if ($service === null) { - return 0; - } - - $matches = $this->entries->findMatching( - $service, - $appointment->getSlotStart(), - $appointment->getAddressId(), - $now, - ); - - $timezone = $this->timezoneOf($appointment->getAddressId()); - - // فیلتر بخش روز **قبل از** بریدن به ده نفر اعمال می‌شود، وگرنه ده جای اول را - // کسانی پر می‌کنند که این ساعت را نمی‌خواستند و نفر یازدهمِ واقعی خبر نمی‌شود. - $matches = array_values(array_filter( - $matches, - static fn (WaitlistEntry $e): bool => $e->coversDayPart($appointment->getSlotStart(), $timezone), - )); - - $notified = 0; - - foreach (array_slice($matches, 0, self::MAX_RECIPIENTS) as $entry) { - if (!$entry->isNotifiable($now)) { - continue; - } - - $this->notify($entry, $appointment->getSlotStart()); - $notified++; - } - - if ($notified > 0) { - $this->em->flush(); - } - - return $notified; - } - - /** شعبهٔ ناشناخته به منطقهٔ زمانی پیش‌فرض برمی‌گردد؛ نبودِ شعبه نباید تطبیق را بخواباند. */ - private function timezoneOf(?int $addressId): string - { - if ($addressId === null) { - return DoctorAddress::DEFAULT_TIMEZONE; - } - - return $this->addresses->find($addressId)?->getTimezone() ?? DoctorAddress::DEFAULT_TIMEZONE; - } - - private function notify(WaitlistEntry $entry, int $slotStart): void - { - $mobile = $entry->getPatientRecord()->getUser()->getMobileNumber(); - - if ($mobile !== '') { - $this->sms->dispatchAsync($mobile, $this->messageFor($entry, $slotStart)); - } - - $entry->markNotified(); - } - - /** جملهٔ «اولین نفر می‌برد» اجباری است — وگرنه انتظارِ اشتباه می‌سازد. */ - private function messageFor(WaitlistEntry $entry, int $slotStart): string - { - return sprintf( - 'یک وقت برای «%s» در تاریخ %s آزاد شد. اولین نفری که رزرو کند آن را می‌گیرد.', - $entry->getServiceItem()->getName(), - $this->jalali->formatDateTime($slotStart), - ); - } -} diff --git a/tests/Cancellation/CancellationTest.php b/tests/Cancellation/CancellationTest.php deleted file mode 100644 index 45d38930..00000000 --- a/tests/Cancellation/CancellationTest.php +++ /dev/null @@ -1,548 +0,0 @@ -createUser(['ROLE_USER', 'ROLE_CLINIC']); - $clinic = new Clinic($user); - $clinic->setName('کلینیک لغو'); - $this->em->persist($clinic); - $this->em->flush(); - - $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); - $this->em->persist($section); - - $address = DoctorAddress::forClinic($clinic->getId()); - $address->setName('شعبهٔ مرکزی'); - $this->em->persist($address); - - $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); - $doctor = new Doctor($doctorUser, 'دکتر لغو'); - $this->em->persist($doctor); - - $patientUser = $this->createUser(['ROLE_USER']); - $patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId()); - $this->em->persist($patient); - $this->em->flush(); - - return [$user, $section, $address, $doctor, $patient]; - } - - private function service(ServiceSection $section, string $name = 'لیزر'): ServiceItem - { - $item = new ServiceItem($section, $name); - $item->setSoloDurationMinutes(30); - $item->setPriceRials(4_000_000); - $this->em->persist($item); - $this->em->flush(); - - return $item; - } - - /** @param array $body */ - private function savePolicy(User $user, array $body): array - { - $saved = $this->authJson('PUT', '/api/v1/cancellation-policy', $user, $body); - self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE)); - - return $saved['data']; - } - - /** نوبتی در آینده، با مبلغ ثبت‌شده و در صورت نیاز پرداخت موفق. */ - private function appointment( - Doctor $doctor, - PatientRecord $patient, - ServiceItem $service, - int $clinicId, - int $hoursAhead, - int $price = 4_000_000, - int $paid = 0, - ): Appointment { - $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); - - $start = time() + $hoursAhead * 3600 + (++$this->slotCursor) * 60; - - $appointment = new Appointment( - $em->getRepository(Doctor::class)->find($doctor->getId()), - $em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(), - $start, - $start + 1800, - ); - $appointment->assignTenantPair('clinic', $clinicId); - $appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId())); - $appointment->setVisitPriceRials($price); - $appointment->setPatientName('بیمار لغو'); - $appointment->transitionTo(Appointment::STATUS_CONFIRMED); - - $em->persist($appointment); - $em->flush(); - - if ($paid > 0) { - $payment = new Payment($appointment->getUser(), $paid, 'zarinpal', Payment::TYPE_APPOINTMENT); - $payment->assignTenantPair('clinic', $clinicId); - $payment->setAppointment($appointment); - $payment->setStatus(Payment::STATUS_SUCCESS); - $em->persist($payment); - $em->flush(); - } - - return $appointment; - } - - private function wallet(): WalletService - { - return static::getContainer()->get(WalletService::class); - } - - // ── پیش‌نمایش ─────────────────────────────────────────────────────────── - - public function testInsideTheFreeWindowThereIsNoPenalty(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $this->savePolicy($user, [ - 'free_window_hours' => 24, - 'penalty_mode' => 'percent', - 'penalty_value' => 50, - 'deposit_refundable' => false, - ]); - - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 48, paid: 4_000_000); - - $preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user); - - self::assertSame(200, $this->responseCode(), json_encode($preview, JSON_UNESCAPED_UNICODE)); - self::assertSame(0, $preview['data']['penalty_rials']); - self::assertTrue($preview['data']['deposit_refundable']); - self::assertTrue($preview['data']['within_free_window']); - } - - public function testOutsideTheFreeWindowThePercentagePenaltyApplies(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $this->savePolicy($user, [ - 'free_window_hours' => 24, - 'penalty_mode' => 'percent', - 'penalty_value' => 50, - 'deposit_refundable' => false, - ]); - - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 6, paid: 4_000_000); - - $preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data']; - - self::assertSame(2_000_000, $preview['penalty_rials']); - self::assertFalse($preview['deposit_refundable']); - self::assertFalse($preview['within_free_window']); - } - - /** ⭐ لغو توسط کلینیک هرگز جریمه ندارد، حتی یک ساعت مانده به نوبت. */ - public function testTheClinicCancellingItsOwnAppointmentIsAlwaysFree(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 100]); - - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 1, paid: 4_000_000); - - $preview = $this->authJson( - 'GET', - "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview?by=doctor", - $user, - )['data']; - - self::assertSame(0, $preview['penalty_rials']); - self::assertTrue($preview['deposit_refundable']); - } - - /** ⭐ جریمهٔ بیشتر از پرداختی یعنی بدهی، و بدهی مسئلهٔ لغو نیست. */ - public function testThePenaltyNeverExceedsWhatWasPaid(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $this->savePolicy($user, [ - 'free_window_hours' => 24, - 'penalty_mode' => 'fixed', - 'penalty_value' => 9_000_000, - ]); - - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2, paid: 1_000_000); - - $preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data']; - - self::assertSame(1_000_000, $preview['penalty_rials']); - self::assertNotEmpty($preview['notes']); - } - - /** نوبت نقدی: جریمه صفر می‌شود و پاسخ توضیحش را می‌دهد. */ - public function testAnUnpaidAppointmentIsNotCharged(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 50]); - - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2); - - $preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data']; - - self::assertSame(0, $preview['penalty_rials']); - self::assertStringContainsString('پرداختی نداشته', implode(' ', $preview['notes'])); - } - - public function testWithoutAPolicyNothingIsCharged(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 1, paid: 4_000_000); - - $preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data']; - - self::assertSame(0, $preview['penalty_rials']); - } - - // ── لغو واقعی ─────────────────────────────────────────────────────────── - - public function testCancellingChargesTheWalletAndReleasesTheSlot(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 25]); - - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 3, paid: 4_000_000); - - // کیف پول باید موجودی داشته باشد وگرنه جریمه کسر نمی‌شود. - $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); - $this->wallet()->charge($em->getRepository(\App\Auth\Entity\User::class)->find($appointment->getUser()->getId()), 5_000_000); - - $body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); - - self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - self::assertSame(1_000_000, $body['data']['penalty_rials']); - self::assertTrue($body['data']['penalty_charged']); - self::assertSame('cancelled_by_user', $body['data']['status']); - - $patientUser = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class) - ->getRepository(\App\Auth\Entity\User::class) - ->find($appointment->getUser()->getId()); - - self::assertSame(4_000_000, $this->wallet()->balance($patientUser), 'جریمه باید از کیف پول کسر شود'); - } - - /** موجودی ناکافی نباید لغو را شکست بدهد؛ نوبت باید آزاد شود. */ - public function testAnEmptyWalletDoesNotBlockTheCancellation(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 50]); - - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 3, paid: 4_000_000); - - $body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); - - self::assertSame(200, $this->responseCode()); - self::assertSame(2_000_000, $body['data']['penalty_rials']); - self::assertFalse($body['data']['penalty_charged'], 'موجودی نبود، پس کسر نشد — ولی نوبت لغو شد'); - self::assertSame('cancelled_by_user', $body['data']['status']); - } - - public function testCancellingTwiceIsRejected(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 30); - - $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); - self::assertSame(200, $this->responseCode()); - - $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); - self::assertSame(409, $this->responseCode()); - } - - /** برای گذشته `no_show` یا `completed` معنا دارد، نه لغو. */ - public function testAPastAppointmentCannotBeCancelled(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 5); - - $this->em->getConnection()->executeStatement( - 'UPDATE appointments SET slot_start = ?, slot_end = ? WHERE uuid = ?', - [time() - 7200, time() - 5400, $appointment->getUuid()], - ); - $this->em->clear(); - - $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); - - self::assertSame(422, $this->responseCode()); - } - - // ── عدم حضور ──────────────────────────────────────────────────────────── - - /** ⭐ سومین عدم حضور برچسب پرریسک می‌گذارد — ولی بیمار را مسدود نمی‌کند. */ - public function testTheThirdNoShowTagsThePatient(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $tag = new TenantTag('clinic', (int) $address->getClinicId(), 'پرریسک', '#dc2626'); - $this->em->persist($tag); - $this->em->flush(); - - $this->savePolicy($user, ['no_show_threshold' => 3, 'risk_tag_uuid' => $tag->getUuid()]); - - $last = null; - - for ($i = 1; $i <= 3; $i++) { - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2); - $last = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user); - self::assertSame(200, $this->responseCode(), json_encode($last, JSON_UNESCAPED_UNICODE)); - } - - self::assertSame(3, $last['data']['count']); - self::assertTrue($last['data']['tagged']); - - $reloaded = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class) - ->getRepository(PatientRecord::class) - ->find($patient->getId()); - - $tagNames = array_map(static fn (TenantTag $t): string => $t->getName(), $reloaded->getTags()->toArray()); - - self::assertContains('پرریسک', $tagNames); - } - - /** ثبت دوباره روی همان نوبت، عدم حضور دوم نمی‌سازد. */ - public function testRecordingTheSameNoShowTwiceCountsOnce(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2); - - $first = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user); - $second = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user); - - self::assertTrue($first['data']['recorded']); - self::assertFalse($second['data']['recorded']); - self::assertSame(1, $second['data']['count']); - } - - // ── جداسازی محیط ──────────────────────────────────────────────────────── - - /** - * ⭐ سیاست سرویس بر سیاست محیط مقدم است — بدون ترکیب. - * - * ترکیب («پنجرهٔ رایگانِ محیط با درصدِ سرویس») یعنی هیچ‌کس نتواند بگوید عدد نهایی از - * کجا آمد. سرویس اگر سیاست دارد، **همه‌اش** مال اوست. - */ - public function testTheServicePolicyWinsOverTheTenantPolicy(): void - { - [$user, $section, , $doctor, $patient] = $this->clinicWithPatient(); - $clinicId = (int) $patient->getEntityId(); - $service = $this->service($section); - - // پنجرهٔ محیط یک ساعت است: با ۲۴ ساعت مانده، لغو رایگان می‌شد. - $this->savePolicy($user, [ - 'free_window_hours' => 1, - 'penalty_mode' => 'percent', - 'penalty_value' => 10, - ]); - - // پنجرهٔ سرویس ۴۸ ساعت است: همان لغو، جریمه دارد. - $saved = $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/cancellation-policy", $user, [ - 'free_window_hours' => 48, - 'penalty_mode' => 'percent', - 'penalty_value' => 50, - ]); - self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE)); - - // عددِ نهایی می‌گوید کدام سیاست حاکم بوده: ۰ یعنی محیط، ۵۰٪ یعنی سرویس. - $appointment = $this->appointment($doctor, $patient, $service, $clinicId, 24, 4_000_000, 4_000_000); - - $preview = $this->authJson( - 'GET', - "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview?by=user", - $user, - ); - - self::assertSame(2_000_000, $preview['data']['penalty_rials'], 'سیاست سرویس حاکم است، نه سیاست محیط'); - self::assertFalse($preview['data']['within_free_window']); - } - - /** - * ⭐ برچسب پرریسک **مسدود نمی‌کند**. - * - * مسدودسازی یک قانون `eligibility` جداست؛ کلینیکی که می‌خواهد بیمار پرریسک را ببیند - * ولی بیعانه بگیرد، نباید مجبور شود برچسب را خاموش کند. - */ - public function testATaggedPatientCanStillBook(): void - { - [$user, $section, , $doctor, $patient] = $this->clinicWithPatient(); - $clinicId = (int) $patient->getEntityId(); - $service = $this->service($section); - - $this->savePolicy($user, ['no_show_threshold' => 2]); - - foreach ([1, 2, 3] as $i) { - $appointment = $this->appointment($doctor, $patient, $service, $clinicId, -$i * 24); - $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user); - self::assertSame(200, $this->responseCode()); - } - - $summary = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/no-shows", $user); - - self::assertTrue($summary['data']['at_risk']); - self::assertSame(3, $summary['data']['count']); - - // و با همین وضعیت، نوبت تازه ثبت می‌شود. - $fresh = $this->appointment($doctor, $patient, $service, $clinicId, 48); - - self::assertSame(Appointment::STATUS_CONFIRMED, $fresh->getStatus()); - } - - public function testAnotherClinicCannotSeeTheNoShowSummary(): void - { - [$user, , , , $patient] = $this->clinicWithPatient(); - [$other] = $this->clinicWithPatient(); - - $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/no-shows", $other); - self::assertSame(404, $this->responseCode()); - - $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/no-shows", $user); - self::assertSame(200, $this->responseCode()); - } - - /** - * ⭐ شکست اطلاع‌رسانی نباید لغو را برگرداند. - * - * حالا که همهٔ نوشتن‌ها در یک تراکنش‌اند، این سؤال جدی است: اگر پیامک **داخل** آن - * بلوک بود، یک خطای سرویس پیامک ظرفیت آزادشده را پس می‌گرفت و بیمار هم نوبت - * نداشت هم وقتش را. اطلاع‌رسانی عمداً بعد از commit است و این تست همان را پین - * می‌کند: با یک notifier که همیشه می‌ترکد، لغو باز هم کامل انجام می‌شود. - */ - public function testAFailingNotifierDoesNotUndoTheCancellation(): void - { - [$user, $section, , $doctor, $patient] = $this->clinicWithPatient(); - $clinicId = (int) $patient->getEntityId(); - $service = $this->service($section); - - $appointment = $this->appointment($doctor, $patient, $service, $clinicId, 48, 4_000_000, 4_000_000); - $uuid = $appointment->getUuid(); - - // برای اینکه اطلاع‌رسانی واقعاً به بیمار برسد، باید کسی در لیست انتظار باشد. - $waiting = $this->createUser(['ROLE_USER']); - $record = new PatientRecord('clinic', $clinicId, $waiting, 'clinic', $clinicId); - $this->em->persist($record); - $this->em->flush(); - - $entry = new \App\Waitlist\Entity\WaitlistEntry( - $record, - $this->em->getRepository(ServiceItem::class)->find($service->getId()), - $appointment->getSlotStart() - 86400, - $appointment->getSlotStart() + 86400, - ); - $this->em->persist($entry); - $this->em->flush(); - - // سرویس پیامکی که همیشه می‌ترکد — همان چیزی که در تولید یک قطعی است. - static::getContainer()->set( - \App\Sms\Service\SmsService::class, - new class extends \App\Sms\Service\SmsService { - public function __construct() {} - - public function dispatchAsync( - string $mobile, - string $message, - string $provider = 'kavenegar', - ?string $templateUuid = null, - array $templateVars = [], - ?string $templateCode = null, - string $tag = \App\Sms\Entity\SmsLog::TAG_GLOBAL, - ): void { - throw new \RuntimeException('sms provider down'); - } - }, - ); - - $threw = false; - - try { - static::getContainer()->get(\App\Cancellation\Service\CancellationService::class) - ->cancel($appointment, Appointment::STATUS_CANCELLED_BY_USER, $user); - } catch (\RuntimeException) { - $threw = true; - } - - self::assertTrue($threw, 'خطای اطلاع‌رسانی بالا می‌آید — پنهانش نمی‌کنیم'); - - // ولی خودِ لغو commit شده است. - $this->em->clear(); - $reloaded = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]); - - self::assertSame( - Appointment::STATUS_CANCELLED_BY_USER, - $reloaded->getStatus(), - 'لغو نباید گروگان سرویس پیامک بماند', - ); - } - - public function testAnotherClinicCannotPreviewTheCancellation(): void - { - [$owner, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - [$other] = $this->clinicWithPatient(); - - $service = $this->service($section); - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 5); - - $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $other); - - self::assertSame(404, $this->responseCode()); - } - - public function testAPercentageAboveOneHundredIsRejected(): void - { - [$user] = $this->clinicWithPatient(); - - $this->authJson('PUT', '/api/v1/cancellation-policy', $user, [ - 'penalty_mode' => 'percent', - 'penalty_value' => 150, - ]); - - self::assertSame(422, $this->responseCode()); - } -} diff --git a/tests/Course/CourseDocsCaptureTest.php b/tests/Course/CourseDocsCaptureTest.php deleted file mode 100644 index c6159ca9..00000000 --- a/tests/Course/CourseDocsCaptureTest.php +++ /dev/null @@ -1,34 +0,0 @@ -createUser(['ROLE_USER','ROLE_CLINIC']); - $clinic = new Clinic($user); $clinic->setName('کلینیک نمونه'); $this->em->persist($clinic); $this->em->flush(); - $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); $this->em->persist($section); - $address = DoctorAddress::forClinic($clinic->getId()); $address->setName('شعبهٔ مرکزی'); $this->em->persist($address); - $pu = $this->createUser(['ROLE_USER']); - $patient = new PatientRecord('clinic', (int)$clinic->getId(), $pu, 'clinic', (int)$clinic->getId()); - $this->em->persist($patient); $this->em->flush(); - $item = new ServiceItem($section, 'لیزر فول‌بادی'); $item->setSoloDurationMinutes(30); $item->setPriceRials(5000000); - $this->em->persist($item); $this->em->flush(); - $d = function(string $l, mixed $b): void { fwrite(STDERR, sprintf("\n===%s %d===\n%s\n", $l, $this->responseCode(), json_encode($b, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT))); }; - $p = $this->authJson('POST','/api/v1/course-protocols',$user,[ - 'service_uuid'=>$item->getUuid(),'session_count'=>8,'min_days'=>21,'ideal_days'=>28,'max_days'=>45, - 'steps'=>[['session_number'=>1,'params'=>['energy'=>12]],['session_number'=>2,'params'=>['energy'=>14]]], - ]); - $d('PROTOCOL_CREATE', $p); - $c = $this->authJson('POST','/api/v1/treatment-course',$user,['patient_uuid'=>$patient->getUuid(),'protocol_uuid'=>$p['data']['uuid']]); - $d('COURSE_CREATE', $c); - $d('COURSE_SHOW', $this->authJson('GET',"/api/v1/treatment-course/{$c['data']['uuid']}",$user)); - $d('NEXT_SLOT', $this->authJson('GET',"/api/v1/treatment-course/{$c['data']['uuid']}/next-slot-suggestion?branch_uuid={$address->getUuid()}",$user)); - $d('PATIENT_COURSES', $this->authJson('GET',"/api/v1/patient/{$patient->getUuid()}/courses",$user)); - $d('DUPLICATE', $this->authJson('POST','/api/v1/treatment-course',$user,['patient_uuid'=>$patient->getUuid(),'protocol_uuid'=>$p['data']['uuid']])); - $d('ABANDON', $this->authJson('POST',"/api/v1/treatment-course/{$c['data']['uuid']}/abandon",$user,['reason'=>'انصراف بیمار'])); - self::assertTrue(true); - } -} diff --git a/tests/Course/TreatmentCourseTest.php b/tests/Course/TreatmentCourseTest.php deleted file mode 100644 index 882572f4..00000000 --- a/tests/Course/TreatmentCourseTest.php +++ /dev/null @@ -1,669 +0,0 @@ -createUser(['ROLE_USER', 'ROLE_CLINIC']); - $clinic = new Clinic($user); - $clinic->setName('کلینیک دوره'); - $this->em->persist($clinic); - $this->em->flush(); - - $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); - $this->em->persist($section); - - $address = DoctorAddress::forClinic($clinic->getId()); - $address->setName('شعبهٔ مرکزی'); - $this->em->persist($address); - - $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); - $doctor = new Doctor($doctorUser, 'دکتر دوره'); - $this->em->persist($doctor); - - $patientUser = $this->createUser(['ROLE_USER']); - $patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId()); - $this->em->persist($patient); - $this->em->flush(); - - return [$user, $section, $address, $doctor, $patient]; - } - - private function service(ServiceSection $section, string $name = 'لیزر فول‌بادی'): ServiceItem - { - $item = new ServiceItem($section, $name); - $item->setSoloDurationMinutes(30); - $item->setPriceRials(5_000_000); - $this->em->persist($item); - $this->em->flush(); - - return $item; - } - - /** @param array $extra */ - private function protocol(User $user, ServiceItem $service, array $extra = []): array - { - $body = $this->authJson('POST', '/api/v1/course-protocols', $user, $extra + [ - 'service_uuid' => $service->getUuid(), - 'session_count' => 8, - 'min_days' => 21, - 'ideal_days' => 28, - 'max_days' => 45, - 'steps' => [ - ['session_number' => 1, 'params' => ['energy' => 12]], - ['session_number' => 2, 'params' => ['energy' => 14]], - ['session_number' => 3, 'params' => ['energy' => 16]], - ['session_number' => 4, 'params' => ['energy' => 18]], - ], - ]); - - self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - - return $body['data']; - } - - private function startCourse(User $user, PatientRecord $patient, string $protocolUuid): array - { - $body = $this->authJson('POST', '/api/v1/treatment-course', $user, [ - 'patient_uuid' => $patient->getUuid(), - 'protocol_uuid' => $protocolUuid, - ]); - - self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - - return $body['data']; - } - - private function courseEntity(string $uuid): TreatmentCourse - { - return static::getContainer()->get(TreatmentCourseRepository::class)->findByUuid($uuid); - } - - private function linker(): CourseSessionLinker - { - return static::getContainer()->get(CourseSessionLinker::class); - } - - // ── پروتکل ────────────────────────────────────────────────────────────── - - public function testProtocolKeepsItsStepsAndSpacing(): void - { - [$user, $section] = $this->clinicWithPatient(); - $protocol = $this->protocol($user, $this->service($section)); - - self::assertSame(8, $protocol['session_count']); - self::assertSame([21, 28, 45], [$protocol['min_days'], $protocol['ideal_days'], $protocol['max_days']]); - self::assertSame([1, 2, 3, 4], array_column($protocol['steps'], 'session_number')); - self::assertSame(16, $protocol['steps'][2]['params']['energy']); - } - - /** ترتیب فاصله‌ها معنا دارد؛ حداکثر کوچک‌تر از حداقل یعنی پروتکل غیرقابل اجرا. */ - public function testSpacingMustBeOrdered(): void - { - [$user, $section] = $this->clinicWithPatient(); - - $this->authJson('POST', '/api/v1/course-protocols', $user, [ - 'service_uuid' => $this->service($section)->getUuid(), - 'session_count' => 6, - 'min_days' => 30, - 'ideal_days' => 20, - 'max_days' => 45, - ]); - - self::assertSame(422, $this->responseCode()); - } - - /** دورهٔ یک‌جلسه‌ای همان نوبت تکی است. */ - public function testASingleSessionCourseIsRejected(): void - { - [$user, $section] = $this->clinicWithPatient(); - - $this->authJson('POST', '/api/v1/course-protocols', $user, [ - 'service_uuid' => $this->service($section)->getUuid(), - 'session_count' => 1, - 'min_days' => 7, - 'ideal_days' => 7, - 'max_days' => 14, - ]); - - self::assertSame(422, $this->responseCode()); - } - - public function testOneProtocolPerService(): void - { - [$user, $section] = $this->clinicWithPatient(); - $service = $this->service($section); - - $this->protocol($user, $service); - - $this->authJson('POST', '/api/v1/course-protocols', $user, [ - 'service_uuid' => $service->getUuid(), - 'session_count' => 4, - 'min_days' => 7, - 'ideal_days' => 14, - 'max_days' => 21, - ]); - - self::assertSame(422, $this->responseCode()); - } - - // ── شروع دوره ─────────────────────────────────────────────────────────── - - public function testStartingACourseCreatesEverySessionWithItsParams(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $protocol = $this->protocol($user, $this->service($section)); - - $course = $this->startCourse($user, $patient, $protocol['uuid']); - - self::assertCount(8, $course['sessions']); - self::assertSame(array_fill(0, 8, 'planned'), array_column($course['sessions'], 'status')); - self::assertSame(12, $course['sessions'][0]['params']['energy']); - self::assertSame(18, $course['sessions'][3]['params']['energy']); - - // جلسات ۵ تا ۸ پارامتری در پروتکل ندارند — آرایهٔ خالی، نه خطا. - self::assertSame([], (array) $course['sessions'][7]['params']); - - self::assertSame( - ['completed' => 0, 'booked' => 0, 'planned' => 8, 'skipped' => 0, 'total' => 8], - array_intersect_key($course['progress'], array_flip(['completed', 'booked', 'planned', 'skipped', 'total'])), - ); - self::assertSame(1, $course['progress']['next_session_number']); - } - - /** ⭐ تغییر پروتکل نباید دورهٔ در جریان را عوض کند. */ - public function testChangingTheProtocolLeavesRunningCoursesAlone(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $protocol = $this->protocol($user, $this->service($section)); - $course = $this->startCourse($user, $patient, $protocol['uuid']); - - $this->authJson('PATCH', "/api/v1/course-protocol/{$protocol['uuid']}", $user, [ - 'session_count' => 12, - 'min_days' => 30, - 'ideal_days' => 40, - 'max_days' => 60, - ]); - self::assertSame(200, $this->responseCode()); - - $after = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']; - - self::assertSame(8, $after['session_count']); - self::assertSame([21, 28, 45], [$after['min_days'], $after['ideal_days'], $after['max_days']]); - self::assertCount(8, $after['sessions']); - } - - public function testASecondActiveCourseForTheSameServiceIsRejected(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $protocol = $this->protocol($user, $this->service($section)); - - $first = $this->startCourse($user, $patient, $protocol['uuid']); - - $body = $this->authJson('POST', '/api/v1/treatment-course', $user, [ - 'patient_uuid' => $patient->getUuid(), - 'protocol_uuid' => $protocol['uuid'], - ]); - - self::assertSame(422, $this->responseCode()); - // پیام باید شناسهٔ دورهٔ موجود را بدهد تا اپراتور بتواند برود سراغش. - self::assertStringContainsString($first['uuid'], $body['errors'][0]['message']); - } - - /** رهاکردن دوره جا را برای دورهٔ تازه باز می‌کند. */ - public function testAbandoningACourseFreesTheSlotForANewOne(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $protocol = $this->protocol($user, $this->service($section)); - $course = $this->startCourse($user, $patient, $protocol['uuid']); - - $this->authJson('POST', "/api/v1/treatment-course/{$course['uuid']}/abandon", $user, ['reason' => '']); - self::assertSame(422, $this->responseCode(), 'رهاکردن بدون دلیل نباید پذیرفته شود'); - - $abandoned = $this->authJson('POST', "/api/v1/treatment-course/{$course['uuid']}/abandon", $user, [ - 'reason' => 'انصراف بیمار', - ]); - - self::assertSame(200, $this->responseCode()); - self::assertSame('abandoned', $abandoned['data']['status']); - - $this->startCourse($user, $patient, $protocol['uuid']); - } - - // ── پیشرفت و لنگر متحرک ───────────────────────────────────────────────── - - public function testProgressCountsCompletedSessionsAndPointsAtTheNextOne(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - $protocol = $this->protocol($user, $service); - $course = $this->startCourse($user, $patient, $protocol['uuid']); - - $this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 3); - - $after = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']; - - self::assertSame(3, $after['progress']['completed']); - self::assertSame(8, $after['progress']['total']); - self::assertSame(4, $after['progress']['next_session_number']); - self::assertSame(18, $after['progress']['next_params']['energy']); - } - - /** ⭐ فاصله از آخرین جلسهٔ **انجام‌شده** حساب می‌شود، نه از شروع دوره. */ - public function testTheSuggestionAnchorsOnTheLastCompletedSession(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - $protocol = $this->protocol($user, $service); - $course = $this->startCourse($user, $patient, $protocol['uuid']); - - $completedAt = time() - 10 * 86400; - $this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 3, $completedAt); - - $body = $this->authJson( - 'GET', - "/api/v1/treatment-course/{$course['uuid']}/next-slot-suggestion?branch_uuid={$address->getUuid()}", - $user, - ); - - self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - - $data = $body['data']; - - self::assertSame(4, $data['session_number']); - self::assertSame(18, $data['params']['energy']); - self::assertSame($completedAt + 21 * 86400, $data['range']['min']); - self::assertSame($completedAt + 45 * 86400, $data['range']['max']); - self::assertSame($completedAt + 28 * 86400, $data['ideal_at']); - self::assertNull($data['warning']); - } - - public function testPassingTheMaximumGapProducesAWarning(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - $protocol = $this->protocol($user, $service); - $course = $this->startCourse($user, $patient, $protocol['uuid']); - - $this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 1, time() - 50 * 86400); - - $data = $this->authJson( - 'GET', - "/api/v1/treatment-course/{$course['uuid']}/next-slot-suggestion?branch_uuid={$address->getUuid()}", - $user, - )['data']; - - self::assertNotNull($data['warning']); - self::assertStringContainsString('45', $data['warning'], 'پیام باید حداکثر فاصلهٔ همان دوره را بگوید'); - } - - // ── لغو یک جلسهٔ وسط دوره ─────────────────────────────────────────────── - - /** ⭐ لغو یک جلسه فقط همان جلسه را برمی‌گرداند؛ بقیهٔ دوره دست‌نخورده. */ - public function testCancellingOneSessionOnlyResetsThatSession(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - $protocol = $this->protocol($user, $service); - $course = $this->startCourse($user, $patient, $protocol['uuid']); - - $entity = $this->courseEntity($course['uuid']); - $sessions = $entity->getSessions()->toArray(); - usort($sessions, static fn (CourseSession $a, CourseSession $b): int => $a->getSessionNumber() <=> $b->getSessionNumber()); - - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId()); - $this->linker()->link($sessions[0], $appointment); - - $second = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId()); - $this->linker()->link($this->reloadSession($sessions[1]->getUuid()), $second); - - $before = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']; - self::assertSame(['booked', 'booked'], array_slice(array_column($before['sessions'], 'status'), 0, 2)); - - self::assertTrue($this->linker()->unlink($this->reloadAppointment($appointment->getUuid()))); - - $after = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']; - - self::assertSame('planned', $after['sessions'][0]['status']); - self::assertSame('booked', $after['sessions'][1]['status'], 'بقیهٔ جلسات نباید دست بخورند'); - } - - public function testTheCourseCompletesOnlyWhenEverySessionIsDone(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - // پروتکل کوتاه تا کل دوره در تست تمام شود. - $protocol = $this->authJson('POST', '/api/v1/course-protocols', $user, [ - 'service_uuid' => $service->getUuid(), - 'session_count' => 2, - 'min_days' => 7, - 'ideal_days' => 14, - 'max_days' => 21, - ])['data']; - - $course = $this->startCourse($user, $patient, $protocol['uuid']); - - $this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 1); - self::assertSame('active', $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']['status']); - - $this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 1); - self::assertSame('completed', $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']['status']); - } - - // ── جداسازی محیط ──────────────────────────────────────────────────────── - - /** - * ⭐ «سخت‌گیرانه‌تر برنده»: قانون `spacing` کلینیک با پروتکل دوره نمی‌جنگد. - * - * پروتکل ۷ روز می‌گوید و قانون ۲۱ روز؛ فاصلهٔ مؤثر باید ۲۱ باشد. اگر پروتکل برنده - * می‌شد، قانونِ ایمنی کلینیک با تعریف یک پروتکل کوتاه دور زده می‌شد. - */ - public function testTheStricterOfProtocolAndSpacingPolicyWins(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - $protocol = $this->protocol($user, $service, ['min_days' => 7, 'ideal_days' => 10, 'max_days' => 20]); - $started = $this->startCourse($user, $patient, $protocol['uuid']); - - $course = $this->courseEntity($started['uuid']); - $scheduler = static::getContainer()->get(\App\Course\Service\CourseScheduler::class); - - self::assertSame(7, $scheduler->effectiveMinDays($course), 'بدون قانون، پروتکل حاکم است'); - - $policy = new \App\Policy\Entity\Policy( - $course->getEntityType(), - $course->getEntityId(), - \App\Policy\Entity\Policy::CATEGORY_SPACING, - 'حداقل ۲۱ روز بین جلسات لیزر', - ); - $policy->setCondition(['match' => 'all', 'conditions' => []]); - $policy->setEffects([['type' => 'min_days_between', 'value' => 21]]); - $policy->setActive(true); - - $this->em->persist($policy); - $this->em->flush(); - $this->em->clear(); - - self::assertSame( - 21, - $scheduler->effectiveMinDays($this->courseEntity($started['uuid'])), - 'قانون سخت‌گیرتر برنده است', - ); - } - - /** - * ⭐ سقف افق: جلسه‌ای که حتی حداقلِ فاصله‌اش بیرون ۹۰ روز می‌افتد **رد** می‌شود، نه - * اینکه `book-all` را بشکند. جلسات بیرون بازه `planned` می‌مانند تا بعداً رزرو شوند. - */ - public function testSessionsBeyondTheHorizonAreSkippedNotFailed(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - // فاصلهٔ ۶۰ روزه با ۸ جلسه: جلسهٔ سوم به بعد بیرون افق ۹۰ روزه است. - $protocol = $this->protocol($user, $service, ['min_days' => 60, 'ideal_days' => 60, 'max_days' => 70]); - $started = $this->startCourse($user, $patient, $protocol['uuid']); - - $course = $this->courseEntity($started['uuid']); - $scheduler = static::getContainer()->get(\App\Course\Service\CourseScheduler::class); - - $now = time(); - $minDays = $scheduler->effectiveMinDays($course, $now); - $horizon = $now + \App\Course\Service\CourseScheduler::SEARCH_HORIZON_DAYS * 86400; - - // لنگر دوم = لنگر اول + ۶۰ روز؛ سومی از افق می‌گذرد. - $third = $now + 3 * $minDays * 86400; - - self::assertGreaterThan($horizon, $third, 'جلسهٔ سوم باید بیرون افق باشد'); - self::assertSame(60, $minDays); - - // خودِ دوره دست‌نخورده می‌ماند: هیچ جلسه‌ای حذف نمی‌شود. - self::assertCount(8, $course->getSessions()->toArray()); - } - - /** - * ⭐ `book-all` همه یا هیچ است. - * - * تقویم فقط یک روزِ هفته باز است و فاصلهٔ پروتکل ۱ تا ۲ روز؛ پس جلسهٔ اول وقت پیدا - * می‌کند و جلسهٔ دوم نه. اگر تراکنش کار نکند، بیمار با یک نوبتِ تنها از یک دورهٔ - * هشت‌جلسه‌ای می‌ماند و هیچ‌کس نمی‌فهمد کجا قطع شد. - */ - public function testAFailedBookAllLeavesEverySessionPlanned(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $type = $this->authJson('POST', '/api/v1/resource-types', $user, [ - 'address_uuid' => $address->getUuid(), - 'code' => 'room', - 'name' => 'اتاق', - ]); - self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE)); - - $resource = $this->authJson('POST', '/api/v1/resource', $user, [ - 'address_uuid' => $address->getUuid(), - 'type_uuid' => $type['data']['uuid'], - 'name' => 'اتاق ۱', - ]); - self::assertSame(201, $this->responseCode()); - - // فقط شنبه‌ها باز است. - $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $user, [ - 'days' => [6 => [['start_minute' => 540, 'end_minute' => 1020]]], - ]); - self::assertSame(200, $this->responseCode()); - - $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, [ - 'segments' => [ - ['sequence' => 1, 'name' => 'جلسه', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $type['data']['uuid']]]], - ], - ]); - self::assertSame(200, $this->responseCode()); - - // بازهٔ ۱ تا ۲ روز: جلسهٔ دوم حتماً بیرون تنها روزِ باز می‌افتد. - $protocol = $this->protocol($user, $service, ['min_days' => 1, 'ideal_days' => 1, 'max_days' => 2]); - $started = $this->startCourse($user, $patient, $protocol['uuid']); - - $body = $this->authJson('POST', "/api/v1/treatment-course/{$started['uuid']}/book-all", $user, [ - 'branch_uuid' => $address->getUuid(), - 'doctor_uuid' => $doctor->getUuid(), - ]); - - self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - - $this->em->clear(); - $course = $this->courseEntity($started['uuid']); - - foreach ($course->getSessions() as $session) { - self::assertSame( - CourseSession::STATUS_PLANNED, - $session->getStatus(), - sprintf('جلسهٔ %d نباید رزرو مانده باشد', $session->getSessionNumber()), - ); - self::assertNull($session->getAppointment()); - } - } - - /** - * ⭐ مسیر **موفق** `book-all`: لنگر بعد از هر رزرو جلو می‌رود. - * - * تست شکست از قبل بود؛ این یکی همان چیزی را می‌سنجد که کار می‌کند. لنگر ثابت یعنی - * هر هشت جلسه دور همان تاریخ جمع می‌شوند و پروتکل عملاً بی‌اثر است. - */ - public function testBookAllMovesTheAnchorForwardBetweenSessions(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $type = $this->authJson('POST', '/api/v1/resource-types', $user, [ - 'address_uuid' => $address->getUuid(), - 'code' => 'room', - 'name' => 'اتاق', - ]); - self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE)); - - $resource = $this->authJson('POST', '/api/v1/resource', $user, [ - 'address_uuid' => $address->getUuid(), - 'type_uuid' => $type['data']['uuid'], - 'name' => 'اتاق دوره', - ]); - self::assertSame(201, $this->responseCode()); - - // هر روز باز — تا جلسات فقط با فاصلهٔ پروتکل جدا شوند، نه با تعطیلی. - $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $user, [ - 'days' => array_fill_keys(range(0, 6), [['start_minute' => 480, 'end_minute' => 1200]]), - ]); - self::assertSame(200, $this->responseCode()); - - $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, [ - 'segments' => [ - ['sequence' => 1, 'name' => 'جلسه', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $type['data']['uuid']]]], - ], - ]); - self::assertSame(200, $this->responseCode()); - - $protocol = $this->protocol($user, $service, [ - 'session_count' => 3, - 'min_days' => 7, - 'ideal_days' => 7, - 'max_days' => 14, - 'steps' => [ - ['session_number' => 1, 'params' => ['energy' => 12]], - ['session_number' => 2, 'params' => ['energy' => 14]], - ['session_number' => 3, 'params' => ['energy' => 16]], - ], - ]); - $started = $this->startCourse($user, $patient, $protocol['uuid']); - - $body = $this->authJson('POST', "/api/v1/treatment-course/{$started['uuid']}/book-all", $user, [ - 'branch_uuid' => $address->getUuid(), - 'doctor_uuid' => $doctor->getUuid(), - ]); - - self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - self::assertSame(3, $body['data']['booked']); - - $this->em->clear(); - $sessions = $this->courseEntity($started['uuid'])->getSessions()->toArray(); - - usort($sessions, static fn (CourseSession $a, CourseSession $b): int - => $a->getSessionNumber() <=> $b->getSessionNumber()); - - $starts = array_map( - static fn (CourseSession $s): ?int => $s->getAppointment()?->getSlotStart(), - $sessions, - ); - - self::assertNotContains(null, $starts, 'هر سه جلسه باید نوبت گرفته باشند'); - - // لنگر متحرک: هر جلسه دست‌کم هفت روز بعد از جلسهٔ قبلی است. - for ($i = 1; $i < count($starts); $i++) { - $gapDays = (int) floor(($starts[$i] - $starts[$i - 1]) / 86400); - - self::assertGreaterThanOrEqual(7, $gapDays, sprintf( - 'فاصلهٔ جلسهٔ %d با قبلی %d روز شد', - $i + 1, - $gapDays, - )); - } - } - - public function testAnotherClinicCannotSeeTheCourse(): void - { - [$owner, $section, , , $patient] = $this->clinicWithPatient(); - [$other] = $this->clinicWithPatient(); - - $protocol = $this->protocol($owner, $this->service($section)); - $course = $this->startCourse($owner, $patient, $protocol['uuid']); - - $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $other); - self::assertSame(404, $this->responseCode()); - } - - // ── کمکی ──────────────────────────────────────────────────────────────── - - private function reloadSession(string $uuid): CourseSession - { - return static::getContainer()->get(CourseSessionRepository::class)->findByUuid($uuid); - } - - private function reloadAppointment(string $uuid): \App\Appointment\Entity\Appointment - { - return static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class) - ->getRepository(\App\Appointment\Entity\Appointment::class) - ->findOneBy(['uuid' => $uuid]); - } - - private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, int $clinicId): \App\Appointment\Entity\Appointment - { - $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); - - $start = time() + 86400 + (++$this->slotCursor) * 3600; - - $appointment = new \App\Appointment\Entity\Appointment( - $em->getRepository(Doctor::class)->find($doctor->getId()), - $em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(), - $start, - $start + 1800, - ); - $appointment->assignTenantPair('clinic', $clinicId); - $appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId())); - $appointment->setPatientName('بیمار دوره'); - - $em->persist($appointment); - $em->flush(); - - return $appointment; - } - - /** n جلسهٔ بعدی را رزرو و انجام‌شده می‌کند. */ - private function completeSessions( - string $courseUuid, - Doctor $doctor, - PatientRecord $patient, - ServiceItem $service, - int $clinicId, - int $count, - ?int $completedAt = null, - ): void { - for ($i = 0; $i < $count; $i++) { - $course = $this->courseEntity($courseUuid); - $sessions = $course->plannedSessions(); - - usort($sessions, static fn (CourseSession $a, CourseSession $b): int - => $a->getSessionNumber() <=> $b->getSessionNumber()); - - $appointment = $this->appointment($doctor, $patient, $service, $clinicId); - - $this->linker()->link($this->reloadSession($sessions[0]->getUuid()), $appointment); - $this->linker()->complete($this->reloadAppointment($appointment->getUuid()), $completedAt); - } - } -} diff --git a/tests/Package/PackageDocsCaptureTest.php b/tests/Package/PackageDocsCaptureTest.php deleted file mode 100644 index f9ed723f..00000000 --- a/tests/Package/PackageDocsCaptureTest.php +++ /dev/null @@ -1,32 +0,0 @@ -createUser(['ROLE_USER','ROLE_CLINIC']); - $clinic = new Clinic($user); $clinic->setName('کلینیک نمونه'); $this->em->persist($clinic); $this->em->flush(); - $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); $this->em->persist($section); - $address = DoctorAddress::forClinic($clinic->getId()); $address->setName('شعبهٔ مرکزی'); $this->em->persist($address); - $pu = $this->createUser(['ROLE_USER']); - $patient = new PatientRecord('clinic', (int)$clinic->getId(), $pu, 'clinic', (int)$clinic->getId()); - $this->em->persist($patient); $this->em->flush(); - $item = new ServiceItem($section, 'لیزر فول‌بادی'); $item->setSoloDurationMinutes(20); $item->setPriceRials(5000000); - $this->em->persist($item); $this->em->flush(); - $d = function(string $l, mixed $b): void { fwrite(STDERR, sprintf("\n===%s %d===\n%s\n", $l, $this->responseCode(), json_encode($b, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT))); }; - $c = $this->authJson('POST','/api/v1/packages',$user,['name'=>'۶ جلسه لیزر فول‌بادی','session_count'=>6,'price_rials'=>25000000,'validity_days'=>365,'service_uuids'=>[$item->getUuid()]]); - $d('CREATE', $c); - $d('INDEX', $this->authJson('GET','/api/v1/packages',$user)); - $s = $this->authJson('POST',"/api/v1/patient/{$patient->getUuid()}/package",$user,['package_uuid'=>$c['data']['uuid']]); - $d('SELL', $s); - $d('PATIENT_PACKAGES', $this->authJson('GET',"/api/v1/patient/{$patient->getUuid()}/packages",$user)); - $this->authJson('POST',"/api/v1/patient-package/{$s['data']['uuid']}/adjust",$user,['delta'=>1,'reason'=>'جبران جلسهٔ لغوشده']); - $d('LEDGER', $this->authJson('GET',"/api/v1/patient-package/{$s['data']['uuid']}/ledger",$user)); - $d('ADJUST_NO_REASON', $this->authJson('POST',"/api/v1/patient-package/{$s['data']['uuid']}/adjust",$user,['delta'=>1])); - $d('QUOTE', $this->authJson('POST','/api/v1/pricing/quote',$user,['service_uuid'=>$item->getUuid(),'branch_uuid'=>$address->getUuid(),'patient_uuid'=>$patient->getUuid()])); - self::assertTrue(true); - } -} diff --git a/tests/Package/PackageLedgerTest.php b/tests/Package/PackageLedgerTest.php deleted file mode 100644 index 5f58f5d0..00000000 --- a/tests/Package/PackageLedgerTest.php +++ /dev/null @@ -1,560 +0,0 @@ -createUser(['ROLE_USER', 'ROLE_CLINIC']); - $clinic = new Clinic($user); - $clinic->setName('کلینیک پکیج'); - $this->em->persist($clinic); - $this->em->flush(); - - $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); - $this->em->persist($section); - - $address = DoctorAddress::forClinic($clinic->getId()); - $address->setName('شعبهٔ مرکزی'); - $this->em->persist($address); - - $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); - $doctor = new Doctor($doctorUser, 'دکتر پکیج'); - $this->em->persist($doctor); - - $patientUser = $this->createUser(['ROLE_USER']); - $patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId()); - $this->em->persist($patient); - $this->em->flush(); - - return [$user, $section, $address, $doctor, $patient]; - } - - private function service(ServiceSection $section, string $name, int $price = 5_000_000): ServiceItem - { - $item = new ServiceItem($section, $name); - $item->setSoloDurationMinutes(20); - $item->setPriceRials($price); - $this->em->persist($item); - $this->em->flush(); - - return $item; - } - - /** @param array $extra */ - private function definePackage(User $user, ServiceItem $service, int $sessions = 6, array $extra = []): array - { - $body = $this->authJson('POST', '/api/v1/packages', $user, $extra + [ - 'name' => '۶ جلسه لیزر فول‌بادی', - 'session_count' => $sessions, - 'price_rials' => 25_000_000, - 'service_uuids' => [$service->getUuid()], - ]); - - self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - - return $body['data']; - } - - private function sell(User $user, PatientRecord $patient, string $packageUuid): array - { - $body = $this->authJson('POST', "/api/v1/patient/{$patient->getUuid()}/package", $user, [ - 'package_uuid' => $packageUuid, - ]); - - self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - - return $body['data']; - } - - /** - * هر درخواست HTTP کرنل را ری‌بوت می‌کند و EntityManager تازه می‌شود، پس entity های - * قبلی detached اند و باید دوباره خوانده شوند. - */ - private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, int $clinicId): Appointment - { - // یک اسلات یکتا per فراخوانی: پزشک کلید یکتای (doctor, slot_start) دارد. - $start = time() + 86400 + (++$this->slotCursor) * 3600; - - $doctor = $this->em->getRepository(Doctor::class)->find($doctor->getId()); - $patient = $this->em->getRepository(PatientRecord::class)->find($patient->getId()); - $service = $this->em->getRepository(ServiceItem::class)->find($service->getId()); - - $appointment = new Appointment($doctor, $patient->getUser(), $start, $start + 1200); - $appointment->assignTenantPair('clinic', $clinicId); - $appointment->setServiceItem($service); - $appointment->setPatientName('بیمار پکیج'); - - $this->em->persist($appointment); - $this->em->flush(); - - return $appointment; - } - - private function ledgerService(): CreditLedgerService - { - return static::getContainer()->get(CreditLedgerService::class); - } - - /** - * سرویس‌های کانتینر با EntityManager خودشان کار می‌کنند؛ entity ساخته‌شده در تست - * باید از همان EM دوباره خوانده شود وگرنه «موجودیت جدیدِ persist نشده» می‌شود. - */ - private function reload(Appointment $appointment): Appointment - { - return static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class) - ->getRepository(Appointment::class) - ->find($appointment->getId()); - } - - private function consumption(): \App\Package\Service\PackageConsumptionService - { - return static::getContainer()->get(\App\Package\Service\PackageConsumptionService::class); - } - - // ── تعریف و فروش ──────────────────────────────────────────────────────── - - public function testSellingAPackageOpensTheLedgerWithItsSessionCount(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section, 'لیزر فول‌بادی'); - - $package = $this->definePackage($user, $service); - $sold = $this->sell($user, $patient, $package['uuid']); - - self::assertSame(6, $sold['balance']); - - $list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user); - self::assertSame(6, $list['data'][0]['balance']); - - $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user); - - self::assertCount(1, $ledger['data']['rows']); - self::assertSame('purchase', $ledger['data']['rows'][0]['kind']); - self::assertSame(6, $ledger['data']['rows'][0]['delta']); - self::assertSame(6, $ledger['data']['rows'][0]['running_balance']); - } - - /** پکیجی که هیچ سرویسی را پوشش نمی‌دهد هرگز قابل مصرف نیست. */ - public function testAPackageWithoutServicesIsRejected(): void - { - [$user] = $this->clinicWithPatient(); - - $this->authJson('POST', '/api/v1/packages', $user, [ - 'name' => 'پکیج بی‌سرویس', - 'session_count' => 3, - 'price_rials' => 1_000_000, - 'service_uuids' => [], - ]); - - self::assertSame(422, $this->responseCode()); - } - - /** ⭐ مانده باید محاسبه شود، نه ذخیره — همین جلوی «بهینه‌سازی» شش ماه بعد را می‌گیرد. */ - public function testNoStoredBalanceColumnExists(): void - { - $columns = $this->em->getConnection() - ->createSchemaManager() - ->listTableColumns('patient_packages'); - - $names = array_map(static fn ($c): string => strtolower($c->getName()), $columns); - - foreach (['remaining', 'remaining_sessions', 'used_count', 'balance'] as $forbidden) { - self::assertNotContains($forbidden, $names, 'مانده باید از دفتر محاسبه شود، نه ذخیره'); - } - } - - // ── مصرف و بازگشت ─────────────────────────────────────────────────────── - - public function testConsumingLeavesARowAndCancellingAddsAnotherOne(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section, 'لیزر'); - - $package = $this->definePackage($user, $service); - $sold = $this->sell($user, $patient, $package['uuid']); - - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId()); - - $appointment = $this->reload($appointment); - self::assertTrue($this->consumption()->consumeFor($appointment)); - - $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']); - self::assertSame(5, $this->ledgerService()->balance($entity)); - - self::assertTrue($this->ledgerService()->refund($appointment)); - self::assertSame(6, $this->ledgerService()->balance($entity)); - - // ردیف `consume` **حذف نمی‌شود** — دفتر append-only است. - $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user); - $kinds = array_column($ledger['data']['rows'], 'kind'); - - self::assertSame(['purchase', 'consume', 'refund'], $kinds); - self::assertSame([6, 5, 6], array_column($ledger['data']['rows'], 'running_balance')); - } - - /** - * ⭐ `credit_refundable: false` اعتبار برگشته را پس می‌گیرد — **بدون** حذف ردیف. - * - * دفتر append-only است، پس «پس گرفتن» یک ردیف `adjustment` منفی است نه پاک کردن - * `refund`. تاریخچه باید نشان بدهد اعتبار برگشت و بعد طبق سیاست پس گرفته شد. - */ - public function testAPolicyThatDoesNotRefundCreditTakesItBackWithAnAdjustment(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section, 'لیزر'); - - // نوبت حدود یک روز دیگر است؛ پنجرهٔ ۹۶ ساعته یعنی این لغو **بیرون** بازهٔ رایگان - // نیست بلکه درونِ محدودهٔ جریمه می‌افتد — تنها حالتی که سیاست اعتبار اثر دارد. - $saved = $this->authJson('PUT', '/api/v1/cancellation-policy', $user, [ - 'free_window_hours' => 96, - 'penalty_mode' => 'none', - 'credit_refundable' => false, - ]); - self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE)); - - $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); - $appointment = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId())); - - self::assertTrue($this->consumption()->consumeFor($appointment)); - - $body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user, ['by' => 'user']); - self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - self::assertFalse($body['data']['credit_refundable']); - - $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user); - $kinds = array_column($ledger['data']['rows'], 'kind'); - - self::assertSame(['purchase', 'consume', 'refund', 'adjustment'], $kinds, 'هیچ ردیفی حذف نمی‌شود'); - - $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']); - self::assertSame(5, $this->ledgerService()->balance($entity), 'جلسه پس گرفته شد'); - } - - /** - * ⭐ رقابت واقعی: ردیف `consume` از یک اتصال دیگر درج می‌شود و بعد سرویس تلاش - * می‌کند همان را بنویسد. - * - * بررسی پیش از درج این پنجره را نمی‌بندد؛ فقط کلید یکتا می‌بندد. و چون Doctrine روی - * نقض کلید `EntityManager` را می‌بندد، بدون بازنشانیِ رجیستری این حالت به یک ۵۰۰ - * بی‌ربط تبدیل می‌شد — نه یک «قبلاً مصرف شده». - */ - public function testAConcurrentConsumeIsAbsorbedWithoutBurningTheRequest(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section, 'لیزر'); - - $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); - $appointment = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId())); - - $package = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']); - - // اتصال جدا = «درخواست دیگر». ردیف مصرف را پشت سرِ سرویس درج می‌کند. - $other = \Doctrine\DBAL\DriverManager::getConnection($this->em->getConnection()->getParams()); - - try { - $other->insert('session_credit_ledger', [ - 'patient_package_id' => $package->getId(), - 'appointment_id' => $appointment->getId(), - 'kind' => 'consume', - 'delta' => -1, - 'created_at' => time(), - 'entity_type' => $package->getEntityType(), - 'entity_id' => $package->getEntityId(), - 'uuid' => \Symfony\Component\Uid\Uuid::v4()->toRfc4122(), - ]); - } finally { - $other->close(); - } - - // سرویس همان مصرف را دوباره تلاش می‌کند: باید `true` بدهد، نه خطا. - self::assertTrue($this->consumption()->consumeFor($this->reload($appointment))); - - // و مهم‌تر: مدیر هنوز زنده است و کارِ بعدی همین request انجام می‌شود. - $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); - self::assertTrue($em->isOpen(), 'EntityManager نباید بعد از نقض کلید بسته بماند'); - - $fresh = $em->getRepository(\App\Package\Entity\PatientPackage::class)->findOneBy(['uuid' => $sold['uuid']]); - self::assertSame(5, $this->ledgerService()->balance($fresh), 'فقط یک جلسه خورده شود'); - } - - /** `confirm` idempotent است؛ اجرای دومش نباید جلسهٔ دوم بخورد. */ - public function testConsumingTwiceForTheSameAppointmentTakesOnlyOneSession(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section, 'لیزر'); - - $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); - $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId()); - - $appointment = $this->reload($appointment); - $this->consumption()->consumeFor($appointment); - $this->consumption()->consumeFor($appointment); - - $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']); - - self::assertSame(5, $this->ledgerService()->balance($entity)); - } - - /** ماندهٔ صفر خطا نیست: بیمار نقدی می‌پردازد. */ - public function testAnEmptyPackageIsSimplyNotApplied(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section, 'لیزر'); - - $sold = $this->sell($user, $patient, $this->definePackage($user, $service, 1)['uuid']); - - $first = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId())); - $second = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId())); - - self::assertTrue($this->consumption()->consumeFor($first)); - self::assertFalse($this->consumption()->consumeFor($second), 'ماندهٔ صفر باید بی‌سروصدا رد شود'); - - $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']); - self::assertSame(0, $this->ledgerService()->balance($entity), 'مانده هرگز منفی نمی‌شود'); - } - - // ── قیمت ──────────────────────────────────────────────────────────────── - - public function testQuoteAnnouncesThePackageWithoutConsumingIt(): void - { - [$user, $section, $address, , $patient] = $this->clinicWithPatient(); - $service = $this->service($section, 'لیزر', 5_000_000); - - $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); - - $quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [ - 'service_uuid' => $service->getUuid(), - 'branch_uuid' => $address->getUuid(), - 'patient_uuid' => $patient->getUuid(), - ]); - - self::assertSame(200, $this->responseCode(), json_encode($quote, JSON_UNESCAPED_UNICODE)); - self::assertTrue($quote['data']['package_will_be_consumed']); - self::assertSame(0, $quote['data']['final_rials']); - - // پیش‌نمایش هرگز مصرف نمی‌کند؛ وگرنه هر رفرش یک جلسه می‌خورد. - $this->authJson('POST', '/api/v1/pricing/quote', $user, [ - 'service_uuid' => $service->getUuid(), - 'branch_uuid' => $address->getUuid(), - 'patient_uuid' => $patient->getUuid(), - ]); - - $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']); - self::assertSame(6, $this->ledgerService()->balance($entity)); - } - - public function testQuoteWithoutAPatientChargesTheFullPrice(): void - { - [$user, $section, $address, , $patient] = $this->clinicWithPatient(); - $service = $this->service($section, 'لیزر', 5_000_000); - - $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); - - $quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [ - 'service_uuid' => $service->getUuid(), - 'branch_uuid' => $address->getUuid(), - ]); - - self::assertFalse($quote['data']['package_will_be_consumed']); - self::assertSame(5_000_000, $quote['data']['final_rials']); - } - - // ── FIFO و انقضا ──────────────────────────────────────────────────────── - - /** قدیمی‌ترین اول، چون به انقضا نزدیک‌تر است. */ - public function testTheOldestUnexpiredPackageIsUsedFirst(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section, 'لیزر'); - - $definition = $this->definePackage($user, $service); - $older = $this->sell($user, $patient, $definition['uuid']); - $newer = $this->sell($user, $patient, $definition['uuid']); - - $repo = static::getContainer()->get(PatientPackageRepository::class); - $olderE = $repo->findByUuid($older['uuid']); - - // خرید دوم را عمداً تازه‌تر می‌کنیم تا ترتیب قطعی باشد. - $this->em->getConnection()->executeStatement( - 'UPDATE patient_packages SET purchased_at = purchased_at + 100 WHERE uuid = ?', - [$newer['uuid']], - ); - $this->em->clear(); - - $chosen = $this->consumption()->firstUsable( - $this->em->getRepository(PatientRecord::class)->find($patient->getId()), - $this->em->getRepository(ServiceItem::class)->find($service->getId()), - ); - - self::assertSame($olderE->getUuid(), $chosen?->getUuid()); - } - - public function testAnExpiredPackageShowsZeroBalanceButKeepsItsLedger(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section, 'لیزر'); - - $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); - - $this->em->getConnection()->executeStatement( - 'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?', - [time() - 86400, $sold['uuid']], - ); - $this->em->clear(); - - $list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user); - - self::assertTrue($list['data'][0]['expired']); - self::assertSame(0, $list['data'][0]['balance']); - - // دفتر دست‌نخورده است: «۶ جلسه‌ام چه شد؟» هنوز جواب دارد. - $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user); - self::assertSame(6, $ledger['data']['rows'][0]['running_balance']); - } - - public function testExpiryCommandWritesTheClosingRow(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section, 'لیزر'); - - $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); - - $this->em->getConnection()->executeStatement( - 'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?', - [time() - 86400, $sold['uuid']], - ); - $this->em->clear(); - - $command = static::getContainer()->get(\App\Package\Command\ExpirePackagesCommand::class); - $tester = new \Symfony\Component\Console\Tester\CommandTester($command); - $tester->execute([]); - - $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user); - $kinds = array_column($ledger['data']['rows'], 'kind'); - - self::assertSame(['purchase', 'expiry'], $kinds); - self::assertSame(-6, $ledger['data']['rows'][1]['delta']); - self::assertSame(0, $ledger['data']['rows'][1]['running_balance']); - } - - // ── اصلاح دستی و جداسازی محیط ─────────────────────────────────────────── - - public function testAdjustmentNeedsAReasonAndIsRecorded(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section, 'لیزر'); - - $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); - - $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, ['delta' => 1]); - self::assertSame(422, $this->responseCode(), 'اصلاح بدون دلیل نباید پذیرفته شود'); - - $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [ - 'delta' => 2, - 'reason' => 'جبران جلسهٔ لغوشده توسط کلینیک', - ]); - self::assertSame(201, $this->responseCode()); - - $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user); - $row = $ledger['data']['rows'][1]; - - self::assertSame('adjustment', $row['kind']); - self::assertSame(2, $row['delta']); - self::assertSame('جبران جلسهٔ لغوشده توسط کلینیک', $row['reason']); - self::assertNotNull($row['created_by']); - self::assertSame(8, $row['running_balance']); - } - - public function testAdjustmentCannotDriveTheBalanceNegative(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section, 'لیزر'); - - $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); - - $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [ - 'delta' => -10, - 'reason' => 'اشتباه اپراتور', - ]); - - self::assertSame(422, $this->responseCode()); - } - - public function testAnotherClinicCannotSeeOrTouchThePackage(): void - { - [$owner, $section, , , $patient] = $this->clinicWithPatient(); - [$other] = $this->clinicWithPatient(); - - $service = $this->service($section, 'لیزر'); - $sold = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']); - - $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $other); - self::assertSame(404, $this->responseCode()); - - $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $other, [ - 'delta' => 5, - 'reason' => 'تلاش از محیط دیگر', - ]); - self::assertSame(404, $this->responseCode()); - } - - /** منشی نباید بتواند اعتبار را دستی عوض کند. */ - public function testASecretaryCannotAdjustTheLedger(): void - { - [$owner, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section, 'لیزر'); - $sold = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']); - - $secretary = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']); - - $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $secretary, [ - 'delta' => 5, - 'reason' => 'تلاش منشی', - ]); - - self::assertContains($this->responseCode(), [403, 404]); - } - - public function testLedgerRowsNeverHaveAZeroDelta(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section, 'لیزر'); - $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); - - $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [ - 'delta' => 0, - 'reason' => 'بی‌اثر', - ]); - - self::assertSame(422, $this->responseCode()); - - $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']); - - self::expectException(\InvalidArgumentException::class); - new SessionCreditLedger($entity, SessionCreditLedger::KIND_ADJUSTMENT, 0); - } -} diff --git a/tests/Policy/DocsCaptureTest.php b/tests/Policy/DocsCaptureTest.php deleted file mode 100644 index ea02878f..00000000 --- a/tests/Policy/DocsCaptureTest.php +++ /dev/null @@ -1,97 +0,0 @@ -createUser(['ROLE_USER', 'ROLE_CLINIC']); - $clinic = new Clinic($user); - $clinic->setName('کلینیک نمونه'); - $this->em->persist($clinic); - $this->em->flush(); - - $section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی'); - $this->em->persist($section); - - $address = DoctorAddress::forClinic($clinic->getId()); - $address->setName('شعبهٔ مرکزی'); - $this->em->persist($address); - $this->em->flush(); - - $service = new ServiceItem($section, 'لیزر صورت'); - $service->setSoloDurationMinutes(20); - $service->setPriceRials(1_000_000); - $this->em->persist($service); - $this->em->flush(); - - $dump = function (string $label, mixed $body): void { - fwrite( - STDERR, - sprintf("\n===%s %d===\n%s\n", $label, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)), - ); - }; - - $dump('SCHEMA', $this->authJson('GET', '/api/v1/policy-schema', $user)); - - $created = $this->authJson('POST', '/api/v1/policy', $user, [ - 'category' => 'timing', - 'name' => 'حداقل یک ساعت برای لیزر', - 'priority' => 10, - 'condition' => ['match' => 'all', 'conditions' => [ - ['field' => 'item_count', 'operator' => 'greater_than', 'value' => 1], - ]], - 'effects' => [['type' => 'min_duration_minutes', 'value' => 60]], - ]); - $dump('CREATE', $created); - - $uuid = $created['data']['uuid']; - - $dump('ACTIVATE', $this->authJson('POST', "/api/v1/policy/$uuid/activate", $user)); - - $dump('VERSION', $this->authJson('POST', "/api/v1/policy/$uuid/version", $user, [ - 'effects' => [['type' => 'min_duration_minutes', 'value' => 90]], - ])); - - $dump('SHOW', $this->authJson('GET', "/api/v1/policy/$uuid", $user)); - $dump('INDEX', $this->authJson('GET', '/api/v1/policies?category=timing', $user)); - - $dump('BAD_FIELD', $this->authJson('POST', '/api/v1/policy', $user, [ - 'category' => 'timing', - 'name' => 'قانون نامعتبر', - 'condition' => ['match' => 'all', 'conditions' => [ - ['field' => 'subtotal_rials', 'operator' => 'greater_than', 'value' => 10], - ]], - 'effects' => [['type' => 'min_duration_minutes', 'value' => 30]], - ])); - - $dump('BAD_EFFECT', $this->authJson('POST', '/api/v1/policy', $user, [ - 'category' => 'timing', - 'name' => 'اثر نامعتبر', - 'effects' => [['type' => 'discount_percent', 'value' => 10]], - ])); - - $dump('DEACTIVATE', $this->authJson('POST', "/api/v1/policy/$uuid/deactivate", $user)); - - self::assertTrue(true); - } -} diff --git a/tests/Policy/NoPolicyRegressionTest.php b/tests/Policy/NoPolicyRegressionTest.php deleted file mode 100644 index c6c801ec..00000000 --- a/tests/Policy/NoPolicyRegressionTest.php +++ /dev/null @@ -1,72 +0,0 @@ -createUser(['ROLE_USER', 'ROLE_CLINIC']); - $clinic = new Clinic($user); - $clinic->setName('کلینیک بی‌قانون'); - $this->em->persist($clinic); - $this->em->flush(); - - $section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی'); - $this->em->persist($section); - - $address = DoctorAddress::forClinic($clinic->getId()); - $address->setName('شعبهٔ مرکزی'); - $this->em->persist($address); - $this->em->flush(); - - $service = new ServiceItem($section, 'لیزر'); - $service->setSoloDurationMinutes(20); - $service->setPriceRials(1_000_000); - $this->em->persist($service); - $this->em->flush(); - - $plan = $this->authJson('POST', '/api/v1/appointment-plan/preview', $user, [ - 'service_uuid' => $service->getUuid(), - 'branch_uuid' => $address->getUuid(), - ]); - - self::assertSame(200, $this->responseCode(), json_encode($plan, JSON_UNESCAPED_UNICODE)); - self::assertSame(20, $plan['data']['total_minutes']); - self::assertSame([0], array_column($plan['data']['segments'], 'offset_minutes')); - - $quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [ - 'service_uuid' => $service->getUuid(), - 'branch_uuid' => $address->getUuid(), - ]); - - self::assertSame(1_000_000, $quote['data']['base_rials']); - self::assertSame(0, $quote['data']['discount_rials']); - self::assertSame(1_000_000, $quote['data']['final_rials']); - - // نبودِ کلید مهم‌تر از صفر بودن مقدار است: کلیدِ خالی هم یعنی موتور چیزی - // اعمال کرده که نباید می‌کرد. - self::assertArrayNotHasKey('applied_policies', $quote['data']['breakdown']['sources']); - - $selection = $this->authJson('POST', '/api/v1/service-selection/validate', $user, [ - 'item_uuids' => [$service->getUuid()], - 'branch_uuid' => $address->getUuid(), - ]); - - self::assertTrue($selection['data']['valid']); - self::assertSame([], $selection['data']['errors']); - } -} diff --git a/tests/Policy/OperatorRegistryTest.php b/tests/Policy/OperatorRegistryTest.php deleted file mode 100644 index e48c000f..00000000 --- a/tests/Policy/OperatorRegistryTest.php +++ /dev/null @@ -1,77 +0,0 @@ -evaluate($op, $actual, $expected, self::NOW), - sprintf('%s(%s, %s)', $op, json_encode($actual), json_encode($expected)), - ); - } - - public static function cases(): array - { - $day = 86400; - - return [ - 'equals' => ['equals', 5, 5, true], - 'equals — رشتهٔ عددی' => ['equals', '5', 5, true], - 'equals — نه' => ['equals', 5, 6, false], - 'not_equals' => ['not_equals', 5, 6, true], - 'greater_than' => ['greater_than', 6, 5, true], - 'greater_than — مرز' => ['greater_than', 5, 5, false], - 'greater_or_equal' => ['greater_or_equal', 5, 5, true], - 'less_than' => ['less_than', 4, 5, true], - 'less_or_equal' => ['less_or_equal', 5, 5, true], - 'in' => ['in', 2, [1, 2, 3], true], - 'in — نه' => ['in', 9, [1, 2, 3], false], - 'not_in' => ['not_in', 9, [1, 2, 3], true], - 'between — داخل' => ['between', 30, [18, 65], true], - 'between — مرز پایین' => ['between', 18, [18, 65], true], - 'between — مرز بالا' => ['between', 65, [18, 65], true], - 'between — بیرون' => ['between', 66, [18, 65], false], - 'contains' => ['contains', ['vip', 'new'], 'vip', true], - 'contains — نه' => ['contains', ['new'], 'vip', false], - 'days_since — گذشته' => ['days_since', self::NOW - 40 * $day, 30, true], - 'days_since — تازه' => ['days_since', self::NOW - 10 * $day, 30, false], - 'days_since — هرگز' => ['days_since', 0, 30, false], - ]; - } - - /** عملگر ناشناخته `false` می‌دهد، نه خطا — ولی ذخیره‌اش از قبل جلوگیری شده. */ - public function testAnUnknownOperatorIsFalseAndNotRegistered(): void - { - $registry = new OperatorRegistry(); - - self::assertFalse($registry->has('regex')); - self::assertFalse($registry->evaluate('regex', 'a', 'a')); - } - - /** فرم فقط عملگرهای معنادار همان نوع را نشان می‌دهد. */ - public function testOperatorsAreFilteredByFieldType(): void - { - $registry = new OperatorRegistry(); - - self::assertSame(['contains'], $registry->forType('list')); - self::assertSame(['equals'], $registry->forType('bool')); - self::assertContains('days_since', $registry->forType('timestamp')); - self::assertNotContains('between', $registry->forType('uuid')); - } -} diff --git a/tests/Policy/PolicyEngineTest.php b/tests/Policy/PolicyEngineTest.php deleted file mode 100644 index 64f92551..00000000 --- a/tests/Policy/PolicyEngineTest.php +++ /dev/null @@ -1,617 +0,0 @@ -createUser(['ROLE_USER', 'ROLE_CLINIC']); - $clinic = new Clinic($user); - $clinic->setName('کلینیک قوانین'); - $this->em->persist($clinic); - $this->em->flush(); - - $section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی'); - $this->em->persist($section); - - $address = DoctorAddress::forClinic($clinic->getId()); - $address->setName('شعبهٔ مرکزی'); - $this->em->persist($address); - $this->em->flush(); - - return [$user, $section, $address]; - } - - private function service(ServiceSection $section, string $name, int $solo = 20, int $price = 1_000_000): ServiceItem - { - $item = new ServiceItem($section, $name); - $item->setSoloDurationMinutes($solo); - $item->setPriceRials($price); - $this->em->persist($item); - $this->em->flush(); - - return $item; - } - - /** - * قانون تازه **پیش‌نویس** است؛ تا فعال نشود اجرا نمی‌شود. - * - * @param array $body - */ - private function policy(User $user, array $body, bool $activate = true): array - { - $created = $this->authJson('POST', '/api/v1/policy', $user, $body); - self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE)); - - if (!$activate) { - return $created['data']; - } - - // فعال‌سازی از تسک ۱۰ به بعد یک اجرای آزمایشی از **همین نسخه** می‌خواهد. - $this->authJson('POST', "/api/v1/policy/{$created['data']['uuid']}/simulate", $user); - self::assertSame(201, $this->responseCode()); - - $active = $this->authJson('POST', "/api/v1/policy/{$created['data']['uuid']}/activate", $user); - self::assertSame(200, $this->responseCode(), json_encode($active, JSON_UNESCAPED_UNICODE)); - - return $active['data']; - } - - /** پیش‌نویس ماندنِ قانون تازه عمدی است: نوشتن قانون نباید یعنی اجرای آن. */ - public function testANewPolicyIsADraftUntilActivated(): void - { - [$user, $section, $address] = $this->clinicWithBranch(); - $service = $this->service($section, 'خدمت پیش‌نویس', 20); - - $draft = $this->policy($user, [ - 'category' => 'timing', - 'name' => 'قانون پیش‌نویس', - 'effects' => [['type' => 'add_duration_minutes', 'value' => 30]], - ], activate: false); - - self::assertFalse($draft['active']); - self::assertSame(20, $this->preview($user, $service, $address)['data']['total_minutes']); - } - - /** @param array $extra */ - private function preview(User $user, ServiceItem $service, DoctorAddress $address, array $extra = []): array - { - return $this->authJson('POST', '/api/v1/appointment-plan/preview', $user, $extra + [ - 'service_uuid' => $service->getUuid(), - 'branch_uuid' => $address->getUuid(), - ]); - } - - /** @param array $extra */ - private function quote(User $user, ServiceItem $service, DoctorAddress $address, array $extra = []): array - { - return $this->authJson('POST', '/api/v1/pricing/quote', $user, $extra + [ - 'service_uuid' => $service->getUuid(), - 'branch_uuid' => $address->getUuid(), - ]); - } - - // ── شِما ──────────────────────────────────────────────────────────────── - - public function testSchemaIsAClosedListPerCategory(): void - { - $user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); - $body = $this->authJson('GET', '/api/v1/policy-schema', $user); - - self::assertSame(200, $this->responseCode()); - - $schema = $body['data']; - - self::assertArrayHasKey('timing', $schema); - self::assertContains('equals', array_column($schema['timing']['operators'], 'value')); - - self::assertSame( - ['min_duration_minutes', 'add_duration_minutes'], - array_column($schema['timing']['effects'], 'type'), - ); - self::assertSame( - ['max', 'sum'], - array_column($schema['timing']['effects'], 'combination'), - ); - - // فیلد قیمتی در دستهٔ زمان جایی ندارد — همین بسته‌بودن نکتهٔ اصلی شِماست. - self::assertNotContains('subtotal_rials', $schema['timing']['fields']); - - // فرم باید عملگرها را per فیلد فیلتر کند، وگرنه کاربر «برچسب > ۵» می‌سازد و - // ۴۲۲ می‌گیرد بی‌آنکه بفهمد چرا. - $meta = array_column($schema['eligibility']['field_meta'], null, 'key'); - - self::assertSame('int', $meta['patient_age']['type']); - // عدد یازده عملگر ندارد؛ فقط آن‌هایی که روی عدد معنا دارند. - self::assertSame( - ['equals', 'not_equals', 'greater_than', 'greater_or_equal', 'less_than', 'less_or_equal', 'between', 'in', 'not_in'], - $meta['patient_age']['operators'], - ); - self::assertSame(['contains'], $meta['patient_tags']['operators']); - self::assertSame('سن بیمار', $meta['patient_age']['label']); - } - - public function testFieldOutsideTheCategoryIsRejectedAtCreateTime(): void - { - [$user] = $this->clinicWithBranch(); - - $body = $this->authJson('POST', '/api/v1/policy', $user, [ - 'category' => 'timing', - 'name' => 'قانون بی‌ربط', - 'condition' => ['match' => 'all', 'conditions' => [['field' => 'subtotal_rials', 'operator' => 'greater_than', 'value' => 10]]], - ]); - - self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - } - - public function testEffectOutsideTheCategoryIsRejectedAtCreateTime(): void - { - [$user] = $this->clinicWithBranch(); - - $this->authJson('POST', '/api/v1/policy', $user, [ - 'category' => 'timing', - 'name' => 'تخفیف در دستهٔ زمان', - 'effects' => [['type' => 'discount_percent', 'value' => 10]], - ]); - - self::assertSame(422, $this->responseCode()); - } - - // ── ترکیب اثرها ───────────────────────────────────────────────────────── - - /** «حداقل مدت» با max ترکیب می‌شود: سخت‌گیرترین قانون برنده است. */ - public function testMinDurationTakesTheLargestNotTheLast(): void - { - [$user, $section, $address] = $this->clinicWithBranch(); - $service = $this->service($section, 'لیزر', 20); - - $this->policy($user, [ - 'category' => 'timing', - 'name' => 'حداقل ۴۵ دقیقه', - 'effects' => [['type' => 'min_duration_minutes', 'value' => 45]], - ]); - - $this->policy($user, [ - 'category' => 'timing', - 'name' => 'حداقل ۶۰ دقیقه', - 'effects' => [['type' => 'min_duration_minutes', 'value' => 60]], - ]); - - $plan = $this->preview($user, $service, $address); - - self::assertSame(200, $this->responseCode(), json_encode($plan, JSON_UNESCAPED_UNICODE)); - self::assertSame(60, $plan['data']['total_minutes']); - } - - /** «افزودن مدت» با sum ترکیب می‌شود — دو قانون ۱۰ دقیقه‌ای یعنی ۲۰ دقیقه. */ - public function testAddDurationSumsAcrossPolicies(): void - { - [$user, $section, $address] = $this->clinicWithBranch(); - $service = $this->service($section, 'پاکسازی', 20); - - foreach (['ضدعفونی اضافه', 'آماده‌سازی اضافه'] as $name) { - $this->policy($user, [ - 'category' => 'timing', - 'name' => $name, - 'effects' => [['type' => 'add_duration_minutes', 'value' => 10]], - ]); - } - - $plan = $this->preview($user, $service, $address); - - self::assertSame(40, $plan['data']['total_minutes']); - } - - /** یک ممنوعیت کافی است؛ ممنوعیت رأی اکثریت نیست. */ - public function testOneForbidVetoesTheSelection(): void - { - [$user, $section, $address] = $this->clinicWithBranch(); - $service = $this->service($section, 'بوتاکس', 20); - - $this->policy($user, [ - 'category' => 'selection', - 'name' => 'این خدمت فعلاً ارائه نمی‌شود', - 'service_uuid' => $service->getUuid(), - 'effects' => [['type' => 'forbid', 'reason' => 'این خدمت موقتاً متوقف است']], - ]); - - $body = $this->authJson('POST', '/api/v1/service-selection/validate', $user, [ - 'item_uuids' => [$service->getUuid()], - 'branch_uuid' => $address->getUuid(), - ]); - - self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - self::assertFalse($body['data']['valid']); - self::assertSame('policy_forbidden', $body['data']['errors'][0]['code']); - self::assertSame('این خدمت موقتاً متوقف است', $body['data']['errors'][0]['message']); - } - - // ── شرط‌ها ────────────────────────────────────────────────────────────── - - public function testConditionThatDoesNotMatchLeavesThePlanAlone(): void - { - [$user, $section, $address] = $this->clinicWithBranch(); - $service = $this->service($section, 'مشاوره', 20); - - $this->policy($user, [ - 'category' => 'timing', - 'name' => 'فقط برای انتخاب‌های پرتعداد', - 'condition' => ['match' => 'all', 'conditions' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => 3]]], - 'effects' => [['type' => 'add_duration_minutes', 'value' => 30]], - ]); - - $plan = $this->preview($user, $service, $address); - - self::assertSame(20, $plan['data']['total_minutes']); - } - - /** حقیقتِ غایب یعنی شرط **برقرار نیست** — نه اینکه بی‌صدا رد شود. */ - public function testMissingFactFailsTheClauseInsteadOfPassingIt(): void - { - [$user, $section, $address] = $this->clinicWithBranch(); - $service = $this->service($section, 'لیزر بدن', 20); - - $this->policy($user, [ - 'category' => 'timing', - 'name' => 'وابسته به سن', - 'condition' => ['match' => 'all', 'conditions' => [['field' => 'patient_age', 'operator' => 'less_than', 'value' => 18]]], - 'effects' => [['type' => 'add_duration_minutes', 'value' => 15]], - ]); - - // پیش‌نمایش برنامه سن بیمار را نمی‌فرستد. - $plan = $this->preview($user, $service, $address); - - self::assertSame(20, $plan['data']['total_minutes']); - } - - public function testExpiredPolicyIsIgnored(): void - { - [$user, $section, $address] = $this->clinicWithBranch(); - $service = $this->service($section, 'میکرونیدلینگ', 20); - - $this->policy($user, [ - 'category' => 'timing', - 'name' => 'کمپین نوروز', - 'valid_from' => time() - 86400 * 30, - 'valid_to' => time() - 86400, - 'effects' => [['type' => 'add_duration_minutes', 'value' => 25]], - ]); - - $plan = $this->preview($user, $service, $address); - - self::assertSame(20, $plan['data']['total_minutes']); - } - - public function testDeactivatedPolicyIsIgnored(): void - { - [$user, $section, $address] = $this->clinicWithBranch(); - $service = $this->service($section, 'هیدرافیشیال', 20); - - $policy = $this->policy($user, [ - 'category' => 'timing', - 'name' => 'قانون خاموش‌شدنی', - 'effects' => [['type' => 'add_duration_minutes', 'value' => 20]], - ]); - - self::assertSame(40, $this->preview($user, $service, $address)['data']['total_minutes']); - - $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/deactivate", $user); - self::assertSame(200, $this->responseCode()); - - self::assertSame(20, $this->preview($user, $service, $address)['data']['total_minutes']); - } - - // ── ترتیب و اختصاصی‌بودن ──────────────────────────────────────────────── - - /** - * در تساوی اولویت، قانونِ اختصاصی‌تر اول می‌نشیند — همان که برچسبش روی فاکتور - * می‌رود. - */ - public function testMoreSpecificPolicyIsRankedFirstOnEqualPriority(): void - { - [$user, $section, $address] = $this->clinicWithBranch(); - $service = $this->service($section, 'فیلر', 20, 2_000_000); - - $this->policy($user, [ - 'category' => 'pricing', - 'name' => 'تخفیف عمومی محیط', - 'effects' => [['type' => 'discount_percent', 'value' => 5]], - ]); - - $this->policy($user, [ - 'category' => 'pricing', - 'name' => 'تخفیف همین سرویس', - 'service_uuid' => $service->getUuid(), - 'effects' => [['type' => 'discount_percent', 'value' => 10]], - ]); - - $quote = $this->quote($user, $service, $address); - - self::assertSame(200, $this->responseCode(), json_encode($quote, JSON_UNESCAPED_UNICODE)); - - $applied = $quote['data']['breakdown']['sources']['applied_policies']; - - self::assertSame('تخفیف همین سرویس', $applied[0]['name']); - // درصدها جمع می‌شوند: ۵٪ + ۱۰٪ روی ۲٬۰۰۰٬۰۰۰ - self::assertSame(300_000, $quote['data']['discount_rials']); - } - - public function testHigherPriorityBeatsSpecificity(): void - { - [$user, $section, $address] = $this->clinicWithBranch(); - $service = $this->service($section, 'مزوتراپی', 20, 1_000_000); - - $this->policy($user, [ - 'category' => 'pricing', - 'name' => 'قانون محیطی با اولویت بالا', - 'priority' => 100, - 'effects' => [['type' => 'discount_percent', 'value' => 5]], - ]); - - $this->policy($user, [ - 'category' => 'pricing', - 'name' => 'قانون سرویسی با اولویت پایین', - 'service_uuid' => $service->getUuid(), - 'priority' => 1, - 'effects' => [['type' => 'discount_percent', 'value' => 5]], - ]); - - $applied = $this->quote($user, $service, $address)['data']['breakdown']['sources']['applied_policies']; - - self::assertSame('قانون محیطی با اولویت بالا', $applied[0]['name']); - } - - // ── نسخه ──────────────────────────────────────────────────────────────── - - /** - * قانون **ویرایش نمی‌شود**: تغییر یعنی نسخهٔ تازه، و شمارهٔ نسخه در فاکتور ثبت - * می‌شود تا سه ماه بعد بشود گفت کدام متن اعمال شده بود (قانون پنجم مستند). - */ - public function testEditingAPolicyCreatesANewVersionAndTheQuoteRecordsIt(): void - { - [$user, $section, $address] = $this->clinicWithBranch(); - $service = $this->service($section, 'لیزر صورت', 20, 1_000_000); - - $policy = $this->policy($user, [ - 'category' => 'pricing', - 'name' => 'تخفیف پاییز', - 'effects' => [['type' => 'discount_percent', 'value' => 10]], - ]); - - self::assertSame(1, $policy['version']); - - $first = $this->quote($user, $service, $address); - self::assertSame(100_000, $first['data']['discount_rials']); - self::assertSame(1, $first['data']['breakdown']['sources']['applied_policies'][0]['version']); - - $updated = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [ - 'effects' => [['type' => 'discount_percent', 'value' => 20]], - ]); - - self::assertSame(200, $this->responseCode(), json_encode($updated, JSON_UNESCAPED_UNICODE)); - self::assertSame(2, $updated['data']['version']); - - // نسخهٔ تازه فعال می‌ماند؛ آزمایش دوباره لازم نیست چون قانون از قبل فعال بود. - - $second = $this->quote($user, $service, $address); - self::assertSame(200_000, $second['data']['discount_rials']); - self::assertSame(2, $second['data']['breakdown']['sources']['applied_policies'][0]['version']); - - // هر دو نسخه در تاریخچه می‌مانند. - $show = $this->authJson('GET', "/api/v1/policy/{$policy['uuid']}", $user); - self::assertSame([1, 2], array_column($show['data']['versions'], 'version')); - } - - /** - * ⭐ نسخهٔ تازه نمی‌تواند ادعا کند از دیروز برقرار بوده. - * - * نوبت‌های دیروز با متن قبلی حساب شده‌اند؛ اعتبار عقب‌رونده یعنی ردپای قیمت‌ها با - * قانونی توضیح داده شود که آن روز وجود نداشت. - */ - public function testANewVersionCannotStartInThePast(): void - { - [$user, , ] = $this->clinicWithBranch(); - - $policy = $this->policy($user, [ - 'category' => 'pricing', - 'name' => 'تخفیف پاییز', - 'effects' => [['type' => 'discount_percent', 'value' => 10]], - ], false); - - $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [ - 'valid_from' => time() - 7 * 86400, - ]); - - self::assertSame(422, $this->responseCode()); - - // آینده مجاز است. - $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [ - 'valid_from' => time() + 86400, - ]); - - self::assertSame(200, $this->responseCode()); - } - - /** - * ⭐ شش عملگر، هر کدام جدا. عملگری که غلط بسنجد، قانونی می‌سازد که یا همیشه - * می‌گیرد یا هرگز — و هیچ‌کدام خطا نمی‌دهند. - * - */ - #[\PHPUnit\Framework\Attributes\DataProvider('operatorCases')] - public function testEachOperatorDecidesOnItsOwn(array $clause, bool $expected): void - { - [$user, $section, $address] = $this->clinicWithBranch(); - $service = $this->service($section, 'خدمت عملگر', 20, 1_000_000); - - $this->policy($user, [ - 'category' => 'pricing', - 'name' => 'آزمون عملگر', - 'condition' => ['match' => 'all', 'conditions' => [$clause]], - 'effects' => [['type' => 'discount_percent', 'value' => 50]], - ]); - - $quote = $this->quote($user, $service, $address); - - self::assertSame( - $expected ? 500_000 : 0, - $quote['data']['discount_rials'], - json_encode($clause, JSON_UNESCAPED_UNICODE), - ); - } - - /** بدون `item_uuids` هیچ آیتم اضافه‌ای انتخاب نشده، پس `item_count` صفر است. */ - public static function operatorCases(): array - { - return [ - 'equals می‌گیرد' => [['field' => 'item_count', 'operator' => 'equals', 'value' => 0], true], - 'equals نمی‌گیرد' => [['field' => 'item_count', 'operator' => 'equals', 'value' => 9], false], - 'not_equals می‌گیرد' => [['field' => 'item_count', 'operator' => 'not_equals', 'value' => 9], true], - 'not_equals نمی‌گیرد' => [['field' => 'item_count', 'operator' => 'not_equals', 'value' => 0], false], - 'greater_than می‌گیرد' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => -1], true], - 'greater_than نمی‌گیرد' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => 5], false], - 'less_than می‌گیرد' => [['field' => 'item_count', 'operator' => 'less_than', 'value' => 5], true], - 'less_than نمی‌گیرد' => [['field' => 'item_count', 'operator' => 'less_than', 'value' => 0], false], - 'in می‌گیرد' => [['field' => 'item_count', 'operator' => 'in', 'value' => [0, 2]], true], - 'in نمی‌گیرد' => [['field' => 'item_count', 'operator' => 'in', 'value' => [7, 8]], false], - 'contains نمی‌گیرد' => [['field' => 'patient_tags', 'operator' => 'contains', 'value' => 'vip'], false], - ]; - } - - /** - * ⭐ قانون `spacing` در لحظهٔ **رزرو موقت** اجرا می‌شود، نه هنگام تولید کاندید. - * - * هزینه‌اش یک اسلات است که نمایش داده می‌شود و بعد رد می‌شود؛ سودش این است که - * جستجوی وقت به‌ازای هر کاندید یک کوئری تاریخچهٔ بیمار نمی‌زند. این تست همان مرز را - * پین می‌کند: نوبت نزدیک رد می‌شود، نوبت دور می‌گذرد. - */ - public function testSpacingRejectsABookingTooCloseToTheLastOne(): void - { - [$user, $section, $address] = $this->clinicWithBranch(); - $service = $this->service($section, 'لیزر', 20, 1_000_000); - - $this->policy($user, [ - 'category' => 'spacing', - 'name' => 'حداقل ۲۱ روز بین جلسات', - 'effects' => [['type' => 'min_days_between', 'value' => 21]], - ]); - - $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); - $doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر فاصله'); - $this->em->persist($doctor); - $this->em->flush(); - - $patient = $this->createUser(['ROLE_USER']); - $last = time() - 5 * 86400; - - $previous = new \App\Appointment\Entity\Appointment($doctor, $patient, $last, $last + 1200); - $previous->assignTenantPair($address->tenantEntityType(), $address->tenantEntityId()); - $previous->setServiceItem($this->em->getRepository(ServiceItem::class)->find($service->getId())); - $previous->setAddressId($address->getId()); - $previous->setPatientName('بیمار فاصله'); - $previous->transitionTo(\App\Appointment\Entity\Appointment::STATUS_CONFIRMED); - $this->em->persist($previous); - $this->em->flush(); - - $guard = static::getContainer()->get(\App\Policy\Service\BookingPolicyGuard::class); - - // پنج روز بعد از جلسهٔ قبلی → رد. - $rejected = false; - try { - $guard->assertSpacing($patient, $service, $address, $last + 5 * 86400); - } catch (\App\Shared\Exception\AppException $e) { - $rejected = true; - self::assertStringContainsString('۲۱', str_replace( - ['0','1','2','3','4','5','6','7','8','9'], - ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'], - $e->getMessage(), - )); - } - self::assertTrue($rejected, 'فاصلهٔ کمتر از قانون باید رد شود'); - - // سی روز بعد → می‌گذرد. - $guard->assertSpacing($patient, $service, $address, $last + 30 * 86400); - self::assertTrue(true); - } - - /** - * ⭐ `specificity` هنگام **ذخیره** حساب می‌شود و در تساوی اولویت تصمیم می‌گیرد. - * - * محاسبه‌اش در زمان اجرا یعنی کاری که یک بار در عمر قانون کافی بود، در هر رزرو - * تکرار شود؛ و ذخیره‌شدنش یعنی می‌شود روزی مرتب‌سازی را به SQL برد. - */ - public function testSpecificityIsStoredAndDecidesTiesAtEqualPriority(): void - { - [$user, $section, $address] = $this->clinicWithBranch(); - $service = $this->service($section, 'لیزر', 20, 1_000_000); - - // قانون عام: بدون دامنه، بدون شرط. - $broad = $this->policy($user, [ - 'category' => 'pricing', - 'name' => 'تخفیف عمومی', - 'priority' => 5, - 'effects' => [['type' => 'discount_percent', 'value' => 10]], - ]); - - // قانون خاص: همان اولویت، ولی سرویس و یک شرط دارد. - $narrow = $this->policy($user, [ - 'category' => 'pricing', - 'name' => 'تخفیف همین سرویس', - 'priority' => 5, - 'service_uuid' => $service->getUuid(), - 'condition' => ['match' => 'all', 'conditions' => [ - ['field' => 'item_count', 'operator' => 'greater_or_equal', 'value' => 0], - ]], - 'effects' => [['type' => 'discount_percent', 'value' => 40]], - ]); - - self::assertSame(0, $broad['specificity'], 'قانون بی‌دامنه و بی‌شرط'); - self::assertSame(5, $narrow['specificity'], 'سرویس ۴ + یک شرط ۱'); - - // هر دو اعمال می‌شوند (تخفیف درصدی جمع می‌شود)، ولی **ترتیب** مال specificity است: - // اختصاصی‌تر اول می‌آید، و همان ترتیبی است که اثرهای «اولی برنده» را تعیین می‌کند. - $quote = $this->quote($user, $service, $address); - $names = array_column($quote['data']['breakdown']['sources']['applied_policies'], 'name'); - - self::assertSame(['تخفیف همین سرویس', 'تخفیف عمومی'], $names, 'اختصاصی‌تر باید اول باشد'); - } - - // ── جداسازی محیط ──────────────────────────────────────────────────────── - - public function testPolicyOfAnotherClinicIsNeitherVisibleNorApplied(): void - { - [$owner, , ] = $this->clinicWithBranch(); - [$other, $section, $address] = $this->clinicWithBranch(); - - $service = $this->service($section, 'خدمت کلینیک دوم', 20, 1_000_000); - - $foreign = $this->policy($owner, [ - 'category' => 'pricing', - 'name' => 'تخفیف کلینیک اول', - 'effects' => [['type' => 'discount_percent', 'value' => 50]], - ]); - - $this->authJson('GET', "/api/v1/policy/{$foreign['uuid']}", $other); - self::assertSame(404, $this->responseCode()); - - $quote = $this->quote($other, $service, $address); - - self::assertSame(0, $quote['data']['discount_rials']); - self::assertArrayNotHasKey('applied_policies', $quote['data']['breakdown']['sources']); - } -} diff --git a/tests/Policy/PolicyFieldCoverageTest.php b/tests/Policy/PolicyFieldCoverageTest.php deleted file mode 100644 index 3c0ce594..00000000 --- a/tests/Policy/PolicyFieldCoverageTest.php +++ /dev/null @@ -1,78 +0,0 @@ -forCategory($category) as $field) { - $fields[$field][] = $category; - } - } - - $sources = $this->sourceFiles(dirname(__DIR__, 2) . '/src'); - $missing = []; - - foreach ($fields as $field => $categories) { - $found = false; - - foreach ($sources as $file => $code) { - // خودِ schema فقط نام را اعلام می‌کند؛ پر کردنش جای دیگری است. - if (str_ends_with($file, 'PolicySchema.php') || str_ends_with($file, 'FieldRegistry.php')) { - continue; - } - - if (str_contains($code, sprintf("'%s'", $field)) && str_contains($code, '=>')) { - $found = true; - break; - } - } - - if (!$found) { - $missing[$field] = $categories; - } - } - - self::assertSame( - [], - $missing, - 'این فیلدها در schema هستند ولی هیچ‌جا در context پر نمی‌شوند: ' . - json_encode($missing, JSON_UNESCAPED_UNICODE), - ); - } - - /** @return array مسیر => محتوا */ - private function sourceFiles(string $root): array - { - $files = []; - $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root)); - - foreach ($iterator as $file) { - if ($file->isFile() && $file->getExtension() === 'php') { - $files[$file->getPathname()] = (string) file_get_contents($file->getPathname()); - } - } - - return $files; - } -} diff --git a/tests/Policy/PolicySimulationTest.php b/tests/Policy/PolicySimulationTest.php deleted file mode 100644 index 1469348c..00000000 --- a/tests/Policy/PolicySimulationTest.php +++ /dev/null @@ -1,483 +0,0 @@ -createUser(['ROLE_USER', 'ROLE_CLINIC']); - $clinic = new Clinic($user); - $clinic->setName('کلینیک آزمایشگاه'); - $this->em->persist($clinic); - $this->em->flush(); - - $section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی'); - $this->em->persist($section); - - $address = DoctorAddress::forClinic($clinic->getId()); - $address->setName('شعبهٔ مرکزی'); - $this->em->persist($address); - - $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); - $doctor = new Doctor($doctorUser, 'دکتر آزمون'); - $this->em->persist($doctor); - $this->em->flush(); - - return [$user, $section, $address, $doctor]; - } - - private function service(ServiceSection $section, string $name, int $price = 1_000_000): ServiceItem - { - $item = new ServiceItem($section, $name); - $item->setSoloDurationMinutes(20); - $item->setPriceRials($price); - $this->em->persist($item); - $this->em->flush(); - - return $item; - } - - /** نوبت گذشتهٔ ثبت‌شده — نمونهٔ آزمایش از همین‌ها ساخته می‌شود. */ - private function pastAppointment( - Doctor $doctor, - User $patient, - ServiceItem $service, - Clinic|int $clinicId, - int $daysAgo, - int $price = 1_000_000, - ): Appointment { - $start = time() - $daysAgo * 86400; - - $appointment = new Appointment($doctor, $patient, $start, $start + 1200); - $appointment->assignTenantPair('clinic', is_int($clinicId) ? $clinicId : (int) $clinicId->getId()); - $appointment->setServiceItem($service); - $appointment->setVisitPriceRials($price); - $appointment->setPatientName('بیمار نمونه'); - $appointment->transitionTo(Appointment::STATUS_CONFIRMED); - $appointment->transitionTo(Appointment::STATUS_COMPLETED); - - $this->em->persist($appointment); - $this->em->flush(); - - return $appointment; - } - - /** @param array $body */ - private function draft(User $user, array $body): array - { - $created = $this->authJson('POST', '/api/v1/policy', $user, $body); - self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE)); - - return $created['data']; - } - - /** @param string[] $tables */ - private function countRows(array $tables): array - { - $connection = $this->em->getConnection(); - $counts = []; - - foreach ($tables as $table) { - $counts[$table] = (int) $connection->fetchOne("SELECT COUNT(*) FROM $table"); - } - - return $counts; - } - - // ── الگوها ────────────────────────────────────────────────────────────── - - public function testTemplatesAreListedWithTheirInputs(): void - { - $user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); - $body = $this->authJson('GET', '/api/v1/policy-templates', $user); - - self::assertSame(200, $this->responseCode()); - - $keys = array_column($body['data'], 'key'); - - self::assertContains('min_days_between_sessions', $keys); - self::assertContains('vip_discount', $keys); - - $vip = current(array_filter($body['data'], static fn (array $t): bool => $t['key'] === 'vip_discount')); - - self::assertSame('pricing', $vip['category']); - self::assertSame(['visit_count', 'percent'], array_column($vip['inputs'], 'key')); - } - - /** الگو باید همان قانونی را بسازد که کاربر دستی می‌ساخت — نه چیز دیگری. */ - public function testTemplateBuildsAValidPolicy(): void - { - [$user] = $this->clinicWithBranch(); - - $policy = $this->draft($user, [ - 'name' => 'تخفیف مشتری وفادار', - 'template' => 'vip_discount', - 'values' => ['visit_count' => 3, 'percent' => 15], - ]); - - self::assertSame('pricing', $policy['category']); - self::assertSame( - [['field' => 'visit_count', 'operator' => 'greater_than', 'value' => 3]], - $policy['condition']['conditions'], - ); - self::assertSame([['type' => 'discount_percent', 'value' => 15]], $policy['effects']); - } - - /** - * ⭐ هر شش الگو باید قانونِ **معتبر** بسازند. - * - * الگو میان‌بُر است، نه مسیر دوم: اگر خروجی یکی از آن‌ها از اعتبارسنجی عادی رد - * نشود، کاربر با یک کلیک قانونی می‌سازد که هیچ‌وقت کار نمی‌کند. - * - * @param array $values - */ - #[\PHPUnit\Framework\Attributes\DataProvider('templateCases')] - public function testEveryTemplateBuildsAValidPolicy(string $key, array $values, string $category): void - { - [$user, , $address] = $this->clinicWithBranch(); - - // الگوی نقش‌محور به یک نوع منبع واقعی نیاز دارد. - if (isset($values['role'])) { - $type = $this->authJson('POST', '/api/v1/resource-types', $user, [ - 'address_uuid' => $address->getUuid(), - 'code' => 'surgeon', - 'name' => 'جراح', - ]); - self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE)); - - $values['role'] = 'surgeon'; - } - - $policy = $this->draft($user, [ - 'name' => sprintf('الگوی %s', $key), - 'template' => $key, - 'values' => $values, - ]); - - self::assertSame($category, $policy['category']); - self::assertNotSame([], $policy['effects'], 'قانونی بدون اثر، قانون نیست'); - - // و باید از مسیر عادیِ آزمایش و فعال‌سازی رد شود. - $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user); - self::assertSame(201, $this->responseCode()); - - $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user); - self::assertSame(200, $this->responseCode()); - } - - public static function templateCases(): array - { - return [ - 'فاصلهٔ جلسات' => ['min_days_between_sessions', ['days' => 21], 'spacing'], - 'حداقل مدت' => ['complex_min_duration', ['minutes' => 60], 'timing'], - 'زمان اضافه' => ['extra_time_for_many_items', ['item_count' => 2, 'minutes' => 15], 'timing'], - 'نقش لازم' => ['surgery_needs_surgeon', ['role' => 'surgeon'], 'resource'], - 'رضایت والدین' => ['minor_needs_consent', ['age' => 18], 'eligibility'], - 'تخفیف وفادار' => ['vip_discount', ['visit_count' => 3, 'percent' => 15], 'pricing'], - ]; - } - - /** - * ⭐ آزمایش نباید هیچ نیمه‌حالتی جا بگذارد که **flushِ بعدیِ همین درخواست** ثبتش کند. - * - * این دقیقاً همان باگی است که `finally { rollback(); clear(); }` جلویش را می‌گیرد و - * پیدا کردنش روزها می‌برد: خطا در صفحهٔ آزمایش ظاهر نمی‌شود، در عملیاتِ بعدی ظاهر - * می‌شود. - */ - public function testSimulationLeavesNoPendingStateForALaterFlush(): void - { - [$user, , $address] = $this->clinicWithBranch(); - - $policy = $this->draft($user, [ - 'category' => 'pricing', - 'name' => 'قانون آزمایشی', - 'effects' => [['type' => 'discount_percent', 'value' => 10]], - ]); - - $entity = static::getContainer() - ->get(\App\Policy\Repository\PolicyRepository::class) - ->findOneBy(['uuid' => $policy['uuid']]); - - // یک entity در انتظار flush — دقیقاً وضعیتی که سناریوی خطرناک با آن شروع می‌شود. - $pending = new \App\Resource\Entity\ResourceType( - $address->tenantEntityType(), - $address->tenantEntityId(), - 'pending_type', - 'نوع در انتظار', - ); - $this->em->persist($pending); - - static::getContainer()->get(\App\Policy\Simulation\PolicySimulator::class)->simulate($entity, 5); - - self::assertSame( - 0, - $this->em->getConnection()->getTransactionNestingLevel(), - 'تراکنش آزمایش باید بسته شده باشد', - ); - - // flushِ بعدی نباید چیزی از قبل از آزمایش را ثبت کند. - $this->em->flush(); - - $written = (int) $this->em->getConnection()->fetchOne( - 'SELECT COUNT(*) FROM resource_types WHERE code = ?', - ['pending_type'], - ); - - self::assertSame(0, $written, 'آزمایش نباید حالتِ در انتظار را به ثبت برساند'); - } - - public function testTemplateWithAMissingValueIsRejected(): void - { - [$user] = $this->clinicWithBranch(); - - $this->authJson('POST', '/api/v1/policy', $user, [ - 'name' => 'بدون مقدار', - 'template' => 'vip_discount', - 'values' => ['visit_count' => 3], - ]); - - self::assertSame(422, $this->responseCode()); - } - - // ── شبیه‌سازی ─────────────────────────────────────────────────────────── - - /** ⭐ ارزشمندترین تست این تسک. */ - public function testSimulationWritesNothingButItsOwnRun(): void - { - [$user, $section, $address, $doctor] = $this->clinicWithBranch(); - $service = $this->service($section, 'لیزر'); - $clinic = $address->getClinicId(); - - for ($i = 1; $i <= 3; $i++) { - $this->pastAppointment($doctor, $user, $service, (int) $clinic, $i * 10); - } - - $policy = $this->draft($user, [ - 'category' => 'pricing', - 'name' => 'تخفیف ۱۰٪', - 'effects' => [['type' => 'discount_percent', 'value' => 10]], - ]); - - $tables = ['appointments', 'price_snapshots', 'resource_occupancy', 'policies', 'policy_version_logs']; - $before = $this->countRows($tables); - $runsBefore = $this->countRows(['policy_simulation_runs'])['policy_simulation_runs']; - - $body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user); - self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - - self::assertSame($before, $this->countRows($tables), 'شبیه‌سازی نباید هیچ ردیفی بنویسد'); - - // دیتابیس تست هرگز ریست نمی‌شود، پس تفاوت شمرده می‌شود نه مقدار مطلق. - self::assertSame( - $runsBefore + 1, - $this->countRows(['policy_simulation_runs'])['policy_simulation_runs'], - 'تنها ردیفی که باید نوشته شود، خودِ نتیجهٔ آزمایش است', - ); - } - - public function testPricingSimulationShowsThePerAppointmentDifference(): void - { - [$user, $section, $address, $doctor] = $this->clinicWithBranch(); - $service = $this->service($section, 'فیلر'); - - $this->pastAppointment($doctor, $user, $service, (int) $address->getClinicId(), 5, 2_000_000); - - $policy = $this->draft($user, [ - 'category' => 'pricing', - 'name' => 'تخفیف ۲۵٪', - 'effects' => [['type' => 'discount_percent', 'value' => 25]], - ]); - - $body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user); - - self::assertSame(1, $body['data']['sample_size']); - self::assertSame(1, $body['data']['affected_count']); - self::assertSame(100, $body['data']['affected_percent']); - self::assertSame('high', $body['data']['severity']); - - $row = $body['data']['rows'][0]; - - self::assertSame('2,000,000 ریال', $row['before']); - self::assertSame('1,500,000 ریال', $row['after']); - } - - /** کلینیک تازه نوبتی ندارد؛ اگر این حالت خطا بود، هرگز قانونی فعال نمی‌کرد. */ - public function testEmptySampleSucceedsWithAWarning(): void - { - [$user] = $this->clinicWithBranch(); - - $policy = $this->draft($user, [ - 'category' => 'timing', - 'name' => 'حداقل ۳۰ دقیقه', - 'effects' => [['type' => 'min_duration_minutes', 'value' => 30]], - ]); - - $body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user); - - self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - self::assertSame(0, $body['data']['sample_size']); - self::assertSame('none', $body['data']['severity']); - self::assertSame('داده‌ای برای آزمایش نیست', $body['data']['warning']); - } - - /** قانونی که همهٔ نمونه را رد می‌کند تقریباً همیشه اشتباه نوشته شده. */ - public function testAPolicyThatRejectsEverythingIsFlaggedHigh(): void - { - [$user, $section, $address, $doctor] = $this->clinicWithBranch(); - $service = $this->service($section, 'بوتاکس'); - - for ($i = 1; $i <= 3; $i++) { - $this->pastAppointment($doctor, $user, $service, (int) $address->getClinicId(), $i); - } - - $policy = $this->draft($user, [ - 'category' => 'selection', - 'name' => 'توقف کامل خدمت', - 'effects' => [['type' => 'forbid', 'reason' => 'این خدمت متوقف است']], - ]); - - $body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user); - - self::assertSame(3, $body['data']['affected_count']); - self::assertSame('high', $body['data']['severity']); - self::assertSame('رد می‌شد', $body['data']['rows'][0]['after']); - } - - /** شرطی که هرگز برقرار نمی‌شود هم هشدار است، نه موفقیت. */ - public function testAPolicyThatMatchesNothingIsFlaggedNone(): void - { - [$user, $section, $address, $doctor] = $this->clinicWithBranch(); - $service = $this->service($section, 'مشاوره'); - - $this->pastAppointment($doctor, $user, $service, (int) $address->getClinicId(), 2); - - $policy = $this->draft($user, [ - 'category' => 'timing', - 'name' => 'فقط برای سبد بزرگ', - 'condition' => ['match' => 'all', 'conditions' => [ - ['field' => 'item_count', 'operator' => 'greater_than', 'value' => 50], - ]], - 'effects' => [['type' => 'add_duration_minutes', 'value' => 15]], - ]); - - $body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user); - - self::assertSame(1, $body['data']['sample_size']); - self::assertSame(0, $body['data']['affected_count']); - self::assertSame('none', $body['data']['severity']); - } - - // ── دروازهٔ فعال‌سازی ──────────────────────────────────────────────────── - - public function testActivateWithoutSimulationIsRejected(): void - { - [$user] = $this->clinicWithBranch(); - - $policy = $this->draft($user, [ - 'category' => 'timing', - 'name' => 'قانون آزمایش‌نشده', - 'effects' => [['type' => 'min_duration_minutes', 'value' => 45]], - ]); - - $body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user); - - self::assertSame(422, $this->responseCode()); - self::assertSame('ابتدا قانون را آزمایش کنید و نتیجه را ببینید', $body['errors'][0]['message']); - } - - public function testSimulationOfTheOldVersionDoesNotUnlockTheNewOne(): void - { - [$user] = $this->clinicWithBranch(); - - $policy = $this->draft($user, [ - 'category' => 'timing', - 'name' => 'قانون نسخه‌دار', - 'effects' => [['type' => 'min_duration_minutes', 'value' => 45]], - ]); - - $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user); - self::assertSame(201, $this->responseCode()); - - $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [ - 'effects' => [['type' => 'min_duration_minutes', 'value' => 90]], - ]); - self::assertSame(200, $this->responseCode()); - - $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user); - self::assertSame(422, $this->responseCode(), 'آزمایش نسخهٔ ۱ نباید نسخهٔ ۲ را باز کند'); - - $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user); - $activated = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user); - - self::assertSame(200, $this->responseCode()); - self::assertTrue($activated['data']['active']); - } - - public function testSimulationHistoryIsListedNewestFirst(): void - { - [$user] = $this->clinicWithBranch(); - - $policy = $this->draft($user, [ - 'category' => 'timing', - 'name' => 'قانون با تاریخچه', - 'effects' => [['type' => 'min_duration_minutes', 'value' => 30]], - ]); - - $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user); - $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user); - - $body = $this->authJson('GET', "/api/v1/policy/{$policy['uuid']}/simulations", $user); - - self::assertSame(200, $this->responseCode()); - self::assertCount(2, $body['data']); - } - - public function testSampleSizeAboveTheCapIsRejected(): void - { - [$user] = $this->clinicWithBranch(); - - $policy = $this->draft($user, [ - 'category' => 'timing', - 'name' => 'قانون با نمونهٔ بزرگ', - 'effects' => [['type' => 'min_duration_minutes', 'value' => 30]], - ]); - - $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user, ['sample_size' => 500]); - - // سقف بی‌صدا اعمال نمی‌شود: کاربری که ۵۰۰ خواسته باید بداند نگرفته. - self::assertSame(422, $this->responseCode()); - } - - public function testSimulatingAnotherClinicsPolicyIsNotFound(): void - { - [$owner] = $this->clinicWithBranch(); - [$other] = $this->clinicWithBranch(); - - $policy = $this->draft($owner, [ - 'category' => 'timing', - 'name' => 'قانون کلینیک اول', - 'effects' => [['type' => 'min_duration_minutes', 'value' => 30]], - ]); - - $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $other); - - self::assertSame(404, $this->responseCode()); - } -} diff --git a/tests/Report/AppointmentLifecycleEventTest.php b/tests/Report/AppointmentLifecycleEventTest.php deleted file mode 100644 index f432c16e..00000000 --- a/tests/Report/AppointmentLifecycleEventTest.php +++ /dev/null @@ -1,87 +0,0 @@ -createUser(['ROLE_DOCTOR']), 'دکتر رویداد'); - $this->em->persist($doctor); - $this->em->flush(); - - return $doctor; - } - - private function latest(string $name): ?DomainEventLog - { - return $this->em->getRepository(DomainEventLog::class) - ->findOneBy(['name' => $name], ['id' => 'DESC']); - } - - public function testCompletingAnAppointmentRecordsTheEvent(): void - { - $doctor = $this->makeDoctor(); - $start = time() + 86_400; - $appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800); - $this->em->persist($appointment); - $this->em->flush(); - - $this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $doctor->getUser(), [ - 'status' => Appointment::STATUS_CONFIRMED, - 'version' => $appointment->getVersion(), - ]); - self::assertSame(200, $this->responseCode()); - - $this->em->clear(); - $reloaded = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $appointment->getUuid()]); - - $this->authJson('PATCH', "/api/v1/appointment/{$reloaded->getUuid()}/status", $doctor->getUser(), [ - 'status' => Appointment::STATUS_COMPLETED, - 'version' => $reloaded->getVersion(), - ]); - self::assertSame(200, $this->responseCode()); - - $event = $this->latest(DomainEvents::APPOINTMENT_COMPLETED); - self::assertNotNull($event); - self::assertSame($appointment->getUuid(), $event->getPayload()['appointment_uuid']); - self::assertSame($start, $event->getPayload()['slot_start']); - } - - /** - * انتقال ردشده نباید رویداد بگذارد؛ وگرنه گزارش «انجام‌شده»ها از خودِ نوبت‌ها جلو می‌زند. - */ - public function testARejectedTransitionRecordsNothing(): void - { - $doctor = $this->makeDoctor(); - $start = time() + 86_400; - $appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800); - $this->em->persist($appointment); - $this->em->flush(); - - $before = $this->latest(DomainEvents::APPOINTMENT_COMPLETED); - - // `pending → completed` در جدول انتقال‌ها نیست. - $this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $doctor->getUser(), [ - 'status' => Appointment::STATUS_COMPLETED, - 'version' => $appointment->getVersion(), - ]); - - self::assertSame(422, $this->responseCode()); - - $after = $this->latest(DomainEvents::APPOINTMENT_COMPLETED); - self::assertSame($before?->getId(), $after?->getId()); - } -} diff --git a/tests/Report/DomainEventTest.php b/tests/Report/DomainEventTest.php deleted file mode 100644 index f76e3fdc..00000000 --- a/tests/Report/DomainEventTest.php +++ /dev/null @@ -1,249 +0,0 @@ -createUser(['ROLE_USER', 'ROLE_CLINIC']); - $clinic = new Clinic($user); - $clinic->setName('کلینیک رویداد'); - $this->em->persist($clinic); - $this->em->flush(); - - $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); - $this->em->persist($section); - - $address = DoctorAddress::forClinic($clinic->getId()); - $address->setName('شعبهٔ مرکزی'); - $this->em->persist($address); - - $patientUser = $this->createUser(['ROLE_USER']); - $patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId()); - $this->em->persist($patient); - $this->em->flush(); - - return [$user, $section, $address, $patient]; - } - - private function service(ServiceSection $section): ServiceItem - { - $item = new ServiceItem($section, 'لیزر فول‌بادی'); - $item->setSoloDurationMinutes(30); - $item->setPriceRials(4_000_000); - $this->em->persist($item); - $this->em->flush(); - - return $item; - } - - private function publisher(): DomainEventPublisher - { - return static::getContainer()->get(DomainEventPublisher::class); - } - - private function repo(): DomainEventLogRepository - { - return static::getContainer()->get(DomainEventLogRepository::class); - } - - private function containerEm(): EntityManagerInterface - { - return static::getContainer()->get(EntityManagerInterface::class); - } - - // ── قرارداد ───────────────────────────────────────────────────────────── - - /** نام رویداد قرارداد عمومی است؛ تایپو باید همان‌جا بترکد نه در سکوت. */ - public function testAnUnknownEventNameIsRejected(): void - { - [$user] = $this->clinic(); - - self::expectException(\InvalidArgumentException::class); - - $this->publisher()->record('clinic', 1, 'AppointmentBookd', ['appointment_uuid' => 'x']); - } - - /** ⭐ payload فقط اسکالر و uuid — هیچ entity ای در رویداد نیست. */ - public function testNonScalarPayloadValuesAreDropped(): void - { - $event = new DomainEventLog('clinic', 1, DomainEvents::APPOINTMENT_BOOKED, [ - 'appointment_uuid' => 'abc', - 'count' => 3, - 'nested' => ['a' => 1], - 'object' => new \stdClass(), - ]); - - self::assertSame(['appointment_uuid' => 'abc', 'count' => 3], $event->getPayload()); - } - - // ── انتشار بعد از commit ──────────────────────────────────────────────── - - /** ⭐⭐ تراکنشی که برمی‌گردد، هیچ رویدادی جا نمی‌گذارد. */ - public function testARolledBackTransactionLeavesNoEvent(): void - { - $this->clinic(); - - $before = $this->repo()->count([]); - $em = $this->containerEm(); - - $em->beginTransaction(); - - try { - $this->publisher()->record('clinic', 999, DomainEvents::APPOINTMENT_BOOKED, ['appointment_uuid' => 'ghost']); - $em->flush(); - } finally { - $em->rollback(); - $em->clear(); - } - - self::assertSame($before, $this->repo()->count([]), 'رویداد نباید از تراکنشِ برگشته جا بماند'); - } - - // ── صندوق خروجی ──────────────────────────────────────────────────────── - - public function testPendingEventsArePublishedAndMarked(): void - { - [$user, $section, $address, $patient] = $this->clinic(); - - $event = $this->publisher()->recordAndFlush( - 'clinic', - (int) $address->getClinicId(), - DomainEvents::PACKAGE_PURCHASED, - ['patient_package_uuid' => 'pkg-1'], - ); - - self::assertNull($event->getPublishedAt()); - self::assertContains($event->getUuid(), array_map( - static fn (DomainEventLog $e): string => $e->getUuid(), - $this->repo()->findPending(500), - )); - - $command = static::getContainer()->get(\App\Shared\Event\Command\PublishDomainEventsCommand::class); - $tester = new \Symfony\Component\Console\Tester\CommandTester($command); - $tester->execute(['--limit' => '500']); - - $this->containerEm()->clear(); - - $reloaded = $this->repo()->findOneBy(['uuid' => $event->getUuid()]); - - self::assertNotNull($reloaded->getPublishedAt(), 'رویداد باید منتشر و علامت‌گذاری شود'); - self::assertSame(0, $reloaded->getAttempts()); - } - - /** ردیفی که سقف تلاش را رد کرده دیگر برداشته نمی‌شود، ولی حذف هم نمی‌شود. */ - public function testAnExhaustedEventIsNoLongerPickedUpButStays(): void - { - [$user, , $address] = $this->clinic(); - - $event = $this->publisher()->recordAndFlush( - 'clinic', - (int) $address->getClinicId(), - DomainEvents::CREDIT_CONSUMED, - ['patient_package_uuid' => 'pkg-2'], - ); - - for ($i = 0; $i < DomainEventLog::MAX_ATTEMPTS; $i++) { - $event->markFailed('اتصال Redis برقرار نشد'); - } - - $this->containerEm()->flush(); - - $pendingUuids = array_map( - static fn (DomainEventLog $e): string => $e->getUuid(), - $this->repo()->findPending(500), - ); - - self::assertNotContains($event->getUuid(), $pendingUuids); - self::assertNotNull($this->repo()->findOneBy(['uuid' => $event->getUuid()]), 'ردیف مرده باید بماند تا دیده شود'); - self::assertSame('اتصال Redis برقرار نشد', $event->getLastError()); - } - - // ── رویدادهای واقعی ──────────────────────────────────────────────────── - - public function testSellingAPackageRecordsItsEvent(): void - { - [$user, $section, , $patient] = $this->clinic(); - $service = $this->service($section); - - $package = $this->authJson('POST', '/api/v1/packages', $user, [ - 'name' => '۶ جلسه', - 'session_count' => 6, - 'price_rials' => 10_000_000, - 'service_uuids' => [$service->getUuid()], - ])['data']; - - $this->authJson('POST', "/api/v1/patient/{$patient->getUuid()}/package", $user, [ - 'package_uuid' => $package['uuid'], - ]); - self::assertSame(201, $this->responseCode()); - - $names = array_map( - static fn (DomainEventLog $e): string => $e->getName(), - $this->repo()->search(DomainEvents::PACKAGE_PURCHASED, null, null, 10), - ); - - self::assertContains(DomainEvents::PACKAGE_PURCHASED, $names); - } - - public function testStartingACourseRecordsItsEvent(): void - { - [$user, $section, , $patient] = $this->clinic(); - $service = $this->service($section); - - $protocol = $this->authJson('POST', '/api/v1/course-protocols', $user, [ - 'service_uuid' => $service->getUuid(), - 'session_count' => 4, - 'min_days' => 7, - 'ideal_days' => 14, - 'max_days' => 21, - ])['data']; - - $this->authJson('POST', '/api/v1/treatment-course', $user, [ - 'patient_uuid' => $patient->getUuid(), - 'protocol_uuid' => $protocol['uuid'], - ]); - self::assertSame(201, $this->responseCode()); - - $events = $this->repo()->search(DomainEvents::COURSE_STARTED, null, null, 10); - - self::assertNotEmpty($events); - self::assertArrayHasKey('course_uuid', $events[0]->getPayload()); - } - - // ── دسترسی ────────────────────────────────────────────────────────────── - - public function testOnlyAdminsCanReadTheEventLog(): void - { - [$user] = $this->clinic(); - - $this->authJson('GET', '/api/v1/domain-events', $user); - self::assertSame(403, $this->responseCode()); - - $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); - - $this->authJson('GET', '/api/v1/domain-events', $admin); - self::assertSame(200, $this->responseCode()); - } -} diff --git a/tests/Report/ReportTest.php b/tests/Report/ReportTest.php deleted file mode 100644 index f8aabf5d..00000000 --- a/tests/Report/ReportTest.php +++ /dev/null @@ -1,512 +0,0 @@ -createUser(['ROLE_USER', 'ROLE_CLINIC']); - $clinic = new Clinic($user); - $clinic->setName('کلینیک گزارش'); - $this->em->persist($clinic); - $this->em->flush(); - - $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); - $this->em->persist($section); - - $address = DoctorAddress::forClinic($clinic->getId()); - $address->setName('شعبهٔ مرکزی'); - $this->em->persist($address); - - $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); - $doctor = new Doctor($doctorUser, 'دکتر گزارش'); - $this->em->persist($doctor); - $this->em->flush(); - - return [$user, $section, $address, $doctor]; - } - - private function service(ServiceSection $section, string $name, int $solo): ServiceItem - { - $item = new ServiceItem($section, $name); - $item->setSoloDurationMinutes($solo); - $item->setPriceRials(1_000_000); - $this->em->persist($item); - $this->em->flush(); - - return $item; - } - - /** نوبت انجام‌شده با مدت پیش‌بینی و مدت واقعی مشخص. */ - private function completed( - Doctor $doctor, - User $patient, - ServiceItem $service, - int $clinicId, - int $plannedMinutes, - int $actualMinutes, - int $daysAgo, - ): Appointment { - $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); - $start = time() - $daysAgo * 86400 + (++$this->slotCursor) * 60; - - $appointment = new Appointment( - $em->getRepository(Doctor::class)->find($doctor->getId()), - $em->getRepository(User::class)->find($patient->getId()), - $start, - $start + $actualMinutes * 60, - ); - $appointment->assignTenantPair('clinic', $clinicId); - $appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId())); - $appointment->setPatientName('بیمار گزارش'); - $appointment->setServiceDuration($plannedMinutes, 0); - $appointment->transitionTo(Appointment::STATUS_CONFIRMED); - $appointment->transitionTo(Appointment::STATUS_COMPLETED); - - $em->persist($appointment); - $em->flush(); - - return $appointment; - } - - // ── دقت برنامه ────────────────────────────────────────────────────────── - - /** ⭐ سرویسی که ۶۰ دقیقه پیش‌بینی شده ولی ۹۰ دقیقه طول می‌کشد. */ - public function testAServiceThatRunsLongIsFlaggedHigh(): void - { - [$user, $section, $address, $doctor] = $this->clinic(); - $service = $this->service($section, 'لیزر فول‌بادی', 60); - $patient = $this->createUser(['ROLE_USER']); - - for ($i = 1; $i <= 10; $i++) { - $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 90, $i); - } - - $body = $this->authJson( - 'GET', - sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), - $user, - ); - - self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - - $row = $body['data']['rows'][0]; - - self::assertSame($service->getUuid(), $row['service_uuid']); - self::assertSame(60, $row['planned_minutes']); - self::assertSame(90, $row['actual_minutes']); - self::assertSame(50, $row['deviation_percent']); - self::assertSame('high', $row['severity']); - } - - /** انحراف منفی هم غلط است: ظرفیتی که می‌شد فروخت، خالی مانده. */ - public function testAServiceThatRunsShortIsAlsoFlagged(): void - { - [$user, $section, $address, $doctor] = $this->clinic(); - $service = $this->service($section, 'مشاوره', 60); - $patient = $this->createUser(['ROLE_USER']); - - for ($i = 1; $i <= 10; $i++) { - $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 30, $i); - } - - $rows = $this->authJson( - 'GET', - sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), - $user, - )['data']['rows']; - - self::assertSame(-50, $rows[0]['deviation_percent']); - self::assertSame('high', $rows[0]['severity']); - } - - /** زیر سه نمونه، میانگین معنا ندارد. */ - /** - * ⭐ زیر آستانه **حذف نمی‌شود، بی‌شدت برمی‌گردد**. - * - * میانگین دو نمونه معنا ندارد و نباید کسی رویش تصمیم بگیرد؛ ولی حذف کاملش یعنی - * کلینیک کوچک گزارشی خالی می‌بیند و فکر می‌کند همه‌چیز درست است. - */ - public function testASmallSampleIsShownWithoutASeverity(): void - { - [$user, $section, $address, $doctor] = $this->clinic(); - $service = $this->service($section, 'خدمت کم‌تکرار', 60); - $patient = $this->createUser(['ROLE_USER']); - - $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 120, 1); - $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 120, 2); - - $rows = $this->authJson( - 'GET', - sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), - $user, - )['data']['rows']; - - $mine = array_values(array_filter( - $rows, - static fn (array $r): bool => $r['service_uuid'] === $service->getUuid(), - )); - - self::assertCount(1, $mine); - self::assertNull($mine[0]['severity'], 'با دو نمونه نباید شدتی ادعا شود'); - self::assertTrue($mine[0]['below_min_sample']); - self::assertSame(2, $mine[0]['sample_size']); - } - - public function testAnAccurateServiceHasNoSeverity(): void - { - [$user, $section, $address, $doctor] = $this->clinic(); - $service = $this->service($section, 'خدمت دقیق', 60); - $patient = $this->createUser(['ROLE_USER']); - - for ($i = 1; $i <= 10; $i++) { - $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 60, $i); - } - - $rows = $this->authJson( - 'GET', - sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), - $user, - )['data']['rows']; - - $row = current(array_filter($rows, static fn (array $r): bool => $r['service_uuid'] === $service->getUuid())); - - self::assertSame(0, $row['deviation_percent']); - self::assertSame('none', $row['severity']); - } - - // ── بهره‌وری منابع ────────────────────────────────────────────────────── - - /** منبعی بدون تقویم «۰٪ بهره‌وری» ندارد — بهره‌وری‌اش تعریف‌نشده است. */ - /** - * ⭐ چهار آستانه، هر کدام روی مرز خودش. - * - * آستانه‌ای که یک درجه اشتباه بیفتد، یا همه‌چیز را قرمز می‌کند (و کسی دیگر نگاه - * نمی‌کند) یا هیچ‌چیز را (و گزارش بی‌فایده است). - * - * @param int $planned مدت برنامه - * @param int $actual مدت واقعی - */ - #[\PHPUnit\Framework\Attributes\DataProvider('severityCases')] - public function testEachSeverityThresholdIsHitExactly(int $planned, int $actual, string $expected): void - { - [$user, $section, $address, $doctor] = $this->clinic(); - $service = $this->service($section, 'خدمت آستانه', $planned); - $patient = $this->createUser(['ROLE_USER']); - - for ($i = 1; $i <= 10; $i++) { - $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), $planned, $actual, $i); - } - - $body = $this->authJson( - 'GET', - sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), - $user, - ); - - $row = $body['data']['rows'][0]; - - self::assertSame($expected, $row['severity'], sprintf( - 'انحراف %d%%', - $row['deviation_percent'], - )); - } - - /** مرزها: ۳۰ · ۱۵ · ۵ درصد، روی قدر مطلق. */ - public static function severityCases(): array - { - return [ - 'دقیقاً روی مرز high' => [100, 130, 'high'], - 'یک قدم زیر high' => [100, 129, 'medium'], - 'دقیقاً روی مرز medium' => [100, 115, 'medium'], - 'یک قدم زیر medium' => [100, 114, 'low'], - 'دقیقاً روی مرز low' => [100, 105, 'low'], - 'یک قدم زیر low' => [100, 104, 'none'], - 'کوتاه‌تر هم شمرده می‌شود' => [100, 70, 'high'], - ]; - } - - public function testAResourceWithoutACalendarHasNullUtilization(): void - { - [$user, , $address] = $this->clinic(); - - $type = $this->authJson('POST', '/api/v1/resource-types', $user, [ - 'address_uuid' => $address->getUuid(), - 'code' => 'device', - 'name' => 'دستگاه', - ]); - self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE)); - - $this->authJson('POST', '/api/v1/resource', $user, [ - 'address_uuid' => $address->getUuid(), - 'type_uuid' => $type['data']['uuid'], - 'name' => 'لیزر ۱', - ]); - self::assertSame(201, $this->responseCode()); - - $body = $this->authJson( - 'GET', - sprintf( - '/api/v1/reports/resource-utilization?branch_uuid=%s&from=%d&to=%d', - $address->getUuid(), - time() - 7 * 86400, - time(), - ), - $user, - ); - - self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - - $row = $body['data']['rows'][0]; - - self::assertSame('لیزر ۱', $row['resource_name']); - self::assertSame(0, $row['available_minutes']); - self::assertNull($row['utilization'], 'تقسیم بر صفر معنای متفاوتی دارد'); - self::assertNull($row['active_ratio']); - self::assertFalse($row['wasted_capacity']); - } - - /** - * ⭐ سنجه‌های واقعی: اشغال شامل انتظار است، «کار مفید» نه. - * - * فاصلهٔ این دو همان چیزی است که تعریف غلط بخش‌ها را لو می‌دهد؛ اگر هر دو یکی - * برگردند، گزارش بی‌فایده است و کسی متوجه نمی‌شود. - */ - public function testOccupiedIncludesTheWaitingSegmentButActiveDoesNot(): void - { - [$user, , $address] = $this->clinic(); - - $type = $this->authJson('POST', '/api/v1/resource-types', $user, [ - 'address_uuid' => $address->getUuid(), - 'code' => 'room', - 'name' => 'اتاق', - ]); - self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE)); - - $created = $this->authJson('POST', '/api/v1/resource', $user, [ - 'address_uuid' => $address->getUuid(), - 'type_uuid' => $type['data']['uuid'], - 'name' => 'اتاق ۱', - ]); - self::assertSame(201, $this->responseCode()); - - $resource = $this->em->getRepository(\App\Resource\Entity\ClinicResource::class) - ->findOneBy(['uuid' => $created['data']['uuid']]); - - $from = time() - 2 * 86400; - $start = $from + 3600; - - $appointment = $this->bookedAppointment($address, $start, 60); - - // یک ساعت اشغال؛ ولی بیمار فقط ۲۰ دقیقهٔ اولش حاضر است. - $this->occupy($resource, $appointment, $start, $start + 3600); - $this->segment($appointment, 1, 'ویزیت', $start, $start + 1200, true); - $this->segment($appointment, 2, 'انتظار', $start + 1200, $start + 3600, false); - - $body = $this->authJson( - 'GET', - sprintf( - '/api/v1/reports/resource-utilization?branch_uuid=%s&from=%d&to=%d', - $address->getUuid(), - $from, - time(), - ), - $user, - ); - - self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - - $row = $body['data']['rows'][0]; - - self::assertSame(60, $row['occupied_minutes'], 'انتظار هم اشغال است'); - self::assertSame(20, $row['active_minutes'], 'ولی کار مفید نیست'); - } - - private function bookedAppointment(\App\Doctor\Entity\DoctorAddress $address, int $start, int $minutes): \App\Appointment\Entity\Appointment - { - $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); - $doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر گزارش'); - $this->em->persist($doctor); - - $appointment = new \App\Appointment\Entity\Appointment( - $doctor, - $this->createUser(['ROLE_USER']), - $start, - $start + $minutes * 60, - ); - $appointment->assignTenantPair('clinic', (int) $address->getClinicId()); - $appointment->setAddressId($address->getId()); - $appointment->setPatientName('بیمار گزارش'); - $appointment->transitionTo(\App\Appointment\Entity\Appointment::STATUS_CONFIRMED); - - $this->em->persist($appointment); - $this->em->flush(); - - return $appointment; - } - - private function occupy( - \App\Resource\Entity\ClinicResource $resource, - \App\Appointment\Entity\Appointment $appointment, - int $from, - int $to, - ): void { - $row = new \App\Appointment\Availability\Entity\ResourceOccupancy( - $resource, - $from, - $to, - \App\Appointment\Availability\Entity\ResourceOccupancy::STATUS_BOOKED, - ); - $row->setAppointmentId($appointment->getId()); - - $this->em->persist($row); - $this->em->flush(); - } - - private function segment( - \App\Appointment\Entity\Appointment $appointment, - int $sequence, - string $name, - int $from, - int $to, - bool $present, - ): void { - $this->em->persist(new \App\Appointment\Booking\Entity\AppointmentSegment( - $appointment, - $sequence, - $name, - $from, - $to, - $present, - )); - $this->em->flush(); - } - - /** - * ⭐ اشغال و کار مفید هرکدام **یک** کوئری‌اند، مستقل از تعداد منبع. - * - * پیمایش per منبع روی کلینیکی با ۴۰ منبع یعنی ۸۰ کوئری برای یک گزارش. تعداد - * دقیقش مهم نیست؛ چیزی که این تست نگه می‌دارد این است که با سه برابر شدن منابع، - * تعداد کوئری‌ها سه برابر **نشود**. - */ - public function testQueryCountDoesNotGrowWithTheNumberOfResources(): void - { - [$user, , $address] = $this->clinic(); - - $type = $this->authJson('POST', '/api/v1/resource-types', $user, [ - 'address_uuid' => $address->getUuid(), - 'code' => 'room', - 'name' => 'اتاق', - ]); - self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE)); - - $reporter = static::getContainer()->get(\App\Report\Service\ResourceUtilizationReporter::class); - - $count = function (int $resources) use ($user, $address, $type, $reporter): int { - for ($i = 0; $i < $resources; $i++) { - $this->authJson('POST', '/api/v1/resource', $user, [ - 'address_uuid' => $address->getUuid(), - 'type_uuid' => $type['data']['uuid'], - 'name' => sprintf('اتاق %d', $i + 1), - ]); - self::assertSame(201, $this->responseCode()); - } - - $all = $this->em->getRepository(\App\Resource\Entity\ClinicResource::class) - ->findBy(['address' => $address]); - - $connection = $this->em->getConnection(); - $before = $this->queryCount($connection); - - $reporter->report($all, $address, time() - 7 * 86400, time()); - - return $this->queryCount($connection) - $before; - }; - - $withOne = $count(1); - $withMany = $count(5); - - self::assertLessThan( - $withOne * 3, - $withMany, - sprintf('یک منبع %d کوئری، شش منبع %d کوئری — رشد خطی است', $withOne, $withMany), - ); - } - - /** شمار کوئری از خودِ سرور — `SHOW SESSION STATUS` روی همان اتصال. */ - private function queryCount(\Doctrine\DBAL\Connection $connection): int - { - return (int) ($connection->fetchAssociative("SHOW SESSION STATUS LIKE 'Questions'")['Value'] ?? 0); - } - - // ── محدودیت بازه و دسترسی ─────────────────────────────────────────────── - - public function testARangeLongerThanNinetyDaysIsRejected(): void - { - [$user] = $this->clinic(); - - $this->authJson( - 'GET', - sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 200 * 86400, time()), - $user, - ); - - self::assertSame(422, $this->responseCode()); - } - - public function testAnInvertedRangeIsRejected(): void - { - [$user] = $this->clinic(); - - $this->authJson( - 'GET', - sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time(), time() - 86400), - $user, - ); - - self::assertSame(422, $this->responseCode()); - } - - public function testAnotherClinicSeesItsOwnNumbersOnly(): void - { - [$owner, $section, $address, $doctor] = $this->clinic(); - [$other] = $this->clinic(); - - $service = $this->service($section, 'لیزر', 60); - $patient = $this->createUser(['ROLE_USER']); - - for ($i = 1; $i <= 10; $i++) { - $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 90, $i); - } - - $rows = $this->authJson( - 'GET', - sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), - $other, - )['data']['rows']; - - self::assertSame([], array_values(array_filter( - $rows, - static fn (array $r): bool => $r['service_uuid'] === $service->getUuid(), - ))); - } -} diff --git a/tests/Waitlist/WaitlistTest.php b/tests/Waitlist/WaitlistTest.php deleted file mode 100644 index 659ecc42..00000000 --- a/tests/Waitlist/WaitlistTest.php +++ /dev/null @@ -1,463 +0,0 @@ -createUser(['ROLE_USER', 'ROLE_CLINIC']); - $clinic = new Clinic($user); - $clinic->setName('کلینیک انتظار'); - $this->em->persist($clinic); - $this->em->flush(); - - $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); - $this->em->persist($section); - - $address = DoctorAddress::forClinic($clinic->getId()); - $address->setName('شعبهٔ مرکزی'); - $this->em->persist($address); - - $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); - $doctor = new Doctor($doctorUser, 'دکتر انتظار'); - $this->em->persist($doctor); - - $patientUser = $this->createUser(['ROLE_USER']); - $patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId()); - $this->em->persist($patient); - $this->em->flush(); - - return [$user, $section, $address, $doctor, $patient]; - } - - private function extraPatient(int $clinicId): PatientRecord - { - $user = $this->createUser(['ROLE_USER']); - $patient = new PatientRecord('clinic', $clinicId, $user, 'clinic', $clinicId); - $this->em->persist($patient); - $this->em->flush(); - - return $patient; - } - - private function service(ServiceSection $section, string $name = 'لیزر'): ServiceItem - { - $item = new ServiceItem($section, $name); - $item->setSoloDurationMinutes(30); - $item->setPriceRials(4_000_000); - $this->em->persist($item); - $this->em->flush(); - - return $item; - } - - /** @param array $extra */ - private function join(User $user, PatientRecord $patient, ServiceItem $service, int $from, int $to, array $extra = []): array - { - $body = $this->authJson('POST', '/api/v1/waitlist', $user, $extra + [ - 'patient_uuid' => $patient->getUuid(), - 'service_uuid' => $service->getUuid(), - 'desired_from' => $from, - 'desired_to' => $to, - ]); - - self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - - return $body['data']; - } - - private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, DoctorAddress $address, int $start): Appointment - { - $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); - - $start += (++$this->slotCursor) * 60; - - $appointment = new Appointment( - $em->getRepository(Doctor::class)->find($doctor->getId()), - $em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(), - $start, - $start + 1800, - ); - $appointment->assignTenantPair('clinic', (int) $address->getClinicId()); - $appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId())); - $appointment->setAddressId($address->getId()); - $appointment->setPatientName('بیمار نوبت'); - $appointment->transitionTo(Appointment::STATUS_CONFIRMED); - - $em->persist($appointment); - $em->flush(); - - return $appointment; - } - - private function notifier(): WaitlistNotifier - { - return static::getContainer()->get(WaitlistNotifier::class); - } - - // ── ثبت ───────────────────────────────────────────────────────────────── - - public function testJoiningTheWaitlistStoresTheWindow(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $from = time() + 86400; - $to = $from + 3 * 86400; - $entry = $this->join($user, $patient, $service, $from, $to, ['preferred_day_parts' => ['evening']]); - - self::assertSame('waiting', $entry['status']); - self::assertSame($from, $entry['desired_from']); - self::assertSame(['evening'], $entry['preferred_day_parts']); - self::assertSame(0, $entry['notify_count']); - - $list = $this->authJson('GET', '/api/v1/waitlist', $user); - self::assertCount(1, $list['data']); - } - - /** انتظار برای بازهٔ گذشته هرگز به نتیجه نمی‌رسد. */ - public function testAPastWindowIsRejected(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $this->authJson('POST', '/api/v1/waitlist', $user, [ - 'patient_uuid' => $patient->getUuid(), - 'service_uuid' => $service->getUuid(), - 'desired_from' => time() - 5 * 86400, - 'desired_to' => time() - 86400, - ]); - - self::assertSame(422, $this->responseCode()); - } - - public function testAnInvertedWindowIsRejected(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $this->authJson('POST', '/api/v1/waitlist', $user, [ - 'patient_uuid' => $patient->getUuid(), - 'service_uuid' => $service->getUuid(), - 'desired_from' => time() + 5 * 86400, - 'desired_to' => time() + 86400, - ]); - - self::assertSame(422, $this->responseCode()); - } - - // ── تطبیق و اطلاع ─────────────────────────────────────────────────────── - - public function testMatchesFindsEveryoneWaitingForThatMoment(): void - { - [$user, $section, $address, , $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $from = time() + 86400; - $to = $from + 3 * 86400; - - $this->join($user, $patient, $service, $from, $to); - $this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $from, $to); - - // کسی که بازه‌اش پوشش نمی‌دهد نباید بیاید. - $this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $to + 86400, $to + 5 * 86400); - - $start = $from + 3600; - $matches = $this->authJson( - 'GET', - sprintf('/api/v1/waitlist/matches?service_uuid=%s&start=%d', $service->getUuid(), $start), - $user, - ); - - self::assertSame(200, $this->responseCode(), json_encode($matches, JSON_UNESCAPED_UNICODE)); - self::assertCount(2, $matches['data']); - } - - /** ⭐ همه خبر می‌شوند — نه فقط نفر اول. */ - public function testCancellingAnAppointmentNotifiesEveryMatchingEntry(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $slot = time() + 2 * 86400; - $from = $slot - 86400; - $to = $slot + 86400; - - $first = $this->join($user, $patient, $service, $from, $to); - $second = $this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $from, $to); - - $appointment = $this->appointment($doctor, $patient, $service, $address, $slot); - - $body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); - - self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - self::assertSame(2, $body['data']['waitlist_notified']); - - $repo = static::getContainer()->get(WaitlistEntryRepository::class); - - foreach ([$first['uuid'], $second['uuid']] as $uuid) { - $entry = $repo->findByUuid($uuid); - self::assertSame(WaitlistEntry::STATUS_NOTIFIED, $entry->getStatus()); - self::assertNotNull($entry->getNotifiedAt()); - self::assertSame(1, $entry->getNotifyCount()); - } - } - - /** سقف اطلاع‌رسانی، یک بازهٔ پرلغو را به منبع اسپم تبدیل نمی‌کند. */ - public function testNotificationsStopAtTheCap(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $slot = time() + 2 * 86400; - $entry = $this->join($user, $patient, $service, $slot - 86400, $slot + 86400); - - $repo = static::getContainer()->get(WaitlistEntryRepository::class); - - for ($i = 0; $i < WaitlistEntry::MAX_NOTIFICATIONS + 2; $i++) { - $appointment = $this->appointment($doctor, $patient, $service, $address, $slot); - $this->notifier()->notifyForFreedSlot($appointment); - } - - self::assertSame( - WaitlistEntry::MAX_NOTIFICATIONS, - $repo->findByUuid($entry['uuid'])->getNotifyCount(), - ); - } - - /** درخواستی که شعبهٔ دیگری را خواسته، برای این ظرفیت خبر نمی‌شود. */ - public function testAnEntryForAnotherBranchIsNotNotified(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $otherBranch = DoctorAddress::forClinic((int) $address->getClinicId()); - $otherBranch->setName('شعبهٔ دوم'); - $this->em->persist($otherBranch); - $this->em->flush(); - - $slot = time() + 2 * 86400; - - $this->join($user, $patient, $service, $slot - 86400, $slot + 86400, [ - 'branch_uuid' => $otherBranch->getUuid(), - ]); - - $appointment = $this->appointment($doctor, $patient, $service, $address, $slot); - - self::assertSame(0, $this->notifier()->notifyForFreedSlot($appointment)); - } - - public function testDeletingAnEntryRemovesItFromTheList(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $entry = $this->join($user, $patient, $service, time() + 86400, time() + 4 * 86400); - - $this->authJson('DELETE', "/api/v1/waitlist/{$entry['uuid']}", $user); - self::assertSame(200, $this->responseCode()); - - self::assertCount(0, $this->authJson('GET', '/api/v1/waitlist', $user)['data']); - } - - public function testAnotherClinicCannotSeeOrDeleteTheEntry(): void - { - [$owner, $section, , , $patient] = $this->clinicWithPatient(); - [$other] = $this->clinicWithPatient(); - - $service = $this->service($section); - $entry = $this->join($owner, $patient, $service, time() + 86400, time() + 4 * 86400); - - self::assertCount(0, $this->authJson('GET', '/api/v1/waitlist', $other)['data']); - - $this->authJson('DELETE', "/api/v1/waitlist/{$entry['uuid']}", $other); - self::assertSame(404, $this->responseCode()); - } - - // ── بخش روز ───────────────────────────────────────────────────────────── - - /** - * ⭐ ترجیح روز باید در **تطبیق** اعمال شود، نه فقط ذخیره. - * - * ذخیره‌کردنِ «عصر» و بعد خبر دادن برای ساعت ۹ صبح، بدتر از نپرسیدن است: بیمار - * فکر می‌کند سیستم حرفش را شنیده. - */ - public function testAnEntryIsNotNotifiedOutsideItsPreferredDayPart(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $morning = $this->localHour(9); - - $this->join($user, $patient, $service, $morning - 86400, $morning + 86400, [ - 'preferred_day_parts' => ['evening'], - ]); - - $appointment = $this->appointment($doctor, $patient, $service, $address, $morning); - - self::assertSame(0, $this->notifier()->notifyForFreedSlot($appointment)); - } - - public function testAnEntryIsNotifiedInsideItsPreferredDayPart(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $evening = $this->localHour(19); - - $this->join($user, $patient, $service, $evening - 86400, $evening + 86400, [ - 'preferred_day_parts' => ['evening'], - ]); - - $appointment = $this->appointment($doctor, $patient, $service, $address, $evening); - - self::assertSame(1, $this->notifier()->notifyForFreedSlot($appointment)); - } - - /** نداشتن ترجیح یعنی «هر ساعتی» — نه «هیچ ساعتی». */ - public function testAnEntryWithoutAPreferenceMatchesAnyHour(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $dawn = $this->localHour(5); - - $this->join($user, $patient, $service, $dawn - 86400, $dawn + 86400); - - $appointment = $this->appointment($doctor, $patient, $service, $address, $dawn); - - self::assertSame(1, $this->notifier()->notifyForFreedSlot($appointment)); - } - - public function testAnUnknownDayPartIsRejected(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $this->authJson('POST', '/api/v1/waitlist', $user, [ - 'patient_uuid' => $patient->getUuid(), - 'service_uuid' => $service->getUuid(), - 'desired_from' => time() + 86400, - 'desired_to' => time() + 3 * 86400, - 'preferred_day_parts' => ['midnight'], - ]); - - self::assertSame(422, $this->responseCode()); - } - - // ── مرزها و چرخهٔ عمر ─────────────────────────────────────────────────── - - public function testARangeLongerThanNinetyDaysIsRejected(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $from = time() + 86400; - - $this->authJson('POST', '/api/v1/waitlist', $user, [ - 'patient_uuid' => $patient->getUuid(), - 'service_uuid' => $service->getUuid(), - 'desired_from' => $from, - 'desired_to' => $from + 91 * 86400, - ]); - - self::assertSame(422, $this->responseCode()); - } - - /** - * ⭐ ردیفِ منقضی از قبل هم در تطبیق نمی‌آمد؛ این پاکسازیِ **نمایش** است تا اپراتور - * بفهمد کدام انتظار هنوز زنده است. - */ - public function testExpiringClosesPassedEntriesAndLeavesLiveOnes(): void - { - [$user, $section, , , $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - - $live = $this->join($user, $patient, $service, time() + 86400, time() + 5 * 86400); - $dead = $this->join($user, $this->extraPatient((int) $patient->getEntityId()), $service, time() + 86400, time() + 2 * 86400); - - // بازهٔ ردیف دوم را به گذشته می‌بریم — API عمداً بازهٔ گذشته را نمی‌پذیرد. - $this->em->getConnection()->executeStatement( - 'UPDATE waitlist_entries SET desired_from = ?, desired_to = ? WHERE uuid = ?', - [time() - 10 * 86400, time() - 86400, $dead['uuid']], - ); - - $expired = static::getContainer()->get(\App\Waitlist\Service\WaitlistExpirer::class)->expire(); - self::assertSame(1, $expired); - - // خواندن مستقیم از دیتابیس: `expire()` با SQL خام می‌نویسد، پس هر نقشهٔ هویتِ - // باز، نسخهٔ کهنه را برمی‌گرداند. - self::assertSame(WaitlistEntry::STATUS_EXPIRED, $this->statusOf($dead['uuid'])); - self::assertSame(WaitlistEntry::STATUS_WAITING, $this->statusOf($live['uuid'])); - } - - /** - * ⭐ تبدیل باید **تنگ** باشد: ردیفِ خدمت دیگر نباید بسته شود، وگرنه بیمار برای - * چیزی که هنوز منتظرش است دیگر هرگز خبر نمی‌شود. - */ - public function testBookingConvertsOnlyTheMatchingEntry(): void - { - [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); - $service = $this->service($section); - $other = $this->service($section, 'بوتاکس'); - - $start = time() + 2 * 86400; - - $mine = $this->join($user, $patient, $service, $start - 86400, $start + 86400); - $unrelated = $this->join($user, $patient, $other, $start - 86400, $start + 86400); - - $appointment = $this->appointment($doctor, $patient, $service, $address, $start); - - $converter = static::getContainer()->get(\App\Waitlist\Service\WaitlistConverter::class); - self::assertSame(1, $converter->convertFor($appointment)); - - // اجرای دوباره چیزی را دوباره نمی‌بندد — تحویل دوبارهٔ پیام بی‌خطر است. - self::assertSame(0, $converter->convertFor($appointment)); - - $this->em->clear(); - $repo = static::getContainer()->get(WaitlistEntryRepository::class); - - self::assertSame(WaitlistEntry::STATUS_CONVERTED, $repo->findByUuid($mine['uuid'])->getStatus()); - self::assertSame(WaitlistEntry::STATUS_WAITING, $repo->findByUuid($unrelated['uuid'])->getStatus()); - } - - private function statusOf(string $uuid): string - { - return (string) $this->em->getConnection()->fetchOne( - 'SELECT status FROM waitlist_entries WHERE uuid = ?', - [$uuid], - ); - } - - /** ساعت محلیِ شعبه روی فردا — تست نباید به ساعت اجرا وابسته باشد. */ - private function localHour(int $hour): int - { - return (new \DateTimeImmutable('tomorrow', new \DateTimeZone(DoctorAddress::DEFAULT_TIMEZONE))) - ->setTime($hour, 0) - ->getTimestamp(); - } -}