Files
clinicpro/src/Patient/Service/PatientService.php
T
hamedandClaude Fable 5 142d653c68 fix(patient): derive is_paid from remaining, recompute on edit, audit add-payment
- is_paid now = remaining_rials == 0 (was payment_method != pending), so adding
  a service/package after settlement correctly flips the session back to debtor.
- updateSessionServices recomputes the cached settlement (payment_method/paid_at)
  after totals change.
- addSessionPayment now records a 'create' audit entry (actor, amount, time).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:34:41 +03:30

598 lines
29 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\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\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;
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 BillingCalculator $billingCalculator,
private readonly WalletService $walletService,
private readonly EntityInsurancePricingRepository $pricingRepo,
private readonly \App\Discount\Service\DiscountEngine $discountEngine,
private readonly \App\Patient\Repository\SessionAuditLogRepository $auditRepo,
) {}
/** ثبت یک رکورد تاریخچه‌ی تغییر مالی/خدماتی روی مراجعه. */
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);
}
/**
* محاسبه‌ی سهم بیمار.
* ویزیت با درصد تخفیف انتخاب‌شده در فرم؛ هر خدمت با قاعده‌ی پوشش همان بیمه‌گر برای همان خدمت
* (TenantServiceCoverage از طریق BillingCalculator). خدمتی که آن بیمه را پوشش نمی‌دهد، کامل بر عهده‌ی بیمار است.
*
* @param array<array{item_id: int, price_rials: int}> $serviceItems قیمت کل هر ردیف (با احتساب تعداد)
*/
public function calculateFinalPrice(
int $visitPrice,
float $baseDiscount,
float $suppDiscount,
array $serviceItems,
string $entityType = 'doctor',
int $entityId = 0,
?int $baseInsuranceId = null,
?int $suppInsuranceId = null,
): array {
$afterBase = $visitPrice * (1 - $baseDiscount / 100);
$afterSupp = $afterBase * (1 - $suppDiscount / 100);
$visitShare = (int) round($afterSupp);
$servicesTotal = 0;
$servicesPatient = 0;
foreach ($serviceItems as $svc) {
$servicesTotal += $svc['price_rials'];
$baseRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $baseInsuranceId, $svc['item_id']);
$suppRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $suppInsuranceId, $svc['item_id']);
$breakdown = $this->billingCalculator->calculateItem(new Money($svc['price_rials']), $baseRule, $suppRule);
$servicesPatient += $breakdown->patientRials;
}
return [
'services_total_rials' => $servicesTotal,
'final_price_rials' => $visitShare + $servicesPatient,
];
}
public function autoCreateOnAppointmentConfirm(Appointment $appointment): void
{
$doctor = $appointment->getDoctor();
// پرونده‌ی پزشک
$this->autoCreateForEntity('doctor', $doctor->getId(), $appointment, $doctor->getId());
// کلینیک نوبت را تعیین کن: اول از آدرس انتخاب‌شده، وگرنه اگر دکتر فقط عضو یک کلینیک باشد.
$clinicId = null;
$addressId = $appointment->getAddressId();
if ($addressId !== null) {
$clinicId = $this->addressRepo->find($addressId)?->getClinicId();
}
if ($clinicId === null) {
$clinics = $this->clinicRepo->findByDoctor($doctor);
if (count($clinics) === 1) {
$clinicId = $clinics[0]->getId();
}
}
if ($clinicId !== null) {
$this->autoCreateForEntity('clinic', $clinicId, $appointment, $clinicId);
}
}
private function autoCreateForEntity(string $entityType, int $entityId, Appointment $appointment, int $createdById): void
{
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
return;
}
$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);
$session->setFinalPriceRials($servicesTotal + $visitPrice);
$this->sessionRepo->save($session);
foreach ($lines as $line) {
$this->sessionServiceRepo->save($line);
$session->addService($line);
}
}
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->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0));
$session->setBaseInsuranceDiscountPercent((float) ($data['base_insurance_discount_percent'] ?? 0));
$session->setSupplementaryDiscountPercent((float) ($data['supplementary_discount_percent'] ?? 0));
$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 ($item !== null) {
$qty = max(1, (int) ($svc['quantity'] ?? 1));
$serviceItemsData[] = ['item_id' => $item->getId(), 'price_rials' => $item->getPriceRials() * $qty];
}
}
$priceCalc = $this->calculateFinalPrice(
$session->getVisitPriceRials(),
$session->getBaseInsuranceDiscountPercent(),
$session->getSupplementaryDiscountPercent(),
$serviceItemsData,
$entityType,
$entityId,
$session->getInsuranceBaseId(),
$session->getInsuranceSupplementaryId(),
);
$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->setFinalPriceRials($priceCalc['final_price_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 ($item === null) {
continue;
}
$staff = !empty($svc['staff_uuid']) ? $this->staffRepo->findByUuid($svc['staff_uuid']) : 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('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) {
$item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? '');
if ($item === null) { continue; }
$staff = !empty($svc['staff_uuid']) ? $this->staffRepo->findByUuid($svc['staff_uuid']) : 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(),
);
$priceCalc = $this->calculateFinalPrice(
$session->getVisitPriceRials(),
$session->getBaseInsuranceDiscountPercent(),
$session->getSupplementaryDiscountPercent(),
$serviceItemsData,
$entityType,
$entityId,
$session->getInsuranceBaseId(),
$session->getInsuranceSupplementaryId(),
);
$session->setServicesTotalRials($priceCalc['services_total_rials']);
$session->setFinalPriceRials($priceCalc['final_price_rials'] + $session->getConsumablesTotalRials());
$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;
$payable = $session->getFinalPriceRials() - $session->getDiscountRials();
if ($othersTotal + $newAmount > $payable) {
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,
): 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) : 'ویزیت';
$this->walletService->withdraw(
$session->getRecord()->getUser(),
$amountRials,
$actor,
'پرداخت سرویس: ' . $label,
'wallet',
'session:' . $session->getUuid(),
);
}
$payment = new SessionPayment($session, $method, $amountRials, $paidAt);
$payment->setCreatedBy($actor)
->setCreatedByName($this->walletService->resolveActorName($actor));
$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;
}
}