feat(treatment): per-case operators, shown on the list and searchable

A treatment case said which doctor supervised it but never who actually did the
work, so the list could not answer the first question a manager asks about a
course: who performed it.

Two separate things now travel with the case. `performed_by` is history —
derived from the sessions' performedBy, so it only ever reports what happened.
`assigned_staff` is plan — a new treatment_case_staff table, editable from the
modal, saying who is meant to handle this patient's course. The card shows the
first and falls back to the second while nothing has been performed yet.

Search matches both. A manager typing an operator's name wants that person's
work, and work already done is part of it.

Assignment also narrows the operator queue: a case with assigned staff shows its
sessions only to those people, because a patient who started a multi-session
course with one operator should keep them. An unassigned case keeps the existing
protocol rule, and an empty list means "anyone the protocol allows" rather than
"nobody" — the same "no rows is not a restriction" convention used elsewhere.

Unlike areas, removing an operator erases nothing: a finished session carries its
real operator on itself and never consults this list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 14:55:01 +03:30
co-authored by Claude Opus 5
parent 12c0d57e1a
commit b78f7311cf
13 changed files with 416 additions and 6 deletions
@@ -16,6 +16,7 @@ const STATUS_OPTIONS: Array<{ value: TreatmentCaseStatus; label: string }> = [
];
interface DoctorRow { uuid: string; name?: string | null; full_name?: string | null }
interface StaffRow { uuid: string; full_name: string }
/**
* ویرایش پروندهٔ درمان.
@@ -43,6 +44,12 @@ export default function TreatmentCaseEditModal({ caseUuid, onClose }: {
staleTime: 60_000,
});
const staffQ = useQuery<ApiResponse<StaffRow[]>>({
queryKey: ['staff-list'],
queryFn: () => api.get('/api/v1/staff'),
staleTime: 60_000,
});
const detail = data?.data;
return (
@@ -59,6 +66,7 @@ export default function TreatmentCaseEditModal({ caseUuid, onClose }: {
detail={detail}
doctors={doctorsQ.data?.data?.data ?? []}
doctorsLoading={doctorsQ.isLoading}
staff={staffQ.data?.data ?? []}
onSaved={() => {
qc.invalidateQueries({ queryKey: ['treatment-cases'] });
qc.invalidateQueries({ queryKey: ['treatment-case', caseUuid] });
@@ -71,10 +79,11 @@ export default function TreatmentCaseEditModal({ caseUuid, onClose }: {
);
}
function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: {
function EditForm({ detail, doctors, doctorsLoading, staff, onSaved, onClose }: {
detail: TreatmentCaseDetail;
doctors: DoctorRow[];
doctorsLoading: boolean;
staff: StaffRow[];
onSaved: () => void;
onClose: () => void;
}) {
@@ -84,6 +93,7 @@ function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: {
const [areas, setAreas] = useState<string[]>(
detail.areas.map((a) => a.category_uuid).filter((u): u is string => u !== null),
);
const [staffUuids, setStaffUuids] = useState<string[]>(detail.assigned_staff.map((s) => s.uuid));
// ناحیه‌ای که دسته‌اش حذف شده در سابقه هست ولی دیگر قابل انتخاب نیست — باید دیده
// شود، وگرنه کاربر فکر می‌کند فرم آن را انداخته است.
@@ -99,6 +109,7 @@ function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: {
status,
supervisor_doctor_uuid: supervisor,
area_uuids: areas,
staff_uuids: staffUuids,
total_sessions: Number(total) || 0,
}),
onSuccess: () => { toast.success('پرونده به‌روزرسانی شد'); onSaved(); },
@@ -107,6 +118,8 @@ function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: {
const toggleArea = (uuid: string) =>
setAreas((prev) => prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]);
const toggleStaff = (uuid: string) =>
setStaffUuids((prev) => prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]);
const totalValid = Number(total) >= 2 && Number(total) <= 60;
const valid = areas.length > 0 && totalValid;
@@ -193,6 +206,48 @@ function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: {
)}
</div>
<div className="field-block">
<label>اپراتور</label>
{staff.length === 0 ? (
<span className="field-hint">پرسنلی در این محیط تعریف نشده است.</span>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{staff.map((p) => {
const on = staffUuids.includes(p.uuid);
return (
<button
key={p.uuid}
type="button"
role="checkbox"
aria-checked={on}
onClick={() => toggleStaff(p.uuid)}
style={{
minHeight: 36, padding: '6px 12px', borderRadius: 'var(--r-sm)',
cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
border: on ? '1px solid var(--primary)' : '1px solid var(--border)',
background: on ? 'var(--primary-soft)' : 'var(--surface)',
color: on ? 'var(--primary-700)' : 'var(--text-2)',
fontWeight: on ? 600 : 400,
}}
>
{p.full_name}
</button>
);
})}
</div>
)}
<span className="field-hint">
{staffUuids.length === 0
? 'خالی یعنی هر پرسنلِ مجازِ این سرویس می‌تواند جلسات را انجام دهد.'
: 'جلسات این پرونده فقط در صف همین افراد دیده می‌شود.'}
</span>
{detail.performed_by.length > 0 && (
<span className="field-hint">
تا اینجا انجامدهنده: {detail.performed_by.map((p) => p.name).join('، ')}
</span>
)}
</div>
<div className="field-block">
<label htmlFor="case-total">تعداد جلسات</label>
<div className="field" style={{ maxWidth: 140 }}>
@@ -24,6 +24,8 @@ function caseRow(over: Record<string, unknown> = {}) {
supervisor: { uuid: 'doc-1', name: 'پزشک مدیسا' },
patient: { record_uuid: 'rec-1', name: 'محمد رسولی', mobile: '09120001111', record_number: '۱۲' },
areas: [{ uuid: 'ca-1', name: 'دست', category_uuid: 'cat-1' }],
assigned_staff: [],
performed_by: [],
...over,
};
}
@@ -124,6 +126,24 @@ describe('صفحهٔ پرونده‌های درمان', () => {
expect(start.textContent).toMatch(/:/);
});
/** «چه کسی انجامش داد» سؤالِ اولِ مدیر است وقتی پرونده را در فهرست می‌بیند. */
it('نام پرسنل انجام‌دهنده را روی کارت می‌آورد', async () => {
mockList([caseRow({ performed_by: [{ uuid: 'st-1', name: 'پرسنل۱' }] })]);
renderWithProviders(<TreatmentCasesPage />);
expect(await screen.findByText(/انجام‌دهنده: پرسنل۱/)).toBeInTheDocument();
});
/** تا وقتی جلسه‌ای انجام نشده، برنامه را نشان می‌دهیم نه هیچ. */
it('اگر هنوز انجام نشده، اپراتورِ اختصاص‌یافته را نشان می‌دهد', async () => {
mockList([caseRow({ assigned_staff: [{ uuid: 'st-2', name: 'پرسنل۲' }] })]);
renderWithProviders(<TreatmentCasesPage />);
expect(await screen.findByText(/اپراتور: پرسنل۲/)).toBeInTheDocument();
});
it('دکمهٔ ویرایش مودال را باز می‌کند', async () => {
mockList([caseRow()]);
+10 -1
View File
@@ -124,7 +124,7 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo }
<input
value={term}
onChange={(e) => setTerm(e.target.value)}
placeholder="نام بیمار، موبایل، کد ملی، شمارهٔ پرونده یا سرویس"
placeholder="نام بیمار، پرسنل، موبایل، کد ملی، شمارهٔ پرونده یا سرویس"
aria-label="جستجوی پرونده"
/>
{term !== '' && (
@@ -242,6 +242,15 @@ function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: ()
{/* ساعت هم لازم است: چند پروندهٔ یک روز فقط با ساعت از هم جدا می‌شوند. */}
<span>شروع: {formatDateTime(c.opened_at)}</span>
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
{/* انجام‌دهنده از جلسات می‌آید (سابقه) و اختصاص‌یافته برنامه است؛ تا وقتی
جلسه‌ای انجام نشده، همان برنامه را نشان می‌دهیم. */}
{c.performed_by.length > 0 ? (
<span>انجامدهنده: {c.performed_by.map((s) => s.name).join('، ')}</span>
) : c.assigned_staff.length > 0 ? (
<span style={{ color: 'var(--text-3)' }}>
اپراتور: {c.assigned_staff.map((s) => s.name).join('، ')} (هنوز انجام نشده)
</span>
) : null}
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
</div>
+4
View File
@@ -1341,6 +1341,10 @@ export interface TreatmentCaseSummary {
record_number: string | null;
};
areas: Array<{ uuid: string; name: string; category_uuid: string | null }>;
/** اپراتورهای اختصاص‌یافته — برنامه. خالی یعنی «هر کسی که پروتکل مجاز دانسته». */
assigned_staff: Array<{ uuid: string; name: string }>;
/** اپراتورهایی که واقعاً جلسه‌ای از این پرونده را انجام داده‌اند — سابقه. */
performed_by: Array<{ uuid: string; name: string }>;
sessions?: TreatmentSessionSummary[];
}
+13 -2
View File
@@ -209,7 +209,7 @@ single-session again. Idempotent: deleting a service that has no protocol still
| Query | توضیح |
|---|---|
| `status` | `active` \| `completed` \| `abandoned` — نبودش یعنی همه |
| `q` | جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده و نام سرویس |
| `q` | جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده، نام سرویس و **نام پرسنل** |
| `from` | `YYYY-MM-DD` میلادی — پرونده‌هایی که از ابتدای این روز به بعد باز شده‌اند |
| `to` | `YYYY-MM-DD` میلادی — تا انتهای این روز |
@@ -232,10 +232,19 @@ single-session again. Idempotent: deleting a service that has no protocol still
"mobile": "09120001111",
"record_number": "۱۲"
},
"areas": [ { "uuid": "…", "name": "بیکینی", "category_uuid": "…" } ]
"areas": [ { "uuid": "…", "name": "بیکینی", "category_uuid": "…" } ],
"assigned_staff": [ { "uuid": "…", "name": "پرسنل۱" } ],
"performed_by": [ { "uuid": "…", "name": "پرسنل۱" } ]
}
```
`assigned_staff` برنامه است و `performed_by` سابقه — اولی از خودِ پرونده می‌آید و دومی
از `TreatmentSession.performedBy`. جستجوی `q` هر دو را می‌گیرد، چون «کارهای این نفر»
شامل کارِ انجام‌شده هم هست.
**پروندهٔ بدون اختصاص یعنی «هر کسی که پروتکل مجاز دانسته»، نه «هیچ‌کس».** پرونده‌ای که
اپراتور دارد، جلساتش فقط در صفِ همان افراد دیده می‌شود.
`areas[].uuid` شناسهٔ همان ردیفِ ناحیه است و `category_uuid` شناسهٔ دستهٔ کاتالوگ.
ویرایش با دومی کار می‌کند؛ `null` یعنی دسته حذف شده و ناحیه فقط در سابقه مانده.
@@ -253,6 +262,7 @@ single-session again. Idempotent: deleting a service that has no protocol still
| `status` | `active` \| `completed` \| `abandoned`. برگرداندن به `active` پروندهٔ بسته را باز می‌کند و `closed_at` را پاک می‌کند |
| `supervisor_doctor_uuid` | پزشک ناظر؛ `null` یعنی بدون ناظر |
| `area_uuids` | فهرست **دستهٔ کاتالوگ**، جایگزین کامل. حداقل یکی |
| `staff_uuids` | اپراتورهای این پرونده، جایگزین کامل. فهرست خالی مجاز است |
| `total_sessions` | بین `TreatmentProtocol::MIN_STEPS` و `MAX_STEPS`. کم‌کردن جلسات را از انتها حذف می‌کند |
مرزِ ثابت: **هیچ ویرایشی سابقهٔ انجام‌شده را بازنویسی نمی‌کند.**
@@ -263,6 +273,7 @@ single-session again. Idempotent: deleting a service that has no protocol still
| ERR_VALIDATION_001 | 422 | `area_uuids` | فهرست خالی یا نامعتبر |
| ERR_VALIDATION_001 | 422 | `total_sessions` | خارج از بازهٔ مجاز |
| ERR_NOT_FOUND_001 | 404 | `supervisor_doctor_uuid` / `area_uuids` | پزشک یا ناحیه یافت نشد |
| ERR_NOT_FOUND_001 | 404 | `staff_uuids` | پرسنل یافت نشد، غیرفعال است، یا مال محیط دیگری است |
| ERR_CONFLICT_001 | 409 | `area_uuids` | ناحیه در جلسه‌ای ثبت شده و حذف نمی‌شود |
| ERR_CONFLICT_001 | 409 | `total_sessions` | کمتر از جلساتی که نوبت دارند یا انجام شده‌اند |
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260807111228 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE treatment_case_staff (id INT AUTO_INCREMENT NOT NULL, treatment_case_id INT NOT NULL, staff_id INT NOT NULL, INDEX IDX_3F9B46BB7D61639 (treatment_case_id), INDEX IDX_3F9B46BD4D57CD (staff_id), UNIQUE INDEX uq_case_staff (treatment_case_id, staff_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE treatment_case_staff ADD CONSTRAINT FK_3F9B46BB7D61639 FOREIGN KEY (treatment_case_id) REFERENCES treatment_cases (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE treatment_case_staff ADD CONSTRAINT FK_3F9B46BD4D57CD FOREIGN KEY (staff_id) REFERENCES clinic_staff (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE treatment_case_staff DROP FOREIGN KEY FK_3F9B46BB7D61639');
$this->addSql('ALTER TABLE treatment_case_staff DROP FOREIGN KEY FK_3F9B46BD4D57CD');
$this->addSql('DROP TABLE treatment_case_staff');
}
}
+3
View File
@@ -122,6 +122,9 @@ final class GlobalTables
// درخواست نمی‌آید. جلسه و رکورد ناحیه برعکس‌اند — پنل پرسنل uuidشان را مستقیم
// می‌فرستد — پس آن دو جفت محیط خودشان را دارند، نه اینجا.
\App\Treatment\Entity\TreatmentCaseArea::class => \App\Treatment\Entity\TreatmentCase::class,
// اپراتورهای اختصاص‌یافته هم همین‌طور: فقط از خودِ پرونده پیمایش می‌شوند و
// درخواست هرگز uuid این ردیف را نمی‌فرستد — `staff_uuids` می‌فرستد.
\App\Treatment\Entity\TreatmentCaseStaff::class => \App\Treatment\Entity\TreatmentCase::class,
// یال «این دسته شامل آن دسته است» جزئی از تعریف دستهٔ والد است؛ هر دو سرِ یال
// در یک محیط‌اند و سازندهٔ یال همین را اجبار می‌کند.
\App\ClinicService\Entity\CatalogCategoryInclude::class => \App\ClinicService\Entity\CatalogCategory::class,
+51
View File
@@ -87,6 +87,10 @@ class TreatmentCase
#[ORM\OrderBy(['sessionNumber' => 'ASC'])]
private Collection $sessions;
/** اپراتورهای اختصاص‌یافته به این پرونده. خالی یعنی «هر کسی که پروتکل مجاز دانسته». */
#[ORM\OneToMany(targetEntity: TreatmentCaseStaff::class, mappedBy: 'treatmentCase', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $assignedStaff;
public function __construct(
string $entityType,
int $entityId,
@@ -106,6 +110,7 @@ class TreatmentCase
$this->updatedAt = time();
$this->areas = new ArrayCollection();
$this->sessions = new ArrayCollection();
$this->assignedStaff = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
@@ -120,6 +125,7 @@ class TreatmentCase
public function getClosedAt(): ?int { return $this->closedAt; }
public function getAreas(): Collection { return $this->areas; }
public function getSessions(): Collection { return $this->sessions; }
public function getAssignedStaff(): Collection { return $this->assignedStaff; }
public function addArea(TreatmentCaseArea $area): self
{
@@ -195,6 +201,22 @@ class TreatmentCase
return $this;
}
/**
* @param list<TreatmentCaseStaff> $staff
*/
public function replaceAssignedStaff(array $staff): self
{
$this->assignedStaff->clear();
foreach ($staff as $row) {
$this->assignedStaff->add($row);
}
$this->touch();
return $this;
}
public function removeSession(TreatmentSession $session): self
{
$this->sessions->removeElement($session);
@@ -234,6 +256,15 @@ class TreatmentCase
static fn (TreatmentCaseArea $a): array => $a->toArray(),
$this->areas->toArray(),
),
'assigned_staff' => array_values(array_map(
static fn (TreatmentCaseStaff $s): array => $s->toArray(),
$this->assignedStaff->toArray(),
)),
/**
* چه کسی واقعاً انجامش داده — از جلسات، نه از اختصاص. اختصاص برنامه است
* و این سابقه؛ فهرست پرونده‌ها باید دومی را نشان بدهد.
*/
'performed_by' => array_values($this->performers()),
];
if ($withSessions) {
@@ -246,5 +277,25 @@ class TreatmentCase
return $data;
}
/**
* اپراتورهای یکتایی که جلسه‌ای از این پرونده را انجام داده‌اند.
*
* @return array<string, array{uuid: string, name: string}>
*/
private function performers(): array
{
$out = [];
foreach ($this->sessions as $session) {
$staff = $session->getPerformedBy();
if ($staff !== null) {
$out[$staff->getUuid()] = ['uuid' => $staff->getUuid(), 'name' => $staff->getFullName()];
}
}
return $out;
}
private function touch(): void { $this->updatedAt = time(); }
}
@@ -0,0 +1,53 @@
<?php
namespace App\Treatment\Entity;
use App\Staff\Entity\ClinicStaff;
use Doctrine\ORM\Mapping as ORM;
/**
* اپراتورِ اختصاص‌یافته به یک پروندهٔ درمان.
*
* جدا از `TreatmentProtocolStaff` است و باید هم باشد: پروتکل می‌گوید «چه کسانی
* *مجازند* این سرویس را انجام دهند» و این می‌گوید «چه کسی *این بیمار* را انجام
* می‌دهد». بیمار معمولاً دوست دارد دورهٔ چندجلسه‌ایش را یک نفر تمام کند.
*
* پروندهٔ بدون اختصاص یعنی «هر کسی که پروتکل مجاز دانسته» — همان قاعدهٔ
* «نبودِ رکورد محدودیت نیست» که در صفِ جلسات هم هست.
*/
#[ORM\Entity]
#[ORM\Table(name: 'treatment_case_staff')]
#[ORM\UniqueConstraint(name: 'uq_case_staff', columns: ['treatment_case_id', 'staff_id'])]
class TreatmentCaseStaff
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: TreatmentCase::class, inversedBy: 'assignedStaff')]
#[ORM\JoinColumn(name: 'treatment_case_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private TreatmentCase $treatmentCase;
#[ORM\ManyToOne(targetEntity: ClinicStaff::class)]
#[ORM\JoinColumn(name: 'staff_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ClinicStaff $staff;
public function __construct(TreatmentCase $case, ClinicStaff $staff)
{
$this->treatmentCase = $case;
$this->staff = $staff;
}
public function getId(): ?int { return $this->id; }
public function getTreatmentCase(): TreatmentCase { return $this->treatmentCase; }
public function getStaff(): ClinicStaff { return $this->staff; }
public function toArray(): array
{
return [
'uuid' => $this->staff->getUuid(),
'name' => $this->staff->getFullName(),
];
}
}
@@ -74,12 +74,34 @@ class TreatmentCaseRepository extends ServiceEntityRepository
}
if ($q !== null && $q !== '') {
/**
* پرسنل از دو راه می‌آید: کسی که به پرونده اختصاص یافته، و کسی که واقعاً
* جلسه‌ای از آن را انجام داده. مدیر که نام اپراتور را می‌زند هر دو را
* می‌خواهد — «کارهای این نفر» شامل کارِ انجام‌شده هم هست.
*/
$assignedStaff = <<<'DQL'
EXISTS (
SELECT 1 FROM App\Treatment\Entity\TreatmentCaseStaff cs
JOIN cs.staff cst
WHERE cs.treatmentCase = c AND cst.fullName LIKE :q
)
DQL;
$performingStaff = <<<'DQL'
EXISTS (
SELECT 1 FROM App\Treatment\Entity\TreatmentSession ts
JOIN ts.performedBy tsp
WHERE ts.treatmentCase = c AND tsp.fullName LIKE :q
)
DQL;
$qb->join('c.patientRecord', 'pr')
->join('pr.user', 'u')
->join('c.serviceItem', 'si')
->andWhere(
'u.realName LIKE :q OR u.mobileNumber LIKE :q OR u.nationalCode LIKE :q'
. ' OR pr.recordNumber LIKE :q OR si.name LIKE :q',
. ' OR pr.recordNumber LIKE :q OR si.name LIKE :q'
. ' OR ' . $assignedStaff . ' OR ' . $performingStaff,
)
->setParameter('q', '%' . $q . '%');
}
@@ -119,6 +119,20 @@ class TreatmentSessionRepository extends ServiceEntityRepository
)
DQL;
$assignedToThisStaff = <<<'DQL'
EXISTS (
SELECT 1 FROM App\Treatment\Entity\TreatmentCaseStaff cs
WHERE cs.treatmentCase = c AND cs.staff = :staff
)
DQL;
$caseNamesAnyStaff = <<<'DQL'
EXISTS (
SELECT 1 FROM App\Treatment\Entity\TreatmentCaseStaff cs2
WHERE cs2.treatmentCase = c
)
DQL;
return $this->createQueryBuilder('s')
->join('s.appointment', 'a')
->join('s.treatmentCase', 'c')
@@ -127,8 +141,16 @@ class TreatmentSessionRepository extends ServiceEntityRepository
// محیط، وگرنه پرسنلِ یک کلینیک جلسات کلینیک دیگر را می‌بیند.
->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))',
's.performedBy = :staff OR a.staff = :staff'
. ' OR (s.performedBy IS NULL AND a.staff IS NULL AND (%s OR (NOT %s AND (%s OR NOT %s))))',
$assignedToThisStaff,
$caseNamesAnyStaff,
$allowsThisStaff,
$namesAnyStaff,
))
@@ -8,7 +8,9 @@ use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Treatment\Entity\TreatmentCase;
use App\Staff\Repository\ClinicStaffRepository;
use App\Treatment\Entity\TreatmentCaseArea;
use App\Treatment\Entity\TreatmentCaseStaff;
use App\Treatment\Entity\TreatmentProtocol;
use App\Treatment\Entity\TreatmentSession;
use Doctrine\ORM\EntityManagerInterface;
@@ -27,6 +29,7 @@ final class TreatmentCaseEditor
public function __construct(
private readonly DoctorRepository $doctors,
private readonly CatalogCategoryRepository $categories,
private readonly ClinicStaffRepository $staff,
private readonly EntityManagerInterface $em,
) {}
@@ -47,6 +50,10 @@ final class TreatmentCaseEditor
$this->applyAreas($case, $data['area_uuids']);
}
if (array_key_exists('staff_uuids', $data)) {
$this->applyStaff($case, $data['staff_uuids']);
}
if (array_key_exists('total_sessions', $data)) {
$this->applyTotalSessions($case, (int) $data['total_sessions']);
}
@@ -220,6 +227,44 @@ final class TreatmentCaseEditor
$case->setTotalSessions($total);
}
/**
* اپراتورهای اختصاص‌یافته — جایگزین کامل، و فهرست خالی مجاز است.
*
* خالی یعنی «هر کسی که پروتکل مجاز دانسته»، نه «هیچ‌کس». برخلاف نواحی، اینجا
* حذف چیزی از سابقه پاک نمی‌کند: جلسهٔ انجام‌شده اپراتور واقعی‌اش را روی خودش
* دارد (`performedBy`) و به این فهرست نگاه نمی‌کند.
*
* @param mixed $uuids فهرست uuid پرسنل
*/
private function applyStaff(TreatmentCase $case, mixed $uuids): void
{
if (!is_array($uuids)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فهرست پرسنل نامعتبر است', 422, 'staff_uuids');
}
$rows = [];
foreach (array_unique(array_map('strval', $uuids)) as $uuid) {
$staff = $this->staff->findOneBy(['uuid' => $uuid, 'active' => true]);
if ($staff === null
|| $staff->getEntityType() !== $case->getEntityType()
|| $staff->getEntityId() !== $case->getEntityId()
) {
throw new AppException(
ErrorCodes::ERR_NOT_FOUND_001,
'پرسنل یافت نشد، غیرفعال است، یا مال محیط دیگری است',
404,
'staff_uuids',
);
}
$rows[] = new TreatmentCaseStaff($case, $staff);
}
$case->replaceAssignedStaff($rows);
}
private function areaHasRecords(TreatmentCaseArea $area): bool
{
return (int) $this->em->createQuery(
+80
View File
@@ -11,6 +11,7 @@ use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
use App\Treatment\Entity\TreatmentCase;
use App\Staff\Entity\ClinicStaff;
use App\Treatment\Entity\TreatmentCaseArea;
use App\Treatment\Entity\TreatmentProtocol;
use App\Treatment\Entity\TreatmentProtocolStep;
@@ -219,6 +220,85 @@ class TreatmentCaseEditTest extends ApiTestCase
self::assertSame($case->getId(), $this->cases()->findForTenant($type, $id, null, $name)[0]->getId());
}
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;
}
/** اپراتورِ پرونده: بیمار دوره‌اش را با یک نفر شروع می‌کند. */
public function testAssignedStaffCanBeSetAndCleared(): void
{
[$case, $clinic] = $this->scenario();
$a = $this->staffIn($clinic, 'اپراتور الف');
$b = $this->staffIn($clinic, 'اپراتور ب');
$this->editor()->update($case, ['staff_uuids' => [$a->getUuid(), $b->getUuid()]]);
$names = array_map(static fn ($r) => $r->getStaff()->getFullName(), $case->getAssignedStaff()->toArray());
sort($names);
self::assertSame(['اپراتور الف', 'اپراتور ب'], $names);
// فهرست خالی مجاز است — یعنی «هر کسی که پروتکل مجاز دانسته»، نه «هیچ‌کس».
$this->editor()->update($case, ['staff_uuids' => []]);
self::assertCount(0, $case->getAssignedStaff());
}
public function testStaffFromAnotherTenantIsRejected(): void
{
[$case] = $this->scenario();
$otherClinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$otherClinic->setName('کلینیک دیگر ' . uniqid());
$this->em->persist($otherClinic);
$this->em->flush();
$outsider = $this->staffIn($otherClinic, 'پرسنل بیرونی');
$this->expectException(\App\Shared\Exception\AppException::class);
$this->editor()->update($case, ['staff_uuids' => [$outsider->getUuid()]]);
}
/** مدیر نام اپراتور را می‌زند و انتظار دارد پرونده‌هایش بیاید. */
public function testSearchMatchesAssignedStaff(): void
{
[$case, $clinic] = $this->scenario();
$name = 'اپراتور ' . uniqid();
$staff = $this->staffIn($clinic, $name);
$type = 'clinic';
$id = (int) $clinic->getId();
self::assertSame([], $this->cases()->findForTenant($type, $id, null, $name));
$this->editor()->update($case, ['staff_uuids' => [$staff->getUuid()]]);
$found = $this->cases()->findForTenant($type, $id, null, $name);
self::assertCount(1, $found);
self::assertSame($case->getId(), $found[0]->getId());
}
/** «کارهای این نفر» شاملِ کارِ انجام‌شده هم هست، نه فقط اختصاصِ آینده. */
public function testSearchMatchesTheOperatorWhoPerformedASession(): void
{
[$case, $clinic] = $this->scenario();
$name = 'انجام‌دهنده ' . uniqid();
$staff = $this->staffIn($clinic, $name);
$case->getSessions()->first()->setPerformedBy($staff);
$this->em->flush();
$found = $this->cases()->findForTenant('clinic', (int) $clinic->getId(), null, $name);
self::assertCount(1, $found);
// و در payload هم دیده می‌شود، جدا از اختصاص.
$payload = $found[0]->toArray();
self::assertSame([$name], array_column($payload['performed_by'], 'name'));
self::assertSame([], $payload['assigned_staff']);
}
/** فیلتر بازه روی تاریخِ باز شدن پرونده است، نه سررسید جلسه. */
public function testDateRangeFiltersByOpenedAt(): void
{