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),
);
}
}