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:
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user