- 견적확정 시 업체명/현장명/담당자/연락처 필수 검증 추가 (QuoteService) - 작업지시 stats API에 by_process 공정별 카운트 반환 추가 - 작업지시 목록/상세 쿼리에 수주 개소(rootNodes) 연관 로딩 - 작업지시 품목에 sourceOrderItem.node 관계 추가 - 입고관리 완료건 수정 허용 및 재고 차이 조정 - work_order_step_progress 테이블 마이그레이션 - receivings 테이블 options 컬럼 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1100 lines
36 KiB
PHP
1100 lines
36 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Commons\Category;
|
|
use App\Models\ItemMaster\ItemField;
|
|
use App\Models\ItemMaster\ItemPage;
|
|
use App\Models\Items\Item;
|
|
use App\Models\Items\ItemDetail;
|
|
use App\Models\ProcessItem;
|
|
use App\Models\Products\CommonCode;
|
|
use App\Models\Scopes\TenantScope;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
|
|
class ItemService extends Service
|
|
{
|
|
/**
|
|
* item_type 캐시 (동일 요청 내 중복 조회 방지)
|
|
*/
|
|
private array $modelCache = [];
|
|
|
|
/**
|
|
* item_type으로 해당하는 Model 클래스와 메타정보 조회
|
|
*
|
|
* @param string $itemType 품목 유형 (FG, PT, SM, RM, CS 등)
|
|
* @return array{model: string, page: ItemPage, source_table: string}
|
|
*/
|
|
private function getModelInfoByItemType(string $itemType): array
|
|
{
|
|
$itemType = strtoupper($itemType);
|
|
$tenantId = $this->tenantId();
|
|
$cacheKey = "{$tenantId}_{$itemType}";
|
|
|
|
if (isset($this->modelCache[$cacheKey])) {
|
|
return $this->modelCache[$cacheKey];
|
|
}
|
|
|
|
$page = ItemPage::where('tenant_id', $tenantId)
|
|
->where('item_type', $itemType)
|
|
->where('is_active', true)
|
|
->first();
|
|
|
|
if (! $page) {
|
|
throw new BadRequestHttpException(__('error.invalid_item_type'));
|
|
}
|
|
|
|
$modelClass = $page->getTargetModelClass();
|
|
if (! $modelClass) {
|
|
throw new BadRequestHttpException(__('error.invalid_source_table'));
|
|
}
|
|
|
|
$this->modelCache[$cacheKey] = [
|
|
'model' => $modelClass,
|
|
'page' => $page,
|
|
'source_table' => $page->source_table,
|
|
];
|
|
|
|
return $this->modelCache[$cacheKey];
|
|
}
|
|
|
|
/**
|
|
* item_type으로 Model 인스턴스 생성
|
|
*/
|
|
private function newModelInstance(string $itemType): Model
|
|
{
|
|
$info = $this->getModelInfoByItemType($itemType);
|
|
|
|
return new $info['model'];
|
|
}
|
|
|
|
/**
|
|
* item_type으로 Query Builder 생성
|
|
*/
|
|
private function newQuery(string $itemType)
|
|
{
|
|
$info = $this->getModelInfoByItemType($itemType);
|
|
$modelClass = $info['model'];
|
|
|
|
return $modelClass::query()
|
|
->where('tenant_id', $this->tenantId())
|
|
->where('item_type', strtoupper($itemType));
|
|
}
|
|
|
|
/**
|
|
* group_id로 해당 그룹의 item_type 목록 조회
|
|
*
|
|
* @param int $groupId 그룹 코드 (code_group='group', code='1' → group_id=1)
|
|
* @return array item_type 코드 배열 ['FG', 'PT', 'SM', 'RM', 'CS']
|
|
*/
|
|
private function getItemTypesByGroupId(int $groupId): array
|
|
{
|
|
// 1. group_id로 그룹 레코드 찾기 (code_group='group', code=group_id)
|
|
$group = \DB::table('common_codes')
|
|
->where('code_group', 'group')
|
|
->where('code', (string) $groupId)
|
|
->where('is_active', true)
|
|
->first();
|
|
|
|
if (! $group) {
|
|
return [];
|
|
}
|
|
|
|
// 2. 해당 그룹의 id를 parent_id로 가진 item_type 조회
|
|
return \DB::table('common_codes')
|
|
->where('code_group', 'item_type')
|
|
->where('parent_id', $group->id)
|
|
->where('is_active', true)
|
|
->pluck('code')
|
|
->toArray();
|
|
}
|
|
|
|
/**
|
|
* 여러 item_type으로 Query Builder 생성
|
|
*/
|
|
private function newQueryForTypes(array $itemTypes)
|
|
{
|
|
return \App\Models\Items\Item::query()
|
|
->where('tenant_id', $this->tenantId())
|
|
->whereIn('item_type', array_map('strtoupper', $itemTypes));
|
|
}
|
|
|
|
/**
|
|
* 콤마 구분 item_type 문자열을 배열로 파싱
|
|
*
|
|
* @param string|null $itemType 콤마 구분 item_type (예: "FG,PT")
|
|
* @return array 정규화된 item_type 배열
|
|
*/
|
|
private function parseItemTypes(?string $itemType): array
|
|
{
|
|
if (! $itemType) {
|
|
return [];
|
|
}
|
|
|
|
return array_filter(array_map('trim', explode(',', strtoupper($itemType))));
|
|
}
|
|
|
|
/**
|
|
* 여러 item_type이 같은 group_id에 속하는지 검증
|
|
*
|
|
* @param array $itemTypes item_type 배열
|
|
* @return int 공통 group_id
|
|
*
|
|
* @throws BadRequestHttpException 다른 group_id에 속하면 예외
|
|
*/
|
|
private function validateItemTypesInSameGroup(array $itemTypes): int
|
|
{
|
|
if (empty($itemTypes)) {
|
|
throw new BadRequestHttpException(__('error.item_type_required'));
|
|
}
|
|
|
|
// item_type 코드들의 parent_id (group) 조회
|
|
$groupCodes = \DB::table('common_codes as t')
|
|
->join('common_codes as g', 't.parent_id', '=', 'g.id')
|
|
->where('t.code_group', 'item_type')
|
|
->whereIn('t.code', $itemTypes)
|
|
->where('t.is_active', true)
|
|
->where('g.code_group', 'group')
|
|
->distinct()
|
|
->pluck('g.code')
|
|
->toArray();
|
|
|
|
// 존재하지 않는 item_type이 있는 경우
|
|
if (count($groupCodes) === 0) {
|
|
throw new BadRequestHttpException(__('error.invalid_item_type'));
|
|
}
|
|
|
|
// 여러 그룹에 속한 경우
|
|
if (count($groupCodes) > 1) {
|
|
throw new BadRequestHttpException(__('error.item_types_must_be_same_group'));
|
|
}
|
|
|
|
return (int) $groupCodes[0];
|
|
}
|
|
|
|
/**
|
|
* items 테이블의 고정 컬럼 목록 조회 (SystemFields + ItemField 기반)
|
|
*/
|
|
private function getKnownFields(): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
// 1. 기본 고정 필드
|
|
$baseFields = [
|
|
'id', 'tenant_id', 'item_type', 'code', 'name', 'unit', 'category_id',
|
|
'bom', 'attributes', 'attributes_archive', 'options', 'description',
|
|
'is_active', 'created_by', 'updated_by', 'deleted_by',
|
|
'created_at', 'updated_at', 'deleted_at',
|
|
];
|
|
|
|
// 2. ItemField에서 storage_type='column'인 필드의 field_key 조회
|
|
$columnFields = ItemField::where('tenant_id', $tenantId)
|
|
->where('source_table', 'items')
|
|
->where('storage_type', 'column')
|
|
->whereNotNull('field_key')
|
|
->pluck('field_key')
|
|
->toArray();
|
|
|
|
// 3. API 전용 필드
|
|
$apiFields = ['type_code'];
|
|
|
|
return array_unique(array_merge($baseFields, $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;
|
|
}
|
|
|
|
/**
|
|
* BOM 검증 (순환 참조 방지)
|
|
*
|
|
* @param array|null $bom BOM 데이터
|
|
* @param int|null $itemId 현재 품목 ID (수정 시)
|
|
*/
|
|
private function validateBom(?array $bom, ?int $itemId = null): void
|
|
{
|
|
if (empty($bom)) {
|
|
return;
|
|
}
|
|
|
|
foreach ($bom as $entry) {
|
|
$childItemId = $entry['child_item_id'] ?? null;
|
|
|
|
// 자기 자신 참조 방지
|
|
if ($itemId && $childItemId == $itemId) {
|
|
throw new BadRequestHttpException(__('error.item.self_reference_bom'));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 트리 전체 조회
|
|
*/
|
|
public function getCategory($request)
|
|
{
|
|
$parentId = $request->parentId ?? null;
|
|
|
|
return $this->fetchCategoryTree($parentId);
|
|
}
|
|
|
|
/**
|
|
* 내부 재귀 함수 (하위 카테고리 트리 구조로 구성)
|
|
*/
|
|
protected function fetchCategoryTree(?int $parentId = null)
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$query = Category::query()
|
|
->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
|
|
->when(
|
|
is_null($parentId),
|
|
fn ($q) => $q->whereNull('parent_id'),
|
|
fn ($q) => $q->where('parent_id', $parentId)
|
|
)
|
|
->where('is_active', 1)
|
|
->orderBy('sort_order');
|
|
|
|
$categories = $query->get();
|
|
|
|
foreach ($categories as $category) {
|
|
$children = $this->fetchCategoryTree($category->id);
|
|
$category->setRelation('children', $children);
|
|
}
|
|
|
|
return $categories;
|
|
}
|
|
|
|
/**
|
|
* 목록/검색 (동적 테이블 라우팅)
|
|
*
|
|
* @param array $params 검색 파라미터 (item_type/group_id 없으면 group_id=1 기본값)
|
|
* - exclude_process_id: 해당 공정 외 다른 공정에 이미 배정된 품목 제외
|
|
*/
|
|
public function index(array $params): LengthAwarePaginator
|
|
{
|
|
$size = (int) ($params['size'] ?? $params['per_page'] ?? 20);
|
|
$q = trim((string) ($params['q'] ?? $params['search'] ?? ''));
|
|
$categoryId = $params['category_id'] ?? null;
|
|
$itemType = $params['item_type'] ?? null;
|
|
$itemCategory = $params['item_category'] ?? null;
|
|
$groupId = $params['group_id'] ?? null;
|
|
$active = $params['active'] ?? null;
|
|
$hasBom = $params['has_bom'] ?? null;
|
|
$excludeProcessId = $params['exclude_process_id'] ?? null;
|
|
|
|
// item_type 또는 group_id 없으면 group_id = 1 기본값 적용
|
|
if (! $itemType && ! $groupId) {
|
|
$groupId = 1;
|
|
}
|
|
|
|
// group_id로 조회 시 해당 그룹의 모든 item_type 조회
|
|
if ($groupId && ! $itemType) {
|
|
$itemTypes = $this->getItemTypesByGroupId((int) $groupId);
|
|
if (empty($itemTypes)) {
|
|
throw new BadRequestHttpException(__('error.invalid_group_id'));
|
|
}
|
|
$query = $this->newQueryForTypes($itemTypes)
|
|
->with(['category:id,name', 'details', 'files']);
|
|
} else {
|
|
// item_type 조회 (단일 또는 콤마 구분 멀티)
|
|
$itemTypes = $this->parseItemTypes($itemType);
|
|
|
|
if (count($itemTypes) === 1) {
|
|
// 단일 item_type
|
|
$query = $this->newQuery($itemTypes[0])
|
|
->with(['category:id,name', 'details', 'files']);
|
|
} else {
|
|
// 멀티 item_type - 같은 그룹인지 검증
|
|
$this->validateItemTypesInSameGroup($itemTypes);
|
|
$query = $this->newQueryForTypes($itemTypes)
|
|
->with(['category:id,name', 'details', 'files']);
|
|
}
|
|
}
|
|
|
|
// 검색어
|
|
if ($q !== '') {
|
|
$query->where(function ($w) use ($q) {
|
|
$w->where('name', 'like', "%{$q}%")
|
|
->orWhere('code', 'like', "%{$q}%")
|
|
->orWhere('description', 'like', "%{$q}%");
|
|
});
|
|
}
|
|
|
|
// 카테고리
|
|
if ($categoryId) {
|
|
$query->where('category_id', (int) $categoryId);
|
|
}
|
|
|
|
// 품목 카테고리 (SCREEN, STEEL, BENDING 등)
|
|
if ($itemCategory) {
|
|
$query->where('item_category', $itemCategory);
|
|
}
|
|
|
|
// 활성 상태
|
|
if ($active !== null && $active !== '') {
|
|
$query->where('is_active', (bool) $active);
|
|
}
|
|
|
|
// BOM 유무 필터 (has_bom=1: BOM 있는 품목만, has_bom=0: BOM 없는 품목만)
|
|
if ($hasBom !== null && $hasBom !== '') {
|
|
if (filter_var($hasBom, FILTER_VALIDATE_BOOLEAN)) {
|
|
// BOM이 있는 품목만 (bom이 null이 아니고 빈 배열이 아님)
|
|
$query->whereNotNull('bom')
|
|
->whereRaw('JSON_LENGTH(bom) > 0');
|
|
} else {
|
|
// BOM이 없는 품목만
|
|
$query->where(function ($q) {
|
|
$q->whereNull('bom')
|
|
->orWhereRaw('JSON_LENGTH(bom) = 0');
|
|
});
|
|
}
|
|
}
|
|
|
|
// 다른 공정에 배정된 품목 제외 (공정별 품목 선택 시 중복 방지)
|
|
// exclude_process_id가 주어지면: 해당 공정에 이미 있는 품목은 허용, 다른 공정 품목은 제외
|
|
if ($excludeProcessId !== null && $excludeProcessId !== '') {
|
|
$excludeProcessId = (int) $excludeProcessId;
|
|
// 다른 공정에 배정된 품목 ID 목록 조회
|
|
$assignedToOtherProcesses = ProcessItem::where('process_id', '!=', $excludeProcessId)
|
|
->where('is_active', true)
|
|
->pluck('item_id')
|
|
->toArray();
|
|
|
|
if (! empty($assignedToOtherProcesses)) {
|
|
$query->whereNotIn('id', $assignedToOtherProcesses);
|
|
}
|
|
}
|
|
|
|
// common_codes에서 item_type 한글명 조회 (서브쿼리로 컬럼 충돌 방지)
|
|
$tableName = $query->getModel()->getTable();
|
|
$query->addSelect([
|
|
'item_type_name' => CommonCode::withoutGlobalScope(TenantScope::class)
|
|
->select('name')
|
|
->whereColumn('code', "{$tableName}.item_type")
|
|
->where('code_group', 'item_type')
|
|
->where('is_active', true)
|
|
->limit(1),
|
|
]);
|
|
|
|
$paginator = $query->orderBy("{$tableName}.id", 'desc')->paginate($size);
|
|
|
|
// 수입검사 양식에 연결된 품목 ID 조회 (incoming_inspection 카테고리)
|
|
$itemIds = $paginator->pluck('id')->toArray();
|
|
$itemsWithInspection = $this->getItemsWithInspectionTemplate($itemIds);
|
|
|
|
// 날짜 형식 변환, files 그룹화, options 펼침, code → item_code
|
|
$paginator->setCollection(
|
|
$paginator->getCollection()->transform(function ($item) use ($itemsWithInspection) {
|
|
$arr = $item->toArray();
|
|
$arr['created_at'] = $item->created_at
|
|
? $item->created_at->format('Y-m-d')
|
|
: null;
|
|
|
|
// files를 field_key별로 그룹화
|
|
$arr['files'] = $this->groupFilesByFieldKey($arr['files'] ?? []);
|
|
|
|
// options를 최상위 레벨로 펼침 (동적 필드)
|
|
$arr = $this->flattenOptionsToResponse($arr);
|
|
|
|
// 'code' 키를 'item_code'로 변경 (ApiResponse::handle의 HTTP 상태 코드 충돌 방지)
|
|
if (isset($arr['code'])) {
|
|
$arr['item_code'] = $arr['code'];
|
|
unset($arr['code']);
|
|
}
|
|
|
|
// has_bom 계산 필드 추가 (BOM이 있는 품목 필터링에 사용)
|
|
$arr['has_bom'] = ! empty($arr['bom']) && is_array($arr['bom']) && count($arr['bom']) > 0;
|
|
|
|
// has_inspection_template 필드 추가 (수입검사 양식 연결 여부)
|
|
$arr['has_inspection_template'] = in_array($arr['id'], $itemsWithInspection);
|
|
|
|
return $arr;
|
|
})
|
|
);
|
|
|
|
return $paginator;
|
|
}
|
|
|
|
/**
|
|
* 생성 (동적 테이블 라우팅)
|
|
*
|
|
* @param array $data 생성 데이터 (item_type 필수)
|
|
*/
|
|
public function store(array $data): Model
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
// item_type 필수 검증
|
|
$itemType = $data['item_type'] ?? null;
|
|
if (! $itemType) {
|
|
throw new BadRequestHttpException(__('error.item_type_required'));
|
|
}
|
|
|
|
// 동적 모델 정보 조회
|
|
$modelInfo = $this->getModelInfoByItemType($itemType);
|
|
$modelClass = $modelInfo['model'];
|
|
|
|
// 동적 필드를 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']);
|
|
}
|
|
|
|
// tenant별 code 유니크 체크 (동적 테이블)
|
|
$dup = $modelClass::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('code', $data['code'])
|
|
->exists();
|
|
if ($dup) {
|
|
throw new BadRequestHttpException(__('error.duplicate_key'));
|
|
}
|
|
|
|
// 테이블 데이터 준비
|
|
$itemData = [
|
|
'tenant_id' => $tenantId,
|
|
'item_type' => strtoupper($itemType),
|
|
'code' => $data['code'],
|
|
'name' => $data['name'],
|
|
'unit' => $data['unit'] ?? null,
|
|
'category_id' => $data['category_id'] ?? null,
|
|
'bom' => $data['bom'] ?? null,
|
|
'attributes' => $data['attributes'] ?? null,
|
|
'options' => $data['options'] ?? null,
|
|
'description' => $data['description'] ?? null,
|
|
'is_active' => $data['is_active'] ?? true,
|
|
'created_by' => $userId,
|
|
];
|
|
|
|
$item = $modelClass::create($itemData);
|
|
|
|
// item_details 테이블 데이터 (items 테이블인 경우에만)
|
|
if ($modelInfo['source_table'] === 'items') {
|
|
$detailData = $this->extractDetailData($data);
|
|
$detailData['item_id'] = $item->id;
|
|
ItemDetail::create($detailData);
|
|
$item->load('details');
|
|
}
|
|
|
|
return $item;
|
|
}
|
|
|
|
/**
|
|
* 단건 조회 (동적 테이블 라우팅)
|
|
*
|
|
* @param int $id 품목 ID
|
|
* @param string|null $itemType 품목 유형 (선택적 - 없으면 ID로만 조회)
|
|
* @return array 품목 데이터 (files 그룹화 포함)
|
|
*/
|
|
public function show(int $id, ?string $itemType = null): array
|
|
{
|
|
// item_type이 없으면 items 테이블에서 직접 조회
|
|
if (! $itemType) {
|
|
$item = Item::with(['category:id,name', 'details', 'files'])
|
|
->find($id);
|
|
|
|
if (! $item) {
|
|
throw new BadRequestHttpException(__('error.not_found'));
|
|
}
|
|
|
|
return $this->formatItemResponse($item);
|
|
}
|
|
|
|
// 동적 테이블 라우팅
|
|
$modelInfo = $this->getModelInfoByItemType($itemType);
|
|
$query = $this->newQuery($itemType);
|
|
|
|
// items 테이블인 경우 관계 로드
|
|
if ($modelInfo['source_table'] === 'items') {
|
|
$query->with(['category:id,name', 'details', 'files']);
|
|
}
|
|
|
|
$item = $query->find($id);
|
|
|
|
if (! $item) {
|
|
throw new BadRequestHttpException(__('error.not_found'));
|
|
}
|
|
|
|
return $this->formatItemResponse($item);
|
|
}
|
|
|
|
/**
|
|
* 품목 응답 포맷 (files 그룹화 + options 펼침)
|
|
*/
|
|
private function formatItemResponse(Model $item): array
|
|
{
|
|
$arr = $item->toArray();
|
|
|
|
// 날짜 포맷
|
|
$arr['created_at'] = $item->created_at
|
|
? $item->created_at->format('Y-m-d')
|
|
: null;
|
|
|
|
// files를 field_key별로 그룹화
|
|
$arr['files'] = $this->groupFilesByFieldKey($arr['files'] ?? []);
|
|
|
|
// options를 최상위 레벨로 펼침 (동적 필드)
|
|
$arr = $this->flattenOptionsToResponse($arr);
|
|
|
|
// 'code' 키를 'item_code'로 변경 (ApiResponse::handle의 HTTP 상태 코드 충돌 방지)
|
|
if (isset($arr['code'])) {
|
|
$arr['item_code'] = $arr['code'];
|
|
unset($arr['code']);
|
|
}
|
|
|
|
return $arr;
|
|
}
|
|
|
|
/**
|
|
* options 배열을 최상위 레벨로 펼침
|
|
*
|
|
* [{label: "field1", value: "val1"}, ...] → {"field1": "val1", ...}
|
|
*/
|
|
private function flattenOptionsToResponse(array $arr): array
|
|
{
|
|
$options = $arr['options'] ?? [];
|
|
unset($arr['options']);
|
|
|
|
if (! is_array($options) || empty($options)) {
|
|
return $arr;
|
|
}
|
|
|
|
// [{label, value, unit}] 형태의 배열을 펼침
|
|
foreach ($options as $opt) {
|
|
if (isset($opt['label']) && $opt['label'] !== '') {
|
|
$key = $opt['label'];
|
|
// 기존 필드와 충돌 방지
|
|
if (! isset($arr[$key])) {
|
|
$arr[$key] = $opt['value'] ?? '';
|
|
}
|
|
}
|
|
}
|
|
|
|
return $arr;
|
|
}
|
|
|
|
/**
|
|
* 수정 (동적 테이블 라우팅)
|
|
*
|
|
* @param int $id 품목 ID
|
|
* @param array $data 수정 데이터 (item_type 필수)
|
|
*/
|
|
public function update(int $id, array $data): Model
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
// item_type 필수 검증
|
|
$itemType = $data['item_type'] ?? null;
|
|
if (! $itemType) {
|
|
throw new BadRequestHttpException(__('error.item_type_required'));
|
|
}
|
|
|
|
// 동적 모델 정보 조회
|
|
$modelInfo = $this->getModelInfoByItemType($itemType);
|
|
$modelClass = $modelInfo['model'];
|
|
|
|
$item = $this->newQuery($itemType)->find($id);
|
|
if (! $item) {
|
|
throw new BadRequestHttpException(__('error.not_found'));
|
|
}
|
|
|
|
// 동적 필드를 options에 병합 (기존 item의 options 기반)
|
|
$dynamicOptions = $this->extractDynamicOptions($data);
|
|
|
|
// 기존 options를 배열 형태로 변환 (label → value 맵)
|
|
$existingOptionsMap = [];
|
|
if (is_array($item->options)) {
|
|
foreach ($item->options as $opt) {
|
|
if (isset($opt['label'])) {
|
|
$existingOptionsMap[$opt['label']] = $opt['value'] ?? '';
|
|
}
|
|
}
|
|
}
|
|
|
|
// 새 동적 필드와 기존 options 병합
|
|
if (! empty($dynamicOptions)) {
|
|
// 새 동적 필드로 기존 값 덮어쓰기
|
|
foreach ($dynamicOptions as $key => $value) {
|
|
$existingOptionsMap[$key] = $value;
|
|
}
|
|
}
|
|
|
|
// 명시적으로 전달된 options 처리
|
|
if (isset($data['options']) && is_array($data['options'])) {
|
|
// 배열 형태의 options 병합
|
|
foreach ($data['options'] as $opt) {
|
|
if (isset($opt['label'])) {
|
|
$existingOptionsMap[$opt['label']] = $opt['value'] ?? '';
|
|
}
|
|
}
|
|
}
|
|
|
|
// 최종 options 설정 (병합된 맵이 비어있지 않으면)
|
|
if (! empty($existingOptionsMap)) {
|
|
$data['options'] = $this->normalizeOptions($existingOptionsMap);
|
|
}
|
|
|
|
// code 변경 시 중복 체크 (동적 테이블)
|
|
if (isset($data['code']) && $data['code'] !== $item->code) {
|
|
$dup = $modelClass::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('code', $data['code'])
|
|
->exists();
|
|
if ($dup) {
|
|
throw new BadRequestHttpException(__('error.duplicate_key'));
|
|
}
|
|
}
|
|
|
|
// BOM 검증 (순환 참조 방지)
|
|
if (isset($data['bom'])) {
|
|
$this->validateBom($data['bom'], $id);
|
|
}
|
|
|
|
// attributes 병합 (기존 값 보존, 새 값으로 덮어쓰기)
|
|
if (isset($data['attributes']) && is_array($data['attributes'])) {
|
|
$existingAttributes = is_array($item->attributes) ? $item->attributes : [];
|
|
$data['attributes'] = array_merge($existingAttributes, $data['attributes']);
|
|
}
|
|
|
|
// 테이블 업데이트
|
|
$itemData = array_intersect_key($data, array_flip([
|
|
'item_type', 'code', 'name', 'unit', 'category_id',
|
|
'bom', 'attributes', 'options', 'description', 'is_active',
|
|
]));
|
|
$itemData['updated_by'] = $userId;
|
|
|
|
if (isset($itemData['item_type'])) {
|
|
$itemData['item_type'] = strtoupper($itemData['item_type']);
|
|
}
|
|
|
|
$item->update($itemData);
|
|
|
|
// item_details 테이블 업데이트 (items 테이블인 경우에만)
|
|
if ($modelInfo['source_table'] === 'items') {
|
|
$detailData = $this->extractDetailData($data);
|
|
if (! empty($detailData)) {
|
|
$item->details()->updateOrCreate(
|
|
['item_id' => $item->id],
|
|
$detailData
|
|
);
|
|
}
|
|
$item->load('details');
|
|
}
|
|
|
|
return $item->refresh();
|
|
}
|
|
|
|
/**
|
|
* 삭제 (동적 테이블 라우팅, soft delete)
|
|
*
|
|
* @param int $id 품목 ID
|
|
* @param string $itemType 품목 유형 (필수)
|
|
*/
|
|
public function destroy(int $id, string $itemType): void
|
|
{
|
|
$userId = $this->apiUserId();
|
|
|
|
// 동적 테이블 라우팅
|
|
$item = $this->newQuery($itemType)->find($id);
|
|
if (! $item) {
|
|
throw new BadRequestHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$item->deleted_by = $userId;
|
|
$item->save();
|
|
$item->delete();
|
|
}
|
|
|
|
/**
|
|
* 간편 검색 (동적 테이블 라우팅, 모달/드롭다운)
|
|
*
|
|
* @param array $params 검색 파라미터 (item_type 필수, 콤마 구분 멀티 지원)
|
|
*/
|
|
public function search(array $params)
|
|
{
|
|
$q = trim((string) ($params['q'] ?? ''));
|
|
$limit = (int) ($params['limit'] ?? 20);
|
|
$itemType = $params['item_type'] ?? null;
|
|
|
|
// item_type 필수 검증
|
|
if (! $itemType) {
|
|
throw new BadRequestHttpException(__('error.item_type_required'));
|
|
}
|
|
|
|
// item_type 파싱 (단일 또는 콤마 구분 멀티)
|
|
$itemTypes = $this->parseItemTypes($itemType);
|
|
|
|
if (count($itemTypes) === 1) {
|
|
// 단일 item_type - 동적 테이블 라우팅
|
|
$query = $this->newQuery($itemTypes[0]);
|
|
} else {
|
|
// 멀티 item_type - 같은 그룹인지 검증
|
|
$this->validateItemTypesInSameGroup($itemTypes);
|
|
$query = $this->newQueryForTypes($itemTypes);
|
|
}
|
|
|
|
if ($q !== '') {
|
|
$query->where(function ($w) use ($q) {
|
|
$w->where('name', 'like', "%{$q}%")
|
|
->orWhere('code', 'like', "%{$q}%");
|
|
});
|
|
}
|
|
|
|
return $query->orderBy('name')
|
|
->limit($limit)
|
|
->get(['id', 'code', 'name', 'item_type', 'category_id']);
|
|
}
|
|
|
|
/**
|
|
* 활성/비활성 토글 (동적 테이블 라우팅)
|
|
*
|
|
* @param int $id 품목 ID
|
|
* @param string $itemType 품목 유형 (필수)
|
|
*/
|
|
public function toggle(int $id, string $itemType): array
|
|
{
|
|
$userId = $this->apiUserId();
|
|
|
|
// 동적 테이블 라우팅
|
|
$item = $this->newQuery($itemType)->find($id);
|
|
if (! $item) {
|
|
throw new BadRequestHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$item->is_active = ! $item->is_active;
|
|
$item->updated_by = $userId;
|
|
$item->save();
|
|
|
|
return ['id' => $item->id, 'is_active' => $item->is_active];
|
|
}
|
|
|
|
/**
|
|
* code 기반 품목 조회 (동적 테이블 라우팅, BOM 포함 옵션)
|
|
*
|
|
* @param string $code 품목 코드
|
|
* @param string $itemType 품목 유형 (없으면 items 테이블에서 직접 검색)
|
|
* @param bool $includeBom BOM 포함 여부
|
|
*/
|
|
public function showByCode(string $code, string $itemType, bool $includeBom = false): Model
|
|
{
|
|
// item_type 없으면 items 테이블에서 직접 검색
|
|
if (empty($itemType)) {
|
|
$item = Item::where('tenant_id', $this->tenantId())
|
|
->where('code', $code)
|
|
->with(['category:id,name', 'details'])
|
|
->first();
|
|
|
|
if (! $item) {
|
|
throw new BadRequestHttpException(__('error.not_found'));
|
|
}
|
|
|
|
if ($includeBom && ! empty($item->bom) && method_exists($item, 'loadBomChildren')) {
|
|
$item->loadBomChildren();
|
|
}
|
|
|
|
return $item;
|
|
}
|
|
|
|
// 동적 테이블 라우팅
|
|
$modelInfo = $this->getModelInfoByItemType($itemType);
|
|
$query = $this->newQuery($itemType)->where('code', $code);
|
|
|
|
// items 테이블인 경우 관계 로드
|
|
if ($modelInfo['source_table'] === 'items') {
|
|
$query->with(['category:id,name', 'details']);
|
|
}
|
|
|
|
$item = $query->first();
|
|
|
|
if (! $item) {
|
|
throw new BadRequestHttpException(__('error.not_found'));
|
|
}
|
|
|
|
// BOM 포함 시 child items 로드 (items 테이블인 경우에만)
|
|
if ($includeBom && ! empty($item->bom) && method_exists($item, 'loadBomChildren')) {
|
|
$item->loadBomChildren();
|
|
}
|
|
|
|
return $item;
|
|
}
|
|
|
|
/**
|
|
* 일괄 삭제 (동적 테이블 라우팅, soft delete)
|
|
*
|
|
* @param array $ids 품목 ID 배열
|
|
* @param string $itemType 품목 유형 (필수)
|
|
*/
|
|
public function batchDestroy(array $ids, string $itemType): int
|
|
{
|
|
$userId = $this->apiUserId();
|
|
|
|
// 동적 테이블 라우팅
|
|
$items = $this->newQuery($itemType)
|
|
->whereIn('id', $ids)
|
|
->get();
|
|
|
|
if ($items->isEmpty()) {
|
|
throw new BadRequestHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$deletedCount = 0;
|
|
foreach ($items as $item) {
|
|
$item->deleted_by = $userId;
|
|
$item->save();
|
|
$item->delete();
|
|
$deletedCount++;
|
|
}
|
|
|
|
return $deletedCount;
|
|
}
|
|
|
|
/**
|
|
* 가격 정보 포함 조회 (동적 테이블 라우팅)
|
|
*
|
|
* @param int $id 품목 ID
|
|
* @param string $itemType 품목 유형 (필수)
|
|
* @param int|null $clientId 거래처 ID
|
|
* @param string|null $priceDate 가격 기준일
|
|
*/
|
|
public function showWithPrice(int $id, ?string $itemType = null, ?int $clientId = null, ?string $priceDate = null): array
|
|
{
|
|
// show()가 이제 array를 반환
|
|
$data = $this->show($id, $itemType);
|
|
|
|
// PricingService로 가격 조회
|
|
try {
|
|
$pricingService = app(\App\Services\Pricing\PricingService::class);
|
|
// item_type에서 가격 유형 결정 (데이터에서 추출)
|
|
$actualItemType = $data['item_type'] ?? $itemType ?? 'FG';
|
|
$itemTypeCode = in_array(strtoupper($actualItemType), ['FG', 'PT']) ? 'PRODUCT' : 'MATERIAL';
|
|
|
|
$data['prices'] = [
|
|
'sale' => $pricingService->getPriceByType($itemTypeCode, $id, 'SALE', $clientId, $priceDate),
|
|
'purchase' => $pricingService->getPriceByType($itemTypeCode, $id, 'PURCHASE', $clientId, $priceDate),
|
|
];
|
|
} catch (\Exception $e) {
|
|
$data['prices'] = ['sale' => null, 'purchase' => null];
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* item_details 데이터 추출
|
|
*/
|
|
private function extractDetailData(array $data): array
|
|
{
|
|
$detailFields = [
|
|
// Products 전용
|
|
'is_sellable', 'is_purchasable', 'is_producible',
|
|
'safety_stock', 'lead_time', 'is_variable_size',
|
|
'product_category', 'part_type',
|
|
'bending_diagram', 'bending_details',
|
|
'specification_file', 'specification_file_name',
|
|
'certification_file', 'certification_file_name',
|
|
'certification_number', 'certification_start_date', 'certification_end_date',
|
|
// Materials 전용
|
|
'is_inspection', 'item_name', 'specification', 'search_tag', 'remarks',
|
|
];
|
|
|
|
return array_intersect_key($data, array_flip($detailFields));
|
|
}
|
|
|
|
/**
|
|
* files 배열을 field_key별로 그룹화
|
|
*
|
|
* @param array $files 파일 배열
|
|
* @return array field_key별로 그룹화된 파일 배열
|
|
*/
|
|
private function groupFilesByFieldKey(array $files): array
|
|
{
|
|
if (empty($files)) {
|
|
return [];
|
|
}
|
|
|
|
$grouped = [];
|
|
foreach ($files as $file) {
|
|
$key = $file['field_key'] ?? 'default';
|
|
if (! isset($grouped[$key])) {
|
|
$grouped[$key] = [];
|
|
}
|
|
$grouped[$key][] = [
|
|
'id' => $file['id'],
|
|
'file_name' => $file['display_name'] ?? $file['original_name'] ?? $file['file_name'] ?? '',
|
|
'file_path' => $file['file_path'] ?? '',
|
|
];
|
|
}
|
|
|
|
return $grouped;
|
|
}
|
|
|
|
/**
|
|
* 수입검사 양식에 연결된 품목 ID 목록 조회
|
|
*
|
|
* @param array $itemIds 조회할 품목 ID 배열
|
|
* @return array 수입검사 양식에 연결된 품목 ID 배열
|
|
*/
|
|
private function getItemsWithInspectionTemplate(array $itemIds): array
|
|
{
|
|
if (empty($itemIds)) {
|
|
return [];
|
|
}
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
// DocumentService::resolve()와 동일한 category 매칭 조건
|
|
$categoryCode = 'incoming_inspection';
|
|
$categoryName = '수입검사';
|
|
|
|
$templates = \DB::table('document_templates')
|
|
->where('tenant_id', $tenantId)
|
|
->where('is_active', true)
|
|
->whereNotNull('linked_item_ids')
|
|
->where(function ($q) use ($categoryCode, $categoryName) {
|
|
$q->where('category', $categoryCode)
|
|
->orWhere('category', $categoryName)
|
|
->orWhere('category', 'LIKE', "%{$categoryName}%");
|
|
})
|
|
->get(['linked_item_ids']);
|
|
|
|
$linkedItemIds = [];
|
|
foreach ($templates as $template) {
|
|
$ids = json_decode($template->linked_item_ids, true) ?? [];
|
|
// int/string 타입 모두 매칭되도록 정수로 통일
|
|
foreach ($ids as $id) {
|
|
$linkedItemIds[] = (int) $id;
|
|
}
|
|
}
|
|
|
|
// 요청된 품목 ID도 정수로 통일하여 교집합
|
|
$intItemIds = array_map('intval', $itemIds);
|
|
|
|
return array_values(array_intersect($intItemIds, array_unique($linkedItemIds)));
|
|
}
|
|
|
|
/**
|
|
* 품목 통계 조회
|
|
*
|
|
* @param array $params 검색 파라미터 (item_type 또는 group_id, 없으면 전체)
|
|
* @return array{total: int, active: int}
|
|
*/
|
|
public function stats(array $params = []): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$itemType = $params['item_type'] ?? null;
|
|
$groupId = $params['group_id'] ?? null;
|
|
|
|
// 기본 쿼리 (items 테이블)
|
|
$baseQuery = Item::where('tenant_id', $tenantId);
|
|
|
|
// item_type 필터
|
|
if ($itemType) {
|
|
$itemTypes = $this->parseItemTypes($itemType);
|
|
if (! empty($itemTypes)) {
|
|
$baseQuery->whereIn('item_type', $itemTypes);
|
|
}
|
|
} elseif ($groupId) {
|
|
// group_id로 해당 그룹의 item_type 조회
|
|
$itemTypes = $this->getItemTypesByGroupId((int) $groupId);
|
|
if (! empty($itemTypes)) {
|
|
$baseQuery->whereIn('item_type', $itemTypes);
|
|
}
|
|
}
|
|
|
|
$total = (clone $baseQuery)->count();
|
|
$active = (clone $baseQuery)->where('is_active', true)->count();
|
|
|
|
return [
|
|
'total' => $total,
|
|
'active' => $active,
|
|
];
|
|
}
|
|
}
|