From cd1c9529cbb39cef2719826f3bb5515b99acb9d3 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 17 Jul 2026 15:11:07 +0330 Subject: [PATCH] feat(patient): session edit + payment edit/delete service logic with audit Add updateSessionServices (replace services/consumables/visit-price/insurance, recompute totals), updatePayment and deletePayment (wallet payments blocked; recompute cached payment_method/paid_at via recomputeSettlement), and thread audit logging (logSessionChange) through service edits and the discount path. Add remove()/findByUuid to payment repo, remove() to service/consumable repos, and method/amount/paid_at setters to SessionPayment. Co-Authored-By: Claude Fable 5 --- src/Patient/Entity/SessionPayment.php | 3 + .../SessionConsumableRepository.php | 8 + .../Repository/SessionPaymentRepository.php | 11 + .../Repository/SessionServiceRepository.php | 8 + src/Patient/Service/PatientService.php | 215 +++++++++++++++++- 5 files changed, 240 insertions(+), 5 deletions(-) diff --git a/src/Patient/Entity/SessionPayment.php b/src/Patient/Entity/SessionPayment.php index 8e10f3bf..dd516923 100644 --- a/src/Patient/Entity/SessionPayment.php +++ b/src/Patient/Entity/SessionPayment.php @@ -70,6 +70,9 @@ class SessionPayment public function setCreatedBy(?User $u): self { $this->createdBy = $u; return $this; } public function setCreatedByName(?string $n): self { $this->createdByName = $n; return $this; } + public function setMethod(string $m): self { $this->method = $m; return $this; } + public function setAmountRials(int $v): self { $this->amountRials = $v; return $this; } + public function setPaidAt(int $v): self { $this->paidAt = $v; return $this; } public function toArray(): array { diff --git a/src/Patient/Repository/SessionConsumableRepository.php b/src/Patient/Repository/SessionConsumableRepository.php index 9ebad397..897d52f1 100644 --- a/src/Patient/Repository/SessionConsumableRepository.php +++ b/src/Patient/Repository/SessionConsumableRepository.php @@ -18,4 +18,12 @@ class SessionConsumableRepository extends ServiceEntityRepository $this->getEntityManager()->persist($consumable); $this->getEntityManager()->flush(); } + + public function remove(SessionConsumable $consumable, bool $flush = true): void + { + $this->getEntityManager()->remove($consumable); + if ($flush) { + $this->getEntityManager()->flush(); + } + } } diff --git a/src/Patient/Repository/SessionPaymentRepository.php b/src/Patient/Repository/SessionPaymentRepository.php index 2ad3b32a..91533de1 100644 --- a/src/Patient/Repository/SessionPaymentRepository.php +++ b/src/Patient/Repository/SessionPaymentRepository.php @@ -18,4 +18,15 @@ class SessionPaymentRepository extends ServiceEntityRepository $this->getEntityManager()->persist($payment); $this->getEntityManager()->flush(); } + + public function remove(SessionPayment $payment): void + { + $this->getEntityManager()->remove($payment); + $this->getEntityManager()->flush(); + } + + public function findByUuid(string $uuid): ?SessionPayment + { + return $this->findOneBy(['uuid' => $uuid]); + } } diff --git a/src/Patient/Repository/SessionServiceRepository.php b/src/Patient/Repository/SessionServiceRepository.php index 69aaf0d4..41a8dab8 100644 --- a/src/Patient/Repository/SessionServiceRepository.php +++ b/src/Patient/Repository/SessionServiceRepository.php @@ -18,4 +18,12 @@ class SessionServiceRepository extends ServiceEntityRepository $this->getEntityManager()->persist($service); $this->getEntityManager()->flush(); } + + public function remove(SessionService $service, bool $flush = true): void + { + $this->getEntityManager()->remove($service); + if ($flush) { + $this->getEntityManager()->flush(); + } + } } diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php index a0c716df..8ce5cb81 100644 --- a/src/Patient/Service/PatientService.php +++ b/src/Patient/Service/PatientService.php @@ -51,8 +51,38 @@ class PatientService 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); + } + /** * محاسبه‌ی سهم بیمار. * ویزیت با درصد تخفیف انتخاب‌شده در فرم؛ هر خدمت با قاعده‌ی پوشش همان بیمه‌گر برای همان خدمت @@ -264,16 +294,187 @@ class PatientService 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); + + // ثبت 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): PatientSession + 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; } @@ -299,7 +500,7 @@ class PatientService throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_value'); } - $this->persistDiscount($session, $type, $value, $rials, $ruleId, $ruleLabel); + $this->persistDiscount($session, $type, $value, $rials, $ruleId, $ruleLabel, $actor); return $session; } @@ -308,19 +509,20 @@ class PatientService * اعمال یک قانون تخفیف روی مراجعه؛ مبلغ ریالی از موتور (با در نظر گرفتن نوع/مبنا) * محاسبه و منبع قانون برای audit ثبت می‌شود. */ - public function applyDiscountRule(PatientSession $session, \App\Discount\Entity\DiscountRule $rule): PatientSession + 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()); + $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): void + 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') { // تخفیف صددرصدی بدهی را صفر کرد — مراجعه تسویه‌شده تلقی می‌شود @@ -328,6 +530,9 @@ class PatientService $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); + } } /**