branches->pair($user); // کل درخت با یک کوئری خوانده و در PHP بسته می‌شود. $all = $this->categories->findForPair($entityType, $entityId); $childrenOf = []; foreach ($all as $node) { $childrenOf[$node->getParent()?->getId() ?? 0][] = $node; } return $this->success($this->buildTree($childrenOf, 0)); } /** @return list> */ private function buildTree(array $childrenOf, int $parentId): array { return array_map( fn (CatalogCategory $node): array => $node->toArray( $this->buildTree($childrenOf, (int) $node->getId()), ), $childrenOf[$parentId] ?? [], ); } #[Route('/api/v1/service-category', name: 'service_category_create', methods: ['POST'])] public function createCategory(#[CurrentUser] User $user, Request $request): JsonResponse { $data = json_decode($request->getContent(), true); $name = is_array($data) && is_string($data['name'] ?? null) ? trim($data['name']) : ''; if ($name === '') { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام دسته الزامی است', 422, 'name'); } [$entityType, $entityId] = $this->branches->pair($user); $parent = is_string($data['parent_uuid'] ?? null) ? $this->requireCategory($user, $data['parent_uuid']) : null; $category = new CatalogCategory($entityType, $entityId, $name, $parent); if ($category->depth() > CatalogCategory::MAX_DEPTH) { return $this->error( ErrorCodes::ERR_VALIDATION_001, sprintf('عمق دسته‌بندی حداکثر %d سطح است', CatalogCategory::MAX_DEPTH), 422, 'parent_uuid', ); } if (is_numeric($data['sort_order'] ?? null)) { $category->setSortOrder((int) $data['sort_order']); } $this->em->persist($category); $this->em->flush(); return $this->success($category->toArray(), 201); } #[Route('/api/v1/service-category/{uuid}', name: 'service_category_update', methods: ['PATCH'])] public function updateCategory(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse { $data = json_decode($request->getContent(), true); $category = $this->requireCategory($user, $uuid); if (!is_array($data)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); } if (is_string($data['name'] ?? null) && trim($data['name']) !== '') { $category->setName(trim($data['name'])); } if (is_numeric($data['sort_order'] ?? null)) { $category->setSortOrder((int) $data['sort_order']); } if (array_key_exists('active', $data)) { $category->setActive((bool) $data['active']); } $this->em->flush(); return $this->success($category->toArray()); } #[Route('/api/v1/service-category/{uuid}', name: 'service_category_delete', methods: ['DELETE'])] public function deleteCategory(#[CurrentUser] User $user, string $uuid): JsonResponse { $category = $this->requireCategory($user, $uuid); if ($this->categories->countChildren($category) > 0) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'ابتدا زیردسته‌ها را حذف کنید', 422); } $this->em->remove($category); $this->em->flush(); return $this->success(null); } // ── گروه آیتم ─────────────────────────────────────────────────────────── #[Route('/api/v1/service-item/{uuid}/groups', name: 'service_item_groups', methods: ['GET'])] public function listGroups(#[CurrentUser] User $user, string $uuid): JsonResponse { $service = $this->requireItem($user, $uuid); return $this->success(array_map( static fn (ItemGroup $g): array => $g->toArray(), $this->groups->findForService($service), )); } #[Route('/api/v1/service-item/{uuid}/groups', name: 'service_item_group_create', methods: ['POST'])] public function createGroup(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse { $data = json_decode($request->getContent(), true); $service = $this->requireItem($user, $uuid); $name = is_array($data) && is_string($data['name'] ?? null) ? trim($data['name']) : ''; if ($name === '') { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام گروه الزامی است', 422, 'name'); } $group = new ItemGroup($service, $name); $this->applyGroupRange($group, $data); if (is_numeric($data['sort_order'] ?? null)) { $group->setSortOrder((int) $data['sort_order']); } $this->em->persist($group); $this->em->flush(); return $this->success($group->toArray(), 201); } #[Route('/api/v1/item-group/{uuid}', name: 'item_group_update', methods: ['PATCH'])] public function updateGroup(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse { $data = json_decode($request->getContent(), true); $group = $this->requireGroup($user, $uuid); if (!is_array($data)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); } if (is_string($data['name'] ?? null) && trim($data['name']) !== '') { $group->setName(trim($data['name'])); } $this->applyGroupRange($group, $data); if (is_numeric($data['sort_order'] ?? null)) { $group->setSortOrder((int) $data['sort_order']); } $this->em->flush(); return $this->success($group->toArray()); } #[Route('/api/v1/item-group/{uuid}', name: 'item_group_delete', methods: ['DELETE'])] public function deleteGroup(#[CurrentUser] User $user, string $uuid): JsonResponse { $this->em->remove($this->requireGroup($user, $uuid)); $this->em->flush(); return $this->success(null); } /** جایگزینی کامل آیتم‌های گروه. */ #[Route('/api/v1/item-group/{uuid}/items', name: 'item_group_items_replace', methods: ['PUT'])] public function replaceGroupItems(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse { $data = json_decode($request->getContent(), true); if (!is_array($data) || !is_array($data['items'] ?? null)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد items الزامی است', 422, 'items'); } $group = $this->requireGroup($user, $uuid); // همهٔ آیتم‌ها پیش از هر حذفی حل می‌شوند: uuid نامعتبر در انتهای فهرست نباید // اعضای درستِ قبلی را پاک کند و بعد ۴۰۴ بدهد. $resolved = []; foreach ($data['items'] as $index => $row) { $itemUuid = is_array($row) ? ($row['item_uuid'] ?? null) : $row; if (!is_string($itemUuid) || $itemUuid === '') { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد item_uuid الزامی است', 422, 'item_uuid'); } $sortOrder = is_array($row) && is_numeric($row['sort_order'] ?? null) ? (int) $row['sort_order'] : $index; $resolved[] = [$this->requireItem($user, $itemUuid), $sortOrder]; } $this->groupMembers->deleteForGroup($group); $group->getMembers()->clear(); foreach ($resolved as [$item, $sortOrder]) { $member = new ItemGroupMember($group, $item, $sortOrder); $this->em->persist($member); $group->getMembers()->add($member); } $group->touch(); $this->em->flush(); return $this->success($group->toArray()); } // ── رابطهٔ آیتم‌ها ─────────────────────────────────────────────────────── #[Route('/api/v1/service-item/{uuid}/relations', name: 'service_item_relations_replace', methods: ['PUT'])] public function replaceRelations(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse { $data = json_decode($request->getContent(), true); if (!is_array($data) || !is_array($data['relations'] ?? null)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد relations الزامی است', 422, 'relations'); } $item = $this->requireItem($user, $uuid); $resolved = []; foreach ($data['relations'] as $row) { if (!is_array($row) || !is_string($row['related_item_uuid'] ?? null) || !is_string($row['type'] ?? null)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'related_item_uuid و type الزامی‌اند', 422, 'relations'); } if (!in_array($row['type'], ServiceItemRelation::TYPES, true)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع رابطه نامعتبر است', 422, 'type'); } $related = $this->requireItem($user, $row['related_item_uuid']); if ($related->getId() === $item->getId()) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آیتم نمی‌تواند با خودش رابطه داشته باشد', 422, 'related_item_uuid'); } if ($row['type'] === ServiceItemRelation::TYPE_REQUIRES && $this->wouldCycle($item, $related)) { return $this->error( ErrorCodes::ERR_VALIDATION_001, sprintf('«%s» به‌طور غیرمستقیم پیش‌نیاز «%s» است؛ حلقهٔ پیش‌نیاز مجاز نیست', $related->getName(), $item->getName()), 422, 'related_item_uuid', ); } $resolved[] = [$related, $row['type']]; } $this->relations->deleteForItem($item); foreach ($resolved as [$related, $type]) { $this->em->persist(new ServiceItemRelation($item, $related, $type)); } $this->em->flush(); return $this->success(array_map( static fn (ServiceItemRelation $r): array => $r->toArray(), $this->relations->findForItem($item), )); } /** * آیا «`$related` پیش‌نیاز `$item`» حلقه می‌سازد؟ یعنی آیا `$item` از راه زنجیرهٔ * پیش‌نیازها به `$related` می‌رسد. بدون این بررسی، «الف نیازمند ب» و «ب نیازمند * الف» هر دو ذخیره می‌شدند و اعتبارسنجی انتخاب هرگز راضی نمی‌شد. */ private function wouldCycle(ServiceItem $item, ServiceItem $related): bool { $stack = [$related]; $visited = []; while ($stack !== []) { $current = array_pop($stack); $id = (int) $current->getId(); if (isset($visited[$id])) { continue; } $visited[$id] = true; if ($id === (int) $item->getId()) { return true; } foreach ($this->relations->findForItem($current) as $relation) { if ($relation->getType() === ServiceItemRelation::TYPE_REQUIRES) { $stack[] = $relation->getRelatedItem(); } } } return false; } // ── قیمت و مدت اختصاصی شعبه ───────────────────────────────────────────── #[Route('/api/v1/service-item/{uuid}/branch-overrides', name: 'service_item_overrides_replace', methods: ['PUT'])] public function replaceBranchOverrides(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse { $data = json_decode($request->getContent(), true); if (!is_array($data) || !is_array($data['overrides'] ?? null)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد overrides الزامی است', 422, 'overrides'); } $item = $this->requireItem($user, $uuid); $resolved = []; // مثل بقیهٔ PUT های این پروژه: همه‌چیز پیش از هر حذفی حل و اعتبارسنجی می‌شود. foreach ($data['overrides'] as $row) { if (!is_array($row) || !is_string($row['address_uuid'] ?? null)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد address_uuid الزامی است', 422, 'address_uuid'); } foreach (['price_rials', 'solo_duration_minutes', 'additional_duration_minutes'] as $field) { $value = $row[$field] ?? null; if ($value !== null && (!is_numeric($value) || (int) $value < 0)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf('%s نامعتبر است', $field), 422, $field); } } $resolved[] = [$this->branches->resolve($user, $row['address_uuid']), $row]; } $this->overrides->deleteForItem($item); foreach ($resolved as [$address, $row]) { $override = new ServiceBranchOverride($item, $address); // `isset()` خودش null را رد می‌کند، پس مقایسهٔ اضافه لازم نیست. // `null` یعنی «همان مقدار خودِ سرویس» — صفر نیست. $override->setPriceRials(isset($row['price_rials']) ? (int) $row['price_rials'] : null); $override->setSoloDurationMinutes(isset($row['solo_duration_minutes']) ? (int) $row['solo_duration_minutes'] : null); $override->setAdditionalDurationMinutes(isset($row['additional_duration_minutes']) ? (int) $row['additional_duration_minutes'] : null); $this->em->persist($override); } $this->em->flush(); return $this->success(array_map( static fn (ServiceBranchOverride $o): array => $o->toArray(), $this->overrides->findForItem($item), )); } // ── اعتبارسنجی انتخاب ─────────────────────────────────────────────────── /** * مهم‌ترین اندپوینت این تسک: سایت عمومی و پنل هر دو پیش از رفتن به مرحلهٔ انتخاب * زمان آن را صدا می‌زنند. */ #[Route('/api/v1/service-selection/validate', name: 'service_selection_validate', methods: ['POST'])] public function validateSelection(#[CurrentUser] User $user, Request $request): JsonResponse { $data = json_decode($request->getContent(), true); if (!is_array($data) || !is_array($data['item_uuids'] ?? null)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد item_uuids الزامی است', 422, 'item_uuids'); } $selected = []; foreach ($data['item_uuids'] as $itemUuid) { if (!is_string($itemUuid)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'item_uuids باید فهرستی از uuid باشد', 422, 'item_uuids'); } // آیتم محیط دیگر ۴۰۴ می‌دهد نه ۴۲۲: وجودش نباید لو برود. $selected[] = $this->requireItem($user, $itemUuid); } $groups = is_string($data['service_uuid'] ?? null) ? $this->groups->findForService($this->requireItem($user, $data['service_uuid'])) : $this->validator->groupsOf($selected); $address = is_string($data['branch_uuid'] ?? null) ? $this->branches->resolve($user, $data['branch_uuid']) : null; return $this->success($this->validator->validate($selected, $groups, $address)); } // ── حل uuid با بررسی محیط ─────────────────────────────────────────────── private function requireCategory(User $user, string $uuid): CatalogCategory { return $this->owned($user, $this->categories->findByUuid($uuid), 'دسته یافت نشد'); } private function requireGroup(User $user, string $uuid): ItemGroup { return $this->owned($user, $this->groups->findByUuid($uuid), 'گروه یافت نشد'); } private function requireItem(User $user, string $uuid): ServiceItem { $item = $this->items->findByUuid($uuid); [$entityType, $entityId] = $this->branches->pair($user); // ServiceItem جفت محیط خودش را ندارد؛ از بخشش می‌آید. if ($item === null || $item->getSection()->getEntityType() !== $entityType || $item->getSection()->getEntityId() !== $entityId ) { throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); } return $item; } /** * @template T of object * @param T|null $entity * @return T */ private function owned(User $user, ?object $entity, string $message): object { [$entityType, $entityId] = $this->branches->pair($user); if ($entity === null || !$this->ownership->belongsToPair($entityType, $entityId, $entity)) { throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, $message, 404); } return $entity; } /** @param array|null $data */ private function applyGroupRange(ItemGroup $group, ?array $data): void { if (!is_array($data)) { return; } $min = array_key_exists('min_select', $data) ? $data['min_select'] : $group->getMinSelect(); $max = array_key_exists('max_select', $data) ? $data['max_select'] : $group->getMaxSelect(); try { $group->setSelectRange( is_numeric($min) ? (int) $min : 0, $max === null ? null : (is_numeric($max) ? (int) $max : null), ); } catch (\InvalidArgumentException $e) { throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'بازهٔ انتخاب نامعتبر است', 422, 'max_select'); } } }