Manage a resource's services from the panel
A "سرویسها" action on each resource row opens a modal listing what that resource performs, with its own duration and price. It follows the skills modal exactly — same PUT-replaces-everything contract, same components, no new page and no new route. Leaving a cell empty means inherit, so the effective value is shown as the placeholder along with where it came from: "40 — service default", "1,800,000 toman — branch". Without that the user cannot tell an unset field from a zero, which is the one thing this screen has to communicate. Two backend adjustments came out of wiring it up: - the offering filter in findEligible is now scoped to the requirement's resource type. Registering lasers for a service was making the room in the same plan ineligible and breaking the whole booking — "who performs this" is about the performing role, not about rooms and support resources. The seeder caught this immediately. - the scenario seeder now creates offerings and resource categories, so the demo data exercises this model instead of leaving every resource empty. Three vitest tests: inherited value with its source, saving an override, and clearing back to inheritance. Verified in the browser at 1440 dark, 1440 compact and 390 mobile — the last with no horizontal scroll. Panel suite 648 green across 98 files, tsc clean, encore build succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -122,8 +122,8 @@ class ClinicResourceRepository extends ServiceEntityRepository
|
||||
* باشد. اگر برای این سرویس هیچ ردیفی نباشد، فیلتر اعمال نمیشود — وگرنه هر
|
||||
* محیطی که هنوز رابطهها را پر نکرده، یکشبه بدون وقت آزاد میشد.
|
||||
*/
|
||||
if ($service !== null && $this->offerings->hasAnyFor($service)) {
|
||||
$resourceIds = $this->offerings->activeResourceIdsFor($service);
|
||||
if ($service !== null && $this->offerings->hasAnyFor($service, $type)) {
|
||||
$resourceIds = $this->offerings->activeResourceIdsFor($service, $type);
|
||||
|
||||
if ($resourceIds === []) {
|
||||
return []; // همه غیرفعالاند: «هیچکس این سرویس را نمیدهد»، نه «فیلتری نیست»
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Resource\Repository;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourceServiceOffering;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
@@ -43,27 +44,42 @@ class ResourceServiceOfferingRepository extends ServiceEntityRepository
|
||||
* رابطهها را پر نکرده نباید یکشبه بدون وقت آزاد شود، پس فیلتر فقط وقتی اعمال
|
||||
* میشود که کلینیک دستکم یک ردیف برای آن سرویس ساخته باشد.
|
||||
*/
|
||||
public function hasAnyFor(ServiceItem $item): bool
|
||||
public function hasAnyFor(ServiceItem $item, ?ResourceType $type = null): bool
|
||||
{
|
||||
return (bool) $this->createQueryBuilder('o')
|
||||
$qb = $this->createQueryBuilder('o')
|
||||
->select('1')
|
||||
->where('o.serviceItem = :item')
|
||||
->setParameter('item', $item)
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
->setMaxResults(1);
|
||||
|
||||
/**
|
||||
* محدود به نوعِ منبع، وقتی نوع داده شده.
|
||||
*
|
||||
* «کدام منبع این سرویس را میدهد» دربارهٔ نقشِ انجامدهنده است، نه دربارهٔ اتاق و
|
||||
* پشتیبانها. بدون این قید، ثبت رابطه برای دستگاهها باعث میشد اتاقِ همان
|
||||
* برنامه ناگهان «واجد شرایط نباشد» و کل برنامه بشکند.
|
||||
*/
|
||||
if ($type !== null) {
|
||||
$qb->join('o.resource', 'r')->andWhere('r.type = :type')->setParameter('type', $type);
|
||||
}
|
||||
|
||||
return (bool) $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/** @return int[] شناسهٔ منابعی که این سرویس را فعال ارائه میدهند */
|
||||
public function activeResourceIdsFor(ServiceItem $item): array
|
||||
public function activeResourceIdsFor(ServiceItem $item, ?ResourceType $type = null): array
|
||||
{
|
||||
$rows = $this->createQueryBuilder('o')
|
||||
$qb = $this->createQueryBuilder('o')
|
||||
->select('IDENTITY(o.resource) AS resource_id')
|
||||
->where('o.serviceItem = :item')
|
||||
->andWhere('o.active = true')
|
||||
->setParameter('item', $item)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
->setParameter('item', $item);
|
||||
|
||||
if ($type !== null) {
|
||||
$qb->join('o.resource', 'r')->andWhere('r.type = :type')->setParameter('type', $type);
|
||||
}
|
||||
|
||||
$rows = $qb->getQuery()->getArrayResult();
|
||||
|
||||
return array_map(static fn (array $row): int => (int) $row['resource_id'], $rows);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ final class BookingEngineSeeder
|
||||
$counts['resources'] = $this->skillsPoolsAndExceptions($entityType, $entityId, $address, $devices, $deviceType);
|
||||
$counts['segments'] = $this->multiSegmentPlan($flagship, $deviceType, $entityType, $entityId, $address);
|
||||
$counts['pricing'] = $this->priceList($entityType, $entityId, $services);
|
||||
$counts['offerings'] = $this->serviceOfferings($address, $devices, $services);
|
||||
$counts['booked'] = $this->realBookings($flagship, $address, $patients, $doctor, $clinic, $entityType, $entityId);
|
||||
|
||||
return $counts;
|
||||
@@ -221,6 +222,45 @@ final class BookingEngineSeeder
|
||||
return count($plan);
|
||||
}
|
||||
|
||||
/**
|
||||
* «کدام دستگاه کدام سرویس را میدهد، با چه مدت و چه قیمتی» — همان مثال سند.
|
||||
*
|
||||
* دو دستگاه یک سرویس را میدهند با اعداد متفاوت، و سومی عمداً بیردیف میماند تا
|
||||
* تفاوت «این را نمیدهد» با «هنوز تنظیم نشده» در داده دیده شود.
|
||||
*/
|
||||
private function serviceOfferings(DoctorAddress $address, array $devices, array $services): int
|
||||
{
|
||||
if ($devices === [] || $services === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$made = 0;
|
||||
|
||||
foreach (array_slice($devices, 0, 2) as $index => $device) {
|
||||
foreach (array_slice($services, 0, 2) as $offset => $service) {
|
||||
$offering = new \App\Resource\Entity\ResourceServiceOffering($device, $service);
|
||||
|
||||
// دستگاه دوم سریعتر و گرانتر است — دقیقاً مثال «۲۰ دقیقه/۸۰۰ک» و «۱۵ دقیقه/۹۵۰ک».
|
||||
$offering
|
||||
->setDurationMinutes($index === 0 ? null : max(5, (int) (($service->getSoloDurationMinutes() ?? 20) / 2)))
|
||||
->setPriceRials($offset === 0 ? (int) round($service->getPriceRials() * (1 + $index * 0.2)) : null);
|
||||
|
||||
$this->em()->persist($offering);
|
||||
$made++;
|
||||
}
|
||||
|
||||
// دستهبندیِ منبع: «این دستگاه برای این ناحیه است».
|
||||
$category = $services[0]->getCatalogCategory();
|
||||
if ($category !== null) {
|
||||
$device->getCategories()->add($category);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em()->flush();
|
||||
|
||||
return $made;
|
||||
}
|
||||
|
||||
// ── تسک ۰۸: لیست قیمت ───────────────────────────────────────────────────
|
||||
|
||||
private function priceList(string $entityType, int $entityId, array $services): int
|
||||
|
||||
@@ -176,7 +176,7 @@ class SeedScenariosCommand extends Command
|
||||
foreach ($this->engineCounts as $scenario => $counts) {
|
||||
$rows[] = array_merge(['سناریو ' . $scenario], array_values($counts));
|
||||
}
|
||||
$io->table(['سناریو', 'کاتالوگ', 'منابع', 'بخشها', 'قیمت', 'رزرو واقعی'], $rows);
|
||||
$io->table(['سناریو', 'کاتالوگ', 'منابع', 'بخشها', 'قیمت', 'سرویسِ منبع', 'رزرو واقعی'], $rows);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user