Files
clinicpro/src/Patient/Service/PatientService.php
T
hamedandClaude Opus 5 6ab1eb6483 fix(tenant): scope the patient wallet ledger to the environment reading it
ownsRecord guards the patient record, not the rows underneath it, so
GET /api/v1/patient/{uuid}/wallet/transactions — and the recent_transactions
in the balance summary — returned the patient's entire history. Clinic A
could read what the patient paid at clinic B, down to the name of the staff
member who entered it.

The wallet stays the person's: the balance is still the sum of that user's
credits minus debits across every environment. Scoping it would show a
patient part of their own money and would make the running balance_after
meaningless. So this is attribution per row, not ownership per wallet.

The columns are deliberately named recorded_entity_type / recorded_entity_id
rather than entity_type / entity_id. TenantFilter keys on the latter and
would then scope the balance query too — the exact bug this avoids. The
naming is load-bearing, and both the entity and the architecture doc say so.

Rows that cannot be attributed — entered before this split, or outside any
environment such as a representation's commission — stay NULL and remain
visible everywhere; hiding them would make an existing patient's history
look deleted. The migration reports how many there are (0 in dev, all
attributable from payments and session references).

Consequence, documented in both docs/api/patient.md and the wallet tab: the
listed rows no longer sum to the displayed balance.

Removing the fix turns 3 of the 6 new tests red.

Tests: 902 backend (+6), 570 frontend. PHPStan unchanged at 17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:21:55 +03:30

713 lines
36 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Patient\Service;
use App\Appointment\Entity\Appointment;
use App\Appointment\Service\AppointmentInsuranceService;
use App\Auth\Repository\UserRepository;
use App\Billing\Service\BillingCalculator;
use App\Billing\ValueObject\Money;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\EntityInsurancePricingRepository;
use App\Insurance\Service\TenantInsuranceService;
use App\Inventory\Repository\InventoryItemRepository;
use App\Inventory\Repository\InventoryPackageRepository;
use App\Doctor\Repository\DoctorAddressRepository;
use App\Auth\Entity\User;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\PatientSession;
use App\Patient\Entity\SessionConsumable;
use App\Patient\Entity\SessionPayment;
use App\Patient\Entity\SessionService;
use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Patient\Repository\SessionConsumableRepository;
use App\Patient\Repository\SessionPaymentRepository;
use App\Patient\Repository\SessionServiceRepository;
use App\Settlement\Service\WalletService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Staff\Repository\ClinicStaffRepository;
use App\Subscription\Service\SubscriptionService;
use Psr\Log\LoggerInterface;
class PatientService
{
public function __construct(
private readonly PatientRecordRepository $recordRepo,
private readonly PatientSessionRepository $sessionRepo,
private readonly SessionServiceRepository $sessionServiceRepo,
private readonly SessionPaymentRepository $sessionPaymentRepo,
private readonly SessionConsumableRepository $sessionConsumableRepo,
private readonly ServiceItemRepository $serviceItemRepo,
private readonly InventoryItemRepository $inventoryItemRepo,
private readonly InventoryPackageRepository $inventoryPackageRepo,
private readonly ClinicStaffRepository $staffRepo,
private readonly UserRepository $userRepo,
private readonly SubscriptionService $subscriptionService,
private readonly DoctorAddressRepository $addressRepo,
private readonly ClinicRepository $clinicRepo,
private readonly TenantInsuranceService $tenantInsuranceService,
private readonly AppointmentInsuranceService $appointmentInsurance,
private readonly BillingCalculator $billingCalculator,
private readonly WalletService $walletService,
private readonly EntityInsurancePricingRepository $pricingRepo,
private readonly \App\Discount\Service\DiscountEngine $discountEngine,
private readonly \App\Patient\Repository\SessionAuditLogRepository $auditRepo,
private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership,
private readonly LoggerInterface $logger,
) {}
/** ثبت یک رکورد تاریخچه‌ی تغییر مالی/خدماتی روی مراجعه. */
public function logSessionChange(PatientSession $session, string $field, string $operation, ?string $old, ?string $new, ?User $actor, ?string $note = null): void
{
$log = new \App\Patient\Entity\SessionAuditLog($session, $field, $operation);
$log->setActor($actor?->getId(), $this->walletService->resolveActorName($actor));
$log->setValues($old, $new);
$log->setNote($note);
$this->auditRepo->save($log);
}
/** خلاصه‌ی خوانا از سرویس‌های یک مراجعه: «نام ×تعداد، ...». */
private function servicesSummary(PatientSession $session): string
{
$parts = array_map(
fn(SessionService $s) => ($s->toArray()['service_name'] ?? '?') . ' ×' . $s->getQuantity(),
$session->getServices()->toArray(),
);
return $parts === [] ? '—' : implode('، ', $parts);
}
private function consumablesSummary(PatientSession $session): string
{
$parts = array_map(
fn(SessionConsumable $c) => ($c->toArray()['item_name'] ?? '?') . ' ×' . $c->getQuantity(),
$session->getConsumables()->toArray(),
);
return $parts === [] ? '—' : implode('، ', $parts);
}
/**
* تفکیک سهم بیمه‌ها و سهم بیمار برای یک مراجعه.
*
* ویزیت خدمتِ سرپایی است و با قاعده‌ی قرارداد بیمه (coverageRule) محاسبه می‌شود؛ هر خدمت
* با قاعده‌ی پوشش نوعِ خودش (coverageRuleForService بر پایه‌ی service_category همان خدمت)
* محاسبه می‌شود — هر دو از طریق BillingCalculator، همان مسیری
* که InvoiceService برای صدور فاکتور استفاده می‌کند. تنها منبع محاسبه همین است تا مبلغ
* صفحه‌ی پرداخت و فاکتور نتوانند از هم واگرا شوند.
*
* @param array<array{item_id: int, price_rials: int}> $serviceItems قیمت کل هر ردیف (با احتساب تعداد)
* @param ?ServiceCategory $visitCategory نوع خدمتِ ویزیت؛ null → سرپایی
* @return array{services_total_rials: int, gross_total_rials: int, base_insurance_rials: int, supplementary_insurance_rials: int, patient_share_rials: int, final_price_rials: int}
*/
public function calculateFinalPrice(
int $visitPrice,
array $serviceItems,
string $entityType = 'doctor',
int $entityId = 0,
?int $baseInsuranceId = null,
?int $suppInsuranceId = null,
?ServiceCategory $visitCategory = null,
): array {
$visitCategory ??= ServiceCategory::Outpatient;
$baseShare = 0;
$suppShare = 0;
$patientShare = 0;
if ($visitPrice > 0) {
$visit = $this->billingCalculator->calculateItem(
new Money($visitPrice),
$this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseInsuranceId, $visitCategory),
$this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppInsuranceId, $visitCategory),
);
$baseShare += $visit->baseInsuranceRials;
$suppShare += $visit->supplementaryRials;
$patientShare += $visit->patientRials;
}
$servicesTotal = 0;
foreach ($serviceItems as $svc) {
$servicesTotal += $svc['price_rials'];
$line = $this->billingCalculator->calculateItem(
new Money($svc['price_rials']),
$this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $baseInsuranceId, $svc['item_id']),
$this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $suppInsuranceId, $svc['item_id']),
);
$baseShare += $line->baseInsuranceRials;
$suppShare += $line->supplementaryRials;
$patientShare += $line->patientRials;
}
return [
'services_total_rials' => $servicesTotal,
'gross_total_rials' => $visitPrice + $servicesTotal,
'base_insurance_rials' => $baseShare,
'supplementary_insurance_rials' => $suppShare,
'patient_share_rials' => $patientShare,
'final_price_rials' => $patientShare,
];
}
/**
* درصد پوشش مؤثر ویزیت (سرپایی) از همان زنجیره‌ی resolve محاسبه —
* snapshot نمایشی روی مراجعه، نه ورودی محاسبه.
*/
private function contractPercent(
string $entityType,
int $entityId,
?int $insuranceId,
?ServiceCategory $category = null,
): float {
return $this->tenantInsuranceService
->coverageRule($entityType, $entityId, $insuranceId, $category ?? ServiceCategory::Outpatient)
->coveragePercent;
}
/**
* پرونده و مراجعهٔ خودکار برای یک نوبت قطعی‌شده.
*
* محیط رزرو تعیین‌کننده است: کلینیک، یا مطب شخصی پزشک — هرگز هر دو. دو پرونده
* برای یک نوبت یعنی درآمد یک ویزیت دو بار شمرده می‌شود.
*/
public function autoCreateOnAppointmentConfirm(Appointment $appointment): ?PatientSession
{
$clinic = $appointment->getClinic();
[$entityType, $entityId] = $clinic !== null
? ['clinic', (int) $clinic->getId()]
: ['doctor', (int) $appointment->getDoctor()->getId()];
return $this->autoCreateForEntity($entityType, $entityId, $appointment, $entityId);
}
/** مراجعهٔ ساخته‌شده یا موجود؛ null یعنی این tenant قابلیت پرونده را ندارد. */
private function autoCreateForEntity(string $entityType, int $entityId, Appointment $appointment, int $createdById): ?PatientSession
{
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
// به‌زور پرونده نمی‌سازیم، ولی بی‌نشانه هم رد نمی‌شویم: بدون این لاگ،
// «چرا این نوبت پرونده ندارد» غیرقابل‌تشخیص است.
$this->logger->info('Skipped auto-creating the patient record: the tenant has no patient_records feature', [
'entity_type' => $entityType,
'entity_id' => $entityId,
'appointment_uuid' => $appointment->getUuid(),
]);
return null;
}
$existing = $this->sessionRepo->findByAppointmentAndEntity($appointment, $entityType, $entityId);
if ($existing !== null) {
return $existing;
}
$patient = $appointment->getUser();
$record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
if ($record === null) {
$record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById);
$this->recordRepo->save($record);
}
$session = new PatientSession($record, $appointment);
// زمان مراجعه = زمان واقعی نوبت.
$session->setSessionAt($appointment->getSlotStart());
// هزینه ویزیت: از نوبت، در نبود آن از «قیمت ویزیت آزاد» تنظیمات همین tenant.
$visitPrice = (int) ($appointment->getVisitPriceRials()
?? $this->pricingRepo->findOneForInsurance($entityType, $entityId, null)?->getPatientShareRials()
?? 0);
$session->setVisitPriceRials($visitPrice);
// خطوط هزینه‌ی سرویس (قیمت snapshot از خود سرویس، بدون ورود دستی).
$servicesTotal = 0;
$lines = [];
foreach ($appointment->getServiceItems() as $item) {
$line = new SessionService($session, $item, null, 1);
$lines[] = $line;
$servicesTotal += $line->getLineTotalRials();
}
$session->setServicesTotalRials($servicesTotal);
// بیمهٔ انتخاب‌شده روی نوبت مبنای محاسبه است؛ نوبتِ بدون بیمه مثل قبل کاملاً
// سهم بیمار می‌ماند (coverageRule برای insuranceId=null، notCovered می‌دهد).
$category = $this->appointmentInsurance->effectiveCategory($appointment);
$session->setInsuranceServiceCategory($category);
$session->setInsuranceBaseId($appointment->getInsuranceBaseId());
$session->setBaseInsuranceDiscountPercent(
$this->contractPercent($entityType, $entityId, $appointment->getInsuranceBaseId(), $category)
);
$shares = $this->calculateFinalPrice(
$visitPrice,
array_map(
fn(SessionService $line) => ['item_id' => $line->getServiceItem()->getId(), 'price_rials' => $line->getLineTotalRials()],
$lines,
),
$entityType,
$entityId,
$appointment->getInsuranceBaseId(),
null,
$category,
);
$session->applyShares(
$shares['gross_total_rials'],
$shares['base_insurance_rials'],
$shares['supplementary_insurance_rials'],
$shares['patient_share_rials'],
);
$this->sessionRepo->save($session);
foreach ($lines as $line) {
$this->sessionServiceRepo->save($line);
$session->addService($line);
}
return $session;
}
public function createSession(
PatientRecord $record,
array $data,
string $entityType,
int $entityId
): PatientSession {
$freeVisitRow = $this->pricingRepo->findOneForInsurance($entityType, $entityId, null);
if (($freeVisitRow?->isRequireVisitPrice() ?? false) && (int) ($data['visit_price_rials'] ?? 0) <= 0) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'هزینه ویزیت الزامی است', 422, 'visit_price_rials');
}
$session = new PatientSession($record);
if (!empty($data['appointment_uuid'])) {
// appointment را از بیرون resolve می‌کنند و session را ست می‌کنند
}
$session->setInsuranceBaseId(isset($data['insurance_base_id']) ? (int) $data['insurance_base_id'] : null);
$session->setInsuranceSupplementaryId(isset($data['insurance_supplementary_id']) ? (int) $data['insurance_supplementary_id'] : null);
$session->setInsuranceServiceCategory(ServiceCategory::tryFromValue($data['insurance_service_category'] ?? null));
$session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0));
$visitCategory = $session->getInsuranceServiceCategory();
$session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId(), $visitCategory));
$session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId(), $visitCategory));
$session->setPaymentMethod($data['payment_method'] ?? 'pending');
$session->setNotes($data['notes'] ?? null);
// زمان پذیرش (اختیاری — پیش‌فرض زمان ثبت)
if (!empty($data['session_at'])) {
$session->setSessionAt((int) $data['session_at']);
}
// پکیج مصرفی (اختیاری، فقط مرجع؛ باید متعلق به همین tenant باشد)
if (!empty($data['inventory_package_uuid'])) {
$package = $this->inventoryPackageRepo->findByUuid((string) $data['inventory_package_uuid']);
if ($package !== null && $package->getEntityType() === $entityType && $package->getEntityId() === $entityId) {
$session->setInventoryPackage($package);
}
}
// جمع‌آوری service items (با احتساب تعداد). سرویسِ محیط دیگر نادیده گرفته
// می‌شود، وگرنه قیمتش وارد محاسبهٔ فاکتور این محیط می‌شد.
$serviceItemsData = [];
foreach (($data['services'] ?? []) as $svc) {
$item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? '');
if ($this->tenantOwnership->belongsToPair($entityType, $entityId, $item)) {
$qty = max(1, (int) ($svc['quantity'] ?? 1));
$serviceItemsData[] = ['item_id' => $item->getId(), 'price_rials' => $item->getPriceRials() * $qty];
}
}
$priceCalc = $this->calculateFinalPrice(
$session->getVisitPriceRials(),
$serviceItemsData,
$entityType,
$entityId,
$session->getInsuranceBaseId(),
$session->getInsuranceSupplementaryId(),
$visitCategory,
);
$session->setServicesTotalRials($priceCalc['services_total_rials']);
// کالاهای مصرفی: بدون پوشش بیمه — تمام مبلغ سهم بیمار است.
// فقط آیتم‌های متعلق به همین tenant پذیرفته می‌شوند؛ بقیه بی‌صدا رد می‌شوند (هم‌رفتار با services).
$consumableRows = [];
$consumablesTotal = 0;
foreach (($data['consumables'] ?? []) as $row) {
$item = $this->inventoryItemRepo->findByUuid((string) ($row['inventory_item_uuid'] ?? ''));
if ($item === null || $item->getEntityType() !== $entityType || $item->getEntityId() !== $entityId) {
continue;
}
$qty = max(1, (int) ($row['quantity'] ?? 1));
$consumableRows[] = ['item' => $item, 'quantity' => $qty];
$consumablesTotal += $item->getPrice() * $qty;
}
// کالاهای مصرفی پوشش بیمه ندارند: مستقیم به سهم بیمار و به جمع کل اضافه می‌شوند.
$session->applyShares(
$priceCalc['gross_total_rials'] + $consumablesTotal,
$priceCalc['base_insurance_rials'],
$priceCalc['supplementary_insurance_rials'],
$priceCalc['patient_share_rials'] + $consumablesTotal,
);
$this->sessionRepo->save($session);
// ثبت session consumables
foreach ($consumableRows as $row) {
$sc = new SessionConsumable($session, $row['item'], $row['quantity']);
$this->sessionConsumableRepo->save($sc);
$session->addConsumable($sc);
}
// ثبت session services — سرویس و پرسنلِ محیط دیگر نادیده گرفته می‌شوند،
// همان قاعده‌ای که کالای مصرفی بالاتر رعایت می‌کند.
foreach (($data['services'] ?? []) as $svc) {
$item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? '');
if (!$this->tenantOwnership->belongsToPair($entityType, $entityId, $item)) {
continue;
}
$staff = !empty($svc['staff_uuid']) ? $this->staffRepo->findByUuid($svc['staff_uuid']) : null;
if ($staff !== null && !$this->tenantOwnership->belongsToPair($entityType, $entityId, $staff)) {
$staff = null;
}
$qty = max(1, (int) ($svc['quantity'] ?? 1));
$ss = new SessionService($session, $item, $staff, $qty);
$this->sessionServiceRepo->save($ss);
$session->addService($ss);
}
return $session;
}
/**
* ویرایش سرویس‌ها/کالاها/قیمت ویزیت/بیمه‌ی یک مراجعه پس از ثبت، با بازمحاسبه‌ی
* مجموع‌ها و ثبت تاریخچه (audit) برای هر فیلد تغییرکرده.
*/
public function updateSessionServices(PatientSession $session, array $data, string $entityType, int $entityId, User $actor): PatientSession
{
$before = [
'visit_price_rials' => (string) $session->getVisitPriceRials(),
'services' => $this->servicesSummary($session),
'consumables' => $this->consumablesSummary($session),
'services_total_rials' => (string) $session->getServicesTotalRials(),
'final_price_rials' => (string) $session->getFinalPriceRials(),
];
// فیلدهای ساده
if (array_key_exists('visit_price_rials', $data)) { $session->setVisitPriceRials((int) $data['visit_price_rials']); }
if (array_key_exists('insurance_base_id', $data)) { $session->setInsuranceBaseId($data['insurance_base_id'] !== null ? (int) $data['insurance_base_id'] : null); }
if (array_key_exists('insurance_supplementary_id', $data)) { $session->setInsuranceSupplementaryId($data['insurance_supplementary_id'] !== null ? (int) $data['insurance_supplementary_id'] : null); }
if (array_key_exists('insurance_service_category', $data)) { $session->setInsuranceServiceCategory(ServiceCategory::tryFromValue($data['insurance_service_category'])); }
if (array_key_exists('base_insurance_discount_percent', $data)) { $session->setBaseInsuranceDiscountPercent((float) $data['base_insurance_discount_percent']); }
if (array_key_exists('supplementary_discount_percent', $data)) { $session->setSupplementaryDiscountPercent((float) $data['supplementary_discount_percent']); }
if (array_key_exists('notes', $data)) { $session->setNotes($data['notes'] !== null ? (string) $data['notes'] : null); }
if (!empty($data['session_at'])) { $session->setSessionAt((int) $data['session_at']); }
// جایگزینی سرویس‌ها (اگر ارسال شده)
if (array_key_exists('services', $data)) {
foreach ($session->getServices()->toArray() as $old) {
$this->sessionServiceRepo->remove($old, false);
$session->getServices()->removeElement($old);
}
foreach (($data['services'] ?? []) as $svc) {
// سرویس و پرسنل با uuid از بدنهٔ درخواست می‌آیند و روی SessionService
// ذخیره می‌شوند؛ بدون این بررسی، دادهٔ محیط دیگری در مراجعه می‌نشست.
$item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? '');
if (!$this->tenantOwnership->belongsToPair($entityType, $entityId, $item)) { continue; }
$staff = !empty($svc['staff_uuid']) ? $this->staffRepo->findByUuid($svc['staff_uuid']) : null;
if ($staff !== null && !$this->tenantOwnership->belongsToPair($entityType, $entityId, $staff)) {
$staff = null;
}
$qty = max(1, (int) ($svc['quantity'] ?? 1));
$ss = new SessionService($session, $item, $staff, $qty);
$this->sessionServiceRepo->save($ss);
$session->addService($ss);
}
}
// جایگزینی کالاهای مصرفی (اگر ارسال شده)
if (array_key_exists('consumables', $data)) {
foreach ($session->getConsumables()->toArray() as $old) {
$this->sessionConsumableRepo->remove($old, false);
$session->getConsumables()->removeElement($old);
}
foreach (($data['consumables'] ?? []) as $row) {
$item = $this->inventoryItemRepo->findByUuid((string) ($row['inventory_item_uuid'] ?? ''));
if ($item === null || $item->getEntityType() !== $entityType || $item->getEntityId() !== $entityId) { continue; }
$qty = max(1, (int) ($row['quantity'] ?? 1));
$sc = new SessionConsumable($session, $item, $qty);
$this->sessionConsumableRepo->save($sc);
$session->addConsumable($sc);
}
}
// بازمحاسبه‌ی مجموع‌ها (مثل createSession)
$serviceItemsData = array_map(
fn(SessionService $s) => ['item_id' => $s->getServiceItem()->getId(), 'price_rials' => $s->getLineTotalRials()],
$session->getServices()->toArray(),
);
$visitCategory = $session->getInsuranceServiceCategory();
$session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId(), $visitCategory));
$session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId(), $visitCategory));
$priceCalc = $this->calculateFinalPrice(
$session->getVisitPriceRials(),
$serviceItemsData,
$entityType,
$entityId,
$session->getInsuranceBaseId(),
$session->getInsuranceSupplementaryId(),
$visitCategory,
);
$consumablesTotal = $session->getConsumablesTotalRials();
$session->setServicesTotalRials($priceCalc['services_total_rials']);
$session->applyShares(
$priceCalc['gross_total_rials'] + $consumablesTotal,
$priceCalc['base_insurance_rials'],
$priceCalc['supplementary_insurance_rials'],
$priceCalc['patient_share_rials'] + $consumablesTotal,
);
$this->sessionRepo->save($session);
// پس از تغییر مبلغ، وضعیت تسویه بازمحاسبه شود (افزودن سرویس/پکیج → بدهکار).
$this->recomputeSettlement($session);
// ثبت audit برای فیلدهای تغییرکرده
$after = [
'visit_price_rials' => (string) $session->getVisitPriceRials(),
'services' => $this->servicesSummary($session),
'consumables' => $this->consumablesSummary($session),
'services_total_rials' => (string) $session->getServicesTotalRials(),
'final_price_rials' => (string) $session->getFinalPriceRials(),
];
foreach ($after as $field => $newVal) {
if ($before[$field] !== $newVal) {
$this->logSessionChange($session, $field, \App\Patient\Entity\SessionAuditLog::OP_UPDATE, $before[$field], $newVal, $actor);
}
}
return $session;
}
/**
* ویرایش یک پرداخت ثبت‌شده (روش/مبلغ/تاریخ) با ثبت audit و بازمحاسبه‌ی
* فیلدهای کش‌شده‌ی تسویه. پرداخت wallet مسدود است (جبران کیف پول خارج از scope).
*/
public function updatePayment(SessionPayment $payment, array $data, User $actor): SessionPayment
{
$session = $payment->getSession();
if ($payment->getMethod() === 'wallet') {
throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, 'ویرایش پرداخت کیف پول ممکن نیست', 422, 'method');
}
$oldAmount = $payment->getAmountRials();
$newAmount = array_key_exists('amount_rials', $data) ? max(0, (int) $data['amount_rials']) : $oldAmount;
if ($newAmount <= 0) {
throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, null, 422, 'amount_rials');
}
// مجموع پرداخت‌ها (با مقدار جدید) نباید از مبلغِ پس از تخفیف بیشتر شود.
$othersTotal = $session->getPaidTotalRials() - $oldAmount;
if ($othersTotal + $newAmount > $session->getPayableRials()) {
throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_EXCEEDS, null, 422, 'amount_rials');
}
if (array_key_exists('method', $data)) {
$method = (string) $data['method'];
if (!in_array($method, SessionPayment::METHODS, true) || $method === 'wallet') {
throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, null, 422, 'method');
}
$payment->setMethod($method);
}
$payment->setAmountRials($newAmount);
if (!empty($data['paid_at'])) { $payment->setPaidAt((int) $data['paid_at']); }
$this->sessionPaymentRepo->save($payment);
$this->recomputeSettlement($session);
$this->logSessionChange($session, 'payment', \App\Patient\Entity\SessionAuditLog::OP_UPDATE, (string) $oldAmount, (string) $newAmount, $actor, 'ویرایش پرداخت');
return $payment;
}
/**
* حذف یک پرداخت ثبت‌شده با ثبت audit و بازمحاسبه‌ی تسویه.
* پرداخت wallet مسدود است (جبران کیف پول خارج از scope).
*/
public function deletePayment(SessionPayment $payment, User $actor): void
{
$session = $payment->getSession();
if ($payment->getMethod() === 'wallet') {
throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, 'حذف پرداخت کیف پول ممکن نیست', 422, 'method');
}
$amount = $payment->getAmountRials();
$session->getPayments()->removeElement($payment);
$this->sessionPaymentRepo->remove($payment);
$this->recomputeSettlement($session);
$this->logSessionChange($session, 'payment', \App\Patient\Entity\SessionAuditLog::OP_DELETE, (string) $amount, null, $actor, 'حذف پرداخت');
}
/** بازمحاسبه‌ی فیلدهای کش‌شده‌ی تسویه پس از تغییر پرداخت‌ها. */
private function recomputeSettlement(PatientSession $session): void
{
if ($session->getRemainingRials() === 0 && $session->getPaidTotalRials() > 0) {
if ($session->getPaymentMethod() === 'pending') {
$session->setPaymentMethod('cash');
}
if ($session->getPaidAt() === null) {
$session->setPaidAt(time());
}
} else {
$session->setPaymentMethod('pending');
$session->setPaidAt(null);
}
$this->sessionRepo->save($session);
}
/**
* اعمال/حذف تخفیف تسویه روی مراجعه.
* type=null → حذف تخفیف. percent باید 0..100 و fixed حداکثر برابر مبلغ نهایی باشد.
* تخفیف نمی‌تواند از مانده‌ی قابل‌تخفیف (مبلغ نهایی منهای پرداخت‌های ثبت‌شده) بیشتر شود.
*/
public function applyDiscount(PatientSession $session, ?string $type, int $value, ?int $ruleId = null, ?string $ruleLabel = null, ?User $actor = null): PatientSession
{
if ($type === null) {
$oldRials = $session->getDiscountRials();
$session->setDiscount(null, 0, 0);
$this->sessionRepo->save($session);
if ($oldRials !== 0) {
$this->logSessionChange($session, 'discount', \App\Patient\Entity\SessionAuditLog::OP_DELETE, (string) $oldRials, null, $actor);
}
return $session;
}
if (!in_array($type, ['percent', 'fixed'], true) || $value < 0) {
throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_type');
}
$final = $session->getFinalPriceRials();
if ($type === 'percent') {
if ($value > 100) {
throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_value');
}
$rials = (int) round($final * $value / 100);
} else {
if ($value > $final) {
throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_value');
}
$rials = $value;
}
// تخفیف نباید از آنچه هنوز پرداخت نشده بیشتر باشد (پرداخت‌ها برگشت‌ناپذیرند)
if ($rials > $final - $session->getPaidTotalRials()) {
throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_value');
}
$this->persistDiscount($session, $type, $value, $rials, $ruleId, $ruleLabel, $actor);
return $session;
}
/**
* اعمال یک قانون تخفیف روی مراجعه؛ مبلغ ریالی از موتور (با در نظر گرفتن نوع/مبنا)
* محاسبه و منبع قانون برای audit ثبت می‌شود.
*/
public function applyDiscountRule(PatientSession $session, \App\Discount\Entity\DiscountRule $rule, ?User $actor = null): PatientSession
{
$rials = $this->discountEngine->computeForRule($session, $rule);
if ($rials <= 0) {
throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_rule_uuid');
}
$this->persistDiscount($session, $rule->getDiscountType(), $rule->getValue(), $rials, $rule->getId(), $rule->getName(), $actor);
return $session;
}
private function persistDiscount(PatientSession $session, string $type, int $value, int $rials, ?int $ruleId, ?string $ruleLabel, ?User $actor = null): void
{
$oldRials = $session->getDiscountRials();
$session->setDiscount($type, $value, $rials, $ruleId, $ruleLabel);
if ($session->getRemainingRials() === 0 && $session->getPaymentMethod() === 'pending') {
// تخفیف صددرصدی بدهی را صفر کرد — مراجعه تسویه‌شده تلقی می‌شود
$session->setPaymentMethod('cash');
$session->setPaidAt(time());
}
$this->sessionRepo->save($session);
if ($oldRials !== $rials) {
$this->logSessionChange($session, 'discount', \App\Patient\Entity\SessionAuditLog::OP_UPDATE, (string) $oldRials, (string) $rials, $actor, $ruleLabel);
}
}
/**
* ثبت یک پرداخت جزئی روی مراجعه. روش wallet همان مبلغ را از کیف پول بیمار
* کسر می‌کند (موجودی ناکافی → ۴۲۲). وقتی مانده صفر شود، payment_method و
* paid_at مراجعه ست می‌شوند تا is_paid برای مصرف‌کننده‌های فعلی درست بماند.
*/
public function addSessionPayment(
PatientSession $session,
string $method,
int $amountRials,
?int $paidAt = null,
?User $actor = null,
?string $paymentMethodUuid = null,
?string $reference = null,
): SessionPayment {
if (!in_array($method, SessionPayment::METHODS, true)) {
throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, null, 422, 'method');
}
if ($amountRials <= 0) {
throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, null, 422, 'amount_rials');
}
$remaining = $session->getRemainingRials();
if ($amountRials > $remaining) {
throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_EXCEEDS, null, 422, 'amount_rials');
}
if ($method === 'wallet') {
$names = array_values(array_filter(array_map(
fn(SessionService $s) => $s->toArray()['service_name'] ?? null,
$session->getServices()->toArray(),
)));
$label = $names !== [] ? implode('، ', $names) : 'ویزیت';
$record = $session->getRecord();
$this->walletService->withdraw(
$record->getUser(),
$amountRials,
$actor,
'پرداخت سرویس: ' . $label,
'wallet',
'session:' . $session->getUuid(),
$record->getEntityType(),
$record->getEntityId(),
);
}
$payment = new SessionPayment($session, $method, $amountRials, $paidAt);
$payment->setCreatedBy($actor)
->setCreatedByName($this->walletService->resolveActorName($actor))
->setPaymentMethodUuid($paymentMethodUuid !== null && $paymentMethodUuid !== '' ? $paymentMethodUuid : null)
->setReference($reference !== null && $reference !== '' ? $reference : null);
$this->sessionPaymentRepo->save($payment);
$session->addPayment($payment);
if ($session->getRemainingRials() === 0) {
$session->setPaymentMethod($method);
$session->setPaidAt($paidAt ?? time());
}
$this->sessionRepo->save($session);
$this->logSessionChange($session, 'payment', \App\Patient\Entity\SessionAuditLog::OP_CREATE, null, (string) $amountRials, $actor, 'ثبت پرداخت');
return $payment;
}
}