diff --git a/assets/admin/components/ServiceItemFormModal.tsx b/assets/admin/components/ServiceItemFormModal.tsx index d2886357..41d258bf 100644 --- a/assets/admin/components/ServiceItemFormModal.tsx +++ b/assets/admin/components/ServiceItemFormModal.tsx @@ -8,7 +8,7 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { ServiceItem, ClinicStaff } from '../types'; -import type { InventoryPackage } from '../hooks/useInventory'; +import type { InventoryPackage, InventoryItem } from '../hooks/useInventory'; import { rialToToman, tomanToRial } from '../lib/utils'; import { numericField } from '../lib/forms'; import Modal from './ui/Modal'; @@ -23,12 +23,14 @@ const itemSchema = z.object({ bookable: z.boolean().optional(), /** پکیج کالای مصرفی؛ رشته‌ی خالی یعنی بدون پکیج. */ inventory_package_uuid: z.string().optional(), + /** اقلام کالای تکی — مستقل از پکیج. */ + consumables: z.array(z.object({ item_uuid: z.string(), amount: z.coerce.number().min(1) })).optional(), }); type ItemForm = z.infer; const EMPTY_FORM: ItemForm = { name: '', price_rials: 0, staff_uuids: [], duration_minutes: undefined, - bookable: false, inventory_package_uuid: '', + bookable: false, inventory_package_uuid: '', consumables: [], }; interface Props { @@ -65,6 +67,14 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan }); const packages = packagesData?.data ?? []; + // این endpoint پاسخ را در { items, stats } می‌پیچد. + const { data: inventoryData } = useQuery>({ + queryKey: ['inventory-items'], + queryFn: () => api.get('/api/v1/inventory-items'), + enabled: item !== null, + }); + const inventoryItems = inventoryData?.data?.items ?? []; + const form = useForm({ resolver: zodResolver(itemSchema), defaultValues: EMPTY_FORM }); useEffect(() => { @@ -77,6 +87,7 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan duration_minutes: editing.duration_minutes ?? undefined, bookable: editing.bookable ?? false, inventory_package_uuid: editing.inventory_package_uuid ?? '', + consumables: (editing.consumables ?? []).map((c) => ({ item_uuid: c.item_uuid, amount: c.amount })), } : EMPTY_FORM); }, [item]); @@ -117,6 +128,15 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan .filter((s) => s.active || editingMembers.some((m) => m.uuid === s.uuid)) .filter((s) => !selectedStaffUuids.includes(s.uuid)) .map((s) => ({ value: s.uuid, label: s.active ? s.full_name : `${s.full_name} (غیرفعال)` })); + const consumables = form.watch('consumables') ?? []; + const inventoryOptions = inventoryItems + .filter((i) => !consumables.some((c) => c.item_uuid === i.uuid)) + .map((i) => ({ value: i.uuid, label: `${i.name} (${i.unit})` })); + const inventoryNameOf = (uuid: string) => inventoryItems.find((i) => i.uuid === uuid)?.name ?? uuid; + const inventoryUnitOf = (uuid: string) => inventoryItems.find((i) => i.uuid === uuid)?.unit ?? ''; + const addConsumable = (uuid: string) => + form.setValue('consumables', [...consumables, { item_uuid: uuid, amount: 1 }]); + const staffNameOf = (uuid: string) => allStaff.find((s) => s.uuid === uuid)?.full_name ?? editingMembers.find((m) => m.uuid === uuid)?.full_name @@ -234,6 +254,46 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan height={42} /> + + {/* کالای تکی — مستقل از پکیج و قابل استفاده هم‌زمان با آن. */} +
+ + { if (v != null) addConsumable(String(v)); }} + placeholder="افزودن کالا (اختیاری)" + noOptionsMessage="کالایی باقی نمانده" + height={42} + /> + {consumables.length > 0 && ( +
+ {consumables.map((line, i) => ( +
+ {inventoryNameOf(line.item_uuid)} + + + {inventoryUnitOf(line.item_uuid)} + + +
+ ))} +
+ )} +
{/* بیمه — تنظیمات فقط در «پوشش بیمه» مدیریت می‌شود تا داده‌ی تکراری ساخته نشود. */} diff --git a/assets/admin/pages/ServiceDetailPage.test.tsx b/assets/admin/pages/ServiceDetailPage.test.tsx index 6a5d7c47..9a9ba723 100644 --- a/assets/admin/pages/ServiceDetailPage.test.tsx +++ b/assets/admin/pages/ServiceDetailPage.test.tsx @@ -55,6 +55,9 @@ const mockApi = (item: unknown = ITEM, notFound = false) => { if (url.includes('/tenant-insurances')) return Promise.resolve({ data: { data: [ { uuid: 'ins1', insurance_name: 'بیمه ایران', insurance_kind: 'basic', coverage_percent: 70 }, ] } }); + if (url.includes('/inventory-items')) return Promise.resolve({ success: true, data: { items: [ + { uuid: 'inv1', name: 'گاز استریل', unit: 'عدد', price: 50_000, stock: 100 }, + ] } }); if (url.includes('/inventory-packages')) return Promise.resolve({ success: true, data: [ { uuid: 'pkg1', title: 'پکیج سرم', total: 1_200_000, available: true, @@ -145,14 +148,42 @@ describe('ServiceDetailPage (جزئیات سرویس)', () => { expect(screen.getByText('۲ عدد')).toBeInTheDocument(); }); - it('سرویس بدون پکیج، حالت خالی با لینک انبار می‌دهد', async () => { + it('سرویس بدون هیچ کالایی، حالت خالی با لینک انبار می‌دهد', async () => { render(); fireEvent.click(await screen.findByText('کالاهای مرتبط')); - expect(await screen.findByText('پکیج کالایی به این سرویس متصل نیست')).toBeInTheDocument(); + expect(await screen.findByText('کالایی به این سرویس متصل نیست')).toBeInTheDocument(); expect(screen.getByText('مدیریت انبار')).toBeInTheDocument(); }); + it('پکیج و کالای تکی هم‌زمان در تب کالاها نمایش داده می‌شوند', async () => { + mockApi({ + ...ITEM, + inventory_package_uuid: 'pkg1', + inventory_package_title: 'پکیج سرم', + consumables: [{ item_uuid: 'inv1', name: 'گاز استریل', unit: 'عدد', price: 50_000, stock: 100, amount: 3 }], + }); + render(); + fireEvent.click(await screen.findByText('کالاهای مرتبط')); + + expect(await screen.findByText('پکیج')).toBeInTheDocument(); + expect(screen.getByText('پکیج سرم')).toBeInTheDocument(); + expect(screen.getByText('کالاهای تکی')).toBeInTheDocument(); + expect(screen.getByText('گاز استریل')).toBeInTheDocument(); + expect(screen.getByText('۳ عدد')).toBeInTheDocument(); + }); + + it('کالای تکی با موجودی ناکافی نشان‌دار می‌شود', async () => { + mockApi({ + ...ITEM, + consumables: [{ item_uuid: 'inv1', name: 'گاز استریل', unit: 'عدد', price: 50_000, stock: 1, amount: 5 }], + }); + render(); + fireEvent.click(await screen.findByText('کالاهای مرتبط')); + + expect(await screen.findByText('موجودی ناکافی')).toBeInTheDocument(); + }); + it('تب لاگ تغییرات، مقدار قبل و بعد را خوانا نشان می‌دهد', async () => { render(); fireEvent.click(await screen.findByText('لاگ تغییرات')); diff --git a/assets/admin/pages/ServiceDetailPage.tsx b/assets/admin/pages/ServiceDetailPage.tsx index 75583337..9485acfc 100644 --- a/assets/admin/pages/ServiceDetailPage.tsx +++ b/assets/admin/pages/ServiceDetailPage.tsx @@ -293,6 +293,8 @@ function GoodsTab({ item, onEdit }: { item: ServiceItem; onEdit: () => void }) { }); const linked = (data?.data ?? []).find((p) => p.uuid === item.inventory_package_uuid); + const consumables = item.consumables ?? []; + const nothingLinked = !item.inventory_package_uuid && consumables.length === 0; return (
@@ -300,52 +302,84 @@ function GoodsTab({ item, onEdit }: { item: ServiceItem; onEdit: () => void }) {
کالاهای مرتبط
- پکیج کالای مصرفی این خدمت؛ در «انبار» ساخته و در فرم سرویس انتخاب می‌شود. + پکیج آماده و کالاهای تکیِ مصرفی این خدمت؛ در «انبار» ساخته و در فرم سرویس انتخاب می‌شوند.
- +
{isLoading ? (
در حال بارگذاری...
- ) : !item.inventory_package_uuid ? ( + ) : nothingLinked ? (
-

پکیج کالایی به این سرویس متصل نیست

+

کالایی به این سرویس متصل نیست

مدیریت انبار
- ) : !linked ? ( -
- {item.inventory_package_title ?? 'پکیج متصل'} — جزئیات در دسترس نیست -
) : ( -
-
- - - {linked.title} - {!linked.available && موجودی ناکافی} - - {formatRial(linked.total)} -
+
+ {item.inventory_package_uuid && ( +
+
پکیج
+ {!linked ? ( +
+ {item.inventory_package_title ?? 'پکیج متصل'} — جزئیات در دسترس نیست +
+ ) : ( + <> +
+ + + {linked.title} + {!linked.available && موجودی ناکافی} + + {formatRial(linked.total)} +
-
- {linked.items.map((line) => ( -
- {line.name} - - {formatNumber(line.amount)} {line.unit} - {formatRial(line.price * line.amount)} - +
+ {linked.items.map((line) => ( +
+ {line.name} + + {formatNumber(line.amount)} {line.unit} + {formatRial(line.price * line.amount)} + +
+ ))} +
+ + )} +
+ )} + + {consumables.length > 0 && ( +
+
کالاهای تکی
+
+ {consumables.map((line) => ( +
+ + {line.name} + {line.stock < line.amount && موجودی ناکافی} + + + {formatNumber(line.amount)} {line.unit} + {formatRial(line.price * line.amount)} + +
+ ))}
- ))} -
+
+ )}
)}
diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 2b018291..2555c68a 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -510,6 +510,16 @@ export interface ServiceSection { items_count?: number; } +/** یک قلم کالای تکی روی سرویس (خروجی ServiceItemConsumable::toArray) */ +export interface ServiceConsumable { + item_uuid: string; + name: string; + unit: string; + price: number; // Rial + stock: number; + amount: number; +} + export interface ServiceItem { uuid: string; name: string; @@ -519,6 +529,8 @@ export interface ServiceItem { /** پکیج کالای مصرفی متصل به این خدمت (اختیاری) */ inventory_package_uuid?: string | null; inventory_package_title?: string | null; + /** اقلام کالای تکی — مستقل از پکیج و قابل استفاده هم‌زمان با آن */ + consumables?: ServiceConsumable[]; created_at?: number; updated_at?: number; /** primary staff (first member) — kept for backward compatibility */ diff --git a/docs/api/clinic-services.md b/docs/api/clinic-services.md index b93917c2..b1428542 100644 --- a/docs/api/clinic-services.md +++ b/docs/api/clinic-services.md @@ -142,6 +142,11 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س "bookable": true, "staff": { "uuid": "...", "full_name": "مریم امینی" }, "staff_members": [{ "uuid": "...", "full_name": "مریم امینی" }], + "inventory_package_uuid": "...", + "inventory_package_title": "پکیج سرم", + "consumables": [ + { "item_uuid": "...", "name": "گاز استریل", "unit": "عدد", "price": 50000, "stock": 100, "amount": 2 } + ], "created_at": 1718000000, "updated_at": 1718000000 } @@ -151,8 +156,8 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س **خطاها:** `404 ERR_SERVICE_NOT_FOUND` — هم برای uuid ناموجود و هم برای سرویس متعلق به tenant دیگر (وجود سرویس نباید لو برود) · `401` بدون احراز هویت. -> `section_name`، `inventory_package_id`، `inventory_package_uuid` و `inventory_package_title` در **همه‌ی** -> پاسخ‌های سرویس این فایل هستند، نه فقط این endpoint. دو فیلد آخر توسط کنترلر اضافه می‌شوند (نه `toArray()`) +> `section_name`، `inventory_package_id`، `inventory_package_uuid`، `inventory_package_title` و `consumables` +> در **همه‌ی** پاسخ‌های سرویس این فایل هستند، نه فقط این endpoint. دو فیلد آخر توسط کنترلر اضافه می‌شوند (نه `toArray()`) > و پکیج‌ها با یک کوئری batch واکشی می‌شوند تا فهرست سرویس‌ها به N+1 نیفتد. --- @@ -182,7 +187,7 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س ``` **فیلدهای رهگیری‌شده** (`ServiceItemAuditService::TRACKED`): `name`، `price_rials`، `active`، -`duration_minutes`، `bookable`، `insurance_covered`، `inventory_package`. +`duration_minutes`، `bookable`، `insurance_covered`، `inventory_package`، `consumables`. - هر فیلدِ تغییریافته **یک ردیف جدا** می‌سازد؛ فیلد بدون تغییر ردیف نمی‌سازد. - `operation`: `create` (هنگام ساخت خدمت، فقط یک ردیف روی فیلد `name`) یا `update`. @@ -226,6 +231,7 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س | duration_minutes | integer\|null | ❌ — «زمان متوسط» انجام خدمت به دقیقه (`""`/`null` = بدون مقدار) | | bookable | boolean | ❌ (پیش‌فرض false) — «نمایش در نوبت‌دهی». فقط سرویس‌های `bookable=true` در حالت نوبت‌دهی سرویسی قابل‌انتخاب‌اند | | inventory_package_uuid | UUID\|null | ❌ — پکیج کالای مصرفی این خدمت ([inventory.md](inventory.md)). `null`/`""` یعنی قطع اتصال. پکیج باید متعلق به همان مطب/کلینیک باشد وگرنه `422 ERR_VALIDATION_001` با فیلد `inventory_package_uuid` | +| consumables | array\|null | ❌ — کالاهای **تکی** این خدمت: `[{ "item_uuid": "…", "amount": 2 }]`. **مکمل پکیج است، نه جایگزین آن** — یک خدمت می‌تواند هم‌زمان پکیج و کالای تکی داشته باشد. ارسال این فیلد کل فهرست را **جایگزین** می‌کند (`[]` = حذف همه). هر کالا باید متعلق به همان مطب/کلینیک باشد وگرنه `422 ERR_VALIDATION_001` با فیلد `consumables`. `amount` حداقل ۱ است | > `bookable` در `PATCH /api/v1/service-item/{uuid}` هم به همین شکل پذیرفته می‌شود. diff --git a/migrations/Version20260718085747.php b/migrations/Version20260718085747.php new file mode 100644 index 00000000..bbd57900 --- /dev/null +++ b/migrations/Version20260718085747.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE service_item_consumables (id INT AUTO_INCREMENT NOT NULL, amount INT NOT NULL, service_item_id INT NOT NULL, item_id INT NOT NULL, INDEX IDX_DC02A217DDEB00C2 (service_item_id), INDEX IDX_DC02A217126F525E (item_id), UNIQUE INDEX uniq_service_item_consumable (service_item_id, item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE service_item_consumables ADD CONSTRAINT FK_DC02A217DDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE service_item_consumables ADD CONSTRAINT FK_DC02A217126F525E FOREIGN KEY (item_id) REFERENCES inventory_items (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE service_item_consumables DROP FOREIGN KEY FK_DC02A217DDEB00C2'); + $this->addSql('ALTER TABLE service_item_consumables DROP FOREIGN KEY FK_DC02A217126F525E'); + $this->addSql('DROP TABLE service_item_consumables'); + } +} diff --git a/src/ClinicService/Controller/ClinicServiceController.php b/src/ClinicService/Controller/ClinicServiceController.php index 6856da9f..fccaf69d 100644 --- a/src/ClinicService/Controller/ClinicServiceController.php +++ b/src/ClinicService/Controller/ClinicServiceController.php @@ -17,6 +17,7 @@ use App\ClinicService\Service\ServiceItemAuditService; use App\ClinicService\Service\TariffService; use App\Clinic\Repository\ClinicRepository; use App\Doctor\Repository\DoctorRepository; +use App\Inventory\Repository\InventoryItemRepository; use App\Inventory\Repository\InventoryPackageRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; @@ -44,6 +45,7 @@ class ClinicServiceController extends BaseController private readonly TariffRepository $tariffRepo, private readonly TariffService $tariffService, private readonly InventoryPackageRepository $packageRepo, + private readonly InventoryItemRepository $inventoryItemRepo, private readonly ServiceItemAuditService $auditService, private readonly ServiceItemAuditLogRepository $auditLogRepo, private readonly EntityManagerInterface $em, @@ -98,6 +100,39 @@ class ClinicServiceController extends BaseController return null; } + /** + * `consumables: [{item_uuid, amount}]` را روی خدمت می‌نشاند. آرایه‌ی خالی یعنی حذف + * همه‌ی اقلام. هر قلم باید متعلق به همان مطب/کلینیک باشد. + */ + private function applyConsumables(ServiceItem $item, array $data, string $entityType, ?int $entityId): ?JsonResponse + { + if (!array_key_exists('consumables', $data)) { + return null; + } + + $lines = []; + foreach ((array) ($data['consumables'] ?? []) as $raw) { + $uuid = is_array($raw) ? ($raw['item_uuid'] ?? null) : null; + if ($uuid === null || $uuid === '') { + continue; + } + + $inventoryItem = $this->inventoryItemRepo->findByUuid((string) $uuid); + if ($inventoryItem === null + || $inventoryItem->getEntityType() !== $entityType + || $inventoryItem->getEntityId() !== $entityId + ) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کالای انتخاب‌شده یافت نشد', 422, 'consumables'); + } + + $lines[] = ['item' => $inventoryItem, 'amount' => max(1, (int) ($raw['amount'] ?? 1))]; + } + + $item->replaceConsumables($lines); + + return null; + } + // ── Service Sections ───────────────────────────────────────────────────── #[Route('/api/v1/service-sections', methods: ['GET'])] @@ -275,6 +310,10 @@ class ClinicServiceController extends BaseController if ($packageError !== null) { return $packageError; } + $consumableError = $this->applyConsumables($item, $data, $entityType, $entityId); + if ($consumableError !== null) { + return $consumableError; + } $this->itemRepo->save($item); @@ -325,6 +364,10 @@ class ClinicServiceController extends BaseController if ($packageError !== null) { return $packageError; } + $consumableError = $this->applyConsumables($item, $data, $entityType, $entityId); + if ($consumableError !== null) { + return $consumableError; + } $this->itemRepo->save($item); diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php index 2ebf1567..d5ab09a7 100644 --- a/src/ClinicService/Entity/ServiceItem.php +++ b/src/ClinicService/Entity/ServiceItem.php @@ -70,6 +70,14 @@ class ServiceItem #[ORM\Column(name: 'inventory_package_id', type: 'integer', nullable: true)] private ?int $inventoryPackageId = null; + /** + * اقلام کالای تکیِ این خدمت — مستقل از پکیج و قابل استفاده هم‌زمان با آن. + * + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'serviceItem', targetEntity: ServiceItemConsumable::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $consumables; + #[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt; @@ -85,6 +93,51 @@ class ServiceItem $this->createdAt = time(); $this->updatedAt = time(); $this->staffMembers = new ArrayCollection(); + $this->consumables = new ArrayCollection(); + } + + /** @return Collection */ + public function getConsumables(): Collection + { + // Doctrine بدون constructor هیدریت می‌کند؛ از property تایپ‌شده محافظت کن. + return $this->consumables ??= new ArrayCollection(); + } + + /** + * جایگزینی کامل اقلام تکی. کلید تطبیق، خودِ InventoryItem است تا ردیف بدون تغییر + * حذف و دوباره ساخته نشود. + * + * @param array $lines + */ + public function replaceConsumables(array $lines): self + { + $existing = []; + foreach ($this->getConsumables() as $consumable) { + $existing[$consumable->getItem()->getId()] = $consumable; + } + + $keep = []; + foreach ($lines as $line) { + $itemId = $line['item']->getId(); + $keep[] = $itemId; + + if (isset($existing[$itemId])) { + $existing[$itemId]->setAmount($line['amount']); + continue; + } + + $this->getConsumables()->add((new ServiceItemConsumable($line['item'], $line['amount']))->setServiceItem($this)); + } + + foreach ($existing as $itemId => $consumable) { + if (!in_array($itemId, $keep, true)) { + $this->getConsumables()->removeElement($consumable); + } + } + + $this->updatedAt = time(); + + return $this; } public function getId(): ?int { return $this->id; } @@ -170,6 +223,10 @@ class ServiceItem 'duration_minutes' => $this->durationMinutes, 'bookable' => $this->bookable, 'inventory_package_id' => $this->inventoryPackageId, + 'consumables' => array_map( + fn(ServiceItemConsumable $c) => $c->toArray(), + $this->getConsumables()->toArray() + ), 'created_at' => $this->createdAt, 'updated_at' => $this->updatedAt, ]; diff --git a/src/ClinicService/Entity/ServiceItemConsumable.php b/src/ClinicService/Entity/ServiceItemConsumable.php new file mode 100644 index 00000000..2178feca --- /dev/null +++ b/src/ClinicService/Entity/ServiceItemConsumable.php @@ -0,0 +1,60 @@ +item = $item; + $this->amount = max(1, $amount); + } + + public function getId(): ?int { return $this->id; } + public function getServiceItem(): ServiceItem { return $this->serviceItem; } + public function getItem(): InventoryItem { return $this->item; } + public function getAmount(): int { return $this->amount; } + + public function setServiceItem(ServiceItem $s): self { $this->serviceItem = $s; return $this; } + public function setAmount(int $v): self { $this->amount = max(1, $v); return $this; } + + public function toArray(): array + { + return [ + 'item_uuid' => $this->item->getUuid(), + 'name' => $this->item->getName(), + 'unit' => $this->item->getUnit(), + 'price' => $this->item->getPrice(), + 'stock' => $this->item->getStock(), + 'amount' => $this->amount, + ]; + } +} diff --git a/src/ClinicService/Service/ServiceItemAuditService.php b/src/ClinicService/Service/ServiceItemAuditService.php index fd00bbbb..f96088b3 100644 --- a/src/ClinicService/Service/ServiceItemAuditService.php +++ b/src/ClinicService/Service/ServiceItemAuditService.php @@ -22,6 +22,7 @@ class ServiceItemAuditService 'bookable' => 'نمایش در نوبت‌دهی', 'insurance_covered' => 'پوشش بیمه', 'inventory_package' => 'پکیج کالا', + 'consumables' => 'کالاهای تکی', ]; public function __construct(private readonly ServiceItemAuditLogRepository $repo) {} @@ -37,9 +38,27 @@ class ServiceItemAuditService 'bookable' => $item->isBookable() ? '1' : '0', 'insurance_covered' => $item->isInsuranceCovered() ? '1' : '0', 'inventory_package' => $item->getInventoryPackageId() === null ? null : (string) $item->getInventoryPackageId(), + 'consumables' => $this->consumablesFingerprint($item), ]; } + /** امضای مرتب‌شده‌ی اقلام تکی تا تغییر در قلم یا تعداد قابل تشخیص باشد. */ + private function consumablesFingerprint(ServiceItem $item): ?string + { + $lines = []; + foreach ($item->getConsumables() as $consumable) { + $lines[] = $consumable->getItem()->getName() . '×' . $consumable->getAmount(); + } + + if ($lines === []) { + return null; + } + + sort($lines); + + return implode('، ', $lines); + } + public function logCreate(ServiceItem $item, ?User $actor): void { $this->repo->save( diff --git a/tests/ClinicService/ServiceItemPackageAndAuditTest.php b/tests/ClinicService/ServiceItemPackageAndAuditTest.php index 086cde35..4916a818 100644 --- a/tests/ClinicService/ServiceItemPackageAndAuditTest.php +++ b/tests/ClinicService/ServiceItemPackageAndAuditTest.php @@ -6,6 +6,7 @@ use App\Auth\Entity\User; use App\ClinicService\Entity\ServiceItem; use App\ClinicService\Entity\ServiceSection; use App\Doctor\Entity\Doctor; +use App\Inventory\Entity\InventoryItem; use App\Inventory\Entity\InventoryPackage; use App\Tests\ApiTestCase; @@ -114,6 +115,140 @@ class ServiceItemPackageAndAuditTest extends ApiTestCase $this->assertSame(422, $this->responseCode()); } + // ── کالاهای تکی (مکمل پکیج) ────────────────────────────────────────────── + + private function makeInventoryItem(Doctor $doctor, string $name, int $price = 50_000): InventoryItem + { + $inventoryItem = new InventoryItem('doctor', $doctor->getId(), $name); + $inventoryItem->setPrice($price)->setStock(100); + $this->em->persist($inventoryItem); + $this->em->flush(); + + return $inventoryItem; + } + + public function testAServiceCanHaveBothAPackageAndIndividualItems(): void + { + [$owner, $doctor, $section] = $this->makeDoctorWithSection(); + $package = $this->makePackage($doctor); + $gauze = $this->makeInventoryItem($doctor, 'گاز استریل'); + $syringe = $this->makeInventoryItem($doctor, 'سرنگ ۵cc'); + $item = $this->makeItem($section); + + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'inventory_package_uuid' => $package->getUuid(), + 'consumables' => [ + ['item_uuid' => $gauze->getUuid(), 'amount' => 2], + ['item_uuid' => $syringe->getUuid(), 'amount' => 1], + ], + ]); + $this->assertSame(200, $this->responseCode()); + + $body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner); + $row = $body['data']; + + $this->assertSame($package->getUuid(), $row['inventory_package_uuid'], 'پکیج باید کنار اقلام تکی بماند'); + $this->assertCount(2, $row['consumables']); + + $byName = array_column($row['consumables'], null, 'name'); + $this->assertSame(2, $byName['گاز استریل']['amount']); + $this->assertSame(1, $byName['سرنگ ۵cc']['amount']); + $this->assertSame(50_000, $byName['گاز استریل']['price']); + } + + public function testResendingConsumablesReplacesTheWholeList(): void + { + [$owner, $doctor, $section] = $this->makeDoctorWithSection(); + $gauze = $this->makeInventoryItem($doctor, 'گاز استریل'); + $syringe = $this->makeInventoryItem($doctor, 'سرنگ ۵cc'); + $item = $this->makeItem($section); + + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'consumables' => [ + ['item_uuid' => $gauze->getUuid(), 'amount' => 2], + ['item_uuid' => $syringe->getUuid(), 'amount' => 1], + ], + ]); + + // فقط یک قلم با تعداد جدید ارسال می‌شود + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'consumables' => [['item_uuid' => $gauze->getUuid(), 'amount' => 5]], + ]); + + $body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner); + $this->assertCount(1, $body['data']['consumables']); + $this->assertSame('گاز استریل', $body['data']['consumables'][0]['name']); + $this->assertSame(5, $body['data']['consumables'][0]['amount']); + } + + public function testEmptyConsumablesArrayClearsThem(): void + { + [$owner, $doctor, $section] = $this->makeDoctorWithSection(); + $gauze = $this->makeInventoryItem($doctor, 'گاز استریل'); + $item = $this->makeItem($section); + + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'consumables' => [['item_uuid' => $gauze->getUuid(), 'amount' => 2]], + ]); + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, ['consumables' => []]); + + $body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner); + $this->assertSame([], $body['data']['consumables']); + } + + public function testConsumableOfAnotherTenantIsRejected(): void + { + [$owner, , $section] = $this->makeDoctorWithSection(); + $item = $this->makeItem($section); + + $stranger = $this->createUser(['ROLE_DOCTOR']); + $other = new Doctor($stranger, 'دکتر غریبه'); + $this->em->persist($other); + $this->em->flush(); + $foreignItem = $this->makeInventoryItem($other, 'کالای غریبه'); + + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'consumables' => [['item_uuid' => $foreignItem->getUuid(), 'amount' => 1]], + ]); + + $this->assertSame(422, $this->responseCode()); + } + + public function testConsumablesCanBeSetAtCreationTime(): void + { + [$owner, $doctor, $section] = $this->makeDoctorWithSection(); + $gauze = $this->makeInventoryItem($doctor, 'گاز استریل'); + + $created = $this->authJson('POST', '/api/v1/service-item', $owner, [ + 'section_uuid' => $section->getUuid(), + 'name' => 'پانسمان', + 'price_rials' => 200_000, + 'consumables' => [['item_uuid' => $gauze->getUuid(), 'amount' => 3]], + ]); + + $this->assertSame(201, $this->responseCode()); + $this->assertCount(1, $created['data']['consumables']); + $this->assertSame(3, $created['data']['consumables'][0]['amount']); + } + + public function testChangingConsumablesIsLogged(): void + { + [$owner, $doctor, $section] = $this->makeDoctorWithSection(); + $gauze = $this->makeInventoryItem($doctor, 'گاز استریل'); + $item = $this->makeItem($section); + + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'consumables' => [['item_uuid' => $gauze->getUuid(), 'amount' => 2]], + ]); + + $body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid() . '/audit-logs', $owner); + $byField = array_column($body['data'], null, 'field'); + + $this->assertArrayHasKey('consumables', $byField); + $this->assertNull($byField['consumables']['old_value']); + $this->assertSame('گاز استریل×2', $byField['consumables']['new_value']); + } + // ── لاگ تغییرات ────────────────────────────────────────────────────────── public function testCreatingAServiceWritesACreateLog(): void