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 $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; } }