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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user