feat: Material/Product 동적 필드 options 저장 및 자재 삭제 보호

- products 테이블에 options JSON 컬럼 추가 (마이그레이션)
- Material/Product 모델에 options 필드 추가 (fillable, casts)
- SystemFields::PRODUCTS에 options 상수 추가
- MaterialService/ProductService에 동적 필드 자동 추출 로직:
  - getKnownFields(): SystemFields + ItemField 기반 고정 필드 조회
  - extractDynamicOptions(): 동적 필드 추출
  - normalizeOptions(): [{label, value, unit}] 형태로 정규화
- material_code 중복 체크 시 soft delete 포함 (withTrashed)
- 사용 중인 자재 삭제 방지 (checkMaterialUsage)
- Material 모델에 category 관계 추가
This commit is contained in:
2025-12-10 21:37:20 +09:00
parent cde89b2fb3
commit b086518075
7 changed files with 362 additions and 13 deletions

View File

@@ -2,13 +2,121 @@
namespace App\Services;
use App\Constants\SystemFields;
use App\Models\Commons\Category;
use App\Models\ItemMaster\ItemField;
use App\Models\Products\CommonCode;
use App\Models\Products\Product;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class ProductService extends Service
{
/**
* products 테이블의 고정 컬럼 목록 조회 (SystemFields + ItemField 기반)
*/
private function getKnownFields(): array
{
$tenantId = $this->tenantId();
// 1. SystemFields에서 products 테이블 고정 컬럼
$systemFields = SystemFields::getReservedKeys(SystemFields::SOURCE_TABLE_PRODUCTS);
// 2. ItemField에서 storage_type='column'인 필드의 field_key 조회
$columnFields = ItemField::where('tenant_id', $tenantId)
->where('source_table', 'products')
->where('storage_type', 'column')
->whereNotNull('field_key')
->pluck('field_key')
->toArray();
// 3. 추가적인 API 전용 필드 (DB 컬럼이 아니지만 API에서 사용하는 필드)
$apiFields = ['item_type', 'type_code', 'bom'];
return array_unique(array_merge($systemFields, $columnFields, $apiFields));
}
/**
* 정의된 필드 외의 동적 필드를 options로 추출
*/
private function extractDynamicOptions(array $params): array
{
$knownFields = $this->getKnownFields();
$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;
}
/**
* 카테고리 트리 전체 조회 (parent_id = null 기준)
*/
@@ -117,7 +225,18 @@ public function store(array $data)
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// FormRequest에서 이미 검증됨
// 동적 필드를 options에 병합
$dynamicOptions = $this->extractDynamicOptions($data);
if (! empty($dynamicOptions)) {
$existingOptions = $data['options'] ?? [];
$data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions);
}
// options 정규화
if (isset($data['options'])) {
$data['options'] = $this->normalizeOptions($data['options']);
}
$payload = $data;
// tenant별 code 유니크 수동 체크
@@ -162,7 +281,18 @@ public function update(int $id, array $data)
throw new BadRequestHttpException(__('error.not_found'));
}
// FormRequest에서 이미 검증됨
// 동적 필드를 options에 병합
$dynamicOptions = $this->extractDynamicOptions($data);
if (! empty($dynamicOptions)) {
$existingOptions = $data['options'] ?? [];
$data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions);
}
// options 정규화
if (isset($data['options'])) {
$data['options'] = $this->normalizeOptions($data['options']);
}
$payload = $data;
// code 변경 시 중복 체크