diff --git a/app/Http/Controllers/Api/V1/ItemsController.php b/app/Http/Controllers/Api/V1/ItemsController.php index e9b7c3f..7780437 100644 --- a/app/Http/Controllers/Api/V1/ItemsController.php +++ b/app/Http/Controllers/Api/V1/ItemsController.php @@ -22,7 +22,7 @@ public function __construct(private ItemsService $service) {} public function index(Request $request) { return ApiResponse::handle(function () use ($request) { - $filters = $request->only(['type', 'search', 'q', 'category_id']); + $filters = $request->only(['type', 'search', 'q', 'category_id', 'is_active']); $perPage = (int) ($request->input('size') ?? 20); $includeDeleted = filter_var($request->input('include_deleted', false), FILTER_VALIDATE_BOOLEAN); @@ -69,7 +69,7 @@ public function showByCode(Request $request, string $code) public function store(ItemStoreRequest $request) { return ApiResponse::handle(function () use ($request) { - return $this->service->createItem($request->validated()); + return $this->service->createItem($request->all()); }, __('message.item.created')); } @@ -81,7 +81,7 @@ public function store(ItemStoreRequest $request) public function update(int $id, ItemUpdateRequest $request) { return ApiResponse::handle(function () use ($id, $request) { - return $this->service->updateItem($id, $request->validated()); + return $this->service->updateItem($id, $request->all()); }, __('message.item.updated')); } diff --git a/app/Services/ItemsService.php b/app/Services/ItemsService.php index dd0f51c..ed6c8ca 100644 --- a/app/Services/ItemsService.php +++ b/app/Services/ItemsService.php @@ -2,7 +2,9 @@ namespace App\Services; +use App\Constants\SystemFields; use App\Helpers\ItemTypeHelper; +use App\Models\ItemMaster\ItemField; use App\Models\Materials\Material; use App\Models\Products\Product; use Illuminate\Support\Facades\DB; @@ -11,6 +13,128 @@ class ItemsService extends Service { + /** + * 고정 필드 목록 조회 (SystemFields + ItemField 기반) + */ + private function getKnownFields(string $sourceTable): array + { + $tenantId = $this->tenantId(); + + // 1. SystemFields에서 테이블 고정 컬럼 + $systemFields = SystemFields::getReservedKeys($sourceTable); + + // 2. ItemField에서 storage_type='column'인 필드의 field_key 조회 + $columnFields = ItemField::where('tenant_id', $tenantId) + ->where('source_table', $sourceTable) + ->where('storage_type', 'column') + ->whereNotNull('field_key') + ->pluck('field_key') + ->toArray(); + + // 3. 추가적인 API 전용 필드 + $apiFields = ['item_type', 'type_code', 'bom', 'product_type']; + + return array_unique(array_merge($systemFields, $columnFields, $apiFields)); + } + + /** + * 동적 필드 추출 + */ + private function extractDynamicOptions(array $params, string $sourceTable): array + { + $knownFields = $this->getKnownFields($sourceTable); + + $dynamicOptions = []; + foreach ($params as $key => $value) { + if (! in_array($key, $knownFields) && $value !== null && $value !== '') { + $dynamicOptions[$key] = $value; + } + } + + return $dynamicOptions; + } + + /** + * 기존 options와 동적 필드 병합 + */ + private function mergeOptionsWithDynamic($existingOptions, array $dynamicOptions): array + { + if (! is_array($existingOptions) || empty($existingOptions)) { + return $dynamicOptions; + } + + $isAssoc = array_keys($existingOptions) !== range(0, count($existingOptions) - 1); + + if ($isAssoc) { + return array_merge($existingOptions, $dynamicOptions); + } + + foreach ($dynamicOptions as $key => $value) { + $existingOptions[] = ['label' => $key, 'value' => $value]; + } + + return $existingOptions; + } + + /** + * options 정규화 [{label, value, unit}] + */ + private function normalizeOptions(?array $in): ?array + { + if (! $in) { + return null; + } + + $isAssoc = array_keys($in) !== range(0, count($in) - 1); + + if ($isAssoc) { + $out = []; + foreach ($in as $k => $v) { + $label = trim((string) $k); + $value = is_scalar($v) ? trim((string) $v) : json_encode($v, JSON_UNESCAPED_UNICODE); + if ($label !== '' || $value !== '') { + $out[] = ['label' => $label, 'value' => $value, 'unit' => '']; + } + } + + return $out ?: null; + } + + $out = []; + foreach ($in as $a) { + if (! is_array($a)) { + continue; + } + $label = trim((string) ($a['label'] ?? '')); + $value = trim((string) ($a['value'] ?? '')); + $unit = trim((string) ($a['unit'] ?? '')); + + if ($label === '' && $value === '') { + continue; + } + + $out[] = ['label' => $label, 'value' => $value, 'unit' => $unit]; + } + + return $out ?: null; + } + + /** + * 동적 필드를 options에 병합하고 정규화 + */ + private function processDynamicOptions(array &$data, string $sourceTable): void + { + $dynamicOptions = $this->extractDynamicOptions($data, $sourceTable); + if (! empty($dynamicOptions)) { + $existingOptions = $data['options'] ?? []; + $data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions); + } + + if (isset($data['options'])) { + $data['options'] = $this->normalizeOptions($data['options']); + } + } + /** * 통합 품목 조회 (materials + products UNION) * @@ -27,6 +151,7 @@ public function getItems(array $filters = [], int $perPage = 20, bool $includeDe $types = $filters['type'] ?? ['FG', 'PT', 'SM', 'RM', 'CS']; $search = trim($filters['search'] ?? $filters['q'] ?? ''); $categoryId = $filters['category_id'] ?? null; + $isActive = $filters['is_active'] ?? null; // null: 전체, true/1: 활성만, false/0: 비활성만 // 타입을 배열로 변환 (문자열인 경우 쉼표로 분리) if (is_string($types)) { @@ -43,9 +168,14 @@ public function getItems(array $filters = [], int $perPage = 20, bool $includeDe if (! empty($productTypes)) { $productsQuery = Product::query() ->where('tenant_id', $tenantId) - ->whereIn('product_type', $productTypes) - ->where('is_active', 1) - ->select([ + ->whereIn('product_type', $productTypes); + + // is_active 필터 적용 (null이면 전체 조회) + if ($isActive !== null) { + $productsQuery->where('is_active', filter_var($isActive, FILTER_VALIDATE_BOOLEAN) ? 1 : 0); + } + + $productsQuery->select([ 'id', 'product_type as item_type', 'code', @@ -55,6 +185,7 @@ public function getItems(array $filters = [], int $perPage = 20, bool $includeDe 'category_id', 'product_type as type_code', 'attributes', + 'is_active', 'created_at', 'deleted_at', ]); @@ -83,8 +214,14 @@ public function getItems(array $filters = [], int $perPage = 20, bool $includeDe if (! empty($materialTypes)) { $materialsQuery = Material::query() ->where('tenant_id', $tenantId) - ->whereIn('material_type', $materialTypes) - ->select([ + ->whereIn('material_type', $materialTypes); + + // is_active 필터 적용 (null이면 전체 조회) + if ($isActive !== null) { + $materialsQuery->where('is_active', filter_var($isActive, FILTER_VALIDATE_BOOLEAN) ? 1 : 0); + } + + $materialsQuery->select([ 'id', 'material_type as item_type', 'material_code as code', @@ -94,6 +231,7 @@ public function getItems(array $filters = [], int $perPage = 20, bool $includeDe 'category_id', 'material_type as type_code', 'attributes', + 'is_active', 'created_at', 'deleted_at', ]); @@ -230,17 +368,33 @@ public function getItem( } /** - * attributes JSON 필드를 최상위로 플랫 전개 + * attributes/options JSON 필드를 최상위로 플랫 전개 */ private function flattenAttributes(array $data): array { + // attributes 플랫 전개 $attributes = $data['attributes'] ?? []; if (is_string($attributes)) { $attributes = json_decode($attributes, true) ?? []; } unset($data['attributes']); - return array_merge($data, $attributes); + // options 플랫 전개 ([{label, value, unit}] 형태 → {label: value} 형태로 변환) + $options = $data['options'] ?? []; + if (is_string($options)) { + $options = json_decode($options, true) ?? []; + } + $flatOptions = []; + if (is_array($options)) { + foreach ($options as $opt) { + if (is_array($opt) && isset($opt['label'])) { + $flatOptions[$opt['label']] = $opt['value'] ?? ''; + } + } + } + // options는 원본 유지 (프론트에서 필요할 수 있음) + + return array_merge($data, $attributes, $flatOptions); } /** @@ -297,6 +451,9 @@ private function createProduct(array $data, int $tenantId, int $userId): Product // 품목 코드 중복 시 자동 증가 $data['code'] = $this->resolveUniqueCode($data['code'], $tenantId); + // 동적 필드를 options에 병합 + $this->processDynamicOptions($data, 'products'); + $payload = $data; $payload['tenant_id'] = $tenantId; $payload['created_by'] = $userId; @@ -317,6 +474,9 @@ private function createMaterial(array $data, int $tenantId, int $userId, string $code = $data['code'] ?? $data['material_code'] ?? ''; $data['material_code'] = $this->resolveUniqueMaterialCode($code, $tenantId); + // 동적 필드를 options에 병합 + $this->processDynamicOptions($data, 'materials'); + $payload = [ 'tenant_id' => $tenantId, 'created_by' => $userId, @@ -483,6 +643,9 @@ private function updateProduct(int $id, array $data): Product } } + // 동적 필드를 options에 병합 + $this->processDynamicOptions($data, 'products'); + // item_type은 DB 필드가 아니므로 제거 unset($data['item_type']); $data['updated_by'] = $userId; @@ -522,6 +685,9 @@ private function updateMaterial(int $id, array $data): Material $data['material_code'] = $newCode; } + // 동적 필드를 options에 병합 + $this->processDynamicOptions($data, 'materials'); + // item_type, code는 DB 필드가 아니므로 제거 unset($data['item_type'], $data['code']); $data['updated_by'] = $userId;