Remove ReportTest and WaitlistTest files as part of codebase cleanup

This commit is contained in:
hamed
2026-08-01 20:16:41 +03:30
parent 4711ba0af7
commit 65d5831c64
102 changed files with 0 additions and 13506 deletions
@@ -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,
];
}
}
-80
View File
@@ -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(),
'سیاست لغو: اعتبار این جلسه برنمی‌گردد',
);
}
}
-100
View File
@@ -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;
}
}
-180
View File
@@ -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,
];
}
}
-70
View File
@@ -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,
];
}
}
-142
View File
@@ -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,
];
}
}
-243
View File
@@ -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();
}
}
}
-141
View File
@@ -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(),
];
}
}
-138
View File
@@ -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;
}
}
-106
View File
@@ -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();
}
}
-95
View File
@@ -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;
}
}
-142
View File
@@ -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,
];
}
}
-43
View File
@@ -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; }
}
-123
View File
@@ -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,
];
}
}
-140
View File
@@ -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]);
}
}
-174
View File
@@ -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;
}
}
-288
View File
@@ -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;
}
}
-59
View File
@@ -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);
}
}
-14
View File
@@ -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;
}
}
-14
View File
@@ -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;
}
}
-14
View File
@@ -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;
}
}
-236
View File
@@ -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(),
];
}
}
-137
View File
@@ -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;
}
}
-61
View File
@@ -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();
}
}
-232
View File
@@ -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;
}
}
-187
View File
@@ -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',
);
}
}
}
}
-158
View File
@@ -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);
}
}
-176
View File
@@ -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;
}
}
-178
View File
@@ -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);
}
}
-138
View File
@@ -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);
}
}
-232
View File
@@ -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),
];
}
}
-119
View File
@@ -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'),
};
}
}
-43
View File
@@ -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,
];
}
}
-126
View File
@@ -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',
);
}
}
-125
View File
@@ -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);
}
}
-49
View File
@@ -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;
}
}
-44
View File
@@ -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,
];
}
-131
View File
@@ -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;
}
}
-252
View File
@@ -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;
}
}
-39
View File
@@ -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],
);
}
}
-113
View File
@@ -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),
);
}
}
-548
View File
@@ -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());
}
}
-34
View File
@@ -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);
}
}
-669
View File
@@ -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);
}
}
}
-32
View File
@@ -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);
}
}
-560
View File
@@ -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);
}
}
-97
View File
@@ -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);
}
}
-72
View File
@@ -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']);
}
}
-77
View File
@@ -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'));
}
}
-617
View File
@@ -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']);
}
}
-78
View File
@@ -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;
}
}
-483
View File
@@ -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());
}
}
-249
View File
@@ -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