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:
hamed
2026-08-01 22:57:55 +03:30
co-authored by Claude Opus 5
parent a6acf3bfe2
commit 26425bec31
10 changed files with 463 additions and 35 deletions
@@ -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
+1 -1
View File
@@ -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;
}