feat(treatment): select treatment behaviour by practice domain, not by if-branch
Everything that differs between specialties as data is already stored as data. What is left is behaviour — when a case opens, what happens once a session ends — so it becomes a TreatmentWorkflow resolved through a tagged-service registry. The booking path calls one collaborator and never names a specialty; adding dentistry is a new class, not an edit to confirmation. A clinic that has chosen no practice domain still gets working multi-session courses: DefaultTreatmentWorkflow answers for null and for any code without a dedicated implementation, keeping "unset means behave as today, not error". LaserTreatmentWorkflow is deliberately empty beyond claiming `beauty` — it is the seam where laser-specific behaviour will land without disturbing anyone else. Session due dates are anchored to the previous session's actual finish, so a patient who comes twenty days late shifts the rest of their course instead of getting the next session while it can still do nothing. Only the next session is recomputed; later ones keep their estimate because they are anchored to nothing yet. Attachment targets the first session without an appointment rather than the first open one: a patient booking again mid-course was otherwise matched to the session that already had a booking, and the second appointment went nowhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,12 +31,18 @@ be able to switch them back on.
|
||||
"code": "beauty",
|
||||
"name": "کلینیک زیبایی",
|
||||
"sort_order": 0,
|
||||
"active": true
|
||||
"active": true,
|
||||
"has_workflow": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`has_workflow` میگوید آیا پیادهسازیِ `TreatmentWorkflow` مخصوصِ این کد ثبت شده یا حوزه
|
||||
فقط رفتار پیشفرض میگیرد. حوزهٔ بیworkflow کار میکند — دورههای چندجلسهایاش باز
|
||||
میشوند — ولی رفتار اختصاصی ندارد، و پنل ادمین پلتفرم باید همین را نشان دهد نه اینکه
|
||||
مدیر حدس بزند.
|
||||
|
||||
**Errors:**
|
||||
| Code | HTTP | توضیح |
|
||||
|------|------|-------|
|
||||
|
||||
@@ -130,6 +130,49 @@ Real 422 responses:
|
||||
|
||||
---
|
||||
|
||||
## Opening a case — what happens on confirm
|
||||
|
||||
There is no endpoint that opens a treatment case; it happens as a side effect of confirming an
|
||||
appointment, in `AppointmentConfirmationService::onConfirmed`:
|
||||
|
||||
```
|
||||
نوبت تأیید شد
|
||||
→ PatientSession ساخته میشود (مالی، مثل همیشه)
|
||||
→ اگر سرویسِ نوبت پروتکل فعال دارد:
|
||||
TreatmentWorkflowRegistry::for(clinic.practice_domain.code)->openCase(...)
|
||||
```
|
||||
|
||||
The booking core never names a specialty. A `TreatmentWorkflow` is selected by the clinic's practice
|
||||
domain code through a tagged-service registry, so adding dentistry is a new class rather than a
|
||||
change in the booking path. `LaserTreatmentWorkflow` handles `beauty`;
|
||||
`DefaultTreatmentWorkflow` answers for everything else, including a clinic that has chosen no domain
|
||||
at all — `null` means "behave as today", never an error.
|
||||
|
||||
What opening a case does:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| پروندهٔ باز موجود | برگردانده میشود؛ پروندهٔ دوم برای همان بیمار و همان سرویس ساخته نمیشود |
|
||||
| نواحی | برگهای دستهٔ سرویس، با نامشان، در همان لحظه کپی میشوند |
|
||||
| جلسات | همهٔ گامهای پروتکل ساخته میشوند، همه `planned` |
|
||||
| نوبت | به **اولین جلسهٔ بدون نوبت** میچسبد و آن جلسه `booked` میشود |
|
||||
| سررسید | جلسهٔ رزروشده ساعت نوبت را میگیرد؛ بقیه `null` میمانند |
|
||||
|
||||
Failure to open a case is logged and swallowed — the appointment is booked and possibly paid for, and
|
||||
losing that is worse than losing the case file, which can be rebuilt.
|
||||
|
||||
### Session due dates
|
||||
|
||||
`due_at` of session *n* is `finished_at` of session *n−1* plus that step's `offset_days`. Only the
|
||||
**next** session is recomputed when one finishes; sessions further out keep their earlier estimate,
|
||||
because a number that is not yet anchored to anything real does not get more accurate by being
|
||||
recalculated.
|
||||
|
||||
A no-show does not burn the session: its status becomes `no_show`, its appointment link is cleared,
|
||||
`total_sessions` is untouched, and the same session comes back to the front of the booking queue.
|
||||
|
||||
---
|
||||
|
||||
## DELETE `/api/v1/service-item/{uuid}/treatment-protocol`
|
||||
|
||||
Turn the switch off — the protocol, its steps and its staff list are removed and the service is
|
||||
|
||||
@@ -30,6 +30,7 @@ class AppointmentConfirmationService
|
||||
private readonly PaymentRepository $paymentRepo,
|
||||
private readonly CommissionService $commissionService,
|
||||
private readonly DomainContextResolver $domainResolver,
|
||||
private readonly \App\Treatment\Service\TreatmentCaseStarter $treatmentCases,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
@@ -72,7 +73,15 @@ class AppointmentConfirmationService
|
||||
$this->splitPaymentShares($appointment);
|
||||
|
||||
try {
|
||||
return $this->patientService->autoCreateOnAppointmentConfirm($appointment);
|
||||
$session = $this->patientService->autoCreateOnAppointmentConfirm($appointment);
|
||||
|
||||
// هسته نمیداند دورهٔ درمان چیست: اگر سرویسِ نوبت پروتکل داشته باشد،
|
||||
// workflowِ حوزهٔ فعالیت تصمیم میگیرد چه اتفاقی بیفتد.
|
||||
if ($session !== null) {
|
||||
$this->treatmentCases->onAppointmentConfirmed($appointment, $session);
|
||||
}
|
||||
|
||||
return $session;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Auto-creating the patient record on confirm failed', [
|
||||
'appointment_uuid' => $appointment->getUuid(),
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\PracticeDomain\Controller;
|
||||
use App\PracticeDomain\Entity\PracticeDomain;
|
||||
use App\PracticeDomain\Repository\PracticeDomainRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Treatment\Workflow\TreatmentWorkflowRegistry;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -19,6 +20,7 @@ class PracticeDomainController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PracticeDomainRepository $domains,
|
||||
private readonly TreatmentWorkflowRegistry $workflows,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
@@ -34,7 +36,7 @@ class PracticeDomainController extends BaseController
|
||||
$includeInactive = $this->isGranted('ROLE_ADMIN');
|
||||
|
||||
return $this->success(array_map(
|
||||
static fn (PracticeDomain $d): array => $d->toArray(),
|
||||
fn (PracticeDomain $d): array => $d->toArray($this->workflows->hasDedicatedWorkflow($d->getCode())),
|
||||
$this->domains->findOrdered($includeInactive),
|
||||
));
|
||||
}
|
||||
@@ -73,7 +75,7 @@ class PracticeDomainController extends BaseController
|
||||
$this->em->persist($domain);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success($domain->toArray(), 201);
|
||||
return $this->success($domain->toArray($this->workflows->hasDedicatedWorkflow($domain->getCode())), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/practice-domain/{uuid}', name: 'practice_domain_update', methods: ['PATCH'])]
|
||||
@@ -108,6 +110,6 @@ class PracticeDomainController extends BaseController
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success($domain->toArray());
|
||||
return $this->success($domain->toArray($this->workflows->hasDedicatedWorkflow($domain->getCode())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,27 @@ class TreatmentSessionRepository extends ServiceEntityRepository
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* اولین جلسهای که هنوز نوبتی ندارد.
|
||||
*
|
||||
* جدا از {@see self::findNextOpen()} است چون آن جلسهٔ رزروشده را هم «باز» میداند:
|
||||
* بیماری که وسط دوره نوبت دوم میگیرد باید به جلسهٔ **بعدی** بچسبد، نه به جلسهای
|
||||
* که همین حالا نوبت دارد.
|
||||
*/
|
||||
public function findNextUnbooked(TreatmentCase $case): ?TreatmentSession
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
->where('s.treatmentCase = :case')
|
||||
->andWhere('s.appointment IS NULL')
|
||||
->andWhere('s.status IN (:open)')
|
||||
->setParameter('case', $case)
|
||||
->setParameter('open', [TreatmentSession::STATUS_PLANNED, TreatmentSession::STATUS_NO_SHOW])
|
||||
->orderBy('s.sessionNumber', 'ASC')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/** جلسهٔ انجامشدهٔ قبلی — لنگرِ محاسبهٔ سررسید جلسهٔ بعد. */
|
||||
public function findLastFinishedBefore(TreatmentCase $case, int $sessionNumber): ?TreatmentSession
|
||||
{
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Treatment\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Treatment\Entity\TreatmentCase;
|
||||
use App\Treatment\Repository\TreatmentProtocolRepository;
|
||||
use App\Treatment\Workflow\TreatmentWorkflowRegistry;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* پلِ بین «نوبت تأیید شد» و «دورهٔ درمان».
|
||||
*
|
||||
* تنها چیزی که مسیر رزرو از دنیای درمان میبیند همین کلاس است: سرویس پروتکل دارد یا
|
||||
* نه، و اگر دارد کارِ باز کردن پرونده به workflow همان حوزهٔ فعالیت سپرده میشود. هستهٔ
|
||||
* رزرو نه نام لیزر را میداند نه ساختار پرونده را.
|
||||
*/
|
||||
final class TreatmentCaseStarter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TreatmentProtocolRepository $protocols,
|
||||
private readonly TreatmentWorkflowRegistry $workflows,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* `null` یعنی این نوبت دورهای نیست — سرویس ندارد، یا سرویسش پروتکل فعال ندارد.
|
||||
*/
|
||||
public function onAppointmentConfirmed(Appointment $appointment, PatientSession $session): ?TreatmentCase
|
||||
{
|
||||
$service = $appointment->getServiceItem();
|
||||
|
||||
if ($service === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$protocol = $this->protocols->findActiveForService($service);
|
||||
|
||||
if ($protocol === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$domainCode = $appointment->getClinic()?->getPracticeDomain()?->getCode();
|
||||
|
||||
try {
|
||||
return $this->workflows->for($domainCode)->openCase(
|
||||
$appointment,
|
||||
$session->getRecord(),
|
||||
$service,
|
||||
$protocol,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// همان قاعدهٔ AppointmentConfirmationService: نوبت رزرو شده و پول پرداخت
|
||||
// شده؛ شکستِ ساخت پرونده نباید آن را برگرداند.
|
||||
$this->logger->error('Opening the treatment case on confirm failed', [
|
||||
'appointment_uuid' => $appointment->getUuid(),
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Treatment\Service;
|
||||
|
||||
use App\Treatment\Entity\TreatmentCase;
|
||||
use App\Treatment\Entity\TreatmentProtocolStep;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
use App\Treatment\Repository\TreatmentSessionRepository;
|
||||
|
||||
/**
|
||||
* سررسید جلسهٔ بعد.
|
||||
*
|
||||
* فاصله از تاریخ **واقعی** جلسهٔ قبل حساب میشود، نه از شروع دوره: مو بعد از درمان
|
||||
* قبلی رشد میکند. با لنگرِ ثابت، بیمارِ بیستروز دیرآمده جلسهٔ بعدیاش را ده روز بعد
|
||||
* میگرفت و آن جلسه بیاثر بود.
|
||||
*
|
||||
* فقط جلسهٔ **بعدی** بازمحاسبه میشود. جلسات دورتر حدسِ قبلیشان را نگه میدارند تا
|
||||
* نوبتشان برسد — عددی که هنوز به هیچ واقعیتی گره نخورده، بازمحاسبهاش دقیقترش نمیکند.
|
||||
*/
|
||||
final class TreatmentScheduler
|
||||
{
|
||||
private const DAY = 86400;
|
||||
|
||||
public function __construct(private readonly TreatmentSessionRepository $sessions) {}
|
||||
|
||||
/**
|
||||
* سررسید جلسهای که بعد از این جلسه میآید را بهروز میکند.
|
||||
*
|
||||
* @return TreatmentSession|null جلسهای که سررسیدش عوض شد، یا null اگر دوره تمام شده
|
||||
*/
|
||||
public function scheduleNextAfter(TreatmentSession $finished): ?TreatmentSession
|
||||
{
|
||||
$anchor = $finished->getFinishedAt();
|
||||
|
||||
if ($anchor === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$next = $this->sessions->findNextOpen($finished->getTreatmentCase());
|
||||
|
||||
if ($next === null || $next->getSessionNumber() <= $finished->getSessionNumber()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$offset = $this->offsetFor($finished->getTreatmentCase(), $next->getSessionNumber());
|
||||
|
||||
if ($offset === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$next->setDueAt($anchor + $offset * self::DAY);
|
||||
|
||||
return $next;
|
||||
}
|
||||
|
||||
/**
|
||||
* سررسید جلسهای که هنوز لنگری ندارد.
|
||||
*
|
||||
* برای جلسهٔ اول لنگر خودِ نوبت است؛ برای بقیه تا انجامشدن جلسهٔ قبل حدس میماند.
|
||||
*/
|
||||
public function seedFirstSession(TreatmentSession $first, int $startsAt): void
|
||||
{
|
||||
$first->setDueAt($startsAt);
|
||||
}
|
||||
|
||||
/** فاصلهٔ گامِ شمارهٔ n از جلسهٔ قبلی، طبق پروتکلِ همان پرونده. */
|
||||
private function offsetFor(TreatmentCase $case, int $sessionNumber): ?int
|
||||
{
|
||||
foreach ($case->getProtocol()->getSteps() as $step) {
|
||||
/** @var TreatmentProtocolStep $step */
|
||||
if ($step->getStepNumber() === $sessionNumber) {
|
||||
return $step->getOffsetDays();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Treatment\Workflow;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Treatment\Entity\TreatmentCase;
|
||||
use App\Treatment\Entity\TreatmentProtocol;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
use App\Treatment\Repository\TreatmentCaseRepository;
|
||||
use App\Treatment\Repository\TreatmentSessionRepository;
|
||||
use App\Treatment\Service\TreatmentCaseOpener;
|
||||
use App\Treatment\Service\TreatmentScheduler;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* رفتار دورهٔ درمان وقتی حوزهٔ فعالیت پیادهسازی اختصاصی ندارد.
|
||||
*
|
||||
* عمداً پایینترین اولویت را دارد و به هر کدی جواب مثبت میدهد، پس محیطی که هنوز
|
||||
* حوزهاش را انتخاب نکرده هم دورهٔ چندجلسهایاش کار میکند — تصمیمِ «نال یعنی رفتار
|
||||
* امروز، نه خطا».
|
||||
*/
|
||||
class DefaultTreatmentWorkflow implements TreatmentWorkflow
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly TreatmentCaseRepository $cases,
|
||||
protected readonly TreatmentSessionRepository $sessions,
|
||||
protected readonly TreatmentCaseOpener $opener,
|
||||
protected readonly TreatmentScheduler $scheduler,
|
||||
protected readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function supports(?string $practiceDomainCode): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function openCase(
|
||||
Appointment $appointment,
|
||||
PatientRecord $record,
|
||||
ServiceItem $service,
|
||||
TreatmentProtocol $protocol,
|
||||
): TreatmentCase {
|
||||
// بیماری که وسط دورهاش نوبت دیگری از همان سرویس میگیرد، جلسهٔ همان دوره را
|
||||
// میگیرد نه یک دورهٔ موازی.
|
||||
$case = $this->cases->findOpenFor($record, $service)
|
||||
?? $this->opener->open(
|
||||
$record->getEntityType(),
|
||||
$record->getEntityId(),
|
||||
$record,
|
||||
$service,
|
||||
$protocol,
|
||||
);
|
||||
|
||||
$this->attachToNextOpenSession($case, $appointment);
|
||||
|
||||
return $case;
|
||||
}
|
||||
|
||||
public function onSessionFinished(TreatmentSession $session): void
|
||||
{
|
||||
$next = $this->scheduler->scheduleNextAfter($session);
|
||||
|
||||
if ($next === null) {
|
||||
$this->closeIfEverySessionIsSettled($session->getTreatmentCase());
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
/** نوبت به اولین جلسهٔ بدونِ نوبت وصل میشود، نه لزوماً به جلسهٔ اول. */
|
||||
protected function attachToNextOpenSession(TreatmentCase $case, Appointment $appointment): void
|
||||
{
|
||||
$session = $this->sessions->findNextUnbooked($case);
|
||||
|
||||
if ($session === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session->attachAppointment($appointment);
|
||||
// سررسیدِ جلسهای که نوبت گرفته دیگر حدس نیست — همان ساعت نوبت است.
|
||||
$this->scheduler->seedFirstSession($session, $appointment->getSlotStart());
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
protected function closeIfEverySessionIsSettled(TreatmentCase $case): void
|
||||
{
|
||||
foreach ($case->getSessions() as $session) {
|
||||
if (!in_array($session->getStatus(), [TreatmentSession::STATUS_DONE, TreatmentSession::STATUS_CANCELLED], true)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$case->close(TreatmentCase::STATUS_COMPLETED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Treatment\Workflow;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
|
||||
|
||||
/**
|
||||
* کلینیک زیبایی.
|
||||
*
|
||||
* امروز رفتارش دقیقاً پیشفرض است و این عمدی است: جدا نگهداشتنش نقطهٔ اتصالِ آینده را
|
||||
* میسازد — «بعد از جلسهٔ چهارم عکس قبل و بعد بگیر» جایی برای نشستن دارد بدون اینکه
|
||||
* رفتار بقیهٔ حوزهها عوض شود.
|
||||
*
|
||||
* اولویت بالاتر از پیشفرض دارد چون `DefaultTreatmentWorkflow::supports()` به همهچیز
|
||||
* بله میگوید و registry اولین تطابق را برمیدارد.
|
||||
*/
|
||||
#[AsTaggedItem('app.treatment_workflow', priority: 100)]
|
||||
final class LaserTreatmentWorkflow extends DefaultTreatmentWorkflow
|
||||
{
|
||||
public const PRACTICE_DOMAIN = 'beauty';
|
||||
|
||||
public function supports(?string $practiceDomainCode): bool
|
||||
{
|
||||
return $practiceDomainCode === self::PRACTICE_DOMAIN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Treatment\Workflow;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Treatment\Entity\TreatmentCase;
|
||||
use App\Treatment\Entity\TreatmentProtocol;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
|
||||
/**
|
||||
* رفتارِ مخصوصِ یک حوزهٔ فعالیت.
|
||||
*
|
||||
* هرچه بین تخصصها **داده** است — دستهبندی، گامهای دوره، نوع منابع، فیلدهای فرم —
|
||||
* از قبل داده ذخیره میشود. آنچه میماند رفتار است: کِی پرونده باز شود و بعد از بستن
|
||||
* جلسه چه اتفاقی بیفتد. همین را اینجا میگذاریم تا افزودن دندانپزشکی یک کلاس تازه
|
||||
* باشد، نه دستبردن در مسیر رزرو.
|
||||
*
|
||||
* موتور دادهمحور عمداً انتخاب نشد: هیچ مدیر کلینیکی state machine رسم نمیکند، پس در
|
||||
* عمل فقط برنامهنویس از آن استفاده میکرد — یعنی کد، با نحو بدتر و بدون type safety.
|
||||
*/
|
||||
#[AutoconfigureTag('app.treatment_workflow')]
|
||||
interface TreatmentWorkflow
|
||||
{
|
||||
/** `null` یعنی محیط حوزهٔ فعالیت انتخاب نکرده — همان رفتار پیشفرض. */
|
||||
public function supports(?string $practiceDomainCode): bool;
|
||||
|
||||
/**
|
||||
* پس از تأیید نوبتی که سرویسش پروتکل فعال دارد.
|
||||
*
|
||||
* پیادهسازی باید پروندهٔ باز موجود را برگرداند و پروندهٔ دوم نسازد.
|
||||
*/
|
||||
public function openCase(
|
||||
Appointment $appointment,
|
||||
PatientRecord $record,
|
||||
ServiceItem $service,
|
||||
TreatmentProtocol $protocol,
|
||||
): TreatmentCase;
|
||||
|
||||
/** پس از بسته شدن یک جلسه — جای محاسبهٔ سررسید جلسهٔ بعد و بستن دوره. */
|
||||
public function onSessionFinished(TreatmentSession $session): void;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Treatment\Workflow;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
|
||||
|
||||
/**
|
||||
* انتخاب workflow بر اساس کدِ حوزهٔ فعالیت.
|
||||
*
|
||||
* ترتیب از اولویتِ tag میآید و اولین تطابق برنده است، پس پیادهسازی اختصاصی جلوتر از
|
||||
* `DefaultTreatmentWorkflow` مینشیند. هستهٔ رزرو فقط این کلاس را میشناسد و هیچجا
|
||||
* کلمهٔ «لیزر» را نمیبیند.
|
||||
*/
|
||||
final class TreatmentWorkflowRegistry
|
||||
{
|
||||
/** @param iterable<TreatmentWorkflow> $workflows */
|
||||
public function __construct(
|
||||
#[AutowireIterator('app.treatment_workflow')]
|
||||
private readonly iterable $workflows,
|
||||
) {}
|
||||
|
||||
public function for(?string $practiceDomainCode): TreatmentWorkflow
|
||||
{
|
||||
foreach ($this->workflows as $workflow) {
|
||||
if ($workflow->supports($practiceDomainCode)) {
|
||||
return $workflow;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \LogicException(sprintf(
|
||||
'No treatment workflow supports "%s"; DefaultTreatmentWorkflow should have caught it.',
|
||||
$practiceDomainCode ?? 'null',
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* آیا این حوزه پیادهسازی اختصاصی دارد، یا فقط رفتار پیشفرض میگیرد؟
|
||||
*
|
||||
* پنل ادمین پلتفرم با همین کنار هر حوزه نشان میدهد که هنوز workflow ندارد — حوزهٔ
|
||||
* بیworkflow کار میکند ولی رفتار اختصاصی ندارد، و این باید دیده شود نه حدس زده.
|
||||
*/
|
||||
public function hasDedicatedWorkflow(?string $practiceDomainCode): bool
|
||||
{
|
||||
return $practiceDomainCode !== null
|
||||
&& $this->for($practiceDomainCode)::class !== DefaultTreatmentWorkflow::class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Treatment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Service\AppointmentConfirmationService;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\CatalogCategory;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\PracticeDomain\Entity\PracticeDomain;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Tests\ApiTestCase;
|
||||
use App\Treatment\Entity\TreatmentCase;
|
||||
use App\Treatment\Entity\TreatmentProtocol;
|
||||
use App\Treatment\Entity\TreatmentProtocolStaff;
|
||||
use App\Treatment\Entity\TreatmentProtocolStep;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
|
||||
/**
|
||||
* تأیید نوبتِ سرویسِ پروتکلدار باید پروندهٔ درمان باز کند — بدون اینکه مسیر رزرو
|
||||
* چیزی دربارهٔ لیزر بداند.
|
||||
*/
|
||||
class OpenCaseOnConfirmTest extends ApiTestCase
|
||||
{
|
||||
private function confirmation(): AppointmentConfirmationService
|
||||
{
|
||||
return static::getContainer()->get(AppointmentConfirmationService::class);
|
||||
}
|
||||
|
||||
private function scenario(bool $withProtocol, ?string $domainCode = 'beauty'): array
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر آزمون');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
|
||||
$clinic->setName('کلینیک دورهٔ درمان');
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
if ($domainCode !== null) {
|
||||
$domain = $this->em->getRepository(PracticeDomain::class)->findOneBy(['code' => $domainCode])
|
||||
?? new PracticeDomain($domainCode, 'حوزهٔ آزمون');
|
||||
$this->em->persist($domain);
|
||||
$this->em->flush();
|
||||
$clinic->setPracticeDomain($domain);
|
||||
}
|
||||
|
||||
$section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر');
|
||||
$this->em->persist($section);
|
||||
|
||||
$category = new CatalogCategory('clinic', (int) $clinic->getId(), 'دست');
|
||||
$this->em->persist($category);
|
||||
|
||||
$service = new ServiceItem($section, 'لیزر دست', 5_000_000);
|
||||
$service->setCatalogCategory($category);
|
||||
$this->em->persist($service);
|
||||
$this->em->flush();
|
||||
|
||||
if ($withProtocol) {
|
||||
$staff = new ClinicStaff('clinic', (int) $clinic->getId(), 'اپراتور');
|
||||
$this->em->persist($staff);
|
||||
|
||||
$protocol = new TreatmentProtocol($service);
|
||||
$this->em->persist($protocol);
|
||||
$protocol->replaceSteps([
|
||||
new TreatmentProtocolStep($protocol, 1, 0),
|
||||
new TreatmentProtocolStep($protocol, 2, 15),
|
||||
new TreatmentProtocolStep($protocol, 3, 30),
|
||||
]);
|
||||
$protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$appointment = $this->newAppointment(
|
||||
$doctor,
|
||||
$this->createUser(['ROLE_USER']),
|
||||
1_790_000_000,
|
||||
1_790_001_800,
|
||||
$clinic,
|
||||
);
|
||||
$appointment->addServiceItem($service);
|
||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
return [$appointment, $service, $clinic];
|
||||
}
|
||||
|
||||
/** @return TreatmentCase[] */
|
||||
private function casesFor(ServiceItem $service): array
|
||||
{
|
||||
return $this->em->getRepository(TreatmentCase::class)->findBy(['serviceItem' => $service]);
|
||||
}
|
||||
|
||||
public function testConfirmingAProtocolServiceOpensACase(): void
|
||||
{
|
||||
[$appointment, $service] = $this->scenario(withProtocol: true);
|
||||
|
||||
$this->confirmation()->onConfirmed($appointment);
|
||||
|
||||
$cases = $this->casesFor($service);
|
||||
self::assertCount(1, $cases);
|
||||
self::assertSame(3, $cases[0]->getTotalSessions());
|
||||
self::assertCount(3, $cases[0]->getSessions());
|
||||
self::assertSame(['دست'], array_map(
|
||||
static fn ($a) => $a->getName(),
|
||||
$cases[0]->getAreas()->toArray(),
|
||||
));
|
||||
}
|
||||
|
||||
/** سرویس بدون پروتکل همان نوبت تکجلسهای است — نباید پروندهای ساخته شود. */
|
||||
public function testConfirmingAPlainServiceOpensNoCase(): void
|
||||
{
|
||||
[$appointment, $service] = $this->scenario(withProtocol: false);
|
||||
|
||||
$this->confirmation()->onConfirmed($appointment);
|
||||
|
||||
self::assertSame([], $this->casesFor($service));
|
||||
}
|
||||
|
||||
/** کلینیکِ بدون حوزهٔ فعالیت هم باید دورهاش کار کند — نال یعنی رفتار پیشفرض. */
|
||||
public function testCaseOpensEvenWithoutAPracticeDomain(): void
|
||||
{
|
||||
[$appointment, $service] = $this->scenario(withProtocol: true, domainCode: null);
|
||||
|
||||
$this->confirmation()->onConfirmed($appointment);
|
||||
|
||||
self::assertCount(1, $this->casesFor($service));
|
||||
}
|
||||
|
||||
public function testFirstSessionIsAttachedToTheAppointment(): void
|
||||
{
|
||||
[$appointment, $service] = $this->scenario(withProtocol: true);
|
||||
|
||||
$this->confirmation()->onConfirmed($appointment);
|
||||
|
||||
$sessions = $this->casesFor($service)[0]->getSessions()->toArray();
|
||||
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
|
||||
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||
|
||||
self::assertSame($appointment->getId(), $sessions[0]->getAppointment()?->getId());
|
||||
self::assertSame(TreatmentSession::STATUS_BOOKED, $sessions[0]->getStatus());
|
||||
self::assertSame($appointment->getSlotStart(), $sessions[0]->getDueAt());
|
||||
|
||||
// جلسات بعدی نه نوبت دارند نه سررسید قطعی — سررسیدشان از جلسهٔ قبل میآید.
|
||||
self::assertNull($sessions[1]->getAppointment());
|
||||
self::assertNull($sessions[1]->getDueAt());
|
||||
}
|
||||
|
||||
/** بیمار وسط دوره نوبت دیگری میگیرد — باید جلسهٔ همان دوره باشد نه دورهٔ موازی. */
|
||||
public function testASecondAppointmentDoesNotOpenASecondCase(): void
|
||||
{
|
||||
[$appointment, $service, $clinic] = $this->scenario(withProtocol: true);
|
||||
|
||||
$this->confirmation()->onConfirmed($appointment);
|
||||
|
||||
$second = $this->newAppointment(
|
||||
$appointment->getDoctor(),
|
||||
$appointment->getUser(),
|
||||
1_791_000_000,
|
||||
1_791_001_800,
|
||||
$clinic,
|
||||
);
|
||||
$second->addServiceItem($service);
|
||||
$second->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$this->em->persist($second);
|
||||
$this->em->flush();
|
||||
|
||||
$this->confirmation()->onConfirmed($second);
|
||||
|
||||
$cases = $this->casesFor($service);
|
||||
self::assertCount(1, $cases);
|
||||
|
||||
$sessions = $cases[0]->getSessions()->toArray();
|
||||
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
|
||||
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||
|
||||
self::assertSame($second->getId(), $sessions[1]->getAppointment()?->getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Treatment;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\CatalogCategory;
|
||||
use App\ClinicService\Entity\CatalogCategoryInclude;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\ClinicService\Service\CategoryClosureResolver;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Tests\ApiTestCase;
|
||||
use App\Treatment\Entity\TreatmentCase;
|
||||
use App\Treatment\Entity\TreatmentProtocol;
|
||||
use App\Treatment\Entity\TreatmentProtocolStaff;
|
||||
use App\Treatment\Entity\TreatmentProtocolStep;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
use App\Treatment\Repository\TreatmentSessionRepository;
|
||||
use App\Treatment\Service\TreatmentCaseOpener;
|
||||
use App\Treatment\Service\TreatmentScheduler;
|
||||
|
||||
/**
|
||||
* سررسید نسبی: فاصله از تاریخ **واقعی** جلسهٔ قبل، نه از شروع دوره.
|
||||
*/
|
||||
class TreatmentSchedulerTest extends ApiTestCase
|
||||
{
|
||||
private const DAY = 86400;
|
||||
|
||||
private TreatmentScheduler $scheduler;
|
||||
private TreatmentSessionRepository $sessions;
|
||||
private TreatmentCase $case;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->sessions = $this->em->getRepository(TreatmentSession::class);
|
||||
$this->scheduler = new TreatmentScheduler($this->sessions);
|
||||
}
|
||||
|
||||
/** سناریوی بوتاکس: جلسه ۲ بعد از ۱۵ روز، بقیه ماهانه. */
|
||||
private function botoxCase(): TreatmentCase
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($user);
|
||||
$clinic->setName('کلینیک بوتاکس');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$section = new ServiceSection('clinic', (int) $clinic->getId(), 'تزریق');
|
||||
$this->em->persist($section);
|
||||
|
||||
$category = new CatalogCategory('clinic', (int) $clinic->getId(), 'پیشانی');
|
||||
$this->em->persist($category);
|
||||
|
||||
$service = new ServiceItem($section, 'بوتاکس پیشانی', 8_000_000);
|
||||
$service->setCatalogCategory($category);
|
||||
$this->em->persist($service);
|
||||
|
||||
$staff = new ClinicStaff('clinic', (int) $clinic->getId(), 'اپراتور');
|
||||
$this->em->persist($staff);
|
||||
|
||||
$protocol = new TreatmentProtocol($service);
|
||||
$this->em->persist($protocol);
|
||||
$protocol->replaceSteps([
|
||||
new TreatmentProtocolStep($protocol, 1, 0),
|
||||
new TreatmentProtocolStep($protocol, 2, 15),
|
||||
new TreatmentProtocolStep($protocol, 3, 30),
|
||||
new TreatmentProtocolStep($protocol, 4, 30),
|
||||
]);
|
||||
$protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]);
|
||||
$this->em->flush();
|
||||
|
||||
$record = new PatientRecord('clinic', (int) $clinic->getId(), $this->createUser(), 'clinic', (int) $clinic->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
$opener = new TreatmentCaseOpener(
|
||||
new CategoryClosureResolver($this->em->getRepository(CatalogCategoryInclude::class)),
|
||||
$this->em->getRepository(CatalogCategory::class),
|
||||
$this->em,
|
||||
);
|
||||
|
||||
return $opener->open('clinic', (int) $clinic->getId(), $record, $service, $protocol);
|
||||
}
|
||||
|
||||
/** @return TreatmentSession[] */
|
||||
private function orderedSessions(TreatmentCase $case): array
|
||||
{
|
||||
$sessions = $case->getSessions()->toArray();
|
||||
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
|
||||
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||
|
||||
return $sessions;
|
||||
}
|
||||
|
||||
private function finish(TreatmentSession $session, int $at): void
|
||||
{
|
||||
$session->finish();
|
||||
// finish() زمان واقعی میگذارد؛ تست تاریخ را کنترلشده جا میاندازد.
|
||||
$ref = new \ReflectionProperty(TreatmentSession::class, 'finishedAt');
|
||||
$ref->setValue($session, $at);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function testUnevenIntervalsAreMeasuredFromTheLastFinishedSession(): void
|
||||
{
|
||||
$case = $this->botoxCase();
|
||||
$sessions = $this->orderedSessions($case);
|
||||
$day1 = 1_800_000_000;
|
||||
|
||||
$this->finish($sessions[0], $day1);
|
||||
$next = $this->scheduler->scheduleNextAfter($sessions[0]);
|
||||
self::assertSame($sessions[1]->getId(), $next?->getId());
|
||||
self::assertSame($day1 + 15 * self::DAY, $sessions[1]->getDueAt());
|
||||
|
||||
// بیمار ۲۰ روز دیر آمد — جلسهٔ سوم باید از همین تاریخ حساب شود، نه از شروع دوره.
|
||||
$actual = $sessions[1]->getDueAt() + 20 * self::DAY;
|
||||
$this->finish($sessions[1], $actual);
|
||||
$this->scheduler->scheduleNextAfter($sessions[1]);
|
||||
|
||||
self::assertSame($actual + 30 * self::DAY, $sessions[2]->getDueAt());
|
||||
}
|
||||
|
||||
/** با لنگر ثابت، بیمارِ دیرآمده جلسهٔ بعدیاش را زودتر از موعد میگرفت. */
|
||||
public function testALateSessionShiftsTheRestOfTheCourse(): void
|
||||
{
|
||||
$case = $this->botoxCase();
|
||||
$sessions = $this->orderedSessions($case);
|
||||
$day1 = 1_800_000_000;
|
||||
|
||||
$this->finish($sessions[0], $day1);
|
||||
$this->scheduler->scheduleNextAfter($sessions[0]);
|
||||
|
||||
$late = $day1 + 60 * self::DAY;
|
||||
$this->finish($sessions[1], $late);
|
||||
$this->scheduler->scheduleNextAfter($sessions[1]);
|
||||
|
||||
self::assertSame($late + 30 * self::DAY, $sessions[2]->getDueAt());
|
||||
self::assertGreaterThan($day1 + 45 * self::DAY, $sessions[2]->getDueAt());
|
||||
}
|
||||
|
||||
/** فقط جلسهٔ بعدی بازمحاسبه میشود؛ جلسات دورتر هنوز لنگری ندارند. */
|
||||
public function testOnlyTheNextSessionIsRescheduled(): void
|
||||
{
|
||||
$case = $this->botoxCase();
|
||||
$sessions = $this->orderedSessions($case);
|
||||
|
||||
$this->finish($sessions[0], 1_800_000_000);
|
||||
$this->scheduler->scheduleNextAfter($sessions[0]);
|
||||
|
||||
self::assertNotNull($sessions[1]->getDueAt());
|
||||
self::assertNull($sessions[2]->getDueAt());
|
||||
self::assertNull($sessions[3]->getDueAt());
|
||||
}
|
||||
|
||||
/** غیبت جلسه را نمیسوزاند: تعداد جلسات ثابت میماند و همان جلسه دوباره برنامهریزی میشود. */
|
||||
public function testNoShowKeepsTheSessionAndTheTotal(): void
|
||||
{
|
||||
$case = $this->botoxCase();
|
||||
$sessions = $this->orderedSessions($case);
|
||||
|
||||
$this->finish($sessions[0], 1_800_000_000);
|
||||
$this->scheduler->scheduleNextAfter($sessions[0]);
|
||||
|
||||
$sessions[1]->markNoShow();
|
||||
$this->em->flush();
|
||||
|
||||
self::assertSame(4, $case->getTotalSessions());
|
||||
self::assertCount(4, $case->getSessions());
|
||||
self::assertSame(TreatmentSession::STATUS_NO_SHOW, $sessions[1]->getStatus());
|
||||
|
||||
// همان جلسه دوباره در صف رزرو میآید، نه جلسهای تازه.
|
||||
self::assertSame($sessions[1]->getId(), $this->sessions->findNextUnbooked($case)?->getId());
|
||||
}
|
||||
|
||||
public function testLastSessionSchedulesNothing(): void
|
||||
{
|
||||
$case = $this->botoxCase();
|
||||
$sessions = $this->orderedSessions($case);
|
||||
|
||||
foreach ($sessions as $index => $session) {
|
||||
$this->finish($session, 1_800_000_000 + $index * 30 * self::DAY);
|
||||
}
|
||||
|
||||
self::assertNull($this->scheduler->scheduleNextAfter($sessions[3]));
|
||||
}
|
||||
|
||||
public function testUnfinishedSessionSchedulesNothing(): void
|
||||
{
|
||||
$case = $this->botoxCase();
|
||||
$sessions = $this->orderedSessions($case);
|
||||
|
||||
self::assertNull($this->scheduler->scheduleNextAfter($sessions[0]));
|
||||
self::assertNull($sessions[1]->getDueAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Treatment;
|
||||
|
||||
use App\PracticeDomain\Entity\PracticeDomain;
|
||||
use App\Tests\ApiTestCase;
|
||||
use App\Treatment\Workflow\DefaultTreatmentWorkflow;
|
||||
use App\Treatment\Workflow\LaserTreatmentWorkflow;
|
||||
use App\Treatment\Workflow\TreatmentWorkflowRegistry;
|
||||
|
||||
class TreatmentWorkflowTest extends ApiTestCase
|
||||
{
|
||||
private TreatmentWorkflowRegistry $registry;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->registry = static::getContainer()->get(TreatmentWorkflowRegistry::class);
|
||||
}
|
||||
|
||||
public function testBeautyResolvesToTheLaserWorkflow(): void
|
||||
{
|
||||
self::assertInstanceOf(LaserTreatmentWorkflow::class, $this->registry->for('beauty'));
|
||||
}
|
||||
|
||||
/** حوزهای که هنوز پیادهسازی ندارد باید رفتار پیشفرض بگیرد، نه خطا. */
|
||||
public function testUnknownDomainFallsBackToTheDefaultWorkflow(): void
|
||||
{
|
||||
self::assertSame(
|
||||
DefaultTreatmentWorkflow::class,
|
||||
$this->registry->for('physiotherapy')::class,
|
||||
);
|
||||
}
|
||||
|
||||
/** نال یعنی «حوزه تنظیم نشده» و همان رفتار امروز، نه خطا. */
|
||||
public function testNullDomainFallsBackToTheDefaultWorkflow(): void
|
||||
{
|
||||
self::assertSame(DefaultTreatmentWorkflow::class, $this->registry->for(null)::class);
|
||||
}
|
||||
|
||||
public function testHasDedicatedWorkflowDistinguishesTheTwo(): void
|
||||
{
|
||||
self::assertTrue($this->registry->hasDedicatedWorkflow('beauty'));
|
||||
self::assertFalse($this->registry->hasDedicatedWorkflow('physiotherapy'));
|
||||
self::assertFalse($this->registry->hasDedicatedWorkflow(null));
|
||||
}
|
||||
|
||||
/** پنل ادمین باید ببیند کدام حوزه هنوز workflow ندارد. */
|
||||
public function testListExposesHasWorkflow(): void
|
||||
{
|
||||
$suffix = bin2hex(random_bytes(4));
|
||||
|
||||
foreach ([['beauty_' . $suffix, false], ['beauty', true]] as [$code, $expected]) {
|
||||
if ($this->em->getRepository(PracticeDomain::class)->findOneBy(['code' => $code]) === null) {
|
||||
$this->em->persist(new PracticeDomain($code, 'حوزهٔ آزمون'));
|
||||
}
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
$body = $this->authJson('GET', '/api/v1/practice-domains', $admin);
|
||||
|
||||
$byCode = array_column($body['data'], 'has_workflow', 'code');
|
||||
|
||||
self::assertTrue($byCode['beauty']);
|
||||
self::assertFalse($byCode['beauty_' . $suffix]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user