feat: Items API is_active 필터/반환 및 동적 필드 지원

- 목록 조회에 is_active 필드 반환 및 필터 파라미터 추가
- 상세 조회에서 options 동적 필드 플랫 전개
- 생성/수정 시 동적 필드 options 저장 지원
This commit is contained in:
2025-12-11 09:56:01 +09:00
parent b086518075
commit d5ab522902
2 changed files with 176 additions and 10 deletions

View File

@@ -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'));
}

View File

@@ -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;