Merge branch 'dev' into main

# Conflicts:
#	docs/api/doctor.md
This commit is contained in:
hamed
2026-07-19 16:15:30 +03:30
1026 changed files with 190049 additions and 15130 deletions
+72 -9
View File
@@ -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 مطب شخصی
+148 -3
View File
@@ -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,
];
}
}
+20 -2
View File
@@ -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,
+17 -1
View File
@@ -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,
+60 -3
View File
@@ -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;
}
}
+219 -17
View File
@@ -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)
+58 -3
View File
@@ -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) {
+2
View File
@@ -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
+233 -22
View File
@@ -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();
}
}
+41
View File
@@ -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,
+94
View File
@@ -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,
];
}
}
+2
View File
@@ -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; }
+1
View File
@@ -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; }
+210
View File
@@ -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);
+34 -4
View File
@@ -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;
}
}
+92
View File
@@ -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,
];
}
}
+46 -5
View File
@@ -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);
+27
View File
@@ -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,
];
}
+21 -2
View File
@@ -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 === '') {
+40 -6
View File
@@ -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;
}
}
+3 -1
View File
@@ -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
+143 -7
View File
@@ -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,
];
}
}
+12 -2
View File
@@ -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();
}
}
+320 -44
View File
@@ -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); }
}
}
+179
View File
@@ -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();
}
}
+161
View File
@@ -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);
}
}
+20 -9
View File
@@ -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;
}
+19 -12
View File
@@ -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(
+66 -24
View File
@@ -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';
+106 -30
View File
@@ -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,
];
}
}
+8
View File
@@ -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];
}
}
+166
View File
@@ -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;
}
}
+87
View File
@@ -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();
}
}
+116
View File
@@ -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
+69
View File
@@ -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,
];
}
}
+79
View File
@@ -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,
];
}
}
+59
View File
@@ -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,
];
}
}
+92
View File
@@ -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,
];
}
}
+44
View File
@@ -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,
];
+192 -1
View File
@@ -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,
];
+87
View File
@@ -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,
];
}
}
+75
View File
@@ -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,
];
}
}
+88
View File
@@ -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