feat(admin): resource booking mode with a readiness guard
Task 06's engine could only be switched on through the API, and nothing checked whether the environment was ready for it. Since the mode choice is irreversible, picking it with no resources defined would lock a clinic into a state where no appointment is ever computable. Backend now refuses that: resource mode requires at least one active resource, with a message that says what to define first. Same shape as the existing service-mode guard, applied on both save paths. The panel shows the same conditions as a ✓/✗ list before the choice is made, each unmet one linking to where it gets fixed — a 422 after an irreversible decision is the wrong place to learn about a prerequisite. Also adds the search step (minimum 5 minutes) and extends the existing mode cards to three rather than building a parallel component. No strategy picker: task 06 never built the strategies, and an empty menu reads worse than an absent one. GET /api/v1/service-items now returns has_segments, computed with one aggregate query for the whole list rather than one per service. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,7 @@ class AppointmentSettingsController extends BaseController
|
||||
private readonly DoctorAddressRepository $addressRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
|
||||
private readonly \App\Resource\Repository\ClinicResourceRepository $resourceRepo,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
||||
private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess,
|
||||
) {}
|
||||
@@ -90,6 +91,33 @@ class AppointmentSettingsController extends BaseController
|
||||
return $this->itemRepo->countBookableByEntity($type, $id) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* حالت منبعمحور بدون هیچ منبع فعال، هیچ نوبتی نمیسازد — و چون انتخابِ حالت
|
||||
* برگشتناپذیر است، محیط برای همیشه قفل میشد.
|
||||
*/
|
||||
private function resourceModeHasNoResources(array $meta, Doctor $doctor, ?Clinic $clinic): bool
|
||||
{
|
||||
if (($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) !== WeeklySchedule::MODE_RESOURCE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$type, $id] = $clinic !== null
|
||||
? [EntityContext::TYPE_CLINIC, $clinic->getId()]
|
||||
: [EntityContext::TYPE_DOCTOR, $doctor->getId()];
|
||||
|
||||
return $this->resourceRepo->countActiveForPair($type, (int) $id) === 0;
|
||||
}
|
||||
|
||||
private function noResourceError(): JsonResponse
|
||||
{
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
'برای نوبتدهی منبعمحور حداقل یک منبع فعال لازم است؛ اول اتاق، اپراتور یا دستگاه تعریف کنید',
|
||||
422,
|
||||
'booking_mode',
|
||||
);
|
||||
}
|
||||
|
||||
private function noBookableServiceError(?Clinic $clinic): JsonResponse
|
||||
{
|
||||
$message = $clinic !== null
|
||||
@@ -163,6 +191,10 @@ class AppointmentSettingsController extends BaseController
|
||||
return $err;
|
||||
}
|
||||
|
||||
if ($this->resourceModeHasNoResources($schedule->getMeta(), $doctor, $clinic)) {
|
||||
return $this->noResourceError();
|
||||
}
|
||||
|
||||
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor, $clinic)) {
|
||||
return $this->noBookableServiceError($clinic);
|
||||
}
|
||||
@@ -213,6 +245,10 @@ class AppointmentSettingsController extends BaseController
|
||||
return $err;
|
||||
}
|
||||
|
||||
if ($this->resourceModeHasNoResources($schedule->getMeta(), $schedule->getDoctor(), $clinic)) {
|
||||
return $this->noResourceError();
|
||||
}
|
||||
|
||||
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $schedule->getDoctor(), $clinic)) {
|
||||
return $this->noBookableServiceError($clinic);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,34 @@ class SegmentTemplateRepository extends ServiceEntityRepository
|
||||
return $byService;
|
||||
}
|
||||
|
||||
/**
|
||||
* کدام سرویسها اصلاً بخش تعریفشده دارند — یک کوئری، نه یکی per سرویس.
|
||||
*
|
||||
* @param int[] $serviceIds
|
||||
* @return array<int, bool> شناسهٔ سرویس => دارد/ندارد
|
||||
*/
|
||||
public function hasSegmentsMap(array $serviceIds): array
|
||||
{
|
||||
if ($serviceIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->createQueryBuilder('s')
|
||||
->select('IDENTITY(s.service) AS service_id', 'COUNT(s.id) AS total')
|
||||
->where('IDENTITY(s.service) IN (:ids)')
|
||||
->setParameter('ids', $serviceIds)
|
||||
->groupBy('service_id')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(int) $row['service_id']] = ((int) $row['total']) > 0;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
public function deleteForService(ServiceItem $service): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('s')
|
||||
|
||||
@@ -46,6 +46,7 @@ class ClinicServiceController extends BaseController
|
||||
private readonly TariffRepository $tariffRepo,
|
||||
private readonly TariffService $tariffService,
|
||||
private readonly InventoryPackageRepository $packageRepo,
|
||||
private readonly \App\Appointment\Plan\Repository\SegmentTemplateRepository $segmentRepo,
|
||||
private readonly InventoryItemRepository $inventoryItemRepo,
|
||||
private readonly ServiceItemAuditService $auditService,
|
||||
private readonly ServiceItemAuditLogRepository $auditLogRepo,
|
||||
@@ -76,11 +77,18 @@ class ClinicServiceController extends BaseController
|
||||
array_map(fn(ServiceItem $i) => $i->getInventoryPackageId(), $items)
|
||||
);
|
||||
|
||||
return array_map(function (ServiceItem $i) use ($packages) {
|
||||
// یک کوئری برای همهٔ سرویسها؛ تنظیمات نوبتدهی از همین میفهمد آمادگیِ حالت
|
||||
// منبعمحور هست یا نه، بدون اینکه per سرویس بپرسد.
|
||||
$hasSegments = $this->segmentRepo->hasSegmentsMap(
|
||||
array_map(fn(ServiceItem $i) => (int) $i->getId(), $items)
|
||||
);
|
||||
|
||||
return array_map(function (ServiceItem $i) use ($packages, $hasSegments) {
|
||||
$row = $i->toArray();
|
||||
$package = $packages[$i->getInventoryPackageId()] ?? null;
|
||||
$row['inventory_package_uuid'] = $package?->getUuid();
|
||||
$row['inventory_package_title'] = $package?->getTitle();
|
||||
$row['has_segments'] = $hasSegments[(int) $i->getId()] ?? false;
|
||||
|
||||
return $row;
|
||||
}, $items);
|
||||
|
||||
@@ -62,6 +62,20 @@ class ClinicResourceRepository extends ServiceEntityRepository
|
||||
return $qb->orderBy('r.name', 'ASC')->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/** منابع فعال یک محیط — شرط آمادگیِ حالت نوبتدهی منبعمحور. */
|
||||
public function countActiveForPair(string $entityType, int $entityId): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('r')
|
||||
->select('COUNT(r.id)')
|
||||
->where('r.entityType = :type')
|
||||
->andWhere('r.entityId = :id')
|
||||
->andWhere('r.active = true')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* همهٔ منابع فعال یک شعبه — ورودی گزارش بهرهوری.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user