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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-17 15:11:07 +03:30
co-authored by Claude Fable 5
parent d8c8ba0df7
commit cd1c9529cb
5 changed files with 240 additions and 5 deletions
+3
View File
@@ -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
{
@@ -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();
}
}
}
@@ -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]);
}
}
@@ -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();
}
}
}
+210 -5
View File
@@ -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);
}
}
/**