- 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>
598 lines
29 KiB
PHP
598 lines
29 KiB
PHP
<?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;
|
||
}
|
||
}
|