Files
clinicpro/src/Patient/Service/PatientService.php
T
hamed a0ddb4c0d1 feat: add visit price requirement feature
- Introduced a new boolean flag `require_visit_price` in the `EntityInsurancePricing` to enforce visit price for appointments.
- Updated the appointment creation endpoints to validate `visit_price_rials` based on the new flag.
- Added `visit_price_rials` field to the `Appointment` entity to store the visit price.
- Enhanced the `PatientService` to validate visit price during session creation.
- Updated API documentation to reflect changes in appointment and insurance pricing.
- Implemented a new service `VisitPriceRequirementResolver` to determine if a visit price is required for a doctor based on their pricing settings.
- Added migrations to update the database schema for the new fields.
2026-07-16 19:44:30 +03:30

340 lines
15 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,
) {}
/**
* محاسبه‌ی سهم بیمار.
* ویزیت با درصد تخفیف انتخاب‌شده در فرم؛ هر خدمت با قاعده‌ی پوشش همان بیمه‌گر برای همان خدمت
* (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);
$this->sessionRepo->save($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->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;
}
/**
* اعمال/حذف تخفیف تسویه روی مراجعه.
* type=null → حذف تخفیف. percent باید 0..100 و fixed حداکثر برابر مبلغ نهایی باشد.
* تخفیف نمی‌تواند از مانده‌ی قابل‌تخفیف (مبلغ نهایی منهای پرداخت‌های ثبت‌شده) بیشتر شود.
*/
public function applyDiscount(PatientSession $session, ?string $type, int $value): PatientSession
{
if ($type === null) {
$session->setDiscount(null, 0, 0);
$this->sessionRepo->save($session);
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');
}
$session->setDiscount($type, $value, $rials);
if ($session->getRemainingRials() === 0 && $session->getPaymentMethod() === 'pending') {
// تخفیف صددرصدی بدهی را صفر کرد — مراجعه تسویه‌شده تلقی می‌شود
$session->setPaymentMethod('cash');
$session->setPaidAt(time());
}
$this->sessionRepo->save($session);
return $session;
}
/**
* ثبت یک پرداخت جزئی روی مراجعه. روش 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);
return $payment;
}
}