Remove ReportTest and WaitlistTest files as part of codebase cleanup
This commit is contained in:
@@ -1,258 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Cancellation\Controller;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\Cancellation\Entity\CancellationPolicy;
|
||||
use App\Cancellation\Repository\CancellationPolicyRepository;
|
||||
use App\Cancellation\Service\CancellationService;
|
||||
use App\Cancellation\Repository\NoShowRecordRepository;
|
||||
use App\Cancellation\Service\NoShowService;
|
||||
use App\Cancellation\Service\PenaltyCalculator;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Cancellation')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class CancellationController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CancellationPolicyRepository $policies,
|
||||
private readonly AppointmentRepository $appointments,
|
||||
private readonly ServiceItemRepository $items,
|
||||
private readonly PatientRecordRepository $patients,
|
||||
private readonly PenaltyCalculator $calculator,
|
||||
private readonly CancellationService $cancellation,
|
||||
private readonly NoShowService $noShow,
|
||||
private readonly NoShowRecordRepository $noShowRecords,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/cancellation-policy', name: 'cancellation_policy_show', methods: ['GET'])]
|
||||
public function show(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Cancellation\Entity;
|
||||
|
||||
use App\Cancellation\Repository\CancellationPolicyRepository;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* سیاست لغو یک محیط، یا override یک سرویس.
|
||||
*
|
||||
* `serviceItem === null` یعنی پیشفرض محیط. سرویسی که سیاست خودش را دارد، همان برنده
|
||||
* است — بدون ترکیب و بدون وراثت جزئی، چون «۲۴ ساعت از محیط ولی ۵۰٪ از سرویس» چیزی
|
||||
* است که هیچ اپراتوری نمیتواند در ذهنش شبیهسازی کند.
|
||||
*
|
||||
* پیشفرض عمداً **بدون جریمه** است: اگر پیشفرض جریمهدار بود، لحظهٔ deploy همهٔ
|
||||
* بیمارانِ با نوبت نزدیک مشمول جریمه میشدند و کلینیک خبر نداشت.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: CancellationPolicyRepository::class)]
|
||||
#[ORM\Table(name: 'cancellation_policies')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_cancel_policy_scope', columns: ['entity_type', 'entity_id', 'service_item_id'])]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_cancel_policies_tenant')]
|
||||
class CancellationPolicy
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const MODE_NONE = 'none';
|
||||
public const MODE_PERCENT = 'percent';
|
||||
public const MODE_FIXED = 'fixed';
|
||||
|
||||
public const MODES = [self::MODE_NONE, self::MODE_PERCENT, self::MODE_FIXED];
|
||||
|
||||
#[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: ServiceItem::class)]
|
||||
#[ORM\JoinColumn(name: 'service_item_id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?ServiceItem $serviceItem = null;
|
||||
|
||||
#[ORM\Column(name: 'free_window_hours', type: 'smallint', options: ['default' => 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<string, mixed> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Cancellation\Entity;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Cancellation\Repository\NoShowRecordRepository;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* یک بار عدم حضور — جدول جدا، نه شمارنده روی بیمار.
|
||||
*
|
||||
* همان استدلال دفتر اعتبار تسک ۱۱: شمارنده «چه زمانی و کدام نوبت» را از دست میدهد و
|
||||
* پنجرهٔ ۱۲ ماهه را غیرقابل محاسبه میکند. بیماری که سه سال پیش سه بار نیامده، امروز
|
||||
* پرریسک نیست.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: NoShowRecordRepository::class)]
|
||||
#[ORM\Table(name: 'no_show_records')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_no_show_appointment', columns: ['appointment_id'])]
|
||||
#[ORM\Index(columns: ['patient_record_id', 'recorded_at'], name: 'idx_no_show_patient')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'recorded_at'], name: 'idx_no_show_tenant')]
|
||||
class NoShowRecord
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
#[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: Appointment::class)]
|
||||
#[ORM\JoinColumn(name: 'appointment_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Appointment $appointment;
|
||||
|
||||
#[ORM\Column(name: 'recorded_at', type: 'integer')]
|
||||
private int $recordedAt;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'recorded_by', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $recordedBy = null;
|
||||
|
||||
public function __construct(PatientRecord $patientRecord, Appointment $appointment, ?User $recordedBy = null, ?int $at = null)
|
||||
{
|
||||
$this->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<string, mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'appointment_uuid' => $this->appointment->getUuid(),
|
||||
'slot_start' => $this->appointment->getSlotStart(),
|
||||
'recorded_at' => $this->recordedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Cancellation\Repository;
|
||||
|
||||
use App\Cancellation\Entity\CancellationPolicy;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<CancellationPolicy> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Cancellation\Repository;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Cancellation\Entity\NoShowRecord;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<NoShowRecord> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Cancellation\Service;
|
||||
|
||||
use App\Appointment\Booking\Service\BookingService;
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Entity\AppointmentEvent;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Cancellation\ValueObject\PenaltyResult;
|
||||
use App\Package\Service\CreditLedgerService;
|
||||
use App\Settlement\Service\WalletService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Waitlist\Service\WaitlistNotifier;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* لغو نوبت با اعمال سیاست.
|
||||
*
|
||||
* ترتیب کارها عمدی است: اول اعتبارسنجی، بعد آزادسازی ظرفیت، بعد پول، و آخر اطلاع به
|
||||
* لیست انتظار. اگر اطلاعرسانی اول بود، ممکن بود ده نفر برای ظرفیتی خبر شوند که هنوز
|
||||
* آزاد نشده.
|
||||
*/
|
||||
final class CancellationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PenaltyCalculator $calculator,
|
||||
private readonly BookingService $booking,
|
||||
private readonly WalletService $wallet,
|
||||
private readonly CreditLedgerService $credits,
|
||||
private readonly WaitlistNotifier $waitlist,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
* @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(),
|
||||
'سیاست لغو: اعتبار این جلسه برنمیگردد',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Cancellation\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Cancellation\Entity\NoShowRecord;
|
||||
use App\Cancellation\Repository\CancellationPolicyRepository;
|
||||
use App\Cancellation\Repository\NoShowRecordRepository;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Shared\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Tag\Entity\TenantTag;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* ثبت عدم حضور و برچسبگذاری بیمار پرریسک.
|
||||
*
|
||||
* برچسب **مسدود نمیکند**. مسدودسازی یک قانون `eligibility` (تسک ۰۹) روی همین برچسب
|
||||
* است؛ اینجا فقط واقعیت ثبت میشود. تفکیکش عمدی است: کلینیکی که میخواهد بیمار پرریسک
|
||||
* را ببیند ولی بیعانه بگیرد، نباید مجبور شود برچسب را خاموش کند.
|
||||
*/
|
||||
final class NoShowService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NoShowRecordRepository $records,
|
||||
private readonly CancellationPolicyRepository $policies,
|
||||
private readonly DomainEventPublisher $events,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{recorded: bool, count: int, threshold: int, tagged: bool}
|
||||
*/
|
||||
public function record(Appointment $appointment, PatientRecord $patient, ?User $actor = null, ?int $now = null): array
|
||||
{
|
||||
$now = $now ?? time();
|
||||
|
||||
// تغییر وضعیت به `no_show` ممکن است دو بار اتفاق بیفتد؛ رکورد دوم ثبت نشود.
|
||||
$existing = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Cancellation\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Cancellation\Entity\CancellationPolicy;
|
||||
use App\Cancellation\Repository\CancellationPolicyRepository;
|
||||
use App\Cancellation\ValueObject\PenaltyResult;
|
||||
use App\Payment\Entity\Payment;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* محاسبهٔ جریمهٔ لغو — خالص، بدون هیچ نوشتنی.
|
||||
*
|
||||
* همین کلاس هم پیشنمایش را میدهد و هم عددی که واقعاً کسر میشود؛ دو مسیر جدا یعنی
|
||||
* بالاخره روزی دو عدد متفاوت.
|
||||
*/
|
||||
final class PenaltyCalculator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CancellationPolicyRepository $policies,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function calculate(Appointment $appointment, string $cancelledBy, ?int $now = null): PenaltyResult
|
||||
{
|
||||
// ⚠️ این شرط باید **اولین** خط باشد. اگر بعد از محاسبهٔ پنجرهٔ زمانی بیاید، یک
|
||||
// refactor میتواند ترتیب را عوض کند و کلینیک از بیمار برای لغو خودش جریمه بگیرد.
|
||||
if ($cancelledBy === Appointment::STATUS_CANCELLED_BY_DOCTOR) {
|
||||
return PenaltyResult::free(true, ['لغو توسط کلینیک هرگز جریمه ندارد']);
|
||||
}
|
||||
|
||||
$now = $now ?? time();
|
||||
$policy = $this->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();
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Cancellation\ValueObject;
|
||||
|
||||
/**
|
||||
* نتیجهٔ محاسبهٔ لغو — پیش از اینکه چیزی اتفاق بیفتد.
|
||||
*
|
||||
* همان شکل در پیشنمایش و در لغو واقعی برمیگردد: بیمار نباید عددی ببیند که با آنچه
|
||||
* واقعاً کسر میشود فرق دارد.
|
||||
*/
|
||||
final readonly class PenaltyResult
|
||||
{
|
||||
/** @param list<string> $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<string, mixed> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Course\Entity\CourseProtocol;
|
||||
use App\Course\Entity\CourseProtocolStep;
|
||||
use App\Course\Repository\CourseProtocolRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Course')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class CourseProtocolController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CourseProtocolRepository $protocols,
|
||||
private readonly ServiceItemRepository $items,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/course-protocols', name: 'course_protocol_index', methods: ['GET'])]
|
||||
public function index(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Course\Repository\CourseProtocolRepository;
|
||||
use App\Course\Repository\TreatmentCourseRepository;
|
||||
use App\Course\Service\CourseBooker;
|
||||
use App\Course\Service\CourseProgressCalculator;
|
||||
use App\Course\Service\CourseScheduler;
|
||||
use App\Course\Service\CourseStarter;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Package\Repository\PatientPackageRepository;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Package\Service\CreditLedgerService;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Course')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class TreatmentCourseController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TreatmentCourseRepository $courses,
|
||||
private readonly CourseProtocolRepository $protocols,
|
||||
private readonly PatientRecordRepository $patients,
|
||||
private readonly PatientPackageRepository $patientPackages,
|
||||
private readonly DoctorRepository $doctors,
|
||||
private readonly CourseStarter $starter,
|
||||
private readonly CourseScheduler $scheduler,
|
||||
private readonly CourseBooker $booker,
|
||||
private readonly CourseProgressCalculator $progress,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly CreditLedgerService $credits,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/treatment-course', name: 'treatment_course_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['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<string, mixed> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Entity;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Course\Repository\CourseProtocolRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* پروتکل دوره — «لیزر فولبادی: ۸ جلسه، ۲۱/۲۸/۴۵ روز».
|
||||
*
|
||||
* سه فاصله سه معنای متفاوت دارند: `min` زودترین زمانی که از نظر درمانی مجاز است،
|
||||
* `ideal` بهترین، و `max` جایی که دیرتر از آن اثر دوره افت میکند. برنامهریز به
|
||||
* **نزدیکترین به ایدهآل** میرسد، نه اولین وقت خالی.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: CourseProtocolRepository::class)]
|
||||
#[ORM\Table(name: 'course_protocols')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_protocol_service', columns: ['service_item_id'])]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_protocols_tenant')]
|
||||
class CourseProtocol
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
#[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: ServiceItem::class)]
|
||||
#[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ServiceItem $serviceItem;
|
||||
|
||||
#[ORM\Column(name: 'session_count', type: 'smallint')]
|
||||
private int $sessionCount;
|
||||
|
||||
#[ORM\Column(name: 'min_days', type: 'smallint')]
|
||||
private int $minDays;
|
||||
|
||||
#[ORM\Column(name: 'ideal_days', type: 'smallint')]
|
||||
private int $idealDays;
|
||||
|
||||
#[ORM\Column(name: 'max_days', type: 'smallint')]
|
||||
private int $maxDays;
|
||||
|
||||
#[ORM\Column(name: 'prefer_same_resource', type: 'boolean', options: ['default' => true])]
|
||||
private bool $preferSameResource = true;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => true])]
|
||||
private bool $active = true;
|
||||
|
||||
/** @var Collection<int, CourseProtocolStep> */
|
||||
#[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<int, CourseProtocolStep> */
|
||||
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<string, mixed> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* پارامترهای یک جلسه از پروتکل — «جلسهٔ ۳: انرژی ۱۶».
|
||||
*
|
||||
* `params` عمداً آزاد است چون هر تخصص پارامتر خودش را دارد (انرژی، دوز، ضخامت)، ولی
|
||||
* فقط اسکالر و **هیچ منطقی به مقدارش وابسته نیست**: فقط کپی و نمایش میشود. لحظهای
|
||||
* که کدی روی `params['energy']` شرط بگذارد، این آزادی به بدهی تبدیل میشود.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'course_protocol_steps')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_step', columns: ['protocol_id', 'session_number'])]
|
||||
class CourseProtocolStep
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: CourseProtocol::class, inversedBy: 'steps')]
|
||||
#[ORM\JoinColumn(name: 'protocol_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private CourseProtocol $protocol;
|
||||
|
||||
#[ORM\Column(name: 'session_number', type: 'smallint')]
|
||||
private int $sessionNumber;
|
||||
|
||||
/** @var array<string, scalar> */
|
||||
#[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<string, mixed> $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<string, mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'session_number' => $this->sessionNumber,
|
||||
'params' => (object) ($this->params ?? []),
|
||||
'override_duration_minutes' => $this->overrideDurationMinutes,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Entity;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Course\Repository\CourseSessionRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* یک جلسه از دوره — برنامهریزیشده، رزروشده، انجامشده یا ردشده.
|
||||
*
|
||||
* `params` از پروتکل **کپی** میشود: بیمار جلسهٔ سوم را با انرژی ۱۶ انجام داده، و اگر
|
||||
* پروتکل فردا عوض شود، پروندهٔ او نباید بگوید ۱۸ بوده.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: CourseSessionRepository::class)]
|
||||
#[ORM\Table(name: 'course_sessions')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_course_session', columns: ['course_id', 'session_number'])]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_session_appointment', columns: ['appointment_id'])]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_sessions_tenant')]
|
||||
#[ORM\Index(columns: ['course_id', 'session_number'], name: 'idx_sessions_course')]
|
||||
class CourseSession
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const STATUS_PLANNED = 'planned';
|
||||
public const STATUS_BOOKED = 'booked';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
#[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: TreatmentCourse::class, inversedBy: 'sessions')]
|
||||
#[ORM\JoinColumn(name: 'course_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private TreatmentCourse $course;
|
||||
|
||||
#[ORM\Column(name: 'session_number', type: 'smallint')]
|
||||
private int $sessionNumber;
|
||||
|
||||
/** @var array<string, scalar>|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<string, mixed> $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<string, mixed> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Entity;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Course\Repository\TreatmentCourseRepository;
|
||||
use App\Package\Entity\PatientPackage;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* دورهٔ درمان یک بیمار.
|
||||
*
|
||||
* چهار عدد پروتکل **کپی** میشوند نه ارجاع: تغییر پروتکل فردا نباید دورهٔ در جریان را
|
||||
* عوض کند — همان تصمیمی که در `appointment_segments` و `patient_packages` گرفته شد.
|
||||
*
|
||||
* یکتایی «یک دورهٔ فعال per (بیمار، سرویس)» با `activeCourseKey` گرفته میشود، همان
|
||||
* الگوی `Appointment::activeSlotKey`: MariaDB کلید یکتای جزئی ندارد، ولی کلیدی که در
|
||||
* حالتهای غیرفعال `null` میشود همان کار را میکند.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: TreatmentCourseRepository::class)]
|
||||
#[ORM\Table(name: 'treatment_courses')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status', 'started_at'], name: 'idx_courses_tenant')]
|
||||
#[ORM\Index(columns: ['patient_record_id', 'status'], name: 'idx_courses_patient')]
|
||||
class TreatmentCourse
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_ABANDONED = 'abandoned';
|
||||
|
||||
#[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: 'RESTRICT')]
|
||||
private PatientRecord $patientRecord;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
|
||||
#[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private ServiceItem $serviceItem;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: CourseProtocol::class)]
|
||||
#[ORM\JoinColumn(name: 'protocol_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private CourseProtocol $protocol;
|
||||
|
||||
#[ORM\Column(name: 'session_count', type: 'smallint')]
|
||||
private int $sessionCount;
|
||||
|
||||
#[ORM\Column(name: 'min_days', type: 'smallint')]
|
||||
private int $minDays;
|
||||
|
||||
#[ORM\Column(name: 'ideal_days', type: 'smallint')]
|
||||
private int $idealDays;
|
||||
|
||||
#[ORM\Column(name: 'max_days', type: 'smallint')]
|
||||
private int $maxDays;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientPackage::class)]
|
||||
#[ORM\JoinColumn(name: 'patient_package_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?PatientPackage $patientPackage = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ClinicResource::class)]
|
||||
#[ORM\JoinColumn(name: 'preferred_resource_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ClinicResource $preferredResource = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 12, options: ['default' => 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<int, CourseSession> */
|
||||
#[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<int, CourseSession> */
|
||||
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<CourseSession> جلساتی که هنوز رزرو نشدهاند، به ترتیب شماره */
|
||||
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<string, mixed> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Repository;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Course\Entity\CourseProtocol;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<CourseProtocol> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Repository;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<CourseSession> */
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Repository;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<TreatmentCourse> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Service;
|
||||
|
||||
use App\Appointment\Availability\ValueObject\AvailableSlot;
|
||||
use App\Appointment\Booking\Service\BookingService;
|
||||
use App\Appointment\Booking\Service\HoldService;
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* رزرو یکجای جلسات باقیماندهٔ دوره.
|
||||
*
|
||||
* سه قاعده که ترتیبشان مهم است:
|
||||
*
|
||||
* ۱. **همه یا هیچ** — کل حلقه در یک تراکنش. رزرو نیمهکاره بدترین حالت است: بیمار فکر
|
||||
* میکند دورهاش رزرو شده و نصفش نیست.
|
||||
* ۲. **لنگر متحرک** — هر جلسه از جلسهٔ قبلی فاصله میگیرد، نه از شروع دوره.
|
||||
* ۳. **سقف افق جستجو** — جلساتی که بیرون بازهٔ مجاز میافتند `planned` میمانند و
|
||||
* پیام روشن برمیگردد؛ خطا نیستند.
|
||||
*/
|
||||
final class CourseBooker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CourseScheduler $scheduler,
|
||||
private readonly AppointmentPlanBuilder $planner,
|
||||
private readonly HoldService $holds,
|
||||
private readonly BookingService $booking,
|
||||
private readonly CourseSessionLinker $linker,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{booked: int, remaining: int, message: string|null}
|
||||
*/
|
||||
public function bookAll(TreatmentCourse $course, DoctorAddress $address, Doctor $doctor, User $operator, ?int $now = null): array
|
||||
{
|
||||
$now = $now ?? time();
|
||||
|
||||
return $this->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<string, list<ClinicResource>> */
|
||||
private function assignmentOf(AvailableSlot $slot): array
|
||||
{
|
||||
return $slot->assignment->byRole;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Service;
|
||||
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
|
||||
/**
|
||||
* پیشرفت دوره — «جلسهٔ ۳ از ۸».
|
||||
*
|
||||
* شمارش از خودِ جلسات میآید نه از یک شمارنده؛ همان دلیل دفتر اعتبار تسک ۱۱: عددی که
|
||||
* جدا از داده نگه داشته شود، بالاخره با آن اختلاف پیدا میکند.
|
||||
*/
|
||||
final class CourseProgressCalculator
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Service;
|
||||
|
||||
use App\Appointment\Availability\Service\AvailabilityEngine;
|
||||
use App\Appointment\Availability\ValueObject\AvailableSlot;
|
||||
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Engine\SpacingPolicyEngine;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
|
||||
/**
|
||||
* برنامهریزی جلسات دوره: پیشنهاد جلسهٔ بعدی و رزرو یکجا.
|
||||
*
|
||||
* ## لنگر متحرک
|
||||
*
|
||||
* فاصله همیشه از **جلسهٔ قبلی** حساب میشود، نه از شروع دوره. اگر جلسهٔ ۲ سه روز دیرتر
|
||||
* افتاد، جلسهٔ ۳ هم جابهجا میشود — وگرنه تأخیر یک جلسه، فاصلهٔ بقیه را خراب میکند.
|
||||
*
|
||||
* ## نزدیکترین به ایدهآل، نه اولین آزاد
|
||||
*
|
||||
* ۲۸ روز ایدهآل است؛ روز ۲۱ (حداقلِ مجاز) از نظر درمانی بدتر از روز ۲۷ است. پس بین
|
||||
* وقتهای موجود، آن که فاصلهاش تا ایدهآل کمتر است برنده میشود.
|
||||
*/
|
||||
final class CourseScheduler
|
||||
{
|
||||
/** سقف جستجوی تسک ۰۶ — جلسات بیرون این بازه `planned` میمانند. */
|
||||
public const SEARCH_HORIZON_DAYS = 90;
|
||||
|
||||
public function __construct(
|
||||
private readonly AppointmentPlanBuilder $planner,
|
||||
private readonly AvailabilityEngine $availability,
|
||||
private readonly SpacingPolicyEngine $spacingPolicies,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* فاصلهٔ مؤثر: سختگیرانهترین بین پروتکل دوره و قانون `spacing` تسک ۰۹.
|
||||
*
|
||||
* قانون کلینیک نباید با پروتکل بجنگد؛ هر کدام سختگیرتر بود همان اجرا میشود.
|
||||
*/
|
||||
public function effectiveMinDays(TreatmentCourse $course, ?int $at = null): int
|
||||
{
|
||||
$service = $course->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<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Course\Repository\CourseSessionRepository;
|
||||
use App\Shared\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* تنها جایی که پیوند «نوبت ↔ جلسهٔ دوره» نوشته میشود.
|
||||
*
|
||||
* پیوند دوطرفه است (`course_sessions.appointment_id` و `appointments.course_session_id`)
|
||||
* تا لیست نوبتهای پنل بدون JOIN بفهمد نوبت جزو دوره است و صفحهٔ دوره بدون JOIN نوبت را
|
||||
* پیدا کند. دو ستون یعنی دو فرصت برای واگرایی، پس **فقط این کلاس** مینویسدشان.
|
||||
*/
|
||||
final class CourseSessionLinker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CourseSessionRepository $sessions,
|
||||
private readonly DomainEventPublisher $events,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function link(CourseSession $session, Appointment $appointment): void
|
||||
{
|
||||
$session->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();
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Service;
|
||||
|
||||
use App\Course\Entity\CourseProtocol;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Course\Repository\TreatmentCourseRepository;
|
||||
use App\Package\Entity\PatientPackage;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* شروع دوره از روی پروتکل.
|
||||
*
|
||||
* همهٔ جلسات **همین لحظه** ساخته میشوند (با وضعیت `planned`) نه هنگام رزرو: بیمار باید
|
||||
* از روز اول ببیند «۸ جلسه» یعنی چه، و پارامتر هر جلسه هم همان لحظه از پروتکل کپی
|
||||
* میشود تا تغییر بعدی پروتکل پروندهٔ او را عوض نکند.
|
||||
*/
|
||||
final class CourseStarter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TreatmentCourseRepository $courses,
|
||||
private readonly DomainEventPublisher $events,
|
||||
) {}
|
||||
|
||||
public function start(
|
||||
PatientRecord $patient,
|
||||
CourseProtocol $protocol,
|
||||
?PatientPackage $package = null,
|
||||
): TreatmentCourse {
|
||||
if (!$protocol->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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Package\Command;
|
||||
|
||||
use App\Package\Entity\SessionCreditLedger;
|
||||
use App\Package\Repository\PatientPackageRepository;
|
||||
use App\Package\Service\CreditLedgerService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* ثبت ردیف `expiry` برای پکیجهایی که تاریخشان گذشته و هنوز مانده دارند.
|
||||
*
|
||||
* دفتر دستنخورده میماند و تاریخچه کامل است: بیمار میتواند بپرسد «۳ جلسهام چه شد؟»
|
||||
* و جواب یک ردیف با تاریخ و دلیل است، نه سکوت.
|
||||
*/
|
||||
#[AsCommand(name: 'app:package:expire', description: 'Write expiry ledger rows for lapsed patient packages.')]
|
||||
class ExpirePackagesCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PatientPackageRepository $packages,
|
||||
private readonly CreditLedgerService $ledger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Package\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Package\Entity\Package;
|
||||
use App\Package\Entity\PackageService;
|
||||
use App\Package\Repository\PackageRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Package')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class PackageController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PackageRepository $packages,
|
||||
private readonly ServiceItemRepository $items,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/packages', name: 'package_index', methods: ['GET'])]
|
||||
public function index(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->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<string, mixed> $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<ServiceItem>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Package\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\Package\Entity\PatientPackage;
|
||||
use App\Package\Entity\SessionCreditLedger;
|
||||
use App\Package\Repository\PackageRepository;
|
||||
use App\Package\Repository\PatientPackageRepository;
|
||||
use App\Package\Service\CreditLedgerService;
|
||||
use App\Package\Service\PackageSalesService;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Package')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class PatientPackageController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PackageRepository $packages,
|
||||
private readonly PatientPackageRepository $patientPackages,
|
||||
private readonly PatientRecordRepository $patients,
|
||||
private readonly PackageSalesService $sales,
|
||||
private readonly CreditLedgerService $ledger,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/patient/{uuid}/package', name: 'patient_package_sell', methods: ['POST'])]
|
||||
public function sell(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$patient = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Package\Entity;
|
||||
|
||||
use App\Package\Repository\PackageRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* تعریف پکیج — «۶ جلسه لیزر فولبادی».
|
||||
*
|
||||
* خودِ این ردیف چیزی نمیفروشد؛ {@see PatientPackage} نمونهٔ خریداریشده است و
|
||||
* تعداد و قیمت را از اینجا **کپی** میکند. تغییر تعریف فردا، پکیج فروختهشدهٔ دیروز را
|
||||
* عوض نمیکند (قانون پنجم مستند).
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: PackageRepository::class)]
|
||||
#[ORM\Table(name: 'packages')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_packages_tenant')]
|
||||
class Package
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 200)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(name: 'session_count', type: 'smallint')]
|
||||
private int $sessionCount;
|
||||
|
||||
/** ریال در `bigint`: پکیج بزرگ از سقف `int` عبور میکند. */
|
||||
#[ORM\Column(name: 'price_rials', type: 'bigint')]
|
||||
private string|int $priceRials = 0;
|
||||
|
||||
/** `null` یعنی بیپایان. */
|
||||
#[ORM\Column(name: 'validity_days', type: 'smallint', nullable: true)]
|
||||
private ?int $validityDays = null;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => true])]
|
||||
private bool $active = true;
|
||||
|
||||
/** @var Collection<int, PackageService> */
|
||||
#[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<int, PackageService> */
|
||||
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<int> شناسهٔ سرویسهای پوششدادهشده */
|
||||
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<string, mixed> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Package\Entity;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* سرویسهایی که یک پکیج پوشش میدهد.
|
||||
*
|
||||
* حذف سرویس `RESTRICT` است: سرویسی که در پکیجِ فروختهشده هست اگر برود، اعتبار
|
||||
* بیمارانی که خریدهاند بیمعنا میشود.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'package_services')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_pkg_service', columns: ['package_id', 'service_item_id'])]
|
||||
class PackageService
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Package::class, inversedBy: 'services')]
|
||||
#[ORM\JoinColumn(name: 'package_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Package $package;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
|
||||
#[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private ServiceItem $serviceItem;
|
||||
|
||||
public function __construct(Package $package, ServiceItem $serviceItem)
|
||||
{
|
||||
$this->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; }
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Package\Entity;
|
||||
|
||||
use App\Package\Repository\PatientPackageRepository;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* پکیجی که یک بیمار خریده.
|
||||
*
|
||||
* ⛔ **هیچ ستون ماندهای اینجا نیست و نباید باشد.** `sessionCount` فقط snapshotِ
|
||||
* تعریف لحظهٔ خرید است؛ مانده همیشه از جمع ردیفهای {@see SessionCreditLedger}
|
||||
* میآید. مستند صریح است: «اگر فقط یک عدد نگه داریم، اولین اشتباه هرگز قابل
|
||||
* ردیابی نیست.»
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: PatientPackageRepository::class)]
|
||||
#[ORM\Table(name: 'patient_packages')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'purchased_at'], name: 'idx_pp_tenant')]
|
||||
#[ORM\Index(columns: ['patient_record_id', 'valid_to'], name: 'idx_pp_patient')]
|
||||
class PatientPackage
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
#[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: Package::class)]
|
||||
#[ORM\JoinColumn(name: 'package_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private Package $package;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientRecord::class)]
|
||||
#[ORM\JoinColumn(name: 'patient_record_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private PatientRecord $patientRecord;
|
||||
|
||||
/** snapshot تعریف — نه مانده. */
|
||||
#[ORM\Column(name: 'session_count', type: 'smallint')]
|
||||
private int $sessionCount;
|
||||
|
||||
#[ORM\Column(name: 'price_paid_rials', type: 'bigint')]
|
||||
private string|int $pricePaidRials;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Payment::class)]
|
||||
#[ORM\JoinColumn(name: 'payment_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Payment $payment = null;
|
||||
|
||||
/** مبنای FIFO. */
|
||||
#[ORM\Column(name: 'purchased_at', type: 'integer')]
|
||||
private int $purchasedAt;
|
||||
|
||||
#[ORM\Column(name: 'valid_to', type: 'integer', nullable: true)]
|
||||
private ?int $validTo = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Package $package, PatientRecord $patientRecord, ?int $purchasedAt = null)
|
||||
{
|
||||
$this->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<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Package\Entity;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Package\Repository\SessionCreditLedgerRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* دفتر اعتبار جلسات — **append-only**.
|
||||
*
|
||||
* ردیفها هرگز حذف یا ویرایش نمیشوند؛ تصحیح یعنی ردیف تازه. مانده جمع `delta` هاست،
|
||||
* پس هر عددی که کاربر میبیند یک تاریخچهٔ کامل پشتش دارد و «۳ جلسهام چه شد؟» همیشه
|
||||
* جواب دارد.
|
||||
*
|
||||
* `uniq_ledger_appointment_kind` مصرف دوباره را میبندد: `confirm` idempotent است و
|
||||
* اجرای دومش نباید جلسهٔ دوم را بخورد.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: SessionCreditLedgerRepository::class)]
|
||||
#[ORM\Table(name: 'session_credit_ledger')]
|
||||
#[ORM\Index(columns: ['patient_package_id', 'created_at'], name: 'idx_scl_package')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'created_at'], name: 'idx_scl_tenant')]
|
||||
#[ORM\Index(columns: ['appointment_id'], name: 'idx_scl_appt')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_scl_consume', columns: ['appointment_id', 'kind'])]
|
||||
class SessionCreditLedger
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const KIND_PURCHASE = 'purchase';
|
||||
public const KIND_CONSUME = 'consume';
|
||||
public const KIND_REFUND = 'refund';
|
||||
public const KIND_ADJUSTMENT = 'adjustment';
|
||||
public const KIND_EXPIRY = 'expiry';
|
||||
|
||||
public const KINDS = [
|
||||
self::KIND_PURCHASE,
|
||||
self::KIND_CONSUME,
|
||||
self::KIND_REFUND,
|
||||
self::KIND_ADJUSTMENT,
|
||||
self::KIND_EXPIRY,
|
||||
];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'bigint')]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientPackage::class)]
|
||||
#[ORM\JoinColumn(name: 'patient_package_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private PatientPackage $patientPackage;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 15)]
|
||||
private string $kind;
|
||||
|
||||
/** مثبت یا منفی — هرگز صفر: ردیفی که چیزی را عوض نمیکند فقط نویز است. */
|
||||
#[ORM\Column(type: 'smallint')]
|
||||
private int $delta;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Appointment::class)]
|
||||
#[ORM\JoinColumn(name: 'appointment_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Appointment $appointment = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
|
||||
#[ORM\JoinColumn(name: 'service_item_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ServiceItem $serviceItem = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $reason = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'created_by', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $createdBy = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(
|
||||
PatientPackage $patientPackage,
|
||||
string $kind,
|
||||
int $delta,
|
||||
?Appointment $appointment = null,
|
||||
?ServiceItem $serviceItem = null,
|
||||
?string $reason = null,
|
||||
?User $createdBy = null,
|
||||
) {
|
||||
if (!in_array($kind, self::KINDS, true)) {
|
||||
throw new \InvalidArgumentException(sprintf('Unknown ledger kind "%s".', $kind));
|
||||
}
|
||||
|
||||
if ($delta === 0) {
|
||||
throw new \InvalidArgumentException('A ledger row with a zero delta changes nothing.');
|
||||
}
|
||||
|
||||
$this->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<string, mixed> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Package\Repository;
|
||||
|
||||
use App\Package\Entity\Package;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<Package> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Package\Repository;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Package\Entity\PatientPackage;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<PatientPackage> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Package\Repository;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Package\Entity\PatientPackage;
|
||||
use App\Package\Entity\SessionCreditLedger;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<SessionCreditLedger> */
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Package\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Package\Entity\PatientPackage;
|
||||
use App\Package\Entity\SessionCreditLedger;
|
||||
use App\Package\Repository\SessionCreditLedgerRepository;
|
||||
use App\Shared\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\DBAL\LockMode;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* تنها نویسندهٔ دفتر اعتبار.
|
||||
*
|
||||
* هیچ کلاس دیگری نباید در `session_credit_ledger` بنویسد؛ اگر بنویسد، قواعد این کلاس
|
||||
* (مصرف یکتا per نوبت، ماندهای که منفی نمیشود) دور زده میشوند و دفتر همان چیزی
|
||||
* میشود که قرار بود نباشد: عددی که کسی نمیداند از کجا آمده.
|
||||
*/
|
||||
final class CreditLedgerService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionCreditLedgerRepository $ledger,
|
||||
private readonly DomainEventPublisher $events,
|
||||
private readonly ManagerRegistry $registry,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/** مانده = جمع delta ها. هیچ ستون ذخیرهشدهای نیست. */
|
||||
public function balance(PatientPackage $package): int
|
||||
{
|
||||
return $this->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);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Package\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Package\Entity\PatientPackage;
|
||||
use App\Package\Repository\PatientPackageRepository;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
|
||||
/**
|
||||
* پیدا کردن پکیج قابل استفاده و مصرفش هنگام ثبت نوبت.
|
||||
*
|
||||
* ⚠️ تفکیک حیاتی: `quote` هیچوقت مصرف نمیکند، فقط **میگوید** که مصرف خواهد شد.
|
||||
* اگر پیشنمایش مصرف میکرد، هر رفرش صفحه یک جلسه از بیمار میگرفت.
|
||||
*/
|
||||
final class PackageConsumptionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PatientPackageRepository $patientPackages,
|
||||
private readonly PatientRecordRepository $patients,
|
||||
private readonly CreditLedgerService $ledger,
|
||||
) {}
|
||||
|
||||
/** قدیمیترین پکیج معتبر با ماندهٔ مثبت (FIFO). */
|
||||
public function firstUsable(PatientRecord $patient, ServiceItem $service, ?int $at = null): ?PatientPackage
|
||||
{
|
||||
$at = $at ?? time();
|
||||
|
||||
foreach ($this->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);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Package\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Package\Entity\Package;
|
||||
use App\Package\Entity\PatientPackage;
|
||||
use App\Package\Entity\SessionCreditLedger;
|
||||
use App\Package\Repository\PatientPackageRepository;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* فروش پکیج به بیمار.
|
||||
*
|
||||
* خرید و ردیف `purchase` یک عملاند: پکیجی که بدون ردیف دفتر ثبت شود ماندهاش صفر
|
||||
* است و بیمار پولش را داده ولی چیزی نگرفته.
|
||||
*/
|
||||
final class PackageSalesService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PatientPackageRepository $patientPackages,
|
||||
private readonly CreditLedgerService $ledger,
|
||||
private readonly DomainEventPublisher $events,
|
||||
) {}
|
||||
|
||||
public function sell(Package $package, PatientRecord $patient, ?User $by = null, ?int $pricePaid = null): PatientPackage
|
||||
{
|
||||
if (!$package->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;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Command;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* اجراهای آزمایشیِ قدیمی ارزشی ندارند — جز **آخرینِ هر (قانون، نسخه)**.
|
||||
*
|
||||
* آن یکی حذفنشدنی است چون `activate` به وجودش وابسته است: پاک کردنش یعنی قانونی که
|
||||
* دیروز آزمایش شده امروز دیگر فعالشدنی نیست، بدون هیچ توضیحی برای کاربر.
|
||||
*/
|
||||
#[AsCommand(name: 'app:policy:prune-simulations', description: 'Delete old policy simulation runs, keeping the latest per policy version.')]
|
||||
class PruneSimulationsCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly Connection $connection)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->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;
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\ClinicService\Repository\CatalogCategoryRepository;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Entity\PolicyVersionLog;
|
||||
use App\Policy\Repository\PolicyRepository;
|
||||
use App\Policy\Repository\PolicySimulationRunRepository;
|
||||
use App\Policy\Repository\PolicyVersionLogRepository;
|
||||
use App\Policy\Template\PolicyTemplateRegistry;
|
||||
use App\Policy\Service\ConditionEvaluator;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Policy')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class PolicyController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyRepository $policies,
|
||||
private readonly PolicyVersionLogRepository $versions,
|
||||
private readonly PolicySimulationRunRepository $simulations,
|
||||
private readonly PolicyTemplateRegistry $templates,
|
||||
private readonly ConditionEvaluator $evaluator,
|
||||
private readonly PolicySchema $schema,
|
||||
private readonly ServiceItemRepository $items,
|
||||
private readonly CatalogCategoryRepository $categories,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* فهرست بستهٔ فیلدها، عملگرها و اثرها.
|
||||
*
|
||||
* فرم ساخت قانون در پنل از **همین** ساخته میشود، نه از فهرستی که در فرانت دوباره
|
||||
* نوشته شده باشد — دو فهرست یعنی دو حقیقت و یکی از آنها همیشه قدیمی است.
|
||||
*/
|
||||
#[Route('/api/v1/policy-schema', name: 'policy_schema', methods: ['GET'])]
|
||||
public function schema(): JsonResponse
|
||||
{
|
||||
return $this->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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Entity\PolicySimulationRun;
|
||||
use App\Policy\Repository\PolicyRepository;
|
||||
use App\Policy\Repository\PolicySimulationRunRepository;
|
||||
use App\Policy\Simulation\PolicySimulator;
|
||||
use App\Policy\Simulation\SimulationSampler;
|
||||
use App\Policy\Template\PolicyTemplateRegistry;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Policy')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class PolicySimulationController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyRepository $policies,
|
||||
private readonly PolicySimulationRunRepository $runs,
|
||||
private readonly PolicySimulator $simulator,
|
||||
private readonly PolicyTemplateRegistry $templates,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
) {}
|
||||
|
||||
/** الگوهای آمادهٔ قانون — ورودیِ فرم ساخت. */
|
||||
#[Route('/api/v1/policy-templates', name: 'policy_templates', methods: ['GET'])]
|
||||
public function templates(): JsonResponse
|
||||
{
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Engine;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
|
||||
/** شرایط بیمار — چه کسی این خدمت را میگیرد. */
|
||||
final class EligibilityPolicyEngine extends PolicyEngine
|
||||
{
|
||||
public function category(): string
|
||||
{
|
||||
return Policy::CATEGORY_ELIGIBILITY;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Engine;
|
||||
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Service\PolicyResolver;
|
||||
use App\Policy\ValueObject\PolicyOutcome;
|
||||
|
||||
/**
|
||||
* پایهٔ شش موتور دستهای — بند ۸ مستند.
|
||||
*
|
||||
* هر دسته موتور خودش را دارد تا نقطهٔ مصرف بگوید «موتور قیمت را صدا میزنم»، نه «resolver
|
||||
* را با رشتهٔ `pricing` صدا میزنم». تفاوتش در **تایپ** است نه در منطق: حل تناقض و ترکیب
|
||||
* اثرها یکی است و در `PolicyResolver` میماند؛ شش نسخهٔ کپیشدهٔ آن یعنی شش جای شکستن.
|
||||
*
|
||||
* `evaluateIsolated()` روی هر موتور هست چون آزمایشگاه قانون (تسک ۱۰) باید بتواند یک قانون
|
||||
* را بدون بقیه بسنجد — «این قانون تنها چه میکرد» سؤالی است که ترکیب جوابش را میپوشاند.
|
||||
*/
|
||||
abstract class PolicyEngine
|
||||
{
|
||||
public function __construct(protected readonly PolicyResolver $resolver) {}
|
||||
|
||||
abstract public function category(): string;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Engine;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
|
||||
/** قیمت و تخفیف. */
|
||||
final class PricingPolicyEngine extends PolicyEngine
|
||||
{
|
||||
public function category(): string
|
||||
{
|
||||
return Policy::CATEGORY_PRICING;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Engine;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
|
||||
/** منبع لازم — چه نقشی باید حاضر باشد. */
|
||||
final class ResourcePolicyEngine extends PolicyEngine
|
||||
{
|
||||
public function category(): string
|
||||
{
|
||||
return Policy::CATEGORY_RESOURCE;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Engine;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
|
||||
/** انتخاب سرویس — چه ترکیبی مجاز است. */
|
||||
final class SelectionPolicyEngine extends PolicyEngine
|
||||
{
|
||||
public function category(): string
|
||||
{
|
||||
return Policy::CATEGORY_SELECTION;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Engine;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
|
||||
/** فاصلهٔ جلسات. */
|
||||
final class SpacingPolicyEngine extends PolicyEngine
|
||||
{
|
||||
public function category(): string
|
||||
{
|
||||
return Policy::CATEGORY_SPACING;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Engine;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
|
||||
/** مدت نوبت — حداقل و افزوده. */
|
||||
final class TimingPolicyEngine extends PolicyEngine
|
||||
{
|
||||
public function category(): string
|
||||
{
|
||||
return Policy::CATEGORY_TIMING;
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Entity;
|
||||
|
||||
use App\ClinicService\Entity\CatalogCategory;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Policy\Repository\PolicyRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* یک قانون از شش دستهٔ بند ۸ مستند.
|
||||
*
|
||||
* سه چیز عمداً **نیست**: کد دلخواه، شرط آزاد، و ویرایش درجا.
|
||||
*
|
||||
* - شرط فقط از فهرست بستهٔ {@see \App\Policy\Service\PolicySchema} میآید. قانونی که
|
||||
* بتواند هر عبارتی را ارزیابی کند، دیگر قابل تحلیل ایستا نیست و دستهٔ `spacing`
|
||||
* هرگز به کوئری تبدیل نمیشود.
|
||||
* - قانون **ویرایش نمیشود، نسخه میگیرد**. نوبتی که دیروز ثبت شده باید همان نسخهای
|
||||
* را که رویش اعمال شده نگه دارد (قانون پنجم مستند).
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: PolicyRepository::class)]
|
||||
#[ORM\Table(name: 'policies')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'category', 'active'], name: 'idx_policy_tenant_category')]
|
||||
#[ORM\Index(columns: ['valid_from', 'valid_to'], name: 'idx_policy_validity')]
|
||||
class Policy
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const CATEGORY_SELECTION = 'selection';
|
||||
public const CATEGORY_ELIGIBILITY = 'eligibility';
|
||||
public const CATEGORY_RESOURCE = 'resource';
|
||||
public const CATEGORY_TIMING = 'timing';
|
||||
public const CATEGORY_SPACING = 'spacing';
|
||||
public const CATEGORY_PRICING = 'pricing';
|
||||
|
||||
public const CATEGORIES = [
|
||||
self::CATEGORY_SELECTION,
|
||||
self::CATEGORY_ELIGIBILITY,
|
||||
self::CATEGORY_RESOURCE,
|
||||
self::CATEGORY_TIMING,
|
||||
self::CATEGORY_SPACING,
|
||||
self::CATEGORY_PRICING,
|
||||
];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
private string $category;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 200)]
|
||||
private string $name;
|
||||
|
||||
/**
|
||||
* نام ستون `condition` نیست چون در MariaDB کلمهٔ کلیدی است و هر INSERT را
|
||||
* میشکند؛ نام فیلد در API همان `condition` میماند.
|
||||
*
|
||||
* @var array{match?: string, conditions?: list<array<string, mixed>>}
|
||||
*/
|
||||
#[ORM\Column(name: 'condition_json', type: 'json')]
|
||||
private array $condition = [];
|
||||
|
||||
/** @var list<array<string, mixed>> */
|
||||
#[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<string, mixed> $condition */
|
||||
public function setCondition(array $condition): self { $this->condition = $condition; $this->touch(); return $this; }
|
||||
|
||||
/** @param list<array<string, mixed>> $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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Policy\Repository\PolicySimulationRunRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* نتیجهٔ یک اجرای آزمایشی — تنها چیزی که شبیهسازی مینویسد.
|
||||
*
|
||||
* وجودش دو کار میکند: به کاربر نشان میدهد قانونش چه میکند، و به `activate` اجازهٔ
|
||||
* فعالسازی میدهد. بدون اجرای آزمایشیِ **همین نسخه**، قانون فعال نمیشود.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: PolicySimulationRunRepository::class)]
|
||||
#[ORM\Table(name: 'policy_simulation_runs')]
|
||||
#[ORM\Index(columns: ['policy_id', 'policy_version', 'created_at'], name: 'idx_psr_policy')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'created_at'], name: 'idx_psr_tenant')]
|
||||
class PolicySimulationRun
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const SEVERITY_NONE = 'none';
|
||||
public const SEVERITY_LOW = 'low';
|
||||
public const SEVERITY_MEDIUM = 'medium';
|
||||
public const SEVERITY_HIGH = 'high';
|
||||
|
||||
#[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: Policy::class)]
|
||||
#[ORM\JoinColumn(name: 'policy_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Policy $policy;
|
||||
|
||||
#[ORM\Column(name: 'policy_version', type: 'smallint')]
|
||||
private int $policyVersion;
|
||||
|
||||
#[ORM\Column(name: 'sample_size', type: 'smallint')]
|
||||
private int $sampleSize;
|
||||
|
||||
#[ORM\Column(name: 'affected_count', type: 'smallint')]
|
||||
private int $affectedCount;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 10)]
|
||||
private string $severity;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
#[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<string, mixed> $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<string, mixed> */
|
||||
public function getReport(): array { return $this->report; }
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Entity;
|
||||
|
||||
use App\Policy\Repository\PolicyVersionLogRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* عکس هر نسخهٔ یک قانون.
|
||||
*
|
||||
* نوبتی که دیروز ثبت شده، نسخهای را در `applied_policies` نگه میدارد که ممکن است
|
||||
* امروز دیگر متن فعلی قانون نباشد. بدون این جدول، «چرا آن نوبت این قیمت را گرفت؟»
|
||||
* سه ماه بعد بیجواب میماند.
|
||||
*
|
||||
* فرزند aggregate با ریشهٔ {@see Policy}؛ فقط نوشته میشود و هرگز ویرایش نمیشود.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: PolicyVersionLogRepository::class)]
|
||||
#[ORM\Table(name: 'policy_version_logs')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_policy_version', columns: ['policy_id', 'version'])]
|
||||
class PolicyVersionLog
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Policy::class)]
|
||||
#[ORM\JoinColumn(name: 'policy_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Policy $policy;
|
||||
|
||||
#[ORM\Column(type: 'smallint')]
|
||||
private int $version;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $snapshot;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(Policy $policy, int $version, array $snapshot)
|
||||
{
|
||||
$this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Repository;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Policy>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Repository;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Entity\PolicySimulationRun;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<PolicySimulationRun>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Repository;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Entity\PolicyVersionLog;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<PolicyVersionLog>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\ValueObject\PolicyOutcome;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\UserProfile\Entity\UserProfile;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* دو دستهٔ قانون که به **بیمار** وابستهاند، نه به سرویس: «صلاحیت» و «فاصله».
|
||||
*
|
||||
* جای اجرایشان لحظهٔ رزرو موقت است نه ثبت نهایی: کاربری که ده دقیقه صندلی گرفته و
|
||||
* بعد میشنود «شما واجد شرایط نیستید» هم وقت خودش را تلف کرده هم صندلی را.
|
||||
*
|
||||
* حقایق از پروفایل و تاریخچهٔ بیمار خوانده میشوند نه از بدنهٔ درخواست — با یک
|
||||
* استثنا: `has_parental_consent` چیزی است که اپراتور همان لحظه تأیید میکند و
|
||||
* جایی برای ذخیره ندارد.
|
||||
*/
|
||||
final class BookingPolicyGuard
|
||||
{
|
||||
public function __construct(
|
||||
private readonly \App\Policy\Engine\EligibilityPolicyEngine $eligibilityPolicies,
|
||||
private readonly \App\Policy\Engine\SpacingPolicyEngine $spacingPolicies,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param ServiceItem[] $items
|
||||
* @param array<string, mixed> $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<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Service;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* ارزیابی شرط یک قانون در برابر «حقایق» یک درخواست.
|
||||
*
|
||||
* هیچ کد دلخواهی اجرا نمیشود: فیلد باید در فهرست بستهٔ {@see PolicySchema} باشد و
|
||||
* عملگر از شش عملگر ثابت. شرطی که فیلد ناشناخته دارد **در زمان ساخت** رد میشود، نه
|
||||
* در زمان اجرا — قانونی که موقع رزرو بیمار بترکد، بدترین جای ممکن برای شکستن است.
|
||||
*/
|
||||
final class ConditionEvaluator
|
||||
{
|
||||
/** قانونِ در حال ارزیابی — فقط برای اینکه لاگِ فیلدِ غایب بگوید کدام قانون بود. */
|
||||
private ?Policy $policy = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly PolicySchema $schema,
|
||||
private readonly FieldRegistry $fields,
|
||||
private readonly OperatorRegistry $operators,
|
||||
private readonly LoggerInterface $logger = new NullLogger(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<mixed> $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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Service;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
|
||||
/**
|
||||
* تنها منبع حقیقتِ فیلدها — schema، استخراج مقدار، و اعتبارسنجی، هر سه از یک جدول.
|
||||
*
|
||||
* دلیلِ یکی بودنشان همان چیزی است که تسک ۰۹ بهعنوان خطرِ باقیمانده ثبت کرده بود: وقتی
|
||||
* فهرست فیلدها در یک کلاس باشد و ساختنِ حقایق در ده نقطهٔ دیگر، فیلدی که در فرم هست و
|
||||
* هیچکس نمیسازدش بیصدا «همیشهرد» میشود. حالا `extract()` همانجایی است که فرم از آن
|
||||
* ساخته میشود، پس چنین فیلدی اصلاً نمیتواند وجود داشته باشد.
|
||||
*
|
||||
* نام فیلدها عمداً همان نامهای امروز است، نه نامهای نقطهدار مستند (`patient.age`):
|
||||
* شرطهای ذخیرهشده در `condition_json` به همین نامها اشاره میکنند و تغییرشان یعنی
|
||||
* مهاجرت داده روی قانونهای زندهٔ کلینیکها.
|
||||
*/
|
||||
final class FieldRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{label: string, type: string, values?: list<string>, categories: list<string>}>
|
||||
*/
|
||||
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<string> */
|
||||
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<string, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed> $facts
|
||||
*/
|
||||
public function extract(string $field, array $facts): mixed
|
||||
{
|
||||
return $facts[$field] ?? null;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $facts */
|
||||
public function supplies(string $field, array $facts): bool
|
||||
{
|
||||
return array_key_exists($field, $facts);
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Service;
|
||||
|
||||
/**
|
||||
* فهرست بستهٔ عملگرها — یازده عملگر بند ۸ مستند.
|
||||
*
|
||||
* «بسته» یعنی عملگری که اینجا نیست، هنگام **ذخیره** رد میشود نه در زمان اجرا. قانونی که
|
||||
* موقع رزرو بیمار بترکد، بدترین جای ممکن برای شکستن است.
|
||||
*
|
||||
* مقایسه عمداً ساده است و هیچ کد دلخواهی اجرا نمیکند: هر عملگر یک تابع خالص روی دو
|
||||
* مقدار است، پس همان قانون را میشود روزی به SQL ترجمه کرد.
|
||||
*/
|
||||
final class OperatorRegistry
|
||||
{
|
||||
public const OP_EQUALS = 'equals';
|
||||
public const OP_NOT_EQUALS = 'not_equals';
|
||||
public const OP_GREATER_THAN = 'greater_than';
|
||||
public const OP_GREATER_EQUAL = 'greater_or_equal';
|
||||
public const OP_LESS_THAN = 'less_than';
|
||||
public const OP_LESS_EQUAL = 'less_or_equal';
|
||||
public const OP_IN = 'in';
|
||||
public const OP_NOT_IN = 'not_in';
|
||||
public const OP_BETWEEN = 'between';
|
||||
public const OP_CONTAINS = 'contains';
|
||||
|
||||
/**
|
||||
* «چند روز از این زمان گذشته» — عملگر ویژهٔ مستند.
|
||||
*
|
||||
* روی فیلدی کار میکند که مقدارش یک timestamp است و مقایسهاش با یک عدد روز انجام
|
||||
* میشود: `last_visit_at days_since 30` یعنی «بیش از سی روز از آخرین ویزیت گذشته».
|
||||
* بدون این، همان شرط باید در هر نقطهٔ مصرف دستی حساب میشد.
|
||||
*/
|
||||
public const OP_DAYS_SINCE = 'days_since';
|
||||
|
||||
public const ALL = [
|
||||
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_IN,
|
||||
self::OP_NOT_IN,
|
||||
self::OP_BETWEEN,
|
||||
self::OP_CONTAINS,
|
||||
self::OP_DAYS_SINCE,
|
||||
];
|
||||
|
||||
/** برچسب فارسیِ هر عملگر — همان چیزی که در فرم ساخت قانون دیده میشود. */
|
||||
private const LABELS = [
|
||||
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_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<string> */
|
||||
public function forType(string $type): array
|
||||
{
|
||||
return self::BY_TYPE[$type] ?? [self::OP_EQUALS, self::OP_NOT_EQUALS];
|
||||
}
|
||||
|
||||
/** @return list<array{value: string, label: string}> */
|
||||
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<int, mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Service;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Repository\PolicyRepository;
|
||||
use App\Policy\ValueObject\PolicyOutcome;
|
||||
|
||||
/**
|
||||
* انتخاب قانونهای مرتبط، حل تناقض، و ترکیب اثرها.
|
||||
*
|
||||
* ## ترتیب حل تناقض (بند ۸ مستند)
|
||||
*
|
||||
* ۱. **اولویت** بزرگتر
|
||||
* ۲. در تساوی: **اختصاصیتر** (شعبه بر محیط، سرویس بر دسته)
|
||||
* ۳. باز هم تساوی: قانون **قدیمیتر**
|
||||
*
|
||||
* قاعدهٔ سوم عمداً «قدیمیتر» است نه «تازهتر»: قانونی که مدتهاست کار میکند رفتار
|
||||
* جاافتادهٔ کلینیک است و قانون تازهای که تصادفاً هماولویت شده نباید بیصدا عوضش کند.
|
||||
*
|
||||
* ## ترکیب اثرها
|
||||
*
|
||||
* از جدول {@see PolicySchema::COMBINATION} میآید — `veto`، `max`، `sum`، `union`.
|
||||
* یک `forbid` کل عملیات را رد میکند حتی اگر ده قانون مجازکننده باشند؛ ممنوعیت رأی
|
||||
* اکثریت نیست.
|
||||
*/
|
||||
final class PolicyResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyRepository $policies,
|
||||
private readonly ConditionEvaluator $evaluator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Service;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
|
||||
/**
|
||||
* فهرست **بستهٔ** فیلدها، عملگرها و اثرها per دسته.
|
||||
*
|
||||
* بند ۸ مستند صریح است: شرط از فهرست بسته میآید و کد دلخواه وجود ندارد. دو دلیل:
|
||||
*
|
||||
* ۱. قانونی که هر عبارتی را بتواند ارزیابی کند، قابل تحلیل ایستا نیست — و دستهٔ
|
||||
* `spacing` باید به **کوئری** تبدیل شود، نه اینکه per اسلات در PHP اجرا شود
|
||||
* (برای ۹۰ روز غیرقابل قبول است).
|
||||
* ۲. فرم ساخت قانون در پنل از همین schema ساخته میشود، نه از فهرستی که در فرانت
|
||||
* دوباره نوشته شده باشد. دو فهرست یعنی دو حقیقت.
|
||||
*/
|
||||
final class PolicySchema
|
||||
{
|
||||
// نامهای عملگر همانهایی است که `OperatorRegistry` تعریف میکند؛ اینجا فقط برای
|
||||
// خوانایی نقاط مصرف نگه داشته شدهاند و **تعریف تازهای نیستند**.
|
||||
public const OP_EQUALS = OperatorRegistry::OP_EQUALS;
|
||||
public const OP_NOT_EQUALS = OperatorRegistry::OP_NOT_EQUALS;
|
||||
public const OP_GREATER_THAN = OperatorRegistry::OP_GREATER_THAN;
|
||||
public const OP_LESS_THAN = OperatorRegistry::OP_LESS_THAN;
|
||||
public const OP_IN = OperatorRegistry::OP_IN;
|
||||
public const OP_CONTAINS = OperatorRegistry::OP_CONTAINS;
|
||||
|
||||
public const OPERATORS = OperatorRegistry::ALL;
|
||||
|
||||
// ── اثرها ───────────────────────────────────────────────────────────────
|
||||
public const EFFECT_FORBID = 'forbid';
|
||||
public const EFFECT_REQUIRE_RESOURCE = 'require_resource';
|
||||
public const EFFECT_MIN_DURATION = 'min_duration_minutes';
|
||||
public const EFFECT_ADD_DURATION = 'add_duration_minutes';
|
||||
public const EFFECT_MIN_DAYS_BETWEEN = 'min_days_between';
|
||||
public const EFFECT_DISCOUNT_PERCENT = 'discount_percent';
|
||||
public const EFFECT_DISCOUNT_RIALS = 'discount_rials';
|
||||
public const EFFECT_REQUIRE_FLAG = 'require_flag';
|
||||
|
||||
/** اثرهای مجاز per دسته — اثر ناسازگار با دسته پذیرفته نمیشود. */
|
||||
public const EFFECTS = [
|
||||
Policy::CATEGORY_SELECTION => [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<string, mixed> */
|
||||
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<string> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Simulation;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Entity\PolicySimulationRun;
|
||||
use App\Policy\Repository\PolicySimulationRunRepository;
|
||||
use App\Policy\Service\PolicyResolver;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
use App\Policy\ValueObject\PolicyOutcome;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* اجرای آزمایشی یک قانون روی نوبتهای واقعیِ گذشته — بدون نوشتن هیچ چیز.
|
||||
*
|
||||
* ## تضمین «چیزی ثبت نمیشود»، سه لایه
|
||||
*
|
||||
* ۱. ارزیابی روی **حقایق** انجام میشود نه روی entity؛ هیچ entity ای تغییر نمیکند.
|
||||
* ۲. کل اجرا داخل تراکنشی است که در `finally` **همیشه** rollback و `clear` میشود —
|
||||
* حتی اگر روزی کسی سهواً یک `flush` اضافه کند.
|
||||
* ۳. `PolicySimulationRunTest` تعداد ردیف جدولهای حساس را قبل و بعد میشمارد.
|
||||
*
|
||||
* ثبت خودِ `PolicySimulationRun` **بعد** از این بلوک و در تراکنش خودش انجام میشود.
|
||||
*/
|
||||
final class PolicySimulator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SimulationSampler $sampler,
|
||||
private readonly SimulationFacts $facts,
|
||||
private readonly PolicyResolver $policies,
|
||||
private readonly PolicySimulationRunRepository $runs,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function simulate(Policy $policy, int $size = SimulationSampler::DEFAULT_SIZE, ?User $runBy = null): PolicySimulationRun
|
||||
{
|
||||
$this->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<array<string, mixed>>, 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<string, mixed>|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<string, mixed> $base
|
||||
* @return array<string, mixed>|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<string, mixed> $base
|
||||
* @return array<string, mixed>|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<string, mixed> $base
|
||||
* @param array<int, mixed> $values
|
||||
* @return array<string, mixed>|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<string, mixed> $base
|
||||
* @return array<string, mixed>|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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Simulation;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\UserProfile\Entity\UserProfile;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* حقایق یک نوبتِ ثبتشده، به همان شکلی که نقاط اجرای زنده میسازند.
|
||||
*
|
||||
* اگر این کلاس حقیقتی را طور دیگری بسازد، آزمایش دروغ میگوید — و آزمایشی که دروغ
|
||||
* بگوید بدتر از نداشتن آزمایش است. به همین دلیل نامها عیناً از
|
||||
* {@see \App\Policy\Service\FieldRegistry} میآیند.
|
||||
*/
|
||||
final class SimulationFacts
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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<string> */
|
||||
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<string>, 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)));
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Simulation;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Policy\Entity\Policy;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* نمونهٔ نوبتهای واقعیِ گذشته برای آزمایش یک قانون.
|
||||
*
|
||||
* نمونه به **دامنهٔ خود قانون** محدود میشود: قانون لیزر روی ۵۰ نوبت دندانپزشکی
|
||||
* «۰٪ تحت تأثیر» میدهد، و آن عدد گمراهکنندهتر از نداشتن گزارش است.
|
||||
*/
|
||||
final class SimulationSampler
|
||||
{
|
||||
public const DEFAULT_SIZE = 50;
|
||||
/** سقف نمونه — گزارش بزرگتر نه خوانده میشود نه در `report` جا میشود. */
|
||||
public const MAX_SIZE = 50;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/** @return Appointment[] جدیدترین اول */
|
||||
public function recentAppointments(Policy $policy, int $size = self::DEFAULT_SIZE): array
|
||||
{
|
||||
$size = max(1, min($size, self::MAX_SIZE));
|
||||
|
||||
|
||||
$qb = $this->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();
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Template;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* الگوهای آمادهٔ قانون — راهِ ۹۰٪ کاربران.
|
||||
*
|
||||
* کاربر غیرفنی نباید شرط خام بنویسد: الگو را انتخاب میکند، دو-سه مقدار پر میکند، و
|
||||
* `condition`/`effects` درست از همینجا ساخته میشود. حالت پیشرفته برای بقیه است.
|
||||
*
|
||||
* الگو **جایگزین** اعتبارسنجی نیست؛ خروجیاش هم از همان `ConditionEvaluator` رد میشود.
|
||||
*/
|
||||
final class PolicyTemplateRegistry
|
||||
{
|
||||
private const TEMPLATES = [
|
||||
'min_days_between_sessions' => [
|
||||
'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<array<string, mixed>> */
|
||||
public function describe(): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach (self::TEMPLATES as $key => $template) {
|
||||
$out[] = ['key' => $key] + $template;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $values
|
||||
* @return array{category: string, condition: array<string, mixed>, effects: list<array<string, mixed>>}
|
||||
*/
|
||||
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<string, mixed> $v
|
||||
* @return array{condition: array<string, mixed>, effects: list<array<string, mixed>>}
|
||||
*/
|
||||
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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\ValueObject;
|
||||
|
||||
/**
|
||||
* نتیجهٔ اعمال یک دسته قانون: اثرهای ترکیبشده + ردِ قانونهایی که اعمال شدند.
|
||||
*
|
||||
* `appliedPolicies` شناسه **و نسخه** را نگه میدارد. فقط شناسه کافی نیست: قانون فردا
|
||||
* نسخهٔ ۲ میگیرد و آنوقت «چرا این نوبت این قیمت را گرفت؟» جواب اشتباه میدهد.
|
||||
*/
|
||||
final readonly class PolicyOutcome
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $effects نوعِ اثر => مقدار ترکیبشده
|
||||
* @param list<array<string, mixed>> $appliedPolicies
|
||||
* @param list<string> $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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Report\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\Report\Service\PlanAccuracyReporter;
|
||||
use App\Report\Service\ResourceUtilizationReporter;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Event\Entity\DomainEventLog;
|
||||
use App\Shared\Event\Repository\DomainEventLogRepository;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Report')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class ReportController extends BaseController
|
||||
{
|
||||
/** بازهٔ بزرگتر از این، هم کند است هم عملاً خوانده نمیشود. */
|
||||
private const MAX_RANGE_DAYS = 90;
|
||||
|
||||
public function __construct(
|
||||
private readonly ResourceUtilizationReporter $utilization,
|
||||
private readonly PlanAccuracyReporter $accuracy,
|
||||
private readonly ClinicResourceRepository $resources,
|
||||
private readonly DomainEventLogRepository $events,
|
||||
private readonly BranchResolver $branches,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/reports/resource-utilization', name: 'report_resource_utilization', methods: ['GET'])]
|
||||
public function resourceUtilization(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
$branch = $request->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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Report\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* مدت پیشبینیشده در برابر مدت واقعی — تشخیص تعریف غلط بخشها.
|
||||
*
|
||||
* بند ۱۷ مستند، ریسک سوم: «کلینیک بخشهای نوبت را اشتباه تعریف کند → ظرفیت غلط حساب
|
||||
* میشود». سرویسی که یک ساعت پیشبینی شده ولی یکساعتونیم طول میکشد، هر روز نیم ساعت
|
||||
* از ظرفیت کلینیک را بیصدا میخورد و هیچ خطایی هم نمیدهد.
|
||||
*
|
||||
* مبنای «واقعی» فاصلهٔ ثبتشدهٔ اسلات است، نه ساعت ورود و خروج بیمار — چون آن دومی
|
||||
* جایی ثبت نمیشود و حدس زدنش بدتر از نداشتنش است.
|
||||
*/
|
||||
final class PlanAccuracyReporter
|
||||
{
|
||||
/** زیر این تعداد نمونه، میانگین معنا ندارد. */
|
||||
public const MIN_SAMPLE = 10;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>> مرتب بر اساس شدت انحراف
|
||||
*/
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Report\Service;
|
||||
|
||||
use App\Appointment\Availability\Entity\ResourceOccupancy;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Service\ResourceAvailabilityService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* بهرهوری منابع — تنها ابزاری که به کلینیک میگوید تعریف بخشهایش درست است یا نه.
|
||||
*
|
||||
* سه عدد، سه معنای متفاوت:
|
||||
*
|
||||
* | عدد | یعنی |
|
||||
* |---|---|
|
||||
* | `available_minutes` | منبع طبق تقویمش چقدر در دسترس بوده |
|
||||
* | `occupied_minutes` | چقدر **گرفته** شده — شامل آمادهسازی، تمیزکاری و بخشهای انتظار |
|
||||
* | `active_minutes` | چقدر واقعاً کار انجام شده — فقط بخشهایی که بیمار حاضر بوده |
|
||||
*
|
||||
* فاصلهٔ `occupied` و `active` همان چیزی است که تعریف غلط بخشها را لو میدهد: منبعی که
|
||||
* هشت ساعت اشغال بوده ولی دو ساعت کار کرده، یا بخشهای `passive` زیادی گرفته یا
|
||||
* زمانهای انتظارش اشتباه به او نسبت داده شده.
|
||||
*/
|
||||
final class ResourceUtilizationReporter
|
||||
{
|
||||
/** زیر این نسبت، ظرفیت عملاً هدر میرود. */
|
||||
public const WASTE_THRESHOLD = 0.3;
|
||||
|
||||
public function __construct(
|
||||
private readonly ResourceAvailabilityService $calendars,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param ClinicResource[] $resources
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed> */
|
||||
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<int, int>
|
||||
*/
|
||||
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<int, int>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Event\Command;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* پاکسازی رویدادهای **منتشرشدهٔ** قدیمی.
|
||||
*
|
||||
* ردیف منتشرنشده هرگز حذف نمیشود، حتی اگر سالخورده باشد: آن یک رویداد گمشده است و
|
||||
* حذفش یعنی پاک کردن مدرکِ همان گمشدن.
|
||||
*/
|
||||
#[AsCommand(name: 'app:events:prune', description: 'Delete published domain events older than a retention window.')]
|
||||
class PruneDomainEventsCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly Connection $connection)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->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;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Event\Command;
|
||||
|
||||
use App\Shared\Event\Entity\DomainEventLog;
|
||||
use App\Shared\Event\Repository\DomainEventLogRepository;
|
||||
use App\Shared\Event\Service\OutboxPublisher;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* اجرای دستیِ انتشار صندوق خروجی.
|
||||
*
|
||||
* منطقش در `OutboxPublisher` است چون زمانبند هم همان را هر دقیقه صدا میزند؛ این دستور
|
||||
* برای وقتی میماند که صف عقب افتاده و باید همین حالا تخلیه شود.
|
||||
*/
|
||||
#[AsCommand(name: 'app:events:publish', description: 'Publish pending domain events from the outbox.')]
|
||||
class PublishDomainEventsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OutboxPublisher $publisher,
|
||||
private readonly DomainEventLogRepository $events,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Event;
|
||||
|
||||
use App\Shared\Event\Entity\DomainEventLog;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* تنها نقطهٔ ثبت رویداد دامنه.
|
||||
*
|
||||
* `record()` عمداً **flush نمیکند**: ردیف رویداد باید در همان تراکنشی commit شود که
|
||||
* خودِ تغییر را انجام میدهد. اگر اینجا flush میکردیم، rollbackِ تراکنش اصلی رویدادی
|
||||
* را جا میگذاشت که هرگز اتفاق نیفتاده.
|
||||
*/
|
||||
final class DomainEventPublisher
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Event;
|
||||
|
||||
/**
|
||||
* فهرست بستهٔ نام رویدادها — بند ۱۶ مستند.
|
||||
*
|
||||
* نام رویداد قرارداد عمومی است: مصرفکننده روی رشته شرط میگذارد. تایپوی یک حرفی
|
||||
* یعنی رویدادی که هیچکس نمیشنود و هیچ خطایی هم نمیدهد، پس فهرست بسته است.
|
||||
*/
|
||||
final class DomainEvents
|
||||
{
|
||||
public const HOLD_CREATED = 'HoldCreated';
|
||||
public const APPOINTMENT_BOOKED = 'AppointmentBooked';
|
||||
public const APPOINTMENT_CANCELLED = 'AppointmentCancelled';
|
||||
public const APPOINTMENT_RESCHEDULED = 'AppointmentRescheduled';
|
||||
public const PATIENT_NO_SHOW = 'PatientNoShow';
|
||||
public const APPOINTMENT_COMPLETED = 'AppointmentCompleted';
|
||||
public const RESOURCE_BLOCKED = 'ResourceBlocked';
|
||||
public const RESOURCE_RELEASED = 'ResourceReleased';
|
||||
public const COURSE_STARTED = 'CourseStarted';
|
||||
public const COURSE_SESSION_COMPLETED = 'CourseSessionCompleted';
|
||||
public const COURSE_COMPLETED = 'CourseCompleted';
|
||||
public const PACKAGE_PURCHASED = 'PackagePurchased';
|
||||
public const CREDIT_CONSUMED = 'CreditConsumed';
|
||||
public const CREDIT_REFUNDED = 'CreditRefunded';
|
||||
|
||||
public const ALL = [
|
||||
self::HOLD_CREATED,
|
||||
self::APPOINTMENT_BOOKED,
|
||||
self::APPOINTMENT_CANCELLED,
|
||||
self::APPOINTMENT_RESCHEDULED,
|
||||
self::PATIENT_NO_SHOW,
|
||||
self::APPOINTMENT_COMPLETED,
|
||||
self::RESOURCE_BLOCKED,
|
||||
self::RESOURCE_RELEASED,
|
||||
self::COURSE_STARTED,
|
||||
self::COURSE_SESSION_COMPLETED,
|
||||
self::COURSE_COMPLETED,
|
||||
self::PACKAGE_PURCHASED,
|
||||
self::CREDIT_CONSUMED,
|
||||
self::CREDIT_REFUNDED,
|
||||
];
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Event\Entity;
|
||||
|
||||
use App\Shared\Event\Repository\DomainEventLogRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* صندوق خروجی رویدادهای دامنه (outbox).
|
||||
*
|
||||
* ردیف رویداد **در همان تراکنشی** نوشته میشود که خودِ تغییر را انجام میدهد، و یک
|
||||
* worker بعداً منتشرش میکند. بدون این الگو دو حالت شکست ممکن است:
|
||||
*
|
||||
* | حالت | نتیجه |
|
||||
* |---|---|
|
||||
* | انتشار پیش از commit، بعد rollback | پیامک رفته، نوبتی وجود ندارد |
|
||||
* | commit موفق، انتشار شکست خورد | نوبت هست، هیچکس مطلع نشد |
|
||||
*
|
||||
* با outbox حداکثر **تأخیر** داریم، هرگز گمشدن.
|
||||
*
|
||||
* این جدول با `AppointmentEvent` موجود اشتباه نشود: آن تاریخچهٔ وضعیت یک نوبت است،
|
||||
* این اعلان تغییر به بیرونِ دامنه.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DomainEventLogRepository::class)]
|
||||
#[ORM\Table(name: 'domain_events')]
|
||||
#[ORM\Index(columns: ['published_at', 'occurred_at'], name: 'idx_de_pending')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'occurred_at'], name: 'idx_de_tenant')]
|
||||
#[ORM\Index(columns: ['name', 'occurred_at'], name: 'idx_de_name')]
|
||||
class DomainEventLog
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
/** سقف تلاش — ردیف مرده با خطایش میماند تا دیده شود، حذف نمیشود. */
|
||||
public const MAX_ATTEMPTS = 5;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'bigint')]
|
||||
private ?string $id = null;
|
||||
|
||||
/** شناسهٔ idempotency برای مصرفکننده. */
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 60)]
|
||||
private string $name;
|
||||
|
||||
/** @var array<string, scalar|null> */
|
||||
#[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<string, mixed> $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<string, mixed> $payload
|
||||
* @return array<string, scalar|null>
|
||||
*/
|
||||
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<string, mixed> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Event\Message;
|
||||
|
||||
/**
|
||||
* پیام async یک رویداد دامنه.
|
||||
*
|
||||
* `uuid` شناسهٔ idempotency است: messenger ممکن است پیام را دوباره تحویل بدهد، و
|
||||
* **مصرفکننده** باید تکراری را تشخیص بدهد — نه اینکه رویداد تضمین یکتایی بدهد.
|
||||
*/
|
||||
final readonly class DomainEventMessage
|
||||
{
|
||||
/** @param array<string, scalar|null> $payload */
|
||||
public function __construct(
|
||||
public string $uuid,
|
||||
public string $name,
|
||||
public string $entityType,
|
||||
public int $entityId,
|
||||
public array $payload,
|
||||
public int $occurredAt,
|
||||
) {}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Event\Message;
|
||||
|
||||
/**
|
||||
* پیام نشانهای که زمانبند هر دقیقه میفرستد تا صندوق خروجی تخلیه شود.
|
||||
*
|
||||
* خودش داده ندارد: «چه چیزی منتشر شود» را `OutboxPublisher` از جدول میخواند، نه از
|
||||
* پیام — وگرنه رویدادی که بین دو تیکِ زمانبند ثبت شده جا میماند.
|
||||
*/
|
||||
final class PublishDomainEventsMessage
|
||||
{
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Event\MessageHandler;
|
||||
|
||||
use App\Shared\Event\Message\DomainEventMessage;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* درزِ اتصال مصرفکنندهها.
|
||||
*
|
||||
* خودش کاری جز ثبت لاگ نمیکند و **نباید بکند**: پیامک، حسابداری و گزارش هر کدام
|
||||
* مصرفکنندهٔ خودشان را کنار این ثبت میکنند. وجودش لازم است چون messenger پیامِ
|
||||
* بدون handler را خطا میدهد، و آن خطا در صندوق خروجی بهعنوان «شکست انتشار» ثبت
|
||||
* میشد — یعنی یک ایراد پیکربندی، شبیه یک رویداد گمشده به نظر میرسید.
|
||||
*
|
||||
* مصرفکنندهٔ تازه باید **idempotent** باشد: messenger ممکن است پیام را دوباره تحویل
|
||||
* بدهد و `DomainEventMessage::$uuid` همان شناسهای است که با آن تکراری را میشناسد.
|
||||
*/
|
||||
#[AsMessageHandler]
|
||||
final class DomainEventHandler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function __invoke(DomainEventMessage $message): void
|
||||
{
|
||||
$this->logger->info('domain event published', [
|
||||
'uuid' => $message->uuid,
|
||||
'name' => $message->name,
|
||||
'entity_type' => $message->entityType,
|
||||
'entity_id' => $message->entityId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Event\MessageHandler;
|
||||
|
||||
use App\Shared\Event\Message\PublishDomainEventsMessage;
|
||||
use App\Shared\Event\Service\OutboxPublisher;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
final class PublishDomainEventsHandler
|
||||
{
|
||||
public function __construct(private readonly OutboxPublisher $publisher) {}
|
||||
|
||||
public function __invoke(PublishDomainEventsMessage $message): void
|
||||
{
|
||||
$this->publisher->publish();
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Event\Repository;
|
||||
|
||||
use App\Shared\Event\Entity\DomainEventLog;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<DomainEventLog> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Event\Service;
|
||||
|
||||
use App\Shared\Event\Message\DomainEventMessage;
|
||||
use App\Shared\Event\Repository\DomainEventLogRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* تخلیهٔ صندوق خروجی — ردیفهای `published_at IS NULL` به messenger میروند.
|
||||
*
|
||||
* منطق اینجاست نه در Command، چون دو فراخوان دارد: دستور دستی برای وقتی که صف عقب
|
||||
* افتاده، و زمانبند برای اجرای همیشگی. اگر در Command میماند، زمانبند مجبور بود
|
||||
* پروسهٔ کنسول اجرا کند و خطاهایش را از exit code حدس بزند.
|
||||
*/
|
||||
final class OutboxPublisher
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DomainEventLogRepository $events,
|
||||
private readonly MessageBusInterface $bus,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* شکستِ یک ردیف بقیه را متوقف نمیکند؛ `attempts` بالا میرود و خطا روی خودِ ردیف
|
||||
* مینشیند تا بعد از سقف تلاش، با دلیلش قابل دیدن بماند.
|
||||
*
|
||||
* @return array{published: int, failed: int}
|
||||
*/
|
||||
public function publish(int $limit = 100): array
|
||||
{
|
||||
$pending = $this->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];
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\Command;
|
||||
|
||||
use App\Waitlist\Service\WaitlistExpirer;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* اجرای دستیِ انقضای لیست انتظار؛ زمانبند هر روز همین کار را میکند.
|
||||
*/
|
||||
#[AsCommand(name: 'app:waitlist:expire', description: 'Close waitlist entries whose desired window has passed.')]
|
||||
class ExpireWaitlistCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly WaitlistExpirer $expirer)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$count = $this->expirer->expire();
|
||||
|
||||
$io->success(sprintf('%d ردیف لیست انتظار منقضی شد.', $count));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use App\Waitlist\Entity\WaitlistEntry;
|
||||
use App\Waitlist\Repository\WaitlistEntryRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Waitlist')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class WaitlistController extends BaseController
|
||||
{
|
||||
/** همان افق رزرو تسک ۱۲؛ بازهٔ بلندتر یعنی ردیفی که هرگز خودش را پاک نمیکند. */
|
||||
private const MAX_RANGE_DAYS = 90;
|
||||
|
||||
public function __construct(
|
||||
private readonly WaitlistEntryRepository $entries,
|
||||
private readonly ServiceItemRepository $items,
|
||||
private readonly PatientRecordRepository $patients,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/waitlist', name: 'waitlist_index', methods: ['GET'])]
|
||||
public function index(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\Entity;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use App\Waitlist\Repository\WaitlistEntryRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* «اگر وقتی در این بازه آزاد شد، خبرم کن.»
|
||||
*
|
||||
* توسعهٔ همان ایدهٔ `Appointment.is_reserve` موجود، ولی با بازهٔ صریح و وضعیت — تا
|
||||
* بشود گفت چه کسی، برای چه، در چه بازهای منتظر است.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: WaitlistEntryRepository::class)]
|
||||
#[ORM\Table(name: 'waitlist_entries')]
|
||||
#[ORM\Index(columns: ['service_item_id', 'branch_id', 'status', 'desired_from', 'desired_to'], name: 'idx_waitlist_match')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status', 'created_at'], name: 'idx_waitlist_tenant')]
|
||||
#[ORM\Index(columns: ['patient_record_id', 'status'], name: 'idx_waitlist_patient')]
|
||||
class WaitlistEntry
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const STATUS_WAITING = 'waiting';
|
||||
public const STATUS_NOTIFIED = 'notified';
|
||||
public const STATUS_CONVERTED = 'converted';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
/** سقف اطلاعرسانی — بدون آن، یک بازهٔ پرلغو به منبع اسپم تبدیل میشود. */
|
||||
public const MAX_NOTIFICATIONS = 3;
|
||||
|
||||
/**
|
||||
* بخشهای روز — فهرست **بسته**، با مرز ساعت محلیِ شعبه.
|
||||
*
|
||||
* مرزها اینجاست نه در UI: «عصر» باید در تطبیق و در نمایش یک معنا داشته باشد،
|
||||
* وگرنه بیمار برای ساعتی خبر میشود که خودش رد کرده بود. `[start, end)` است تا
|
||||
* ساعت ۱۲ دقیقاً یکبار شمرده شود.
|
||||
*
|
||||
* @var array<string, array{label: string, from: int, to: int}>
|
||||
*/
|
||||
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<string>|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<string> $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<string, mixed> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\Message;
|
||||
|
||||
/**
|
||||
* پیام نشانهای روزانه — «چه ردیفهایی منقضیاند» از جدول خوانده میشود، نه از پیام.
|
||||
*/
|
||||
final class ExpireWaitlistMessage
|
||||
{
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\MessageHandler;
|
||||
|
||||
use App\Waitlist\Message\ExpireWaitlistMessage;
|
||||
use App\Waitlist\Service\WaitlistExpirer;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
final class ExpireWaitlistHandler
|
||||
{
|
||||
public function __construct(private readonly WaitlistExpirer $expirer) {}
|
||||
|
||||
public function __invoke(ExpireWaitlistMessage $message): void
|
||||
{
|
||||
$this->expirer->expire();
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\MessageHandler;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Event\Message\DomainEventMessage;
|
||||
use App\Waitlist\Service\WaitlistConverter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* وقتی بیمار بالاخره رزرو کرد، ردیف انتظارش `converted` میشود.
|
||||
*
|
||||
* از رویداد میآید نه از خودِ `BookingService`: تبدیل، اثر جانبیِ رزرو است نه بخشی از
|
||||
* آن، و اگر داخل تراکنش رزرو مینشست یک خطای لیست انتظار میتوانست نوبت واقعی بیمار
|
||||
* را برگرداند.
|
||||
*
|
||||
* **idempotent** است: `WaitlistConverter` ردیفِ از قبل تبدیلشده را رد میکند، پس تحویل
|
||||
* دوبارهٔ پیام چیزی را خراب نمیکند.
|
||||
*/
|
||||
#[AsMessageHandler]
|
||||
final class WaitlistConversionHandler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WaitlistConverter $converter,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function __invoke(DomainEventMessage $message): void
|
||||
{
|
||||
if ($message->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\Repository;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Waitlist\Entity\WaitlistEntry;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<WaitlistEntry> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Waitlist\Entity\WaitlistEntry;
|
||||
use App\Waitlist\Repository\WaitlistEntryRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* بستن ردیف انتظار وقتی همان بیمار همان سرویس را رزرو کرد.
|
||||
*
|
||||
* تطبیق عمداً **تنگ** است: همان بیمار، همان سرویس، و زمان نوبت داخل بازهٔ خواستهشده.
|
||||
* تطبیق شل («هر انتظاری از این بیمار») ردیفی را میبندد که برای خدمت دیگری بود و بیمار
|
||||
* هنوز منتظرش است — و او دیگر هرگز خبر نمیشود.
|
||||
*/
|
||||
final class WaitlistConverter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WaitlistEntryRepository $entries,
|
||||
private readonly PatientRecordRepository $patients,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return int تعداد ردیفهایی که بسته شد
|
||||
*/
|
||||
public function convertFor(Appointment $appointment): int
|
||||
{
|
||||
$service = $appointment->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;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\Service;
|
||||
|
||||
use App\Waitlist\Entity\WaitlistEntry;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* بستن ردیفهایی که بازهٔ خواستهشدهشان گذشته است.
|
||||
*
|
||||
* ردیف منقضی از قبل هم در تطبیق نمیآمد (`desiredTo >= 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Waitlist\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Doctor\Repository\DoctorAddressRepository;
|
||||
use App\Representation\Service\JalaliDateService;
|
||||
use App\Sms\Service\SmsService;
|
||||
use App\Waitlist\Entity\WaitlistEntry;
|
||||
use App\Waitlist\Repository\WaitlistEntryRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* اطلاعرسانی به لیست انتظار وقتی ظرفیتی آزاد میشود.
|
||||
*
|
||||
* ## چرا broadcast و نه صف انحصاری
|
||||
*
|
||||
* ظرفیت آزادشده به حداکثر ده نفر خبر داده میشود و **اولین رزروکننده میبرد**. صف
|
||||
* انحصاری («فقط نفر اول ۳۰ دقیقه فرصت دارد») روی کاغذ عادلانهتر است، ولی در عمل
|
||||
* یعنی وقتی که کسی جوابش را نمیدهد نیم ساعت قفل بماند و بعد به نفر دوم برسد — و
|
||||
* ظرفیت آزادشدهٔ دو ساعت مانده به نوبت، نیم ساعت وقت تلفکردنی ندارد.
|
||||
*
|
||||
* در عوض، متن پیامک **اجباراً** این را میگوید تا کسی احساس نکند وعدهای شکسته شده.
|
||||
*/
|
||||
final class WaitlistNotifier
|
||||
{
|
||||
public const MAX_RECIPIENTS = 10;
|
||||
|
||||
public function __construct(
|
||||
private readonly WaitlistEntryRepository $entries,
|
||||
private readonly DoctorAddressRepository $addresses,
|
||||
private readonly SmsService $sms,
|
||||
private readonly JalaliDateService $jalali,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return int تعداد کسانی که خبر شدند
|
||||
*/
|
||||
public function notifyForFreedSlot(Appointment $appointment, ?int $now = null): int
|
||||
{
|
||||
$service = $appointment->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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,548 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Cancellation;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Settlement\Service\WalletService;
|
||||
use App\Tag\Entity\TenantTag;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* سیاست لغو، جریمه و عدم حضور — تسک ۱۳.
|
||||
*
|
||||
* دو قاعده که شکستنشان گران است: لغو توسط کلینیک هرگز جریمه ندارد، و جریمه هرگز از
|
||||
* مبلغ پرداختی بیشتر نمیشود.
|
||||
*/
|
||||
class CancellationTest extends ApiTestCase
|
||||
{
|
||||
private int $slotCursor = 0;
|
||||
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
|
||||
private function clinicWithPatient(): array
|
||||
{
|
||||
$user = $this->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<string, mixed> $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());
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
namespace App\Tests\Course;
|
||||
use App\Clinic\Entity\Clinic; use App\ClinicService\Entity\ServiceItem; use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress; use App\Patient\Entity\PatientRecord; use App\Tests\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
#[Group('docs')]
|
||||
class CourseDocsCaptureTest extends ApiTestCase {
|
||||
public function testCapture(): void {
|
||||
if (getenv('COURSE_DOCS') !== '1') { self::markTestSkipped('برای تولید خروجی مستندات: COURSE_DOCS=1'); }
|
||||
$user = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -1,669 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Course;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Course\Repository\CourseSessionRepository;
|
||||
use App\Course\Repository\TreatmentCourseRepository;
|
||||
use App\Course\Service\CourseSessionLinker;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* دورهٔ درمان — تسک ۱۲.
|
||||
*
|
||||
* «لیزر معمولاً شش تا هشت جلسه است»؛ طراحی قبلی فقط نوبت تکی میشناخت. تستها روی سه
|
||||
* چیز تمرکز دارند: snapshot پروتکل، لنگر متحرک فاصلهها، و اینکه لغو یک جلسه بقیهٔ
|
||||
* دوره را خراب نکند.
|
||||
*/
|
||||
class TreatmentCourseTest extends ApiTestCase
|
||||
{
|
||||
private int $slotCursor = 0;
|
||||
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
|
||||
private function clinicWithPatient(): array
|
||||
{
|
||||
$user = $this->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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
namespace App\Tests\Package;
|
||||
use App\Clinic\Entity\Clinic; use App\ClinicService\Entity\ServiceItem; use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress; use App\Patient\Entity\PatientRecord; use App\Tests\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
#[Group('docs')]
|
||||
class PackageDocsCaptureTest extends ApiTestCase {
|
||||
public function testCapture(): void {
|
||||
if (getenv('PKG_DOCS') !== '1') { self::markTestSkipped('برای تولید خروجی مستندات: PKG_DOCS=1'); }
|
||||
$user = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -1,560 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Package;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Package\Entity\SessionCreditLedger;
|
||||
use App\Package\Repository\PatientPackageRepository;
|
||||
use App\Package\Service\CreditLedgerService;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* پکیج و دفتر اعتبار جلسات — تسک ۱۱.
|
||||
*
|
||||
* محور همهٔ تستها یک جمله از مستند است: «اعتبار را به صورت دفتر حساب نگه میداریم،
|
||||
* نه یک عدد شمارنده.» پس مانده هیچجا ذخیره نمیشود و هر تغییر یک ردیف است.
|
||||
*/
|
||||
class PackageLedgerTest extends ApiTestCase
|
||||
{
|
||||
private int $slotCursor = 0;
|
||||
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
|
||||
private function clinicWithPatient(): array
|
||||
{
|
||||
$user = $this->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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
|
||||
/**
|
||||
* خروجی واقعیِ اندپوینتهای قانون برای `docs/api/policy.md`.
|
||||
*
|
||||
* جدا از تستهای رفتاری است و در گروه `docs` میماند تا در اجرای عادی نویز نسازد.
|
||||
*/
|
||||
#[Group('docs')]
|
||||
class DocsCaptureTest extends ApiTestCase
|
||||
{
|
||||
public function testCapture(): void
|
||||
{
|
||||
// بهصورت پیشفرض رد میشود: کارش تولید خروجی برای مستندات است، نه ادعای رفتار.
|
||||
if (getenv('POLICY_DOCS') !== '1') {
|
||||
self::markTestSkipped('برای تولید خروجی مستندات: POLICY_DOCS=1');
|
||||
}
|
||||
|
||||
$user = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* بدون هیچ قانونی، خروجیها باید **دقیقاً** همان تسک ۰۸ باشند.
|
||||
*
|
||||
* موتور قوانین یک لایهٔ افزودنی است نه بازنویسی: کلینیکی که هیچ قانونی ننوشته نباید
|
||||
* هیچ تفاوتی حس کند. این تست همان تضمین را میگیرد — و چون همهٔ قلابها در مسیر داغ
|
||||
* نشستهاند، شکستنش یعنی یک اثرِ پیشفرضِ ناخواسته وارد محاسبه شده.
|
||||
*/
|
||||
class NoPolicyRegressionTest extends ApiTestCase
|
||||
{
|
||||
public function testWithoutAnyPolicyThePlanAndQuoteAreUnchanged(): void
|
||||
{
|
||||
$user = $this->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']);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
use App\Policy\Service\OperatorRegistry;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* یازده عملگر، هر کدام روی هر دو نتیجه.
|
||||
*
|
||||
* عملگری که غلط بسنجد قانونی میسازد که یا همیشه میگیرد یا هرگز — و هیچکدام خطا
|
||||
* نمیدهند. تست واحد است نه یکپارچه، چون خودِ مقایسه تابعی خالص است.
|
||||
*/
|
||||
class OperatorRegistryTest extends TestCase
|
||||
{
|
||||
private const NOW = 1_800_000_000;
|
||||
|
||||
#[DataProvider('cases')]
|
||||
public function testOperatorDecides(string $op, mixed $actual, mixed $expected, bool $result): void
|
||||
{
|
||||
self::assertSame(
|
||||
$result,
|
||||
(new OperatorRegistry())->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'));
|
||||
}
|
||||
}
|
||||
@@ -1,617 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* موتور قوانین ششدستهای — بند ۸ مستند.
|
||||
*
|
||||
* تأکید تستها روی سه چیز است که خرابیشان بیصداست: ترکیب اثرها (max/sum/veto)،
|
||||
* ترتیب حل تناقض (اولویت ← اختصاصیبودن ← قدمت)، و نسخهپذیری (قانون ویرایش
|
||||
* نمیشود، نسخهٔ تازه میگیرد).
|
||||
*/
|
||||
class PolicyEngineTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress} */
|
||||
private function clinicWithBranch(): array
|
||||
{
|
||||
$user = $this->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<string, mixed> $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<string, mixed> $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<string, mixed> $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']);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Service\FieldRegistry;
|
||||
use App\Policy\Service\OperatorRegistry;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* هر فیلدی که schema تبلیغ میکند، باید جایی در کد **واقعاً پر شود**.
|
||||
*
|
||||
* خطر مشخص است و بیصداست: قانونی که روی فیلدی شرط بگذارد که هیچ فراخوانی آن را
|
||||
* نمیفرستد، هرگز مطابقت نمیکند و هیچ خطایی هم نمیدهد. اپراتور قانون را میسازد،
|
||||
* فعالش میکند، و تا ابد فکر میکند دارد کار میکند.
|
||||
*
|
||||
* این تست عمداً ساختاری است نه رفتاری: پیمایشِ همهٔ مسیرهای واقعی برای هر فیلد،
|
||||
* دستگاه تستی میخواست بزرگتر از خودِ موتور.
|
||||
*/
|
||||
class PolicyFieldCoverageTest extends TestCase
|
||||
{
|
||||
public function testEveryAdvertisedFieldIsSuppliedSomewhereInTheCode(): void
|
||||
{
|
||||
$registry = new FieldRegistry(new OperatorRegistry());
|
||||
$fields = [];
|
||||
|
||||
foreach (Policy::CATEGORIES as $category) {
|
||||
foreach ($registry->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<string, string> مسیر => محتوا */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* آزمایشگاه قانون — تسک ۱۰.
|
||||
*
|
||||
* مهمترین تستِ این فایل `testSimulationWritesNothingButItsOwnRun` است: هر بار که کسی
|
||||
* `PolicySimulator` را عوض کند، همان تست جلوی نوشتنِ ناخواسته را میگیرد.
|
||||
*/
|
||||
class PolicySimulationTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor} */
|
||||
private function clinicWithBranch(): array
|
||||
{
|
||||
$user = $this->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<string, mixed> $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<string, mixed> $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());
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Report;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Event\Entity\DomainEventLog;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* دو رویدادی که مسیرشان از تسکهای قدیمیتر میآید: «نوبت انجام شد» و «نوبت جابهجا شد».
|
||||
*
|
||||
* هر دو از مسیرِ وضعیتِ موجود عبور میکنند، پس چیزی که این تستها نگه میدارند این است
|
||||
* که رویداد **بعد از ذخیرهٔ موفق** ثبت شود — نه هنگام درخواستِ تغییر وضعیت.
|
||||
*/
|
||||
class AppointmentLifecycleEventTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctor(): Doctor
|
||||
{
|
||||
$doctor = new Doctor($this->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());
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Report;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Shared\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Event\Entity\DomainEventLog;
|
||||
use App\Shared\Event\Repository\DomainEventLogRepository;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* رویدادهای دامنه و صندوق خروجی — تسک ۱۴.
|
||||
*
|
||||
* دو تضمین که کل الگو برایشان وجود دارد: رویداد **بعد از** commit منتشر میشود، و
|
||||
* هیچ رویدادی گم نمیشود.
|
||||
*/
|
||||
class DomainEventTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: PatientRecord} */
|
||||
private function clinic(): array
|
||||
{
|
||||
$user = $this->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());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user