Merge branch 'dev' into main
# Conflicts: # docs/api/doctor.md
This commit is contained in:
@@ -30,6 +30,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use App\Shared\Util\PersianText;
|
||||
|
||||
#[OA\Tag(name: 'Admin')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
@@ -38,9 +39,13 @@ class AdminApiController extends BaseController
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly \App\Appointment\Service\SlotCalculatorService $slotCalculator,
|
||||
private readonly \App\Appointment\Service\BookingContextResolver $bookingContext,
|
||||
private readonly \App\Insurance\Service\TenantInsuranceCleanupService $insuranceCleanup,
|
||||
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,
|
||||
private readonly \App\Appointment\Service\AppointmentConfirmationService $appointmentConfirmation,
|
||||
) {}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────
|
||||
@@ -453,7 +458,7 @@ class AdminApiController extends BaseController
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = InputValidator::toEnglishDigits(trim((string) ($data['mobile'] ?? '')));
|
||||
$name = trim((string) ($data['name'] ?? ''));
|
||||
$name = PersianText::stripDoctorTitle((string) ($data['name'] ?? ''));
|
||||
|
||||
if ($mobile === '' || $name === '') {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'موبایل و نام الزامی هستند', 422);
|
||||
@@ -857,35 +862,93 @@ class AdminApiController extends BaseController
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$slotStart = (int) ($data['slot_start'] ?? 0);
|
||||
$slotEnd = (int) ($data['slot_end'] ?? 0);
|
||||
$mobile = trim($data['patient_mobile'] ?? '');
|
||||
$mobile = InputValidator::toEnglishDigits(trim($data['patient_mobile'] ?? ''));
|
||||
$patientName = trim($data['patient_name'] ?? '');
|
||||
$nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? '')));
|
||||
|
||||
// سرویسهای نوبت (چند سرویس). `duration_from_services` فقط در نوبتدهی سرویسی
|
||||
// true است و آنگاه slot_end از مجموع مدت سرویسها محاسبه میشود؛ در حالت اسلاتی
|
||||
// سرویسها صرفاً پیوست میشوند و ساعت پایانِ دستی حفظ میماند.
|
||||
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
|
||||
$computeDuration = (bool) ($data['duration_from_services'] ?? false);
|
||||
// مدتِ override منشی برای همین نوبت (پیشفرض سرویس تغییر نمیکند). { uuid: minutes }
|
||||
$durationOverrides = (array) ($data['service_durations'] ?? []);
|
||||
$serviceItems = [];
|
||||
if (!empty($serviceUuids)) {
|
||||
$itemRepo = $this->em->getRepository(\App\ClinicService\Entity\ServiceItem::class);
|
||||
$totalMinutes = 0;
|
||||
foreach ($serviceUuids as $u) {
|
||||
$item = $itemRepo->findOneBy(['uuid' => $u]);
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'سرویس یافت نشد', 422, 'service_item_uuids');
|
||||
}
|
||||
if ($computeDuration) {
|
||||
if (!$item->isBookable()) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
||||
}
|
||||
$duration = isset($durationOverrides[$u]) && (int) $durationOverrides[$u] > 0
|
||||
? (int) $durationOverrides[$u]
|
||||
: (int) ($item->getDurationMinutes() ?? 0);
|
||||
if ($duration <= 0) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += $duration;
|
||||
}
|
||||
$serviceItems[] = $item;
|
||||
}
|
||||
if ($computeDuration) {
|
||||
$slotEnd = $slotStart + $totalMinutes * 60;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'doctor_uuid، slot_start، slot_end، patient_mobile و patient_name الزامی است', 422);
|
||||
}
|
||||
|
||||
if ($nationalCode === '') {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'کد ملی بیمار الزامی است', 422, 'patient_national_code');
|
||||
}
|
||||
if (!InputValidator::isValidIranNationalCode($nationalCode)) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'کد ملی نامعتبر است', 422, 'patient_national_code');
|
||||
}
|
||||
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $doctorUuid]);
|
||||
if (!$doctor) return $this->error(ErrorCodes::DOCTOR_NOT_FOUND, 'پزشک یافت نشد', 404);
|
||||
|
||||
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
if (!$patient) {
|
||||
$patient = new User($mobile);
|
||||
$patient->setRealName($patientName);
|
||||
$patient->setRoles(['ROLE_USER']);
|
||||
$this->em->persist($patient);
|
||||
$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);
|
||||
|
||||
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
|
||||
$appointment->setPatientNationalCode($nationalCode);
|
||||
$appointment->setPatientName($patientName);
|
||||
$appointment->setPatientMobile($mobile);
|
||||
if (!empty($data['note'])) $appointment->setNote($data['note']);
|
||||
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
|
||||
if ($visitPriceRials !== null) $appointment->setVisitPriceRials($visitPriceRials);
|
||||
foreach ($serviceItems as $si) {
|
||||
$appointment->addServiceItem($si);
|
||||
}
|
||||
$bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null);
|
||||
$appointment->setClinic($bookingClinic);
|
||||
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic);
|
||||
if ($locationId !== null) $appointment->setAddressId($locationId);
|
||||
|
||||
// نوبتِ ثبتشده توسط ادمین پرداخت آنلاین ندارد و منتظر چیزی نیست؛ قطعی است.
|
||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
|
||||
try {
|
||||
$this->em->getRepository(Appointment::class)->bookAtomically($appointment);
|
||||
} catch (SlotTakenException) {
|
||||
return $this->error(ErrorCodes::SLOT_TAKEN, 'این نوبت قبلاً رزرو شده است', 409);
|
||||
}
|
||||
|
||||
$this->appointmentConfirmation->onConfirmed($appointment);
|
||||
|
||||
return $this->success([
|
||||
'uuid' => $appointment->getUuid(),
|
||||
'slot_start' => $slotStart,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Command;
|
||||
|
||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorAddressRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Moves an existing weekly schedule from the personal context into a clinic.
|
||||
*
|
||||
* The clinic_id migration marks every pre-existing schedule as personal, because
|
||||
* nothing in the data says otherwise. A schedule whose sessions actually point at
|
||||
* a clinic address needs to be moved by hand — this command does that, and refuses
|
||||
* when the sessions do not agree with the target clinic.
|
||||
*/
|
||||
#[AsCommand(name: 'app:schedule:assign-clinic', description: 'Move a doctor\'s personal weekly schedule into a clinic context')]
|
||||
class AssignScheduleClinicCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorAddressRepository $addressRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('doctor-uuid', InputArgument::REQUIRED, 'Doctor uuid')
|
||||
->addArgument('clinic-uuid', InputArgument::REQUIRED, 'Target clinic uuid')
|
||||
->addOption('force', null, InputOption::VALUE_NONE, 'Move even when some sessions use an address outside the clinic');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid((string) $input->getArgument('doctor-uuid'));
|
||||
$clinic = $this->clinicRepo->findByUuid((string) $input->getArgument('clinic-uuid'));
|
||||
|
||||
if ($doctor === null || $clinic === null) {
|
||||
$io->error('Doctor or clinic not found.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$clinic->hasDoctor($doctor)) {
|
||||
$io->error('This doctor is not a member of that clinic.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, null);
|
||||
if ($schedule === null) {
|
||||
$io->warning('This doctor has no personal schedule to move.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic) !== null) {
|
||||
$io->error('A schedule already exists for this doctor in that clinic; merge it manually.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$foreign = $this->sessionsOutsideClinic($schedule->getDaySchedule(), $doctor, $clinic->getId());
|
||||
if ($foreign !== [] && !$input->getOption('force')) {
|
||||
$io->error(sprintf(
|
||||
'Sessions use address ids outside the clinic: %s. Re-run with --force to move anyway.',
|
||||
implode(', ', $foreign)
|
||||
));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$schedule->setClinic($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$io->success(sprintf('Schedule %s moved to clinic "%s".', $schedule->getUuid(), $clinic->getName()));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return int[] address ids referenced by the schedule that the clinic does not own */
|
||||
private function sessionsOutsideClinic(array $daySchedule, \App\Doctor\Entity\Doctor $doctor, int $clinicId): array
|
||||
{
|
||||
$owned = [];
|
||||
foreach ($this->addressRepo->findForContext($doctor, $clinicId) as $address) {
|
||||
$owned[(int) $address->getId()] = true;
|
||||
}
|
||||
|
||||
$foreign = [];
|
||||
foreach ($daySchedule as $day) {
|
||||
foreach (($day['sessions'] ?? []) as $session) {
|
||||
$id = (int) ($session['location_id'] ?? 0);
|
||||
if ($id > 0 && !isset($owned[$id])) {
|
||||
$foreign[$id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($foreign);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Command;
|
||||
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use App\Doctor\Repository\DoctorAddressRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Finds active shifts whose location_id is empty or points outside their own
|
||||
* booking context.
|
||||
*
|
||||
* The API rejects such shifts today (validateSessions), but rows created before
|
||||
* that validation are still around. They are invisible in the panel yet make a
|
||||
* location look bookable when nothing can actually be reserved there — the
|
||||
* public site used to advertise a "personal practice" built entirely out of one.
|
||||
*/
|
||||
#[AsCommand(name: 'app:schedule:audit-locations', description: 'Report (and optionally disable) schedule shifts pointing at no valid address')]
|
||||
class AuditScheduleLocationsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly DoctorAddressRepository $addressRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('fix', null, InputOption::VALUE_NONE, 'Deactivate the offending shifts instead of only reporting them');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$fix = (bool) $input->getOption('fix');
|
||||
|
||||
$rows = [];
|
||||
$touched = 0;
|
||||
|
||||
foreach ($this->scheduleRepo->findAll() as $schedule) {
|
||||
$clinic = $schedule->getClinic();
|
||||
$allowed = [];
|
||||
foreach ($this->addressRepo->findForContext($schedule->getDoctor(), $clinic?->getId()) as $address) {
|
||||
$allowed[(int) $address->getId()] = true;
|
||||
}
|
||||
|
||||
$setting = $schedule->getSetting();
|
||||
$changed = false;
|
||||
|
||||
foreach ($setting as $dayKey => $day) {
|
||||
foreach (($day['sessions'] ?? []) as $index => $session) {
|
||||
if (!($session['active'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$locationId = (int) ($session['location_id'] ?? 0);
|
||||
if ($locationId !== 0 && isset($allowed[$locationId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
$schedule->getDoctor()->getUuid(),
|
||||
$clinic === null ? 'personal' : ($clinic->getName() ?? 'clinic'),
|
||||
WeeklySchedule::DAYS[(int) $dayKey] ?? (string) $dayKey,
|
||||
$locationId === 0 ? '—' : (string) $locationId,
|
||||
$locationId === 0 ? 'no address' : 'address outside context',
|
||||
];
|
||||
|
||||
if ($fix) {
|
||||
$setting[$dayKey]['sessions'][$index]['active'] = false;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
// دادهی کاربر حذف نمیشود؛ فقط غیرفعال میشود تا قابل بازیابی بماند.
|
||||
$schedule->setSetting($setting);
|
||||
$touched++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
$io->success('No schedule shift points at a missing or foreign address.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->table(['doctor', 'context', 'day', 'location_id', 'problem'], $rows);
|
||||
|
||||
if (!$fix) {
|
||||
$io->warning(sprintf('%d offending shift(s). Re-run with --fix to deactivate them.', count($rows)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
$io->success(sprintf('Deactivated %d shift(s) across %d schedule(s).', count($rows), $touched));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Command;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Appointment\Service\AppointmentConfirmationService;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Finds appointments that were confirmed without getting a patient session.
|
||||
*
|
||||
* Until the confirmation side-effects were funnelled through
|
||||
* AppointmentConfirmationService, only the two PATCH endpoints created records —
|
||||
* anything confirmed by an online payment never did. Those appointments are
|
||||
* still missing their case file, and completed ones need it just as much: a
|
||||
* visit that was never filed does not stop mattering because time passed.
|
||||
*/
|
||||
#[AsCommand(name: 'app:appointment:backfill-sessions', description: 'Report (and optionally create) missing patient sessions for confirmed appointments')]
|
||||
class BackfillAppointmentSessionsCommand extends Command
|
||||
{
|
||||
private const FILED_STATUSES = [
|
||||
Appointment::STATUS_CONFIRMED,
|
||||
Appointment::STATUS_COMPLETED,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly AppointmentRepository $appointmentRepo,
|
||||
private readonly PatientSessionRepository $sessionRepo,
|
||||
private readonly AppointmentConfirmationService $confirmation,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('fix', null, InputOption::VALUE_NONE, 'Create the missing sessions instead of only reporting them');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$fix = (bool) $input->getOption('fix');
|
||||
|
||||
$appointments = $this->appointmentRepo->createQueryBuilder('a')
|
||||
->where('a.status IN (:statuses)')
|
||||
->andWhere('a.isReserve = false')
|
||||
->setParameter('statuses', self::FILED_STATUSES)
|
||||
->orderBy('a.id', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$rows = [];
|
||||
$missing = [];
|
||||
|
||||
foreach ($appointments as $appointment) {
|
||||
$clinic = $appointment->getClinic();
|
||||
[$entityType, $entityId] = $clinic !== null
|
||||
? ['clinic', (int) $clinic->getId()]
|
||||
: ['doctor', (int) $appointment->getDoctor()->getId()];
|
||||
|
||||
if ($this->sessionRepo->findByAppointmentAndEntity($appointment, $entityType, $entityId) !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$missing[] = [$appointment, $entityType, $entityId];
|
||||
$rows[] = [
|
||||
$appointment->getUuid(),
|
||||
$appointment->getStatus(),
|
||||
date('Y-m-d H:i', $appointment->getSlotStart()),
|
||||
$appointment->getDoctor()->getName(),
|
||||
$clinic !== null ? ($clinic->getName() ?? 'clinic') : 'personal',
|
||||
];
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
$io->success('Every confirmed appointment already has its patient session.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->table(['appointment', 'status', 'slot', 'doctor', 'context'], $rows);
|
||||
|
||||
if (!$fix) {
|
||||
$io->warning(sprintf('%d appointment(s) without a session. Re-run with --fix to create them.', count($rows)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
foreach ($missing as [$appointment, $entityType, $entityId]) {
|
||||
$this->confirmation->onConfirmed($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
if ($this->sessionRepo->findByAppointmentAndEntity($appointment, $entityType, $entityId) !== null) {
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
// آنهایی که ساخته نشدند عمداً رد شدهاند (نبودِ ویژگی patient_records برای آن
|
||||
// tenant)؛ جدا گزارش میشوند تا با شکست اشتباه گرفته نشوند.
|
||||
$skipped = count($missing) - $created;
|
||||
$io->success(sprintf('Created %d session(s).', $created));
|
||||
if ($skipped > 0) {
|
||||
$io->note(sprintf('%d skipped — their tenant has no patient_records feature.', $skipped));
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Command;
|
||||
|
||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Converts legacy weekly-schedule settings to the canonical shape.
|
||||
*
|
||||
* Canonical: keys 0..6 (0 = Saturday), each {"sessions": [...]}, plus a "meta"
|
||||
* key. Legacy rows are either a bare JSON list ([{"sessions": ...}]) or miss
|
||||
* some day keys entirely; readers treat the absent days as day-off, which does
|
||||
* not match what the owner configured and hides the row from per-day tooling.
|
||||
*/
|
||||
#[AsCommand(name: 'app:schedule:normalize-format', description: 'Report (and optionally rewrite) weekly schedules stored in a legacy setting format')]
|
||||
class NormalizeScheduleFormatCommand extends Command
|
||||
{
|
||||
private const DAY_COUNT = 7;
|
||||
|
||||
public function __construct(
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('fix', null, InputOption::VALUE_NONE, 'Rewrite offending rows to the canonical 7-day format');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$fix = (bool) $input->getOption('fix');
|
||||
|
||||
$rows = [];
|
||||
$touched = 0;
|
||||
|
||||
foreach ($this->scheduleRepo->findAll() as $schedule) {
|
||||
$setting = $schedule->getDaySchedule();
|
||||
// json_decode کلیدهای "0".."6" را به int تبدیل میکند؛ ردیف canonical کامل
|
||||
// هم list سرراست است. تنها نشانهٔ قابلاتکای فرمت legacy، غیبت روزهاست.
|
||||
$missing = [];
|
||||
for ($i = 0; $i < self::DAY_COUNT; $i++) {
|
||||
if (!isset($setting[$i]['sessions'])) {
|
||||
$missing[] = $i;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$clinic = $schedule->getClinic();
|
||||
$rows[] = [
|
||||
$schedule->getDoctor()->getUuid(),
|
||||
$clinic === null ? 'personal' : ($clinic->getName() ?? 'clinic'),
|
||||
implode(',', $missing),
|
||||
];
|
||||
|
||||
if (!$fix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
for ($i = 0; $i < self::DAY_COUNT; $i++) {
|
||||
$day = $setting[$i] ?? null;
|
||||
$normalized[$i] = is_array($day) && isset($day['sessions'])
|
||||
? $day
|
||||
: ['sessions' => []];
|
||||
}
|
||||
|
||||
// setSetting جای meta موجود را حفظ میکند؛ setMeta آن را (در نبودش با
|
||||
// DEFAULT_META) صریح در ردیف مینویسد تا فرمت canonical کامل شود.
|
||||
$schedule->setSetting($normalized);
|
||||
$schedule->setMeta($schedule->getMeta());
|
||||
$touched++;
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
$io->success('All weekly schedules already use the canonical 7-day format.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->table(['doctor', 'context', 'missing days'], $rows);
|
||||
|
||||
if (!$fix) {
|
||||
$io->warning(sprintf('%d legacy row(s). Re-run with --fix to rewrite them.', count($rows)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
$io->success(sprintf('Rewrote %d schedule(s) to the canonical format.', $touched));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,13 @@ use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Appointment\Repository\SlotTakenException;
|
||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use App\Appointment\Security\AppointmentAccessChecker;
|
||||
use App\Appointment\Service\AppointmentConfirmationService;
|
||||
use App\Appointment\Service\SlotCalculatorService;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Patient\Service\PatientService;
|
||||
use App\Shared\Service\InputValidator;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
@@ -30,11 +32,43 @@ class AppointmentController extends BaseController
|
||||
private readonly AppointmentRepository $appointmentRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly SlotCalculatorService $slotCalculator,
|
||||
private readonly PatientService $patientService,
|
||||
private readonly AppointmentConfirmationService $appointmentConfirmation,
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly \App\Appointment\Service\BookingContextResolver $bookingContext,
|
||||
private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo,
|
||||
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
|
||||
private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo,
|
||||
private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
|
||||
private readonly \App\Staff\Repository\ClinicStaffRepository $staffRepo,
|
||||
private readonly \App\Appointment\Repository\AppointmentEventRepository $eventRepo,
|
||||
private readonly \App\Appointment\Security\AppointmentAccessChecker $accessChecker,
|
||||
private readonly \Psr\Log\LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
private const CANCEL_STATUSES = [
|
||||
Appointment::STATUS_CANCELLED_BY_DOCTOR,
|
||||
Appointment::STATUS_CANCELLED_BY_USER,
|
||||
];
|
||||
|
||||
/**
|
||||
* ثبت رویداد لغو در Timeline نوبت + لاگ سطح warning (تا در app_log هم persist شود).
|
||||
* بعد از ذخیرهی موفق نوبت صدا زده میشود.
|
||||
*/
|
||||
private function recordCancellation(Appointment $appointment, string $status, ?string $reason, User $user): void
|
||||
{
|
||||
$actorName = $user->getRealName() ?: $user->getMobileNumber();
|
||||
|
||||
$event = new \App\Appointment\Entity\AppointmentEvent($appointment, \App\Appointment\Entity\AppointmentEvent::TYPE_CANCELLED, 'نوبت لغو شد');
|
||||
$event->setActor($user->getId(), $actorName);
|
||||
$event->setReason($reason);
|
||||
$this->eventRepo->save($event);
|
||||
|
||||
$this->logger->warning(sprintf(
|
||||
'Appointment cancelled: uuid=%s status=%s by user=%d(%s) reason=%s',
|
||||
$appointment->getUuid(), $status, (int) $user->getId(), $actorName, $reason ?? '-'
|
||||
));
|
||||
}
|
||||
|
||||
// ── Public: available slots ───────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
@@ -124,12 +158,183 @@ class AppointmentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
|
||||
}
|
||||
|
||||
$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date);
|
||||
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
|
||||
$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date, $clinic);
|
||||
|
||||
return $this->success([
|
||||
'doctor_uuid' => $doctorUuid,
|
||||
'clinic_uuid' => $clinic?->getUuid(),
|
||||
'date' => $date,
|
||||
'sessions' => $sessions,
|
||||
// خالیبودن دلایل مختلفی دارد؛ کلاینت نباید همه را «تعطیل» بنامد.
|
||||
'empty_reason' => $sessions === []
|
||||
? $this->slotCalculator->explainEmptyDay($doctor, $date, $clinic)
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* حالت نوبتدهی سرویسی: زمانهای خالیِ کافی برای مجموعِ مدت سرویسهای انتخابشده.
|
||||
* فقط سرویسهای «نمایش در نوبتدهی» (bookable) و دارای مدت پذیرفته میشوند.
|
||||
*
|
||||
* GET /api/v1/appointment-service-slots?doctor_uuid=..&date=Y-m-d&service_item_uuids[]=..
|
||||
*/
|
||||
#[Route('/api/v1/appointment-service-slots', methods: ['GET'])]
|
||||
public function serviceSlots(Request $request): JsonResponse
|
||||
{
|
||||
$doctorUuid = trim($request->query->get('doctor_uuid', ''));
|
||||
$date = trim($request->query->get('date', ''));
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
|
||||
}
|
||||
|
||||
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
|
||||
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
|
||||
$mode = ($schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META)['booking_mode'] ?? WeeklySchedule::MODE_SLOT;
|
||||
if ($mode !== WeeklySchedule::MODE_SERVICE) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پزشک در حالت نوبتدهی سرویسی نیست', 422);
|
||||
}
|
||||
|
||||
$uuids = array_values(array_filter(array_map('trim', (array) $request->query->all('service_item_uuids'))));
|
||||
if (empty($uuids)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids');
|
||||
}
|
||||
|
||||
// مدتِ override منشی (فقط برای همین محاسبه؛ پیشفرض سرویس تغییر نمیکند). durations[uuid]=minutes
|
||||
$overrides = (array) $request->query->all('durations');
|
||||
|
||||
$totalMinutes = 0;
|
||||
foreach ($uuids as $u) {
|
||||
$item = $this->itemRepo->findByUuid($u);
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids');
|
||||
}
|
||||
if (!$item->isBookable()) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
||||
}
|
||||
$duration = isset($overrides[$u]) && (int) $overrides[$u] > 0
|
||||
? (int) $overrides[$u]
|
||||
: (int) ($item->getDurationMinutes() ?? 0);
|
||||
if ($duration <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += $duration;
|
||||
}
|
||||
|
||||
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
|
||||
|
||||
return $this->success([
|
||||
'doctor_uuid' => $doctorUuid,
|
||||
'date' => $date,
|
||||
'total_duration_minutes' => $totalMinutes,
|
||||
'buffer_minutes' => (int) $meta['buffer_minutes'],
|
||||
'clinic_uuid' => $clinic?->getUuid(),
|
||||
'start_times' => $this->slotCalculator->getServiceStartTimes($doctor, $date, $totalMinutes, $clinic),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* عمومی: روش نوبتدهی پزشک + سرویسهای قابلانتخاب برای نوبتگیری سرویسی.
|
||||
* سایت با این پاسخ تصمیم میگیرد مرحلهٔ انتخاب سرویس را نشان دهد یا جریان اسلاتی.
|
||||
*
|
||||
* GET /api/v1/appointment-booking-services/{doctorUuid}
|
||||
*/
|
||||
#[Route('/api/v1/appointment-booking-services/{doctorUuid}', methods: ['GET'])]
|
||||
public function bookingServices(string $doctorUuid, Request $request): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
|
||||
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
|
||||
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
|
||||
|
||||
return $this->success([
|
||||
'doctor_uuid' => $doctorUuid,
|
||||
'clinic_uuid' => $clinic?->getUuid(),
|
||||
'booking_mode' => $meta['booking_mode'],
|
||||
'buffer_minutes' => (int) $meta['buffer_minutes'],
|
||||
'services' => $this->bookableServices($doctor, $clinic),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* عمومی: همهٔ محلهای نوبتدهی یک پزشک — مطب شخصی و هر کلینیکی که در آن برنامهٔ
|
||||
* فعال دارد. سایت باید همه را نشان دهد؛ انتخاب یکی و پنهانکردن بقیه یعنی حذف
|
||||
* بخشی از ظرفیت واقعی پزشک.
|
||||
*
|
||||
* GET /api/v1/appointment-booking-locations/{doctorUuid}
|
||||
*/
|
||||
#[Route('/api/v1/appointment-booking-locations/{doctorUuid}', methods: ['GET'])]
|
||||
public function bookingLocations(string $doctorUuid, Request $request): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$date = trim((string) $request->query->get('date', ''));
|
||||
if ($date !== '' && !$this->isCalendarDate($date)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
|
||||
}
|
||||
|
||||
$locations = [];
|
||||
foreach ($this->scheduleRepo->findAllByDoctor($doctor) as $schedule) {
|
||||
$clinic = $schedule->getClinic();
|
||||
$addresses = $this->addressRepo->findForContext($doctor, $clinic?->getId());
|
||||
|
||||
// محلی که آدرسی ندارد، محل نیست — چیزی برای مراجعهٔ بیمار وجود ندارد.
|
||||
if ($addresses === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$byId = [];
|
||||
foreach ($addresses as $a) {
|
||||
$byId[(int) $a->getId()] = $a;
|
||||
}
|
||||
|
||||
// و برنامهای که هیچ شیفتش روی آدرسهای همین محیط ننشسته، قابل رزرو نیست.
|
||||
$hours = $this->openingHours($schedule, $byId);
|
||||
if ($hours === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$meta = $schedule->getMeta();
|
||||
$address = $byId[$hours[0]['location_id']] ?? $addresses[0];
|
||||
|
||||
$locations[] = [
|
||||
'location_uuid' => $address->getUuid(),
|
||||
'type' => $clinic === null ? 'personal' : 'clinic',
|
||||
'title' => $clinic?->getName() ?? ($address->getName() ?: 'مطب شخصی'),
|
||||
'address' => $address->getAddress(),
|
||||
'clinic_uuid' => $clinic?->getUuid(),
|
||||
'booking_mode' => $meta['booking_mode'],
|
||||
'buffer_minutes' => (int) $meta['buffer_minutes'],
|
||||
'opening_hours' => $hours,
|
||||
'services' => $meta['booking_mode'] === WeeklySchedule::MODE_SERVICE
|
||||
? $this->bookableServices($doctor, $clinic)
|
||||
: [],
|
||||
'next_available_at' => $this->slotCalculator->findNextAvailableStart($doctor, $clinic),
|
||||
'available_on_date' => $date === ''
|
||||
? null
|
||||
: $this->slotCalculator->getAvailableSlots($doctor, $date, $clinic) !== [],
|
||||
];
|
||||
}
|
||||
|
||||
// پیشفرضِ سایت = زودترین نوبت آزاد؛ محلهای بدون ظرفیت به انتها میروند.
|
||||
usort($locations, fn(array $a, array $b) => ($a['next_available_at'] ?? PHP_INT_MAX) <=> ($b['next_available_at'] ?? PHP_INT_MAX));
|
||||
|
||||
return $this->success([
|
||||
'doctor_uuid' => $doctorUuid,
|
||||
'date' => $date !== '' ? $date : null,
|
||||
'booking_locations' => $locations,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -147,24 +352,26 @@ class AppointmentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سال یا ماه نامعتبر است', 422, 'month');
|
||||
}
|
||||
|
||||
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
|
||||
$daysInMonth = (int) date('t', (int) strtotime(sprintf('%04d-%02d-01', $year, $month)));
|
||||
|
||||
$disabled = [];
|
||||
$enabled = [];
|
||||
for ($day = 1; $day <= $daysInMonth; $day++) {
|
||||
$date = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
if ($this->slotCalculator->hasAnyAvailability($doctor, $date)) {
|
||||
if ($this->slotCalculator->hasAnyAvailability($doctor, $date, $clinic)) {
|
||||
$enabled[] = $date;
|
||||
} else {
|
||||
$disabled[] = $date;
|
||||
}
|
||||
}
|
||||
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
|
||||
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
|
||||
|
||||
return $this->success([
|
||||
'year' => $year,
|
||||
'clinic_uuid' => $clinic?->getUuid(),
|
||||
'month' => $month,
|
||||
'disabled_dates' => $disabled,
|
||||
'enabled_dates' => $enabled,
|
||||
@@ -220,6 +427,30 @@ class AppointmentController extends BaseController
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$slotStart = (int) ($data['slot_start'] ?? 0);
|
||||
$slotEnd = (int) ($data['slot_end'] ?? 0);
|
||||
$clinicUuid = $data['clinic_uuid'] ?? null;
|
||||
|
||||
// حالت نوبتدهی سرویسی: مدت نوبت = مجموع مدت سرویسهای bookableِ انتخابشده،
|
||||
// و slot_end سمت سرور محاسبه میشود (به مقدار کلاینت اعتماد نمیشود).
|
||||
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
|
||||
$serviceItem = null;
|
||||
if (!empty($serviceUuids)) {
|
||||
$totalMinutes = 0;
|
||||
foreach ($serviceUuids as $u) {
|
||||
$item = $this->itemRepo->findByUuid($u);
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids');
|
||||
}
|
||||
if (!$item->isBookable()) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
||||
}
|
||||
if (($item->getDurationMinutes() ?? 0) <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += (int) $item->getDurationMinutes();
|
||||
$serviceItem ??= $item;
|
||||
}
|
||||
$slotEnd = $slotStart + $totalMinutes * 60;
|
||||
}
|
||||
|
||||
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid، slot_start و slot_end الزامی است', 422);
|
||||
@@ -234,6 +465,14 @@ class AppointmentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$bookingClinic = $this->bookingClinic($doctor, $clinicUuid);
|
||||
|
||||
// سرویس باید متعلق به همان محلی باشد که نوبت در آن ثبت میشود؛ وگرنه بیمار
|
||||
// میتوانست سرویس کلینیک را روی نوبت مطب شخصی بنشاند.
|
||||
if ($serviceItem !== null && ($err = $this->assertServicesMatchContext($serviceUuids, $doctor, $bookingClinic)) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
$forSelf = (bool) ($data['for_self'] ?? true);
|
||||
|
||||
// کد ملی و جنسیت بیمار همیشه الزامی است (چه برای خود، چه برای دیگری).
|
||||
@@ -258,6 +497,7 @@ class AppointmentController extends BaseController
|
||||
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
|
||||
$appointment->setPatientNationalCode($nationalCode);
|
||||
$appointment->setPatientGender($gender);
|
||||
if ($serviceItem !== null) $appointment->setServiceItem($serviceItem);
|
||||
if (isset($data['note'])) $appointment->setNote($data['note']);
|
||||
|
||||
// نمایندهی دامنهی مبدأ رزرو (از Origin مرورگر)؛ گاردِ نهایی پورسانت در لحظهی
|
||||
@@ -268,7 +508,8 @@ class AppointmentController extends BaseController
|
||||
}
|
||||
|
||||
// آدرس نوبت از روی session متناظر در برنامهی هفتگی تعیین میشود (location_id).
|
||||
$locationId = $this->resolveSlotLocationId($doctor, $slotStart);
|
||||
$appointment->setClinic($bookingClinic);
|
||||
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic);
|
||||
if ($locationId !== null) {
|
||||
$appointment->setAddressId($locationId);
|
||||
}
|
||||
@@ -337,7 +578,7 @@ class AppointmentController extends BaseController
|
||||
}
|
||||
|
||||
if (!$this->canView($appointment, $user)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()]);
|
||||
@@ -392,14 +633,56 @@ class AppointmentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
$isOwner = $doctor->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN');
|
||||
|
||||
// مدیر کلینیک لیست پزشک عضو را میبیند، ولی فقط نوبتهای همان کلینیک —
|
||||
// نوبتهای مطب شخصی پزشک به کلینیک نشت نمیکند.
|
||||
$scopeClinic = $isOwner ? null : $this->accessChecker->viewableClinicFor($user, $doctor);
|
||||
if (!$isOwner && $scopeClinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$status = $request->query->get('status');
|
||||
$appointments = $this->appointmentRepo->findByDoctor($doctor, $status);
|
||||
// بدون هیچ پارامتر فیلتر/صفحهبندی، رفتار قدیمی (لیست کامل، پاسخ تودرتو) حفظ
|
||||
// میشود تا کلاینتهای موجود نشکنند. با هر فیلتری پاسخ صفحهبندیشده میآید.
|
||||
$filterKeys = ['statuses', 'from', 'to', 'q', 'service_uuid', 'page', 'limit'];
|
||||
$isFiltered = (bool) array_filter($filterKeys, fn(string $k) => $request->query->has($k));
|
||||
|
||||
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
|
||||
if (!$isFiltered) {
|
||||
$status = $request->query->get('status');
|
||||
$appointments = $this->appointmentRepo->findByDoctor($doctor, $status, $scopeClinic);
|
||||
|
||||
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
|
||||
}
|
||||
|
||||
// هم `statuses[]=a&statuses[]=b` و هم `statuses=a,b` پذیرفته میشود؛ سینتکس
|
||||
// دوم بدون براکت در Symfony به رشته تبدیل میشود و all() استثنا میدهد.
|
||||
$rawStatuses = $request->query->has('statuses') ? $request->query->all()['statuses'] : [];
|
||||
$statuses = is_array($rawStatuses) ? $rawStatuses : explode(',', (string) $rawStatuses);
|
||||
if ($statuses === [] && $request->query->get('status')) {
|
||||
$statuses = [$request->query->get('status')];
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
|
||||
|
||||
$result = $this->appointmentRepo->searchByDoctor(
|
||||
doctor: $doctor,
|
||||
statuses: array_values(array_filter($statuses, fn($s) => is_string($s) && $s !== '')),
|
||||
clinic: $scopeClinic,
|
||||
from: $request->query->has('from') ? (int) $request->query->get('from') : null,
|
||||
to: $request->query->has('to') ? (int) $request->query->get('to') : null,
|
||||
query: $request->query->get('q'),
|
||||
serviceUuid: $request->query->get('service_uuid'),
|
||||
page: $page,
|
||||
limit: $limit,
|
||||
);
|
||||
|
||||
return $this->paginated(
|
||||
array_map(fn(Appointment $a) => $a->toArray(), $result['items']),
|
||||
$result['total'],
|
||||
$page,
|
||||
$limit,
|
||||
);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
@@ -446,23 +729,122 @@ class AppointmentController extends BaseController
|
||||
|
||||
private function canView(Appointment $a, User $user): bool
|
||||
{
|
||||
return $a->getUser()->getId() === $user->getId()
|
||||
|| $a->getDoctor()->getUser()->getId() === $user->getId()
|
||||
|| $user->hasRole('ROLE_ADMIN');
|
||||
return $this->accessChecker->canView($a, $user);
|
||||
}
|
||||
|
||||
private function canManage(Appointment $a, User $user): bool
|
||||
{
|
||||
return $a->getUser()->getId() === $user->getId()
|
||||
|| $a->getDoctor()->getUser()->getId() === $user->getId()
|
||||
|| $user->hasRole('ROLE_ADMIN');
|
||||
return $this->accessChecker->canManage($a, $user);
|
||||
}
|
||||
|
||||
private function resolveSlotLocationId(Doctor $doctor, int $slotStart): ?int
|
||||
/**
|
||||
* محلِ نوبتدهی این درخواست. بدون clinic_uuid یعنی مطب شخصی پزشک — نه «هر محلی
|
||||
* که پیدا شد»: با چند برنامهٔ همزمان، حدسزدن محل یعنی ثبت خاموشِ نوبت در جای
|
||||
* اشتباه.
|
||||
*/
|
||||
private function bookingClinic(Doctor $doctor, ?string $clinicUuid): ?Clinic
|
||||
{
|
||||
return $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
|
||||
return $this->bookingContext->resolve($doctor, $clinicUuid);
|
||||
}
|
||||
|
||||
private function assertServicesMatchContext(array $serviceUuids, Doctor $doctor, ?Clinic $clinic): ?JsonResponse
|
||||
{
|
||||
[$type, $id] = $clinic !== null
|
||||
? ['clinic', $clinic->getId()]
|
||||
: ['doctor', $doctor->getId()];
|
||||
|
||||
foreach ($serviceUuids as $uuid) {
|
||||
$section = $this->itemRepo->findByUuid($uuid)?->getSection();
|
||||
if ($section === null || $section->getEntityType() !== $type || $section->getEntityId() !== $id) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سرویس انتخابشده به این محل نوبتدهی تعلق ندارد', 422, 'service_item_uuids');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* تاریخ Y-m-d که واقعاً روی تقویم وجود دارد. regex تنها کافی نیست: «2026-13-99»
|
||||
* الگو را پاس میکند ولی روزی نیست.
|
||||
*/
|
||||
private function isCalendarDate(string $date): bool
|
||||
{
|
||||
$parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
|
||||
|
||||
return $parsed !== false && $parsed->format('Y-m-d') === $date;
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
private function bookableServices(Doctor $doctor, ?Clinic $clinic): array
|
||||
{
|
||||
[$type, $id] = $clinic !== null
|
||||
? ['clinic', $clinic->getId()]
|
||||
: ['doctor', $doctor->getId()];
|
||||
|
||||
return array_map(function (\App\ClinicService\Entity\ServiceItem $i): array {
|
||||
$section = $i->getSection();
|
||||
return [
|
||||
'uuid' => $i->getUuid(),
|
||||
'name' => $i->getName(),
|
||||
'duration_minutes' => $i->getDurationMinutes(),
|
||||
'price_rials' => $i->getPriceRials(),
|
||||
'service_section' => ['uuid' => $section->getUuid(), 'name' => $section->getName()],
|
||||
];
|
||||
}, $this->itemRepo->findBookableByEntity($type, $id));
|
||||
}
|
||||
|
||||
/**
|
||||
* شیفتهای فعال هفته بهصورت تخت، با نام انگلیسی روز — آمادهٔ نگاشت به
|
||||
* openingHoursSpecification در schema.org. کلیدهای برنامه 0..6 هستند و 0 شنبه است.
|
||||
*
|
||||
* فقط شیفتهایی برمیگردند که آدرسشان در $allowedAddressIds باشد.
|
||||
*
|
||||
* @param array<int, \App\Doctor\Entity\DoctorAddress> $allowedAddressIds
|
||||
*
|
||||
* @return array<int, array{day: string, day_index: int, location_id: int, opens: string, closes: string}>
|
||||
*/
|
||||
private function openingHours(WeeklySchedule $schedule, array $allowedAddressIds): array
|
||||
{
|
||||
$hours = [];
|
||||
|
||||
foreach ($schedule->getDaySchedule() as $dayIndex => $day) {
|
||||
$dayName = WeeklySchedule::DAYS[(int) $dayIndex] ?? null;
|
||||
if ($dayName === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (($day['sessions'] ?? []) as $session) {
|
||||
if (!($session['active'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// شیفتی که آدرس ندارد یا به آدرسی خارج از این محیط اشاره میکند،
|
||||
// قابل رزرو نیست و نباید ساعت کاری تولید کند.
|
||||
$locationId = (int) ($session['location_id'] ?? 0);
|
||||
if ($locationId === 0 || !isset($allowedAddressIds[$locationId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$opens = $session['start_time'] ?? null;
|
||||
$closes = $session['end_time'] ?? null;
|
||||
if ($opens === null || $closes === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hours[] = [
|
||||
'day' => ucfirst($dayName),
|
||||
'day_index' => (int) $dayIndex,
|
||||
'location_id' => $locationId,
|
||||
'opens' => $opens,
|
||||
'closes' => $closes,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $hours;
|
||||
}
|
||||
|
||||
|
||||
#[OA\Patch(
|
||||
path: '/api/v1/appointment/{uuid}/status',
|
||||
summary: 'Update the status of an appointment',
|
||||
@@ -513,14 +895,20 @@ class AppointmentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$this->canManage($appointment, $user)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$newStatus = trim($data['status'] ?? '');
|
||||
$version = (int) ($data['version'] ?? $appointment->getVersion());
|
||||
|
||||
// لغو مجوز جداگانه دارد: منشی بهصورت پیشفرض اجازهٔ لغو ندارد ولی وضعیتهای
|
||||
// دیگر را تغییر میدهد.
|
||||
$action = in_array($newStatus, self::CANCEL_STATUSES, true)
|
||||
? AppointmentAccessChecker::ACTION_CANCEL
|
||||
: AppointmentAccessChecker::ACTION_UPDATE_STATUS;
|
||||
|
||||
if (!$this->accessChecker->can($appointment, $user, $action)) {
|
||||
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
if (!$appointment->canTransitionTo($newStatus)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
|
||||
'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus
|
||||
@@ -536,9 +924,228 @@ class AppointmentController extends BaseController
|
||||
}
|
||||
|
||||
if ($newStatus === Appointment::STATUS_CONFIRMED) {
|
||||
$this->patientService->autoCreateOnAppointmentConfirm($appointment);
|
||||
$this->appointmentConfirmation->onConfirmed($appointment);
|
||||
}
|
||||
|
||||
if (in_array($newStatus, self::CANCEL_STATUSES, true)) {
|
||||
$reason = isset($data['cancel_reason']) ? trim((string) $data['cancel_reason']) : '';
|
||||
$this->recordCancellation($appointment, $newStatus, $reason !== '' ? $reason : null, $user);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* قطعیکردن نوبت بههمراه پرداخت — «ثبتشده» → «قطعی».
|
||||
*
|
||||
* یک عملِ اتمیک: انتقال وضعیت، ساخت/یافتنِ پروندهٔ همان محیط با سرویسهای نوبت،
|
||||
* و ثبت پرداختهای کامل یا جزئی روی همان مراجعه. اگر هر مرحله شکست بخورد هیچکدام
|
||||
* ثبت نمیشوند.
|
||||
*/
|
||||
#[OA\Post(
|
||||
path: '/api/v1/appointment/{uuid}/confirm',
|
||||
summary: 'Confirm an appointment and register its payments on the patient case file',
|
||||
security: [['bearerAuth' => []]],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Appointment confirmed'),
|
||||
new OA\Response(response: 403, description: 'Access denied, or payments sent without the patient_records feature'),
|
||||
new OA\Response(response: 404, description: 'Appointment not found'),
|
||||
new OA\Response(response: 409, description: 'Version conflict'),
|
||||
new OA\Response(response: 422, description: 'Invalid transition or payment'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/appointment/{uuid}/confirm', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function confirm(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
||||
if ($appointment === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$this->accessChecker->can($appointment, $user, AppointmentAccessChecker::ACTION_UPDATE_STATUS)) {
|
||||
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$version = (int) ($data['version'] ?? $appointment->getVersion());
|
||||
|
||||
if (!$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
|
||||
'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), Appointment::STATUS_CONFIRMED
|
||||
), 422);
|
||||
}
|
||||
|
||||
$payments = [];
|
||||
foreach ((array) ($data['payments'] ?? []) as $row) {
|
||||
$method = trim((string) ($row['method'] ?? ''));
|
||||
$amount = (int) ($row['amount_rials'] ?? 0);
|
||||
if (!in_array($method, \App\Patient\Entity\SessionPayment::METHODS, true)) {
|
||||
return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, ErrorCodes::message(ErrorCodes::ERR_SESSION_PAYMENT_INVALID), 422, 'method');
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, ErrorCodes::message(ErrorCodes::ERR_SESSION_PAYMENT_INVALID), 422, 'amount_rials');
|
||||
}
|
||||
$payments[] = ['method' => $method, 'amount_rials' => $amount];
|
||||
}
|
||||
|
||||
try {
|
||||
$session = $this->appointmentConfirmation->confirmWithPayments($appointment, $version, $payments, $user);
|
||||
} catch (OptimisticLockException) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'appointment' => $appointment->toArray(),
|
||||
'session' => $session === null ? null : [
|
||||
'uuid' => $session->getUuid(),
|
||||
'visit_price_rials' => $session->getVisitPriceRials(),
|
||||
'services_total_rials' => $session->getServicesTotalRials(),
|
||||
'final_price_rials' => $session->getFinalPriceRials(),
|
||||
'discount_rials' => $session->getDiscountRials(),
|
||||
'paid_total_rials' => $session->getPaidTotalRials(),
|
||||
'remaining_rials' => $session->getRemainingRials(),
|
||||
'is_paid' => $session->getRemainingRials() === 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی).
|
||||
* All fields optional; only what is present in the body changes. Slot moves
|
||||
* go through rescheduleTo so active_slot_key stays consistent. Optimistic
|
||||
* lock via `version` like the status endpoint.
|
||||
*/
|
||||
#[Route('/api/v1/appointment/{uuid}', methods: ['PATCH'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
||||
if ($appointment === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$this->canManage($appointment, $user)) {
|
||||
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$version = (int) ($data['version'] ?? $appointment->getVersion());
|
||||
|
||||
// status درونخطی نباید گیت لغو را دور بزند.
|
||||
if (in_array(trim((string) ($data['status'] ?? '')), self::CANCEL_STATUSES, true)
|
||||
&& !$this->accessChecker->canCancel($appointment, $user)) {
|
||||
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
// Slot move / reserve toggle — both times together, or neither.
|
||||
$hasStart = array_key_exists('slot_start', $data);
|
||||
$hasEnd = array_key_exists('slot_end', $data);
|
||||
if ($hasStart !== $hasEnd) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'slot_start و slot_end باید با هم ارسال شوند', 422, 'slot_start');
|
||||
}
|
||||
if ($hasStart || array_key_exists('is_reserve', $data)) {
|
||||
$newStart = $hasStart ? (int) $data['slot_start'] : $appointment->getSlotStart();
|
||||
$newEnd = $hasStart ? (int) $data['slot_end'] : $appointment->getSlotEnd();
|
||||
if ($newEnd < $newStart) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'ساعت پایان قبل از شروع است', 422, 'slot_end');
|
||||
}
|
||||
$isReserve = array_key_exists('is_reserve', $data) ? (bool) $data['is_reserve'] : null;
|
||||
$movingToLiveSlot = ($isReserve ?? $appointment->isReserve()) === false;
|
||||
if ($movingToLiveSlot && ($newStart !== $appointment->getSlotStart() || $newEnd !== $appointment->getSlotEnd())
|
||||
&& $this->appointmentRepo->isSlotTaken($appointment->getDoctor(), $newStart, $newEnd, $appointment->getId())) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'slot_start');
|
||||
}
|
||||
$appointment->rescheduleTo($newStart, $newEnd, $isReserve);
|
||||
}
|
||||
|
||||
// Workflow relations — empty string clears, uuid assigns, unknown → 422.
|
||||
foreach ([
|
||||
'service_section_uuid' => [$this->sectionRepo, 'setServiceSection', 'بخش'],
|
||||
'service_item_uuid' => [$this->itemRepo, 'setServiceItem', 'سرویس'],
|
||||
'staff_uuid' => [$this->staffRepo, 'setStaff', 'پرسنل'],
|
||||
] as $key => [$repo, $setter, $label]) {
|
||||
if (!array_key_exists($key, $data)) {
|
||||
continue;
|
||||
}
|
||||
$value = trim((string) ($data[$key] ?? ''));
|
||||
if ($value === '') {
|
||||
$appointment->$setter(null);
|
||||
continue;
|
||||
}
|
||||
$entity = $repo->findByUuid($value);
|
||||
if ($entity === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, $label . ' یافت نشد', 422, $key);
|
||||
}
|
||||
$appointment->$setter($entity);
|
||||
}
|
||||
|
||||
if (array_key_exists('deposit_required', $data)) {
|
||||
$appointment->setDepositRequired((bool) $data['deposit_required']);
|
||||
}
|
||||
if (array_key_exists('deposit_amount_rials', $data)) {
|
||||
$appointment->setDepositAmountRials($data['deposit_amount_rials'] !== null ? (int) $data['deposit_amount_rials'] : null);
|
||||
}
|
||||
if (array_key_exists('note', $data)) {
|
||||
$appointment->setNote($data['note'] !== null ? trim((string) $data['note']) : null);
|
||||
}
|
||||
// جایگزینی نوبت — swap the person occupying the slot.
|
||||
if (array_key_exists('patient_name', $data)) {
|
||||
$appointment->setPatientName($data['patient_name'] !== null ? trim((string) $data['patient_name']) : null);
|
||||
}
|
||||
if (array_key_exists('patient_mobile', $data)) {
|
||||
$appointment->setPatientMobile($data['patient_mobile'] !== null ? trim((string) $data['patient_mobile']) : null);
|
||||
}
|
||||
|
||||
// Optional status transition, same rules as the dedicated endpoint.
|
||||
$newStatus = trim((string) ($data['status'] ?? ''));
|
||||
$cancelledTo = null;
|
||||
if ($newStatus !== '' && $newStatus !== $appointment->getStatus()) {
|
||||
if (!$appointment->canTransitionTo($newStatus)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
|
||||
'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus
|
||||
), 422);
|
||||
}
|
||||
$appointment->transitionTo($newStatus);
|
||||
if ($newStatus === Appointment::STATUS_CONFIRMED) {
|
||||
$this->appointmentConfirmation->onConfirmed($appointment);
|
||||
}
|
||||
if (in_array($newStatus, self::CANCEL_STATUSES, true)) {
|
||||
$cancelledTo = $newStatus;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->appointmentRepo->saveWithLock($appointment, $version);
|
||||
} catch (OptimisticLockException) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
|
||||
} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) {
|
||||
// race backstop: someone grabbed the slot between the pre-check and the flush
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'slot_start');
|
||||
}
|
||||
|
||||
if ($cancelledTo !== null) {
|
||||
$reason = isset($data['cancel_reason']) ? trim((string) $data['cancel_reason']) : '';
|
||||
$this->recordCancellation($appointment, $cancelledTo, $reason !== '' ? $reason : null, $user);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()]);
|
||||
}
|
||||
|
||||
// ── Timeline: رویدادهای یک نوبت ───────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/appointment/{uuid}/events', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function events(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
||||
if ($appointment === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
||||
}
|
||||
if (!$this->canView($appointment, $user)) {
|
||||
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
return $this->success($this->eventRepo->findByAppointmentUuid($uuid));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,14 @@ use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Doctor\Repository\DoctorAddressRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Context\EntityContext;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -34,8 +37,75 @@ class AppointmentSettingsController extends BaseController
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly DoctorAddressRepository $addressRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* نوع نوبتدهی پس از اولین ثبت غیرقابلتغییر است — اما فقط داخل همان context.
|
||||
* پزشکی که در مطب شخصی نوبتدهی اسلاتی دارد، همچنان میتواند در کلینیک سرویسی
|
||||
* انتخاب کند.
|
||||
*/
|
||||
private function assertModeImmutable(?string $prevMode, array $newMeta): ?JsonResponse
|
||||
{
|
||||
if ($prevMode !== null && ($newMeta['booking_mode'] ?? null) !== $prevMode) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع نوبتدهی پس از ثبت قابل تغییر نیست', 422, 'booking_mode');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* در حالت نوبتدهی سرویسی، صاحبِ همین context باید حداقل یک سرویسِ
|
||||
* «نمایش در نوبتدهی» داشته باشد؛ وگرنه هیچ نوبتی قابلمحاسبه نیست.
|
||||
*
|
||||
* سرویسها polymorphicاند و بین پزشک و کلینیک مشترک نمیشوند، پس شمارش باید با
|
||||
* همان (entity_type, entity_id) محیط انجام شود — نه همیشه 'doctor'.
|
||||
*/
|
||||
private function serviceModeHasNoBookable(array $meta, Doctor $doctor, ?Clinic $clinic): bool
|
||||
{
|
||||
if (($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) !== WeeklySchedule::MODE_SERVICE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$type, $id] = $clinic !== null
|
||||
? [EntityContext::TYPE_CLINIC, $clinic->getId()]
|
||||
: [EntityContext::TYPE_DOCTOR, $doctor->getId()];
|
||||
|
||||
return $this->itemRepo->countBookableByEntity($type, $id) === 0;
|
||||
}
|
||||
|
||||
private function noBookableServiceError(?Clinic $clinic): JsonResponse
|
||||
{
|
||||
$message = $clinic !== null
|
||||
? 'برای نوبتدهی سرویسی، کلینیک باید حداقل یک سرویس با «نمایش در نوبتدهی» داشته باشد'
|
||||
: 'برای نوبتدهی سرویسی حداقل یک سرویس با «نمایش در نوبتدهی» لازم است';
|
||||
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, $message, 422, 'booking_mode');
|
||||
}
|
||||
|
||||
/**
|
||||
* محیطی که این درخواست در آن اجرا میشود: کلینیکِ دادهشده، یا null یعنی مطب
|
||||
* شخصی پزشک. پزشک حتماً باید عضو آن کلینیک باشد، وگرنه اصلاً چنین محیطی وجود
|
||||
* ندارد.
|
||||
*/
|
||||
private function contextClinic(?string $clinicUuid, Doctor $doctor): ?Clinic
|
||||
{
|
||||
if ($clinicUuid === null || trim($clinicUuid) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid(trim($clinicUuid));
|
||||
if ($clinic === null) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$clinic->hasDoctor($doctor)) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این پزشک عضو کلینیک انتخابشده نیست', 422);
|
||||
}
|
||||
|
||||
return $clinic;
|
||||
}
|
||||
|
||||
// ── Weekly Schedule ───────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/appointment-settings/weekly-schedule', methods: ['POST'])]
|
||||
@@ -49,26 +119,37 @@ class AppointmentSettingsController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
$clinic = $this->contextClinic($data['clinic_uuid'] ?? null, $doctor);
|
||||
|
||||
if (($err = $this->denyDoctorAccess($doctor, $user, 'update', $clinic)) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
if (($err = $this->validateSessionsHaveLocation($data['schedule'] ?? [])) !== null) {
|
||||
if (($err = $this->validateSessions($data['schedule'] ?? [], $doctor, $clinic)) !== null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, $err, 422);
|
||||
}
|
||||
|
||||
// Only one schedule per doctor — upsert
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
// یک برنامه به ازای هر context — upsert
|
||||
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
|
||||
$prevMode = $schedule?->getStoredBookingMode();
|
||||
if ($schedule !== null) {
|
||||
$schedule->setSetting($data['schedule'] ?? []);
|
||||
} else {
|
||||
$schedule = new WeeklySchedule($doctor, $data['schedule'] ?? []);
|
||||
$schedule = new WeeklySchedule($doctor, $data['schedule'] ?? [], $clinic);
|
||||
}
|
||||
|
||||
if (isset($data['meta']) && is_array($data['meta'])) {
|
||||
$schedule->setMeta($data['meta']);
|
||||
}
|
||||
|
||||
if (($err = $this->assertModeImmutable($prevMode, $schedule->getMeta())) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor, $clinic)) {
|
||||
return $this->noBookableServiceError($clinic);
|
||||
}
|
||||
|
||||
$this->scheduleRepo->save($schedule);
|
||||
|
||||
return $this->success(['data' => $schedule->toArray()], 201);
|
||||
@@ -77,24 +158,32 @@ class AppointmentSettingsController extends BaseController
|
||||
#[Route('/api/v1/appointment-settings/weekly-schedule/{uuid}', methods: ['PATCH'])]
|
||||
public function updateSchedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
// uuid may be doctor uuid or schedule uuid
|
||||
$schedule = $this->scheduleRepo->findByUuid($uuid);
|
||||
if ($schedule === null) {
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
$schedule = $doctor ? $this->scheduleRepo->findByDoctor($doctor) : null;
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
|
||||
}
|
||||
$clinic = $this->contextClinic($data['clinic_uuid'] ?? $request->query->get('clinic_uuid'), $doctor);
|
||||
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
|
||||
} else {
|
||||
$clinic = $schedule->getClinic();
|
||||
}
|
||||
|
||||
if ($schedule === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($schedule->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'update', $clinic)) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$prevMode = $schedule->getStoredBookingMode();
|
||||
if (isset($data['schedule'])) {
|
||||
if (($err = $this->validateSessionsHaveLocation($data['schedule'])) !== null) {
|
||||
if (($err = $this->validateSessions($data['schedule'], $schedule->getDoctor(), $clinic)) !== null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, $err, 422);
|
||||
}
|
||||
$schedule->setSetting($data['schedule']);
|
||||
@@ -103,26 +192,38 @@ class AppointmentSettingsController extends BaseController
|
||||
$schedule->setMeta($data['meta']);
|
||||
}
|
||||
|
||||
if (($err = $this->assertModeImmutable($prevMode, $schedule->getMeta())) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $schedule->getDoctor(), $clinic)) {
|
||||
return $this->noBookableServiceError($clinic);
|
||||
}
|
||||
|
||||
$this->scheduleRepo->save($schedule);
|
||||
|
||||
return $this->success(['data' => $schedule->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/weekly-schedule/{uuid}', methods: ['GET'])]
|
||||
public function getSchedule(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
public function getSchedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
// Try doctor uuid first, then schedule uuid
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
$schedule = $doctor
|
||||
? $this->scheduleRepo->findByDoctor($doctor)
|
||||
: $this->scheduleRepo->findByUuid($uuid);
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
if ($doctor !== null) {
|
||||
$clinic = $this->contextClinic($request->query->get('clinic_uuid'), $doctor);
|
||||
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
|
||||
} else {
|
||||
$schedule = $this->scheduleRepo->findByUuid($uuid);
|
||||
$clinic = $schedule?->getClinic();
|
||||
}
|
||||
|
||||
if ($schedule === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($schedule->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'view', $clinic)) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
return $this->success(['data' => $schedule->toArray()]);
|
||||
@@ -136,8 +237,8 @@ class AppointmentSettingsController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($schedule->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'update', $schedule->getClinic())) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
$this->scheduleRepo->remove($schedule);
|
||||
@@ -148,20 +249,22 @@ class AppointmentSettingsController extends BaseController
|
||||
// ── Date Overrides ────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/appointment-settings/date-override/list/{doctorUuid}', methods: ['GET'])]
|
||||
public function listOverrides(string $doctorUuid, #[CurrentUser] User $user): JsonResponse
|
||||
public function listOverrides(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
$clinic = $this->contextClinic($request->query->get('clinic_uuid'), $doctor);
|
||||
|
||||
if (($err = $this->denyDoctorAccess($doctor, $user, 'view', $clinic)) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
$overrides = array_map(
|
||||
fn(DateOverride $o) => $o->toArray(),
|
||||
$this->overrideRepo->findByDoctor($doctor)
|
||||
$this->overrideRepo->findByDoctorAndClinic($doctor, $clinic)
|
||||
);
|
||||
|
||||
return $this->success(['data' => $overrides]);
|
||||
@@ -179,8 +282,10 @@ class AppointmentSettingsController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
$clinic = $this->contextClinic($data['clinic_uuid'] ?? null, $doctor);
|
||||
|
||||
if (($err = $this->denyDoctorAccess($doctor, $user, 'update', $clinic)) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
$timestamp = strtotime($dateStr);
|
||||
@@ -188,7 +293,7 @@ class AppointmentSettingsController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است', 422, 'date');
|
||||
}
|
||||
|
||||
$override = new DateOverride($doctor, $timestamp, (bool) ($data['active'] ?? false));
|
||||
$override = new DateOverride($doctor, $timestamp, (bool) ($data['active'] ?? false), $clinic);
|
||||
if (isset($data['reason'])) $override->setReason($data['reason']);
|
||||
if (isset($data['custom_slots'])) $override->setSetting($data['custom_slots']);
|
||||
|
||||
@@ -205,8 +310,8 @@ class AppointmentSettingsController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($override->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'update', $override->getClinic())) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
@@ -231,8 +336,8 @@ class AppointmentSettingsController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($override->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'update', $override->getClinic())) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
$this->overrideRepo->remove($override);
|
||||
@@ -248,8 +353,8 @@ class AppointmentSettingsController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($override->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'view', $override->getClinic())) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
return $this->success(['data' => $override->toArray()]);
|
||||
@@ -258,18 +363,26 @@ class AppointmentSettingsController extends BaseController
|
||||
// ── Holidays ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/appointment-settings/holidays/list/{doctorUuid}', methods: ['GET'])]
|
||||
public function listHolidays(string $doctorUuid, #[CurrentUser] User $user): JsonResponse
|
||||
public function listHolidays(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
$clinic = $this->contextClinic($request->query->get('clinic_uuid'), $doctor);
|
||||
|
||||
if (($err = $this->denyDoctorAccess($doctor, $user, 'view', $clinic)) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
$items = array_map(fn(Holiday $h) => $h->toArray(), $this->holidayRepo->findAllByDoctor($doctor));
|
||||
// محیط کلینیک تعطیلی سراسری پزشک را هم میبیند (باید بداند پزشک نیست)، اما
|
||||
// editable=false یعنی اجازهٔ تغییرش را ندارد.
|
||||
$items = array_map(function (Holiday $h) use ($clinic): array {
|
||||
$data = $h->toArray();
|
||||
$data['editable'] = $clinic === null || $h->getClinic() !== null;
|
||||
return $data;
|
||||
}, $this->holidayRepo->findAllByDoctorInContext($doctor, $clinic));
|
||||
|
||||
return $this->success(['data' => $items]);
|
||||
}
|
||||
@@ -282,8 +395,8 @@ class AppointmentSettingsController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($holiday->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
if (($err = $this->denyDoctorAccess($holiday->getDoctor(), $user, 'update', $holiday->getClinic())) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
$this->holidayRepo->remove($holiday);
|
||||
@@ -304,8 +417,16 @@ class AppointmentSettingsController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
$clinic = $this->contextClinic($data['clinic_uuid'] ?? null, $doctor);
|
||||
|
||||
if (($err = $this->denyDoctorAccess($doctor, $user, 'update', $clinic)) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
// تعطیلی سراسری (بدون clinic_uuid) یعنی «پزشک در هیچ محلی نیست» و مطب شخصی
|
||||
// را هم میبندد؛ فقط خود پزشک یا ادمین حق چنین کاری دارد.
|
||||
if ($clinic === null && !$user->hasRole('ROLE_ADMIN') && $doctor->getUser()->getId() !== $user->getId()) {
|
||||
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'کلینیک فقط میتواند تعطیلی مخصوص خودش را ثبت کند', 403, 'clinic_uuid');
|
||||
}
|
||||
|
||||
$startTs = strtotime($startStr);
|
||||
@@ -315,7 +436,7 @@ class AppointmentSettingsController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'تاریخ نادرست است', 422);
|
||||
}
|
||||
|
||||
$holiday = new Holiday($doctor, $startTs, $endTs);
|
||||
$holiday = new Holiday($doctor, $startTs, $endTs, $clinic);
|
||||
if (isset($data['reason'])) $holiday->setReason($data['reason']);
|
||||
|
||||
$this->holidayRepo->save($holiday);
|
||||
@@ -331,8 +452,8 @@ class AppointmentSettingsController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($holiday->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
if (($err = $this->denyDoctorAccess($holiday->getDoctor(), $user, 'update', $holiday->getClinic())) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
@@ -355,48 +476,81 @@ class AppointmentSettingsController extends BaseController
|
||||
// ── Available Locations ───────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/appointment-settings/available-locations/{doctorUuid}', methods: ['GET'])]
|
||||
public function availableLocations(string $doctorUuid, #[CurrentUser] User $user): JsonResponse
|
||||
public function availableLocations(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
$clinic = $this->contextClinic($request->query->get('clinic_uuid'), $doctor);
|
||||
|
||||
if (($err = $this->denyDoctorAccess($doctor, $user, 'view', $clinic)) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
$clinics = $this->clinicRepo->findByDoctor($doctor);
|
||||
$clinicIds = array_map(fn(Clinic $c) => $c->getId(), $clinics);
|
||||
$clinicMap = [];
|
||||
foreach ($clinics as $clinic) {
|
||||
$clinicMap[$clinic->getId()] = $clinic->getName();
|
||||
}
|
||||
|
||||
$addresses = $this->addressRepo->findAvailableForDoctor($doctor, $clinicIds);
|
||||
|
||||
$result = array_map(function (DoctorAddress $a) use ($clinicMap): array {
|
||||
$data = $a->toArray();
|
||||
$data['clinic_name'] = $a->getClinicId() !== null ? ($clinicMap[$a->getClinicId()] ?? null) : null;
|
||||
return $data;
|
||||
}, $addresses);
|
||||
$result = array_map(
|
||||
fn(DoctorAddress $a): array => $a->toArray($clinic?->getName()),
|
||||
$this->addressRepo->findForContext($doctor, $clinic?->getId())
|
||||
);
|
||||
|
||||
return $this->success(['data' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* هر session فعال در برنامهی هفتگی باید آدرس (location_id) داشته باشد.
|
||||
* در صورت نقص، پیام خطا برمیگرداند؛ در غیر این صورت null.
|
||||
* تنها نقطهٔ تصمیمگیری دربارهٔ «چه کسی تنظیمات نوبتدهی این پزشک را میبیند/مینویسد».
|
||||
*
|
||||
* تصمیم به context وابسته است و نه فقط به شخص:
|
||||
* • مطب شخصی ($clinic === null) فقط برای خود پزشک و ادمین باز است — مالک کلینیک
|
||||
* هیچ کاری با برنامهٔ شخصی پزشک ندارد.
|
||||
* • محیط کلینیک با مجوز appointment_settings همان کلینیک سنجیده میشود، نه
|
||||
* حلقه روی همهٔ کلینیکهای پزشک.
|
||||
*
|
||||
* @param 'view'|'update' $action
|
||||
*/
|
||||
private function validateSessionsHaveLocation(array $schedule): ?string
|
||||
private function denyDoctorAccess(Doctor $doctor, User $user, string $action, ?Clinic $clinic): ?JsonResponse
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $user->getId()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($clinic !== null && $this->permChecker->can($user, $clinic, 'appointment_settings', $action)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* هر شیفت فعال باید آدرسی داشته باشد که به همین context تعلق دارد. بدون بررسی
|
||||
* دوم، کلینیک میتوانست شیفت را روی آدرس مطب شخصی پزشک بنشاند (و برعکس).
|
||||
*/
|
||||
private function validateSessions(array $schedule, Doctor $doctor, ?Clinic $clinic): ?string
|
||||
{
|
||||
$allowed = [];
|
||||
foreach ($this->addressRepo->findForContext($doctor, $clinic?->getId()) as $address) {
|
||||
$allowed[(string) $address->getId()] = true;
|
||||
}
|
||||
|
||||
foreach ($schedule as $day) {
|
||||
foreach (($day['sessions'] ?? []) as $session) {
|
||||
if (($session['active'] ?? false) && empty($session['location_id'])) {
|
||||
if (!($session['active'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$locationId = (string) ($session['location_id'] ?? '');
|
||||
if ($locationId === '') {
|
||||
return 'برای هر شیفت فعال باید آدرس (مطب/کلینیک) انتخاب شود';
|
||||
}
|
||||
|
||||
if (!isset($allowed[$locationId])) {
|
||||
return $clinic !== null
|
||||
? 'آدرس انتخابشده متعلق به این کلینیک نیست'
|
||||
: 'آدرس انتخابشده متعلق به مطب شخصی این پزشک نیست';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,12 @@ 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;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Service\InputValidator;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -34,6 +37,14 @@ class MyAppointmentsController extends BaseController
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly SlotCalculatorService $slotCalculator,
|
||||
private readonly \App\Appointment\Service\BookingContextResolver $bookingContext,
|
||||
private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo,
|
||||
private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
|
||||
private readonly \App\Staff\Repository\ClinicStaffRepository $staffRepo,
|
||||
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'])]
|
||||
@@ -50,14 +61,65 @@ class MyAppointmentsController extends BaseController
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$slotStart = (int) ($data['slot_start'] ?? 0);
|
||||
$slotEnd = (int) ($data['slot_end'] ?? 0);
|
||||
$mobile = trim($data['patient_mobile'] ?? '');
|
||||
$mobile = InputValidator::toEnglishDigits(trim($data['patient_mobile'] ?? ''));
|
||||
$patientName = trim($data['patient_name'] ?? '');
|
||||
$nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? '')));
|
||||
$isReserve = (bool) ($data['is_reserve'] ?? false);
|
||||
|
||||
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) {
|
||||
// Reserve entries are day-level: only a date is picked in the UI, so
|
||||
// slot_end may equal slot_start and the past-slot rule does not apply.
|
||||
if ($isReserve && $slotEnd < $slotStart) {
|
||||
$slotEnd = $slotStart;
|
||||
}
|
||||
|
||||
// حالت نوبتدهی سرویسی: مدت نوبت از مجموعِ مدت سرویسهای انتخابشده تعیین
|
||||
// و slot_end سمت سرور محاسبه میشود (به مقدار کلاینت اعتماد نمیشود).
|
||||
// `duration_from_services` فقط در نوبتدهی سرویسی true است: آنجا مدت نوبت از
|
||||
// مجموع سرویسها محاسبه و slot_end بازنویسی میشود. در حالت اسلاتی، سرویسها
|
||||
// فقط به نوبت پیوست میشوند و ساعت پایانِ دستی حفظ میشود.
|
||||
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
|
||||
$computeDuration = (bool) ($data['duration_from_services'] ?? false);
|
||||
// مدتِ override منشی برای همین نوبت (پیشفرض سرویس تغییر نمیکند). { uuid: minutes }
|
||||
$durationOverrides = (array) ($data['service_durations'] ?? []);
|
||||
$serviceItems = [];
|
||||
if (!empty($serviceUuids) && !$isReserve) {
|
||||
$totalMinutes = 0;
|
||||
foreach ($serviceUuids as $u) {
|
||||
$item = $this->itemRepo->findByUuid($u);
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'سرویس یافت نشد', 422, 'service_item_uuids');
|
||||
}
|
||||
if ($computeDuration) {
|
||||
if (!$item->isBookable()) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
||||
}
|
||||
$duration = isset($durationOverrides[$u]) && (int) $durationOverrides[$u] > 0
|
||||
? (int) $durationOverrides[$u]
|
||||
: (int) ($item->getDurationMinutes() ?? 0);
|
||||
if ($duration <= 0) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += $duration;
|
||||
}
|
||||
$serviceItems[] = $item;
|
||||
}
|
||||
if ($computeDuration) {
|
||||
$slotEnd = $slotStart + $totalMinutes * 60;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($doctorUuid) || $slotStart <= 0 || (!$isReserve && $slotEnd <= $slotStart) || empty($mobile) || empty($patientName)) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'همه فیلدها الزامی است', 422);
|
||||
}
|
||||
|
||||
if ($slotStart < time()) {
|
||||
if ($nationalCode === '') {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'کد ملی بیمار الزامی است', 422, 'patient_national_code');
|
||||
}
|
||||
if (!InputValidator::isValidIranNationalCode($nationalCode)) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'کد ملی نامعتبر است', 422, 'patient_national_code');
|
||||
}
|
||||
|
||||
if (!$isReserve && $slotStart < time()) {
|
||||
return $this->error(ErrorCodes::SLOT_PAST, 'زمان این اسلات گذشته است', 422);
|
||||
}
|
||||
|
||||
@@ -68,23 +130,76 @@ class MyAppointmentsController extends BaseController
|
||||
return $this->error(ErrorCodes::FORBIDDEN, 'برای این پزشک مجاز به ثبت نوبت نیستید', 403);
|
||||
}
|
||||
|
||||
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
if (!$patient) {
|
||||
$patient = new User($mobile);
|
||||
$patient->setRealName($patientName);
|
||||
$patient->setRoles(['ROLE_USER']);
|
||||
$this->em->persist($patient);
|
||||
$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);
|
||||
|
||||
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
|
||||
$appointment->setPatientNationalCode($nationalCode);
|
||||
if (!empty($data['note'])) $appointment->setNote($data['note']);
|
||||
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
|
||||
// محل نوبت باید از همان محیطی بیاید که نوبت در آن ثبت میشود؛ بدون clinic_uuid
|
||||
// یعنی مطب شخصی، نه «هر برنامهای که پیدا شد».
|
||||
$bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null);
|
||||
$appointment->setClinic($bookingClinic);
|
||||
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic);
|
||||
if ($locationId !== null) $appointment->setAddressId($locationId);
|
||||
|
||||
try {
|
||||
$this->appointmentRepo->bookAtomically($appointment);
|
||||
} catch (SlotTakenException) {
|
||||
return $this->error(ErrorCodes::SLOT_TAKEN, 'این نوبت قبلاً رزرو شده است', 409);
|
||||
// Optional clinic-workflow fields (بخش/سرویس/پرسنل/بیعانه) — unknown uuid → 422.
|
||||
foreach ([
|
||||
'service_section_uuid' => [$this->sectionRepo, 'setServiceSection', 'بخش'],
|
||||
'service_item_uuid' => [$this->itemRepo, 'setServiceItem', 'سرویس'],
|
||||
'staff_uuid' => [$this->staffRepo, 'setStaff', 'پرسنل'],
|
||||
] as $key => [$repo, $setter, $label]) {
|
||||
$value = trim((string) ($data[$key] ?? ''));
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$entity = $repo->findByUuid($value);
|
||||
if ($entity === null) {
|
||||
return $this->error(ErrorCodes::VALIDATION, $label . ' یافت نشد', 422);
|
||||
}
|
||||
$appointment->$setter($entity);
|
||||
}
|
||||
// پیوستِ همهٔ سرویسهای انتخابشده؛ سرویسِ اصلی = اولین سرویس (addServiceItem).
|
||||
foreach ($serviceItems as $si) {
|
||||
$appointment->addServiceItem($si);
|
||||
}
|
||||
if (!empty($data['deposit_required'])) {
|
||||
$appointment->setDepositRequired(true);
|
||||
}
|
||||
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
|
||||
// the entered name only for a brand-new patient without a stored name.
|
||||
$appointment->setPatientName($patient->getRealName() ?: $patientName);
|
||||
$appointment->setPatientMobile($mobile);
|
||||
|
||||
// نوبت پنلی «ثبتشده» (pending) متولد میشود، نه قطعی: قطعیشدن یک عملِ جداست
|
||||
// که هزینهها را نشان میدهد و پرداخت میگیرد (POST /appointment/{uuid}/confirm).
|
||||
// pending هم اسلات را اشغال میکند (SLOT_OCCUPYING_STATUSES)، پس جای نوبت
|
||||
// محفوظ میماند. expiresAt ست نمیشود، پس هرگز خودبهخود منقضی نمیشود.
|
||||
|
||||
if ($isReserve) {
|
||||
// Day-level reserve: no slot occupation, plain save (no atomic slot check).
|
||||
$appointment->rescheduleTo($slotStart, $slotEnd, true);
|
||||
$this->appointmentRepo->save($appointment);
|
||||
} else {
|
||||
try {
|
||||
$this->appointmentRepo->bookAtomically($appointment);
|
||||
} catch (SlotTakenException) {
|
||||
return $this->error(ErrorCodes::SLOT_TAKEN, 'این نوبت قبلاً رزرو شده است', 409);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
@@ -92,9 +207,46 @@ class MyAppointmentsController extends BaseController
|
||||
'slot_start' => $slotStart,
|
||||
'slot_end' => $slotEnd,
|
||||
'status' => $appointment->getStatus(),
|
||||
'is_reserve' => $appointment->isReserve(),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking-scoped patient lookup by mobile. Lets the booking form search an
|
||||
* existing patient before asking for national code / name. Unlike
|
||||
* /patient/search-user this is not gated by the patient_records feature and
|
||||
* allows ROLE_ADMIN, because booking must work regardless of subscription.
|
||||
*/
|
||||
#[Route('/api/v1/my/appointment/patient-lookup', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function patientLookup(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$allowed = ['ROLE_DOCTOR', 'ROLE_CLINIC', 'ROLE_SECRETARY', 'ROLE_ADMIN'];
|
||||
if (!array_intersect($allowed, $user->getRoles())) {
|
||||
return $this->error(ErrorCodes::FORBIDDEN, 'دسترسی ندارید', 403);
|
||||
}
|
||||
|
||||
$mobile = InputValidator::toEnglishDigits(trim((string) $request->query->get('mobile', '')));
|
||||
if (!InputValidator::isValidIranMobile($mobile)) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'شماره موبایل نامعتبر است', 422, 'mobile');
|
||||
}
|
||||
|
||||
$patient = $this->userRepo->findByMobile($mobile);
|
||||
if ($patient === null) {
|
||||
return $this->success(['found' => false]);
|
||||
}
|
||||
|
||||
// National code lives on the profile (profiles.national_code), not on User.
|
||||
$nationalCode = $this->profileRepo->findByUser($patient)?->getNationalCode();
|
||||
|
||||
return $this->success([
|
||||
'found' => true,
|
||||
'name' => $patient->getRealName(),
|
||||
'mobile' => $patient->getMobileNumber(),
|
||||
'national_code' => $nationalCode,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/my/appointments', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function myAppointments(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
@@ -106,15 +258,28 @@ class MyAppointmentsController extends BaseController
|
||||
$date = trim((string) $request->query->get('date', ''));
|
||||
$doctorUuid = trim((string) $request->query->get('doctor_uuid', ''));
|
||||
|
||||
// reserve=1 → only reserve-list entries; otherwise the regular slot list.
|
||||
$reserveOnly = $request->query->get('reserve') === '1';
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'DISTINCT a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt, a.version',
|
||||
'a.isReserve, a.depositRequired, a.depositAmountRials, a.note, a.patientName as override_name',
|
||||
'a.patientNationalCode as national_code, a.patientGender as gender',
|
||||
'd.uuid as doctor_uuid, d.name as doctor_name',
|
||||
'u.mobileNumber as patient_mobile, u.realName as patient_name'
|
||||
'u.uuid as patient_uuid, u.mobileNumber as patient_mobile, u.realName as patient_name',
|
||||
'ss.uuid as section_uuid, ss.name as section_name',
|
||||
'si.uuid as service_uuid, si.name as service_name',
|
||||
'st.uuid as staff_uuid, st.fullName as staff_name'
|
||||
)
|
||||
->from(Appointment::class, 'a')
|
||||
->join('a.doctor', 'd')
|
||||
->join('a.user', 'u')
|
||||
->leftJoin('a.serviceSection', 'ss')
|
||||
->leftJoin('a.serviceItem', 'si')
|
||||
->leftJoin('a.staff', 'st')
|
||||
->andWhere('a.isReserve = :reserveOnly')
|
||||
->setParameter('reserveOnly', $reserveOnly)
|
||||
->orderBy('a.slotStart', 'ASC');
|
||||
|
||||
$roles = $user->getRoles();
|
||||
@@ -146,9 +311,12 @@ class MyAppointmentsController extends BaseController
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
if ($filterType === 'clinic') {
|
||||
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
|
||||
->andWhere('c = :clinic')
|
||||
->setParameter('clinic', $filterValue);
|
||||
// $filterValue = آرایهی idهای پزشکانِ تخصیصیافته به این منشی در کلینیک
|
||||
if (empty($filterValue)) {
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
$qb->andWhere('a.doctor IN (:doctorIds)')
|
||||
->setParameter('doctorIds', $filterValue);
|
||||
} else {
|
||||
$qb->andWhere('a.doctor = :doctor')
|
||||
->setParameter('doctor', $filterValue);
|
||||
@@ -184,8 +352,9 @@ class MyAppointmentsController extends BaseController
|
||||
|
||||
$items = array_map(fn(array $a) => [
|
||||
'uuid' => $a['uuid'],
|
||||
'patient_name' => $a['patient_name'] ?? '',
|
||||
'patient_name' => $a['override_name'] ?: ($a['patient_name'] ?? ''),
|
||||
'patient_mobile' => $a['patient_mobile'],
|
||||
'patient_uuid' => $a['patient_uuid'],
|
||||
'doctor_uuid' => $a['doctor_uuid'],
|
||||
'doctor_name' => $a['doctor_name'],
|
||||
'slot_start' => (int) $a['slotStart'],
|
||||
@@ -196,6 +365,15 @@ class MyAppointmentsController extends BaseController
|
||||
'status' => $a['status'],
|
||||
'version' => (int) $a['version'],
|
||||
'created_at' => date('c', (int) $a['createdAt']),
|
||||
'is_reserve' => (bool) $a['isReserve'],
|
||||
'patient_national_code' => $a['national_code'],
|
||||
'patient_gender' => $a['gender'],
|
||||
'deposit_required' => (bool) $a['depositRequired'],
|
||||
'deposit_amount_rials' => $a['depositAmountRials'] !== null ? (int) $a['depositAmountRials'] : null,
|
||||
'note' => $a['note'],
|
||||
'service_section' => $a['section_uuid'] ? ['uuid' => $a['section_uuid'], 'name' => $a['section_name']] : null,
|
||||
'service_item' => $a['service_uuid'] ? ['uuid' => $a['service_uuid'], 'name' => $a['service_name']] : null,
|
||||
'staff' => $a['staff_uuid'] ? ['uuid' => $a['staff_uuid'], 'full_name' => $a['staff_name']] : null,
|
||||
], $rows);
|
||||
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
@@ -222,30 +400,44 @@ class MyAppointmentsController extends BaseController
|
||||
->groupBy('a.status');
|
||||
|
||||
$roles = $user->getRoles();
|
||||
if (in_array('ROLE_CLINIC', $roles, true)) {
|
||||
if (in_array('ROLE_ADMIN', $roles, true)) {
|
||||
// Admin sees all
|
||||
} elseif (in_array('ROLE_CLINIC', $roles, true)) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
if ($clinic) {
|
||||
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
|
||||
->andWhere('c = :clinic')
|
||||
->setParameter('clinic', $clinic);
|
||||
if ($clinic === null) {
|
||||
return $this->emptyTodayStats();
|
||||
}
|
||||
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
|
||||
->andWhere('c = :clinic')
|
||||
->setParameter('clinic', $clinic);
|
||||
} elseif (in_array('ROLE_DOCTOR', $roles, true)) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor) {
|
||||
$qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $doctor);
|
||||
if ($doctor === null) {
|
||||
return $this->emptyTodayStats();
|
||||
}
|
||||
$qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $doctor);
|
||||
} elseif (in_array('ROLE_SECRETARY', $roles, true)) {
|
||||
$filter = $this->resolveSecretaryFilter($user);
|
||||
if ($filter !== null) {
|
||||
[$filterType, $filterValue] = $filter;
|
||||
if ($filterType === 'clinic') {
|
||||
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
|
||||
->andWhere('c = :clinic')
|
||||
->setParameter('clinic', $filterValue);
|
||||
} else {
|
||||
$qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $filterValue);
|
||||
}
|
||||
if ($filter === null) {
|
||||
return $this->emptyTodayStats();
|
||||
}
|
||||
// در scope کلینیک، filterValue آرایهی idهای پزشکانِ تخصیصیافته است —
|
||||
// نه خود کلینیک؛ همشکل با myAppointments.
|
||||
[$filterType, $filterValue, $canView] = $filter;
|
||||
if (!$canView) {
|
||||
return $this->emptyTodayStats();
|
||||
}
|
||||
if ($filterType === 'clinic') {
|
||||
if (empty($filterValue)) {
|
||||
return $this->emptyTodayStats();
|
||||
}
|
||||
$qb->andWhere('a.doctor IN (:doctorIds)')->setParameter('doctorIds', $filterValue);
|
||||
} else {
|
||||
$qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $filterValue);
|
||||
}
|
||||
} else {
|
||||
// بیمار عادی: فقط نوبتهای خودش — نه شمارشِ بیمحدودهٔ کل سیستم.
|
||||
$qb->andWhere('a.user = :patient')->setParameter('patient', $user);
|
||||
}
|
||||
|
||||
$rows = $qb->getQuery()->getArrayResult();
|
||||
@@ -270,6 +462,11 @@ class MyAppointmentsController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
private function emptyTodayStats(): JsonResponse
|
||||
{
|
||||
return $this->success(['total' => 0, 'completed' => 0, 'waiting' => 0, 'cancelled' => 0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the acting user is allowed to book onto this doctor's calendar.
|
||||
* The role gate alone is not enough: a doctor/clinic/secretary must be
|
||||
@@ -314,10 +511,8 @@ class MyAppointmentsController extends BaseController
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
if (!$clinic->getDoctors()->contains($doctor)) {
|
||||
return false;
|
||||
}
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
|
||||
// منشی فقط برای پزشکانِ تخصیصیافتهی خودش میتواند رزرو کند، نه کل کلینیک
|
||||
$rel = $this->secretaryRepo->findActiveClinicRow($user, $clinic, $doctor);
|
||||
return $rel !== null && (bool) ($rel->getPermissions()['resources']['appointments']['create'] ?? false);
|
||||
}
|
||||
|
||||
@@ -345,13 +540,17 @@ class MyAppointmentsController extends BaseController
|
||||
return null;
|
||||
}
|
||||
|
||||
// بررسی scope کلینیک
|
||||
// بررسی scope کلینیک — فقط پزشکانِ تخصیصیافته به این منشی، نه کل کلینیک
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
|
||||
if ($rel === null) return null;
|
||||
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
|
||||
return ['clinic', $clinic, $canView];
|
||||
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
|
||||
$doctorIds = array_map(
|
||||
fn(Doctor $d) => $d->getId(),
|
||||
$this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic)
|
||||
);
|
||||
return ['clinic', $doctorIds, $canView];
|
||||
}
|
||||
|
||||
// بررسی scope مطب شخصی
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Appointment\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
@@ -19,6 +21,7 @@ class Appointment
|
||||
// ↘ cancelled_by_doctor / cancelled_by_user
|
||||
// pending → expired (cron)
|
||||
// confirmed → no_show
|
||||
// Day-of clinic workflow (Figma نوبتها): confirmed → following_up → salon → completed
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_CONFIRMED = 'confirmed';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
@@ -26,12 +29,16 @@ class Appointment
|
||||
public const STATUS_CANCELLED_BY_USER = 'cancelled_by_user';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_NO_SHOW = 'no_show';
|
||||
public const STATUS_FOLLOWING_UP = 'following_up'; // در حال پیگیری
|
||||
public const STATUS_SALON = 'salon'; // سالن (در اتاق انتظار)
|
||||
|
||||
public const PAYMENT_TTL = 900; // 15 minutes to pay before a pending booking expires
|
||||
|
||||
public const ALLOWED_TRANSITIONS = [
|
||||
self::STATUS_PENDING => [self::STATUS_CONFIRMED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_EXPIRED],
|
||||
self::STATUS_CONFIRMED => [self::STATUS_COMPLETED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW],
|
||||
self::STATUS_PENDING => [self::STATUS_CONFIRMED, self::STATUS_FOLLOWING_UP, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_EXPIRED],
|
||||
self::STATUS_CONFIRMED => [self::STATUS_COMPLETED, self::STATUS_FOLLOWING_UP, self::STATUS_SALON, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW],
|
||||
self::STATUS_FOLLOWING_UP => [self::STATUS_CONFIRMED, self::STATUS_SALON, self::STATUS_COMPLETED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW],
|
||||
self::STATUS_SALON => [self::STATUS_COMPLETED, self::STATUS_FOLLOWING_UP, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -47,6 +54,22 @@ class Appointment
|
||||
self::STATUS_CONFIRMED,
|
||||
];
|
||||
|
||||
/**
|
||||
* Statuses that make a slot unavailable for a *new* booking, as seen by the
|
||||
* public availability view (AppointmentRepository::isSlotTaken). Broader than
|
||||
* SLOT_OCCUPYING_STATUSES: besides a live booking, a slot is also spoken for
|
||||
* once the visit has been consumed (completed / in-progress / no_show). Only
|
||||
* cancelled_* and expired truly release it. STATUS_PENDING is handled
|
||||
* separately in the query because it blocks only while not yet expired.
|
||||
*/
|
||||
public const SLOT_BLOCKING_STATUSES = [
|
||||
self::STATUS_CONFIRMED,
|
||||
self::STATUS_COMPLETED,
|
||||
self::STATUS_FOLLOWING_UP,
|
||||
self::STATUS_SALON,
|
||||
self::STATUS_NO_SHOW,
|
||||
];
|
||||
|
||||
// Optimistic locking
|
||||
#[ORM\Version]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
@@ -104,9 +127,57 @@ class Appointment
|
||||
#[ORM\Column(name: 'address_id', type: 'integer', nullable: true)]
|
||||
private ?int $addressId = null;
|
||||
|
||||
/**
|
||||
* محیط رزرو: null یعنی مطب شخصی پزشک، مقدار یعنی همان کلینیک. مبنای واحدِ
|
||||
* تشخیص پرونده — از روی آدرس حدس زده نمیشود، چون با چند برنامهٔ همزمان
|
||||
* حدسزدن یعنی چسباندنِ خاموشِ نوبت به پروندهٔ محیط اشتباه.
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: \App\Clinic\Entity\Clinic::class)]
|
||||
#[ORM\JoinColumn(name: 'clinic_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?\App\Clinic\Entity\Clinic $clinic = null;
|
||||
|
||||
#[ORM\Column(name: 'booking_representation_id', type: 'integer', nullable: true)]
|
||||
private ?int $bookingRepresentationId = null;
|
||||
|
||||
// ── Clinic-workflow fields (Figma نوبتها) ────────────────────────────────
|
||||
|
||||
/** بخش — clinic service section this appointment belongs to. */
|
||||
#[ORM\ManyToOne(targetEntity: \App\ClinicService\Entity\ServiceSection::class)]
|
||||
#[ORM\JoinColumn(name: 'service_section_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?\App\ClinicService\Entity\ServiceSection $serviceSection = null;
|
||||
|
||||
/** سرویس — سرویس اصلی/اولِ نوبت (برای سازگاری با مصرفکنندههای موجود). */
|
||||
#[ORM\ManyToOne(targetEntity: \App\ClinicService\Entity\ServiceItem::class)]
|
||||
#[ORM\JoinColumn(name: 'service_item_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?\App\ClinicService\Entity\ServiceItem $serviceItem = null;
|
||||
|
||||
/** سرویسهای نوبت — امکان انتخاب چند سرویس. serviceItem بالا همان سرویسِ اول است. */
|
||||
#[ORM\ManyToMany(targetEntity: \App\ClinicService\Entity\ServiceItem::class)]
|
||||
#[ORM\JoinTable(name: 'appointment_service_items')]
|
||||
private Collection $serviceItems;
|
||||
|
||||
/** پرسنل — staff member assigned to the appointment. */
|
||||
#[ORM\ManyToOne(targetEntity: \App\Staff\Entity\ClinicStaff::class)]
|
||||
#[ORM\JoinColumn(name: 'staff_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?\App\Staff\Entity\ClinicStaff $staff = null;
|
||||
|
||||
/** بیعانه مورد نیاز است. */
|
||||
#[ORM\Column(name: 'deposit_required', type: 'boolean', options: ['default' => false])]
|
||||
private bool $depositRequired = false;
|
||||
|
||||
#[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.
|
||||
*/
|
||||
#[ORM\Column(name: 'is_reserve', type: 'boolean', options: ['default' => false])]
|
||||
private bool $isReserve = false;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
@@ -122,16 +193,24 @@ class Appointment
|
||||
$this->slotEnd = $slotEnd;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
$this->serviceItems = new ArrayCollection();
|
||||
$this->refreshActiveSlotKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the unique active-slot key from the current status. Non-null
|
||||
* while the appointment occupies the slot; null once it is cancelled.
|
||||
*
|
||||
* کلید عمداً clinic ندارد و فقط doctor+slotStart است: برنامهٔ هفتگی هر محیط
|
||||
* جداست (WeeklySchedule با UNIQUE(doctor_id, clinic_key)) و میتواند با محیط
|
||||
* دیگر همپوشانی داشته باشد، ولی پزشک یک نفر است. افزودن clinic به کلید یعنی
|
||||
* اجازهٔ رزرو همزمان همان پزشک در مطب و کلینیک — نه رفع باگ.
|
||||
*/
|
||||
private function refreshActiveSlotKey(): void
|
||||
{
|
||||
$this->activeSlotKey = in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
|
||||
// Reserve-list entries are day-level wishes, not slot bookings — they
|
||||
// never occupy a slot, so several reserves may share the same day.
|
||||
$this->activeSlotKey = !$this->isReserve && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
|
||||
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
|
||||
: null;
|
||||
}
|
||||
@@ -152,17 +231,66 @@ class Appointment
|
||||
public function getPatientGender(): ?string { return $this->patientGender; }
|
||||
public function getPatientReason(): ?string { return $this->patientReason; }
|
||||
public function getAddressId(): ?int { return $this->addressId; }
|
||||
public function getClinic(): ?\App\Clinic\Entity\Clinic { return $this->clinic; }
|
||||
public function getBookingRepresentationId(): ?int { return $this->bookingRepresentationId; }
|
||||
|
||||
public function setNote(?string $v): self { $this->note = $v; return $this; }
|
||||
public function setBookingRepresentationId(?int $v): self { $this->bookingRepresentationId = $v; return $this; }
|
||||
public function setAddressId(?int $v): self { $this->addressId = $v; return $this; }
|
||||
public function setClinic(?\App\Clinic\Entity\Clinic $v): self { $this->clinic = $v; return $this; }
|
||||
public function setPatientName(?string $v): self { $this->patientName = $v; return $this; }
|
||||
public function setPatientMobile(?string $v): self { $this->patientMobile = $v; return $this; }
|
||||
public function setPatientNationalCode(?string $v): self { $this->patientNationalCode = $v; return $this; }
|
||||
public function setPatientGender(?string $v): self { $this->patientGender = $v; return $this; }
|
||||
public function setPatientReason(?string $v): self { $this->patientReason = $v; return $this; }
|
||||
|
||||
public function getServiceSection(): ?\App\ClinicService\Entity\ServiceSection { return $this->serviceSection; }
|
||||
public function getServiceItem(): ?\App\ClinicService\Entity\ServiceItem { return $this->serviceItem; }
|
||||
|
||||
/** @return Collection<int,\App\ClinicService\Entity\ServiceItem> */
|
||||
public function getServiceItems(): Collection { return $this->serviceItems; }
|
||||
|
||||
public function addServiceItem(\App\ClinicService\Entity\ServiceItem $item): self
|
||||
{
|
||||
if (!$this->serviceItems->contains($item)) {
|
||||
$this->serviceItems->add($item);
|
||||
}
|
||||
// سرویسِ اصلی = اولین سرویس، تا مصرفکنندههای موجود کار کنند.
|
||||
if ($this->serviceItem === null) {
|
||||
$this->serviceItem = $item;
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
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; }
|
||||
public function setServiceItem(?\App\ClinicService\Entity\ServiceItem $v): self { $this->serviceItem = $v; return $this; }
|
||||
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
|
||||
* reserve flag (انتقال به لیست رزرو و بالعکس). Goes through here — not raw
|
||||
* setters — so active_slot_key stays consistent with the new slot.
|
||||
*/
|
||||
public function rescheduleTo(int $slotStart, int $slotEnd, ?bool $isReserve = null): self
|
||||
{
|
||||
$this->slotStart = $slotStart;
|
||||
$this->slotEnd = $slotEnd;
|
||||
if ($isReserve !== null) {
|
||||
$this->isReserve = $isReserve;
|
||||
}
|
||||
$this->updatedAt = time();
|
||||
$this->refreshActiveSlotKey();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markPendingWithTtl(int $ttl): self
|
||||
{
|
||||
$this->expiresAt = time() + $ttl;
|
||||
@@ -231,6 +359,23 @@ class Appointment
|
||||
'patient_national_code' => $this->patientNationalCode,
|
||||
'patient_gender' => $this->patientGender,
|
||||
'patient_reason' => $this->patientReason,
|
||||
'service_section' => $this->serviceSection ? ['uuid' => $this->serviceSection->getUuid(), 'name' => $this->serviceSection->getName()] : null,
|
||||
'service_item' => $this->serviceItem ? ['uuid' => $this->serviceItem->getUuid(), 'name' => $this->serviceItem->getName()] : null,
|
||||
// price_rials لازم است تا مودالِ «قطعی کردن نوبت» بتواند هزینهها را قبل از
|
||||
// ساختهشدنِ مراجعه نشان دهد.
|
||||
'service_items' => array_map(
|
||||
fn(\App\ClinicService\Entity\ServiceItem $i) => [
|
||||
'uuid' => $i->getUuid(),
|
||||
'name' => $i->getName(),
|
||||
'price_rials' => $i->getPriceRials(),
|
||||
],
|
||||
$this->serviceItems->toArray()
|
||||
),
|
||||
'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,
|
||||
'updated_at' => $this->updatedAt,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Entity;
|
||||
|
||||
use App\Appointment\Repository\AppointmentEventRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* تاریخچهی رویدادهای یک نوبت (Timeline). فعلاً فقط رویداد لغو ثبت میشود، اما
|
||||
* ساختار عمومی است تا رویدادهای بعدی (ایجاد/تأیید/جابهجایی) هم قابل افزودن باشند.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: AppointmentEventRepository::class)]
|
||||
#[ORM\Table(name: 'appointment_events')]
|
||||
#[ORM\Index(columns: ['appointment_id', 'created_at'], name: 'idx_appointment_events_appt')]
|
||||
class AppointmentEvent
|
||||
{
|
||||
public const TYPE_CANCELLED = 'cancelled';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Appointment::class)]
|
||||
#[ORM\JoinColumn(name: 'appointment_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Appointment $appointment;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 40)]
|
||||
private string $type;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 191)]
|
||||
private string $title;
|
||||
|
||||
// کاربرِ عاملِ رویداد (مثلاً لغوکننده)؛ null برای رویدادهای سیستمی.
|
||||
#[ORM\Column(name: 'actor_user_id', type: 'integer', nullable: true)]
|
||||
private ?int $actorUserId = null;
|
||||
|
||||
// کشِ نام عامل برای نمایش بدون join اضافه.
|
||||
#[ORM\Column(name: 'actor_name', type: 'string', length: 191, nullable: true)]
|
||||
private ?string $actorName = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $reason = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(Appointment $appointment, string $type, string $title)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->appointment = $appointment;
|
||||
$this->type = $type;
|
||||
$this->title = $title;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function setActor(?int $userId, ?string $name): self { $this->actorUserId = $userId; $this->actorName = $name; return $this; }
|
||||
public function setReason(?string $reason): self { $this->reason = $reason; return $this; }
|
||||
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'type' => $this->type,
|
||||
'title' => $this->title,
|
||||
'actor_name' => $this->actorName,
|
||||
'reason' => $this->reason,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
namespace App\Appointment\Entity;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use App\Appointment\Repository\DateOverrideRepository;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* استثنای ساعت کاری یک روز خاص. چون خودِ ساعت کاری per-context است، استثنای آن هم
|
||||
* per-context است: clinic باید همان clinic برنامهٔ هفتگی متناظر باشد (NULL = شخصی).
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DateOverrideRepository::class)]
|
||||
#[ORM\Table(name: 'date_overrides')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_date', columns: ['doctor_id', 'date'])]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_clinic_date', columns: ['doctor_id', 'clinic_key', 'date'])]
|
||||
class DateOverride
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -24,6 +29,15 @@ class DateOverride
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
/** NULL = مطب شخصی پزشک. */
|
||||
#[ORM\ManyToOne(targetEntity: Clinic::class)]
|
||||
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Clinic $clinic = null;
|
||||
|
||||
/** ستون تولیدشده: IFNULL(clinic_id, 0) — تا یکتایی با clinic_id تهی هم برقرار بماند. */
|
||||
#[ORM\Column(name: 'clinic_key', type: 'integer', insertable: false, updatable: false, generated: 'ALWAYS', columnDefinition: 'INT AS (IFNULL(clinic_id, 0)) STORED')]
|
||||
private int $clinicKey = 0;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $date;
|
||||
|
||||
@@ -42,10 +56,11 @@ class DateOverride
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Doctor $doctor, int $date, bool $active = false)
|
||||
public function __construct(Doctor $doctor, int $date, bool $active = false, ?Clinic $clinic = null)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->clinic = $clinic;
|
||||
$this->date = $date;
|
||||
$this->active = $active;
|
||||
$this->createdAt = time();
|
||||
@@ -55,6 +70,7 @@ class DateOverride
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getClinic(): ?Clinic { return $this->clinic; }
|
||||
public function getDate(): int { return $this->date; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getSetting(): ?array { return $this->setting; }
|
||||
@@ -72,6 +88,8 @@ class DateOverride
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'clinic_uuid' => $this->clinic?->getUuid(),
|
||||
'context' => $this->clinic === null ? 'personal' : 'clinic',
|
||||
'date' => $this->date,
|
||||
'active' => $this->active,
|
||||
'reason' => $this->reason,
|
||||
|
||||
@@ -2,11 +2,17 @@
|
||||
|
||||
namespace App\Appointment\Entity;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use App\Appointment\Repository\HolidayRepository;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* تعطیلی پزشک. برخلاف WeeklySchedule و DateOverride، تعطیلی پیشفرضاً سراسری است:
|
||||
* «پزشک آن روز نیست» یک واقعیت فیزیکی است و همزمان روی مطب شخصی و همهٔ کلینیکها
|
||||
* اثر میگذارد (clinic = null). مقدار غیر-NULL یعنی پزشک فقط در همان کلینیک نیست.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: HolidayRepository::class)]
|
||||
#[ORM\Table(name: 'holidays')]
|
||||
#[ORM\Index(columns: ['doctor_id', 'start_date', 'end_date'], name: 'idx_holidays_doctor_range')]
|
||||
@@ -24,6 +30,11 @@ class Holiday
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
/** NULL = پزشک در هیچ محلی نیست (همهٔ contextها). */
|
||||
#[ORM\ManyToOne(targetEntity: Clinic::class)]
|
||||
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Clinic $clinic = null;
|
||||
|
||||
#[ORM\Column(name: 'start_date', type: 'integer')]
|
||||
private int $startDate;
|
||||
|
||||
@@ -42,10 +53,11 @@ class Holiday
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Doctor $doctor, int $startDate, int $endDate)
|
||||
public function __construct(Doctor $doctor, int $startDate, int $endDate, ?Clinic $clinic = null)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->clinic = $clinic;
|
||||
$this->startDate = $startDate;
|
||||
$this->endDate = $endDate;
|
||||
$this->createdAt = time();
|
||||
@@ -55,6 +67,8 @@ class Holiday
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getClinic(): ?Clinic { return $this->clinic; }
|
||||
public function isGlobal(): bool { return $this->clinic === null; }
|
||||
public function getStartDate(): int { return $this->startDate; }
|
||||
public function getEndDate(): int { return $this->endDate; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
@@ -72,6 +86,8 @@ class Holiday
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'clinic_uuid' => $this->clinic?->getUuid(),
|
||||
'scope' => $this->clinic === null ? 'global' : 'clinic',
|
||||
'start_date' => $this->startDate,
|
||||
'end_date' => $this->endDate,
|
||||
'active' => $this->active,
|
||||
|
||||
@@ -2,23 +2,37 @@
|
||||
|
||||
namespace App\Appointment\Entity;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* برنامهٔ هفتگی نوبتدهی یک پزشک در یک context مشخص.
|
||||
*
|
||||
* context با ستون clinic_id بیان میشود: NULL یعنی مطب شخصی پزشک، و مقدار غیر-NULL
|
||||
* یعنی همان پزشک در آن کلینیک. یک پزشک میتواند همزمان چند برنامه داشته باشد
|
||||
* (شخصی + یکی به ازای هر کلینیک) و این برنامهها کاملاً مستقلاند.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)]
|
||||
#[ORM\Table(name: 'weekly_schedules')]
|
||||
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor', columns: ['doctor_id'])]
|
||||
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor_clinic', columns: ['doctor_id', 'clinic_key'])]
|
||||
class WeeklySchedule
|
||||
{
|
||||
public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
|
||||
|
||||
public const META_KEY = 'meta';
|
||||
|
||||
public const MODE_SLOT = 'slot'; // نوبتدهی اسلاتی (رفتار پیشفرض)
|
||||
public const MODE_SERVICE = 'service'; // نوبتدهی بر اساس مدت سرویس
|
||||
|
||||
public const DEFAULT_META = [
|
||||
'online_booking_enabled' => true,
|
||||
'booking_window_value' => 1,
|
||||
'booking_window_unit' => 'month',
|
||||
'booking_mode' => self::MODE_SLOT,
|
||||
'buffer_minutes' => 0,
|
||||
];
|
||||
|
||||
#[ORM\Id]
|
||||
@@ -29,10 +43,25 @@ class WeeklySchedule
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\OneToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
/** NULL = مطب شخصی پزشک. */
|
||||
#[ORM\ManyToOne(targetEntity: Clinic::class)]
|
||||
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Clinic $clinic = null;
|
||||
|
||||
/**
|
||||
* ستون تولیدشدهٔ پایگاهداده: IFNULL(clinic_id, 0).
|
||||
*
|
||||
* MySQL/MariaDB مقادیر NULL را در unique index متمایز میشمارند، پس
|
||||
* UNIQUE(doctor_id, clinic_id) جلوی دو برنامهٔ شخصی برای یک پزشک را نمیگرفت.
|
||||
* این ستون NULL را به 0 نگاشت میکند تا یکتایی در سطح دیتابیس تضمین شود.
|
||||
*/
|
||||
#[ORM\Column(name: 'clinic_key', type: 'integer', insertable: false, updatable: false, generated: 'ALWAYS', columnDefinition: 'INT AS (IFNULL(clinic_id, 0)) STORED')]
|
||||
private int $clinicKey = 0;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $setting = [];
|
||||
|
||||
@@ -42,10 +71,11 @@ class WeeklySchedule
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Doctor $doctor, array $setting)
|
||||
public function __construct(Doctor $doctor, array $setting, ?Clinic $clinic = null)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->clinic = $clinic;
|
||||
$this->setting = $setting;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
@@ -54,6 +84,15 @@ class WeeklySchedule
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getClinic(): ?Clinic { return $this->clinic; }
|
||||
|
||||
/** فقط برای انتقال دستی برنامههای قدیمی به محیط کلینیک (app:schedule:assign-clinic). */
|
||||
public function setClinic(?Clinic $clinic): self
|
||||
{
|
||||
$this->clinic = $clinic;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
public function getSetting(): array { return $this->setting; }
|
||||
|
||||
public function setSetting(array $setting): self
|
||||
@@ -73,6 +112,16 @@ class WeeklySchedule
|
||||
return array_merge(self::DEFAULT_META, $this->setting[self::META_KEY] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* booking_mode ذخیرهشده بهصورت خام (بدون merge پیشفرض). null یعنی هنوز
|
||||
* صریحاً ثبت نشده — تا وقتی null است، انتخاب روش قابلتغییر است؛ پس از اولین
|
||||
* ثبت، قفل میشود.
|
||||
*/
|
||||
public function getStoredBookingMode(): ?string
|
||||
{
|
||||
return $this->setting[self::META_KEY]['booking_mode'] ?? null;
|
||||
}
|
||||
|
||||
public function setMeta(array $meta): self
|
||||
{
|
||||
$current = $this->getMeta();
|
||||
@@ -82,6 +131,10 @@ class WeeklySchedule
|
||||
'booking_window_unit' => in_array($meta['booking_window_unit'] ?? null, ['week', 'month'], true)
|
||||
? $meta['booking_window_unit']
|
||||
: $current['booking_window_unit'],
|
||||
'booking_mode' => in_array($meta['booking_mode'] ?? null, [self::MODE_SLOT, self::MODE_SERVICE], true)
|
||||
? $meta['booking_mode']
|
||||
: $current['booking_mode'],
|
||||
'buffer_minutes' => max(0, (int)($meta['buffer_minutes'] ?? $current['buffer_minutes'])),
|
||||
];
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
@@ -99,8 +152,12 @@ class WeeklySchedule
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'clinic_uuid' => $this->clinic?->getUuid(),
|
||||
'context' => $this->clinic === null ? 'personal' : 'clinic',
|
||||
'schedule' => $this->getDaySchedule(),
|
||||
'meta' => $this->getMeta(),
|
||||
// نوع نوبتدهی پس از اولین ثبت قفل میشود (پنل توگل را غیرفعال میکند).
|
||||
'booking_mode_locked' => $this->getStoredBookingMode() !== null,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Repository;
|
||||
|
||||
use App\Appointment\Entity\AppointmentEvent;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class AppointmentEventRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry) { parent::__construct($registry, AppointmentEvent::class); }
|
||||
|
||||
public function save(AppointmentEvent $e, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($e);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/** رویدادهای یک نوبت بهترتیب زمان (قدیمی → جدید)، بهصورت آرایه. */
|
||||
public function findByAppointmentUuid(string $appointmentUuid): array
|
||||
{
|
||||
return $this->createQueryBuilder('e')
|
||||
->select('e.type AS type', 'e.title AS title', 'e.actorName AS actor_name', 'e.reason AS reason', 'e.createdAt AS created_at')
|
||||
->join('e.appointment', 'a')
|
||||
->where('a.uuid = :uuid')->setParameter('uuid', $appointmentUuid)
|
||||
->orderBy('e.createdAt', 'ASC')
|
||||
->getQuery()->getArrayResult();
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ namespace App\Appointment\Repository;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\DBAL\LockMode;
|
||||
use Doctrine\ORM\OptimisticLockException;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
@@ -37,6 +39,13 @@ class AppointmentRepository extends ServiceEntityRepository
|
||||
$start = $appointment->getSlotStart();
|
||||
$end = $appointment->getSlotEnd();
|
||||
|
||||
// قفلِ per-doctor (SELECT ... FOR UPDATE روی ردیف پزشک): رزروهای
|
||||
// همزمانِ یک پزشک را سریالایز میکند. در حالت نوبتدهی سرویسی که
|
||||
// نوبتها طول متغیر و شروعِ متفاوت دارند، unique-keyِ (doctor,slot_start)
|
||||
// تداخلِ بازهایِ دو رزروِ همزمان را نمیگیرد؛ این قفل تضمین میکند
|
||||
// بررسیِ isSlotTaken و insert بهصورت اتمیک نسبت به سایر رزروها انجام شود.
|
||||
$em->lock($doctor, LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
if ($this->isSlotTaken($doctor, $start, $end)) {
|
||||
throw new SlotTakenException();
|
||||
}
|
||||
@@ -87,6 +96,57 @@ class AppointmentRepository extends ServiceEntityRepository
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* بازههای اشغالشدهٔ یک پزشک در پنجرهٔ [$from, $to) — برای محاسبهٔ زمانِ خالی
|
||||
* در حالت نوبتدهی سرویسی. همان معیارِ isSlotTaken (blocking یا pendingِ زنده)،
|
||||
* ولی نوبتهای «آزاد» (is_reserve) هیچ بازهای اشغال نمیکنند.
|
||||
*
|
||||
* @return array<array{start:int,end:int}> مرتبشده بر اساس start
|
||||
*/
|
||||
public function findBusyIntervals(Doctor $doctor, int $from, int $to): array
|
||||
{
|
||||
return $this->occupiedIntervals($doctor, $from, $to, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* همان مجموعهای که isSlotTaken() یک اسلات را با آن میسنجد — شامل نوبتهای
|
||||
* رزروی. برای پیمایش چندروزه که نمیخواهد به ازای هر اسلات یک کوئری بزند.
|
||||
*
|
||||
* @return array<int, array{start: int, end: int}>
|
||||
*/
|
||||
public function findBlockingIntervals(Doctor $doctor, int $from, int $to): array
|
||||
{
|
||||
return $this->occupiedIntervals($doctor, $from, $to, false);
|
||||
}
|
||||
|
||||
/** @return array<int, array{start: int, end: int}> */
|
||||
private function occupiedIntervals(Doctor $doctor, int $from, int $to, bool $skipReserve): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->select('a.slotStart AS start, a.slotEnd AS end')
|
||||
->where('a.doctor = :doctor')
|
||||
->andWhere('a.slotStart < :to')
|
||||
->andWhere('a.slotEnd > :from')
|
||||
->andWhere(
|
||||
'a.status IN (:blocking) OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))'
|
||||
)
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('blocking', Appointment::SLOT_BLOCKING_STATUSES)
|
||||
->setParameter('pending', Appointment::STATUS_PENDING)
|
||||
->setParameter('now', time())
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->orderBy('a.slotStart', 'ASC');
|
||||
|
||||
if ($skipReserve) {
|
||||
$qb->andWhere('a.isReserve = false');
|
||||
}
|
||||
|
||||
$rows = $qb->getQuery()->getScalarResult();
|
||||
|
||||
return array_map(fn($r) => ['start' => (int) $r['start'], 'end' => (int) $r['end']], $rows);
|
||||
}
|
||||
|
||||
/** Check if a slot is already taken (confirmed or pending) */
|
||||
public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool
|
||||
{
|
||||
@@ -96,10 +156,10 @@ class AppointmentRepository extends ServiceEntityRepository
|
||||
->andWhere('a.slotStart < :slotEnd')
|
||||
->andWhere('a.slotEnd > :slotStart')
|
||||
->andWhere(
|
||||
'a.status = :confirmed OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))'
|
||||
'a.status IN (:blocking) OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))'
|
||||
)
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('confirmed', Appointment::STATUS_CONFIRMED)
|
||||
->setParameter('blocking', Appointment::SLOT_BLOCKING_STATUSES)
|
||||
->setParameter('pending', Appointment::STATUS_PENDING)
|
||||
->setParameter('now', time())
|
||||
->setParameter('slotStart', $slotStart)
|
||||
@@ -113,13 +173,92 @@ class AppointmentRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/** @return Appointment[] */
|
||||
public function findByDoctor(Doctor $doctor, ?string $status = null): array
|
||||
public function findByDoctor(Doctor $doctor, ?string $status = null, ?Clinic $clinic = null): array
|
||||
{
|
||||
$criteria = ['doctor' => $doctor];
|
||||
if ($status !== null) $criteria['status'] = $status;
|
||||
// محدودکردن به یک محیط: مدیر کلینیک نباید نوبتهای مطب شخصی پزشک را ببیند.
|
||||
if ($clinic !== null) $criteria['clinic'] = $clinic;
|
||||
return $this->findBy($criteria, ['slotStart' => 'ASC']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtered + paginated appointment list for one doctor — backs the doctor
|
||||
* dashboard filter bar. Kept separate from findByDoctor() so existing
|
||||
* unfiltered callers keep their plain-array contract.
|
||||
*
|
||||
* @param string[] $statuses empty = no status restriction
|
||||
* @return array{items: Appointment[], total: int}
|
||||
*/
|
||||
public function searchByDoctor(
|
||||
Doctor $doctor,
|
||||
array $statuses = [],
|
||||
?Clinic $clinic = null,
|
||||
?int $from = null,
|
||||
?int $to = null,
|
||||
?string $query = null,
|
||||
?string $serviceUuid = null,
|
||||
int $page = 1,
|
||||
int $limit = 20,
|
||||
): array {
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->join('a.user', 'u')
|
||||
->where('a.doctor = :doctor')
|
||||
->setParameter('doctor', $doctor);
|
||||
|
||||
if ($statuses !== []) {
|
||||
$qb->andWhere('a.status IN (:statuses)')->setParameter('statuses', $statuses);
|
||||
}
|
||||
// همان محدودسازی محیط که findByDoctor دارد: نوبت مطب شخصی به کلینیک نشت نکند.
|
||||
if ($clinic !== null) {
|
||||
$qb->andWhere('a.clinic = :clinic')->setParameter('clinic', $clinic);
|
||||
}
|
||||
if ($from !== null) {
|
||||
$qb->andWhere('a.slotStart >= :from')->setParameter('from', $from);
|
||||
}
|
||||
if ($to !== null) {
|
||||
$qb->andWhere('a.slotStart <= :to')->setParameter('to', $to);
|
||||
}
|
||||
if ($serviceUuid !== null && $serviceUuid !== '') {
|
||||
$qb->join('a.serviceItem', 'si')
|
||||
->andWhere('si.uuid = :serviceUuid')
|
||||
->setParameter('serviceUuid', $serviceUuid);
|
||||
}
|
||||
// نام/موبایل هم روی فیلدهای خودِ نوبت ذخیره میشود و هم روی کاربر؛ هر دو جستوجو میشوند.
|
||||
if ($query !== null && trim($query) !== '') {
|
||||
$qb->andWhere('a.patientName LIKE :q OR a.patientMobile LIKE :q OR u.realName LIKE :q OR u.mobileNumber LIKE :q')
|
||||
->setParameter('q', '%' . trim($query) . '%');
|
||||
}
|
||||
|
||||
$total = (int) (clone $qb)->select('COUNT(a.id)')->getQuery()->getSingleScalarResult();
|
||||
|
||||
$items = $qb->orderBy('a.slotStart', 'ASC')
|
||||
->setFirstResult(max(0, ($page - 1) * $limit))
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return ['items' => $items, 'total' => $total];
|
||||
}
|
||||
|
||||
/**
|
||||
* نوبتهای یک بیمار در یک کلینیک — بر پایهٔ خودِ محیطِ ثبتشدهٔ نوبت، تا غیرفعال
|
||||
* شدنِ بعدیِ پزشک تاریخچه را از پروندهٔ کلینیک حذف نکند.
|
||||
*
|
||||
* @return Appointment[]
|
||||
*/
|
||||
public function findByUserAndClinic(User $user, int $clinicId): array
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->where('a.user = :user')
|
||||
->andWhere('IDENTITY(a.clinic) = :clinicId')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('clinicId', $clinicId)
|
||||
->orderBy('a.slotStart', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** @return Appointment[] */
|
||||
public function findByUser(User $user, ?string $status = null): array
|
||||
{
|
||||
@@ -128,6 +267,29 @@ class AppointmentRepository extends ServiceEntityRepository
|
||||
return $this->findBy($criteria, ['slotStart' => 'DESC']);
|
||||
}
|
||||
|
||||
/**
|
||||
* نوبتهای یک بیمار (کاربر) که با پزشک(های) مشخص گرفته شدهاند — برای نمایش در
|
||||
* پروندهٔ بیمار. اگر لیست پزشک خالی باشد، آرایهٔ خالی برمیگرداند.
|
||||
*
|
||||
* @param int[] $doctorIds
|
||||
* @return Appointment[]
|
||||
*/
|
||||
public function findByUserAndDoctorIds(User $user, array $doctorIds): array
|
||||
{
|
||||
if ($doctorIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->createQueryBuilder('a')
|
||||
->where('a.user = :user')
|
||||
->andWhere('a.doctor IN (:doctorIds)')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('doctorIds', $doctorIds)
|
||||
->orderBy('a.slotStart', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** Whether the user had a confirmed appointment with this doctor within the last $sinceDays days. */
|
||||
public function hasRecentConfirmed(User $user, Doctor $doctor, int $sinceDays = 30): bool
|
||||
{
|
||||
@@ -165,11 +327,20 @@ class AppointmentRepository extends ServiceEntityRepository
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** @return Appointment[] pending appointments older than given timestamp */
|
||||
/**
|
||||
* رزروهای آنلاینِ پرداختنشده که ساعتشان هم گذشته است.
|
||||
*
|
||||
* `expiresAt IS NOT NULL` یعنی فقط نگهداشتِ موقتِ درگاه (markPendingWithTtl).
|
||||
* نوبت «ثبتشده»ای که کلینیک/پزشک از پنل ثبت کرده TTL ندارد و نباید سرِ ساعتِ
|
||||
* نوبت خودبهخود منقضی شود — قطعی/لغو کردنش تصمیم اپراتور است.
|
||||
*
|
||||
* @return Appointment[]
|
||||
*/
|
||||
public function findExpiredPending(int $before): array
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->where('a.status = :status')
|
||||
->andWhere('a.expiresAt IS NOT NULL')
|
||||
->andWhere('a.slotStart < :before')
|
||||
->setParameter('status', Appointment::STATUS_PENDING)
|
||||
->setParameter('before', $before)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Appointment\Repository;
|
||||
|
||||
use App\Appointment\Entity\DateOverride;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -19,10 +20,32 @@ class DateOverrideRepository extends ServiceEntityRepository
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* استثناهای یک context. برخلاف تعطیلی، هیچ اجتماعی در کار نیست: استثنای کلینیک
|
||||
* فقط در همان کلینیک دیده میشود و استثنای شخصی فقط در مطب شخصی.
|
||||
*
|
||||
* @return DateOverride[]
|
||||
*/
|
||||
public function findByDoctorAndClinic(Doctor $doctor, ?Clinic $clinic): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('o')
|
||||
->where('o.doctor = :doctor')
|
||||
->setParameter('doctor', $doctor)
|
||||
->orderBy('o.date', 'ASC');
|
||||
|
||||
if ($clinic === null) {
|
||||
$qb->andWhere('o.clinic IS NULL');
|
||||
} else {
|
||||
$qb->andWhere('o.clinic = :clinic')->setParameter('clinic', $clinic);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/** @return DateOverride[] */
|
||||
public function findByDoctor(Doctor $doctor): array
|
||||
{
|
||||
return $this->findBy(['doctor' => $doctor], ['date' => 'ASC']);
|
||||
return $this->findByDoctorAndClinic($doctor, null);
|
||||
}
|
||||
|
||||
public function save(DateOverride $entity, bool $flush = true): void
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Appointment\Repository;
|
||||
|
||||
use App\Appointment\Entity\Holiday;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -30,19 +31,48 @@ class HolidayRepository extends ServiceEntityRepository
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** @return Holiday[] */
|
||||
public function findActiveByDoctor(Doctor $doctor, int $from, int $to): array
|
||||
/**
|
||||
* تعطیلیهای دیدهشده در یک context: اجتماع تعطیلیهای سراسری پزشک با
|
||||
* تعطیلیهای مخصوص همان کلینیک. $clinic === null یعنی مطب شخصی، که فقط
|
||||
* تعطیلی سراسری میبیند.
|
||||
*
|
||||
* @return Holiday[]
|
||||
*/
|
||||
public function findAllByDoctorInContext(Doctor $doctor, ?Clinic $clinic): array
|
||||
{
|
||||
return $this->createQueryBuilder('h')
|
||||
$qb = $this->createQueryBuilder('h')
|
||||
->where('h.doctor = :doctor')
|
||||
->setParameter('doctor', $doctor)
|
||||
->orderBy('h.startDate', 'DESC');
|
||||
|
||||
if ($clinic === null) {
|
||||
$qb->andWhere('h.clinic IS NULL');
|
||||
} else {
|
||||
$qb->andWhere('h.clinic IS NULL OR h.clinic = :clinic')->setParameter('clinic', $clinic);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/** @return Holiday[] */
|
||||
public function findActiveByDoctor(Doctor $doctor, int $from, int $to, ?Clinic $clinic = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('h')
|
||||
->where('h.doctor = :doctor')
|
||||
->andWhere('h.active = true')
|
||||
->andWhere('h.startDate <= :to')
|
||||
->andWhere('h.endDate >= :from')
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
->setParameter('to', $to);
|
||||
|
||||
if ($clinic === null) {
|
||||
$qb->andWhere('h.clinic IS NULL');
|
||||
} else {
|
||||
$qb->andWhere('h.clinic IS NULL OR h.clinic = :clinic')->setParameter('clinic', $clinic);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function save(Holiday $entity, bool $flush = true): void
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Appointment\Repository;
|
||||
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -14,9 +15,36 @@ class WeeklyScheduleRepository extends ServiceEntityRepository
|
||||
parent::__construct($registry, WeeklySchedule::class);
|
||||
}
|
||||
|
||||
public function findByDoctor(Doctor $doctor): ?WeeklySchedule
|
||||
/**
|
||||
* برنامهٔ یک context مشخص. $clinic === null یعنی مطب شخصی.
|
||||
*
|
||||
* چون MySQL در unique index مقادیر NULL را متمایز میشمارد، یکتایی رکورد شخصی
|
||||
* را همین متد تضمین میکند: قبل از ساخت برنامهٔ جدید همیشه صدا زده میشود.
|
||||
*/
|
||||
public function findByDoctorAndClinic(Doctor $doctor, ?Clinic $clinic): ?WeeklySchedule
|
||||
{
|
||||
return $this->findOneBy(['doctor' => $doctor]);
|
||||
$qb = $this->createQueryBuilder('ws')
|
||||
->where('ws.doctor = :doctor')
|
||||
->setParameter('doctor', $doctor);
|
||||
|
||||
if ($clinic === null) {
|
||||
$qb->andWhere('ws.clinic IS NULL');
|
||||
} else {
|
||||
$qb->andWhere('ws.clinic = :clinic')->setParameter('clinic', $clinic);
|
||||
}
|
||||
|
||||
return $qb->setMaxResults(1)->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/** همهٔ برنامههای پزشک در همهٔ contextها (شخصی + هر کلینیک). @return WeeklySchedule[] */
|
||||
public function findAllByDoctor(Doctor $doctor): array
|
||||
{
|
||||
return $this->createQueryBuilder('ws')
|
||||
->where('ws.doctor = :doctor')
|
||||
->setParameter('doctor', $doctor)
|
||||
->orderBy('ws.clinic', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** @param Doctor[] $doctors @return WeeklySchedule[] */
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Security;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Clinic\Security\ClinicDoctorPermissionChecker;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Secretary\Security\SecretaryPermissionChecker;
|
||||
|
||||
/**
|
||||
* تنها تصمیمگیرندهٔ دسترسی روی «یک نوبت مشخص».
|
||||
*
|
||||
* پیش از این، مسیرهای تکنوبت فقط بیمار، پزشکِ مالک و ادمین را میشناختند؛ نوبتی که
|
||||
* کاربر کلینیک از مسیر /my/appointment میساخت، روی مشاهده و ویرایش ۴۰۳ میگرفت.
|
||||
* محیط نوبت با appointment.clinic بیان میشود (NULL یعنی مطب شخصی) و همان مبنای
|
||||
* تصمیم است — نه نقش کاربر.
|
||||
*
|
||||
* اکشنها از همان واژگان ClinicDoctorPermission/DoctorSecretary گرفته شدهاند تا
|
||||
* «پایان همکاری» فقط یک منبع حقیقت داشته باشد: active=false در همان رکوردها.
|
||||
*/
|
||||
class AppointmentAccessChecker
|
||||
{
|
||||
public const ACTION_VIEW = 'view';
|
||||
public const ACTION_UPDATE_STATUS = 'update_status';
|
||||
public const ACTION_CANCEL = 'cancel';
|
||||
|
||||
private const RESOURCE = 'appointments';
|
||||
|
||||
public function __construct(
|
||||
private readonly ClinicDoctorPermissionChecker $clinicPermissions,
|
||||
private readonly SecretaryPermissionChecker $secretaryPermissions,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
) {}
|
||||
|
||||
public function canView(Appointment $appointment, User $user): bool
|
||||
{
|
||||
return $this->can($appointment, $user, self::ACTION_VIEW);
|
||||
}
|
||||
|
||||
/** مجوز تغییر نوبت: ویرایش، جابهجایی، رزرو، جایگزینی و تغییر وضعیت. */
|
||||
public function canManage(Appointment $appointment, User $user): bool
|
||||
{
|
||||
return $this->can($appointment, $user, self::ACTION_UPDATE_STATUS);
|
||||
}
|
||||
|
||||
public function canCancel(Appointment $appointment, User $user): bool
|
||||
{
|
||||
return $this->can($appointment, $user, self::ACTION_CANCEL);
|
||||
}
|
||||
|
||||
public function can(Appointment $appointment, User $user, string $action): bool
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($appointment->getDoctor()->getUser()->getId() === $user->getId()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// بیمار نوبت خودش را میبیند و لغو میکند، ولی جابهجا/ویرایش نمیکند.
|
||||
if ($appointment->getUser()->getId() === $user->getId()) {
|
||||
return $action === self::ACTION_VIEW || $action === self::ACTION_CANCEL;
|
||||
}
|
||||
|
||||
$clinic = $appointment->getClinic();
|
||||
if ($clinic !== null && $this->clinicPermissions->can($user, $clinic, self::RESOURCE, $action)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->secretaryCan($appointment, $user, $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* کلینیکی که این کاربر در آن اجازهٔ دیدن نوبتهای این پزشک را دارد، یا null.
|
||||
* برای لیستهایی که باید به یک محیط محدود شوند (نه تکنوبت).
|
||||
*/
|
||||
public function viewableClinicFor(User $user, \App\Doctor\Entity\Doctor $doctor): ?\App\Clinic\Entity\Clinic
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
$clinic = $dbUuid !== null ? $this->clinicRepo->findByUuid($dbUuid) : null;
|
||||
|
||||
if ($clinic === null) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
}
|
||||
|
||||
if ($clinic === null || !$clinic->hasDoctor($doctor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->clinicPermissions->can($user, $clinic, self::RESOURCE, self::ACTION_VIEW)
|
||||
? $clinic
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* منشی در محیط فعالِ خودش. در محیط کلینیک، نوبت باید هم متعلق به همان کلینیک
|
||||
* باشد و هم پزشکش جزو پزشکان تخصیصیافته به این منشی — عضویت در کلینیک بهتنهایی
|
||||
* یعنی منشیِ یک پزشک بتواند نوبت پزشک دیگری را دستکاری کند.
|
||||
*/
|
||||
private function secretaryCan(Appointment $appointment, User $user, string $action): bool
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
if ($appointment->getClinic()?->getId() !== $clinic->getId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$relation = $this->secretaryRepo->findActiveClinicRow($user, $clinic, $appointment->getDoctor());
|
||||
|
||||
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, $action);
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($doctor === null || $doctor->getId() !== $appointment->getDoctor()->getId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$relation = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
|
||||
|
||||
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, $action);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Patient\Service\PatientService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* عوارض جانبیِ قطعیشدن نوبت، در یک نقطه.
|
||||
*
|
||||
* قطعیشدن پنج مسیر دارد (پرداخت آنلاین، دو مسیر PATCH، رزرو پنل، رزرو ادمین) و
|
||||
* تا امروز فقط دو تای آنها پرونده میساختند — نوبتهای سایت عمومی که با پرداخت
|
||||
* قطعی میشوند هیچوقت پرونده نداشتند. هر مسیر جدیدی هم باید همین را صدا بزند.
|
||||
*/
|
||||
class AppointmentConfirmationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PatientService $patientService,
|
||||
private readonly AppointmentRepository $appointmentRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* idempotent: فراخوانی دوباره برای همان نوبت چیزی نمیسازد.
|
||||
*
|
||||
* شکست ساخت پرونده نباید قطعیشدن نوبت یا تأیید پرداخت را برگرداند — نوبت
|
||||
* رزرو شده و پول پرداخت شده است؛ پرونده را میشود با
|
||||
* `app:appointment:backfill-sessions` ساخت، ولی رولبکِ پرداخت برگشتناپذیر است.
|
||||
*/
|
||||
public function onConfirmed(Appointment $appointment): ?PatientSession
|
||||
{
|
||||
// نوبت رزروِ روز-محور اسلات و ساعت مشخص ندارد؛ مراجعهٔ زماندار برایش معنا ندارد.
|
||||
if ($appointment->isReserve()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->patientService->autoCreateOnAppointmentConfirm($appointment);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Auto-creating the patient record on confirm failed', [
|
||||
'appointment_uuid' => $appointment->getUuid(),
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* قطعیکردنِ صریح از پنل: انتقال وضعیت، ثبت پرونده/مراجعه و ثبت پرداختها — همه
|
||||
* در یک تراکنش. برخلاف onConfirmed اینجا شکست خاموش نمیماند: کاربر روبهروی
|
||||
* مودالی ایستاده که مبلغ نشان داده و منتظر تأیید است؛ «قطعی شد ولی پول ثبت نشد»
|
||||
* بدترین خروجیِ ممکن است.
|
||||
*
|
||||
* @param array<int, array{method: string, amount_rials: int}> $payments
|
||||
* @return PatientSession|null null یعنی این tenant قابلیت پرونده را ندارد
|
||||
* (فقط وقتی مجاز است که پرداختی هم ارسال نشده باشد)
|
||||
*/
|
||||
public function confirmWithPayments(
|
||||
Appointment $appointment,
|
||||
int $expectedVersion,
|
||||
array $payments,
|
||||
User $actor,
|
||||
): ?PatientSession {
|
||||
return $this->em->wrapInTransaction(function () use ($appointment, $expectedVersion, $payments, $actor) {
|
||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$this->appointmentRepo->saveWithLock($appointment, $expectedVersion);
|
||||
|
||||
$session = $this->patientService->autoCreateOnAppointmentConfirm($appointment);
|
||||
|
||||
if ($session === null) {
|
||||
if ($payments !== []) {
|
||||
throw new AppException(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, null, 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($payments as $payment) {
|
||||
$this->patientService->addSessionPayment(
|
||||
$session,
|
||||
$payment['method'],
|
||||
$payment['amount_rials'],
|
||||
null,
|
||||
$actor,
|
||||
);
|
||||
}
|
||||
|
||||
return $session;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Service;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* محل نوبتدهی یک درخواست: کلینیکِ دادهشده، یا null یعنی مطب شخصی پزشک.
|
||||
*
|
||||
* نبودِ clinic_uuid هرگز به معنی «هر محلی که پیدا شد» نیست. با چند برنامهٔ همزمان،
|
||||
* حدسزدن محل یعنی ثبت خاموشِ نوبت در جای اشتباه — پس یا محل صریح است، یا شخصی.
|
||||
*/
|
||||
class BookingContextResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
) {}
|
||||
|
||||
public function resolve(Doctor $doctor, ?string $clinicUuid): ?Clinic
|
||||
{
|
||||
if ($clinicUuid === null || trim($clinicUuid) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid(trim($clinicUuid));
|
||||
if ($clinic === null || !$clinic->hasDoctor($doctor)) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'محل نوبتدهی یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $clinic;
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,18 @@ use App\Appointment\Repository\DateOverrideRepository;
|
||||
use App\Appointment\Repository\HolidayRepository;
|
||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
|
||||
|
||||
class SlotCalculatorService
|
||||
{
|
||||
/** دلایل خالیبودن یک روز — برای پیام دقیق در پنل. */
|
||||
public const EMPTY_NO_SCHEDULE = 'no_schedule';
|
||||
public const EMPTY_HOLIDAY = 'holiday';
|
||||
public const EMPTY_DAY_OFF = 'day_off';
|
||||
public const EMPTY_OUTSIDE_WINDOW = 'outside_window';
|
||||
|
||||
public function __construct(
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly DateOverrideRepository $overrideRepo,
|
||||
@@ -25,9 +32,9 @@ class SlotCalculatorService
|
||||
*
|
||||
* @return array[] [{start, end, start_time, end_time, location_id}]
|
||||
*/
|
||||
public function getAvailableSlots(Doctor $doctor, string $date): array
|
||||
public function getAvailableSlots(Doctor $doctor, string $date, ?Clinic $clinic = null): array
|
||||
{
|
||||
$sessions = $this->buildAllSessions($doctor, $date);
|
||||
$sessions = $this->buildAllSessions($doctor, $date, $clinic);
|
||||
if (empty($sessions)) return [];
|
||||
$flat = array_merge(...array_map(fn($s) => $s['slots'], $sessions));
|
||||
return $this->filterBookedSlots($doctor, $flat);
|
||||
@@ -36,10 +43,10 @@ class SlotCalculatorService
|
||||
/**
|
||||
* آدرس (location_id) متناظر با اسلاتِ شروعشده در تاریخ مشخص. اگر پیدا نشد null.
|
||||
*/
|
||||
public function resolveSlotLocationId(Doctor $doctor, int $slotStart): ?int
|
||||
public function resolveSlotLocationId(Doctor $doctor, int $slotStart, ?Clinic $clinic = null): ?int
|
||||
{
|
||||
$date = date('Y-m-d', $slotStart);
|
||||
$sessions = $this->buildAllSessions($doctor, $date);
|
||||
$sessions = $this->buildAllSessions($doctor, $date, $clinic);
|
||||
foreach ($sessions as $session) {
|
||||
foreach (($session['slots'] ?? []) as $slot) {
|
||||
if ((int) ($slot['start'] ?? 0) === $slotStart) {
|
||||
@@ -57,9 +64,9 @@ class SlotCalculatorService
|
||||
*
|
||||
* @return array[] [{start_time, end_time, slots: [{start, end, start_time, end_time, location_id, is_available}]}]
|
||||
*/
|
||||
public function getAllSlotsWithAvailability(Doctor $doctor, string $date): array
|
||||
public function getAllSlotsWithAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null): array
|
||||
{
|
||||
$sessions = $this->buildAllSessions($doctor, $date);
|
||||
$sessions = $this->buildAllSessions($doctor, $date, $clinic);
|
||||
$now = time();
|
||||
return array_map(fn(array $session) => [
|
||||
'start_time' => $session['start_time'],
|
||||
@@ -75,23 +82,218 @@ class SlotCalculatorService
|
||||
* Whether a doctor has at least one slot on the given date.
|
||||
* Lightweight check for the month-availability endpoint.
|
||||
*/
|
||||
public function hasAnyAvailability(Doctor $doctor, string $date): bool
|
||||
public function hasAnyAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null): bool
|
||||
{
|
||||
return !empty($this->buildAllSessions($doctor, $date));
|
||||
return !empty($this->buildAllSessions($doctor, $date, $clinic));
|
||||
}
|
||||
|
||||
/**
|
||||
* حالت نوبتدهی سرویسی: زمانهای شروعِ ممکن برای نوبتی به طول $durationMinutes
|
||||
* در یک روز. برخلاف اسلاتِ ثابت، فضای خالی داخل هر session را با توجه به مدت
|
||||
* سرویس (+ بافر) پُر میکند: از ابتدای window شروع، بازههای اشغالشده را رد
|
||||
* میکند و اولین جای پیوستهٔ کافی را برمیگرداند، سپس نوبتهای بعدی را پشتسرهم
|
||||
* (با فاصلهٔ بافر) میچیند.
|
||||
*
|
||||
* زمان پایانِ ذخیرهشدهٔ نوبت = start + duration (بدون بافر)؛ بافر فقط فاصلهٔ
|
||||
* بین دو نوبت است، پس candidate بعدی از start + duration + buffer شروع میشود.
|
||||
*
|
||||
* @return array<array{start:int,end:int,start_time:string,end_time:string,location_id:?int}>
|
||||
*/
|
||||
public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes, ?Clinic $clinic = null): array
|
||||
{
|
||||
if ($durationMinutes <= 0) return [];
|
||||
|
||||
$buffer = (int)($this->getBookingMeta($doctor, $clinic)['buffer_minutes'] ?? 0);
|
||||
$durSec = $durationMinutes * 60;
|
||||
$needSec = $durSec + $buffer * 60; // فضای لازم شامل بافر
|
||||
|
||||
$sessions = $this->buildAllSessions($doctor, $date, $clinic); // window/holiday/override/booking-window رعایت میشود
|
||||
if (empty($sessions)) return [];
|
||||
|
||||
$dayStart = (int) strtotime($date . ' 00:00:00');
|
||||
$busy = $this->appointmentRepo->findBusyIntervals($doctor, $dayStart, $dayStart + 86400);
|
||||
$now = time();
|
||||
|
||||
$result = [];
|
||||
foreach ($sessions as $session) {
|
||||
$winStart = $dayStart + $this->parseTime($session['start_time'] ?? '00:00');
|
||||
$winEnd = $dayStart + $this->parseTime($session['end_time'] ?? '00:00');
|
||||
$locationId = $session['slots'][0]['location_id'] ?? null;
|
||||
|
||||
$t = max($winStart, $now);
|
||||
while ($t + $durSec <= $winEnd) {
|
||||
$end = $t + $durSec;
|
||||
$conflict = $this->firstOverlap($t, $t + $needSec, $busy);
|
||||
if ($conflict !== null) {
|
||||
$t = $conflict; // به انتهای بازهٔ اشغالشدهٔ متداخل بپر
|
||||
continue;
|
||||
}
|
||||
$result[] = [
|
||||
'start' => $t,
|
||||
'end' => $end,
|
||||
'start_time' => date('H:i', $t),
|
||||
'end_time' => date('H:i', $end),
|
||||
'location_id' => $locationId !== null ? (int) $locationId : null,
|
||||
];
|
||||
$t += $needSec; // نوبت بعدی پس از این نوبت + بافر
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* چرا این روز اسلاتی ندارد. null یعنی اسلات دارد.
|
||||
*
|
||||
* پنل نمیتواند خالیبودن را به «تعطیل» ترجمه کند: نبودِ برنامه، تعطیلی، روزِ
|
||||
* بدون شیفت و خارجبودن از بازهٔ نوبتدهی چهار چیز متفاوتاند و کاربر باید
|
||||
* بداند کدامیک رخ داده تا بداند چه کاری باید بکند.
|
||||
*/
|
||||
public function explainEmptyDay(Doctor $doctor, string $date, ?Clinic $clinic = null): ?string
|
||||
{
|
||||
if ($this->buildAllSessions($doctor, $date, $clinic) !== []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
|
||||
if ($schedule === null) {
|
||||
return self::EMPTY_NO_SCHEDULE;
|
||||
}
|
||||
|
||||
$dayStart = (int) strtotime($date . ' 00:00:00');
|
||||
|
||||
if ($this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayStart + 86399, $clinic) !== []) {
|
||||
return self::EMPTY_HOLIDAY;
|
||||
}
|
||||
|
||||
if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic)) {
|
||||
return self::EMPTY_OUTSIDE_WINDOW;
|
||||
}
|
||||
|
||||
return self::EMPTY_DAY_OFF;
|
||||
}
|
||||
|
||||
/**
|
||||
* زودترین اسلات آزاد در $daysAhead روز آینده، یا null اگر ظرفیتی نباشد.
|
||||
*
|
||||
* برخلاف صدا زدن getAvailableSlots() به ازای هر روز، برنامه و تعطیلی و استثناها
|
||||
* و نوبتهای اشغال یکبار برای کل بازه واکشی میشوند و بقیه در حافظه محاسبه
|
||||
* میشود: ۴ کوئری ثابت بهجای رشدِ خطی با تعداد روز و اسلات.
|
||||
*/
|
||||
public function findNextAvailableStart(Doctor $doctor, ?Clinic $clinic = null, int $daysAhead = 30): ?int
|
||||
{
|
||||
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
|
||||
if ($schedule === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = $schedule->getMeta();
|
||||
if (!($meta['online_booking_enabled'] ?? true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$todayStart = (int) strtotime('today 00:00:00');
|
||||
$windowEnd = $this->bookingWindowEnd($meta);
|
||||
$scanEnd = min($windowEnd, $todayStart + $daysAhead * 86400);
|
||||
if ($scanEnd < $todayStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$holidays = $this->holidayRepo->findActiveByDoctor($doctor, $todayStart, $scanEnd + 86399, $clinic);
|
||||
$blocking = $this->appointmentRepo->findBlockingIntervals($doctor, $now, $scanEnd + 86400);
|
||||
$overrides = [];
|
||||
foreach ($this->overrideRepo->findByDoctorAndClinic($doctor, $clinic) as $override) {
|
||||
$overrides[date('Y-m-d', $override->getDate())] = $override;
|
||||
}
|
||||
|
||||
$daySchedule = $schedule->getSetting();
|
||||
|
||||
for ($dayStart = $todayStart; $dayStart <= $scanEnd; $dayStart += 86400) {
|
||||
if ($this->isHoliday($holidays, $dayStart)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = date('Y-m-d', $dayStart);
|
||||
$override = $overrides[$date] ?? null;
|
||||
|
||||
if ($override !== null) {
|
||||
if (!$override->isActive()) {
|
||||
continue;
|
||||
}
|
||||
$sessions = $this->buildSessionsFromOverride($override->getSetting() ?? [], $dayStart);
|
||||
} else {
|
||||
$dayKey = (string) (((int) date('w', $dayStart) + 1) % 7);
|
||||
$dayConf = $daySchedule[$dayKey] ?? null;
|
||||
if ($dayConf === null) {
|
||||
continue;
|
||||
}
|
||||
$sessions = [];
|
||||
foreach (($dayConf['sessions'] ?? []) as $session) {
|
||||
if ($session['active'] ?? false) {
|
||||
$sessions[] = ['slots' => $this->buildSessionSlots($session, $dayStart)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($sessions as $session) {
|
||||
foreach (($session['slots'] ?? []) as $slot) {
|
||||
if ($slot['start'] >= $now && $this->firstOverlap($slot['start'], $slot['end'], $blocking) === null) {
|
||||
return (int) $slot['start'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param \App\Appointment\Entity\Holiday[] $holidays */
|
||||
private function isHoliday(array $holidays, int $dayStart): bool
|
||||
{
|
||||
$dayEnd = $dayStart + 86399;
|
||||
foreach ($holidays as $holiday) {
|
||||
if ($holiday->getStartDate() <= $dayEnd && $holiday->getEndDate() >= $dayStart) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function bookingWindowEnd(array $meta): int
|
||||
{
|
||||
$value = max(1, (int) ($meta['booking_window_value'] ?? 1));
|
||||
$unit = ($meta['booking_window_unit'] ?? 'month') === 'week' ? 'week' : 'month';
|
||||
|
||||
return (int) strtotime("today +{$value} {$unit} 00:00:00");
|
||||
}
|
||||
|
||||
/**
|
||||
* انتهای اولین بازهٔ اشغالشدهای که با [$start, $end) تداخل دارد، یا null.
|
||||
* @param array<array{start:int,end:int}> $busy
|
||||
*/
|
||||
private function firstOverlap(int $start, int $end, array $busy): ?int
|
||||
{
|
||||
foreach ($busy as $b) {
|
||||
if ($b['start'] < $end && $b['end'] > $start) {
|
||||
return $b['end'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking is allowed only when online booking is enabled and the date is
|
||||
* today..(today + window). Past dates are always rejected.
|
||||
*/
|
||||
private function isWithinBookingWindow(Doctor $doctor, int $dayStart): bool
|
||||
private function isWithinBookingWindow(Doctor $doctor, int $dayStart, ?Clinic $clinic): bool
|
||||
{
|
||||
$todayStart = (int) strtotime('today 00:00:00');
|
||||
if ($dayStart < $todayStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$meta = $this->getBookingMeta($doctor);
|
||||
$meta = $this->getBookingMeta($doctor, $clinic);
|
||||
if (!($meta['online_booking_enabled'] ?? true)) {
|
||||
return false;
|
||||
}
|
||||
@@ -103,9 +305,9 @@ class SlotCalculatorService
|
||||
return $dayStart <= $maxStart;
|
||||
}
|
||||
|
||||
private function getBookingMeta(Doctor $doctor): array
|
||||
private function getBookingMeta(Doctor $doctor, ?Clinic $clinic): array
|
||||
{
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
|
||||
return $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
|
||||
}
|
||||
|
||||
@@ -114,23 +316,23 @@ class SlotCalculatorService
|
||||
*
|
||||
* @return array[] [{start_time: string, end_time: string, slots: array[]}]
|
||||
*/
|
||||
private function buildAllSessions(Doctor $doctor, string $date): array
|
||||
private function buildAllSessions(Doctor $doctor, string $date, ?Clinic $clinic = null): array
|
||||
{
|
||||
$dayStart = (int) strtotime($date . ' 00:00:00');
|
||||
$dayEnd = $dayStart + 86400;
|
||||
|
||||
// 0. Online booking disabled or date outside the booking window
|
||||
if (!$this->isWithinBookingWindow($doctor, $dayStart)) {
|
||||
if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 1. Blocked by holiday
|
||||
if (!empty($this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1))) {
|
||||
if (!empty($this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1, $clinic))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. Date override takes precedence over weekly schedule
|
||||
foreach ($this->overrideRepo->findByDoctor($doctor) as $override) {
|
||||
foreach ($this->overrideRepo->findByDoctorAndClinic($doctor, $clinic) as $override) {
|
||||
if (date('Y-m-d', $override->getDate()) === $date) {
|
||||
if (!$override->isActive()) return [];
|
||||
return $this->buildSessionsFromOverride($override->getSetting() ?? [], $dayStart);
|
||||
@@ -138,7 +340,7 @@ class SlotCalculatorService
|
||||
}
|
||||
|
||||
// 3. Weekly schedule
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
|
||||
if ($schedule === null) return [];
|
||||
|
||||
// Convert PHP date('w') (0=Sunday) to Iranian week index (0=Saturday)
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Auth\Service\OtpService;
|
||||
use App\Auth\Service\TokenService;
|
||||
use App\Clinic\Entity\ClinicDoctorPermission;
|
||||
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
@@ -37,6 +39,7 @@ class AuthController extends BaseController
|
||||
private readonly RateLimiterFactory $passwordResetLimiter,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly ClinicDoctorPermissionRepository $clinicDoctorPermRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
@@ -430,6 +433,35 @@ class AuthController extends BaseController
|
||||
return $this->success(['message' => 'رمز عبور با موفقیت تغییر یافت']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the password of the authenticated user. Requires the current
|
||||
* password (verified against the stored hash); the new one must be ≥ 8
|
||||
* chars and different from the current.
|
||||
*/
|
||||
#[Route('/api/v1/user/change-password', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function changePassword(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$current = trim($data['current_password'] ?? '');
|
||||
$new = trim($data['new_password'] ?? '');
|
||||
|
||||
if (mb_strlen($new) < 8) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'رمز عبور جدید باید حداقل ۸ کاراکتر باشد', 422, 'new_password');
|
||||
}
|
||||
if ($current === '' || !$this->hasher->isPasswordValid($user, $current)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'رمز عبور فعلی نادرست است', 422, 'current_password');
|
||||
}
|
||||
if ($this->hasher->isPasswordValid($user, $new)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'رمز جدید نباید با رمز فعلی یکسان باشد', 422, 'new_password');
|
||||
}
|
||||
|
||||
$user->setPasswordHash($this->hasher->hashPassword($user, $new));
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success(['message' => 'رمز عبور با موفقیت تغییر یافت']);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/oauth/token/refresh',
|
||||
summary: 'Refresh access token using a refresh token',
|
||||
@@ -674,11 +706,18 @@ class AuthController extends BaseController
|
||||
'name' => 'مطب شخصی ' . $doctor->getName(),
|
||||
'role' => 'doctor',
|
||||
];
|
||||
foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
|
||||
// پزشکِ عضو کلینیک «مالک» نیست؛ نقش doctor با scope کلینیک میگیرد تا
|
||||
// فقط نوبتهای خودش در آن کلینیک را ببیند، نه دسترسی کامل پنل کلینیک.
|
||||
$memberClinics = $this->clinicRepo->findByDoctor($doctor);
|
||||
$permMap = $this->clinicDoctorPermRepo->mapByClinicForDoctor(
|
||||
$doctor,
|
||||
array_map(fn($c) => $c->getId(), $memberClinics),
|
||||
);
|
||||
|
||||
foreach ($memberClinics as $clinic) {
|
||||
// پزشکِ عضو کلینیک «مالک» نیست؛ نقش doctor با scope کلینیک میگیرد و
|
||||
// دسترسیاش را مجوزهای همان کلینیک تعیین میکند، نه hardcode.
|
||||
// اگر همین پزشک مالک کلینیک باشد، نقش کامل clinic در بلوک مالک پایین ست میشود.
|
||||
$isOwner = $clinic->getUser()->getId() === $user->getId();
|
||||
$perm = $permMap[$clinic->getId()] ?? null;
|
||||
$contexts[] = [
|
||||
'type' => 'clinic',
|
||||
'db_uuid' => $clinic->getUuid(),
|
||||
@@ -686,6 +725,7 @@ class AuthController extends BaseController
|
||||
'role' => $isOwner ? 'clinic' : 'doctor',
|
||||
'scope' => $isOwner ? null : 'clinic',
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'permissions' => $isOwner ? null : $this->contextPermissions($perm),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -735,6 +775,21 @@ class AuthController extends BaseController
|
||||
return $contexts;
|
||||
}
|
||||
|
||||
/**
|
||||
* مجوزی که به کلاینت داده میشود: نبودِ سطر یعنی عضویت قدیمی (پیشفرض)، و
|
||||
* سطر غیرفعال یعنی هیچ دسترسی.
|
||||
*/
|
||||
private function contextPermissions(?ClinicDoctorPermission $perm): array
|
||||
{
|
||||
if ($perm === null) {
|
||||
return ClinicDoctorPermission::DEFAULT_PERMISSIONS;
|
||||
}
|
||||
|
||||
return $perm->isActive()
|
||||
? $perm->getPermissions()
|
||||
: ['version' => 1, 'resources' => []];
|
||||
}
|
||||
|
||||
private function findContextByDbUuid(string $dbUuid, array $contexts): ?array
|
||||
{
|
||||
foreach ($contexts as $ctx) {
|
||||
|
||||
@@ -85,6 +85,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
public function getUserIdentifier(): string { return $this->mobileNumber; }
|
||||
public function eraseCredentials(): void {}
|
||||
|
||||
/** موبایل همان شناسه ورود است؛ تغییر آن نامکاربری کاربر را نیز تغییر میدهد. یکتایی در سطح فراخوان بررسی شود. */
|
||||
public function setMobileNumber(string $mobileNumber): self { $this->mobileNumber = $mobileNumber; $this->updatedAt = time(); return $this; }
|
||||
public function setEmail(?string $email): self { $this->email = $email; return $this; }
|
||||
public function setRealName(?string $name): self { $this->realName = $name; $this->updatedAt = time(); return $this; }
|
||||
public function setNationalCode(?string $code): self
|
||||
|
||||
@@ -5,14 +5,15 @@ namespace App\Billing\Controller;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Billing\Entity\Claim;
|
||||
use App\Billing\Repository\ClaimRepository;
|
||||
use App\Billing\Repository\ClaimStatusLogRepository;
|
||||
use App\Billing\Repository\InvoiceItemRepository;
|
||||
use App\Billing\Repository\InvoiceRepository;
|
||||
use App\Billing\Service\ClaimService;
|
||||
use App\Billing\Service\InvoiceService;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Insurance\Repository\InsuranceRepository;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
use App\Patient\Security\PatientRecordScopeResolver;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
@@ -33,9 +34,10 @@ class BillingController extends BaseController
|
||||
private readonly ClaimService $claimService,
|
||||
private readonly ClaimRepository $claimRepo,
|
||||
private readonly PatientSessionRepository $sessionRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly PatientRecordRepository $recordRepo,
|
||||
private readonly InsuranceRepository $insuranceRepo,
|
||||
private readonly ClaimStatusLogRepository $statusLogRepo,
|
||||
private readonly PatientRecordScopeResolver $scopeResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -135,7 +137,7 @@ class BillingController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'صورتحساب یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $invoice->toArray()]);
|
||||
return $this->success(['data' => $this->invoiceService->detailWithSession($invoice)]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/billing/invoices/{uuid}/finalize', methods: ['POST'])]
|
||||
@@ -152,6 +154,106 @@ class BillingController extends BaseController
|
||||
return $this->success(['data' => $invoice->toArray()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* لیست پرداختها — یک ردیف بهازای هر صورتحساب ثبتشدهی tenant (flat).
|
||||
* فیلترها: national_code، status (paid|unsettled)، from/to (unix ثانیه).
|
||||
* پاسخ صفحهبندی: هر ردیف { invoice_uuid, patient_uuid, patient_name,
|
||||
* national_code, issued_at, amount_rials, status }.
|
||||
*/
|
||||
#[Route('/api/v1/my/billing/payments', methods: ['GET'])]
|
||||
public function listPayments(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
|
||||
|
||||
$result = $this->invoiceService->tenantInvoiceList(
|
||||
$entityType,
|
||||
$entityId,
|
||||
$this->paymentFilters($request),
|
||||
$page,
|
||||
$limit,
|
||||
);
|
||||
|
||||
return $this->paginated($result['items'], $result['total'], $page, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* خلاصهی مالی همان مجموعهی فیلترشدهی listPayments — برای کارتهای آمار.
|
||||
* پاسخ: { total_rials, paid_rials, unsettled_rials, invoices_count }.
|
||||
*/
|
||||
#[Route('/api/v1/my/billing/payments/summary', methods: ['GET'])]
|
||||
public function paymentsSummary(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
return $this->success(
|
||||
$this->invoiceService->tenantInvoiceSummary($entityType, $entityId, $this->paymentFilters($request)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* فیلترهای مشترک لیست پرداختها و خلاصهی آن؛ یک منبع تا دو نما واگرا نشوند.
|
||||
*
|
||||
* @return array{national_code:?string,status:?string,from:?string,to:?string}
|
||||
*/
|
||||
private function paymentFilters(Request $request): array
|
||||
{
|
||||
return [
|
||||
'national_code' => $request->query->get('national_code') ?: null,
|
||||
'status' => $request->query->get('status') ?: null,
|
||||
'from' => $request->query->get('from') ?: null,
|
||||
'to' => $request->query->get('to') ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* پرداختهای ثبتشدهی یک بیمار — سربرگ بیمار + فهرست صفحهبندیشدهی صورتحسابها.
|
||||
* فقط مالک رکورد (همان tenant) اجازه دارد؛ در غیر این صورت ۴۰۴.
|
||||
* پاسخ: { patient:{uuid,name,national_code}, data:[صورتحسابها], summary:{...}, meta:{...} }.
|
||||
*/
|
||||
#[Route('/api/v1/my/billing/patients/{patientUuid}/invoices', methods: ['GET'])]
|
||||
public function listPatientInvoices(string $patientUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$record = $this->recordRepo->findByUuid($patientUuid);
|
||||
if ($record === null || $record->getEntityType() !== $entityType || $record->getEntityId() !== $entityId) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'بیمار یافت نشد', 404);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
|
||||
|
||||
$result = $this->invoiceService->patientInvoiceList($entityType, $entityId, $record->getId(), $page, $limit);
|
||||
$patient = $record->getUser();
|
||||
|
||||
return $this->success([
|
||||
'patient' => [
|
||||
'uuid' => $record->getUuid(),
|
||||
'name' => $patient->getRealName(),
|
||||
'national_code' => $patient->getNationalCode(),
|
||||
],
|
||||
'data' => $result['items'],
|
||||
'summary' => $result['summary'],
|
||||
'meta' => [
|
||||
'totalRecords' => $result['total'],
|
||||
'totalPages' => (int) ceil($result['total'] / $limit),
|
||||
'currentPage' => $page,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Claims (مطالبات بیمه) ──────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/billing/claims', methods: ['POST'])]
|
||||
@@ -212,6 +314,120 @@ class BillingController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* نمای سطحاول داشبورد مطالبات: یک ردیف بهازای هر بیمار با جمعهای تجمیعی.
|
||||
* فیلترها همان فیلترهای مطالبهاند و روی count هم اعمال میشوند.
|
||||
*/
|
||||
#[Route('/api/v1/billing/claims/by-patient', methods: ['GET'])]
|
||||
public function claimsByPatient(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$filters = $this->claimFilters($request);
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
|
||||
$sort = (string) $request->query->get('sort', 'last_activity_at');
|
||||
$dir = (string) $request->query->get('dir', 'desc');
|
||||
|
||||
$rows = $this->claimRepo->aggregateByPatient($entityType, $entityId, $filters, $sort, $dir, $page, $limit);
|
||||
$total = $this->claimRepo->countPatientsWithClaims($entityType, $entityId, $filters);
|
||||
|
||||
return $this->paginated($rows, $total, $page, $limit);
|
||||
}
|
||||
|
||||
/** جزئیات کامل مطالبات یک بیمار، بههمراه تاریخچهی تغییر وضعیت هر مطالبه. */
|
||||
#[Route('/api/v1/billing/claims/by-patient/{patientUuid}', methods: ['GET'])]
|
||||
public function claimsForPatient(string $patientUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$record = $this->recordRepo->findByUuid($patientUuid);
|
||||
if ($record === null || $record->getEntityType() !== $entityType || $record->getEntityId() !== $entityId) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پرونده بیمار یافت نشد', 404);
|
||||
}
|
||||
|
||||
$rows = $this->claimRepo->detailsForPatient($entityType, $entityId, (int) $record->getId(), $this->claimFilters($request));
|
||||
$timelines = $this->statusLogRepo->timelinesForClaims(array_map(static fn(array $r) => (int) $r['claim_id'], $rows));
|
||||
|
||||
$insuranceNames = $this->insuranceNamesFor(array_map(static fn(array $r) => (int) $r['insurance_id'], $rows));
|
||||
|
||||
$items = array_map(function (array $r) use ($timelines, $insuranceNames) {
|
||||
$claimed = (int) $r['total_claimed_rials'];
|
||||
$base = (int) $r['service_base_rials'];
|
||||
|
||||
return [
|
||||
'uuid' => $r['uuid'],
|
||||
'invoice_uuid' => $r['invoice_uuid'],
|
||||
'visit_date' => $r['visit_date'] !== null ? (int) $r['visit_date'] : null,
|
||||
'doctor_name' => $r['doctor_name'],
|
||||
'insurance_id' => (int) $r['insurance_id'],
|
||||
'insurance_name' => $insuranceNames[(int) $r['insurance_id']] ?? null,
|
||||
'insurance_kind' => $r['insurance_kind'],
|
||||
'service_base_rials' => $base,
|
||||
'coverage_percent' => $base > 0 ? round($claimed * 100 / $base, 2) : 0.0,
|
||||
'insurance_share_rials'=> $claimed,
|
||||
'patient_share_rials' => (int) $r['patient_share_rials'],
|
||||
'total_approved_rials' => $r['total_approved_rials'] !== null ? (int) $r['total_approved_rials'] : null,
|
||||
'total_paid_rials' => $r['total_paid_rials'] !== null ? (int) $r['total_paid_rials'] : null,
|
||||
'status' => $r['status'],
|
||||
'tracking_number' => $r['tracking_number'],
|
||||
'reject_reason' => $r['reject_reason'],
|
||||
'submitted_at' => $r['submitted_at'] !== null ? (int) $r['submitted_at'] : null,
|
||||
'settled_at' => $r['settled_at'] !== null ? (int) $r['settled_at'] : null,
|
||||
'created_at' => (int) $r['created_at'],
|
||||
'allowed_transitions' => Claim::transitionsFrom($r['status']),
|
||||
'logs' => $timelines[(int) $r['claim_id']] ?? [],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return $this->success([
|
||||
'patient' => [
|
||||
'uuid' => $record->getUser()->getUuid(),
|
||||
'record_uuid' => $record->getUuid(),
|
||||
'full_name' => $record->getUser()->getRealName(),
|
||||
'mobile' => $record->getUser()->getMobileNumber(),
|
||||
'national_code' => $record->getUser()->getNationalCode(),
|
||||
],
|
||||
'claims' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param int[] $ids @return array<int, string> */
|
||||
private function insuranceNamesFor(array $ids): array
|
||||
{
|
||||
$unique = array_values(array_unique($ids));
|
||||
if ($unique === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = [];
|
||||
foreach ($this->insuranceRepo->findBy(['id' => $unique]) as $insurance) {
|
||||
$names[$insurance->getId()] = $insurance->getName();
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function claimFilters(Request $request): array
|
||||
{
|
||||
return [
|
||||
'status' => $request->query->get('status') ?: null,
|
||||
'insurance_id' => $request->query->get('insurance_id') ?: null,
|
||||
'doctor_id' => $request->query->get('doctor_id') ?: null,
|
||||
'payment_status' => $request->query->get('payment_status') ?: null,
|
||||
'from' => $request->query->get('from') ?: null,
|
||||
'to' => $request->query->get('to') ?: null,
|
||||
'search' => $request->query->get('search') ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
#[Route('/api/v1/billing/claims/{uuid}/{action}', methods: ['POST'], requirements: ['action' => 'submit|approve|reject|pay'])]
|
||||
public function transitionClaim(string $uuid, string $action, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
@@ -229,10 +445,6 @@ class BillingController extends BaseController
|
||||
'pay' => Claim::STATUS_PAID,
|
||||
};
|
||||
|
||||
if ($action === 'reject' && trim($data['reason'] ?? '') === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'دلیل رد الزامی است', 422);
|
||||
}
|
||||
|
||||
// Bound the financial figures: approved/paid cannot be negative, approved
|
||||
// cannot exceed the claimed total, and paid cannot exceed approved.
|
||||
if ($action === 'approve' && isset($data['approved_rials'])) {
|
||||
@@ -250,10 +462,12 @@ class BillingController extends BaseController
|
||||
}
|
||||
|
||||
$this->claimService->transition($claim, $target, [
|
||||
'approved_rials' => isset($data['approved_rials']) ? (int) $data['approved_rials'] : null,
|
||||
'paid_rials' => isset($data['paid_rials']) ? (int) $data['paid_rials'] : null,
|
||||
'reason' => trim($data['reason'] ?? ''),
|
||||
]);
|
||||
'approved_rials' => isset($data['approved_rials']) ? (int) $data['approved_rials'] : null,
|
||||
'paid_rials' => isset($data['paid_rials']) ? (int) $data['paid_rials'] : null,
|
||||
'reason' => trim($data['reason'] ?? ''),
|
||||
'tracking_number' => isset($data['tracking_number']) ? trim((string) $data['tracking_number']) : null,
|
||||
'note' => isset($data['note']) ? trim((string) $data['note']) : null,
|
||||
], $user);
|
||||
|
||||
return $this->success(['data' => $this->enrichClaims([$claim])[0]]);
|
||||
}
|
||||
@@ -280,16 +494,13 @@ class BillingController extends BaseController
|
||||
return $record->getEntityType() === $entityType && $record->getEntityId() === $entityId;
|
||||
}
|
||||
|
||||
/**
|
||||
* محیط صورتحساب همان محیط پرونده است — صورتحساب و مطالبه از دل مراجعه بیرون میآیند.
|
||||
* ترتیب نقشها بهتنهایی کافی نبود: مالک کلینیکی که خودش پزشک هم هست به مطب شخصیاش
|
||||
* نگاشت میشد و صورتحسابهای کلینیک خودش را «یافت نشد» میگرفت.
|
||||
*/
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
|
||||
}
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
|
||||
}
|
||||
return ['unknown', null];
|
||||
return $this->scopeResolver->resolve($user)->toLegacyTuple();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ class Claim
|
||||
#[ORM\Column(name: 'reject_reason', type: 'text', nullable: true)]
|
||||
private ?string $rejectReason = null;
|
||||
|
||||
/** شماره پرونده/پیگیری نزد بیمهگر — هنگام ارسال وارد میشود. */
|
||||
#[ORM\Column(name: 'tracking_number', type: 'string', length: 60, nullable: true)]
|
||||
private ?string $trackingNumber = null;
|
||||
|
||||
#[ORM\Column(name: 'submitted_at', type: 'integer', nullable: true)]
|
||||
private ?int $submittedAt = null;
|
||||
|
||||
@@ -119,6 +123,41 @@ class Claim
|
||||
return in_array($status, self::TRANSITIONS[$this->status] ?? [], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* انتقالهای مجاز از وضعیت فعلی. پنل دکمهها را از همین میسازد تا فهرست
|
||||
* مجاز فقط یکجا تعریف شده باشد.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function allowedTransitions(): array
|
||||
{
|
||||
return self::transitionsFrom($this->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* همان جدول انتقال، برای مسیرهایی که ردیف خام (array hydration) دارند و
|
||||
* موجودیت را هیدریت نمیکنند.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function transitionsFrom(string $status): array
|
||||
{
|
||||
return self::TRANSITIONS[$status] ?? [];
|
||||
}
|
||||
|
||||
public function getTrackingNumber(): ?string { return $this->trackingNumber; }
|
||||
public function getRejectReason(): ?string { return $this->rejectReason; }
|
||||
public function getSubmittedAt(): ?int { return $this->submittedAt; }
|
||||
public function getSettledAt(): ?int { return $this->settledAt; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
|
||||
public function setTrackingNumber(?string $v): self
|
||||
{
|
||||
$this->trackingNumber = $v !== null && trim($v) !== '' ? trim($v) : null;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$this->status = self::STATUS_SUBMITTED;
|
||||
@@ -162,6 +201,8 @@ class Claim
|
||||
'total_paid_rials' => $this->totalPaidRials,
|
||||
'status' => $this->status,
|
||||
'reject_reason' => $this->rejectReason,
|
||||
'tracking_number' => $this->trackingNumber,
|
||||
'allowed_transitions' => $this->allowedTransitions(),
|
||||
'submitted_at' => $this->submittedAt,
|
||||
'settled_at' => $this->settledAt,
|
||||
'created_at' => $this->createdAt,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Billing\Entity;
|
||||
|
||||
use App\Billing\Repository\ClaimStatusLogRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* تاریخچهی تغییر وضعیت یک مطالبه.
|
||||
*
|
||||
* وضعیت روی خودِ Claim فقط «آخرین حالت» است؛ پیگیری پروندهی بیمه نیاز دارد بداند
|
||||
* چه کسی، کِی و با چه توضیحی آن را جابهجا کرده است.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ClaimStatusLogRepository::class)]
|
||||
#[ORM\Table(name: 'claim_status_logs')]
|
||||
#[ORM\Index(columns: ['claim_id'], name: 'idx_claim_status_log_claim')]
|
||||
class ClaimStatusLog
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(name: 'claim_id', type: 'integer')]
|
||||
private int $claimId;
|
||||
|
||||
/** null فقط برای ردیف ساخت اولیه. */
|
||||
#[ORM\Column(name: 'from_status', type: 'string', length: 15, nullable: true)]
|
||||
private ?string $fromStatus = null;
|
||||
|
||||
#[ORM\Column(name: 'to_status', type: 'string', length: 15)]
|
||||
private string $toStatus;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $note = null;
|
||||
|
||||
/** null یعنی سیستمی (backfill یا اتوماسیون). */
|
||||
#[ORM\Column(name: 'created_by_id', type: 'integer', nullable: true)]
|
||||
private ?int $createdById = null;
|
||||
|
||||
#[ORM\Column(name: 'created_by_name', type: 'string', length: 120, nullable: true)]
|
||||
private ?string $createdByName = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(int $claimId, ?string $fromStatus, string $toStatus)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->claimId = $claimId;
|
||||
$this->fromStatus = $fromStatus;
|
||||
$this->toStatus = $toStatus;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getClaimId(): int { return $this->claimId; }
|
||||
public function getToStatus(): string { return $this->toStatus; }
|
||||
|
||||
public function setNote(?string $note): self
|
||||
{
|
||||
$this->note = $note !== null && trim($note) !== '' ? trim($note) : null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setActor(?int $userId, ?string $name): self
|
||||
{
|
||||
$this->createdById = $userId;
|
||||
$this->createdByName = $name;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setCreatedAt(int $at): self
|
||||
{
|
||||
$this->createdAt = $at;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'from_status' => $this->fromStatus,
|
||||
'to_status' => $this->toStatus,
|
||||
'note' => $this->note,
|
||||
'by' => $this->createdByName,
|
||||
'at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,8 @@ class Invoice
|
||||
public function getSupplementaryInsuranceId(): ?int { return $this->supplementaryInsuranceId; }
|
||||
public function getTotalRials(): int { return $this->totalRials; }
|
||||
public function getPatientRials(): int { return $this->patientRials; }
|
||||
public function getIssuedAt(): int { return $this->issuedAt; }
|
||||
public function getPatientSessionId(): ?int { return $this->patientSessionId; }
|
||||
/** @return Collection<int, InvoiceItem> */
|
||||
public function getItems(): Collection { return $this->items; }
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ class InvoiceItem
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getServiceItemId(): ?int { return $this->serviceItemId; }
|
||||
public function getTitle(): string { return $this->title; }
|
||||
public function getTotalRials(): int { return $this->totalRials; }
|
||||
public function getBaseInsuranceRials(): int { return $this->baseInsuranceRials; }
|
||||
public function getSupplementaryRials(): int { return $this->supplementaryRials; }
|
||||
|
||||
@@ -119,6 +119,216 @@ class ClaimRepository extends ServiceEntityRepository
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* تجمیع مطالبات بر اساس بیمار — نمای سطحاول داشبورد.
|
||||
*
|
||||
* مبالغ خدمات/سهم بیمار از صورتحسابهای **یکتا** جمع میشوند، نه از مطالبات؛ یک
|
||||
* صورتحساب میتواند دو مطالبه (پایه و مکمل) داشته باشد و جمعزدن از سمت مطالبه
|
||||
* مبلغ خدمات را دوبار میشمرد.
|
||||
*
|
||||
* @param array<string, mixed> $filters
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function aggregateByPatient(string $entityType, int $entityId, array $filters, string $sort, string $dir, int $page, int $limit): array
|
||||
{
|
||||
[$where, $params] = $this->patientAggregateFilters($filters);
|
||||
|
||||
$orderBy = match ($sort) {
|
||||
'claims_count' => 'claims_count',
|
||||
'total_services_rials' => 'total_services_rials',
|
||||
'total_insurance_rials' => 'total_insurance_rials',
|
||||
'last_activity_at' => 'last_activity_at',
|
||||
default => 'full_name',
|
||||
};
|
||||
$direction = strtolower($dir) === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
// LIMIT/OFFSET بهصورت مقدار درج میشوند: MariaDB پارامتر رشتهای در LIMIT نمیپذیرد.
|
||||
// هر دو از قبل به int تبدیل شدهاند، پس تزریقی ممکن نیست.
|
||||
$offset = ($page - 1) * $limit;
|
||||
$sql = $this->patientAggregateSql($where)
|
||||
. " ORDER BY {$orderBy} {$direction} LIMIT {$limit} OFFSET {$offset}";
|
||||
|
||||
$params['type'] = $entityType;
|
||||
$params['id'] = $entityId;
|
||||
|
||||
$rows = $this->getEntityManager()->getConnection()->executeQuery($sql, $params)->fetchAllAssociative();
|
||||
|
||||
return array_map(static function (array $r): array {
|
||||
$statuses = array_filter(explode(',', (string) $r['statuses']));
|
||||
|
||||
return [
|
||||
'patient_uuid' => $r['patient_uuid'],
|
||||
'record_uuid' => $r['record_uuid'],
|
||||
'full_name' => $r['full_name'],
|
||||
'mobile' => $r['mobile'],
|
||||
'national_code' => $r['national_code'],
|
||||
'claims_count' => (int) $r['claims_count'],
|
||||
'total_services_rials' => (int) $r['total_services_rials'],
|
||||
'total_insurance_rials' => (int) $r['total_insurance_rials'],
|
||||
'total_patient_rials' => (int) $r['total_patient_rials'],
|
||||
'total_approved_rials' => (int) $r['total_approved_rials'],
|
||||
'total_paid_rials' => (int) $r['total_paid_rials'],
|
||||
'overall_status' => count($statuses) === 1 ? reset($statuses) : 'mixed',
|
||||
'last_activity_at' => (int) $r['last_activity_at'],
|
||||
];
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
public function countPatientsWithClaims(string $entityType, int $entityId, array $filters): int
|
||||
{
|
||||
[$where, $params] = $this->patientAggregateFilters($filters);
|
||||
$params['type'] = $entityType;
|
||||
$params['id'] = $entityId;
|
||||
|
||||
$sql = 'SELECT COUNT(*) FROM (' . $this->patientAggregateSql($where) . ') agg';
|
||||
|
||||
return (int) $this->getEntityManager()->getConnection()->executeQuery($sql, $params)->fetchOne();
|
||||
}
|
||||
|
||||
private function patientAggregateSql(string $where): string
|
||||
{
|
||||
return <<<SQL
|
||||
WITH claim_map AS (
|
||||
SELECT c.id AS claim_id, MIN(inv.id) AS invoice_id, MIN(inv.patient_record_id) AS record_id
|
||||
FROM claims c
|
||||
JOIN claim_items ci ON ci.claim_id = c.id
|
||||
JOIN invoice_items ii ON ii.id = ci.invoice_item_id
|
||||
JOIN invoices inv ON inv.id = ii.invoice_id
|
||||
WHERE c.entity_type = :type AND c.entity_id = :id
|
||||
GROUP BY c.id
|
||||
)
|
||||
SELECT
|
||||
u.uuid AS patient_uuid,
|
||||
pr.uuid AS record_uuid,
|
||||
u.real_name AS full_name,
|
||||
u.mobile_number AS mobile,
|
||||
u.national_code AS national_code,
|
||||
COUNT(DISTINCT c.id) AS claims_count,
|
||||
COALESCE(SUM(c.total_claimed_rials), 0) AS total_insurance_rials,
|
||||
COALESCE(SUM(c.total_approved_rials), 0) AS total_approved_rials,
|
||||
COALESCE(SUM(c.total_paid_rials), 0) AS total_paid_rials,
|
||||
COALESCE((
|
||||
SELECT SUM(i2.total_rials) FROM invoices i2
|
||||
WHERE i2.id IN (SELECT DISTINCT cm2.invoice_id FROM claim_map cm2 WHERE cm2.record_id = pr.id)
|
||||
), 0) AS total_services_rials,
|
||||
COALESCE((
|
||||
SELECT SUM(i3.patient_rials) FROM invoices i3
|
||||
WHERE i3.id IN (SELECT DISTINCT cm3.invoice_id FROM claim_map cm3 WHERE cm3.record_id = pr.id)
|
||||
), 0) AS total_patient_rials,
|
||||
GROUP_CONCAT(DISTINCT c.status) AS statuses,
|
||||
MAX(c.updated_at) AS last_activity_at
|
||||
FROM claim_map cm
|
||||
JOIN claims c ON c.id = cm.claim_id
|
||||
JOIN invoices inv ON inv.id = cm.invoice_id
|
||||
JOIN patient_records pr ON pr.id = cm.record_id
|
||||
JOIN users u ON u.id = pr.user_id
|
||||
{$where}
|
||||
GROUP BY pr.id, u.uuid, pr.uuid, u.real_name, u.mobile_number, u.national_code
|
||||
SQL;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return array{0: string, 1: array<string, mixed>}
|
||||
*/
|
||||
private function patientAggregateFilters(array $filters): array
|
||||
{
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
|
||||
if (!empty($filters['status'])) {
|
||||
$conditions[] = 'c.status = :status';
|
||||
$params['status'] = $filters['status'];
|
||||
}
|
||||
if (!empty($filters['insurance_id'])) {
|
||||
$conditions[] = 'c.insurance_id = :insId';
|
||||
$params['insId'] = (int) $filters['insurance_id'];
|
||||
}
|
||||
if (!empty($filters['from'])) {
|
||||
$conditions[] = 'c.created_at >= :from';
|
||||
$params['from'] = (int) $filters['from'];
|
||||
}
|
||||
if (!empty($filters['to'])) {
|
||||
$conditions[] = 'c.created_at <= :to';
|
||||
$params['to'] = (int) $filters['to'];
|
||||
}
|
||||
if (!empty($filters['doctor_id'])) {
|
||||
$conditions[] = 'EXISTS (SELECT 1 FROM patient_sessions ps '
|
||||
. 'JOIN appointments a ON a.id = ps.appointment_id '
|
||||
. 'WHERE ps.id = inv.patient_session_id AND a.doctor_id = :docId)';
|
||||
$params['docId'] = (int) $filters['doctor_id'];
|
||||
}
|
||||
if (!empty($filters['payment_status'])) {
|
||||
$conditions[] = $filters['payment_status'] === 'paid'
|
||||
? 'c.status = \'paid\''
|
||||
: 'c.status <> \'paid\'';
|
||||
}
|
||||
if (!empty($filters['search'])) {
|
||||
$conditions[] = '(u.real_name LIKE :search OR u.mobile_number LIKE :search OR u.national_code LIKE :search)';
|
||||
$params['search'] = '%' . trim((string) $filters['search']) . '%';
|
||||
}
|
||||
|
||||
return [$conditions === [] ? '' : 'WHERE ' . implode(' AND ', $conditions), $params];
|
||||
}
|
||||
|
||||
/**
|
||||
* مطالبات یک بیمار با جزئیات نمایشی (پزشک، سرویس، تاریخ مراجعه، سهمها).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function detailsForPatient(string $entityType, int $entityId, int $recordId, array $filters): array
|
||||
{
|
||||
[$where, $params] = $this->patientAggregateFilters($filters);
|
||||
$where = $where === '' ? 'WHERE pr.id = :recordId' : $where . ' AND pr.id = :recordId';
|
||||
|
||||
$params['type'] = $entityType;
|
||||
$params['id'] = $entityId;
|
||||
$params['recordId'] = $recordId;
|
||||
|
||||
$sql = <<<SQL
|
||||
WITH claim_map AS (
|
||||
SELECT c.id AS claim_id, MIN(inv.id) AS invoice_id, MIN(inv.patient_record_id) AS record_id
|
||||
FROM claims c
|
||||
JOIN claim_items ci ON ci.claim_id = c.id
|
||||
JOIN invoice_items ii ON ii.id = ci.invoice_item_id
|
||||
JOIN invoices inv ON inv.id = ii.invoice_id
|
||||
WHERE c.entity_type = :type AND c.entity_id = :id
|
||||
GROUP BY c.id
|
||||
)
|
||||
SELECT
|
||||
c.id AS claim_id,
|
||||
c.uuid,
|
||||
c.insurance_id,
|
||||
c.insurance_kind,
|
||||
c.status,
|
||||
c.total_claimed_rials,
|
||||
c.total_approved_rials,
|
||||
c.total_paid_rials,
|
||||
c.reject_reason,
|
||||
c.tracking_number,
|
||||
c.submitted_at,
|
||||
c.settled_at,
|
||||
c.created_at,
|
||||
inv.uuid AS invoice_uuid,
|
||||
inv.total_rials AS service_base_rials,
|
||||
inv.patient_rials AS patient_share_rials,
|
||||
ps.session_at AS visit_date,
|
||||
d.name AS doctor_name
|
||||
FROM claim_map cm
|
||||
JOIN claims c ON c.id = cm.claim_id
|
||||
JOIN invoices inv ON inv.id = cm.invoice_id
|
||||
JOIN patient_records pr ON pr.id = cm.record_id
|
||||
JOIN users u ON u.id = pr.user_id
|
||||
LEFT JOIN patient_sessions ps ON ps.id = inv.patient_session_id
|
||||
LEFT JOIN appointments a ON a.id = ps.appointment_id
|
||||
LEFT JOIN doctors d ON d.id = a.doctor_id
|
||||
{$where}
|
||||
ORDER BY c.created_at DESC, c.id DESC
|
||||
SQL;
|
||||
|
||||
return $this->getEntityManager()->getConnection()->executeQuery($sql, $params)->fetchAllAssociative();
|
||||
}
|
||||
|
||||
/** آیا برای این صورتحساب از قبل مطالبهای ساخته شده؟ (از طریق آیتمهای صورتحساب) */
|
||||
public function existsForInvoice(int $invoiceId): bool
|
||||
{
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Billing\Repository;
|
||||
|
||||
use App\Billing\Entity\ClaimStatusLog;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class ClaimStatusLogRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ClaimStatusLog::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* تاریخچهی چند مطالبه در یک رفتوآمد، گروهبندیشده بر اساس claim_id.
|
||||
*
|
||||
* @param int[] $claimIds
|
||||
* @return array<int, array<int, array<string, mixed>>>
|
||||
*/
|
||||
public function timelinesForClaims(array $claimIds): array
|
||||
{
|
||||
if ($claimIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$logs = $this->createQueryBuilder('l')
|
||||
->where('l.claimId IN (:ids)')
|
||||
->setParameter('ids', $claimIds)
|
||||
->orderBy('l.createdAt', 'ASC')
|
||||
->addOrderBy('l.id', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$grouped = [];
|
||||
foreach ($logs as $log) {
|
||||
$grouped[$log->getClaimId()][] = $log->toArray();
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
public function save(ClaimStatusLog $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,12 @@
|
||||
namespace App\Billing\Repository;
|
||||
|
||||
use App\Billing\Entity\Invoice;
|
||||
use App\Billing\Service\InvoicePaymentStatus;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\SessionPayment;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\Query\Expr\Join;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class InvoiceRepository extends ServiceEntityRepository
|
||||
@@ -23,6 +28,234 @@ class InvoiceRepository extends ServiceEntityRepository
|
||||
return $this->findOneBy(['patientSessionId' => $patientSessionId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A flat, newest-first page of a tenant's recorded (finalized/paid) invoices,
|
||||
* one row per invoice with the patient's name and national code joined in.
|
||||
*
|
||||
* @param array{national_code?:?string,status?:?string,from?:?int,to?:?int} $filters
|
||||
* status: paid | unsettled (maps to invoice paid / finalized).
|
||||
* @return list<array{invoice_uuid:string,patient_uuid:string,patient_name:?string,national_code:?string,issued_at:int,amount_rials:int,status:string}>
|
||||
*/
|
||||
public function tenantInvoices(string $entityType, int $entityId, array $filters, int $page, int $limit): array
|
||||
{
|
||||
$rows = $this->tenantInvoicesQuery($entityType, $entityId, $filters)
|
||||
->select(
|
||||
'i.uuid AS invoice_uuid', 'r.uuid AS patient_uuid', 'u.realName AS patient_name',
|
||||
'u.nationalCode AS national_code', 'i.issuedAt AS issued_at',
|
||||
'i.patientRials AS amount_rials',
|
||||
sprintf('%s AS paid_rials', self::paidSumDql('sp_row')),
|
||||
)
|
||||
->orderBy('i.issuedAt', 'DESC')
|
||||
->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
return array_map(static fn(array $r): array => [
|
||||
'invoice_uuid' => $r['invoice_uuid'],
|
||||
'patient_uuid' => $r['patient_uuid'],
|
||||
'patient_name' => $r['patient_name'],
|
||||
'national_code' => $r['national_code'],
|
||||
'issued_at' => (int) $r['issued_at'],
|
||||
'amount_rials' => (int) $r['amount_rials'],
|
||||
'paid_rials' => (int) $r['paid_rials'],
|
||||
'status' => InvoicePaymentStatus::resolve((int) $r['amount_rials'], (int) $r['paid_rials']),
|
||||
], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* زیرپرسوجوی مجموع پرداختهای مراجعهی همان صورتحساب. صورتحساب بدون مراجعه
|
||||
* هیچ پرداختی ندارد، پس `COALESCE` صفر میدهد و «تسویهنشده» میماند.
|
||||
*
|
||||
* @param string $alias نام یکتا؛ استفادهی دوبار از یک alias در یک query خطای semantical میدهد.
|
||||
*/
|
||||
private static function paidSumDql(string $alias): string
|
||||
{
|
||||
return sprintf(
|
||||
'(SELECT COALESCE(SUM(%1$s.amountRials), 0) FROM %2$s %1$s WHERE IDENTITY(%1$s.session) = i.patientSessionId)',
|
||||
$alias,
|
||||
SessionPayment::class,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* پرداختهای ثبتشدهی هر صورتحساب (از راه مراجعهاش)، در یک کوئری برای کل صفحه.
|
||||
* مبلغ وصولشده هم از همین ردیفها جمع میشود تا کوئری دوم لازم نباشد.
|
||||
*
|
||||
* @param list<Invoice> $invoices
|
||||
* @return array<int, list<array{method:string,amount_rials:int,paid_at:int,created_by_name:?string}>>
|
||||
* کلید = شناسهی صورتحساب؛ قدیمیترین پرداخت اول.
|
||||
*/
|
||||
public function paymentsForInvoices(array $invoices): array
|
||||
{
|
||||
$sessionIds = [];
|
||||
foreach ($invoices as $invoice) {
|
||||
$sessionId = $invoice->getPatientSessionId();
|
||||
if ($sessionId !== null) {
|
||||
$sessionIds[$invoice->getId()] = $sessionId;
|
||||
}
|
||||
}
|
||||
if ($sessionIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->getEntityManager()->createQueryBuilder()
|
||||
->select(
|
||||
'IDENTITY(sp.session) AS session_id', 'sp.method AS method',
|
||||
'sp.amountRials AS amount_rials', 'sp.paidAt AS paid_at',
|
||||
'sp.createdByName AS created_by_name',
|
||||
)
|
||||
->from(SessionPayment::class, 'sp')
|
||||
->where('IDENTITY(sp.session) IN (:sessions)')
|
||||
->setParameter('sessions', array_values($sessionIds))
|
||||
->orderBy('sp.paidAt', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$bySession = [];
|
||||
foreach ($rows as $row) {
|
||||
$bySession[(int) $row['session_id']][] = [
|
||||
'method' => $row['method'],
|
||||
'amount_rials' => (int) $row['amount_rials'],
|
||||
'paid_at' => (int) $row['paid_at'],
|
||||
'created_by_name' => $row['created_by_name'],
|
||||
];
|
||||
}
|
||||
|
||||
$byInvoice = [];
|
||||
foreach ($sessionIds as $invoiceId => $sessionId) {
|
||||
$byInvoice[$invoiceId] = $bySession[$sessionId] ?? [];
|
||||
}
|
||||
|
||||
return $byInvoice;
|
||||
}
|
||||
|
||||
public function countTenantInvoices(string $entityType, int $entityId, array $filters): int
|
||||
{
|
||||
return (int) $this->tenantInvoicesQuery($entityType, $entityId, $filters)
|
||||
->select('COUNT(i.id)')
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate totals over the same filtered set as {@see tenantInvoices}, so the
|
||||
* summary cards always agree with the table below them.
|
||||
*
|
||||
* @param array{national_code?:?string,status?:?string,from?:?int,to?:?int} $filters
|
||||
* @return array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int}
|
||||
*/
|
||||
public function tenantInvoiceSummary(string $entityType, int $entityId, array $filters): array
|
||||
{
|
||||
return $this->summarize($this->tenantInvoicesQuery($entityType, $entityId, $filters), 'patientRials');
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate totals over one patient's invoices — the same set {@see invoicesForPatient}
|
||||
* pages through, so the detail page's cards match its table.
|
||||
*
|
||||
* @return array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int}
|
||||
*/
|
||||
public function patientInvoiceSummary(string $entityType, int $entityId, int $recordId): array
|
||||
{
|
||||
return $this->summarize($this->patientInvoicesQuery($entityType, $entityId, $recordId), 'totalRials');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum `$field` over a prepared invoice query, split by paid vs. still unsettled.
|
||||
*
|
||||
* @return array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int}
|
||||
*/
|
||||
private function summarize(QueryBuilder $qb, string $field): array
|
||||
{
|
||||
$row = (clone $qb)
|
||||
->select(
|
||||
sprintf('COALESCE(SUM(i.%s), 0) AS total_rials', $field),
|
||||
'COUNT(i.id) AS invoices_count',
|
||||
)
|
||||
->getQuery()
|
||||
->getSingleResult();
|
||||
|
||||
// «پرداختشده» = مجموع صورتحسابهایی که سهم بیمارشان کامل وصول شده
|
||||
$paid = (int) (clone $qb)
|
||||
->select(sprintf('COALESCE(SUM(i.%s), 0)', $field))
|
||||
->andWhere(sprintf('%s >= i.patientRials', self::paidSumDql('sp_sum')))
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
$total = (int) $row['total_rials'];
|
||||
|
||||
return [
|
||||
'total_rials' => $total,
|
||||
'paid_rials' => $paid,
|
||||
'unsettled_rials' => $total - $paid,
|
||||
'invoices_count' => (int) $row['invoices_count'],
|
||||
];
|
||||
}
|
||||
|
||||
private function tenantInvoicesQuery(string $entityType, int $entityId, array $filters): QueryBuilder
|
||||
{
|
||||
$qb = $this->createQueryBuilder('i')
|
||||
->innerJoin(PatientRecord::class, 'r', Join::WITH, 'r.id = i.patientRecordId')
|
||||
->innerJoin('r.user', 'u')
|
||||
->where('i.entityType = :type')
|
||||
->andWhere('i.entityId = :id')
|
||||
->andWhere('i.status IN (:active)')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->setParameter('active', [Invoice::STATUS_FINALIZED, Invoice::STATUS_PAID]);
|
||||
|
||||
if (!empty($filters['national_code'])) {
|
||||
$qb->andWhere('u.nationalCode LIKE :nc')->setParameter('nc', '%' . $filters['national_code'] . '%');
|
||||
}
|
||||
if (!empty($filters['from'])) {
|
||||
$qb->andWhere('i.issuedAt >= :from')->setParameter('from', (int) $filters['from']);
|
||||
}
|
||||
if (!empty($filters['to'])) {
|
||||
$qb->andWhere('i.issuedAt <= :to')->setParameter('to', (int) $filters['to']);
|
||||
}
|
||||
// وضعیت پرداخت مشتق است (پرداختهای مراجعه در برابر سهم بیمار)، نه ستون status
|
||||
match ($filters['status'] ?? null) {
|
||||
InvoicePaymentStatus::PAID => $qb->andWhere(sprintf('%s >= i.patientRials', self::paidSumDql('sp_f1'))),
|
||||
InvoicePaymentStatus::PARTIAL => $qb->andWhere(sprintf(
|
||||
'%s > 0 AND %s < i.patientRials',
|
||||
self::paidSumDql('sp_f1'),
|
||||
self::paidSumDql('sp_f2'),
|
||||
)),
|
||||
InvoicePaymentStatus::UNSETTLED => $qb->andWhere(sprintf('%s <= 0 AND i.patientRials > 0', self::paidSumDql('sp_f1'))),
|
||||
default => null,
|
||||
};
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* A patient's recorded (finalized/paid) invoices for a tenant, newest first.
|
||||
* @return list<Invoice>
|
||||
*/
|
||||
public function invoicesForPatient(string $entityType, int $entityId, int $recordId, int $page, int $limit): array
|
||||
{
|
||||
return $this->patientInvoicesQuery($entityType, $entityId, $recordId)
|
||||
->orderBy('i.issuedAt', 'DESC')
|
||||
->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
private function patientInvoicesQuery(string $entityType, int $entityId, int $recordId): QueryBuilder
|
||||
{
|
||||
return $this->createQueryBuilder('i')
|
||||
->where('i.entityType = :type')
|
||||
->andWhere('i.entityId = :id')
|
||||
->andWhere('i.patientRecordId = :record')
|
||||
->andWhere('i.status IN (:active)')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->setParameter('record', $recordId)
|
||||
->setParameter('active', [Invoice::STATUS_FINALIZED, Invoice::STATUS_PAID]);
|
||||
}
|
||||
|
||||
public function save(Invoice $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
|
||||
@@ -6,7 +6,10 @@ use App\Billing\Contract\ClaimSubmitterInterface;
|
||||
use App\Billing\Entity\Claim;
|
||||
use App\Billing\Entity\ClaimItem;
|
||||
use App\Billing\Entity\Invoice;
|
||||
use App\Billing\Entity\ClaimStatusLog;
|
||||
use App\Billing\Repository\ClaimRepository;
|
||||
use App\Billing\Repository\ClaimStatusLogRepository;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
@@ -15,6 +18,7 @@ class ClaimService
|
||||
public function __construct(
|
||||
private readonly ClaimRepository $claimRepo,
|
||||
private readonly ClaimSubmitterInterface $submitter,
|
||||
private readonly ClaimStatusLogRepository $statusLogRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -59,6 +63,12 @@ class ClaimService
|
||||
}
|
||||
$this->claimRepo->getEntityManager()->flush();
|
||||
|
||||
// بعد از flush تا id مطالبه موجود باشد.
|
||||
foreach ($claims as $claim) {
|
||||
$this->logTransition($claim, null, $claim->getStatus(), 'ایجاد مطالبه', null);
|
||||
}
|
||||
$this->claimRepo->getEntityManager()->flush();
|
||||
|
||||
return $claims;
|
||||
}
|
||||
|
||||
@@ -81,7 +91,7 @@ class ClaimService
|
||||
return $hasShare ? $claim : null;
|
||||
}
|
||||
|
||||
public function transition(Claim $claim, string $target, array $opts = []): void
|
||||
public function transition(Claim $claim, string $target, array $opts = [], ?User $actor = null): void
|
||||
{
|
||||
if (!$claim->canTransitionTo($target)) {
|
||||
throw new AppException(
|
||||
@@ -91,23 +101,43 @@ class ClaimService
|
||||
);
|
||||
}
|
||||
|
||||
if ($target === Claim::STATUS_REJECTED && trim((string) ($opts['reason'] ?? '')) === '') {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'دلیل رد الزامی است', 422, 'reason');
|
||||
}
|
||||
|
||||
$from = $claim->getStatus();
|
||||
|
||||
match ($target) {
|
||||
Claim::STATUS_SUBMITTED => $this->doSubmit($claim),
|
||||
Claim::STATUS_SUBMITTED => $this->doSubmit($claim, $opts['tracking_number'] ?? null),
|
||||
Claim::STATUS_APPROVED => $claim->approve($opts['approved_rials'] ?? null),
|
||||
Claim::STATUS_REJECTED => $claim->reject($opts['reason'] ?? ''),
|
||||
Claim::STATUS_PAID => $claim->pay($opts['paid_rials'] ?? null),
|
||||
default => throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'وضعیت نامعتبر', 422),
|
||||
};
|
||||
|
||||
$this->claimRepo->save($claim);
|
||||
$this->claimRepo->save($claim, false);
|
||||
$this->logTransition($claim, $from, $target, $opts['note'] ?? $opts['reason'] ?? null, $actor);
|
||||
$this->claimRepo->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
private function doSubmit(Claim $claim): void
|
||||
private function logTransition(Claim $claim, ?string $from, string $to, ?string $note, ?User $actor): void
|
||||
{
|
||||
$log = (new ClaimStatusLog((int) $claim->getId(), $from, $to))
|
||||
->setNote($note)
|
||||
->setActor($actor?->getId(), $actor?->getRealName() ?? $actor?->getMobileNumber());
|
||||
|
||||
$this->statusLogRepo->save($log, false);
|
||||
}
|
||||
|
||||
private function doSubmit(Claim $claim, ?string $trackingNumber): void
|
||||
{
|
||||
$result = $this->submitter->submit($claim);
|
||||
if (!$result->success) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, $result->errorMessage ?? 'ارسال مطالبه ناموفق بود', 422);
|
||||
}
|
||||
$claim->submit();
|
||||
if ($trackingNumber !== null) {
|
||||
$claim->setTrackingNumber((string) $trackingNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Billing\Service;
|
||||
|
||||
/**
|
||||
* وضعیت پرداخت یک صورتحساب.
|
||||
*
|
||||
* ستون `invoices.status` چرخهی حیات صورتحساب را نگه میدارد (draft → finalized → void)
|
||||
* و هرگز به `paid` نمیرود؛ پول واقعی در `session_payments` ثبت میشود. بنابراین وضعیت
|
||||
* پرداخت **مشتق** است: مجموع پرداختهای مراجعه در برابر سهم بیمار (`patient_rials`).
|
||||
* همینجا تنها مرجع این قاعده است تا نماها از هم واگرا نشوند.
|
||||
*/
|
||||
final class InvoicePaymentStatus
|
||||
{
|
||||
public const PAID = 'paid';
|
||||
public const PARTIAL = 'partial';
|
||||
public const UNSETTLED = 'unsettled';
|
||||
|
||||
/** @return self::PAID|self::PARTIAL|self::UNSETTLED */
|
||||
public static function resolve(int $dueRials, int $paidRials): string
|
||||
{
|
||||
if ($paidRials >= $dueRials) {
|
||||
return self::PAID;
|
||||
}
|
||||
|
||||
return $paidRials > 0 ? self::PARTIAL : self::UNSETTLED;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Billing\ValueObject\Money;
|
||||
use App\ClinicService\Service\TariffService;
|
||||
use App\Insurance\Service\TenantInsuranceService;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
|
||||
class InvoiceService
|
||||
{
|
||||
@@ -17,6 +18,7 @@ class InvoiceService
|
||||
private readonly TariffService $tariffService,
|
||||
private readonly TenantInsuranceService $tenantInsuranceService,
|
||||
private readonly BillingCalculator $calculator,
|
||||
private readonly PatientSessionRepository $sessionRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -74,4 +76,94 @@ class InvoiceService
|
||||
$invoice->finalize();
|
||||
$this->invoiceRepo->save($invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice detail enriched with its source encounter (`session` key):
|
||||
* payments, consumables, discount and paid totals for the invoice summary
|
||||
* view. `session` is null for invoices not created from a session.
|
||||
*/
|
||||
public function detailWithSession(Invoice $invoice): array
|
||||
{
|
||||
$data = $invoice->toArray();
|
||||
|
||||
$sessionId = $data['patient_session_id'];
|
||||
$session = $sessionId !== null ? $this->sessionRepo->find($sessionId) : null;
|
||||
$data['session'] = $session?->toArray();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated flat list of a tenant's recorded (finalized/paid) invoices for
|
||||
* the payments list (node 1). Rows arrive ready-shaped from the repository;
|
||||
* this only pairs them with the total for pagination.
|
||||
*
|
||||
* @param array{national_code?:?string,status?:?string,from?:?int,to?:?int} $filters
|
||||
* @return array{items: list<array<string, mixed>>, total: int}
|
||||
*/
|
||||
public function tenantInvoiceList(string $entityType, int $entityId, array $filters, int $page, int $limit): array
|
||||
{
|
||||
return [
|
||||
'items' => $this->invoiceRepo->tenantInvoices($entityType, $entityId, $filters, $page, $limit),
|
||||
'total' => $this->invoiceRepo->countTenantInvoices($entityType, $entityId, $filters),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Financial summary of the same filtered invoice set as {@see tenantInvoiceList},
|
||||
* used by the payments page stat cards.
|
||||
*
|
||||
* @param array{national_code?:?string,status?:?string,from?:?int,to?:?int} $filters
|
||||
* @return array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int}
|
||||
*/
|
||||
public function tenantInvoiceSummary(string $entityType, int $entityId, array $filters): array
|
||||
{
|
||||
return $this->invoiceRepo->tenantInvoiceSummary($entityType, $entityId, $filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* A patient's recorded invoices, shaped for the detail table: number, issue
|
||||
* time, a single service title (first item, "+ more" when several), total,
|
||||
* a two-state status (paid|unsettled), and the full item breakdown.
|
||||
*
|
||||
* @return array{items: list<array<string, mixed>>, total: int, summary: array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int}}
|
||||
*/
|
||||
public function patientInvoiceList(string $entityType, int $entityId, int $recordId, int $page, int $limit): array
|
||||
{
|
||||
$invoices = $this->invoiceRepo->invoicesForPatient($entityType, $entityId, $recordId, $page, $limit);
|
||||
$paymentsById = $this->invoiceRepo->paymentsForInvoices($invoices);
|
||||
|
||||
$items = array_map(function (Invoice $invoice) use ($paymentsById): array {
|
||||
$lineItems = array_map(static fn(InvoiceItem $i) => $i->toArray(), $invoice->getItems()->toArray());
|
||||
$title = match (count($lineItems)) {
|
||||
0 => null,
|
||||
1 => $lineItems[0]['title'],
|
||||
default => $lineItems[0]['title'] . ' و موارد دیگر',
|
||||
};
|
||||
|
||||
$payments = $paymentsById[$invoice->getId()] ?? [];
|
||||
$paid = array_sum(array_column($payments, 'amount_rials'));
|
||||
|
||||
return [
|
||||
'uuid' => $invoice->getUuid(),
|
||||
'number' => $invoice->getId(),
|
||||
'issued_at' => $invoice->getIssuedAt(),
|
||||
'total_rials' => $invoice->getTotalRials(),
|
||||
'patient_rials' => $invoice->getPatientRials(),
|
||||
'paid_rials' => $paid,
|
||||
'status' => InvoicePaymentStatus::resolve($invoice->getPatientRials(), $paid),
|
||||
'service_title' => $title,
|
||||
'items' => $lineItems,
|
||||
'payments' => $payments,
|
||||
];
|
||||
}, $invoices);
|
||||
|
||||
$summary = $this->invoiceRepo->patientInvoiceSummary($entityType, $entityId, $recordId);
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $summary['invoices_count'],
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Blog\Controller;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Blog\Entity\Blog;
|
||||
use App\Blog\Repository\BlogRepository;
|
||||
use App\Location\Repository\CityRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Service\FileValidatorService;
|
||||
@@ -20,10 +21,31 @@ class BlogController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BlogRepository $blogRepo,
|
||||
private readonly CityRepository $cityRepo,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* city_id ورودی ادمین را به Entity تبدیل میکند.
|
||||
* مقدار خالی/صفر/null یعنی «سراسری» و عمداً به null نگاشت میشود.
|
||||
*
|
||||
* @throws \App\Shared\Exception\AppException وقتی شناسهٔ شهر نامعتبر باشد
|
||||
*/
|
||||
private function resolveCity(mixed $cityId): ?\App\Location\Entity\City
|
||||
{
|
||||
if ($cityId === null || $cityId === '' || (int) $cityId === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$city = $this->cityRepo->find((int) $cityId);
|
||||
if ($city === null) {
|
||||
throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_VALIDATION_002, 'شهر یافت نشد', 422);
|
||||
}
|
||||
|
||||
return $city;
|
||||
}
|
||||
|
||||
// ── Public list/detail ────────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
@@ -32,6 +54,13 @@ class BlogController extends BaseController
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
|
||||
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 20, maximum: 50)),
|
||||
new OA\Parameter(
|
||||
name: 'city_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Scope to one city: returns that city\'s posts plus nationwide posts (city_id IS NULL). Omit to return every published post.',
|
||||
schema: new OA\Schema(type: 'integer')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
@@ -58,12 +87,18 @@ class BlogController extends BaseController
|
||||
#[Route('/api/v1/blogs', methods: ['GET'])]
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
|
||||
$tag = $request->query->get('tag') ?: null;
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
|
||||
$tag = $request->query->get('tag') ?: null;
|
||||
$cityId = $request->query->get('city_id') !== null
|
||||
? max(1, (int) $request->query->get('city_id'))
|
||||
: null;
|
||||
|
||||
$blogs = array_map(fn(Blog $b) => $b->toListArray(), $this->blogRepo->findPublished($page, $limit, $tag));
|
||||
$total = $this->blogRepo->countPublished($tag);
|
||||
$blogs = array_map(
|
||||
fn(Blog $b) => $b->toListArray(),
|
||||
$this->blogRepo->findPublished($page, $limit, $tag, $cityId)
|
||||
);
|
||||
$total = $this->blogRepo->countPublished($tag, $cityId);
|
||||
|
||||
return $this->paginated($blogs, $total, $page, $limit);
|
||||
}
|
||||
@@ -127,6 +162,7 @@ class BlogController extends BaseController
|
||||
new OA\Property(property: 'summary', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'tags', type: 'array', items: new OA\Items(type: 'string'), nullable: true),
|
||||
new OA\Property(property: 'status', type: 'string', enum: ['draft', 'published'], nullable: true),
|
||||
new OA\Property(property: 'city_id', type: 'integer', nullable: true, description: 'City this post belongs to. Omit or null for a nationwide post.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
@@ -190,6 +226,8 @@ class BlogController extends BaseController
|
||||
if (!empty($data['tags'])) $blog->setTags((array)$data['tags']);
|
||||
if (!empty($data['status'])) $blog->setStatus($data['status']);
|
||||
if (!empty($data['image_url'])) $blog->setImageUrl($data['image_url']);
|
||||
// نبودِ city_id یعنی سراسری — پس همیشه اعمال میشود، نه فقط وقتی مقدار دارد.
|
||||
$blog->setCity($this->resolveCity($data['city_id'] ?? null));
|
||||
|
||||
// Ensure slug uniqueness
|
||||
if ($this->blogRepo->findBySlug($blog->getSlug()) !== null) {
|
||||
@@ -214,6 +252,7 @@ class BlogController extends BaseController
|
||||
new OA\Property(property: 'summary', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'tags', type: 'array', items: new OA\Items(type: 'string'), nullable: true),
|
||||
new OA\Property(property: 'status', type: 'string', enum: ['draft', 'published'], nullable: true),
|
||||
new OA\Property(property: 'city_id', type: 'integer', nullable: true, description: 'City this post belongs to. Omit or null for a nationwide post.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
@@ -263,6 +302,8 @@ class BlogController extends BaseController
|
||||
if (array_key_exists('tags', $data)) $blog->setTags((array)$data['tags']);
|
||||
if (array_key_exists('status', $data)) $blog->setStatus($data['status']);
|
||||
if (array_key_exists('image_url', $data)) $blog->setImageUrl($data['image_url'] ?: null);
|
||||
// PATCH: فقط وقتی صریحاً فرستاده شد تغییر کند. ارسال null یعنی «سراسریاش کن».
|
||||
if (array_key_exists('city_id', $data)) $blog->setCity($this->resolveCity($data['city_id']));
|
||||
|
||||
$this->blogRepo->save($blog);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Blog\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Location\Entity\City;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use App\Blog\Repository\BlogRepository;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
@@ -10,6 +11,7 @@ use Symfony\Component\Uid\Uuid;
|
||||
#[ORM\Entity(repositoryClass: BlogRepository::class)]
|
||||
#[ORM\Table(name: 'blogs')]
|
||||
#[ORM\Index(columns: ['status', 'created_at'], name: 'idx_blogs_status')]
|
||||
#[ORM\Index(columns: ['city_id'], name: 'idx_blogs_city')]
|
||||
class Blog
|
||||
{
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
@@ -46,6 +48,13 @@ class Blog
|
||||
#[ORM\JoinColumn(name: 'author_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private User $author;
|
||||
|
||||
// شهر پست. NULL معنای دائمی دارد: «پست سراسری» که روی دامنهٔ اصلی canonical
|
||||
// میشود. سایت عمومی چند-دامنهای بر پایهٔ همین تفکیک تصمیم میگیرد پست را روی
|
||||
// دامنهٔ شهر نشان دهد یا روی دامنهٔ اصلی.
|
||||
#[ORM\ManyToOne(targetEntity: City::class)]
|
||||
#[ORM\JoinColumn(name: 'city_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?City $city = null;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $tags = [];
|
||||
|
||||
@@ -80,6 +89,7 @@ class Blog
|
||||
public function getAuthor(): User { return $this->author; }
|
||||
public function getTags(): array { return $this->tags; }
|
||||
public function getStatus(): string { return $this->status; }
|
||||
public function getCity(): ?City { return $this->city; }
|
||||
|
||||
public function setTitle(string $v): self { $this->title = $v; $this->touch(); return $this; }
|
||||
public function setSlug(string $v): self { $this->slug = $v; $this->touch(); return $this; }
|
||||
@@ -89,6 +99,8 @@ class Blog
|
||||
public function setImagePath(?string $v): self { $this->imagePath = $v; $this->touch(); return $this; }
|
||||
public function setTags(array $v): self { $this->tags = $v; $this->touch(); return $this; }
|
||||
public function setStatus(string $v): self { $this->status = $v; $this->touch(); return $this; }
|
||||
/** null = پست سراسری (روی همهٔ دامنهها، canonical روی دامنهٔ اصلی) */
|
||||
public function setCity(?City $v): self { $this->city = $v; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
@@ -112,11 +124,25 @@ class Blog
|
||||
'tags' => $this->tags,
|
||||
'status' => $this->status,
|
||||
'author' => $this->authorToArray(),
|
||||
'city' => $this->cityToArray(),
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
|
||||
/** null = پست سراسری. مصرفکننده روی همین null تصمیم canonical میگیرد. */
|
||||
private function cityToArray(): ?array
|
||||
{
|
||||
if ($this->city === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (string) $this->city->getId(),
|
||||
'name' => $this->city->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
private function authorToArray(): ?array
|
||||
{
|
||||
if ($this->author === null) {
|
||||
@@ -144,6 +170,7 @@ class Blog
|
||||
'image_url' => $this->imageUrl,
|
||||
'tags' => $this->tags,
|
||||
'status' => $this->status,
|
||||
'city' => $this->cityToArray(),
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@ class BlogRepository extends ServiceEntityRepository
|
||||
public function findBySlug(string $slug): ?Blog { return $this->findOneBy(['slug' => $slug]); }
|
||||
|
||||
/** @return Blog[] published, newest first */
|
||||
public function findPublished(int $page = 1, int $limit = 20, ?string $tag = null): array
|
||||
public function findPublished(int $page = 1, int $limit = 20, ?string $tag = null, ?int $cityId = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('b')
|
||||
->leftJoin('b.city', 'c')
|
||||
->addSelect('c')
|
||||
->where('b.status = :status')
|
||||
->setParameter('status', Blog::STATUS_PUBLISHED)
|
||||
->orderBy('b.createdAt', 'DESC')
|
||||
@@ -24,11 +26,12 @@ class BlogRepository extends ServiceEntityRepository
|
||||
->setMaxResults($limit);
|
||||
|
||||
$this->applyTagFilter($qb, $tag);
|
||||
$this->applyCityFilter($qb, $cityId);
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function countPublished(?string $tag = null): int
|
||||
public function countPublished(?string $tag = null, ?int $cityId = null): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('b')
|
||||
->select('COUNT(b.id)')
|
||||
@@ -36,10 +39,26 @@ class BlogRepository extends ServiceEntityRepository
|
||||
->setParameter('status', Blog::STATUS_PUBLISHED);
|
||||
|
||||
$this->applyTagFilter($qb, $tag);
|
||||
$this->applyCityFilter($qb, $cityId);
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* دامنهٔ یک شهر باید پستهای همان شهر **و** پستهای سراسری را ببیند — پست
|
||||
* سراسری (city NULL) روی همهٔ دامنهها منتشر است، فقط canonicalش روی دامنهٔ اصلی
|
||||
* مینشیند. بدون شرط NULL، دامنههای شهری محتوای عمومی را از دست میدادند.
|
||||
*/
|
||||
private function applyCityFilter(\Doctrine\ORM\QueryBuilder $qb, ?int $cityId): void
|
||||
{
|
||||
if ($cityId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$qb->andWhere('b.city = :cityId OR b.city IS NULL')
|
||||
->setParameter('cityId', $cityId);
|
||||
}
|
||||
|
||||
private function applyTagFilter(\Doctrine\ORM\QueryBuilder $qb, ?string $tag): void
|
||||
{
|
||||
if ($tag === null || $tag === '') {
|
||||
|
||||
@@ -43,6 +43,8 @@ class ClinicController extends BaseController
|
||||
private readonly CityRepository $cityRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly \App\Clinic\Repository\ClinicDoctorPermissionRepository $permRepo,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
|
||||
private readonly string $projectDir,
|
||||
@@ -97,6 +99,19 @@ class ClinicController extends BaseController
|
||||
if (($err = $this->validateGallerySize($data)) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
// بدون نام، کلینیکِ بیهویت ساخته میشد که در هیچ لیستی قابلتشخیص نیست.
|
||||
if (trim((string) ($data['name'] ?? '')) === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام کلینیک الزامی است', 422, 'name');
|
||||
}
|
||||
|
||||
// یک کلینیک به ازای هر کاربر: ClinicRepository::findByUser() — که محیط کاری
|
||||
// کاربر از آن حل میشود — findOneBy است، پس کلینیک دوم به بعد هرگز انتخاب
|
||||
// نمیشود و به دادهٔ یتیم تبدیل میشود.
|
||||
if ($this->clinicRepo->findByUser($user) !== null) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'برای این کاربر کلینیک ثبت شده است', 409);
|
||||
}
|
||||
|
||||
$clinic = new Clinic($user);
|
||||
$this->hydrateClinic($clinic, $data);
|
||||
$this->clinicRepo->save($clinic);
|
||||
@@ -210,7 +225,8 @@ class ClinicController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
// مالک و ادمین همیشه؛ پزشکِ عضو فقط با مجوز clinic_info.update
|
||||
if (!$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
@@ -321,11 +337,16 @@ class ClinicController extends BaseController
|
||||
|
||||
$scheduleMap = [];
|
||||
foreach ($this->scheduleRepo->findByDoctors($clinicDoctors) as $schedule) {
|
||||
$scheduleMap[$schedule->getDoctor()->getId()] = $schedule;
|
||||
$scheduleMap[$schedule->getDoctor()->getId()][] = $schedule;
|
||||
}
|
||||
|
||||
$locationMap = $this->doctorRepo->findLocationsByDoctors($clinicDoctors);
|
||||
|
||||
$doctors = array_map(
|
||||
fn(Doctor $d) => $d->toListArray($scheduleMap[$d->getId()] ?? null),
|
||||
fn(Doctor $d) => $d->toListArray(
|
||||
$scheduleMap[$d->getId()] ?? [],
|
||||
$locationMap[$d->getId()] ?? null
|
||||
),
|
||||
$clinicDoctors
|
||||
);
|
||||
|
||||
@@ -341,7 +362,7 @@ class ClinicController extends BaseController
|
||||
|
||||
#[OA\Delete(
|
||||
path: '/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}',
|
||||
summary: 'Detach a doctor from a clinic (admin only)',
|
||||
summary: 'Detach a doctor from a clinic (admin or the clinic owner)',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'clinicUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid')),
|
||||
@@ -349,18 +370,23 @@ class ClinicController extends BaseController
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Doctor detached from clinic'),
|
||||
new OA\Response(response: 403, description: 'Not the clinic owner'),
|
||||
new OA\Response(response: 404, description: 'Clinic or doctor not found'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}', methods: ['DELETE'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function detachDoctor(string $clinicUuid, string $doctorUuid): JsonResponse
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function detachDoctor(string $clinicUuid, string $doctorUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$this->canManageClinic($clinic, $user)) {
|
||||
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی مجاز نیست', 403);
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
|
||||
@@ -372,10 +398,18 @@ class ClinicController extends BaseController
|
||||
|
||||
$clinic->removeDoctor($doctor);
|
||||
$this->clinicRepo->save($clinic);
|
||||
$this->permRepo->deleteFor($clinic, $doctor);
|
||||
|
||||
return $this->success(['message' => 'پزشک از کلینیک جدا شد']);
|
||||
}
|
||||
|
||||
/** ادمین یا مالکِ همان کلینیک اجازهی مدیریت پزشکان را دارد. */
|
||||
private function canManageClinic(Clinic $clinic, User $user): bool
|
||||
{
|
||||
return $user->hasRole('ROLE_ADMIN')
|
||||
|| ($user->hasRole('ROLE_CLINIC') && $clinic->getUser()->getId() === $user->getId());
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/file/upload/clinic_pro/clinic/field_image_clinic',
|
||||
summary: 'Upload a clinic gallery image',
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clinic\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Entity\ClinicDoctorPermission;
|
||||
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Clinic Doctor Permissions')]
|
||||
class ClinicDoctorPermissionController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicDoctorPermissionRepository $permRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor-permissions', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function listPermissions(string $clinicUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$clinic = $this->resolveClinic($clinicUuid, $user);
|
||||
|
||||
$data = array_map(
|
||||
fn(ClinicDoctorPermission $p) => $p->toArray(),
|
||||
$this->permRepo->findByClinic($clinic),
|
||||
);
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function showPermissions(string $clinicUuid, string $doctorUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$clinic = $this->resolveClinic($clinicUuid, $user);
|
||||
$doctor = $this->resolveMember($clinic, $doctorUuid);
|
||||
|
||||
return $this->success($this->permRepo->getOrCreate($clinic, $doctor)->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions', methods: ['PATCH'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function updatePermissions(string $clinicUuid, string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$clinic = $this->resolveClinic($clinicUuid, $user);
|
||||
$doctor = $this->resolveMember($clinic, $doctorUuid);
|
||||
$perm = $this->permRepo->getOrCreate($clinic, $doctor);
|
||||
|
||||
$body = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (array_key_exists('permissions', $body)) {
|
||||
if (!is_array($body['permissions'])) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'permissions باید آبجکت باشد', 422, 'permissions');
|
||||
}
|
||||
$perm->mergePermissions($body['permissions']);
|
||||
}
|
||||
|
||||
if (array_key_exists('active', $body)) {
|
||||
$perm->setActive((bool) $body['active']);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success($perm->toArray());
|
||||
}
|
||||
|
||||
private function resolveClinic(string $clinicUuid, User $user): Clinic
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
|
||||
if ($clinic === null) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$isOwner = $clinic->getUser()->getId() === $user->getId();
|
||||
if (!$user->hasRole('ROLE_ADMIN') && !$isOwner) {
|
||||
throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ندارید', 403);
|
||||
}
|
||||
|
||||
return $clinic;
|
||||
}
|
||||
|
||||
private function resolveMember(Clinic $clinic, string $doctorUuid): \App\Doctor\Entity\Doctor
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'این پزشک به کلینیک متصل نیست', 404);
|
||||
}
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\DoctorService\Entity\DoctorService;
|
||||
use App\Insurance\Entity\Insurance;
|
||||
use App\Shared\Util\DisplayName;
|
||||
use App\Specialty\Entity\Specialty;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -166,7 +167,8 @@ class Clinic
|
||||
public function getServices(): Collection { return $this->services; }
|
||||
public function getInsurances(): Collection { return $this->insurances; }
|
||||
|
||||
public function setName(?string $v): self { $this->name = $v; $this->touch(); return $this; }
|
||||
// null مجاز است (کلینیک تازهساخته هنوز نام ندارد)؛ ولی نام آلوده رد میشود.
|
||||
public function setName(?string $v): self { if ($v !== null) DisplayName::assertReal($v); $this->name = $v; $this->touch(); return $this; }
|
||||
public function setInfo(?string $v): self { $this->info = $v; $this->touch(); return $this; }
|
||||
public function setAddress(?string $v): self { $this->address = $v; $this->touch(); return $this; }
|
||||
public function setTelephone(?string $v): self { $this->telephone = $v; $this->touch(); return $this; }
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clinic\Entity;
|
||||
|
||||
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* سطح دسترسی یک پزشکِ عضو در یک کلینیک مشخص.
|
||||
*
|
||||
* جدول join «clinic_doctors» عمداً دستنخورده میماند (شش نقطه در کد به ManyToMany
|
||||
* آن وابستهاند)؛ این جدول موازی فقط مجوزها را نگه میدارد.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ClinicDoctorPermissionRepository::class)]
|
||||
#[ORM\Table(name: 'clinic_doctor_permissions')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_clinic_doctor_permission', columns: ['clinic_id', 'doctor_id'])]
|
||||
class ClinicDoctorPermission
|
||||
{
|
||||
public const DEFAULT_PERMISSIONS = [
|
||||
'version' => 1,
|
||||
'resources' => [
|
||||
'appointments' => ['view' => true, 'create' => true, 'cancel' => true, 'update_status' => true],
|
||||
'appointment_settings' => ['view' => true, 'update' => true],
|
||||
'patients' => ['view' => true, 'create' => true, 'update' => true, 'delete' => false],
|
||||
'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'services' => ['view' => true, 'update' => false],
|
||||
'clinic_info' => ['view' => true, 'update' => false],
|
||||
],
|
||||
];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Clinic::class)]
|
||||
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Clinic $clinic;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(name: 'permission', type: 'json')]
|
||||
private array $permissions;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $active = true;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Clinic $clinic, Doctor $doctor)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->clinic = $clinic;
|
||||
$this->doctor = $doctor;
|
||||
$this->permissions = self::DEFAULT_PERMISSIONS;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getClinic(): Clinic { return $this->clinic; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getPermissions(): array { return $this->permissions; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
|
||||
public function can(string $resource, string $action): bool
|
||||
{
|
||||
if (!$this->active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) ($this->permissions['resources'][$resource][$action] ?? false);
|
||||
}
|
||||
|
||||
/** ادغام عمقی — فقط منابع/اکشنهایی که ارسال شدهاند تغییر میکنند. */
|
||||
public function mergePermissions(array $patch): void
|
||||
{
|
||||
$current = $this->permissions;
|
||||
$resources = $patch['resources'] ?? $patch;
|
||||
|
||||
foreach ($resources as $resource => $actions) {
|
||||
if (!is_array($actions) || !isset(self::DEFAULT_PERMISSIONS['resources'][$resource])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($actions as $action => $value) {
|
||||
if (!array_key_exists($action, self::DEFAULT_PERMISSIONS['resources'][$resource])) {
|
||||
continue;
|
||||
}
|
||||
$current['resources'][$resource][$action] = (bool) $value;
|
||||
}
|
||||
}
|
||||
|
||||
$this->permissions = $current;
|
||||
$this->touch();
|
||||
}
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
/**
|
||||
* envelope کامل برگردانده میشود (نه flatten) تا کلاینت همهجا با یک شکل واحد
|
||||
* روبهرو باشد — برخلاف DoctorSecretary::toArray که آن را تخت میکند.
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'clinic_uuid' => $this->clinic->getUuid(),
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'doctor_name' => $this->doctor->getName(),
|
||||
'active' => $this->active,
|
||||
'permissions' => $this->permissions,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clinic\Repository;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Entity\ClinicDoctorPermission;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ClinicDoctorPermission>
|
||||
*/
|
||||
class ClinicDoctorPermissionRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ClinicDoctorPermission::class);
|
||||
}
|
||||
|
||||
public function findOneFor(Clinic $clinic, Doctor $doctor): ?ClinicDoctorPermission
|
||||
{
|
||||
return $this->findOneBy(['clinic' => $clinic, 'doctor' => $doctor]);
|
||||
}
|
||||
|
||||
/** @return ClinicDoctorPermission[] */
|
||||
public function findByClinic(Clinic $clinic): array
|
||||
{
|
||||
return $this->findBy(['clinic' => $clinic]);
|
||||
}
|
||||
|
||||
/**
|
||||
* پزشکانی که پیش از این قابلیت عضو شدهاند سطر مجوز ندارند؛ در اولین دسترسی
|
||||
* با مجوز پیشفرض ساخته میشود.
|
||||
*/
|
||||
public function getOrCreate(Clinic $clinic, Doctor $doctor): ClinicDoctorPermission
|
||||
{
|
||||
$perm = $this->findOneFor($clinic, $doctor);
|
||||
if ($perm !== null) {
|
||||
return $perm;
|
||||
}
|
||||
|
||||
$perm = new ClinicDoctorPermission($clinic, $doctor);
|
||||
$em = $this->getEntityManager();
|
||||
$em->persist($perm);
|
||||
$em->flush();
|
||||
|
||||
return $perm;
|
||||
}
|
||||
|
||||
/**
|
||||
* مجوزهای یک پزشک در چند کلینیک، کلیددار با شناسهٔ کلینیک — برای پرهیز از N+1
|
||||
* هنگام ساخت available_contexts.
|
||||
*
|
||||
* @param int[] $clinicIds
|
||||
* @return array<int, ClinicDoctorPermission>
|
||||
*/
|
||||
public function mapByClinicForDoctor(Doctor $doctor, array $clinicIds): array
|
||||
{
|
||||
if ($clinicIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->createQueryBuilder('p')
|
||||
->andWhere('p.doctor = :doctor')
|
||||
->andWhere('IDENTITY(p.clinic) IN (:clinics)')
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('clinics', $clinicIds)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[$row->getClinic()->getId()] = $row;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
public function deleteFor(Clinic $clinic, Doctor $doctor): void
|
||||
{
|
||||
$perm = $this->findOneFor($clinic, $doctor);
|
||||
if ($perm === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->remove($perm);
|
||||
$em->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clinic\Security;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* تصمیمگیرندهٔ واحد برای «این کاربر در این کلینیک اجازهٔ فلان کار را دارد؟».
|
||||
*
|
||||
* مالک کلینیک و ادمین همیشه مجازند — مالک هرگز نباید بتواند خودش را قفل کند.
|
||||
*/
|
||||
class ClinicDoctorPermissionChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicDoctorPermissionRepository $permRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
) {}
|
||||
|
||||
public function can(User $user, Clinic $clinic, string $resource, string $action): bool
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->permRepo->getOrCreate($clinic, $doctor)->can($resource, $action);
|
||||
}
|
||||
|
||||
public function assert(User $user, Clinic $clinic, string $resource, string $action): void
|
||||
{
|
||||
if (!$this->can($user, $clinic, $resource, $action)) {
|
||||
throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ندارید', 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\ClinicInvitation\Command;
|
||||
|
||||
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
|
||||
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
|
||||
use App\ClinicInvitation\Service\ClinicInvitationService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* دعوتنامههای پذیرفتهشدهای که قبل از رفع باگ بدون پروفایل پزشک ماندهاند را ترمیم میکند:
|
||||
* کاربر و پروفایل پزشک را میسازد و پزشک را به کلینیک متصل میکند.
|
||||
*/
|
||||
#[AsCommand(name: 'app:invitations:repair', description: 'ترمیم دعوتنامههای پذیرفتهشده بدون پروفایل پزشک')]
|
||||
class RepairAcceptedInvitationsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicDoctorInvitationRepository $invRepo,
|
||||
private readonly ClinicInvitationService $invitationService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'فقط گزارش بده، چیزی را تغییر نده');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
$orphans = $this->invRepo->createQueryBuilder('i')
|
||||
->where('i.status = :status')
|
||||
->andWhere('i.doctor IS NULL')
|
||||
->setParameter('status', ClinicDoctorInvitation::STATUS_ACCEPTED)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
if ($orphans === []) {
|
||||
$io->success('دعوتنامهی ناقصی یافت نشد.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->writeln(sprintf('%d دعوتنامه ناقص یافت شد.', count($orphans)));
|
||||
|
||||
$repaired = 0;
|
||||
foreach ($orphans as $inv) {
|
||||
$io->writeln(sprintf(
|
||||
' - %s (%s) → کلینیک %s',
|
||||
$inv->getMobile(),
|
||||
$inv->getInvitedName() ?? '—',
|
||||
$inv->getClinic()->getName() ?? $inv->getClinic()->getUuid(),
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->invitationService->repairAccepted($inv);
|
||||
$repaired++;
|
||||
}
|
||||
|
||||
$io->success($dryRun ? 'حالت آزمایشی — چیزی تغییر نکرد.' : sprintf('%d دعوتنامه ترمیم شد.', $repaired));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ class ClinicInvitationController extends BaseController
|
||||
$this->assertClinicAccess($inv->getClinic(), $user);
|
||||
$this->invitationService->delete($inv);
|
||||
|
||||
return $this->success(null, 204);
|
||||
return $this->success(['message' => 'دعوتنامه حذف شد']);
|
||||
}
|
||||
|
||||
// ── Doctor-facing endpoints ──────────────────────────────────────────────
|
||||
|
||||
@@ -46,6 +46,26 @@ class ClinicDoctorInvitationRepository extends ServiceEntityRepository
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* شناسهٔ پزشکانی که دعوت پذیرفتهشده در این کلینیک دارند.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
public function acceptedDoctorIdsByClinic(int $clinicId): array
|
||||
{
|
||||
$rows = $this->createQueryBuilder('i')
|
||||
->select('IDENTITY(i.doctor) AS doctorId')
|
||||
->where('i.clinic = :clinicId')
|
||||
->andWhere('i.status = :accepted')
|
||||
->andWhere('i.doctor IS NOT NULL')
|
||||
->setParameter('clinicId', $clinicId)
|
||||
->setParameter('accepted', ClinicDoctorInvitation::STATUS_ACCEPTED)
|
||||
->getQuery()
|
||||
->getScalarResult();
|
||||
|
||||
return array_map(static fn(array $r) => (int) $r['doctorId'], $rows);
|
||||
}
|
||||
|
||||
public function save(ClinicDoctorInvitation $invitation): void
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
@@ -3,19 +3,27 @@
|
||||
namespace App\ClinicInvitation\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
|
||||
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Util\DisplayName;
|
||||
use App\Sms\Service\SmsService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
class ClinicInvitationService
|
||||
{
|
||||
private const UNNAMED_DOCTOR = 'پزشک دعوتشده';
|
||||
|
||||
public function __construct(
|
||||
private readonly ClinicDoctorInvitationRepository $repo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly string $appUrl,
|
||||
@@ -32,10 +40,9 @@ class ClinicInvitationService
|
||||
$inv->setInvitedName($name);
|
||||
$inv->setInvitedSpecialty($specialty);
|
||||
|
||||
$doctor = $this->doctorRepo->findOneByMobile($mobile);
|
||||
if ($doctor !== null) {
|
||||
$inv->setDoctor($doctor);
|
||||
}
|
||||
// پروفایل پزشک همینجا ساخته میشود تا بلافاصله پس از دعوت قابل مشاهده باشد،
|
||||
// ولی بدون رمز عبور و بدون اتصال به کلینیک — اتصال فقط پس از پذیرش انجام میشود.
|
||||
$inv->setDoctor($this->provisionDoctor($inv));
|
||||
|
||||
$this->repo->save($inv);
|
||||
$this->sendSms($inv, $clinic);
|
||||
@@ -56,10 +63,28 @@ class ClinicInvitationService
|
||||
|
||||
public function changeStatus(ClinicDoctorInvitation $inv, string $status): void
|
||||
{
|
||||
$allowed = [ClinicDoctorInvitation::STATUS_SUSPENDED, ClinicDoctorInvitation::STATUS_REMOVED];
|
||||
$allowed = [
|
||||
ClinicDoctorInvitation::STATUS_PENDING,
|
||||
ClinicDoctorInvitation::STATUS_SUSPENDED,
|
||||
ClinicDoctorInvitation::STATUS_REMOVED,
|
||||
];
|
||||
if (!in_array($status, $allowed, true)) {
|
||||
throw new AppException('ERR_VALIDATION_001', 'وضعیت نامعتبر است', 422);
|
||||
}
|
||||
|
||||
// بازگشت به «در انتظار» فقط وقتی معنا دارد که لینک هم دوباره قابل استفاده شود،
|
||||
// پس توکن تازه میشود و پیامک مجدداً ارسال میگردد.
|
||||
if ($status === ClinicDoctorInvitation::STATUS_PENDING) {
|
||||
if (in_array($inv->getStatus(), [ClinicDoctorInvitation::STATUS_ACCEPTED, ClinicDoctorInvitation::STATUS_REJECTED], true)) {
|
||||
throw new AppException('ERR_CONFLICT_001', 'دعوتنامه پاسخدادهشده را نمیتوان به حالت انتظار برگرداند', 409);
|
||||
}
|
||||
$inv->refresh();
|
||||
$this->em->flush();
|
||||
$this->sendSms($inv, $inv->getClinic());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$inv->setStatus($status);
|
||||
$this->em->flush();
|
||||
}
|
||||
@@ -76,25 +101,118 @@ class ClinicInvitationService
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410);
|
||||
}
|
||||
|
||||
$inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED);
|
||||
$inv->markUsed();
|
||||
$password = $this->em->wrapInTransaction(function () use ($inv): ?string {
|
||||
$doctor = $this->attachDoctorToClinic($inv);
|
||||
|
||||
$doctor = $inv->getDoctor();
|
||||
if ($doctor === null) {
|
||||
$doctor = $this->doctorRepo->findOneByMobile($inv->getMobile());
|
||||
if ($doctor !== null) {
|
||||
$inv->setDoctor($doctor);
|
||||
}
|
||||
$inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED);
|
||||
$inv->markUsed();
|
||||
|
||||
return $this->ensureLoginCredentials($doctor->getUser());
|
||||
});
|
||||
|
||||
if ($password !== null) {
|
||||
$this->sendCredentialsSms($inv->getMobile(), $password);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ترمیم دعوتنامهای که قبلاً «پذیرفتهشده» ثبت شده ولی پروفایل پزشک برایش ساخته نشده است.
|
||||
* برخلاف accept()، وضعیت دعوتنامه را دست نمیزند.
|
||||
*/
|
||||
public function repairAccepted(ClinicDoctorInvitation $inv): void
|
||||
{
|
||||
$password = $this->em->wrapInTransaction(function () use ($inv): ?string {
|
||||
$doctor = $this->attachDoctorToClinic($inv);
|
||||
|
||||
return $this->ensureLoginCredentials($doctor->getUser());
|
||||
});
|
||||
|
||||
if ($password !== null) {
|
||||
$this->sendCredentialsSms($inv->getMobile(), $password);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* پروفایل پزشکِ دعوتنامه را قطعی میکند (در صورت نبود میسازد) و به کلینیک متصل میکند.
|
||||
*/
|
||||
private function attachDoctorToClinic(ClinicDoctorInvitation $inv): Doctor
|
||||
{
|
||||
$doctor = $this->provisionDoctor($inv);
|
||||
$inv->setDoctor($doctor);
|
||||
|
||||
if ($doctor->getOwnerStatus() !== 'claimed') {
|
||||
$doctor->transferOwnershipTo($doctor->getUser());
|
||||
}
|
||||
|
||||
$clinic = $inv->getClinic();
|
||||
if (!$clinic->getDoctors()->contains($doctor)) {
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
}
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
/**
|
||||
* پروفایل پزشک متناظر با شماره موبایل دعوتنامه را برمیگرداند و در صورت نبود
|
||||
* کاربر و پروفایل را میسازد. رمز عبور اینجا ست نمیشود — آن کار فقط هنگام پذیرش.
|
||||
*/
|
||||
private function provisionDoctor(ClinicDoctorInvitation $inv): Doctor
|
||||
{
|
||||
$doctor = $inv->getDoctor() ?? $this->doctorRepo->findOneByMobile($inv->getMobile());
|
||||
if ($doctor !== null) {
|
||||
$clinic = $inv->getClinic();
|
||||
if (!$clinic->getDoctors()->contains($doctor)) {
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
}
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
$mobile = $inv->getMobile();
|
||||
// شمارهٔ موبایل نامِ پزشک نیست. دعوت بدون نام قبلاً موبایل را بهعنوان نام
|
||||
// مینشاند و همان رکورد در نتایج عمومی و <title> صفحات سایت منتشر میشد.
|
||||
$name = $this->resolveInvitedName($inv->getInvitedName());
|
||||
|
||||
$user = $this->userRepo->findOneBy(['mobileNumber' => $mobile]);
|
||||
if ($user === null) {
|
||||
$user = new User($mobile);
|
||||
$user->setRealName($name);
|
||||
$this->em->persist($user);
|
||||
}
|
||||
$user->addRole('ROLE_DOCTOR');
|
||||
|
||||
$doctor = $this->doctorRepo->findOneBy(['user' => $user]);
|
||||
if ($doctor === null) {
|
||||
$doctor = new Doctor($user, $this->resolveInvitedName($user->getRealName()));
|
||||
$doctor->setMobileNumber($mobile);
|
||||
$doctor->setOwnerStatus('unclaimed');
|
||||
$this->em->persist($doctor);
|
||||
}
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
/**
|
||||
* نام پزشکِ دعوتشده. کلینیک اغلب فقط شمارهٔ موبایل را دارد، پس نام واقعی هنوز
|
||||
* ناشناخته است — یک برچسب خنثی مینشیند تا وقتی خود پزشک پروفایلش را claim کند.
|
||||
* هرگز موبایل یا مقدار آزمایشی برنمیگرداند.
|
||||
*/
|
||||
private function resolveInvitedName(?string $candidate): string
|
||||
{
|
||||
return DisplayName::isPlaceholder($candidate) ? self::UNNAMED_DOCTOR : $candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* برای کاربری که هنوز رمز عبور ندارد یک رمز تولید میکند تا بتواند وارد شود.
|
||||
* رمز کاربران موجود هرگز بازنویسی نمیشود.
|
||||
*
|
||||
* @return string|null رمز خام برای ارسال پیامک، یا null اگر کاربر از قبل رمز داشته
|
||||
*/
|
||||
private function ensureLoginCredentials(User $user): ?string
|
||||
{
|
||||
if ($user->getPasswordHash() !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$password = bin2hex(random_bytes(4));
|
||||
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
public function reject(ClinicDoctorInvitation $inv): void
|
||||
@@ -117,4 +235,13 @@ class ClinicInvitationService
|
||||
'link' => $link,
|
||||
]);
|
||||
}
|
||||
|
||||
private function sendCredentialsSms(string $mobile, string $password): void
|
||||
{
|
||||
$this->smsService->dispatchTemplate(\App\Sms\Entity\SmsLog::TAG_PRE_REGISTRATION, $mobile, [
|
||||
'username' => $mobile,
|
||||
'password' => $password,
|
||||
'link' => rtrim($this->appUrl, '/') . '/admin',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,28 @@ namespace App\ClinicService\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceItemAuditLog;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Insurance\Entity\TenantServiceCoverage;
|
||||
use App\ClinicService\Entity\Tariff;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use App\ClinicService\Repository\ServiceItemAuditLogRepository;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\ClinicService\Repository\ServiceSectionRepository;
|
||||
use App\ClinicService\Repository\TariffRepository;
|
||||
use App\ClinicService\Service\ServiceItemAuditService;
|
||||
use App\ClinicService\Service\TariffService;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Inventory\Repository\InventoryItemRepository;
|
||||
use App\Inventory\Repository\InventoryPackageRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Context\EntityContextResolver;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
@@ -35,13 +40,99 @@ class ClinicServiceController extends BaseController
|
||||
private readonly ServiceItemRepository $itemRepo,
|
||||
private readonly ClinicStaffRepository $staffRepo,
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly TariffRepository $tariffRepo,
|
||||
private readonly TariffService $tariffService,
|
||||
private readonly InventoryPackageRepository $packageRepo,
|
||||
private readonly InventoryItemRepository $inventoryItemRepo,
|
||||
private readonly ServiceItemAuditService $auditService,
|
||||
private readonly ServiceItemAuditLogRepository $auditLogRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly RequestStack $requestStack,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* uuid و عنوان پکیج کالای هر سرویس را به آرایهی خروجی اضافه میکند. پکیجها با یک
|
||||
* کوئری واکشی میشوند تا فهرست سرویسها به N+1 نیفتد.
|
||||
*
|
||||
* @param ServiceItem[] $items
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function serializeItems(array $items): array
|
||||
{
|
||||
$packages = $this->packageRepo->findMapByIds(
|
||||
array_map(fn(ServiceItem $i) => $i->getInventoryPackageId(), $items)
|
||||
);
|
||||
|
||||
return array_map(function (ServiceItem $i) use ($packages) {
|
||||
$row = $i->toArray();
|
||||
$package = $packages[$i->getInventoryPackageId()] ?? null;
|
||||
$row['inventory_package_uuid'] = $package?->getUuid();
|
||||
$row['inventory_package_title'] = $package?->getTitle();
|
||||
|
||||
return $row;
|
||||
}, $items);
|
||||
}
|
||||
|
||||
/**
|
||||
* `inventory_package_uuid` را به id داخلی تبدیل و روی سرویس ست میکند.
|
||||
* مقدار null یعنی قطع اتصال. خطای دسترسی/نبود پکیج را برمیگرداند.
|
||||
*/
|
||||
private function applyInventoryPackage(ServiceItem $item, array $data, string $entityType, ?int $entityId): ?JsonResponse
|
||||
{
|
||||
if (!array_key_exists('inventory_package_uuid', $data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$uuid = $data['inventory_package_uuid'];
|
||||
if ($uuid === null || $uuid === '') {
|
||||
$item->setInventoryPackageId(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
$package = $this->packageRepo->findByUuid((string) $uuid);
|
||||
if ($package === null || $package->getEntityType() !== $entityType || $package->getEntityId() !== $entityId) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پکیج کالا یافت نشد', 422, 'inventory_package_uuid');
|
||||
}
|
||||
|
||||
$item->setInventoryPackageId($package->getId());
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `consumables: [{item_uuid, amount}]` را روی خدمت مینشاند. آرایهی خالی یعنی حذف
|
||||
* همهی اقلام. هر قلم باید متعلق به همان مطب/کلینیک باشد.
|
||||
*/
|
||||
private function applyConsumables(ServiceItem $item, array $data, string $entityType, ?int $entityId): ?JsonResponse
|
||||
{
|
||||
if (!array_key_exists('consumables', $data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
foreach ((array) ($data['consumables'] ?? []) as $raw) {
|
||||
$uuid = is_array($raw) ? ($raw['item_uuid'] ?? null) : null;
|
||||
if ($uuid === null || $uuid === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$inventoryItem = $this->inventoryItemRepo->findByUuid((string) $uuid);
|
||||
if ($inventoryItem === null
|
||||
|| $inventoryItem->getEntityType() !== $entityType
|
||||
|| $inventoryItem->getEntityId() !== $entityId
|
||||
) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کالای انتخابشده یافت نشد', 422, 'consumables');
|
||||
}
|
||||
|
||||
$lines[] = ['item' => $inventoryItem, 'amount' => max(1, (int) ($raw['amount'] ?? 1))];
|
||||
}
|
||||
|
||||
$item->replaceConsumables($lines);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Service Sections ─────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/service-sections', methods: ['GET'])]
|
||||
@@ -50,9 +141,12 @@ class ClinicServiceController extends BaseController
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
$sectionEntities = $this->sectionRepo->findByEntity($entityType, $entityId);
|
||||
$counts = $this->itemRepo->countBySections($sectionEntities);
|
||||
|
||||
$sections = array_map(
|
||||
fn(ServiceSection $s) => $s->toArray(),
|
||||
$this->sectionRepo->findByEntity($entityType, $entityId)
|
||||
fn(ServiceSection $s) => $s->toArray($counts[$s->getUuid()] ?? 0),
|
||||
$sectionEntities
|
||||
);
|
||||
|
||||
return $this->success($sections);
|
||||
@@ -119,6 +213,25 @@ class ClinicServiceController extends BaseController
|
||||
|
||||
// ── Service Items ────────────────────────────────────────────────────────
|
||||
|
||||
/** همهی سرویسهای owner در همهی بخشها — برای انتخاب/جستجوی سراسری. */
|
||||
#[Route('/api/v1/service-items', methods: ['GET'])]
|
||||
public function listAllItems(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
// A user with neither a doctor profile nor a clinic (admin, secretary,
|
||||
// representation, plain patient) resolves to EntityContext::unknown(),
|
||||
// whose id is null — findByEntity() declares int and fataled with a 500.
|
||||
// Same condition, same answer as assertServicesGate(): forbidden.
|
||||
if ($entityId === null) {
|
||||
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
|
||||
}
|
||||
|
||||
return $this->success($this->serializeItems(
|
||||
$this->itemRepo->findByEntity($entityType, $entityId)
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/service-items/{sectionUuid}', methods: ['GET'])]
|
||||
public function listItems(string $sectionUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
@@ -129,12 +242,37 @@ class ClinicServiceController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
$items = array_map(
|
||||
fn(ServiceItem $i) => $i->toArray(),
|
||||
$this->itemRepo->findBySection($section)
|
||||
);
|
||||
return $this->success($this->serializeItems($this->itemRepo->findBySection($section)));
|
||||
}
|
||||
|
||||
return $this->success($items);
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['GET'])]
|
||||
public function getItem(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
return $this->success($this->serializeItems([$item])[0]);
|
||||
}
|
||||
|
||||
/** تاریخچهی تغییرات یک خدمت — تازهترین رویداد اول. */
|
||||
#[Route('/api/v1/service-item/{uuid}/audit-logs', methods: ['GET'])]
|
||||
public function listItemAuditLogs(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
return $this->success(array_map(
|
||||
fn(ServiceItemAuditLog $l) => $l->toArray(),
|
||||
$this->auditLogRepo->findByItem($item)
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/service-item', methods: ['POST'])]
|
||||
@@ -158,27 +296,37 @@ class ClinicServiceController extends BaseController
|
||||
|
||||
$item = new ServiceItem($section, $name, (int) ($data['price_rials'] ?? 0));
|
||||
|
||||
if (!empty($data['staff_uuid'])) {
|
||||
$staff = $this->staffRepo->findByUuid($data['staff_uuid']);
|
||||
if ($staff === null || $staff->getEntityType() !== $entityType || $staff->getEntityId() !== $entityId) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پرسنل انتخابشده متعلق به شما نیست', 422, 'staff_uuid');
|
||||
}
|
||||
$item->setStaff($staff);
|
||||
$staffError = $this->applyStaffMembers($item, $data, $entityType, $entityId);
|
||||
if ($staffError !== null) {
|
||||
return $staffError;
|
||||
}
|
||||
|
||||
if (isset($data['insurance_covered'])) {
|
||||
$item->setInsuranceCovered((bool) $data['insurance_covered']);
|
||||
}
|
||||
if (array_key_exists('insurance_price_rials', $data)) {
|
||||
$item->setInsurancePriceRials($data['insurance_price_rials'] !== null ? (int) $data['insurance_price_rials'] : null);
|
||||
if (array_key_exists('duration_minutes', $data)) {
|
||||
$dm = $data['duration_minutes'];
|
||||
$item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm);
|
||||
}
|
||||
if (array_key_exists('bookable', $data)) {
|
||||
$item->setBookable((bool) $data['bookable']);
|
||||
}
|
||||
$packageError = $this->applyInventoryPackage($item, $data, $entityType, $entityId);
|
||||
if ($packageError !== null) {
|
||||
return $packageError;
|
||||
}
|
||||
$consumableError = $this->applyConsumables($item, $data, $entityType, $entityId);
|
||||
if ($consumableError !== null) {
|
||||
return $consumableError;
|
||||
}
|
||||
|
||||
$this->itemRepo->save($item);
|
||||
|
||||
// قیمت سرویس همان تعرفهی سال جاری است؛ هنگام ساخت، تعرفهی سال جاری ثبت میشود.
|
||||
$this->tariffService->upsert($item->getId(), $this->tariffService->currentJalaliYear(), $item->getPriceRials());
|
||||
$this->auditService->logCreate($item, $user);
|
||||
|
||||
return $this->success($item->toArray(), 201);
|
||||
return $this->success($this->serializeItems([$item])[0], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['PATCH'])]
|
||||
@@ -191,27 +339,36 @@ class ClinicServiceController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$before = $this->auditService->snapshot($item);
|
||||
|
||||
$priceChanged = false;
|
||||
if (isset($data['name']) && trim($data['name']) !== '') { $item->setName(trim($data['name'])); }
|
||||
if (isset($data['price_rials'])) { $item->setPriceRials((int) $data['price_rials']); $priceChanged = true; }
|
||||
if (isset($data['active'])) { $item->setActive((bool) $data['active']); }
|
||||
if (array_key_exists('staff_uuid', $data)) {
|
||||
$staff = null;
|
||||
if ($data['staff_uuid']) {
|
||||
$staff = $this->staffRepo->findByUuid($data['staff_uuid']);
|
||||
if ($staff === null || $staff->getEntityType() !== $entityType || $staff->getEntityId() !== $entityId) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پرسنل انتخابشده متعلق به شما نیست', 422, 'staff_uuid');
|
||||
}
|
||||
if (array_key_exists('staff_uuids', $data) || array_key_exists('staff_uuid', $data)) {
|
||||
$staffError = $this->applyStaffMembers($item, $data, $entityType, $entityId);
|
||||
if ($staffError !== null) {
|
||||
return $staffError;
|
||||
}
|
||||
$item->setStaff($staff);
|
||||
}
|
||||
if (isset($data['insurance_covered'])) {
|
||||
$item->setInsuranceCovered((bool) $data['insurance_covered']);
|
||||
}
|
||||
if (array_key_exists('insurance_price_rials', $data)) {
|
||||
$item->setInsurancePriceRials($data['insurance_price_rials'] !== null ? (int) $data['insurance_price_rials'] : null);
|
||||
if (array_key_exists('duration_minutes', $data)) {
|
||||
$dm = $data['duration_minutes'];
|
||||
$item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm);
|
||||
}
|
||||
if (array_key_exists('bookable', $data)) {
|
||||
$item->setBookable((bool) $data['bookable']);
|
||||
}
|
||||
$packageError = $this->applyInventoryPackage($item, $data, $entityType, $entityId);
|
||||
if ($packageError !== null) {
|
||||
return $packageError;
|
||||
}
|
||||
$consumableError = $this->applyConsumables($item, $data, $entityType, $entityId);
|
||||
if ($consumableError !== null) {
|
||||
return $consumableError;
|
||||
}
|
||||
|
||||
$this->itemRepo->save($item);
|
||||
@@ -221,7 +378,9 @@ class ClinicServiceController extends BaseController
|
||||
$this->tariffService->upsert($item->getId(), $this->tariffService->currentJalaliYear(), $item->getPriceRials());
|
||||
}
|
||||
|
||||
return $this->success($item->toArray());
|
||||
$this->auditService->logChanges($item, $before, $this->auditService->snapshot($item), $user);
|
||||
|
||||
return $this->success($this->serializeItems([$item])[0]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['DELETE'])]
|
||||
@@ -304,19 +463,66 @@ class ClinicServiceController extends BaseController
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve and assign the service's personnel from the payload, scoped to the
|
||||
* tenant. Accepts `staff_uuids` (array, preferred) or the legacy single
|
||||
* `staff_uuid`. Returns a 422 JsonResponse if any staff is missing or not
|
||||
* owned by the tenant, otherwise null.
|
||||
*/
|
||||
private function applyStaffMembers(ServiceItem $item, array $data, string $entityType, int $entityId): ?JsonResponse
|
||||
{
|
||||
$uuids = [];
|
||||
if (array_key_exists('staff_uuids', $data) && is_array($data['staff_uuids'])) {
|
||||
$uuids = $data['staff_uuids'];
|
||||
} elseif (!empty($data['staff_uuid'])) {
|
||||
$uuids = [$data['staff_uuid']];
|
||||
}
|
||||
|
||||
$members = [];
|
||||
foreach (array_values(array_unique(array_filter($uuids))) as $uuid) {
|
||||
$staff = $this->staffRepo->findByUuid((string) $uuid);
|
||||
if ($staff === null || $staff->getEntityType() !== $entityType || $staff->getEntityId() !== $entityId) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پرسنل انتخابشده متعلق به شما نیست', 422, 'staff_uuids');
|
||||
}
|
||||
$members[] = $staff;
|
||||
}
|
||||
|
||||
$item->setStaffMembers($members);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* صاحب سرویسهای این درخواست. clinic_uuid درخواست (query یا body) مقدم است، بعد
|
||||
* محیط فعال کاربر، و در آخر نقش — منطق کامل در EntityContextResolver.
|
||||
*
|
||||
* @return array{0: string, 1: ?int}
|
||||
*/
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
|
||||
return $this->contextResolver->resolve($user, $this->requestedClinicUuid())->toEntityPair();
|
||||
}
|
||||
|
||||
private function requestedClinicUuid(): ?string
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
if ($request === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
|
||||
$fromQuery = $request->query->get('clinic_uuid');
|
||||
if (is_string($fromQuery) && $fromQuery !== '') {
|
||||
return $fromQuery;
|
||||
}
|
||||
|
||||
return ['unknown', null];
|
||||
if (!in_array($request->getMethod(), ['POST', 'PATCH', 'PUT'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true);
|
||||
|
||||
return is_array($body) && is_string($body['clinic_uuid'] ?? null) && $body['clinic_uuid'] !== ''
|
||||
? $body['clinic_uuid']
|
||||
: null;
|
||||
}
|
||||
|
||||
private function assertServicesGate(string $entityType, ?int $entityId): void
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\ClinicService\Entity;
|
||||
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
@@ -23,10 +25,21 @@ class ServiceItem
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ServiceSection $section;
|
||||
|
||||
// Legacy single-staff column, kept for backward compatibility with existing
|
||||
// consumers (reception/session). Mirrors the first entry of $staffMembers.
|
||||
#[ORM\ManyToOne(targetEntity: ClinicStaff::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ClinicStaff $staff = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, ClinicStaff> personnel assigned to this service.
|
||||
* EAGER so hydration always populates the typed property (avoids the
|
||||
* "accessed before initialization" pitfall on lazy typed collections).
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: ClinicStaff::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'service_item_staff')]
|
||||
private Collection $staffMembers;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 200)]
|
||||
private string $name;
|
||||
|
||||
@@ -39,9 +52,36 @@ class ServiceItem
|
||||
#[ORM\Column(name: 'insurance_covered', type: 'boolean')]
|
||||
private bool $insuranceCovered = false;
|
||||
|
||||
/**
|
||||
* @deprecated منبع حقیقتِ پوشش، TenantServiceCoverage است و هیچ محاسبهای این مقدار
|
||||
* را نمیخواند. ستون برای دادهی تاریخی مانده ولی نه نوشته میشود و نه منتشر.
|
||||
*/
|
||||
#[ORM\Column(name: 'insurance_price_rials', type: 'integer', nullable: true)]
|
||||
private ?int $insurancePriceRials = null;
|
||||
|
||||
#[ORM\Column(name: 'duration_minutes', type: 'integer', nullable: true)]
|
||||
private ?int $durationMinutes = null;
|
||||
|
||||
/** نمایش این سرویس در نوبتدهی (پزشک ممکن است همهٔ سرویسها را ارائه ندهد). */
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
private bool $bookable = false;
|
||||
|
||||
/**
|
||||
* پکیج کالای مصرفی این خدمت ({@see \App\Inventory\Entity\InventoryPackage}).
|
||||
* ارجاع خام int بدون FK — همان الگوی Tariff و TenantServiceCoverage — تا دامنهٔ
|
||||
* ClinicService به Inventory وابسته نشود.
|
||||
*/
|
||||
#[ORM\Column(name: 'inventory_package_id', type: 'integer', nullable: true)]
|
||||
private ?int $inventoryPackageId = null;
|
||||
|
||||
/**
|
||||
* اقلام کالای تکیِ این خدمت — مستقل از پکیج و قابل استفاده همزمان با آن.
|
||||
*
|
||||
* @var Collection<int, ServiceItemConsumable>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'serviceItem', targetEntity: ServiceItemConsumable::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $consumables;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
@@ -56,6 +96,52 @@ class ServiceItem
|
||||
$this->priceRials = $priceRials;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
$this->staffMembers = new ArrayCollection();
|
||||
$this->consumables = new ArrayCollection();
|
||||
}
|
||||
|
||||
/** @return Collection<int, ServiceItemConsumable> */
|
||||
public function getConsumables(): Collection
|
||||
{
|
||||
// Doctrine بدون constructor هیدریت میکند؛ از property تایپشده محافظت کن.
|
||||
return $this->consumables ??= new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* جایگزینی کامل اقلام تکی. کلید تطبیق، خودِ InventoryItem است تا ردیف بدون تغییر
|
||||
* حذف و دوباره ساخته نشود.
|
||||
*
|
||||
* @param array<int, array{item: \App\Inventory\Entity\InventoryItem, amount: int}> $lines
|
||||
*/
|
||||
public function replaceConsumables(array $lines): self
|
||||
{
|
||||
$existing = [];
|
||||
foreach ($this->getConsumables() as $consumable) {
|
||||
$existing[$consumable->getItem()->getId()] = $consumable;
|
||||
}
|
||||
|
||||
$keep = [];
|
||||
foreach ($lines as $line) {
|
||||
$itemId = $line['item']->getId();
|
||||
$keep[] = $itemId;
|
||||
|
||||
if (isset($existing[$itemId])) {
|
||||
$existing[$itemId]->setAmount($line['amount']);
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->getConsumables()->add((new ServiceItemConsumable($line['item'], $line['amount']))->setServiceItem($this));
|
||||
}
|
||||
|
||||
foreach ($existing as $itemId => $consumable) {
|
||||
if (!in_array($itemId, $keep, true)) {
|
||||
$this->getConsumables()->removeElement($consumable);
|
||||
}
|
||||
}
|
||||
|
||||
$this->updatedAt = time();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
@@ -66,32 +152,82 @@ class ServiceItem
|
||||
public function getPriceRials(): int { return $this->priceRials; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function isInsuranceCovered(): bool { return $this->insuranceCovered; }
|
||||
public function getInsurancePriceRials(): ?int { return $this->insurancePriceRials; }
|
||||
public function getDurationMinutes(): ?int { return $this->durationMinutes; }
|
||||
public function isBookable(): bool { return $this->bookable; }
|
||||
public function getInventoryPackageId(): ?int { return $this->inventoryPackageId; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setStaff(?ClinicStaff $staff): self { $this->staff = $staff; $this->updatedAt = time(); return $this; }
|
||||
|
||||
/** @return Collection<int, ClinicStaff> */
|
||||
public function getStaffMembers(): Collection
|
||||
{
|
||||
// Doctrine hydrates without the constructor; guard the typed property.
|
||||
return $this->staffMembers ??= new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the assigned personnel. Also mirrors the first member into the
|
||||
* legacy single {@see $staff} column so back-compat consumers keep working.
|
||||
*
|
||||
* @param ClinicStaff[] $members
|
||||
*/
|
||||
public function setStaffMembers(array $members): self
|
||||
{
|
||||
$collection = $this->getStaffMembers();
|
||||
$collection->clear();
|
||||
foreach ($members as $m) {
|
||||
if (!$collection->contains($m)) {
|
||||
$collection->add($m);
|
||||
}
|
||||
}
|
||||
$this->staff = $members[0] ?? null;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
public function setName(string $name): self { $this->name = $name; $this->updatedAt = time(); return $this; }
|
||||
public function setPriceRials(int $price): self { $this->priceRials = $price; $this->updatedAt = time(); return $this; }
|
||||
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
|
||||
public function setInsuranceCovered(bool $v): self { $this->insuranceCovered = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setInsurancePriceRials(?int $v): self { $this->insurancePriceRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setDurationMinutes(?int $v): self { $this->durationMinutes = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setBookable(bool $v): self { $this->bookable = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setInventoryPackageId(?int $v): self { $this->inventoryPackageId = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
// Prefer the multi-staff collection; fall back to the legacy single
|
||||
// staff so rows created before the migration still expose personnel.
|
||||
$members = array_values($this->getStaffMembers()->toArray());
|
||||
if (empty($members) && $this->staff !== null) {
|
||||
$members = [$this->staff];
|
||||
}
|
||||
$primary = $members[0] ?? null;
|
||||
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'section_uuid' => $this->section->getUuid(),
|
||||
'staff_uuid' => $this->staff?->getUuid(),
|
||||
'staff_name' => $this->staff?->getFullName(),
|
||||
'staff' => $this->staff !== null
|
||||
? ['uuid' => $this->staff->getUuid(), 'full_name' => $this->staff->getFullName()]
|
||||
'section_name' => $this->section->getName(),
|
||||
'staff_uuid' => $primary?->getUuid(),
|
||||
'staff_name' => $primary?->getFullName(),
|
||||
'staff' => $primary !== null
|
||||
? ['uuid' => $primary->getUuid(), 'full_name' => $primary->getFullName()]
|
||||
: null,
|
||||
'staff_members' => array_map(
|
||||
fn(ClinicStaff $s) => ['uuid' => $s->getUuid(), 'full_name' => $s->getFullName()],
|
||||
$members
|
||||
),
|
||||
'name' => $this->name,
|
||||
'price_rials' => $this->priceRials,
|
||||
'active' => $this->active,
|
||||
'insurance_covered' => $this->insuranceCovered,
|
||||
'insurance_price_rials' => $this->insurancePriceRials,
|
||||
'duration_minutes' => $this->durationMinutes,
|
||||
'bookable' => $this->bookable,
|
||||
'inventory_package_id' => $this->inventoryPackageId,
|
||||
'consumables' => array_map(
|
||||
fn(ServiceItemConsumable $c) => $c->toArray(),
|
||||
$this->getConsumables()->toArray()
|
||||
),
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\ClinicService\Entity;
|
||||
|
||||
use App\ClinicService\Repository\ServiceItemAuditLogRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* تاریخچهی تغییرات یک خدمت: چه کسی، چه فیلدی را، کِی و از چه مقداری به چه مقداری
|
||||
* تغییر داد. همشکل {@see \App\Patient\Entity\SessionAuditLog} است تا الگوی لاگ در
|
||||
* سراسر پروژه یکسان بماند.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ServiceItemAuditLogRepository::class)]
|
||||
#[ORM\Table(name: 'service_item_audit_logs')]
|
||||
#[ORM\Index(columns: ['service_item_id', 'created_at'], name: 'idx_service_item_audit_item')]
|
||||
class ServiceItemAuditLog
|
||||
{
|
||||
public const OP_CREATE = 'create';
|
||||
public const OP_UPDATE = 'update';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
|
||||
#[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ServiceItem $serviceItem;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 40)]
|
||||
private string $field;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 10)]
|
||||
private string $operation;
|
||||
|
||||
#[ORM\Column(name: 'old_value', type: 'text', nullable: true)]
|
||||
private ?string $oldValue = null;
|
||||
|
||||
#[ORM\Column(name: 'new_value', type: 'text', nullable: true)]
|
||||
private ?string $newValue = null;
|
||||
|
||||
#[ORM\Column(name: 'actor_user_id', type: 'integer', nullable: true)]
|
||||
private ?int $actorUserId = null;
|
||||
|
||||
#[ORM\Column(name: 'actor_name', type: 'string', length: 191, nullable: true)]
|
||||
private ?string $actorName = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(ServiceItem $serviceItem, string $field, string $operation)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->serviceItem = $serviceItem;
|
||||
$this->field = $field;
|
||||
$this->operation = $operation;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function setActor(?int $userId, ?string $name): self { $this->actorUserId = $userId; $this->actorName = $name; return $this; }
|
||||
public function setValues(?string $old, ?string $new): self { $this->oldValue = $old; $this->newValue = $new; return $this; }
|
||||
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'field' => $this->field,
|
||||
'operation' => $this->operation,
|
||||
'old_value' => $this->oldValue,
|
||||
'new_value' => $this->newValue,
|
||||
'actor_name' => $this->actorName,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\ClinicService\Entity;
|
||||
|
||||
use App\Inventory\Entity\InventoryItem;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* یک قلم کالای مصرفیِ مستقل روی یک خدمت: ارجاع به {@see InventoryItem} بههمراه تعداد.
|
||||
*
|
||||
* مکمل (نه جایگزین) `ServiceItem::$inventoryPackageId` است؛ یک خدمت میتواند هم یک
|
||||
* پکیج آماده داشته باشد و هم چند قلم تکی. همشکل {@see \App\Inventory\Entity\InventoryPackageItem}.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'service_item_consumables')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_service_item_consumable', columns: ['service_item_id', 'item_id'])]
|
||||
class ServiceItemConsumable
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ServiceItem::class, inversedBy: 'consumables')]
|
||||
#[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ServiceItem $serviceItem;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: InventoryItem::class)]
|
||||
#[ORM\JoinColumn(name: 'item_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private InventoryItem $item;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $amount = 1;
|
||||
|
||||
public function __construct(InventoryItem $item, int $amount = 1)
|
||||
{
|
||||
$this->item = $item;
|
||||
$this->amount = max(1, $amount);
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getServiceItem(): ServiceItem { return $this->serviceItem; }
|
||||
public function getItem(): InventoryItem { return $this->item; }
|
||||
public function getAmount(): int { return $this->amount; }
|
||||
|
||||
public function setServiceItem(ServiceItem $s): self { $this->serviceItem = $s; return $this; }
|
||||
public function setAmount(int $v): self { $this->amount = max(1, $v); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'item_uuid' => $this->item->getUuid(),
|
||||
'name' => $this->item->getName(),
|
||||
'unit' => $this->item->getUnit(),
|
||||
'price' => $this->item->getPrice(),
|
||||
'stock' => $this->item->getStock(),
|
||||
'amount' => $this->amount,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -65,9 +65,13 @@ class ServiceSection
|
||||
public function setName(string $name): self { $this->name = $name; $this->updatedAt = time(); return $this; }
|
||||
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
/**
|
||||
* @param int|null $itemsCount when provided, added as `items_count`
|
||||
* (number of services in this section)
|
||||
*/
|
||||
public function toArray(?int $itemsCount = null): array
|
||||
{
|
||||
return [
|
||||
$data = [
|
||||
'uuid' => $this->uuid,
|
||||
'entity_type' => $this->entityType,
|
||||
'entity_id' => $this->entityId,
|
||||
@@ -76,5 +80,11 @@ class ServiceSection
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
|
||||
if ($itemsCount !== null) {
|
||||
$data['items_count'] = $itemsCount;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\ClinicService\Repository;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceItemAuditLog;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class ServiceItemAuditLogRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ServiceItemAuditLog::class);
|
||||
}
|
||||
|
||||
public function save(ServiceItemAuditLog $log, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($log);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
/** @return ServiceItemAuditLog[] تازهترین رویداد اول. */
|
||||
public function findByItem(ServiceItem $item, int $limit = 100): array
|
||||
{
|
||||
return $this->createQueryBuilder('l')
|
||||
->where('l.serviceItem = :item')
|
||||
->setParameter('item', $item)
|
||||
->orderBy('l.createdAt', 'DESC')
|
||||
->addOrderBy('l.id', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,112 @@ class ServiceItemRepository extends ServiceEntityRepository
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** همهی سرویسهای یک owner (در همهی بخشها) — برای انتخاب/جستجوی سراسری. */
|
||||
public function findByEntity(string $entityType, int $entityId): array
|
||||
{
|
||||
return $this->createQueryBuilder('i')
|
||||
->join('i.section', 's')
|
||||
->where('s.entityType = :type')->setParameter('type', $entityType)
|
||||
->andWhere('s.entityId = :id')->setParameter('id', $entityId)
|
||||
->orderBy('i.name', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count services per section in a single query (avoids N+1 in the section list).
|
||||
*
|
||||
* @param ServiceSection[] $sections
|
||||
* @return array<string,int> section uuid → number of services
|
||||
*/
|
||||
public function countBySections(array $sections): array
|
||||
{
|
||||
if (empty($sections)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->createQueryBuilder('i')
|
||||
->select('s.uuid AS uuid, COUNT(i.id) AS cnt')
|
||||
->join('i.section', 's')
|
||||
->where('i.section IN (:sections)')
|
||||
->setParameter('sections', $sections)
|
||||
->groupBy('s.uuid')
|
||||
->getQuery()
|
||||
->getScalarResult();
|
||||
|
||||
$counts = [];
|
||||
foreach ($rows as $row) {
|
||||
$counts[$row['uuid']] = (int) $row['cnt'];
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* تعداد سرویسهای فعالِ «نمایش در نوبتدهی» (bookable) متعلق به یک entity
|
||||
* (پزشک/کلینیک) — از طریق section.entityType/entityId. برای اجبارِ حالت سرویس.
|
||||
*/
|
||||
public function countBookableByEntity(string $entityType, int $entityId): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('i')
|
||||
->select('COUNT(i.id)')
|
||||
->join('i.section', 's')
|
||||
->where('s.entityType = :type')
|
||||
->andWhere('s.entityId = :id')
|
||||
->andWhere('i.bookable = true')
|
||||
->andWhere('i.active = true')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* سرویسهای فعالِ «نمایش در نوبتدهی» (bookable) یک entity — برای نمایش عمومیِ
|
||||
* انتخاب سرویس در نوبتگیری آنلاین.
|
||||
*
|
||||
* @return ServiceItem[]
|
||||
*/
|
||||
public function findBookableByEntity(string $entityType, int $entityId): array
|
||||
{
|
||||
return $this->createQueryBuilder('i')
|
||||
->join('i.section', 's')
|
||||
->where('s.entityType = :type')
|
||||
->andWhere('s.entityId = :id')
|
||||
->andWhere('i.bookable = true')
|
||||
->andWhere('i.active = true')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->orderBy('i.name', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* نگاشت id → uuid برای مجموعهای از خدمات.
|
||||
*
|
||||
* عمداً entity هیدریت نمیکند: ServiceItem رابطهٔ staffMembers را EAGER دارد،
|
||||
* پس هر entity یک کوئری اضافه برای بارگذاری کارکنانش میزند و فهرستی که فقط
|
||||
* uuid میخواهد به N+1 میافتد.
|
||||
*
|
||||
* @param int[] $ids
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function findUuidsByIds(array $ids): array
|
||||
{
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->createQueryBuilder('i')
|
||||
->select('i.id AS id, i.uuid AS uuid')
|
||||
->where('i.id IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getScalarResult();
|
||||
|
||||
return array_column($rows, 'uuid', 'id');
|
||||
}
|
||||
|
||||
public function save(ServiceItem $item): void
|
||||
{
|
||||
$this->getEntityManager()->persist($item);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\ClinicService\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceItemAuditLog;
|
||||
use App\ClinicService\Repository\ServiceItemAuditLogRepository;
|
||||
|
||||
/**
|
||||
* ثبت تاریخچهی تغییرات خدمات. کنترلر فقط snapshot قبل و بعد را میدهد؛ تشخیص
|
||||
* فیلدهای تغییریافته اینجا انجام میشود.
|
||||
*/
|
||||
class ServiceItemAuditService
|
||||
{
|
||||
/** فیلدهایی که تغییرشان لاگ میشود (کلید = نام فیلد در لاگ). */
|
||||
private const TRACKED = [
|
||||
'name' => 'نام سرویس',
|
||||
'price_rials' => 'قیمت پایه',
|
||||
'active' => 'وضعیت',
|
||||
'duration_minutes' => 'زمان متوسط',
|
||||
'bookable' => 'نمایش در نوبتدهی',
|
||||
'insurance_covered' => 'پوشش بیمه',
|
||||
'inventory_package' => 'پکیج کالا',
|
||||
'consumables' => 'کالاهای تکی',
|
||||
];
|
||||
|
||||
public function __construct(private readonly ServiceItemAuditLogRepository $repo) {}
|
||||
|
||||
/** @return array<string, string|null> snapshot قابل مقایسه از وضعیت فعلی خدمت. */
|
||||
public function snapshot(ServiceItem $item): array
|
||||
{
|
||||
return [
|
||||
'name' => $item->getName(),
|
||||
'price_rials' => (string) $item->getPriceRials(),
|
||||
'active' => $item->isActive() ? '1' : '0',
|
||||
'duration_minutes' => $item->getDurationMinutes() === null ? null : (string) $item->getDurationMinutes(),
|
||||
'bookable' => $item->isBookable() ? '1' : '0',
|
||||
'insurance_covered' => $item->isInsuranceCovered() ? '1' : '0',
|
||||
'inventory_package' => $item->getInventoryPackageId() === null ? null : (string) $item->getInventoryPackageId(),
|
||||
'consumables' => $this->consumablesFingerprint($item),
|
||||
];
|
||||
}
|
||||
|
||||
/** امضای مرتبشدهی اقلام تکی تا تغییر در قلم یا تعداد قابل تشخیص باشد. */
|
||||
private function consumablesFingerprint(ServiceItem $item): ?string
|
||||
{
|
||||
$lines = [];
|
||||
foreach ($item->getConsumables() as $consumable) {
|
||||
$lines[] = $consumable->getItem()->getName() . '×' . $consumable->getAmount();
|
||||
}
|
||||
|
||||
if ($lines === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sort($lines);
|
||||
|
||||
return implode('، ', $lines);
|
||||
}
|
||||
|
||||
public function logCreate(ServiceItem $item, ?User $actor): void
|
||||
{
|
||||
$this->repo->save(
|
||||
(new ServiceItemAuditLog($item, 'name', ServiceItemAuditLog::OP_CREATE))
|
||||
->setActor($actor?->getId(), $this->actorName($actor))
|
||||
->setValues(null, $item->getName())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* تفاوت دو snapshot را لاگ میکند. فیلد بدون تغییر ردیف نمیسازد.
|
||||
*
|
||||
* @param array<string, string|null> $before
|
||||
* @param array<string, string|null> $after
|
||||
*/
|
||||
public function logChanges(ServiceItem $item, array $before, array $after, ?User $actor): void
|
||||
{
|
||||
$logged = false;
|
||||
|
||||
foreach (array_keys(self::TRACKED) as $field) {
|
||||
if (($before[$field] ?? null) === ($after[$field] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->repo->save(
|
||||
(new ServiceItemAuditLog($item, $field, ServiceItemAuditLog::OP_UPDATE))
|
||||
->setActor($actor?->getId(), $this->actorName($actor))
|
||||
->setValues($before[$field] ?? null, $after[$field] ?? null),
|
||||
false,
|
||||
);
|
||||
$logged = true;
|
||||
}
|
||||
|
||||
if ($logged) {
|
||||
$this->repo->flush();
|
||||
}
|
||||
}
|
||||
|
||||
private function actorName(?User $actor): ?string
|
||||
{
|
||||
if ($actor === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $actor->getRealName() ?: $actor->getMobileNumber();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use App\Patient\Repository\PatientSessionRepository;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Context\EntityContextResolver;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Sms\Service\SmsWalletService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -33,6 +34,9 @@ class DashboardController extends BaseController
|
||||
private readonly PatientRecordRepository $patientRecordRepo,
|
||||
private readonly PatientSessionRepository $patientSessionRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
||||
private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo,
|
||||
) {}
|
||||
|
||||
// ── Clinic Dashboard ────────────────────────────────────────────────────
|
||||
@@ -96,11 +100,13 @@ class DashboardController extends BaseController
|
||||
|
||||
// ۵ نوبت امروز این کلینیک
|
||||
$todayAppts = $this->em->createQuery('
|
||||
SELECT a.uuid, u.realName AS patient_name, d.name AS doctor_name,
|
||||
a.slotStart AS slot_start, a.status
|
||||
SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
|
||||
d.name AS doctor_name, si.name AS service_name,
|
||||
a.slotStart AS slot_start, a.slotEnd AS slot_end, a.status
|
||||
FROM App\Appointment\Entity\Appointment a
|
||||
JOIN a.doctor d
|
||||
JOIN a.user u
|
||||
LEFT JOIN a.serviceItem si
|
||||
JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors
|
||||
WHERE c.id = :clinicId
|
||||
AND a.slotStart >= :todayStart AND a.slotStart <= :todayEnd
|
||||
@@ -132,6 +138,25 @@ class DashboardController extends BaseController
|
||||
$smsBalance = $this->smsWalletService->getBalance('clinic', $clinicId);
|
||||
$uniquePatients = $this->patientRecordRepo->countUnique('clinic', $clinicId, $from, $to);
|
||||
$revenuePeriod = $this->patientSessionRepo->sumRevenue('clinic', $clinicId, $from, $to);
|
||||
$totalPatients = $this->patientRecordRepo->countUnique('clinic', $clinicId, 0, time());
|
||||
|
||||
$chartPeriod = $this->chartPeriodParams($request);
|
||||
$rev = $this->revenueDaily('clinic', $clinicId);
|
||||
$revenueByMon = $this->revenueByJalaliYear('clinic', $clinicId, $chartPeriod['revenue_year']);
|
||||
$apptByDay = $this->appointmentsByJalaliMonth(
|
||||
$chartPeriod['patients_year'],
|
||||
$chartPeriod['patients_month'],
|
||||
function (int $s, int $e) use ($clinicId): array {
|
||||
$rows = $this->em->createQuery('
|
||||
SELECT a.slotStart FROM App\Appointment\Entity\Appointment a
|
||||
JOIN App\Doctor\Entity\Doctor d WITH a.doctor = d
|
||||
JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors
|
||||
WHERE c.id = :clinicId AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
')->setParameters(['clinicId' => $clinicId, 's' => $s, 'e' => $e])->getArrayResult();
|
||||
|
||||
return array_column($rows, 'slotStart');
|
||||
}
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'clinic' => [
|
||||
@@ -147,8 +172,17 @@ class DashboardController extends BaseController
|
||||
'pending_invitations' => $pendingInvitations,
|
||||
'sms_wallet_balance' => $smsBalance,
|
||||
'unique_patients_count' => $uniquePatients,
|
||||
'total_patients' => $totalPatients,
|
||||
'revenue_period_rials' => $revenuePeriod,
|
||||
'today_payments_rials' => $rev['today_payments_rials'],
|
||||
'week_payments_rials' => $rev['week_payments_rials'],
|
||||
],
|
||||
'charts' => [
|
||||
'revenue_by_day' => $rev['revenue'],
|
||||
'revenue_by_month' => $revenueByMon,
|
||||
'appointments_by_day' => $apptByDay,
|
||||
],
|
||||
'charts_period' => $chartPeriod,
|
||||
'period' => ['from' => $from, 'to' => $to],
|
||||
'today_appointments' => $todayAppts,
|
||||
'doctors' => $doctors,
|
||||
@@ -166,6 +200,11 @@ class DashboardController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
// محیط فعال تعیین میکند این داشبورد شخصی است یا داخل یک کلینیک. بدون این،
|
||||
// پزشکِ دعوتشده در محیط کلینیک آمار و درآمد مطب شخصی خودش را میدید.
|
||||
$context = $this->contextResolver->tryResolve($user, $request->query->get('clinic_uuid'));
|
||||
$clinic = $context?->clinic;
|
||||
|
||||
$doctorId = $doctor->getId();
|
||||
$todayStart = strtotime('today midnight');
|
||||
$todayEnd = strtotime('tomorrow midnight') - 1;
|
||||
@@ -177,23 +216,9 @@ class DashboardController extends BaseController
|
||||
$to = $request->query->get('to') ? (int) $request->query->get('to') : time();
|
||||
|
||||
// آمار
|
||||
$todayCount = (int) $this->em->createQuery('
|
||||
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
')->setParameters(['doctor' => $doctor, 's' => $todayStart, 'e' => $todayEnd])
|
||||
->getSingleScalarResult();
|
||||
|
||||
$tmrCount = (int) $this->em->createQuery('
|
||||
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
')->setParameters(['doctor' => $doctor, 's' => $tmrStart, 'e' => $tmrEnd])
|
||||
->getSingleScalarResult();
|
||||
|
||||
$monthCount = (int) $this->em->createQuery('
|
||||
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.doctor = :doctor AND a.slotStart >= :s
|
||||
')->setParameters(['doctor' => $doctor, 's' => $monthStart])
|
||||
->getSingleScalarResult();
|
||||
$todayCount = $this->countAppointments($doctor, $clinic, $todayStart, $todayEnd);
|
||||
$tmrCount = $this->countAppointments($doctor, $clinic, $tmrStart, $tmrEnd);
|
||||
$monthCount = $this->countAppointments($doctor, $clinic, $monthStart, PHP_INT_MAX);
|
||||
|
||||
// میانگین و تعداد امتیاز
|
||||
$ratingRow = $this->em->createQuery('
|
||||
@@ -203,18 +228,24 @@ class DashboardController extends BaseController
|
||||
')->setParameter('doctor', $doctor)->getOneOrNullResult() ?? [];
|
||||
|
||||
// نوبتهای امروز
|
||||
$todayAppts = $this->em->createQuery('
|
||||
SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
|
||||
a.slotStart AS slot_start, a.status
|
||||
FROM App\Appointment\Entity\Appointment a
|
||||
JOIN a.user u
|
||||
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
ORDER BY a.slotStart ASC
|
||||
')->setMaxResults(10)->setParameters([
|
||||
'doctor' => $doctor,
|
||||
's' => $todayStart,
|
||||
'e' => $todayEnd,
|
||||
])->getArrayResult();
|
||||
$todayApptsQb = $this->em->createQueryBuilder()
|
||||
->select('a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
|
||||
d.name AS doctor_name, si.name AS service_name,
|
||||
a.slotStart AS slot_start, a.slotEnd AS slot_end, a.status')
|
||||
->from(\App\Appointment\Entity\Appointment::class, 'a')
|
||||
->join('a.user', 'u')
|
||||
->join('a.doctor', 'd')
|
||||
->leftJoin('a.serviceItem', 'si')
|
||||
->where('a.doctor = :doctor')
|
||||
->andWhere('a.slotStart >= :s AND a.slotStart <= :e')
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('s', $todayStart)
|
||||
->setParameter('e', $todayEnd)
|
||||
->orderBy('a.slotStart', 'ASC')
|
||||
->setMaxResults(10);
|
||||
|
||||
$this->restrictToClinicAddresses($todayApptsQb, $clinic);
|
||||
$todayAppts = $todayApptsQb->getQuery()->getArrayResult();
|
||||
|
||||
// کلینیکهای عضو
|
||||
$clinics = $this->em->createQuery('
|
||||
@@ -224,9 +255,39 @@ class DashboardController extends BaseController
|
||||
WHERE d.id = :doctorId
|
||||
')->setParameter('doctorId', $doctorId)->getArrayResult();
|
||||
|
||||
$smsBalance = $this->smsWalletService->getBalance('doctor', $doctorId);
|
||||
$uniquePatients = $this->patientRecordRepo->countUnique('doctor', $doctorId, $from, $to);
|
||||
$revenuePeriod = $this->patientSessionRepo->sumRevenue('doctor', $doctorId, $from, $to);
|
||||
$chartPeriod = $this->chartPeriodParams($request);
|
||||
$apptByDay = $this->appointmentsByJalaliMonth(
|
||||
$chartPeriod['patients_year'],
|
||||
$chartPeriod['patients_month'],
|
||||
fn(int $s, int $e): array => $this->appointmentSlotStarts($doctor, $clinic, $s, $e)
|
||||
);
|
||||
|
||||
$stats = [
|
||||
'today_appointments' => $todayCount,
|
||||
'tomorrow_appointments' => $tmrCount,
|
||||
'this_month_appointments' => $monthCount,
|
||||
'avg_rating' => $ratingRow['avg_score'] ? round((float) $ratingRow['avg_score'], 1) : null,
|
||||
'total_ratings' => (int) ($ratingRow['total'] ?? 0),
|
||||
];
|
||||
|
||||
$charts = ['appointments_by_day' => $apptByDay];
|
||||
|
||||
// ارقام مالی و کیف پول پیامک به مطب شخصی تعلق دارند. در محیط کلینیک اصلاً
|
||||
// برگردانده نمیشوند مگر کاربر مالک همان کلینیک باشد — مخفیکردن در UI کافی
|
||||
// نیست، چون endpoint مستقیماً قابل صدا زدن است.
|
||||
if ($this->maySeeFinancials($user, $clinic)) {
|
||||
$rev = $this->revenueDaily('doctor', $doctorId);
|
||||
|
||||
$stats['sms_wallet_balance'] = $this->smsWalletService->getBalance('doctor', $doctorId);
|
||||
$stats['unique_patients_count'] = $this->patientRecordRepo->countUnique('doctor', $doctorId, $from, $to);
|
||||
$stats['total_patients'] = $this->patientRecordRepo->countUnique('doctor', $doctorId, 0, time());
|
||||
$stats['revenue_period_rials'] = $this->patientSessionRepo->sumRevenue('doctor', $doctorId, $from, $to);
|
||||
$stats['today_payments_rials'] = $rev['today_payments_rials'];
|
||||
$stats['week_payments_rials'] = $rev['week_payments_rials'];
|
||||
|
||||
$charts['revenue_by_day'] = $rev['revenue'];
|
||||
$charts['revenue_by_month'] = $this->revenueByJalaliYear('doctor', $doctorId, $chartPeriod['revenue_year']);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'doctor' => [
|
||||
@@ -234,22 +295,237 @@ class DashboardController extends BaseController
|
||||
'name' => $doctor->getName(),
|
||||
'degree' => $doctor->getDegree(),
|
||||
],
|
||||
'stats' => [
|
||||
'today_appointments' => $todayCount,
|
||||
'tomorrow_appointments' => $tmrCount,
|
||||
'this_month_appointments' => $monthCount,
|
||||
'avg_rating' => $ratingRow['avg_score'] ? round((float) $ratingRow['avg_score'], 1) : null,
|
||||
'total_ratings' => (int) ($ratingRow['total'] ?? 0),
|
||||
'sms_wallet_balance' => $smsBalance,
|
||||
'unique_patients_count' => $uniquePatients,
|
||||
'revenue_period_rials' => $revenuePeriod,
|
||||
'context' => [
|
||||
'type' => $clinic === null ? 'personal' : 'clinic',
|
||||
'clinic_uuid' => $clinic?->getUuid(),
|
||||
'clinic_name' => $clinic?->getName(),
|
||||
],
|
||||
'stats' => $stats,
|
||||
'charts' => $charts,
|
||||
'charts_period' => $chartPeriod,
|
||||
'period' => ['from' => $from, 'to' => $to],
|
||||
'today_appointments' => $todayAppts,
|
||||
'clinics' => $clinics,
|
||||
// فهرست کلینیکها فقط در محیط شخصی معنا دارد.
|
||||
'clinics' => $clinic === null ? $clinics : [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* نوبتهای پزشک در یک بازه، محدود به محیط جاری. در محیط کلینیک فقط نوبتهایی
|
||||
* شمرده میشوند که آدرسشان متعلق به همان کلینیک است.
|
||||
*/
|
||||
private function countAppointments(\App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic, int $from, int $to): int
|
||||
{
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select('COUNT(a.id)')
|
||||
->from(\App\Appointment\Entity\Appointment::class, 'a')
|
||||
->where('a.doctor = :doctor')
|
||||
->andWhere('a.slotStart >= :s AND a.slotStart <= :e')
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('s', $from)
|
||||
->setParameter('e', $to);
|
||||
|
||||
$this->restrictToClinicAddresses($qb, $clinic);
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* تایماستمپ شروع نوبتهای یک پزشک در بازه — نسخهی لیستی countAppointments
|
||||
* برای سریهای روزانه (یک کوئری به جای یکی به ازای هر روز).
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function appointmentSlotStarts(\App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic, int $from, int $to): array
|
||||
{
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select('a.slotStart')
|
||||
->from(\App\Appointment\Entity\Appointment::class, 'a')
|
||||
->where('a.doctor = :doctor')
|
||||
->andWhere('a.slotStart >= :s AND a.slotStart <= :e')
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('s', $from)
|
||||
->setParameter('e', $to);
|
||||
|
||||
$this->restrictToClinicAddresses($qb, $clinic);
|
||||
|
||||
return array_column($qb->getQuery()->getArrayResult(), 'slotStart');
|
||||
}
|
||||
|
||||
private function restrictToClinicAddresses(\Doctrine\ORM\QueryBuilder $qb, ?\App\Clinic\Entity\Clinic $clinic): void
|
||||
{
|
||||
if ($clinic === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$addressIds = array_map(
|
||||
fn(\App\Doctor\Entity\DoctorAddress $a): int => (int) $a->getId(),
|
||||
$this->addressRepo->findForContext($qb->getParameter('doctor')->getValue(), $clinic->getId())
|
||||
);
|
||||
|
||||
$qb->andWhere('a.addressId IN (:addressIds)')
|
||||
->setParameter('addressIds', $addressIds ?: [0]);
|
||||
}
|
||||
|
||||
/** فقط محیط شخصی خود پزشک، یا مالک همان کلینیک. */
|
||||
private function maySeeFinancials(User $user, ?\App\Clinic\Entity\Clinic $clinic): bool
|
||||
{
|
||||
if ($clinic === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $clinic->getUser()->getId() === $user->getId()
|
||||
&& $this->permChecker->can($user, $clinic, 'payments', 'view');
|
||||
}
|
||||
|
||||
// ── Jalali chart helpers ────────────────────────────────────────────────
|
||||
|
||||
/** تقویم شمسی تهران — پایهی همهی محاسبات بازهی نمودارها. */
|
||||
private function jalaliCalendar(): \IntlCalendar
|
||||
{
|
||||
return \IntlCalendar::createInstance(new \DateTimeZone('Asia/Tehran'), 'fa_IR@calendar=persian');
|
||||
}
|
||||
|
||||
/**
|
||||
* سال/ماه شمسی جاری.
|
||||
* @return array{0:int, 1:int} [year, month] با ماه ۱..۱۲
|
||||
*/
|
||||
private function currentJalaliYearMonth(): array
|
||||
{
|
||||
$cal = $this->jalaliCalendar();
|
||||
|
||||
return [
|
||||
$cal->get(\IntlCalendar::FIELD_YEAR),
|
||||
$cal->get(\IntlCalendar::FIELD_MONTH) + 1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* بازهی یونیکس یک ماه شمسی و تعداد روزهای آن.
|
||||
* @return array{start:int, end:int, days:int}
|
||||
*/
|
||||
private function jalaliMonthRange(int $year, int $month): array
|
||||
{
|
||||
$cal = $this->jalaliCalendar();
|
||||
$cal->set(\IntlCalendar::FIELD_YEAR, $year);
|
||||
$cal->set(\IntlCalendar::FIELD_MONTH, $month - 1);
|
||||
$cal->set(\IntlCalendar::FIELD_DAY_OF_MONTH, 1);
|
||||
$cal->set(\IntlCalendar::FIELD_HOUR_OF_DAY, 0);
|
||||
$cal->set(\IntlCalendar::FIELD_MINUTE, 0);
|
||||
$cal->set(\IntlCalendar::FIELD_SECOND, 0);
|
||||
$cal->set(\IntlCalendar::FIELD_MILLISECOND, 0);
|
||||
|
||||
$start = (int) ($cal->getTime() / 1000);
|
||||
$days = $cal->getActualMaximum(\IntlCalendar::FIELD_DAY_OF_MONTH);
|
||||
|
||||
return ['start' => $start, 'end' => $start + $days * 86400 - 1, 'days' => $days];
|
||||
}
|
||||
|
||||
/**
|
||||
* سری تعداد نوبت به تفکیک روزهای یک ماه شمسی (نمودار «تعداد بیماران»).
|
||||
* روزهای بدون نوبت صفر میمانند تا طول سری برابر طول ماه باشد.
|
||||
*
|
||||
* `$fetchSlotStarts` باید تایماستمپ شروع همهی نوبتهای بازه را برگرداند —
|
||||
* یک کوئری برای کل ماه، نه یکی به ازای هر روز.
|
||||
*
|
||||
* @param callable(int $from, int $to): array<int, int> $fetchSlotStarts
|
||||
* @return array<int, array{label:string, count:int}>
|
||||
*/
|
||||
private function appointmentsByJalaliMonth(int $year, int $month, callable $fetchSlotStarts): array
|
||||
{
|
||||
$range = $this->jalaliMonthRange($year, $month);
|
||||
$buckets = array_fill(0, $range['days'], 0);
|
||||
$dayFmt = new \IntlDateFormatter(
|
||||
'fa_IR@calendar=persian',
|
||||
\IntlDateFormatter::NONE,
|
||||
\IntlDateFormatter::NONE,
|
||||
'Asia/Tehran',
|
||||
\IntlDateFormatter::TRADITIONAL,
|
||||
'd'
|
||||
);
|
||||
|
||||
foreach ($fetchSlotStarts($range['start'], $range['end']) as $slotStart) {
|
||||
$day = intdiv($slotStart - $range['start'], 86400);
|
||||
if ($day >= 0 && $day < $range['days']) {
|
||||
$buckets[$day]++;
|
||||
}
|
||||
}
|
||||
|
||||
$series = [];
|
||||
foreach ($buckets as $day => $count) {
|
||||
$series[] = ['label' => $dayFmt->format($range['start'] + $day * 86400), 'count' => $count];
|
||||
}
|
||||
|
||||
return $series;
|
||||
}
|
||||
|
||||
/**
|
||||
* سری درآمد به تفکیک ۱۲ ماه یک سال شمسی (نمودار «میزان درآمد»).
|
||||
* @return array<int, array{label:string, amount_rials:int}>
|
||||
*/
|
||||
private function revenueByJalaliYear(string $entityType, int $entityId, int $year): array
|
||||
{
|
||||
$fmt = new \IntlDateFormatter(
|
||||
'fa_IR@calendar=persian',
|
||||
\IntlDateFormatter::NONE,
|
||||
\IntlDateFormatter::NONE,
|
||||
'Asia/Tehran',
|
||||
\IntlDateFormatter::TRADITIONAL,
|
||||
'MMMM'
|
||||
);
|
||||
|
||||
$series = [];
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
$range = $this->jalaliMonthRange($year, $m);
|
||||
$series[] = [
|
||||
'label' => $fmt->format($range['start']),
|
||||
'amount_rials' => (int) $this->patientSessionRepo->sumRevenue($entityType, $entityId, $range['start'], $range['end']),
|
||||
];
|
||||
}
|
||||
|
||||
return $series;
|
||||
}
|
||||
|
||||
/**
|
||||
* پارامترهای بازهی نمودارها از query string، با fallback به دورهی جاری شمسی.
|
||||
* @return array{patients_year:int, patients_month:int, revenue_year:int}
|
||||
*/
|
||||
private function chartPeriodParams(Request $request): array
|
||||
{
|
||||
[$curYear, $curMonth] = $this->currentJalaliYearMonth();
|
||||
|
||||
$patientsYear = (int) ($request->query->get('patients_year') ?: $curYear);
|
||||
$patientsMonth = (int) ($request->query->get('patients_month') ?: $curMonth);
|
||||
$revenueYear = (int) ($request->query->get('revenue_year') ?: $curYear);
|
||||
|
||||
return [
|
||||
'patients_year' => max(1300, min(1500, $patientsYear)),
|
||||
'patients_month' => max(1, min(12, $patientsMonth)),
|
||||
'revenue_year' => max(1300, min(1500, $revenueYear)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* سری ۷ روز اخیر درآمد (بر اساس PatientSession) + پرداختی امروز و هفته.
|
||||
* @return array{revenue: array<int, array{label:string, amount_rials:int}>, today_payments_rials:int, week_payments_rials:int}
|
||||
*/
|
||||
private function revenueDaily(string $entityType, int $entityId): array
|
||||
{
|
||||
$fmt = new \IntlDateFormatter('fa_IR@calendar=persian', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'Asia/Tehran', \IntlDateFormatter::TRADITIONAL, 'd MMMM');
|
||||
$series = [];
|
||||
$todayPay = 0;
|
||||
$weekPay = 0;
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$ds = strtotime('today midnight') - $i * 86400;
|
||||
$de = $ds + 86399;
|
||||
$rev = (int) $this->patientSessionRepo->sumRevenue($entityType, $entityId, $ds, $de);
|
||||
$series[] = ['label' => $fmt->format($ds), 'amount_rials' => $rev];
|
||||
$weekPay += $rev;
|
||||
if ($i === 0) { $todayPay = $rev; }
|
||||
}
|
||||
return ['revenue' => $series, 'today_payments_rials' => $todayPay, 'week_payments_rials' => $weekPay];
|
||||
}
|
||||
|
||||
// ── Secretary Dashboard ──────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/dashboard/secretary', methods: ['GET'])]
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace App\Discount\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Discount\Entity\DiscountRule;
|
||||
use App\Discount\Repository\DiscountRuleRepository;
|
||||
use App\Discount\Service\DiscountEngine;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class DiscountController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DiscountRuleRepository $ruleRepo,
|
||||
private readonly DiscountEngine $engine,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly PatientSessionRepository $sessionRepo,
|
||||
) {}
|
||||
|
||||
/** @return array{0: string, 1: ?int} */
|
||||
private function resolveOwner(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
return ['doctor', $this->doctorRepo->findByUser($user)?->getId()];
|
||||
}
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
return ['clinic', $this->clinicRepo->findByUser($user)?->getId()];
|
||||
}
|
||||
return ['unknown', null];
|
||||
}
|
||||
|
||||
// ── Admin CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/admin/discount-rules', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function list(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$ownerType, $ownerId] = $this->resolveOwner($user);
|
||||
if ($ownerId === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
$items = array_map(fn(DiscountRule $r) => $r->toArray(), $this->ruleRepo->findAllForOwner($ownerType, $ownerId));
|
||||
return $this->success(['data' => $items]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/discount-rules', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$ownerType, $ownerId] = $this->resolveOwner($user);
|
||||
if ($ownerId === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$name = trim((string) ($data['name'] ?? ''));
|
||||
$type = (string) ($data['type'] ?? '');
|
||||
|
||||
if ($name === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام قانون الزامی است', 422, 'name');
|
||||
}
|
||||
if (!in_array($type, DiscountRule::TYPES, true)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع قانون نامعتبر است', 422, 'type');
|
||||
}
|
||||
|
||||
$rule = new DiscountRule($ownerType, $ownerId, $name, $type);
|
||||
$this->applyPayload($rule, $data);
|
||||
$this->ruleRepo->save($rule);
|
||||
|
||||
return $this->success(['data' => $rule->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/discount-rules/{uuid}', methods: ['PATCH'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$ownerType, $ownerId] = $this->resolveOwner($user);
|
||||
if ($ownerId === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
$rule = $this->ruleRepo->findByUuidForOwner($uuid, $ownerType, $ownerId);
|
||||
if ($rule === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'قانون یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (array_key_exists('name', $data)) {
|
||||
$name = trim((string) $data['name']);
|
||||
if ($name === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام قانون الزامی است', 422, 'name');
|
||||
}
|
||||
$rule->setName($name);
|
||||
}
|
||||
if (array_key_exists('type', $data)) {
|
||||
if (!in_array((string) $data['type'], DiscountRule::TYPES, true)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع قانون نامعتبر است', 422, 'type');
|
||||
}
|
||||
$rule->setType((string) $data['type']);
|
||||
}
|
||||
$this->applyPayload($rule, $data);
|
||||
$this->ruleRepo->save($rule);
|
||||
|
||||
return $this->success(['data' => $rule->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/discount-rules/{uuid}', methods: ['DELETE'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function delete(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$ownerType, $ownerId] = $this->resolveOwner($user);
|
||||
if ($ownerId === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
$rule = $this->ruleRepo->findByUuidForOwner($uuid, $ownerType, $ownerId);
|
||||
if ($rule === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'قانون یافت نشد', 404);
|
||||
}
|
||||
$this->ruleRepo->remove($rule);
|
||||
return $this->success(['data' => ['deleted' => true]]);
|
||||
}
|
||||
|
||||
// ── Suggestions for a session ─────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/session/{uuid}/discount-suggestions', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function suggestions(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$ownerType, $ownerId] = $this->resolveOwner($user);
|
||||
if ($ownerId === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
$session = $this->sessionRepo->findByUuid($uuid);
|
||||
if ($session === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پرونده یافت نشد', 404);
|
||||
}
|
||||
$record = $session->getRecord();
|
||||
if ($record->getEntityType() !== $ownerType || $record->getEntityId() !== $ownerId) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
return $this->success(['data' => $this->engine->evaluate($session)]);
|
||||
}
|
||||
|
||||
private function applyPayload(DiscountRule $rule, array $data): void
|
||||
{
|
||||
if (array_key_exists('discount_type', $data)) {
|
||||
$dt = (string) $data['discount_type'];
|
||||
$rule->setDiscountType($dt === DiscountRule::DISCOUNT_FIXED ? DiscountRule::DISCOUNT_FIXED : DiscountRule::DISCOUNT_PERCENT);
|
||||
}
|
||||
if (array_key_exists('value', $data)) { $rule->setValue((int) $data['value']); }
|
||||
if (array_key_exists('priority', $data)) { $rule->setPriority((int) $data['priority']); }
|
||||
if (array_key_exists('combinable', $data)) { $rule->setCombinable((bool) $data['combinable']); }
|
||||
if (array_key_exists('active', $data)) { $rule->setActive((bool) $data['active']); }
|
||||
if (array_key_exists('valid_from', $data)) { $rule->setValidFrom($data['valid_from'] !== null ? (int) $data['valid_from'] : null); }
|
||||
if (array_key_exists('valid_to', $data)) { $rule->setValidTo($data['valid_to'] !== null ? (int) $data['valid_to'] : null); }
|
||||
if (array_key_exists('target_tag_uuid', $data)) { $rule->setTargetTagUuid($data['target_tag_uuid'] !== null ? (string) $data['target_tag_uuid'] : null); }
|
||||
if (array_key_exists('target_record_uuid', $data)) { $rule->setTargetRecordUuid($data['target_record_uuid'] !== null ? (string) $data['target_record_uuid'] : null); }
|
||||
if (array_key_exists('target_service_item_uuid', $data)) { $rule->setTargetServiceItemUuid($data['target_service_item_uuid'] !== null ? (string) $data['target_service_item_uuid'] : null); }
|
||||
if (array_key_exists('min_amount_rials', $data)) { $rule->setMinAmountRials($data['min_amount_rials'] !== null ? (int) $data['min_amount_rials'] : null); }
|
||||
if (array_key_exists('min_visit_count', $data)) { $rule->setMinVisitCount($data['min_visit_count'] !== null ? (int) $data['min_visit_count'] : null); }
|
||||
if (array_key_exists('occasion_kind', $data)) { $rule->setOccasionKind($data['occasion_kind'] !== null ? (string) $data['occasion_kind'] : null); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace App\Discount\Entity;
|
||||
|
||||
use App\Discount\Repository\DiscountRuleRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* قانون تخفیف عمومی، per-tenant (owner = doctor|clinic). موتور تخفیف
|
||||
* (DiscountEngine) این قوانین را برای یک پرونده ارزیابی میکند.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DiscountRuleRepository::class)]
|
||||
#[ORM\Table(name: 'discount_rules')]
|
||||
#[ORM\Index(columns: ['owner_type', 'owner_id', 'active'], name: 'idx_discount_rules_owner')]
|
||||
class DiscountRule
|
||||
{
|
||||
public const TYPE_PATIENT_TAG = 'patient_tag';
|
||||
public const TYPE_INVOICE_AMOUNT = 'invoice_amount';
|
||||
public const TYPE_SPECIFIC_PATIENT = 'specific_patient';
|
||||
public const TYPE_OCCASION = 'occasion';
|
||||
public const TYPE_SERVICE = 'service';
|
||||
public const TYPE_VISIT_COUNT = 'visit_count';
|
||||
|
||||
public const TYPES = [
|
||||
self::TYPE_PATIENT_TAG,
|
||||
self::TYPE_INVOICE_AMOUNT,
|
||||
self::TYPE_SPECIFIC_PATIENT,
|
||||
self::TYPE_OCCASION,
|
||||
self::TYPE_SERVICE,
|
||||
self::TYPE_VISIT_COUNT,
|
||||
];
|
||||
|
||||
public const DISCOUNT_PERCENT = 'percent';
|
||||
public const DISCOUNT_FIXED = 'fixed';
|
||||
|
||||
/** زیرنوع مناسبت: birthday | null (بازهی تاریخی) */
|
||||
public const OCCASION_BIRTHDAY = 'birthday';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(name: 'owner_type', type: 'string', length: 10)]
|
||||
private string $ownerType;
|
||||
|
||||
#[ORM\Column(name: 'owner_id', type: 'integer')]
|
||||
private int $ownerId;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 120)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
private string $type;
|
||||
|
||||
#[ORM\Column(name: 'discount_type', type: 'string', length: 10)]
|
||||
private string $discountType = self::DISCOUNT_PERCENT;
|
||||
|
||||
/** مقدار خام: درصد (۰..۱۰۰) یا ریال، بسته به discountType */
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $value = 0;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $priority = 0;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
private bool $combinable = false;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => true])]
|
||||
private bool $active = true;
|
||||
|
||||
#[ORM\Column(name: 'valid_from', type: 'integer', nullable: true)]
|
||||
private ?int $validFrom = null;
|
||||
|
||||
#[ORM\Column(name: 'valid_to', type: 'integer', nullable: true)]
|
||||
private ?int $validTo = null;
|
||||
|
||||
// ── target fields (بسته به type فقط یکی معنیدار است؛ uuid برای سازگاری با UI) ──
|
||||
#[ORM\Column(name: 'target_tag_uuid', type: 'string', length: 36, nullable: true)]
|
||||
private ?string $targetTagUuid = null;
|
||||
|
||||
#[ORM\Column(name: 'target_record_uuid', type: 'string', length: 36, nullable: true)]
|
||||
private ?string $targetRecordUuid = null;
|
||||
|
||||
#[ORM\Column(name: 'target_service_item_uuid', type: 'string', length: 36, nullable: true)]
|
||||
private ?string $targetServiceItemUuid = null;
|
||||
|
||||
#[ORM\Column(name: 'min_amount_rials', type: 'integer', nullable: true)]
|
||||
private ?int $minAmountRials = null;
|
||||
|
||||
#[ORM\Column(name: 'min_visit_count', type: 'integer', nullable: true)]
|
||||
private ?int $minVisitCount = null;
|
||||
|
||||
#[ORM\Column(name: 'occasion_kind', type: 'string', length: 20, nullable: true)]
|
||||
private ?string $occasionKind = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $ownerType, int $ownerId, string $name, string $type)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->ownerType = $ownerType;
|
||||
$this->ownerId = $ownerId;
|
||||
$this->name = $name;
|
||||
$this->type = $type;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getOwnerType(): string { return $this->ownerType; }
|
||||
public function getOwnerId(): int { return $this->ownerId; }
|
||||
public function getName(): string { return $this->name; }
|
||||
public function getType(): string { return $this->type; }
|
||||
public function getDiscountType(): string { return $this->discountType; }
|
||||
public function getValue(): int { return $this->value; }
|
||||
public function getPriority(): int { return $this->priority; }
|
||||
public function isCombinable(): bool { return $this->combinable; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getValidFrom(): ?int { return $this->validFrom; }
|
||||
public function getValidTo(): ?int { return $this->validTo; }
|
||||
public function getTargetTagUuid(): ?string { return $this->targetTagUuid; }
|
||||
public function getTargetRecordUuid(): ?string { return $this->targetRecordUuid; }
|
||||
public function getTargetServiceItemUuid(): ?string { return $this->targetServiceItemUuid; }
|
||||
public function getMinAmountRials(): ?int { return $this->minAmountRials; }
|
||||
public function getMinVisitCount(): ?int { return $this->minVisitCount; }
|
||||
public function getOccasionKind(): ?string { return $this->occasionKind; }
|
||||
|
||||
public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
|
||||
public function setType(string $v): self { $this->type = $v; $this->touch(); return $this; }
|
||||
public function setDiscountType(string $v): self { $this->discountType = $v; $this->touch(); return $this; }
|
||||
public function setValue(int $v): self { $this->value = $v; $this->touch(); return $this; }
|
||||
public function setPriority(int $v): self { $this->priority = $v; $this->touch(); return $this; }
|
||||
public function setCombinable(bool $v): self { $this->combinable = $v; $this->touch(); return $this; }
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
public function setValidFrom(?int $v): self { $this->validFrom = $v; $this->touch(); return $this; }
|
||||
public function setValidTo(?int $v): self { $this->validTo = $v; $this->touch(); return $this; }
|
||||
public function setTargetTagUuid(?string $v): self { $this->targetTagUuid = $v; $this->touch(); return $this; }
|
||||
public function setTargetRecordUuid(?string $v): self { $this->targetRecordUuid = $v; $this->touch(); return $this; }
|
||||
public function setTargetServiceItemUuid(?string $v): self { $this->targetServiceItemUuid = $v; $this->touch(); return $this; }
|
||||
public function setMinAmountRials(?int $v): self { $this->minAmountRials = $v; $this->touch(); return $this; }
|
||||
public function setMinVisitCount(?int $v): self { $this->minVisitCount = $v; $this->touch(); return $this; }
|
||||
public function setOccasionKind(?string $v): self { $this->occasionKind = $v; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'type' => $this->type,
|
||||
'discount_type' => $this->discountType,
|
||||
'value' => $this->value,
|
||||
'priority' => $this->priority,
|
||||
'combinable' => $this->combinable,
|
||||
'active' => $this->active,
|
||||
'valid_from' => $this->validFrom,
|
||||
'valid_to' => $this->validTo,
|
||||
'target_tag_uuid' => $this->targetTagUuid,
|
||||
'target_record_uuid' => $this->targetRecordUuid,
|
||||
'target_service_item_uuid' => $this->targetServiceItemUuid,
|
||||
'min_amount_rials' => $this->minAmountRials,
|
||||
'min_visit_count' => $this->minVisitCount,
|
||||
'occasion_kind' => $this->occasionKind,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Discount\Repository;
|
||||
|
||||
use App\Discount\Entity\DiscountRule;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class DiscountRuleRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry) { parent::__construct($registry, DiscountRule::class); }
|
||||
|
||||
public function save(DiscountRule $e, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($e);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(DiscountRule $e, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($e);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function findByUuidForOwner(string $uuid, string $ownerType, int $ownerId): ?DiscountRule
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid, 'ownerType' => $ownerType, 'ownerId' => $ownerId]);
|
||||
}
|
||||
|
||||
/** @return DiscountRule[] قوانین فعالِ یک owner (برای موتور). */
|
||||
public function findActiveForOwner(string $ownerType, int $ownerId): array
|
||||
{
|
||||
return $this->findBy(['ownerType' => $ownerType, 'ownerId' => $ownerId, 'active' => true], ['priority' => 'DESC']);
|
||||
}
|
||||
|
||||
/**
|
||||
* لیست کامل قوانین یک owner (شامل غیرفعالها) برای ادمین، مرتب بر priority.
|
||||
* موجودیتها برمیگردند تا کنترلر با toArray() قرارداد snake_case فرانت را بدهد.
|
||||
* @return DiscountRule[]
|
||||
*/
|
||||
public function findAllForOwner(string $ownerType, int $ownerId): array
|
||||
{
|
||||
return $this->createQueryBuilder('r')
|
||||
->where('r.ownerType = :t')->setParameter('t', $ownerType)
|
||||
->andWhere('r.ownerId = :i')->setParameter('i', $ownerId)
|
||||
->orderBy('r.priority', 'DESC')->addOrderBy('r.id', 'DESC')
|
||||
->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Discount\Service;
|
||||
|
||||
use App\Discount\Entity\DiscountRule;
|
||||
use App\Discount\Repository\DiscountRuleRepository;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Patient\Entity\SessionService;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
use App\UserProfile\Repository\UserProfileRepository;
|
||||
|
||||
/**
|
||||
* موتور تخفیف: برای یک پرونده، قوانین قابلاعمالِ owner را ارزیابی و مبلغ تخفیف
|
||||
* هرکدام را محاسبه میکند. اعمال نهایی در PatientService::applyDiscount انجام میشود.
|
||||
*/
|
||||
class DiscountEngine
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DiscountRuleRepository $ruleRepo,
|
||||
private readonly PatientSessionRepository $sessionRepo,
|
||||
private readonly UserProfileRepository $profileRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, array{rule_uuid: string, rule_name: string, type: string, discount_type: string, value: int, discount_rials: int, combinable: bool, priority: int}>
|
||||
* مرتب بر priority نزولی (از repository). فقط قوانینِ برقرار با discount_rials > 0.
|
||||
*/
|
||||
public function evaluate(PatientSession $session): array
|
||||
{
|
||||
$record = $session->getRecord();
|
||||
$rules = $this->ruleRepo->findActiveForOwner($record->getEntityType(), $record->getEntityId());
|
||||
|
||||
$final = $session->getFinalPriceRials();
|
||||
$remaining = max(0, $final - $session->getPaidTotalRials());
|
||||
$now = time();
|
||||
|
||||
$out = [];
|
||||
foreach ($rules as $rule) {
|
||||
$rials = $this->computeForRule($session, $rule, $now, $remaining);
|
||||
if ($rials <= 0) {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'rule_uuid' => $rule->getUuid(),
|
||||
'rule_name' => $rule->getName(),
|
||||
'type' => $rule->getType(),
|
||||
'discount_type' => $rule->getDiscountType(),
|
||||
'value' => $rule->getValue(),
|
||||
'discount_rials' => $rials,
|
||||
'combinable' => $rule->isCombinable(),
|
||||
'priority' => $rule->getPriority(),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* مبلغ تخفیف ریالیِ یک قانون برای یک پرونده؛ 0 اگر برقرار نباشد.
|
||||
* $remaining را ندهی، از خود session محاسبه میشود.
|
||||
*/
|
||||
public function computeForRule(PatientSession $session, DiscountRule $rule, ?int $now = null, ?int $remaining = null): int
|
||||
{
|
||||
$now ??= time();
|
||||
$remaining ??= max(0, $session->getFinalPriceRials() - $session->getPaidTotalRials());
|
||||
if (!$this->withinValidity($rule, $now)) {
|
||||
return 0;
|
||||
}
|
||||
$base = $this->applicableBase($rule, $session, $session->getFinalPriceRials(), $now);
|
||||
if ($base === null) {
|
||||
return 0;
|
||||
}
|
||||
return min($this->computeRials($rule, $base), $remaining);
|
||||
}
|
||||
|
||||
private function withinValidity(DiscountRule $rule, int $now): bool
|
||||
{
|
||||
if ($rule->getValidFrom() !== null && $now < $rule->getValidFrom()) {
|
||||
return false;
|
||||
}
|
||||
if ($rule->getValidTo() !== null && $now > $rule->getValidTo()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* مبنای محاسبهی تخفیف اگر قانون برقرار باشد؛ null یعنی قانون اعمال نمیشود.
|
||||
*/
|
||||
private function applicableBase(DiscountRule $rule, PatientSession $session, int $final, int $now): ?int
|
||||
{
|
||||
$record = $session->getRecord();
|
||||
|
||||
return match ($rule->getType()) {
|
||||
DiscountRule::TYPE_PATIENT_TAG => $this->hasTag($session, $rule->getTargetTagUuid()) ? $final : null,
|
||||
|
||||
DiscountRule::TYPE_INVOICE_AMOUNT => ($rule->getMinAmountRials() !== null && $final >= $rule->getMinAmountRials()) ? $final : null,
|
||||
|
||||
DiscountRule::TYPE_SPECIFIC_PATIENT => ($rule->getTargetRecordUuid() !== null && $record->getUuid() === $rule->getTargetRecordUuid()) ? $final : null,
|
||||
|
||||
DiscountRule::TYPE_OCCASION => $this->occasionMatches($rule, $session, $now) ? $final : null,
|
||||
|
||||
DiscountRule::TYPE_SERVICE => $this->serviceBase($session, $rule->getTargetServiceItemUuid()),
|
||||
|
||||
DiscountRule::TYPE_VISIT_COUNT => ($rule->getMinVisitCount() !== null
|
||||
&& $this->sessionRepo->countByRecord($record) >= $rule->getMinVisitCount()) ? $final : null,
|
||||
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function hasTag(PatientSession $session, ?string $tagUuid): bool
|
||||
{
|
||||
if ($tagUuid === null) {
|
||||
return false;
|
||||
}
|
||||
foreach ($session->getRecord()->getTags() as $tag) {
|
||||
if ($tag->getUuid() === $tagUuid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** مبنای تخفیف سرویس = جمع خطوطِ همان سرویس؛ null اگر سرویس در پرونده نباشد. */
|
||||
private function serviceBase(PatientSession $session, ?string $serviceItemUuid): ?int
|
||||
{
|
||||
if ($serviceItemUuid === null) {
|
||||
return null;
|
||||
}
|
||||
$sum = 0;
|
||||
foreach ($session->getServices() as $line) {
|
||||
/** @var SessionService $line */
|
||||
if ($line->getServiceItem()->getUuid() === $serviceItemUuid) {
|
||||
$sum += $line->getLineTotalRials();
|
||||
}
|
||||
}
|
||||
return $sum > 0 ? $sum : null;
|
||||
}
|
||||
|
||||
private function occasionMatches(DiscountRule $rule, PatientSession $session, int $now): bool
|
||||
{
|
||||
if ($rule->getOccasionKind() !== DiscountRule::OCCASION_BIRTHDAY) {
|
||||
// مناسبت مبتنی بر بازهی تاریخی؛ withinValidity قبلاً چک شده.
|
||||
return true;
|
||||
}
|
||||
$profile = $this->profileRepo->findByUser($session->getRecord()->getUser());
|
||||
$dob = $profile?->getDateOfBirth();
|
||||
if ($dob === null) {
|
||||
return false;
|
||||
}
|
||||
return date('m-d', $dob) === date('m-d', $now);
|
||||
}
|
||||
|
||||
private function computeRials(DiscountRule $rule, int $base): int
|
||||
{
|
||||
if ($rule->getDiscountType() === DiscountRule::DISCOUNT_PERCENT) {
|
||||
return (int) round($base * min(100, $rule->getValue()) / 100);
|
||||
}
|
||||
return min($rule->getValue(), $base);
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,14 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* اصلاح یکبارمصرف نامِ پزشکانِ ایمپورتشدهٔ IRIMC که با پیشوند «دکتر» ذخیره شدهاند.
|
||||
* فقط source='irimc' را دست میزند؛ پزشکان manual/seed را تغییر نمیدهد.
|
||||
* حذف پیشوند «دکتر» از نام پزشکانی که با عنوان ذخیره شدهاند. نام پزشک هرگز نباید
|
||||
* عنوان داشته باشد؛ لایهٔ نمایش خودش تصمیم میگیرد چطور نشانش دهد.
|
||||
*
|
||||
* پیشفرض فقط source='irimc' است. `--all` هر منبعی (seed/manual) را هم پاک میکند —
|
||||
* لازم است چون مسیرهای ثبتنام تا پیش از این عنوان را حذف نمیکردند.
|
||||
*
|
||||
* php bin/console app:doctors:fix-irimc-names --dry-run
|
||||
* php bin/console app:doctors:fix-irimc-names
|
||||
* php bin/console app:doctors:fix-irimc-names --all
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:doctors:fix-irimc-names',
|
||||
@@ -33,19 +36,21 @@ class FixIrimcNamesCommand extends Command
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, change nothing');
|
||||
$this->addOption('all', null, InputOption::VALUE_NONE, 'Every doctor, not just source=irimc');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
$all = (bool) $input->getOption('all');
|
||||
|
||||
$qb = $this->em->getRepository(Doctor::class)->createQueryBuilder('d');
|
||||
if (!$all) {
|
||||
$qb->where('d.source = :src')->setParameter('src', 'irimc');
|
||||
}
|
||||
/** @var Doctor[] $doctors */
|
||||
$doctors = $this->em->getRepository(Doctor::class)->createQueryBuilder('d')
|
||||
->where('d.source = :src')
|
||||
->setParameter('src', 'irimc')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
$doctors = $qb->getQuery()->getResult();
|
||||
|
||||
$fixed = 0;
|
||||
foreach ($doctors as $doctor) {
|
||||
@@ -63,7 +68,13 @@ class FixIrimcNamesCommand extends Command
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d نام %s (از %d پزشک IRIMC).', $fixed, $dryRun ? 'قابل اصلاح' : 'اصلاح شد', count($doctors)));
|
||||
$io->success(sprintf(
|
||||
'%d نام %s (از %d پزشک %s).',
|
||||
$fixed,
|
||||
$dryRun ? 'قابل اصلاح' : 'اصلاح شد',
|
||||
count($doctors),
|
||||
$all ? 'بررسیشده' : 'IRIMC',
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use App\Shared\Util\PersianText;
|
||||
|
||||
#[OA\Tag(name: 'Doctors')]
|
||||
class DoctorController extends BaseController
|
||||
@@ -105,7 +106,7 @@ class DoctorController extends BaseController
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$name = trim($data['title'] ?? $data['name'] ?? '');
|
||||
$name = PersianText::stripDoctorTitle($data['title'] ?? $data['name'] ?? '');
|
||||
|
||||
if ($name === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام دکتر الزامی است', 422, 'title');
|
||||
@@ -123,8 +124,7 @@ class DoctorController extends BaseController
|
||||
$this->userRepo->save($user);
|
||||
}
|
||||
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
return $this->success(['data' => $doctor->toDetailArray($schedule)], 201);
|
||||
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))], 201);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
@@ -176,8 +176,8 @@ class DoctorController extends BaseController
|
||||
? ['id' => $rep->getId(), 'uuid' => $rep->getUuid(), 'full_name' => $rep->getFullName()]
|
||||
: null;
|
||||
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
return $this->success(['data' => array_merge($doctor->toDetailArray($schedule), [
|
||||
$schedules = $this->scheduleRepo->findAllByDoctor($doctor);
|
||||
return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), [
|
||||
'clinics' => $clinicData,
|
||||
'representation' => $representation,
|
||||
])]);
|
||||
@@ -201,8 +201,8 @@ class DoctorController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'این پزشک عضو کلینیک شما نیست', 403);
|
||||
}
|
||||
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
return $this->success(['data' => array_merge($doctor->toDetailArray($schedule), ['clinics' => [[
|
||||
$schedules = $this->scheduleRepo->findAllByDoctor($doctor);
|
||||
return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), ['clinics' => [[
|
||||
'id' => (string) $clinic->getId(),
|
||||
'uuid' => $clinic->getUuid(),
|
||||
'name' => $clinic->getName(),
|
||||
@@ -258,11 +258,19 @@ class DoctorController extends BaseController
|
||||
|
||||
$scheduleMap = [];
|
||||
foreach ($this->scheduleRepo->findByDoctors($result['items']) as $schedule) {
|
||||
$scheduleMap[$schedule->getDoctor()->getId()] = $schedule;
|
||||
$scheduleMap[$schedule->getDoctor()->getId()][] = $schedule;
|
||||
}
|
||||
|
||||
$locationMap = $this->doctorRepo->findLocationsByDoctors($result['items']);
|
||||
|
||||
return $this->paginated(
|
||||
array_map(fn(Doctor $d) => $d->toListArray($scheduleMap[$d->getId()] ?? null), $result['items']),
|
||||
array_map(
|
||||
fn(Doctor $d) => $d->toListArray(
|
||||
$scheduleMap[$d->getId()] ?? [],
|
||||
$locationMap[$d->getId()] ?? null
|
||||
),
|
||||
$result['items']
|
||||
),
|
||||
$result['total'],
|
||||
$result['page'],
|
||||
$result['limit']
|
||||
@@ -343,13 +351,12 @@ class DoctorController extends BaseController
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (!empty($data['title'])) $doctor->setName($data['title']);
|
||||
if (!empty($data['title'])) $doctor->setName(PersianText::stripDoctorTitle($data['title']));
|
||||
|
||||
$this->hydrateDoctor($doctor, $data);
|
||||
$this->doctorRepo->save($doctor);
|
||||
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
return $this->success(['data' => $doctor->toDetailArray($schedule)]);
|
||||
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))]);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Auth\Entity\User;
|
||||
use App\DoctorService\Entity\DoctorService;
|
||||
use App\Location\Entity\City;
|
||||
use App\Location\Entity\Province;
|
||||
use App\Shared\Util\DisplayName;
|
||||
use App\Specialty\Entity\Specialty;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -142,6 +143,8 @@ class Doctor
|
||||
|
||||
public function __construct(User $user, string $name)
|
||||
{
|
||||
DisplayName::assertReal($name);
|
||||
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->user = $user;
|
||||
$this->name = $name;
|
||||
@@ -273,6 +276,7 @@ class Doctor
|
||||
|
||||
public function setName(string $v): self
|
||||
{
|
||||
DisplayName::assertReal($v);
|
||||
$this->name = $v;
|
||||
return $this;
|
||||
}
|
||||
@@ -414,29 +418,52 @@ class Doctor
|
||||
|
||||
private const APPOINTMENT_DISABLED_LABEL = 'نوبتدهی آنلاین غیرفعال است';
|
||||
|
||||
private function computeScheduleFields(?WeeklySchedule $schedule): array
|
||||
/**
|
||||
* وضعیت نوبتدهی از دید سایت عمومی، تجمیعشده روی همهٔ برنامههای پزشک
|
||||
* (شخصی + هر کلینیک). برنامهٔ شخصیِ خاموش نباید برنامهٔ کلینیکیِ روشن را بپوشاند.
|
||||
*
|
||||
* @param WeeklySchedule[] $schedules
|
||||
*/
|
||||
private function computeScheduleFields(array $schedules): array
|
||||
{
|
||||
$parts = $this->computeScheduleParts($schedule);
|
||||
|
||||
// Online booking enabled flag lives in the weekly schedule meta.
|
||||
// When disabled, free_turn reflects that while hours_of_work is kept.
|
||||
if ($schedule !== null && !$schedule->getMeta()['online_booking_enabled']) {
|
||||
return [
|
||||
'free_turn' => self::APPOINTMENT_DISABLED_LABEL,
|
||||
'hours_of_work' => $parts['hours_of_work'],
|
||||
'has_schedule' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
private function computeScheduleParts(?WeeklySchedule $schedule): array
|
||||
{
|
||||
if ($schedule === null) {
|
||||
if ($schedules === []) {
|
||||
return ['free_turn' => 'نوبت آزادی موجود نیست', 'hours_of_work' => 'برنامه کاری تنظیم نشده', 'has_schedule' => false];
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
foreach ($schedules as $schedule) {
|
||||
if (!$schedule->getMeta()['online_booking_enabled']) {
|
||||
continue;
|
||||
}
|
||||
$parts = $this->computeScheduleParts($schedule);
|
||||
if ($parts['has_schedule']) {
|
||||
$candidates[] = $parts;
|
||||
}
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
$allDisabled = array_filter($schedules, fn(WeeklySchedule $s) => $s->getMeta()['online_booking_enabled']) === [];
|
||||
if ($allDisabled) {
|
||||
return [
|
||||
'free_turn' => self::APPOINTMENT_DISABLED_LABEL,
|
||||
'hours_of_work' => $this->computeScheduleParts($schedules[array_key_first($schedules)])['hours_of_work'],
|
||||
'has_schedule' => false,
|
||||
];
|
||||
}
|
||||
return ['free_turn' => 'نوبت آزادی موجود نیست', 'hours_of_work' => 'برنامه کاری تنظیم نشده', 'has_schedule' => false];
|
||||
}
|
||||
|
||||
// نزدیکترین نوبت بین همهٔ محلها؛ ساعت کاری همان محل نمایش داده میشود
|
||||
// تا ترکیب ساعتهای دو محل در یک رشته گمراهکننده نشود.
|
||||
usort($candidates, fn(array $a, array $b) => $a['rank'] <=> $b['rank']);
|
||||
$best = $candidates[0];
|
||||
unset($best['rank']);
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
private function computeScheduleParts(WeeklySchedule $schedule): array
|
||||
{
|
||||
$setting = $schedule->getSetting();
|
||||
|
||||
// استخراج ساعتهای هر روز — key: dayIdx، value: رشته ساعتها یا null
|
||||
@@ -453,7 +480,7 @@ class Doctor
|
||||
|
||||
$hasAnyDay = array_filter($dayTimes) !== [];
|
||||
if (!$hasAnyDay) {
|
||||
return ['free_turn' => 'نوبت آزادی موجود نیست', 'hours_of_work' => 'برنامه کاری تنظیم نشده', 'has_schedule' => false];
|
||||
return ['free_turn' => 'نوبت آزادی موجود نیست', 'hours_of_work' => 'برنامه کاری تنظیم نشده', 'has_schedule' => false, 'rank' => [7, '99:99']];
|
||||
}
|
||||
|
||||
// گروهبندی روزهای متوالی با ساعت یکسان
|
||||
@@ -485,11 +512,13 @@ class Doctor
|
||||
$iranDay = $phpDay === 0 ? 1 : ($phpDay === 6 ? 0 : $phpDay + 1);
|
||||
|
||||
$freeTurn = null;
|
||||
$rank = [7, '99:99'];
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
$idx = ($iranDay + $i) % 7;
|
||||
if ($dayTimes[$idx] !== null) {
|
||||
$firstTime = explode(' و ', $dayTimes[$idx])[0];
|
||||
$freeTurn = self::DAY_NAMES[$idx] . ' ' . $firstTime;
|
||||
$rank = [$i, explode('–', $firstTime)[0]];
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -498,6 +527,8 @@ class Doctor
|
||||
'free_turn' => $freeTurn ?? 'نوبت آزادی موجود نیست',
|
||||
'hours_of_work' => implode(' | ', $parts),
|
||||
'has_schedule' => true,
|
||||
// فاصله تا نزدیکترین روز کاری + ساعت شروع — برای مقایسهٔ بین برنامهها
|
||||
'rank' => $rank,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -509,9 +540,15 @@ class Doctor
|
||||
return max(0, (int)((time() - $this->activityTime) / (365.25 * 24 * 3600)));
|
||||
}
|
||||
|
||||
public function toListArray(?WeeklySchedule $schedule = null): array
|
||||
/** @param WeeklySchedule[] $schedules همهٔ برنامههای پزشک (شخصی + کلینیکها) */
|
||||
/**
|
||||
* @param array{city: ?array, province: ?array}|null $location
|
||||
* شهر/استان از DoctorRepository::findLocationsByDoctors — این Entity به آدرس
|
||||
* کلینیک دسترسی ندارد، پس مکان دستهای بیرون حل و تزریق میشود.
|
||||
*/
|
||||
public function toListArray(array $schedules = [], ?array $location = null): array
|
||||
{
|
||||
$sf = $this->computeScheduleFields($schedule);
|
||||
$sf = $this->computeScheduleFields($schedules);
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
@@ -530,12 +567,17 @@ class Doctor
|
||||
'hours_of_work' => $sf['hours_of_work'],
|
||||
'active' => $this->activeDoctorAppointment && $sf['has_schedule'],
|
||||
'owner_status' => $this->ownerStatus,
|
||||
// آرایه — همشکل با city/state در پاسخ جزئیات پزشک و پاسخ لیست کلینیکها.
|
||||
// پزشک بدون مکان آرایهٔ خالی میگیرد (نه null) تا مصرفکننده شرط یکسانی بنویسد.
|
||||
'city' => isset($location['city']) ? [$location['city']] : [],
|
||||
'state' => isset($location['province']) ? [$location['province']] : [],
|
||||
];
|
||||
}
|
||||
|
||||
public function toDetailArray(?WeeklySchedule $schedule = null): array
|
||||
/** @param WeeklySchedule[] $schedules همهٔ برنامههای پزشک (شخصی + کلینیکها) */
|
||||
public function toDetailArray(array $schedules = []): array
|
||||
{
|
||||
$sf = $this->computeScheduleFields($schedule);
|
||||
$sf = $this->computeScheduleFields($schedules);
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
|
||||
@@ -62,6 +62,32 @@ class DoctorAddressRepository extends ServiceEntityRepository
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* آدرسهای قابلانتخاب در یک context. مطب شخصی فقط آدرسهای شخصی خود پزشک را
|
||||
* میبیند و کلینیک فقط آدرسهای خودش — این دو هرگز union نمیشوند.
|
||||
*
|
||||
* @return DoctorAddress[]
|
||||
*/
|
||||
public function findForContext(Doctor $doctor, ?int $clinicId): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a');
|
||||
|
||||
if ($clinicId === null) {
|
||||
$qb->where('a.doctor = :doctor')
|
||||
->andWhere('a.type = :personal')
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('personal', DoctorAddress::TYPE_PERSONAL);
|
||||
} else {
|
||||
$qb->where('a.clinicId = :clinicId')
|
||||
->andWhere('a.type = :clinic')
|
||||
->setParameter('clinicId', $clinicId)
|
||||
->setParameter('clinic', DoctorAddress::TYPE_CLINIC);
|
||||
}
|
||||
|
||||
return $qb->orderBy('a.id', 'ASC')->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/** @deprecated آدرسهای دو محیط را union میکند؛ از findForContext() استفاده کن. */
|
||||
public function findAvailableForDoctor(Doctor $doctor, array $clinicIds): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a');
|
||||
|
||||
@@ -138,6 +138,88 @@ class DoctorRepository extends ServiceEntityRepository
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
/**
|
||||
* شهر/استان دستهای پزشکان برای پاسخ لیست — دو کوئری ثابت، نه یکی بهازای هر پزشک.
|
||||
*
|
||||
* همان قاعدهای که فیلتر city_id/state_id در findWithFilters اعمال میکند اینجا هم
|
||||
* برقرار است: اول آدرس شخصی خود پزشک، و اگر نداشت آدرس کلینیکی که عضو آن است
|
||||
* (آدرس کلینیک ردیفی از DoctorAddress با doctor IS NULL و clinicId پرشده است).
|
||||
* بدون این fallback، پزشکی که فقط از طریق کلینیک مکان دارد در فیلتر city_id
|
||||
* میآمد ولی در پاسخ شهرش خالی بود.
|
||||
*
|
||||
* @param Doctor[] $doctors
|
||||
* @return array<int, array{city: ?array, province: ?array}>
|
||||
*/
|
||||
public function findLocationsByDoctors(array $doctors): array
|
||||
{
|
||||
$ids = array_values(array_filter(array_map(fn(Doctor $d) => $d->getId(), $doctors)));
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$locationFields = [
|
||||
'c.id AS cityId', 'c.uuid AS cityUuid', 'c.name AS cityName',
|
||||
'p.id AS provinceId', 'p.uuid AS provinceUuid', 'p.name AS provinceName',
|
||||
];
|
||||
|
||||
$ownRows = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('IDENTITY(da.doctor) AS doctorId', ...$locationFields)
|
||||
->from(DoctorAddress::class, 'da')
|
||||
->join('da.city', 'c')
|
||||
->leftJoin('da.province', 'p')
|
||||
->where('da.doctor IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$map = $this->indexLocationRows($ownRows, []);
|
||||
|
||||
$missing = array_values(array_diff($ids, array_keys($map)));
|
||||
if ($missing) {
|
||||
$clinicRows = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('cd.id AS doctorId', ...$locationFields)
|
||||
->from(Clinic::class, 'cl')
|
||||
->join('cl.doctors', 'cd')
|
||||
->join(DoctorAddress::class, 'ca', Join::WITH, 'ca.clinicId = cl.id AND ca.doctor IS NULL')
|
||||
->join('ca.city', 'c')
|
||||
->leftJoin('ca.province', 'p')
|
||||
->where('cd.id IN (:ids)')
|
||||
->setParameter('ids', $missing)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$map = $this->indexLocationRows($clinicRows, $map);
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** اولین مکانِ هر پزشک برنده است — پزشک چند-مطبی یک شهر اصلی میگیرد. */
|
||||
private function indexLocationRows(array $rows, array $map): array
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
$doctorId = (int) $row['doctorId'];
|
||||
if (isset($map[$doctorId])) {
|
||||
continue;
|
||||
}
|
||||
$map[$doctorId] = [
|
||||
'city' => [
|
||||
'uuid' => $row['cityUuid'],
|
||||
'id' => (string) $row['cityId'],
|
||||
'name' => $row['cityName'],
|
||||
'parent' => $row['provinceId'] !== null ? (string) $row['provinceId'] : null,
|
||||
],
|
||||
'province' => $row['provinceId'] !== null ? [
|
||||
'uuid' => $row['provinceUuid'],
|
||||
'id' => (string) $row['provinceId'],
|
||||
'name' => $row['provinceName'],
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function doctorIdsViaClinicLocation(string $field, int $locationId): array
|
||||
{
|
||||
$column = $field === 'city' ? 'ca.city' : 'ca.province';
|
||||
|
||||
@@ -42,20 +42,51 @@ class InsuranceController extends BaseController
|
||||
private readonly TenantInsuranceService $tenantInsuranceService,
|
||||
private readonly ServiceItemRepository $serviceItemRepo,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
||||
private readonly \App\Patient\Security\PatientRecordScopeResolver $scopeResolver,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* وقتی doctor_uuid داده شود، قیمتگذاری همان پزشک هدف است — برای مدیریت پزشکان
|
||||
* کلینیک از پنل کلینیک. بدون آن، رفتار قبلی (موجودیتِ خودِ کاربر) حفظ میشود.
|
||||
*
|
||||
* @param 'view'|'update' $action
|
||||
* @return array{0: string, 1: int|null, 2: JsonResponse|null}
|
||||
*/
|
||||
private function resolveTargetEntity(User $user, ?string $doctorUuid, string $action): array
|
||||
{
|
||||
if ($doctorUuid === null || $doctorUuid === '') {
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
return [$type, $id, null];
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return ['unknown', null, $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404)];
|
||||
}
|
||||
|
||||
if ($user->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $user->getId()) {
|
||||
return [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), null];
|
||||
}
|
||||
|
||||
foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
|
||||
if ($this->permChecker->can($user, $clinic, 'services', $action)) {
|
||||
return [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), null];
|
||||
}
|
||||
}
|
||||
|
||||
return ['unknown', null, $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403)];
|
||||
}
|
||||
|
||||
/**
|
||||
* قرارداد بیمه به همان محیطی تعلق دارد که پروندهها در آن ثبت میشوند، پس همان
|
||||
* رزولوِر مبنا است: مالک کلینیکی که خودش پزشک هم هست باید قراردادهای کلینیکش را
|
||||
* ببیند، نه مطب شخصیاش.
|
||||
*/
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return $doctor !== null ? [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId()] : [EntityInsurancePricing::TYPE_DOCTOR, null];
|
||||
}
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
return $clinic !== null ? [EntityInsurancePricing::TYPE_CLINIC, $clinic->getId()] : [EntityInsurancePricing::TYPE_CLINIC, null];
|
||||
}
|
||||
return ['unknown', null];
|
||||
return $this->scopeResolver->resolve($user)->toLegacyTuple();
|
||||
}
|
||||
|
||||
// ── Public list ───────────────────────────────────────────────────────────
|
||||
@@ -219,20 +250,30 @@ class InsuranceController extends BaseController
|
||||
|
||||
#[Route('/api/v1/insurance-pricing', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function getInsurancePricing(#[CurrentUser] User $user): JsonResponse
|
||||
public function getInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
|
||||
if ($err !== null) {
|
||||
return $err;
|
||||
}
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
return $this->success($this->pricingPayload($entityType, $entityId));
|
||||
}
|
||||
|
||||
private function pricingPayload(string $entityType, int $entityId): array
|
||||
{
|
||||
$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();
|
||||
}
|
||||
@@ -247,27 +288,47 @@ class InsuranceController extends BaseController
|
||||
];
|
||||
}, $this->insuranceRepo->findActive(null));
|
||||
|
||||
return $this->success([
|
||||
return [
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'free_visit_price_rials' => $freeVisitPriceRials,
|
||||
'require_visit_price' => $requireVisitPrice,
|
||||
'insurances' => $insurances,
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
#[Route('/api/v1/insurance-pricing', methods: ['PUT'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function saveInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
|
||||
if ($err !== null) {
|
||||
return $err;
|
||||
}
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$freeVisitRow = $this->pricingRepo->findOneForInsurance($entityType, $entityId, null);
|
||||
|
||||
if (array_key_exists('free_visit_price_rials', $data)) {
|
||||
$this->upsertPricing($entityType, $entityId, null, (int) $data['free_visit_price_rials']);
|
||||
$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) {
|
||||
@@ -287,10 +348,10 @@ class InsuranceController extends BaseController
|
||||
|
||||
$this->pricingRepo->getEntityManager()->flush();
|
||||
|
||||
return $this->getInsurancePricing($user);
|
||||
return $this->success($this->pricingPayload($entityType, $entityId));
|
||||
}
|
||||
|
||||
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 +360,8 @@ class InsuranceController extends BaseController
|
||||
$row->setPatientShareRials($shareRials);
|
||||
}
|
||||
$this->pricingRepo->save($row, false);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ── TenantInsurance — قراردادهای بیمهی tenant ─────────────────────────────
|
||||
@@ -312,7 +375,7 @@ class InsuranceController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$contracts = $this->tenantInsuranceRepo->findActiveByTenant($entityType, $entityId);
|
||||
$contracts = $this->tenantInsuranceRepo->findLatestByTenant($entityType, $entityId);
|
||||
|
||||
$byId = [];
|
||||
foreach ($this->insuranceRepo->findActive(null) as $ins) {
|
||||
@@ -322,7 +385,8 @@ class InsuranceController extends BaseController
|
||||
$data = array_map(function (TenantInsurance $c) use ($byId) {
|
||||
$row = $c->toArray();
|
||||
$row['insurance_name'] = $byId[$c->getInsuranceId()]['name'] ?? null;
|
||||
$row['insurance_kind'] = $byId[$c->getInsuranceId()]['type'] ?? null;
|
||||
// Contract-level kind wins over the catalog type when the tenant categorised it.
|
||||
$row['insurance_kind'] = $c->getKind() ?? ($byId[$c->getInsuranceId()]['type'] ?? null);
|
||||
return $row;
|
||||
}, $contracts);
|
||||
|
||||
@@ -352,6 +416,9 @@ class InsuranceController extends BaseController
|
||||
(int) ($data['franchise_rials'] ?? 0),
|
||||
isset($data['annual_ceiling_rials']) && $data['annual_ceiling_rials'] !== null
|
||||
? (int) $data['annual_ceiling_rials'] : null,
|
||||
isset($data['effective_from']) && $data['effective_from'] !== null ? (int) $data['effective_from'] : null,
|
||||
isset($data['effective_to']) && $data['effective_to'] !== null ? (int) $data['effective_to'] : null,
|
||||
isset($data['kind']) && $data['kind'] !== '' ? (string) $data['kind'] : null,
|
||||
);
|
||||
|
||||
return $this->success(['data' => $contract->toArray()], 201);
|
||||
@@ -377,6 +444,20 @@ class InsuranceController extends BaseController
|
||||
if (array_key_exists('annual_ceiling_rials', $data)) {
|
||||
$contract->setAnnualCeilingRials($data['annual_ceiling_rials'] !== null ? (int) $data['annual_ceiling_rials'] : null);
|
||||
}
|
||||
if (array_key_exists('kind', $data)) {
|
||||
$contract->setKind($data['kind'] !== '' && $data['kind'] !== null ? (string) $data['kind'] : null);
|
||||
}
|
||||
if (array_key_exists('effective_from', $data) && $data['effective_from'] !== null) {
|
||||
$contract->setEffectiveFrom((int) $data['effective_from']);
|
||||
}
|
||||
if (array_key_exists('effective_to', $data)) {
|
||||
$contract->setEffectiveTo($data['effective_to'] !== null ? (int) $data['effective_to'] : null);
|
||||
}
|
||||
// Status toggle (فعال/غیرفعال) is set here directly so it does not clobber the
|
||||
// user-chosen effective_to the way the DELETE/deactivate path does.
|
||||
if (array_key_exists('is_active', $data)) {
|
||||
$contract->setActive((bool) $data['is_active']);
|
||||
}
|
||||
|
||||
$this->tenantInsuranceRepo->save($contract);
|
||||
|
||||
@@ -410,15 +491,10 @@ class InsuranceController extends BaseController
|
||||
|
||||
$rows = $this->serviceCoverageRepo->findByContract($contract->getId());
|
||||
|
||||
// Batch-fetch the referenced service items once instead of one find()
|
||||
// per coverage row (N+1).
|
||||
$itemIds = array_values(array_unique(array_map(fn($r) => $r->getServiceItemId(), $rows)));
|
||||
$uuidById = [];
|
||||
if ($itemIds !== []) {
|
||||
foreach ($this->serviceItemRepo->findBy(['id' => $itemIds]) as $item) {
|
||||
$uuidById[$item->getId()] = $item->getUuid();
|
||||
}
|
||||
}
|
||||
// یک کوئری اسکالر برای همهٔ uuidها. هیدریتکردن entity کافی نیست: رابطهٔ
|
||||
// EAGER staffMembers روی ServiceItem به ازای هر ردیف یک کوئری اضافه میزند.
|
||||
$itemIds = array_values(array_unique(array_map(fn($r) => $r->getServiceItemId(), $rows)));
|
||||
$uuidById = $this->serviceItemRepo->findUuidsByIds($itemIds);
|
||||
|
||||
$data = array_map(function ($r) use ($uuidById) {
|
||||
$row = $r->toArray();
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,10 @@ class TenantInsurance
|
||||
#[ORM\Column(name: 'annual_ceiling_rials', type: 'integer', nullable: true)]
|
||||
private ?int $annualCeilingRials = null;
|
||||
|
||||
/** Contract-level insurance kind ('basic'|'supplementary'); overrides the catalog type when set. */
|
||||
#[ORM\Column(name: 'kind', type: 'string', length: 20, nullable: true)]
|
||||
private ?string $kind = null;
|
||||
|
||||
#[ORM\Column(name: 'effective_from', type: 'integer')]
|
||||
private int $effectiveFrom;
|
||||
|
||||
@@ -81,6 +85,7 @@ class TenantInsurance
|
||||
public function getCoveragePercent(): float { return (float) $this->coveragePercent; }
|
||||
public function getFranchiseRials(): int { return $this->franchiseRials; }
|
||||
public function getAnnualCeilingRials(): ?int { return $this->annualCeilingRials; }
|
||||
public function getKind(): ?string { return $this->kind; }
|
||||
public function getEffectiveFrom(): int { return $this->effectiveFrom; }
|
||||
public function getEffectiveTo(): ?int { return $this->effectiveTo; }
|
||||
|
||||
@@ -88,6 +93,8 @@ class TenantInsurance
|
||||
public function setCoveragePercent(float $v): self { $this->coveragePercent = (string) $v; $this->updatedAt = time(); return $this; }
|
||||
public function setFranchiseRials(int $v): self { $this->franchiseRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setAnnualCeilingRials(?int $v): self { $this->annualCeilingRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setKind(?string $v): self { $this->kind = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setEffectiveFrom(int $v): self { $this->effectiveFrom = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setEffectiveTo(?int $v): self { $this->effectiveTo = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
@@ -102,6 +109,7 @@ class TenantInsurance
|
||||
'coverage_percent' => (float) $this->coveragePercent,
|
||||
'franchise_rials' => $this->franchiseRials,
|
||||
'annual_ceiling_rials' => $this->annualCeilingRials,
|
||||
'kind' => $this->kind,
|
||||
'effective_from' => $this->effectiveFrom,
|
||||
'effective_to' => $this->effectiveTo,
|
||||
];
|
||||
|
||||
@@ -27,6 +27,33 @@ class TenantInsuranceRepository extends ServiceEntityRepository
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest version of every insurance the tenant has a contract with, active or not.
|
||||
* The management UI shows one row per insurance with a فعال/غیرفعال toggle, so both
|
||||
* states must be returned; older versions are collapsed to the newest.
|
||||
*
|
||||
* @return TenantInsurance[]
|
||||
*/
|
||||
public function findLatestByTenant(string $entityType, int $entityId): array
|
||||
{
|
||||
$rows = $this->createQueryBuilder('t')
|
||||
->where('t.entityType = :type')
|
||||
->andWhere('t.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->orderBy('t.insuranceId', 'ASC')
|
||||
->addOrderBy('t.version', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$latest = [];
|
||||
foreach ($rows as $row) {
|
||||
$latest[$row->getInsuranceId()] ??= $row;
|
||||
}
|
||||
|
||||
return array_values($latest);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?TenantInsurance
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
|
||||
@@ -27,6 +27,17 @@ class TenantServiceCoverageRepository extends ServiceEntityRepository
|
||||
return $this->findBy(['tenantInsuranceId' => $tenantInsuranceId]);
|
||||
}
|
||||
|
||||
/** آیا این خدمت زیر هر قرارداد بیمهای پوشش فعال دارد؟ */
|
||||
public function hasActiveCoverage(int $serviceItemId): bool
|
||||
{
|
||||
return (int) $this->createQueryBuilder('c')
|
||||
->select('COUNT(c.id)')
|
||||
->where('c.serviceItemId = :item AND c.covered = true')
|
||||
->setParameter('item', $serviceItemId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult() > 0;
|
||||
}
|
||||
|
||||
public function save(TenantServiceCoverage $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
|
||||
@@ -31,8 +31,12 @@ class TenantInsuranceService
|
||||
float $coveragePercent,
|
||||
int $franchiseRials = 0,
|
||||
?int $annualCeilingRials = null,
|
||||
?int $effectiveFrom = null,
|
||||
?int $effectiveTo = null,
|
||||
?string $kind = null,
|
||||
): TenantInsurance {
|
||||
if ($this->insuranceRepo->find($insuranceId) === null) {
|
||||
$insurance = $this->insuranceRepo->find($insuranceId);
|
||||
if ($insurance === null) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمه یافت نشد', 404);
|
||||
}
|
||||
|
||||
@@ -45,8 +49,15 @@ class TenantInsuranceService
|
||||
$contract->setCoveragePercent($coveragePercent)
|
||||
->setFranchiseRials($franchiseRials)
|
||||
->setAnnualCeilingRials($annualCeilingRials)
|
||||
// kind defaults to the catalog type; caller may override to categorise the contract.
|
||||
->setKind($kind ?? $insurance->getType()->value)
|
||||
->setEffectiveTo($effectiveTo)
|
||||
->setActive(true);
|
||||
|
||||
if ($effectiveFrom !== null) {
|
||||
$contract->setEffectiveFrom($effectiveFrom);
|
||||
}
|
||||
|
||||
$this->repo->save($contract);
|
||||
|
||||
return $contract;
|
||||
@@ -151,5 +162,23 @@ class TenantInsuranceService
|
||||
->setCeilingRials($ceilingRials);
|
||||
|
||||
$this->coverageRepo->save($override);
|
||||
$this->syncServiceItemInsuranceFlag($serviceItemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* پرچم insurance_covered خدمت را با پوششهای واقعی همتراز میکند. پنل دیگر این
|
||||
* پرچم را دستی نمیگیرد؛ تنها منبع حقیقت، ردیفهای پوشش قراردادهای بیمه است.
|
||||
*/
|
||||
private function syncServiceItemInsuranceFlag(int $serviceItemId): void
|
||||
{
|
||||
$serviceItem = $this->serviceItemRepo->find($serviceItemId);
|
||||
if ($serviceItem === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$covered = $this->coverageRepo->hasActiveCoverage($serviceItemId);
|
||||
if ($serviceItem->isInsuranceCovered() !== $covered) {
|
||||
$this->serviceItemRepo->save($serviceItem->setInsuranceCovered($covered));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
namespace App\Inventory\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Inventory\Entity\InventoryItem;
|
||||
use App\Inventory\Entity\InventoryPackage;
|
||||
use App\Inventory\Repository\InventoryItemRepository;
|
||||
use App\Inventory\Repository\InventoryPackageRepository;
|
||||
use App\Inventory\Service\InventoryService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
/**
|
||||
* Per-tenant (doctor/clinic) inventory: consumable items and their packages.
|
||||
* Every row is scoped to the caller's resolved entity; a tenant can only see and
|
||||
* mutate its own inventory. Scoping mirrors {@see \App\Tag\Controller\TenantTagController}.
|
||||
*/
|
||||
#[OA\Tag(name: 'Inventory')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class InventoryController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InventoryItemRepository $itemRepo,
|
||||
private readonly InventoryPackageRepository $packageRepo,
|
||||
private readonly InventoryService $service,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
) {}
|
||||
|
||||
// ── Items ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/inventory-items', methods: ['GET'])]
|
||||
public function listItems(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$items = $this->itemRepo->findByEntity($type, $id);
|
||||
|
||||
return $this->success([
|
||||
'items' => array_map(fn(InventoryItem $i) => $i->toArray(), $items),
|
||||
'stats' => $this->service->stats($items),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/inventory-categories', methods: ['GET'])]
|
||||
public function listCategories(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
return $this->success($this->itemRepo->findCategories($type, $id));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/inventory-meta', methods: ['GET'])]
|
||||
public function meta(): JsonResponse
|
||||
{
|
||||
return $this->success([
|
||||
'units' => InventoryItem::UNITS,
|
||||
'categories' => InventoryItem::CATEGORIES,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/inventory-item', methods: ['POST'])]
|
||||
public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$name = trim($data['name'] ?? '');
|
||||
if ($name === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام کالا الزامی است', 422, 'name');
|
||||
}
|
||||
|
||||
$item = new InventoryItem($type, $id, $name);
|
||||
$this->applyItemFields($item, $data);
|
||||
$this->itemRepo->save($item);
|
||||
|
||||
return $this->success($item->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/inventory-item/{uuid}', methods: ['PATCH'])]
|
||||
public function updateItem(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$item = $this->ownedItem($uuid, $user);
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (array_key_exists('name', $data)) {
|
||||
$name = trim($data['name']);
|
||||
if ($name === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام کالا الزامی است', 422, 'name');
|
||||
}
|
||||
$item->setName($name);
|
||||
}
|
||||
$this->applyItemFields($item, $data);
|
||||
$this->itemRepo->save($item);
|
||||
|
||||
return $this->success($item->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/inventory-item/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$item = $this->ownedItem($uuid, $user);
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->itemRepo->remove($item);
|
||||
|
||||
return $this->success(['message' => 'کالا حذف شد']);
|
||||
}
|
||||
|
||||
// ── Packages ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/inventory-packages', methods: ['GET'])]
|
||||
public function listPackages(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
return $this->success(array_map(
|
||||
fn(InventoryPackage $p) => $this->service->packageToArray($p),
|
||||
$this->packageRepo->findByEntity($type, $id)
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/inventory-package', methods: ['POST'])]
|
||||
public function createPackage(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$title = trim($data['title'] ?? '');
|
||||
if ($title === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام پکیج الزامی است', 422, 'title');
|
||||
}
|
||||
|
||||
$package = new InventoryPackage($type, $id, $title);
|
||||
$this->service->syncPackageItems($package, $data['items'] ?? [], $type, $id);
|
||||
$this->packageRepo->save($package);
|
||||
|
||||
return $this->success($this->service->packageToArray($package), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/inventory-package/{uuid}', methods: ['PATCH'])]
|
||||
public function updatePackage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$package = $this->ownedPackage($uuid, $user);
|
||||
if ($package === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
|
||||
}
|
||||
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (array_key_exists('title', $data)) {
|
||||
$title = trim($data['title']);
|
||||
if ($title === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام پکیج الزامی است', 422, 'title');
|
||||
}
|
||||
$package->setTitle($title);
|
||||
}
|
||||
if (array_key_exists('items', $data)) {
|
||||
$this->service->syncPackageItems($package, $data['items'], $type, (int) $id);
|
||||
}
|
||||
$this->packageRepo->save($package);
|
||||
|
||||
return $this->success($this->service->packageToArray($package));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/inventory-package/{uuid}', methods: ['DELETE'])]
|
||||
public function deletePackage(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$package = $this->ownedPackage($uuid, $user);
|
||||
if ($package === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->packageRepo->remove($package);
|
||||
|
||||
return $this->success(['message' => 'پکیج حذف شد']);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Apply optional mutable item fields present in the payload. */
|
||||
private function applyItemFields(InventoryItem $item, array $data): void
|
||||
{
|
||||
if (array_key_exists('consumable', $data)) {
|
||||
$c = trim((string) $data['consumable']);
|
||||
$item->setConsumable($c === '' ? null : $c);
|
||||
}
|
||||
if (array_key_exists('unit', $data)) {
|
||||
$unit = trim((string) $data['unit']);
|
||||
if ($unit !== '' && !in_array($unit, InventoryItem::UNITS, true)) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'واحد نامعتبر است', 422, 'unit');
|
||||
}
|
||||
$item->setUnit($unit === '' ? InventoryItem::DEFAULT_UNIT : $unit);
|
||||
}
|
||||
if (array_key_exists('category', $data)) {
|
||||
$category = trim((string) $data['category']);
|
||||
if ($category !== '' && !in_array($category, InventoryItem::CATEGORIES, true)) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'دستهبندی نامعتبر است', 422, 'category');
|
||||
}
|
||||
$item->setCategory($category === '' ? null : $category);
|
||||
}
|
||||
if (array_key_exists('price', $data)) {
|
||||
$item->setPrice((int) $data['price']);
|
||||
}
|
||||
if (array_key_exists('stock', $data)) {
|
||||
$item->setStock((int) $data['stock']);
|
||||
}
|
||||
if (array_key_exists('alertThreshold', $data)) {
|
||||
$item->setAlertThreshold((int) $data['alertThreshold']);
|
||||
}
|
||||
}
|
||||
|
||||
/** The item only if it belongs to the caller's entity, else null. */
|
||||
private function ownedItem(string $uuid, User $user): ?InventoryItem
|
||||
{
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
if ($item === null || $id === null || $item->getEntityType() !== $type || $item->getEntityId() !== $id) {
|
||||
return null;
|
||||
}
|
||||
return $item;
|
||||
}
|
||||
|
||||
/** The package only if it belongs to the caller's entity, else null. */
|
||||
private function ownedPackage(string $uuid, User $user): ?InventoryPackage
|
||||
{
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
$package = $this->packageRepo->findByUuid($uuid);
|
||||
if ($package === null || $id === null || $package->getEntityType() !== $type || $package->getEntityId() !== $id) {
|
||||
return null;
|
||||
}
|
||||
return $package;
|
||||
}
|
||||
|
||||
/** @return array{0: string, 1: int|null} [entityType, entityId] */
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return ['doctor', $doctor?->getId()];
|
||||
}
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
return ['clinic', $clinic?->getId()];
|
||||
}
|
||||
if ($user->hasRole('ROLE_SECRETARY')) {
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid !== null) {
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
return ['clinic', $clinic->getId()];
|
||||
}
|
||||
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($doctor !== null) {
|
||||
return ['doctor', $doctor->getId()];
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['unknown', null];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Inventory\Entity;
|
||||
|
||||
use App\Inventory\Repository\InventoryItemRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* A consumable stock item owned by a tenant (doctor/clinic). Scoped through the
|
||||
* polymorphic entity_type/entity_id pair, mirroring {@see \App\Tag\Entity\TenantTag}.
|
||||
*
|
||||
* Availability status is derived, never stored: an item with zero stock is
|
||||
* "out_of_stock", one at or below its alert threshold is "low_stock", otherwise
|
||||
* "in_stock".
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: InventoryItemRepository::class)]
|
||||
#[ORM\Table(name: 'inventory_items')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_inventory_items_owner')]
|
||||
class InventoryItem
|
||||
{
|
||||
public const STATUS_IN_STOCK = 'in_stock';
|
||||
public const STATUS_LOW_STOCK = 'low_stock';
|
||||
public const STATUS_OUT_OF_STOCK = 'out_of_stock';
|
||||
|
||||
public const DEFAULT_UNIT = 'عدد';
|
||||
|
||||
/**
|
||||
* Allowed measurement units for a stock item. Backend is the single source of
|
||||
* truth (served via GET /api/v1/inventory-meta); the admin never hardcodes these.
|
||||
* Extend by appending — values are plain Persian strings stored as-is, so no
|
||||
* migration is needed. Most-used units are listed first for the picker.
|
||||
*/
|
||||
public const UNITS = [
|
||||
'عدد', 'بسته', 'جعبه', 'قوطی', 'جفت', 'دست',
|
||||
'ویال', 'آمپول', 'قرص', 'کپسول', 'ورق (بلیستر)', 'ساشه', 'تیوب',
|
||||
'سیسی', 'میلیلیتر', 'لیتر', 'میلیگرم', 'گرم', 'کیلوگرم',
|
||||
'رول', 'متر', 'سانتیمتر', 'کیسه',
|
||||
];
|
||||
|
||||
/**
|
||||
* Allowed inventory categories for a clinic/office. Same contract as {@see self::UNITS}:
|
||||
* backend-owned, plain Persian strings, extend by appending (no migration).
|
||||
*/
|
||||
public const CATEGORIES = [
|
||||
'دارو',
|
||||
'لوازم مصرفی و تزریقات',
|
||||
'لوازم پانسمان و بخیه',
|
||||
'مواد ضدعفونی و استریلیزاسیون',
|
||||
'تجهیزات پزشکی',
|
||||
'بیهوشی و بیحسی',
|
||||
'لوازم زیبایی و پوست',
|
||||
'لوازم آزمایشگاهی',
|
||||
'لوازم دندانپزشکی',
|
||||
'ملزومات اداری و مصرفی دفتری',
|
||||
'سایر',
|
||||
];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(name: 'entity_type', type: 'string', length: 20)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 120)]
|
||||
private string $name;
|
||||
|
||||
/** Free-text "مصرفی" note carried over from the source modal (kept for compatibility). */
|
||||
#[ORM\Column(type: 'string', length: 120, nullable: true)]
|
||||
private ?string $consumable = null;
|
||||
|
||||
/** Standard category from {@see self::CATEGORIES}; primary grouping/filter dimension. */
|
||||
#[ORM\Column(type: 'string', length: 60, nullable: true)]
|
||||
private ?string $category = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 30)]
|
||||
private string $unit = self::DEFAULT_UNIT;
|
||||
|
||||
/** Unit price in Rial (integer), consistent with the rest of ClinicPro. */
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $price = 0;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $stock = 0;
|
||||
|
||||
/** At or below this stock level the item is flagged "low". */
|
||||
#[ORM\Column(name: 'alert_threshold', type: 'integer')]
|
||||
private int $alertThreshold = 0;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $entityType, int $entityId, string $name)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->name = $name;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getEntityType(): string { return $this->entityType; }
|
||||
public function getEntityId(): int { return $this->entityId; }
|
||||
public function getName(): string { return $this->name; }
|
||||
public function getConsumable(): ?string { return $this->consumable; }
|
||||
public function getCategory(): ?string { return $this->category; }
|
||||
public function getUnit(): string { return $this->unit; }
|
||||
public function getPrice(): int { return $this->price; }
|
||||
public function getStock(): int { return $this->stock; }
|
||||
public function getAlertThreshold(): int { return $this->alertThreshold; }
|
||||
|
||||
public function setName(string $v): self { $this->name = $v; return $this->touch(); }
|
||||
public function setConsumable(?string $v): self { $this->consumable = $v; return $this->touch(); }
|
||||
public function setCategory(?string $v): self { $this->category = $v; return $this->touch(); }
|
||||
public function setUnit(string $v): self { $this->unit = $v; return $this->touch(); }
|
||||
public function setPrice(int $v): self { $this->price = max(0, $v); return $this->touch(); }
|
||||
public function setStock(int $v): self { $this->stock = max(0, $v); return $this->touch(); }
|
||||
public function setAlertThreshold(int $v): self { $this->alertThreshold = max(0, $v); return $this->touch(); }
|
||||
|
||||
/** Derived availability — see class docblock. */
|
||||
public function getStatus(): string
|
||||
{
|
||||
if ($this->stock <= 0) {
|
||||
return self::STATUS_OUT_OF_STOCK;
|
||||
}
|
||||
if ($this->stock <= $this->alertThreshold) {
|
||||
return self::STATUS_LOW_STOCK;
|
||||
}
|
||||
return self::STATUS_IN_STOCK;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'consumable' => $this->consumable,
|
||||
'category' => $this->category,
|
||||
'unit' => $this->unit,
|
||||
'price' => $this->price,
|
||||
'stock' => $this->stock,
|
||||
'alertThreshold' => $this->alertThreshold,
|
||||
'status' => $this->getStatus(),
|
||||
];
|
||||
}
|
||||
|
||||
private function touch(): self
|
||||
{
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Inventory\Entity;
|
||||
|
||||
use App\Inventory\Repository\InventoryPackageRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* A named bundle of consumable items owned by a tenant (doctor/clinic). The
|
||||
* package price and its availability are derived from its component items at
|
||||
* read time — never stored — so they always reflect current item prices/stock.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: InventoryPackageRepository::class)]
|
||||
#[ORM\Table(name: 'inventory_packages')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_inventory_packages_owner')]
|
||||
class InventoryPackage
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(name: 'entity_type', type: 'string', length: 20)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 120)]
|
||||
private string $title;
|
||||
|
||||
/** @var Collection<int, InventoryPackageItem> */
|
||||
#[ORM\OneToMany(mappedBy: 'package', targetEntity: InventoryPackageItem::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $items;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $entityType, int $entityId, string $title)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->title = $title;
|
||||
$this->items = new ArrayCollection();
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getEntityType(): string { return $this->entityType; }
|
||||
public function getEntityId(): int { return $this->entityId; }
|
||||
public function getTitle(): string { return $this->title; }
|
||||
|
||||
public function setTitle(string $v): self { $this->title = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
/** @return Collection<int, InventoryPackageItem> */
|
||||
public function getItems(): Collection { return $this->items; }
|
||||
|
||||
public function addItem(InventoryPackageItem $item): self
|
||||
{
|
||||
if (!$this->items->contains($item)) {
|
||||
$this->items->add($item);
|
||||
$item->setPackage($this);
|
||||
}
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** Drop every component item (used before re-populating on update). */
|
||||
public function clearItems(): self
|
||||
{
|
||||
$this->items->clear();
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Inventory\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* A line in an {@see InventoryPackage}: a reference to an {@see InventoryItem}
|
||||
* plus the quantity of it the package contains.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'inventory_package_items')]
|
||||
class InventoryPackageItem
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: InventoryPackage::class, inversedBy: 'items')]
|
||||
#[ORM\JoinColumn(name: 'package_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private InventoryPackage $package;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: InventoryItem::class)]
|
||||
#[ORM\JoinColumn(name: 'item_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private InventoryItem $item;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $amount = 1;
|
||||
|
||||
public function __construct(InventoryItem $item, int $amount)
|
||||
{
|
||||
$this->item = $item;
|
||||
$this->amount = max(1, $amount);
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getPackage(): InventoryPackage { return $this->package; }
|
||||
public function getItem(): InventoryItem { return $this->item; }
|
||||
public function getAmount(): int { return $this->amount; }
|
||||
|
||||
public function setPackage(InventoryPackage $p): self { $this->package = $p; return $this; }
|
||||
public function setAmount(int $v): self { $this->amount = max(1, $v); return $this; }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Inventory\Repository;
|
||||
|
||||
use App\Inventory\Entity\InventoryItem;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class InventoryItemRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, InventoryItem::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?InventoryItem
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** @return InventoryItem[] */
|
||||
public function findByEntity(string $entityType, int $entityId): array
|
||||
{
|
||||
return $this->createQueryBuilder('i')
|
||||
->where('i.entityType = :type AND i.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->orderBy('i.name', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct non-empty category values actually in use by the tenant — powers the
|
||||
* category filter dropdown on the inventory page.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function findCategories(string $entityType, int $entityId): array
|
||||
{
|
||||
$rows = $this->createQueryBuilder('i')
|
||||
->select('DISTINCT i.category AS category')
|
||||
->where('i.entityType = :type AND i.entityId = :id AND i.category IS NOT NULL AND i.category != :empty')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->setParameter('empty', '')
|
||||
->orderBy('i.category', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
return array_map(static fn(array $r): string => $r['category'], $rows);
|
||||
}
|
||||
|
||||
public function save(InventoryItem $item): void
|
||||
{
|
||||
$this->getEntityManager()->persist($item);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(InventoryItem $item): void
|
||||
{
|
||||
$this->getEntityManager()->remove($item);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Inventory\Repository;
|
||||
|
||||
use App\Inventory\Entity\InventoryPackage;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class InventoryPackageRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, InventoryPackage::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?InventoryPackage
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** @return InventoryPackage[] */
|
||||
public function findByEntity(string $entityType, int $entityId): array
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->leftJoin('p.items', 'pi')->addSelect('pi')
|
||||
->leftJoin('pi.item', 'it')->addSelect('it')
|
||||
->where('p.entityType = :type AND p.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->orderBy('p.createdAt', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* پکیجها را با یک کوئری برمیگرداند (کلید = id) تا سریالایز کردن فهرست سرویسها
|
||||
* به find()-per-row نیفتد.
|
||||
*
|
||||
* @param int[] $ids
|
||||
* @return array<int, InventoryPackage>
|
||||
*/
|
||||
public function findMapByIds(array $ids): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter($ids)));
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($this->findBy(['id' => $ids]) as $package) {
|
||||
$map[$package->getId()] = $package;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
public function save(InventoryPackage $package): void
|
||||
{
|
||||
$this->getEntityManager()->persist($package);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(InventoryPackage $package): void
|
||||
{
|
||||
$this->getEntityManager()->remove($package);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Inventory\Service;
|
||||
|
||||
use App\Inventory\Entity\InventoryItem;
|
||||
use App\Inventory\Entity\InventoryPackage;
|
||||
use App\Inventory\Entity\InventoryPackageItem;
|
||||
use App\Inventory\Repository\InventoryItemRepository;
|
||||
use App\Inventory\Repository\InventoryPackageRepository;
|
||||
|
||||
/**
|
||||
* Inventory domain logic: derived aggregates (stat counters, package totals and
|
||||
* availability) and package assembly from item references. Controllers stay thin
|
||||
* and delegate every non-HTTP decision here.
|
||||
*/
|
||||
class InventoryService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InventoryItemRepository $itemRepo,
|
||||
private readonly InventoryPackageRepository $packageRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The four headline counters shown as stat cards, derived from item statuses.
|
||||
*
|
||||
* @param InventoryItem[] $items
|
||||
* @return array{total:int, low:int, inStock:int, outOfStock:int}
|
||||
*/
|
||||
public function stats(array $items): array
|
||||
{
|
||||
$low = $inStock = $out = 0;
|
||||
foreach ($items as $item) {
|
||||
match ($item->getStatus()) {
|
||||
InventoryItem::STATUS_LOW_STOCK => $low++,
|
||||
InventoryItem::STATUS_IN_STOCK => $inStock++,
|
||||
InventoryItem::STATUS_OUT_OF_STOCK => $out++,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => count($items),
|
||||
'low' => $low,
|
||||
'inStock' => $inStock,
|
||||
'outOfStock' => $out,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a package with its component items, derived total price (Rial)
|
||||
* and availability (true only if every component has enough stock).
|
||||
*/
|
||||
public function packageToArray(InventoryPackage $package): array
|
||||
{
|
||||
$items = [];
|
||||
$total = 0;
|
||||
$available = true;
|
||||
|
||||
foreach ($package->getItems() as $line) {
|
||||
/** @var InventoryPackageItem $line */
|
||||
$item = $line->getItem();
|
||||
$amount = $line->getAmount();
|
||||
$total += $item->getPrice() * $amount;
|
||||
|
||||
if ($item->getStock() < $amount) {
|
||||
$available = false;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'itemUuid' => $item->getUuid(),
|
||||
'name' => $item->getName(),
|
||||
'unit' => $item->getUnit(),
|
||||
'price' => $item->getPrice(),
|
||||
'amount' => $amount,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'uuid' => $package->getUuid(),
|
||||
'title' => $package->getTitle(),
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'available' => $available,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a package's component lines from a list of {itemUuid, amount}.
|
||||
* Silently skips references the tenant does not own. Returns the number of
|
||||
* lines actually attached.
|
||||
*
|
||||
* @param array<int, array{itemUuid?:string, amount?:int|string}> $lines
|
||||
*/
|
||||
public function syncPackageItems(InventoryPackage $package, array $lines, string $entityType, int $entityId): int
|
||||
{
|
||||
$package->clearItems();
|
||||
$count = 0;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$uuid = trim((string) ($line['itemUuid'] ?? ''));
|
||||
if ($uuid === '') {
|
||||
continue;
|
||||
}
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
// Only attach items the caller owns — never leak another tenant's stock.
|
||||
if ($item === null || $item->getEntityType() !== $entityType || $item->getEntityId() !== $entityId) {
|
||||
continue;
|
||||
}
|
||||
$amount = (int) ($line['amount'] ?? 1);
|
||||
$package->addItem(new InventoryPackageItem($item, $amount));
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Patient\Repository\PatientAttachmentRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/** A file attached to a patient record (the «ضمیمه» tab). */
|
||||
#[ORM\Entity(repositoryClass: PatientAttachmentRepository::class)]
|
||||
#[ORM\Table(name: 'patient_attachments')]
|
||||
#[ORM\Index(columns: ['record_id'], name: 'idx_patient_attachments_record')]
|
||||
class PatientAttachment
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientRecord::class)]
|
||||
#[ORM\JoinColumn(name: 'record_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private PatientRecord $record;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 200)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 500)]
|
||||
private string $url;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
||||
private ?string $mime = null;
|
||||
|
||||
#[ORM\Column(type: 'integer', nullable: true)]
|
||||
private ?int $size = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(PatientRecord $record, string $name, string $url, ?string $mime = null, ?int $size = null)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->record = $record;
|
||||
$this->name = $name;
|
||||
$this->url = $url;
|
||||
$this->mime = $mime;
|
||||
$this->size = $size;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getRecord(): PatientRecord { return $this->record; }
|
||||
public function getUrl(): string { return $this->url; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'url' => $this->url,
|
||||
'mime' => $this->mime,
|
||||
'size' => $this->size,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Patient\Repository\PatientCallRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/** A logged phone call with a patient (the «کال سنتر» tab). */
|
||||
#[ORM\Entity(repositoryClass: PatientCallRepository::class)]
|
||||
#[ORM\Table(name: 'patient_calls')]
|
||||
#[ORM\Index(columns: ['record_id'], name: 'idx_patient_calls_record')]
|
||||
class PatientCall
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientRecord::class)]
|
||||
#[ORM\JoinColumn(name: 'record_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private PatientRecord $record;
|
||||
|
||||
/** موضوع تماس — short subject (e.g. «پیگیری نوبت»). */
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
private string $subject;
|
||||
|
||||
/** خلاصه تماس — free-text call summary. */
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $summary = null;
|
||||
|
||||
/** Call outcome: success | missed. */
|
||||
#[ORM\Column(type: 'string', length: 10)]
|
||||
private string $outcome = 'success';
|
||||
|
||||
/** When the call happened (Unix seconds); may differ from createdAt. */
|
||||
#[ORM\Column(name: 'called_at', type: 'integer')]
|
||||
private int $calledAt;
|
||||
|
||||
/** Display name of the staff member who logged the call. */
|
||||
#[ORM\Column(type: 'string', length: 120, nullable: true)]
|
||||
private ?string $personnel = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(PatientRecord $record, string $subject, string $outcome = 'success', ?int $calledAt = null)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->record = $record;
|
||||
$this->subject = $subject;
|
||||
$this->outcome = $outcome;
|
||||
$this->calledAt = $calledAt ?? time();
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getRecord(): PatientRecord { return $this->record; }
|
||||
|
||||
public function setSummary(?string $s): self { $this->summary = $s; return $this; }
|
||||
public function setPersonnel(?string $p): self { $this->personnel = $p; return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'subject' => $this->subject,
|
||||
'summary' => $this->summary,
|
||||
'outcome' => $this->outcome,
|
||||
'called_at' => $this->calledAt,
|
||||
'personnel' => $this->personnel,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Patient\Repository\PatientMedicalRecordRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/** A medical examination / note entry on a patient record (the «پرونده پزشکی» tab). */
|
||||
#[ORM\Entity(repositoryClass: PatientMedicalRecordRepository::class)]
|
||||
#[ORM\Table(name: 'patient_medical_records')]
|
||||
#[ORM\Index(columns: ['record_id'], name: 'idx_pmr_record')]
|
||||
class PatientMedicalRecord
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientRecord::class)]
|
||||
#[ORM\JoinColumn(name: 'record_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private PatientRecord $record;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 200)]
|
||||
private string $title;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $body = null;
|
||||
|
||||
/** Exam date (Unix ts); defaults to creation time. */
|
||||
#[ORM\Column(name: 'recorded_at', type: 'integer')]
|
||||
private int $recordedAt;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(PatientRecord $record, string $title, ?string $body = null, ?int $recordedAt = null)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->record = $record;
|
||||
$this->title = $title;
|
||||
$this->body = $body;
|
||||
$this->createdAt = time();
|
||||
$this->recordedAt = $recordedAt ?? $this->createdAt;
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getRecord(): PatientRecord { return $this->record; }
|
||||
|
||||
public function setTitle(string $v): self { $this->title = $v; return $this; }
|
||||
public function setBody(?string $v): self { $this->body = $v; return $this; }
|
||||
public function setRecordedAt(int $v): self { $this->recordedAt = $v; return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'title' => $this->title,
|
||||
'body' => $this->body,
|
||||
'recorded_at' => $this->recordedAt,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Patient\Repository\PatientMessageRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/** A logged message/communication with a patient (the «پیامها» tab). */
|
||||
#[ORM\Entity(repositoryClass: PatientMessageRepository::class)]
|
||||
#[ORM\Table(name: 'patient_messages')]
|
||||
#[ORM\Index(columns: ['record_id'], name: 'idx_patient_messages_record')]
|
||||
class PatientMessage
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientRecord::class)]
|
||||
#[ORM\JoinColumn(name: 'record_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private PatientRecord $record;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $body;
|
||||
|
||||
/** Channel: sms | note | call | email … */
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
private string $channel = 'sms';
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(PatientRecord $record, string $body, string $channel = 'sms')
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->record = $record;
|
||||
$this->body = $body;
|
||||
$this->channel = $channel;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getRecord(): PatientRecord { return $this->record; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'body' => $this->body,
|
||||
'channel' => $this->channel,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Patient\Repository\PatientNoteRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* A personal, pinnable staff note attached to a patient record (the «یادداشتها» tab).
|
||||
* Shared across the staff who own the record; the author's display name is denormalised
|
||||
* so the note survives the author being deleted.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: PatientNoteRepository::class)]
|
||||
#[ORM\Table(name: 'patient_notes')]
|
||||
#[ORM\Index(columns: ['record_id'], name: 'idx_patient_notes_record')]
|
||||
class PatientNote
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientRecord::class)]
|
||||
#[ORM\JoinColumn(name: 'record_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private PatientRecord $record;
|
||||
|
||||
/** متن یادداشت — free-text body. */
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $body;
|
||||
|
||||
/** یادداشتهای پینشده بالای لیست نشان داده میشوند. */
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $pinned = false;
|
||||
|
||||
/** The staff user who wrote the note; kept for possible audit, nulled if they are removed. */
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'created_by', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $createdBy = null;
|
||||
|
||||
/** Denormalised author display name (survives user deletion). */
|
||||
#[ORM\Column(name: 'author_name', type: 'string', length: 120, nullable: true)]
|
||||
private ?string $authorName = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer', nullable: true)]
|
||||
private ?int $updatedAt = null;
|
||||
|
||||
public function __construct(PatientRecord $record, string $body, bool $pinned = false)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->record = $record;
|
||||
$this->body = $body;
|
||||
$this->pinned = $pinned;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getRecord(): PatientRecord { return $this->record; }
|
||||
public function isPinned(): bool { return $this->pinned; }
|
||||
|
||||
public function setBody(string $body): self { $this->body = $body; $this->touch(); return $this; }
|
||||
public function setPinned(bool $pinned): self { $this->pinned = $pinned; $this->touch(); return $this; }
|
||||
|
||||
public function setAuthor(?User $user, ?string $name): self
|
||||
{
|
||||
$this->createdBy = $user;
|
||||
$this->authorName = $name;
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'body' => $this->body,
|
||||
'pinned' => $this->pinned,
|
||||
'author' => $this->authorName,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Patient\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Tag\Entity\TenantTag;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -41,6 +42,20 @@ class PatientRecord
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
// Clinic-scoped case-file number. Patient identity/demographics (gender,
|
||||
// date_of_birth, referral_source, description, insurance, …) live on the
|
||||
// patient's UserProfile and are set via PATCH /patient/{uuid}.
|
||||
#[ORM\Column(name: 'record_number', type: 'string', length: 40, nullable: true)]
|
||||
private ?string $recordNumber = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, TenantTag> record labels. EAGER so the typed
|
||||
* collection is always hydrated (see ServiceItem for the same pitfall).
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: TenantTag::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'patient_record_tags')]
|
||||
private Collection $tags;
|
||||
|
||||
#[ORM\OneToMany(targetEntity: PatientSession::class, mappedBy: 'record', cascade: ['remove'])]
|
||||
private Collection $sessions;
|
||||
|
||||
@@ -54,6 +69,7 @@ class PatientRecord
|
||||
$this->createdById = $createdById;
|
||||
$this->createdAt = time();
|
||||
$this->sessions = new ArrayCollection();
|
||||
$this->tags = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
@@ -65,6 +81,29 @@ class PatientRecord
|
||||
public function getCreatedById(): int { return $this->createdById; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
|
||||
public function getRecordNumber(): ?string { return $this->recordNumber; }
|
||||
public function setRecordNumber(?string $v): self { $this->recordNumber = $v; return $this; }
|
||||
|
||||
/** @return Collection<int, TenantTag> */
|
||||
public function getTags(): Collection
|
||||
{
|
||||
// Doctrine hydrates without the constructor; guard the typed property.
|
||||
return $this->tags ??= new ArrayCollection();
|
||||
}
|
||||
|
||||
/** @param TenantTag[] $tags */
|
||||
public function setTags(array $tags): self
|
||||
{
|
||||
$collection = $this->getTags();
|
||||
$collection->clear();
|
||||
foreach ($tags as $t) {
|
||||
if (!$collection->contains($t)) {
|
||||
$collection->add($t);
|
||||
}
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
@@ -75,6 +114,11 @@ class PatientRecord
|
||||
'user_name' => $this->user->getRealName(),
|
||||
'user_mobile' => $this->user->getMobileNumber(),
|
||||
'user_national_code' => $this->user->getNationalCode(),
|
||||
'record_number' => $this->recordNumber,
|
||||
'tags' => array_map(
|
||||
fn(TenantTag $t) => $t->toArray(),
|
||||
array_values($this->getTags()->toArray())
|
||||
),
|
||||
'created_by_type' => $this->createdByType,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Inventory\Entity\InventoryPackage;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -30,6 +31,15 @@ class PatientSession
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Appointment $appointment = null;
|
||||
|
||||
/** زمان پذیرش (unix)؛ اگر ست نشود همان زمان ثبت است */
|
||||
#[ORM\Column(name: 'session_at', type: 'integer', nullable: true)]
|
||||
private ?int $sessionAt = null;
|
||||
|
||||
/** پکیج مصرفی انتخابشده برای این مراجعه (اختیاری، فقط مرجع) */
|
||||
#[ORM\ManyToOne(targetEntity: InventoryPackage::class)]
|
||||
#[ORM\JoinColumn(name: 'inventory_package_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?InventoryPackage $inventoryPackage = null;
|
||||
|
||||
#[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)]
|
||||
private ?int $insuranceBaseId = null;
|
||||
|
||||
@@ -48,15 +58,64 @@ class PatientSession
|
||||
#[ORM\Column(name: 'services_total_rials', type: 'integer')]
|
||||
private int $servicesTotalRials = 0;
|
||||
|
||||
/**
|
||||
* تفکیک بیمهی این مراجعه، محاسبهشده توسط PatientService::calculateFinalPrice.
|
||||
* پایاست تا صفحهی پرداخت بدون صدور فاکتور هم سهمها را داشته باشد.
|
||||
* ثابت: gross = baseInsurance + supplementaryInsurance + patientShare
|
||||
*/
|
||||
#[ORM\Column(name: 'gross_total_rials', type: 'integer', options: ['default' => 0])]
|
||||
private int $grossTotalRials = 0;
|
||||
|
||||
#[ORM\Column(name: 'base_insurance_rials', type: 'integer', options: ['default' => 0])]
|
||||
private int $baseInsuranceRials = 0;
|
||||
|
||||
#[ORM\Column(name: 'supplementary_insurance_rials', type: 'integer', options: ['default' => 0])]
|
||||
private int $supplementaryInsuranceRials = 0;
|
||||
|
||||
#[ORM\Column(name: 'patient_share_rials', type: 'integer', options: ['default' => 0])]
|
||||
private int $patientShareRials = 0;
|
||||
|
||||
/** سهم بیمار پیش از تخفیف دستی — همیشه برابر patientShareRials */
|
||||
#[ORM\Column(name: 'final_price_rials', type: 'integer')]
|
||||
private int $finalPriceRials = 0;
|
||||
|
||||
#[ORM\Column(name: 'payment_method', type: 'string', length: 15)]
|
||||
private string $paymentMethod = 'pending';
|
||||
|
||||
/** نوع تخفیف تسویه: percent | fixed | null (بدون تخفیف) */
|
||||
#[ORM\Column(name: 'discount_type', type: 'string', length: 10, nullable: true)]
|
||||
private ?string $discountType = null;
|
||||
|
||||
/** مقدار خام تخفیف (درصد یا ریال، بسته به نوع) */
|
||||
#[ORM\Column(name: 'discount_value', type: 'integer')]
|
||||
private int $discountValue = 0;
|
||||
|
||||
/** مبلغ محاسبهشدهی تخفیف به ریال (سقف: مبلغ نهایی) */
|
||||
#[ORM\Column(name: 'discount_rials', type: 'integer')]
|
||||
private int $discountRials = 0;
|
||||
|
||||
/** منبع تخفیف: id قانون تخفیف اعمالشده (audit)؛ null اگر دستی یا بدون تخفیف */
|
||||
#[ORM\Column(name: 'applied_discount_rule_id', type: 'integer', nullable: true)]
|
||||
private ?int $appliedDiscountRuleId = null;
|
||||
|
||||
/** کشِ نام قانون اعمالشده برای نمایش/گزارش بدون join */
|
||||
#[ORM\Column(name: 'applied_discount_rule_label', type: 'string', length: 120, nullable: true)]
|
||||
private ?string $appliedDiscountRuleLabel = null;
|
||||
|
||||
/** زمان تسویهی کامل (unix)؛ تا قبل از صفر شدن بدهی null است */
|
||||
#[ORM\Column(name: 'paid_at', type: 'integer', nullable: true)]
|
||||
private ?int $paidAt = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $notes = null;
|
||||
|
||||
/** آرشیو نرم: مراجعهی اشتباه از لیست پیشفرض مخفی میشود ولی سابقه حفظ میگردد. */
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
private bool $archived = false;
|
||||
|
||||
#[ORM\Column(name: 'archived_at', type: 'integer', nullable: true)]
|
||||
private ?int $archivedAt = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
@@ -66,6 +125,12 @@ class PatientSession
|
||||
#[ORM\OneToMany(targetEntity: SessionService::class, mappedBy: 'session', cascade: ['remove'])]
|
||||
private Collection $services;
|
||||
|
||||
#[ORM\OneToMany(targetEntity: SessionPayment::class, mappedBy: 'session', cascade: ['remove'])]
|
||||
private Collection $payments;
|
||||
|
||||
#[ORM\OneToMany(targetEntity: SessionConsumable::class, mappedBy: 'session', cascade: ['remove'])]
|
||||
private Collection $consumables;
|
||||
|
||||
public function __construct(PatientRecord $record, ?Appointment $appointment = null)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
@@ -74,6 +139,8 @@ class PatientSession
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
$this->services = new ArrayCollection();
|
||||
$this->payments = new ArrayCollection();
|
||||
$this->consumables = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
@@ -95,8 +162,68 @@ class PatientSession
|
||||
public function getBaseInsuranceDiscountPercent(): float { return (float) $this->baseInsuranceDiscountPercent; }
|
||||
public function getSupplementaryDiscountPercent(): float { return (float) $this->supplementaryDiscountPercent; }
|
||||
public function getServicesTotalRials(): int { return $this->servicesTotalRials; }
|
||||
public function getGrossTotalRials(): int { return $this->grossTotalRials; }
|
||||
public function getBaseInsuranceRials(): int { return $this->baseInsuranceRials; }
|
||||
public function getSupplementaryInsuranceRials(): int { return $this->supplementaryInsuranceRials; }
|
||||
public function getPatientShareRials(): int { return $this->patientShareRials; }
|
||||
public function getFinalPriceRials(): int { return $this->finalPriceRials; }
|
||||
public function getPaymentMethod(): string { return $this->paymentMethod; }
|
||||
public function getDiscountType(): ?string { return $this->discountType; }
|
||||
public function getDiscountValue(): int { return $this->discountValue; }
|
||||
public function getDiscountRials(): int { return $this->discountRials; }
|
||||
public function getPaidAt(): ?int { return $this->paidAt; }
|
||||
public function getPayments(): Collection { return $this->payments; }
|
||||
|
||||
public function addPayment(SessionPayment $payment): self
|
||||
{
|
||||
if (!$this->payments->contains($payment)) {
|
||||
$this->payments->add($payment);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** مجموع پرداختهای ثبتشده روی این مراجعه (ریال) */
|
||||
public function getPaidTotalRials(): int
|
||||
{
|
||||
return array_sum(array_map(
|
||||
fn(SessionPayment $p) => $p->getAmountRials(),
|
||||
$this->payments->toArray(),
|
||||
));
|
||||
}
|
||||
|
||||
/** مبلغ قابل پرداخت بیمار: سهم بیمار پس از کسر تخفیف دستی */
|
||||
public function getPayableRials(): int
|
||||
{
|
||||
return max(0, $this->finalPriceRials - $this->discountRials);
|
||||
}
|
||||
|
||||
/** ماندهی بدهی پس از کسر تخفیف و پرداختها؛ هرگز منفی نمیشود */
|
||||
public function getRemainingRials(): int
|
||||
{
|
||||
return max(0, $this->getPayableRials() - $this->getPaidTotalRials());
|
||||
}
|
||||
|
||||
public function getSessionAt(): ?int { return $this->sessionAt; }
|
||||
public function getInventoryPackage(): ?InventoryPackage { return $this->inventoryPackage; }
|
||||
public function getConsumables(): Collection { return $this->consumables; }
|
||||
|
||||
public function addConsumable(SessionConsumable $consumable): self
|
||||
{
|
||||
if (!$this->consumables->contains($consumable)) {
|
||||
$this->consumables->add($consumable);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** مجموع قیمت کالاهای مصرفی این مراجعه (ریال) */
|
||||
public function getConsumablesTotalRials(): int
|
||||
{
|
||||
return array_sum(array_map(
|
||||
fn(SessionConsumable $c) => $c->getLineTotalRials(),
|
||||
$this->consumables->toArray(),
|
||||
));
|
||||
}
|
||||
|
||||
public function getNotes(): ?string { return $this->notes; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
@@ -107,9 +234,42 @@ class PatientSession
|
||||
public function setBaseInsuranceDiscountPercent(float $v): self { $this->baseInsuranceDiscountPercent = (string) $v; $this->updatedAt = time(); return $this; }
|
||||
public function setSupplementaryDiscountPercent(float $v): self { $this->supplementaryDiscountPercent = (string) $v; $this->updatedAt = time(); return $this; }
|
||||
public function setServicesTotalRials(int $v): self { $this->servicesTotalRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setFinalPriceRials(int $v): self { $this->finalPriceRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setFinalPriceRials(int $v): self { $this->finalPriceRials = $v; $this->patientShareRials = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
/**
|
||||
* تفکیک بیمه را یکجا مینشاند تا سهمها و مبلغ نهایی نتوانند ناسازگار شوند.
|
||||
* مبلغ نهایی همیشه سهم بیمار است؛ تخفیف دستی جدا و بعد از این اعمال میشود.
|
||||
*/
|
||||
public function applyShares(int $gross, int $baseInsurance, int $supplementaryInsurance, int $patientShare): self
|
||||
{
|
||||
$this->grossTotalRials = $gross;
|
||||
$this->baseInsuranceRials = $baseInsurance;
|
||||
$this->supplementaryInsuranceRials = $supplementaryInsurance;
|
||||
$this->patientShareRials = $patientShare;
|
||||
$this->finalPriceRials = $patientShare;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
public function setPaymentMethod(string $v): self { $this->paymentMethod = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setDiscount(?string $type, int $value, int $rials, ?int $ruleId = null, ?string $ruleLabel = null): self
|
||||
{
|
||||
$this->discountType = $type;
|
||||
$this->discountValue = $type === null ? 0 : $value;
|
||||
$this->discountRials = $type === null ? 0 : $rials;
|
||||
$this->appliedDiscountRuleId = $type === null ? null : $ruleId;
|
||||
$this->appliedDiscountRuleLabel = $type === null ? null : $ruleLabel;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
public function getAppliedDiscountRuleId(): ?int { return $this->appliedDiscountRuleId; }
|
||||
public function getAppliedDiscountRuleLabel(): ?string { return $this->appliedDiscountRuleLabel; }
|
||||
public function setSessionAt(?int $v): self { $this->sessionAt = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setInventoryPackage(?InventoryPackage $v): self { $this->inventoryPackage = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setPaidAt(?int $v): self { $this->paidAt = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setNotes(?string $v): self { $this->notes = $v; $this->updatedAt = time(); return $this; }
|
||||
public function isArchived(): bool { return $this->archived; }
|
||||
public function setArchived(bool $v): self { $this->archived = $v; $this->archivedAt = $v ? time() : null; $this->updatedAt = time(); return $this; }
|
||||
public function getArchivedAt(): ?int { return $this->archivedAt; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
@@ -125,9 +285,40 @@ class PatientSession
|
||||
'base_insurance_discount_percent' => (float) $this->baseInsuranceDiscountPercent,
|
||||
'supplementary_discount_percent' => (float) $this->supplementaryDiscountPercent,
|
||||
'services_total_rials' => $this->servicesTotalRials,
|
||||
'gross_total_rials' => $this->grossTotalRials,
|
||||
'base_insurance_rials' => $this->baseInsuranceRials,
|
||||
'supplementary_insurance_rials' => $this->supplementaryInsuranceRials,
|
||||
'patient_share_rials' => $this->patientShareRials,
|
||||
'final_price_rials' => $this->finalPriceRials,
|
||||
'remaining_rials' => $this->getRemainingRials(),
|
||||
'payment_method' => $this->paymentMethod,
|
||||
'is_paid' => $this->getRemainingRials() === 0,
|
||||
'discount_type' => $this->discountType,
|
||||
'discount_value' => $this->discountValue,
|
||||
'discount_rials' => $this->discountRials,
|
||||
'applied_discount_rule_id' => $this->appliedDiscountRuleId,
|
||||
'applied_discount_rule_label' => $this->appliedDiscountRuleLabel,
|
||||
'paid_at' => $this->paidAt,
|
||||
'paid_total_rials' => $this->getPaidTotalRials(),
|
||||
'payments' => array_map(
|
||||
fn(SessionPayment $p) => $p->toArray(),
|
||||
$this->payments->toArray()
|
||||
),
|
||||
'services' => array_map(
|
||||
fn(SessionService $s) => $s->toArray(),
|
||||
$this->services->toArray()
|
||||
),
|
||||
'session_at' => $this->sessionAt,
|
||||
'inventory_package_uuid' => $this->inventoryPackage?->getUuid(),
|
||||
'inventory_package_title' => $this->inventoryPackage?->getTitle(),
|
||||
'consumables' => array_map(
|
||||
fn(SessionConsumable $c) => $c->toArray(),
|
||||
$this->consumables->toArray()
|
||||
),
|
||||
'consumables_total_rials' => $this->getConsumablesTotalRials(),
|
||||
'notes' => $this->notes,
|
||||
'archived' => $this->archived,
|
||||
'archived_at' => $this->archivedAt,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Patient\Repository\SessionAuditLogRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* تاریخچهی تغییرات مالی/خدماتی یک مراجعه (Audit Log): چه کسی، چه چیزی، کِی و
|
||||
* مقدار قبل/بعد را تغییر داد. برای شفافیت و قابلیت پیگیری کامل پرونده.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: SessionAuditLogRepository::class)]
|
||||
#[ORM\Table(name: 'session_audit_logs')]
|
||||
#[ORM\Index(columns: ['session_id', 'created_at'], name: 'idx_session_audit_session')]
|
||||
class SessionAuditLog
|
||||
{
|
||||
public const OP_CREATE = 'create';
|
||||
public const OP_UPDATE = 'update';
|
||||
public const OP_DELETE = 'delete';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientSession::class)]
|
||||
#[ORM\JoinColumn(name: 'session_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private PatientSession $session;
|
||||
|
||||
/** فیلد تغییرکرده: visit_price_rials | services | consumables | payment | discount | ... */
|
||||
#[ORM\Column(type: 'string', length: 40)]
|
||||
private string $field;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 10)]
|
||||
private string $operation;
|
||||
|
||||
#[ORM\Column(name: 'old_value', type: 'text', nullable: true)]
|
||||
private ?string $oldValue = null;
|
||||
|
||||
#[ORM\Column(name: 'new_value', type: 'text', nullable: true)]
|
||||
private ?string $newValue = null;
|
||||
|
||||
#[ORM\Column(name: 'actor_user_id', type: 'integer', nullable: true)]
|
||||
private ?int $actorUserId = null;
|
||||
|
||||
#[ORM\Column(name: 'actor_name', type: 'string', length: 191, nullable: true)]
|
||||
private ?string $actorName = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 191, nullable: true)]
|
||||
private ?string $note = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(PatientSession $session, string $field, string $operation)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->session = $session;
|
||||
$this->field = $field;
|
||||
$this->operation = $operation;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function setActor(?int $userId, ?string $name): self { $this->actorUserId = $userId; $this->actorName = $name; return $this; }
|
||||
public function setValues(?string $old, ?string $new): self { $this->oldValue = $old; $this->newValue = $new; return $this; }
|
||||
public function setNote(?string $note): self { $this->note = $note; return $this; }
|
||||
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'field' => $this->field,
|
||||
'operation' => $this->operation,
|
||||
'old_value' => $this->oldValue,
|
||||
'new_value' => $this->newValue,
|
||||
'actor_name' => $this->actorName,
|
||||
'note' => $this->note,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Inventory\Entity\InventoryItem;
|
||||
use App\Patient\Repository\SessionConsumableRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* A consumable (inventory item) used during a patient session. Price is a
|
||||
* snapshot of the item's unit price at creation time, mirroring {@see SessionService}.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: SessionConsumableRepository::class)]
|
||||
#[ORM\Table(name: 'session_consumables')]
|
||||
class SessionConsumable
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientSession::class, inversedBy: 'consumables')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private PatientSession $session;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: InventoryItem::class)]
|
||||
#[ORM\JoinColumn(name: 'inventory_item_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private InventoryItem $item;
|
||||
|
||||
#[ORM\Column(name: 'price_rials', type: 'integer')]
|
||||
private int $priceRials;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $quantity = 1;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(PatientSession $session, InventoryItem $item, int $quantity = 1)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->session = $session;
|
||||
$this->item = $item;
|
||||
$this->priceRials = $item->getPrice();
|
||||
$this->quantity = max(1, $quantity);
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getSession(): PatientSession { return $this->session; }
|
||||
public function getItem(): InventoryItem { return $this->item; }
|
||||
public function getPriceRials(): int { return $this->priceRials; }
|
||||
public function getQuantity(): int { return $this->quantity; }
|
||||
public function getLineTotalRials(): int { return $this->priceRials * $this->quantity; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'inventory_item_uuid' => $this->item->getUuid(),
|
||||
'item_name' => $this->item->getName(),
|
||||
'unit' => $this->item->getUnit(),
|
||||
'price_rials' => $this->priceRials,
|
||||
'quantity' => $this->quantity,
|
||||
'line_total_rials' => $this->getLineTotalRials(),
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Patient\Repository\SessionPaymentRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* یک پرداختِ جزئی روی یک مراجعه (تسویهی چندتکه). مجموع پرداختها بهعلاوهی
|
||||
* تخفیف، بدهیِ مراجعه را صفر میکند.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: SessionPaymentRepository::class)]
|
||||
#[ORM\Table(name: 'session_payments')]
|
||||
class SessionPayment
|
||||
{
|
||||
public const METHODS = ['wallet', 'pos', 'cash', 'card'];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientSession::class, inversedBy: 'payments')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private PatientSession $session;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 15)]
|
||||
private string $method;
|
||||
|
||||
#[ORM\Column(name: 'amount_rials', type: 'integer')]
|
||||
private int $amountRials;
|
||||
|
||||
#[ORM\Column(name: 'paid_at', type: 'integer')]
|
||||
private int $paidAt;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'created_by_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $createdBy = null;
|
||||
|
||||
#[ORM\Column(name: 'created_by_name', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $createdByName = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(PatientSession $session, string $method, int $amountRials, ?int $paidAt = null)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->session = $session;
|
||||
$this->method = $method;
|
||||
$this->amountRials = $amountRials;
|
||||
$this->paidAt = $paidAt ?? time();
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getSession(): PatientSession { return $this->session; }
|
||||
public function getMethod(): string { return $this->method; }
|
||||
public function getAmountRials(): int { return $this->amountRials; }
|
||||
public function getPaidAt(): int { return $this->paidAt; }
|
||||
public function getCreatedBy(): ?User { return $this->createdBy; }
|
||||
public function getCreatedByName(): ?string { return $this->createdByName; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
|
||||
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
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'method' => $this->method,
|
||||
'amount_rials' => $this->amountRials,
|
||||
'paid_at' => $this->paidAt,
|
||||
'created_by_name' => $this->createdByName,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Patient\Entity\PatientAttachment;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class PatientAttachmentRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PatientAttachment::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?PatientAttachment
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** @return PatientAttachment[] */
|
||||
public function findByRecord(PatientRecord $record): array
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->where('a.record = :record')
|
||||
->setParameter('record', $record)
|
||||
->orderBy('a.id', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(PatientAttachment $a): void
|
||||
{
|
||||
$this->getEntityManager()->persist($a);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(PatientAttachment $a): void
|
||||
{
|
||||
$this->getEntityManager()->remove($a);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Patient\Entity\PatientCall;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class PatientCallRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PatientCall::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?PatientCall
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest-first call log for a record, optionally filtered by outcome.
|
||||
*
|
||||
* @return PatientCall[]
|
||||
*/
|
||||
public function findByRecord(PatientRecord $record, ?string $outcome = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->where('c.record = :record')
|
||||
->setParameter('record', $record)
|
||||
->orderBy('c.calledAt', 'DESC')
|
||||
->addOrderBy('c.id', 'DESC');
|
||||
|
||||
if ($outcome !== null) {
|
||||
$qb->andWhere('c.outcome = :outcome')->setParameter('outcome', $outcome);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function save(PatientCall $c): void
|
||||
{
|
||||
$this->getEntityManager()->persist($c);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(PatientCall $c): void
|
||||
{
|
||||
$this->getEntityManager()->remove($c);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Patient\Entity\PatientMedicalRecord;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class PatientMedicalRecordRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PatientMedicalRecord::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?PatientMedicalRecord
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** @return PatientMedicalRecord[] */
|
||||
public function findByRecord(PatientRecord $record): array
|
||||
{
|
||||
return $this->createQueryBuilder('m')
|
||||
->where('m.record = :record')
|
||||
->setParameter('record', $record)
|
||||
->orderBy('m.recordedAt', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(PatientMedicalRecord $m): void
|
||||
{
|
||||
$this->getEntityManager()->persist($m);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(PatientMedicalRecord $m): void
|
||||
{
|
||||
$this->getEntityManager()->remove($m);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Patient\Entity\PatientMessage;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class PatientMessageRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PatientMessage::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?PatientMessage
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** @return PatientMessage[] */
|
||||
public function findByRecord(PatientRecord $record): array
|
||||
{
|
||||
return $this->createQueryBuilder('m')
|
||||
->where('m.record = :record')
|
||||
->setParameter('record', $record)
|
||||
->orderBy('m.id', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(PatientMessage $m): void
|
||||
{
|
||||
$this->getEntityManager()->persist($m);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(PatientMessage $m): void
|
||||
{
|
||||
$this->getEntityManager()->remove($m);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Patient\Entity\PatientNote;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class PatientNoteRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PatientNote::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?PatientNote
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes for a record, pinned first then newest first.
|
||||
*
|
||||
* @return PatientNote[]
|
||||
*/
|
||||
public function findByRecord(PatientRecord $record): array
|
||||
{
|
||||
return $this->createQueryBuilder('n')
|
||||
->where('n.record = :record')
|
||||
->setParameter('record', $record)
|
||||
->orderBy('n.pinned', 'DESC')
|
||||
->addOrderBy('n.id', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(PatientNote $n): void
|
||||
{
|
||||
$this->getEntityManager()->persist($n);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(PatientNote $n): void
|
||||
{
|
||||
$this->getEntityManager()->remove($n);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -28,42 +28,157 @@ class PatientRecordRepository extends ServiceEntityRepository
|
||||
]);
|
||||
}
|
||||
|
||||
public function findByEntity(string $entityType, int $entityId, int $page = 1, int $limit = 20, ?string $search = null): array
|
||||
/**
|
||||
* @param array<string, mixed> $filters tags(string[] tenant-tag uuids), gender,
|
||||
* insurance_id, admitted_from/admitted_to (unix), service_status
|
||||
* (pending|completed), has_debt(bool)
|
||||
* @return list<PatientRecord>
|
||||
*/
|
||||
public function findByEntity(string $entityType, int $entityId, int $page = 1, int $limit = 20, ?string $search = null, array $filters = [], ?array $restrictToDoctorIds = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('r')
|
||||
->join('r.user', 'u')
|
||||
->where('r.entityType = :type')
|
||||
->andWhere('r.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->orderBy('r.id', 'DESC')
|
||||
$qb = $this->baseQuery($entityType, $entityId);
|
||||
$this->applyFilters($qb, $search, $filters);
|
||||
$this->applyDoctorRestriction($qb, $entityType, $entityId, $restrictToDoctorIds);
|
||||
|
||||
return $qb->orderBy('r.id', 'DESC')
|
||||
->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit);
|
||||
|
||||
if ($search !== null && $search !== '') {
|
||||
$qb->andWhere('u.realName LIKE :search OR u.mobileNumber LIKE :search OR u.nationalCode LIKE :search')
|
||||
->setParameter('search', '%' . $search . '%');
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function countByEntity(string $entityType, int $entityId, ?string $search = null): int
|
||||
/** @param array<string, mixed> $filters same shape as {@see findByEntity}. */
|
||||
public function countByEntity(string $entityType, int $entityId, ?string $search = null, array $filters = [], ?array $restrictToDoctorIds = null): int
|
||||
{
|
||||
$qb = $this->baseQuery($entityType, $entityId)->select('COUNT(r.id)');
|
||||
$this->applyFilters($qb, $search, $filters);
|
||||
$this->applyDoctorRestriction($qb, $entityType, $entityId, $restrictToDoctorIds);
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* آیا این پرونده در دسترسِ محدودشدهٔ این پزشک(ها) هست؟ همان قاعدهٔ لیست، برای یک
|
||||
* رکورد — تا detail و list هرگز از هم واگرا نشوند.
|
||||
*
|
||||
* @param int[]|null $restrictToDoctorIds
|
||||
*/
|
||||
public function isVisibleToDoctors(PatientRecord $record, ?array $restrictToDoctorIds): bool
|
||||
{
|
||||
if ($restrictToDoctorIds === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($restrictToDoctorIds === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$qb = $this->createQueryBuilder('r')
|
||||
->select('COUNT(r.id)')
|
||||
->where('r.id = :recordId')
|
||||
->setParameter('recordId', $record->getId());
|
||||
|
||||
$this->applyDoctorRestriction($qb, $record->getEntityType(), $record->getEntityId(), $restrictToDoctorIds);
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* پزشکِ عضو فقط بیمارانِ خودش را میبیند. پروندهٔ کلینیکی ستون پزشک ندارد
|
||||
* (یکتایی clinic+user)، پس رابطه از نوبتهای همان پزشک در همان کلینیک میآید —
|
||||
* نه از session، چون مراجعهٔ دستی اصلاً پزشک ثبتشده ندارد.
|
||||
*
|
||||
* @param int[]|null $restrictToDoctorIds
|
||||
*/
|
||||
private function applyDoctorRestriction(
|
||||
\Doctrine\ORM\QueryBuilder $qb,
|
||||
string $entityType,
|
||||
?int $entityId,
|
||||
?array $restrictToDoctorIds,
|
||||
): void {
|
||||
if ($restrictToDoctorIds === null || $entityType !== 'clinic') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($restrictToDoctorIds === []) {
|
||||
$qb->andWhere('1 = 0');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$qb->andWhere(
|
||||
$qb->expr()->exists(
|
||||
'SELECT 1 FROM App\Appointment\Entity\Appointment ra
|
||||
WHERE ra.user = r.user
|
||||
AND IDENTITY(ra.clinic) = :restrictClinicId
|
||||
AND IDENTITY(ra.doctor) IN (:restrictDoctorIds)'
|
||||
)
|
||||
)
|
||||
->setParameter('restrictClinicId', $entityId)
|
||||
->setParameter('restrictDoctorIds', $restrictToDoctorIds);
|
||||
}
|
||||
|
||||
private function baseQuery(string $entityType, int $entityId): \Doctrine\ORM\QueryBuilder
|
||||
{
|
||||
return $this->createQueryBuilder('r')
|
||||
->join('r.user', 'u')
|
||||
->where('r.entityType = :type')
|
||||
->andWhere('r.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared search + advanced filters for the patient records list, applied to
|
||||
* both the page query and its count so totals stay consistent.
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
private function applyFilters(\Doctrine\ORM\QueryBuilder $qb, ?string $search, array $filters): void
|
||||
{
|
||||
if ($search !== null && $search !== '') {
|
||||
$qb->andWhere('u.realName LIKE :search OR u.mobileNumber LIKE :search OR u.nationalCode LIKE :search')
|
||||
->setParameter('search', '%' . $search . '%');
|
||||
}
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
// برچسبها — رکوردهایی که حداقل یکی از تگهای انتخابشده را دارند.
|
||||
if (!empty($filters['tags'])) {
|
||||
$qb->andWhere('r.id IN (SELECT rt.id FROM App\Patient\Entity\PatientRecord rt JOIN rt.tags tg WHERE tg.uuid IN (:tagUuids))')
|
||||
->setParameter('tagUuids', (array) $filters['tags']);
|
||||
}
|
||||
|
||||
// جنسیت / نوع بیمه — از UserProfile بیمار (OneToOne با user).
|
||||
if (!empty($filters['gender']) || !empty($filters['insurance_id'])) {
|
||||
$qb->leftJoin(\App\UserProfile\Entity\UserProfile::class, 'pr', \Doctrine\ORM\Query\Expr\Join::WITH, 'pr.user = u');
|
||||
if (!empty($filters['gender'])) {
|
||||
$qb->andWhere('pr.gender = :gender')->setParameter('gender', $filters['gender']);
|
||||
}
|
||||
if (!empty($filters['insurance_id'])) {
|
||||
$qb->andWhere('pr.basicInsuranceId = :insId')->setParameter('insId', (int) $filters['insurance_id']);
|
||||
}
|
||||
}
|
||||
|
||||
// تاریخ پذیرش — تاریخ تشکیل پرونده (record.createdAt).
|
||||
if (!empty($filters['admitted_from'])) {
|
||||
$qb->andWhere('r.createdAt >= :aFrom')->setParameter('aFrom', (int) $filters['admitted_from']);
|
||||
}
|
||||
if (!empty($filters['admitted_to'])) {
|
||||
$qb->andWhere('r.createdAt <= :aTo')->setParameter('aTo', (int) $filters['admitted_to']);
|
||||
}
|
||||
|
||||
// وضعیت سرویس / بدهی — بر اساس وجود مراجعهی پرداختنشده (payment_method='pending').
|
||||
$pendingSub = 'SELECT sp.id FROM App\Patient\Entity\PatientSession sp WHERE sp.record = r AND sp.paymentMethod = :pendingPm';
|
||||
$anySub = 'SELECT sa.id FROM App\Patient\Entity\PatientSession sa WHERE sa.record = r';
|
||||
|
||||
if (!empty($filters['has_debt'])) {
|
||||
$qb->andWhere("EXISTS ($pendingSub)")->setParameter('pendingPm', 'pending');
|
||||
}
|
||||
if (($filters['service_status'] ?? null) === 'pending') {
|
||||
$qb->andWhere("EXISTS ($pendingSub)")->setParameter('pendingPm', 'pending');
|
||||
} elseif (($filters['service_status'] ?? null) === 'completed') {
|
||||
$qb->andWhere("NOT EXISTS ($pendingSub)")
|
||||
->andWhere("EXISTS ($anySub)")
|
||||
->setParameter('pendingPm', 'pending');
|
||||
}
|
||||
}
|
||||
|
||||
public function countUnique(string $entityType, int $entityId, int $from, int $to): int
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
@@ -19,26 +20,58 @@ class PatientSessionRepository extends ServiceEntityRepository
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findByRecord(PatientRecord $record, int $page = 1, int $limit = 20): array
|
||||
/**
|
||||
* مراجعهٔ ساختهشده برای این نوبت در همین محیط، یا null.
|
||||
*
|
||||
* گاردِ ساختِ خودکار: قطعیشدنِ دوباره (confirmed → cancelled → confirmed) نباید
|
||||
* مراجعهٔ تکراری بسازد. مراجعهٔ آرشیوشده هم «ساختهشده» حساب میشود، وگرنه آرشیو
|
||||
* کردنِ یک مراجعهٔ اشتباه باعث ساخت دوبارهاش میشود.
|
||||
*/
|
||||
public function findByAppointmentAndEntity(Appointment $appointment, string $entityType, int $entityId): ?PatientSession
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
->join('s.record', 'r')
|
||||
->where('s.appointment = :appointment')
|
||||
->andWhere('r.entityType = :entityType')
|
||||
->andWhere('r.entityId = :entityId')
|
||||
->setParameter('appointment', $appointment)
|
||||
->setParameter('entityType', $entityType)
|
||||
->setParameter('entityId', $entityId)
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/** $filter: all | active | archived. پیشفرض all برای حفظ رفتار callerهای موجود. */
|
||||
public function findByRecord(PatientRecord $record, int $page = 1, int $limit = 20, string $filter = 'all'): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('s')
|
||||
->where('s.record = :record')
|
||||
->setParameter('record', $record)
|
||||
->orderBy('s.id', 'DESC')
|
||||
->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
->setMaxResults($limit);
|
||||
$this->applyArchivedFilter($qb, $filter);
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function countByRecord(PatientRecord $record): int
|
||||
public function countByRecord(PatientRecord $record, string $filter = 'all'): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('s')
|
||||
$qb = $this->createQueryBuilder('s')
|
||||
->select('COUNT(s.id)')
|
||||
->where('s.record = :record')
|
||||
->setParameter('record', $record)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
->setParameter('record', $record);
|
||||
$this->applyArchivedFilter($qb, $filter);
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
private function applyArchivedFilter(\Doctrine\ORM\QueryBuilder $qb, string $filter): void
|
||||
{
|
||||
if ($filter === 'active') {
|
||||
$qb->andWhere('s.archived = false');
|
||||
} elseif ($filter === 'archived') {
|
||||
$qb->andWhere('s.archived = true');
|
||||
}
|
||||
}
|
||||
|
||||
public function sumRevenue(string $entityType, int $entityId, int $from, int $to): int
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Patient\Entity\SessionAuditLog;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class SessionAuditLogRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry) { parent::__construct($registry, SessionAuditLog::class); }
|
||||
|
||||
public function save(SessionAuditLog $e, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($e);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/** تاریخچهی یک مراجعه (جدید → قدیم)، آرایهای. */
|
||||
public function findBySessionUuid(string $sessionUuid): array
|
||||
{
|
||||
return $this->createQueryBuilder('l')
|
||||
->select('l.field AS field', 'l.operation AS operation', 'l.oldValue AS old_value', 'l.newValue AS new_value', 'l.actorName AS actor_name', 'l.note AS note', 'l.createdAt AS created_at')
|
||||
->join('l.session', 's')
|
||||
->where('s.uuid = :uuid')->setParameter('uuid', $sessionUuid)
|
||||
->orderBy('l.createdAt', 'DESC')->addOrderBy('l.id', 'DESC')
|
||||
->getQuery()->getArrayResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Patient\Entity\SessionConsumable;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class SessionConsumableRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, SessionConsumable::class);
|
||||
}
|
||||
|
||||
public function save(SessionConsumable $consumable): void
|
||||
{
|
||||
$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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user