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 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
Reference in New Issue
Block a user