feat: add visit price requirement feature
- Introduced a new boolean flag `require_visit_price` in the `EntityInsurancePricing` to enforce visit price for appointments. - Updated the appointment creation endpoints to validate `visit_price_rials` based on the new flag. - Added `visit_price_rials` field to the `Appointment` entity to store the visit price. - Enhanced the `PatientService` to validate visit price during session creation. - Updated API documentation to reflect changes in appointment and insurance pricing. - Implemented a new service `VisitPriceRequirementResolver` to determine if a visit price is required for a doctor based on their pricing settings. - Added migrations to update the database schema for the new fields.
This commit is contained in:
@@ -42,6 +42,7 @@ class AdminApiController extends BaseController
|
||||
private readonly \App\Payment\Service\PaymentManager $paymentManager,
|
||||
private readonly \App\Payment\Repository\PaymentRepository $paymentRepo,
|
||||
private readonly \App\Patient\Service\PatientResolver $patientResolver,
|
||||
private readonly \App\Insurance\Service\VisitPriceRequirementResolver $visitPriceResolver,
|
||||
) {}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────
|
||||
@@ -911,6 +912,11 @@ class AdminApiController extends BaseController
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $doctorUuid]);
|
||||
if (!$doctor) return $this->error(ErrorCodes::DOCTOR_NOT_FOUND, 'پزشک یافت نشد', 404);
|
||||
|
||||
$visitPriceRials = isset($data['visit_price_rials']) ? (int) $data['visit_price_rials'] : null;
|
||||
if ($this->visitPriceResolver->isRequiredForDoctor($doctor) && ($visitPriceRials ?? 0) <= 0) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'هزینه ویزیت الزامی است', 422, 'visit_price_rials');
|
||||
}
|
||||
|
||||
// Identity is keyed on the national code (unique) so the case-file stays
|
||||
// single per person even when booked under a different mobile.
|
||||
$patient = $this->patientResolver->resolveForBooking($nationalCode, $mobile, $patientName);
|
||||
@@ -920,6 +926,7 @@ class AdminApiController extends BaseController
|
||||
$appointment->setPatientName($patientName);
|
||||
$appointment->setPatientMobile($mobile);
|
||||
if (!empty($data['note'])) $appointment->setNote($data['note']);
|
||||
if ($visitPriceRials !== null) $appointment->setVisitPriceRials($visitPriceRials);
|
||||
foreach ($serviceItems as $si) {
|
||||
$appointment->addServiceItem($si);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Insurance\Service\VisitPriceRequirementResolver;
|
||||
use App\Patient\Service\PatientResolver;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
@@ -42,6 +43,7 @@ class MyAppointmentsController extends BaseController
|
||||
private readonly PatientResolver $patientResolver,
|
||||
private readonly \App\Auth\Repository\UserRepository $userRepo,
|
||||
private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo,
|
||||
private readonly VisitPriceRequirementResolver $visitPriceResolver,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/my/appointment', methods: ['POST'])]
|
||||
@@ -127,6 +129,11 @@ class MyAppointmentsController extends BaseController
|
||||
return $this->error(ErrorCodes::FORBIDDEN, 'برای این پزشک مجاز به ثبت نوبت نیستید', 403);
|
||||
}
|
||||
|
||||
$visitPriceRials = isset($data['visit_price_rials']) ? (int) $data['visit_price_rials'] : null;
|
||||
if ($this->visitPriceResolver->isRequiredForDoctor($doctor) && ($visitPriceRials ?? 0) <= 0) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'هزینه ویزیت الزامی است', 422, 'visit_price_rials');
|
||||
}
|
||||
|
||||
// Identity is keyed on the national code (unique) so the case-file stays
|
||||
// single per person even when booked under a different mobile.
|
||||
$patient = $this->patientResolver->resolveForBooking($nationalCode, $mobile, $patientName);
|
||||
@@ -163,6 +170,9 @@ class MyAppointmentsController extends BaseController
|
||||
if (isset($data['deposit_amount_rials'])) {
|
||||
$appointment->setDepositAmountRials((int) $data['deposit_amount_rials']);
|
||||
}
|
||||
if ($visitPriceRials !== null) {
|
||||
$appointment->setVisitPriceRials($visitPriceRials);
|
||||
}
|
||||
// The resolver returns the patient keyed on national code, so its profile
|
||||
// name is the real identity. Snapshot that (not the free-typed modal name)
|
||||
// so the appointment never diverges from an existing profile; fall back to
|
||||
|
||||
@@ -159,6 +159,9 @@ class Appointment
|
||||
#[ORM\Column(name: 'deposit_amount_rials', type: 'integer', nullable: true)]
|
||||
private ?int $depositAmountRials = null;
|
||||
|
||||
#[ORM\Column(name: 'visit_price_rials', type: 'integer', nullable: true)]
|
||||
private ?int $visitPriceRials = null;
|
||||
|
||||
/**
|
||||
* Reserve-list entry (نوبت رزرو): booked for a day, not a time slot.
|
||||
* slotStart/slotEnd hold that day's midnight so date queries keep working.
|
||||
@@ -245,6 +248,7 @@ class Appointment
|
||||
public function getStaff(): ?\App\Staff\Entity\ClinicStaff { return $this->staff; }
|
||||
public function isDepositRequired(): bool { return $this->depositRequired; }
|
||||
public function getDepositAmountRials(): ?int { return $this->depositAmountRials; }
|
||||
public function getVisitPriceRials(): ?int { return $this->visitPriceRials; }
|
||||
public function isReserve(): bool { return $this->isReserve; }
|
||||
|
||||
public function setServiceSection(?\App\ClinicService\Entity\ServiceSection $v): self { $this->serviceSection = $v; return $this; }
|
||||
@@ -252,6 +256,7 @@ class Appointment
|
||||
public function setStaff(?\App\Staff\Entity\ClinicStaff $v): self { $this->staff = $v; return $this; }
|
||||
public function setDepositRequired(bool $v): self { $this->depositRequired = $v; return $this; }
|
||||
public function setDepositAmountRials(?int $v): self { $this->depositAmountRials = $v; return $this; }
|
||||
public function setVisitPriceRials(?int $v): self { $this->visitPriceRials = $v; return $this; }
|
||||
|
||||
/**
|
||||
* Move the appointment to a new slot (جا به جایی نوبت) and/or flip its
|
||||
@@ -347,6 +352,7 @@ class Appointment
|
||||
'staff' => $this->staff ? ['uuid' => $this->staff->getUuid(), 'full_name' => $this->staff->getFullName()] : null,
|
||||
'deposit_required' => $this->depositRequired,
|
||||
'deposit_amount_rials' => $this->depositAmountRials,
|
||||
'visit_price_rials' => $this->visitPriceRials,
|
||||
'is_reserve' => $this->isReserve,
|
||||
'version' => $this->version,
|
||||
'created_at' => $this->createdAt,
|
||||
|
||||
@@ -229,10 +229,12 @@ class InsuranceController extends BaseController
|
||||
$rows = $this->pricingRepo->findByEntity($entityType, $entityId);
|
||||
|
||||
$freeVisitPriceRials = 0;
|
||||
$requireVisitPrice = false;
|
||||
$perInsurance = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($row->isFreeVisit()) {
|
||||
$freeVisitPriceRials = $row->getPatientShareRials();
|
||||
$requireVisitPrice = $row->isRequireVisitPrice();
|
||||
} else {
|
||||
$perInsurance[$row->getInsuranceId()] = $row->getPatientShareRials();
|
||||
}
|
||||
@@ -251,6 +253,7 @@ class InsuranceController extends BaseController
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'free_visit_price_rials' => $freeVisitPriceRials,
|
||||
'require_visit_price' => $requireVisitPrice,
|
||||
'insurances' => $insurances,
|
||||
]);
|
||||
}
|
||||
@@ -266,8 +269,24 @@ class InsuranceController extends BaseController
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (array_key_exists('free_visit_price_rials', $data)) {
|
||||
$this->upsertPricing($entityType, $entityId, null, (int) $data['free_visit_price_rials']);
|
||||
$freeVisitRow = $this->pricingRepo->findOneForInsurance($entityType, $entityId, null);
|
||||
|
||||
$requireVisitPrice = array_key_exists('require_visit_price', $data)
|
||||
? (bool) $data['require_visit_price']
|
||||
: ($freeVisitRow?->isRequireVisitPrice() ?? false);
|
||||
|
||||
$freeVisitPrice = array_key_exists('free_visit_price_rials', $data)
|
||||
? (int) $data['free_visit_price_rials']
|
||||
: ($freeVisitRow?->getPatientShareRials() ?? 0);
|
||||
|
||||
if ($requireVisitPrice && $freeVisitPrice <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'با فعال بودن «الزامی کردن هزینه ویزیت»، قیمت ویزیت آزاد الزامی است', 422, 'free_visit_price_rials');
|
||||
}
|
||||
|
||||
$touchesFreeVisit = array_key_exists('free_visit_price_rials', $data) || array_key_exists('require_visit_price', $data);
|
||||
if ($touchesFreeVisit && ($freeVisitRow !== null || $freeVisitPrice > 0)) {
|
||||
$this->upsertPricing($entityType, $entityId, null, $freeVisitPrice)
|
||||
->setRequireVisitPrice($requireVisitPrice);
|
||||
}
|
||||
|
||||
foreach (($data['insurances'] ?? []) as $row) {
|
||||
@@ -290,7 +309,7 @@ class InsuranceController extends BaseController
|
||||
return $this->getInsurancePricing($user);
|
||||
}
|
||||
|
||||
private function upsertPricing(string $entityType, int $entityId, ?int $insuranceId, int $shareRials): void
|
||||
private function upsertPricing(string $entityType, int $entityId, ?int $insuranceId, int $shareRials): EntityInsurancePricing
|
||||
{
|
||||
$row = $this->pricingRepo->findOneForInsurance($entityType, $entityId, $insuranceId);
|
||||
if ($row === null) {
|
||||
@@ -299,6 +318,8 @@ class InsuranceController extends BaseController
|
||||
$row->setPatientShareRials($shareRials);
|
||||
}
|
||||
$this->pricingRepo->save($row, false);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ── TenantInsurance — قراردادهای بیمهی tenant ─────────────────────────────
|
||||
|
||||
@@ -31,6 +31,10 @@ class EntityInsurancePricing
|
||||
#[ORM\Column(name: 'patient_share_rials', type: 'integer')]
|
||||
private int $patientShareRials = 0;
|
||||
|
||||
/** Only meaningful on the free-visit row (insurance_id = NULL). */
|
||||
#[ORM\Column(name: 'require_visit_price', type: 'boolean', options: ['default' => false])]
|
||||
private bool $requireVisitPrice = false;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
@@ -50,8 +54,10 @@ class EntityInsurancePricing
|
||||
public function getPatientShareRials(): int { return $this->patientShareRials; }
|
||||
|
||||
public function isFreeVisit(): bool { return $this->insuranceId === null; }
|
||||
public function isRequireVisitPrice(): bool { return $this->requireVisitPrice; }
|
||||
|
||||
public function setPatientShareRials(int $v): self { $this->patientShareRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setRequireVisitPrice(bool $v): self { $this->requireVisitPrice = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
@@ -61,6 +67,7 @@ class EntityInsurancePricing
|
||||
'entity_id' => $this->entityId,
|
||||
'insurance_id' => $this->insuranceId,
|
||||
'patient_share_rials' => $this->patientShareRials,
|
||||
'require_visit_price' => $this->requireVisitPrice,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Insurance\Service;
|
||||
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Insurance\Entity\EntityInsurancePricing;
|
||||
use App\Insurance\Repository\EntityInsurancePricingRepository;
|
||||
|
||||
/**
|
||||
* فلگ «الزامی کردن هزینه ویزیت» را برای پزشکِ یک نوبت resolve میکند:
|
||||
* ردیف قیمتگذاری خود پزشک اگر موجود باشد؛ وگرنه کلینیکِ واحد پزشک
|
||||
* (همان ترتیب resolve کردن tenant در PatientService).
|
||||
*/
|
||||
class VisitPriceRequirementResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityInsurancePricingRepository $pricingRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
) {}
|
||||
|
||||
public function isRequiredForDoctor(Doctor $doctor): bool
|
||||
{
|
||||
$row = $this->pricingRepo->findOneForInsurance(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), null);
|
||||
if ($row !== null) {
|
||||
return $row->isRequireVisitPrice();
|
||||
}
|
||||
|
||||
$clinics = $this->clinicRepo->findByDoctor($doctor);
|
||||
if (count($clinics) === 1) {
|
||||
return $this->pricingRepo
|
||||
->findOneForInsurance(EntityInsurancePricing::TYPE_CLINIC, $clinics[0]->getId(), null)
|
||||
?->isRequireVisitPrice() ?? false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Billing\Service\BillingCalculator;
|
||||
use App\Billing\ValueObject\Money;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Insurance\Repository\EntityInsurancePricingRepository;
|
||||
use App\Insurance\Service\TenantInsuranceService;
|
||||
use App\Inventory\Repository\InventoryItemRepository;
|
||||
use App\Inventory\Repository\InventoryPackageRepository;
|
||||
@@ -48,6 +49,7 @@ class PatientService
|
||||
private readonly TenantInsuranceService $tenantInsuranceService,
|
||||
private readonly BillingCalculator $billingCalculator,
|
||||
private readonly WalletService $walletService,
|
||||
private readonly EntityInsurancePricingRepository $pricingRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -138,6 +140,11 @@ class PatientService
|
||||
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'])) {
|
||||
|
||||
Reference in New Issue
Block a user