From 7da7d968b959eec7c04d94eadd31c1f9a5984f6f Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 17 Jul 2026 15:14:49 +0330 Subject: [PATCH] feat(patient): session edit + payment PATCH/DELETE + audit-log endpoints updateSession now accepts services/consumables/visit_price/insurance and calls updateSessionServices; discount paths pass the actor for audit. Add PATCH/DELETE /session/{uuid}/payments/{paymentUuid} and GET /session/{uuid}/audit-log (owner-scoped). Inject the payment + audit repos. Verified end-to-end (edit visit price, payment edit-exceeds guard, delete + recompute, audit trail). Docs updated. Co-Authored-By: Claude Fable 5 --- docs/api/patient.md | 46 +++++++++++++ src/Patient/Controller/PatientController.php | 72 +++++++++++++++++++- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/docs/api/patient.md b/docs/api/patient.md index 522b86a9..a04cbf27 100644 --- a/docs/api/patient.md +++ b/docs/api/patient.md @@ -563,6 +563,52 @@ POST /api/v1/session/{uuid}/payments --- +### Edit Session Payment + +``` +PATCH /api/v1/session/{uuid}/payments/{paymentUuid} +DELETE /api/v1/session/{uuid}/payments/{paymentUuid} +``` + +ویرایش/حذف یک پرداخت ثبت‌شده. پس از تغییر، فیلدهای کش‌شده‌ی تسویه (`payment_method`، `paid_at`، `is_paid`) و `paid_total_rials`/`patient_debt_rials` بازمحاسبه می‌شوند. هر عملیات در **Audit Log** ثبت می‌شود. + +**PATCH body (همه اختیاری):** `{ "method": "pos|cash|card", "amount_rials": 300000, "paid_at": 1770000000 }` + +- **پرداخت `wallet` قابل ویرایش/حذف نیست** (`422` — جبران تراکنش کیف پول پشتیبانی نمی‌شود). +- مجموع پرداخت‌ها پس از ویرایش نباید از «مبلغ نهایی منهای تخفیف» بیشتر شود. + +**Response 200:** session object با فیلدهای صورتحساب (مثل بالا). + +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_SESSION_NOT_FOUND` | 404 | Session یافت نشد یا متعلق به owner نیست | +| `ERR_SESSION_PAYMENT_INVALID` | 404/422 | پرداخت یافت نشد / روش نامعتبر / پرداخت wallet | +| `ERR_SESSION_PAYMENT_EXCEEDS` | 422 | مبلغ از مانده بیشتر است | + +### Session Audit Log + +``` +GET /api/v1/session/{uuid}/audit-log +``` + +تاریخچه‌ی کامل تغییرات مالی/خدماتی مراجعه (جدید → قدیم). هر رکورد: + +```json +{ + "success": true, + "data": [ + { "field": "visit_price_rials", "operation": "update", "old_value": "1000000", "new_value": "900000", "actor_name": "دکتر ...", "note": null, "created_at": 1770000000 }, + { "field": "payment", "operation": "delete", "old_value": "200000", "new_value": null, "actor_name": "منشی ...", "note": "حذف پرداخت", "created_at": 1770000100 } + ] +} +``` + +`field` یکی از: `visit_price_rials` | `services` | `consumables` | `services_total_rials` | `final_price_rials` | `payment` | `discount`. `operation`: `create` | `update` | `delete`. مقادیر پول ریال؛ `created_at` unix. + +> **ویرایش سرویس‌ها/کالاها/قیمت:** `PATCH /api/v1/session/{uuid}` علاوه بر فیلدهای قبلی، اکنون `services[]`، `consumables[]`، `visit_price_rials`، `insurance_base_id`/`insurance_supplementary_id`، `base_insurance_discount_percent`/`supplementary_discount_percent` را هم می‌پذیرد (بدنه مثل ایجاد سرویس). مجموع‌ها بازمحاسبه و هر فیلد تغییرکرده در audit-log ثبت می‌شود. + +--- + ## Auto-Creation on Appointment Confirm When an appointment's status changes to `confirmed` via `PATCH /api/v1/appointment/{uuid}/status`, the system automatically: diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php index 151a590f..43e4f22a 100644 --- a/src/Patient/Controller/PatientController.php +++ b/src/Patient/Controller/PatientController.php @@ -61,6 +61,8 @@ class PatientController extends BaseController private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo, private readonly \App\Settlement\Service\WalletService $walletService, private readonly \App\Discount\Repository\DiscountRuleRepository $discountRuleRepo, + private readonly \App\Patient\Repository\SessionPaymentRepository $sessionPaymentRepo, + private readonly \App\Patient\Repository\SessionAuditLogRepository $sessionAuditRepo, private readonly LoggerInterface $logger, ) {} @@ -1052,23 +1054,30 @@ class PatientController extends BaseController // آرشیو نرم: مخفی‌سازی مراجعه‌ی اشتباه بدون حذف سابقه. if (array_key_exists('archived', $data)) { $session->setArchived((bool) $data['archived']); } + // ویرایش سرویس‌ها/کالاها/قیمت ویزیت/بیمه — با بازمحاسبه و ثبت تاریخچه. + if (array_key_exists('services', $data) || array_key_exists('consumables', $data) + || array_key_exists('visit_price_rials', $data) || array_key_exists('insurance_base_id', $data) + || array_key_exists('base_insurance_discount_percent', $data)) { + $this->patientService->updateSessionServices($session, $data, $entityType, $entityId, $user); + } + // تخفیف بر اساس قانون (discount_rule_uuid): مقدار از خود قانون، با ثبت منبع. // '' یا null → حذف تخفیف. اولویت بر تخفیف دستی. if (array_key_exists('discount_rule_uuid', $data)) { $ruleUuid = $data['discount_rule_uuid']; if ($ruleUuid === null || $ruleUuid === '') { - $this->patientService->applyDiscount($session, null, 0); + $this->patientService->applyDiscount($session, null, 0, null, null, $user); } else { $rule = $this->discountRuleRepo->findByUuidForOwner((string) $ruleUuid, $entityType, $entityId); if ($rule === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'قانون تخفیف یافت نشد', 404, 'discount_rule_uuid'); } - $this->patientService->applyDiscountRule($session, $rule); + $this->patientService->applyDiscountRule($session, $rule, $user); } } elseif (array_key_exists('discount_type', $data)) { // تخفیف دستی: discount_type = percent|fixed|null (null = حذف تخفیف) $type = $data['discount_type'] !== null ? (string) $data['discount_type'] : null; - $this->patientService->applyDiscount($session, $type, (int) ($data['discount_value'] ?? 0)); + $this->patientService->applyDiscount($session, $type, (int) ($data['discount_value'] ?? 0), null, null, $user); } if (isset($data['paid_at'])) { $session->setPaidAt((int) $data['paid_at']); } @@ -1121,6 +1130,63 @@ class PatientController extends BaseController return $this->success($this->sessionWithBilling($session), 201); } + /** ویرایش یک پرداخت ثبت‌شده. body: { method?, amount_rials?, paid_at? }. */ + #[Route('/api/v1/session/{uuid}/payments/{paymentUuid}', methods: ['PATCH'])] + public function updateSessionPayment(string $uuid, string $paymentUuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + $this->assertPatientGate($entityType, $entityId); + + $session = $this->sessionRepo->findByUuid($uuid); + if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404); + } + $payment = $this->sessionPaymentRepo->findByUuid($paymentUuid); + if ($payment === null || $payment->getSession()->getUuid() !== $uuid) { + return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, 'پرداخت یافت نشد', 404, 'paymentUuid'); + } + + $this->patientService->updatePayment($payment, json_decode($request->getContent(), true) ?? [], $user); + + return $this->success($this->sessionWithBilling($session)); + } + + /** حذف یک پرداخت ثبت‌شده. */ + #[Route('/api/v1/session/{uuid}/payments/{paymentUuid}', methods: ['DELETE'])] + public function deleteSessionPayment(string $uuid, string $paymentUuid, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + $this->assertPatientGate($entityType, $entityId); + + $session = $this->sessionRepo->findByUuid($uuid); + if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404); + } + $payment = $this->sessionPaymentRepo->findByUuid($paymentUuid); + if ($payment === null || $payment->getSession()->getUuid() !== $uuid) { + return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, 'پرداخت یافت نشد', 404, 'paymentUuid'); + } + + $this->patientService->deletePayment($payment, $user); + + return $this->success($this->sessionWithBilling($session)); + } + + /** تاریخچه‌ی تغییرات مالی/خدماتی مراجعه (Audit Log). */ + #[Route('/api/v1/session/{uuid}/audit-log', methods: ['GET'])] + public function sessionAuditLog(string $uuid, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + $this->assertPatientGate($entityType, $entityId); + + $session = $this->sessionRepo->findByUuid($uuid); + if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404); + } + + return $this->success($this->sessionAuditRepo->findBySessionUuid($uuid)); + } + private function resolveEntity(User $user): array { if ($user->hasRole('ROLE_DOCTOR')) {