fix(treatment): make the staff panel a queue instead of an assignment list

findTodayForStaff filtered on appointments.staff, and no booking path ever sets
that column. The result was a staff panel that was empty for every operator, in
every tenant, no matter how much work the day held.

Today's sessions now reach an operator three ways: the session they already
claimed (performedBy), the appointment a secretary pre-assigned to them, or
unclaimed work whose protocol names them. A protocol with no staff list means
everyone may perform it — the same "no rows is not a restriction" rule
ResourceServiceOffering already uses.

The query is also tenant-scoped, which the old one was not: it relied on
appointments.staff being a same-tenant row rather than saying so.

Verified against the dev database: the operator behind 09128726723 now gets
both of today's sessions on "لیزر توتال", which the old query returned none of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 12:34:01 +03:30
co-authored by Claude Opus 5
parent f5902eeeeb
commit 38ea477429
2 changed files with 250 additions and 2 deletions
@@ -88,16 +88,53 @@ class TreatmentSessionRepository extends ServiceEntityRepository
/**
* جلسات امروزِ یک پرسنل — از روی نوبتِ متصل، نه از روی سررسید تخمینی.
*
* صف است، نه فهرستِ اختصاص‌یافته. سه چیز اینجا می‌آید:
* - جلسه‌ای که همین پرسنل برداشته (`performedBy`)،
* - نوبتی که منشی از قبل به او داده (`appointment.staff`)،
* - و جلسهٔ بی‌صاحبی که پروتکلش او را مجاز می‌داند.
*
* فیلترِ قبلی فقط شرط دوم را داشت، و چون هیچ مسیرِ نوبت‌دهی اپراتور را ست
* نمی‌کند، پنل پرسنل همیشه خالی بود.
*
* پروتکلِ بدون فهرست پرسنل یعنی «همه مجازند» — همان قاعدهٔ
* `ResourceServiceOffering` که نبودِ رکورد را محدودیت حساب نمی‌کند.
*
* @return TreatmentSession[]
*/
public function findTodayForStaff(ClinicStaff $staff, int $dayStart, int $dayEnd): array
{
$allowsThisStaff = <<<'DQL'
EXISTS (
SELECT 1 FROM App\Treatment\Entity\TreatmentProtocolStaff ps
JOIN ps.protocol p
WHERE p.serviceItem = c.serviceItem AND p.active = true AND ps.staff = :staff
)
DQL;
$namesAnyStaff = <<<'DQL'
EXISTS (
SELECT 1 FROM App\Treatment\Entity\TreatmentProtocolStaff ps2
JOIN ps2.protocol p2
WHERE p2.serviceItem = c.serviceItem AND p2.active = true
)
DQL;
return $this->createQueryBuilder('s')
->join('s.appointment', 'a')
->where('a.staff = :staff')
->andWhere('a.slotStart >= :from')
->join('s.treatmentCase', 'c')
->where('a.slotStart >= :from')
->andWhere('a.slotStart <= :to')
// محیط، وگرنه پرسنلِ یک کلینیک جلسات کلینیک دیگر را می‌بیند.
->andWhere('s.entityType = :type')
->andWhere('s.entityId = :id')
->andWhere(sprintf(
's.performedBy = :staff OR a.staff = :staff OR (s.performedBy IS NULL AND a.staff IS NULL AND (%s OR NOT %s))',
$allowsThisStaff,
$namesAnyStaff,
))
->setParameter('staff', $staff)
->setParameter('type', $staff->getEntityType())
->setParameter('id', $staff->getEntityId())
->setParameter('from', $dayStart)
->setParameter('to', $dayEnd)
->orderBy('a.slotStart', 'ASC')
+211
View File
@@ -0,0 +1,211 @@
<?php
namespace App\Tests\Treatment;
use App\Appointment\Entity\Appointment;
use App\Appointment\Service\AppointmentConfirmationService;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Staff\Entity\ClinicStaff;
use App\Tests\ApiTestCase;
use App\Treatment\Entity\TreatmentProtocol;
use App\Treatment\Entity\TreatmentProtocolStaff;
use App\Treatment\Entity\TreatmentProtocolStep;
use App\Treatment\Entity\TreatmentSession;
use App\Treatment\Repository\TreatmentSessionRepository;
/**
* «جلسات امروزِ من» یک صف است، نه فهرستِ اختصاص‌یافته.
*
* هیچ مسیرِ نوبت‌دهی اپراتور را ست نمی‌کند، پس فیلتر کردن صرفاً روی
* `appointment.staff` یعنی پنل پرسنل همیشه خالی است. اپراتورِ مجاز باید کارِ
* بی‌صاحبِ امروز را ببیند و برش دارد؛ کارِ برداشته‌شده یا از پیش اختصاص‌یافته فقط
* مالِ صاحبش است.
*/
class StaffSessionQueueTest extends ApiTestCase
{
private function repo(): TreatmentSessionRepository
{
return static::getContainer()->get(TreatmentSessionRepository::class);
}
/**
* @return array{Clinic, ServiceItem, Doctor, TreatmentProtocol}
*/
private function clinicWithProtocol(bool $namesStaff, ?ClinicStaff $allowed = null): array
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر صف');
$this->em->persist($doctor);
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$clinic->setName('کلینیک صف ' . uniqid());
$clinic->getDoctors()->add($doctor);
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر');
$this->em->persist($section);
// بدون دستهٔ کاتالوگ، نواحیِ پرونده جایی برای snapshot ندارند و باز کردن
// پرونده خطا می‌دهد — همان چیزی که TreatmentCaseOpener انتظار دارد.
$category = new CatalogCategory('clinic', (int) $clinic->getId(), 'صورت');
$this->em->persist($category);
$service = new ServiceItem($section, 'لیزر صورت', 3_000_000);
$service->setCatalogCategory($category);
$this->em->persist($service);
$this->em->flush();
$protocol = new TreatmentProtocol($service);
$this->em->persist($protocol);
$protocol->replaceSteps([
new TreatmentProtocolStep($protocol, 1, 0),
new TreatmentProtocolStep($protocol, 2, 15),
]);
if ($namesStaff && $allowed !== null) {
$protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $allowed)]);
}
$this->em->flush();
return [$clinic, $service, $doctor, $protocol];
}
private function staffIn(Clinic $clinic, string $name): ClinicStaff
{
$staff = new ClinicStaff('clinic', (int) $clinic->getId(), $name);
$this->em->persist($staff);
$this->em->flush();
return $staff;
}
/** نوبتِ امروز روی سرویسِ پروتکل‌دار، قطعی‌شده تا پرونده و جلسه ساخته شود. */
private function bookToday(Clinic $clinic, Doctor $doctor, ServiceItem $service, ?ClinicStaff $staff = null): Appointment
{
$start = strtotime('today midnight') + 10 * 3600;
$appointment = $this->newAppointment(
$doctor,
$this->createUser(['ROLE_USER']),
$start,
$start + 1800,
$clinic,
);
$appointment->addServiceItem($service);
if ($staff !== null) {
$appointment->setStaff($staff);
}
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($appointment);
$this->em->flush();
static::getContainer()->get(AppointmentConfirmationService::class)->onConfirmed($appointment);
return $appointment;
}
private function today(ClinicStaff $staff): array
{
return $this->repo()->findTodayForStaff(
$staff,
strtotime('today midnight'),
strtotime('tomorrow midnight') - 1,
);
}
public function testAnAllowedOperatorSeesUnclaimedWork(): void
{
[$clinic, $service, $doctor] = $this->clinicWithProtocol(namesStaff: false);
$operator = $this->staffIn($clinic, 'اپراتور مجاز');
$this->clinicWithProtocolNamesStaff($service, $operator);
$appointment = $this->bookToday($clinic, $doctor, $service);
$sessions = $this->today($operator);
self::assertCount(1, $sessions);
self::assertSame($appointment->getId(), $sessions[0]->getAppointment()?->getId());
}
/** پرسنلی که پروتکل نامش را نبرده، کارِ بی‌صاحب را هم نباید ببیند. */
public function testAnOperatorOutsideTheProtocolSeesNothing(): void
{
[$clinic, $service, $doctor] = $this->clinicWithProtocol(namesStaff: false);
$allowed = $this->staffIn($clinic, 'اپراتور مجاز');
$other = $this->staffIn($clinic, 'اپراتور دیگر');
$this->clinicWithProtocolNamesStaff($service, $allowed);
$this->bookToday($clinic, $doctor, $service);
self::assertCount(1, $this->today($allowed));
self::assertSame([], $this->today($other));
}
/** پروتکلِ بدون فهرست پرسنل یعنی همه مجازند، نه هیچ‌کس. */
public function testAProtocolWithNoStaffListIsOpenToEveryone(): void
{
[$clinic, $service, $doctor] = $this->clinicWithProtocol(namesStaff: false);
$anyone = $this->staffIn($clinic, 'هر اپراتوری');
$this->bookToday($clinic, $doctor, $service);
self::assertCount(1, $this->today($anyone));
}
/** اختصاص از قبل توسط منشی: جلسه فقط مالِ همان نفر است. */
public function testPreAssignedWorkIsHiddenFromOthers(): void
{
[$clinic, $service, $doctor] = $this->clinicWithProtocol(namesStaff: false);
$mine = $this->staffIn($clinic, 'اپراتور من');
$theirs = $this->staffIn($clinic, 'اپراتور آن‌ها');
$this->bookToday($clinic, $doctor, $service, staff: $mine);
self::assertCount(1, $this->today($mine));
self::assertSame([], $this->today($theirs));
}
/** جلسه‌ای که یکی برداشته از صفِ بقیه بیرون می‌رود. */
public function testClaimedWorkLeavesTheSharedQueue(): void
{
[$clinic, $service, $doctor] = $this->clinicWithProtocol(namesStaff: false);
$first = $this->staffIn($clinic, 'اپراتور اول');
$second = $this->staffIn($clinic, 'اپراتور دوم');
$this->bookToday($clinic, $doctor, $service);
self::assertCount(1, $this->today($first));
self::assertCount(1, $this->today($second));
/** @var TreatmentSession $session */
$session = $this->today($first)[0];
$session->setPerformedBy($first);
$this->em->flush();
self::assertCount(1, $this->today($first));
self::assertSame([], $this->today($second));
}
/** پرسنلِ کلینیک دیگر هیچ‌وقت این صف را نمی‌بیند. */
public function testTheQueueIsScopedToTheTenant(): void
{
[$clinic, $service, $doctor] = $this->clinicWithProtocol(namesStaff: false);
$this->bookToday($clinic, $doctor, $service);
[$otherClinic] = $this->clinicWithProtocol(namesStaff: false);
$outsider = $this->staffIn($otherClinic, 'پرسنل کلینیک دیگر');
self::assertSame([], $this->today($outsider));
}
/** فهرست پرسنلِ پروتکل بعد از ساختِ سرویس ست می‌شود؛ همان مسیر تنظیمات. */
private function clinicWithProtocolNamesStaff(ServiceItem $service, ClinicStaff $staff): void
{
$protocol = $this->em->getRepository(TreatmentProtocol::class)->findOneBy(['serviceItem' => $service]);
self::assertNotNull($protocol);
$protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]);
$this->em->flush();
}
}