feat(package): session packages backed by a credit ledger

"Six laser sessions" is the common case in an aesthetics clinic: the patient
pays once and books the sessions later.

Credit is a ledger, not a counter. No table has a remaining/used_count column
and a schema test enforces that — the balance is always SUM(delta) over
append-only rows, so every number a patient sees has a full history behind it.
Corrections are new rows, never edits.

- purchase / consume / refund / adjustment / expiry, each with a reason, an
  author and the appointment it belongs to
- consume happens in confirm(), never in quote(): if the preview consumed, a
  page refresh would cost the patient a session
- cancelling adds a refund row; the consume row stays
- FIFO across a patient's packages — the oldest is closest to expiring
- an empty package is not an error, it just does not apply and the patient pays
- adjust/expire need a doctor or clinic role, and adjust always needs a reason
- app:package:expire writes the closing row so "where did my 3 sessions go?"
  always has an answer

Consume takes a pessimistic lock on the one package row. That is the opposite
of task 07's slot buckets, and docs/api/package.md carries the table explaining
why, so nobody unifies them later.

Idempotency checks for an existing consume row before inserting rather than
catching the unique violation: in Doctrine that exception closes the
EntityManager and burns the rest of the request. The unique key stays as the
last line of defence.

Admin: PackagesPage, a packages tab on the patient record, and a ledger page
whose running-balance column shows where the final number came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 11:11:03 +03:30
co-authored by Claude Opus 5
parent d6294242b7
commit ca9648732d
35 changed files with 3205 additions and 78 deletions
@@ -11,6 +11,7 @@ use App\Auth\Repository\UserRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Pricing\Entity\PriceSnapshot;
use App\Pricing\Service\PriceSnapshotService;
use App\Package\Service\PackageConsumptionService;
use App\Policy\Service\BookingPolicyGuard;
use App\Pricing\Service\PricingEngine;
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
@@ -52,6 +53,7 @@ class BookingController extends BaseController
private readonly PriceSnapshotService $snapshots,
private readonly BranchResolver $branches,
private readonly BookingPolicyGuard $guard,
private readonly PackageConsumptionService $packages,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
) {}
@@ -258,6 +260,8 @@ class BookingController extends BaseController
$address,
$hold->getStartsAt(),
is_array($data['policy'] ?? null) ? $data['policy'] : [],
// پروندهٔ بیمار در همین محیط — پکیج کلینیک الف در کلینیک ب معنا ندارد.
$this->packages->patientRecordFor($appointment),
);
return $this->snapshots->record($appointment, $quote);
@@ -6,6 +6,8 @@ use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Booking\Entity\AppointmentHold;
use App\Appointment\Booking\Entity\AppointmentSegment;
use App\Appointment\Entity\Appointment;
use App\Package\Service\CreditLedgerService;
use App\Package\Service\PackageConsumptionService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityManagerInterface;
@@ -20,8 +22,10 @@ use Doctrine\ORM\EntityManagerInterface;
final class BookingService
{
public function __construct(
private readonly HoldService $holds,
private readonly EntityManagerInterface $em,
private readonly HoldService $holds,
private readonly PackageConsumptionService $packages,
private readonly CreditLedgerService $credits,
private readonly EntityManagerInterface $em,
) {}
/**
@@ -54,6 +58,10 @@ final class BookingService
$this->em->flush();
// مصرف اعتبار **اینجا**ست نه در پیش‌نمایش قیمت: تنها لحظه‌ای که نوبت واقعاً
// وجود دارد. کلید یکتای دفتر هم تضمین می‌کند اجرای دوباره جلسهٔ دوم نخورد.
$this->packages->consumeFor($appointment);
return $appointment;
}
@@ -96,6 +104,9 @@ final class BookingService
$this->holds->release($occupancies);
// ردیف `consume` **حذف نمی‌شود**؛ بازگشت یک ردیف تازه است تا تاریخچه بماند.
$this->credits->refund($appointment);
return count($occupancies);
}
@@ -0,0 +1,68 @@
<?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;
}
}
@@ -0,0 +1,206 @@
<?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;
}
}
@@ -0,0 +1,202 @@
<?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
@@ -0,0 +1,142 @@
<?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
@@ -0,0 +1,43 @@
<?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
@@ -0,0 +1,123 @@
<?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
@@ -0,0 +1,140 @@
<?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,
];
}
}
@@ -0,0 +1,50 @@
<?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();
}
}
}
@@ -0,0 +1,81 @@
<?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();
}
}
}
@@ -0,0 +1,46 @@
<?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]);
}
}
+133
View File
@@ -0,0 +1,133 @@
<?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 Doctrine\ORM\EntityManagerInterface;
use Doctrine\DBAL\LockMode;
/**
* تنها نویسندهٔ دفتر اعتبار.
*
* هیچ کلاس دیگری نباید در `session_credit_ledger` بنویسد؛ اگر بنویسد، قواعد این کلاس
* (مصرف یکتا per نوبت، مانده‌ای که منفی نمی‌شود) دور زده می‌شوند و دفتر همان چیزی
* می‌شود که قرار بود نباشد: عددی که کسی نمی‌داند از کجا آمده.
*/
final class CreditLedgerService
{
public function __construct(
private readonly SessionCreditLedgerRepository $ledger,
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
{
// قفل بدون تراکنش معنا ندارد؛ خواندن و نوشتن باید در یک واحد اتمی باشند
// وگرنه دو درخواست هم‌زمان هر دو ماندهٔ ۱ را می‌بینند.
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 است و اجرای دومش نباید
// جلسهٔ دوم بخورد. بررسی **پیش از** درج است نه گرفتنِ استثنا: نقض کلید
// یکتا در Doctrine خودِ EntityManager را می‌بندد و بقیهٔ همان request را
// هم می‌سوزاند. کلید یکتا آخرین خط دفاع می‌ماند، نه مسیر عادی.
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);
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,
);
return true;
}
/** @return SessionCreditLedger[] */
public function history(PatientPackage $package): array
{
return $this->ledger->historyFor($package);
}
}
@@ -0,0 +1,74 @@
<?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);
}
}
@@ -0,0 +1,59 @@
<?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\Exception\AppException;
/**
* فروش پکیج به بیمار.
*
* خرید و ردیف `purchase` یک عمل‌اند: پکیجی که بدون ردیف دفتر ثبت شود مانده‌اش صفر
* است و بیمار پولش را داده ولی چیزی نگرفته.
*/
final class PackageSalesService
{
public function __construct(
private readonly PatientPackageRepository $patientPackages,
private readonly CreditLedgerService $ledger,
) {}
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,
);
return $sold;
}
}
+23 -1
View File
@@ -12,6 +12,7 @@ use App\Pricing\Entity\PriceListItem;
use App\Pricing\Repository\PriceListItemRepository;
use App\Pricing\Repository\PriceListRepository;
use App\Pricing\Repository\PriceSnapshotRepository;
use App\Patient\Repository\PatientRecordRepository;
use App\Pricing\Service\PricingEngine;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
@@ -35,6 +36,7 @@ class PricingController extends BaseController
private readonly PriceSnapshotRepository $snapshots,
private readonly ServiceItemRepository $items,
private readonly PricingEngine $engine,
private readonly PatientRecordRepository $patients,
private readonly BranchResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
@@ -210,7 +212,27 @@ class PricingController extends BaseController
$at = is_numeric($data['at'] ?? null) ? (int) $data['at'] : time();
$policy = is_array($data['policy'] ?? null) ? $data['policy'] : [];
return $this->success($this->engine->quote($service, $items, $address, $at, $policy)->toArray());
// بیمار اختیاری است: بدون او پکیج معنا ندارد و قیمت همان قیمت کامل است.
$patient = is_string($data['patient_uuid'] ?? null)
? $this->requirePatient($user, $data['patient_uuid'])
: null;
return $this->success($this->engine->quote($service, $items, $address, $at, $policy, $patient)->toArray());
}
private function requirePatient(User $user, string $uuid): \App\Patient\Entity\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;
}
/**
+25
View File
@@ -6,6 +6,8 @@ use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceBranchOverrideRepository;
use App\ClinicService\Repository\TariffRepository;
use App\Doctor\Entity\DoctorAddress;
use App\Package\Service\PackageConsumptionService;
use App\Patient\Entity\PatientRecord;
use App\Pricing\Repository\PriceListItemRepository;
use App\Pricing\Repository\PriceListRepository;
use App\Pricing\ValueObject\PriceQuote;
@@ -37,6 +39,7 @@ final class PricingEngine
{
public function __construct(
private readonly PolicyResolver $policies,
private readonly PackageConsumptionService $packages,
private readonly PriceListRepository $priceLists,
private readonly PriceListItemRepository $priceListItems,
private readonly ServiceBranchOverrideRepository $overrides,
@@ -59,6 +62,7 @@ final class PricingEngine
DoctorAddress $address,
int $at,
array $policy = [],
?PatientRecord $patient = null,
): PriceQuote {
$entityType = $address->tenantEntityType();
$entityId = $address->tenantEntityId();
@@ -76,6 +80,17 @@ final class PricingEngine
$subtotal = $base + $itemsTotal;
// ── پکیج ──────────────────────────────────────────────────────────────
// پکیج **قیمت پایهٔ سرویس** را می‌پوشاند، نه آیتم‌های اضافه: «شش جلسه لیزر»
// یعنی شش بار خودِ لیزر، نه هر چیزی که کنارش انتخاب شود.
$usable = $patient === null ? null : $this->packages->firstUsable($patient, $service, $at);
$covered = 0;
if ($usable !== null) {
$covered = min($base, $subtotal);
$subtotal -= $covered;
}
// ── تخفیف ─────────────────────────────────────────────────────────────
// قوانین دستهٔ «قیمت» کنار سیاست دستیِ درخواست می‌نشینند، نه به‌جایش: تخفیفی
// که اپراتور دستی می‌دهد و تخفیفی که قانون می‌دهد هر دو واقعی‌اند.
@@ -110,6 +125,14 @@ final class PricingEngine
$deposit = max(0, min($deposit, $final));
if ($covered > 0) {
$discounts[] = [
'label' => sprintf('پوشش پکیج «%s»', $usable?->getPackage()->getName() ?? '—'),
'rials' => $covered,
'kind' => 'package',
];
}
return new PriceQuote(
baseRials: $base,
itemsRials: $itemsTotal,
@@ -121,6 +144,8 @@ final class PricingEngine
depositRials: $deposit,
discounts: $discounts,
sources: $sources,
packageWillBeConsumed: $usable !== null,
packageUuid: $usable?->getUuid(),
);
}
+8
View File
@@ -22,6 +22,12 @@ final readonly class PriceQuote
public int $depositRials,
public array $discounts = [],
public array $sources = [],
/**
* پکیج در پیش‌نمایش **مصرف نمی‌شود** — فقط اعلام می‌شود. مصرف واقعی هنگام
* ثبت نهایی است، وگرنه هر رفرش صفحه یک جلسه از بیمار می‌گرفت.
*/
public bool $packageWillBeConsumed = false,
public ?string $packageUuid = null,
) {}
public function breakdown(): array
@@ -40,6 +46,8 @@ final readonly class PriceQuote
'tax_rials' => $this->taxRials,
'final_rials' => $this->finalRials,
'deposit_rials' => $this->depositRials,
'package_will_be_consumed' => $this->packageWillBeConsumed,
'package_uuid' => $this->packageUuid,
'breakdown' => $this->breakdown(),
];
}
+3
View File
@@ -118,6 +118,9 @@ final class GlobalTables
\App\Inventory\Entity\InventoryPackageItem::class => \App\Inventory\Entity\InventoryPackage::class,
// سرویس‌های یک پکیج جزئی از تعریف همان پکیج‌اند، نه دادهٔ مستقل.
\App\Package\Entity\PackageService::class => \App\Package\Entity\Package::class,
\App\Insurance\Entity\TenantInsuranceCategoryCoverage::class => \App\Insurance\Entity\TenantInsurance::class,
\App\Insurance\Entity\TenantServiceCoverage::class => \App\Insurance\Entity\TenantInsurance::class,