1211 lines
39 KiB
PHP
1211 lines
39 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Constants\SystemFields;
|
|
use App\Exceptions\DuplicateCodeException;
|
|
use App\Helpers\ItemTypeHelper;
|
|
use App\Models\Commons\File;
|
|
use App\Models\ItemMaster\ItemField;
|
|
use App\Models\Materials\Material;
|
|
use App\Models\Products\Product;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
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']);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* BOM 데이터에서 child_item_id, child_item_type, quantity만 추출
|
|
*
|
|
* @param array|null $bomData BOM 데이터 배열
|
|
* @return array|null [{child_item_id, child_item_type, quantity}, ...]
|
|
*/
|
|
private function extractBomData(?array $bomData): ?array
|
|
{
|
|
if (empty($bomData)) {
|
|
return null;
|
|
}
|
|
|
|
$extracted = [];
|
|
foreach ($bomData as $item) {
|
|
if (! is_array($item)) {
|
|
continue;
|
|
}
|
|
|
|
$childItemId = $item['child_item_id'] ?? null;
|
|
$childItemType = $item['child_item_type'] ?? $item['ref_type'] ?? 'PRODUCT';
|
|
$quantity = $item['quantity'] ?? null;
|
|
|
|
if ($childItemId === null) {
|
|
continue;
|
|
}
|
|
|
|
// child_item_type 정규화 (PRODUCT/MATERIAL)
|
|
$childItemType = strtoupper($childItemType);
|
|
if (! in_array($childItemType, ['PRODUCT', 'MATERIAL'])) {
|
|
$childItemType = 'PRODUCT';
|
|
}
|
|
|
|
$extracted[] = [
|
|
'child_item_id' => (int) $childItemId,
|
|
'child_item_type' => $childItemType,
|
|
'quantity' => $quantity !== null ? (float) $quantity : 1,
|
|
];
|
|
}
|
|
|
|
return empty($extracted) ? null : $extracted;
|
|
}
|
|
|
|
/**
|
|
* BOM 데이터 확장 (child_item 상세 정보 포함)
|
|
*
|
|
* DB에 저장된 [{child_item_id, child_item_type, quantity}] 형태를
|
|
* [{child_item_id, child_item_type, child_item_code, child_item_name, quantity, unit, specification?}]
|
|
* 형태로 확장하여 반환
|
|
*
|
|
* @param array $bomData BOM 데이터 배열 [{child_item_id, child_item_type, quantity}, ...]
|
|
* @param int $tenantId 테넌트 ID
|
|
* @return array 확장된 BOM 데이터
|
|
*/
|
|
private function expandBomData(array $bomData, int $tenantId): array
|
|
{
|
|
if (empty($bomData)) {
|
|
return [];
|
|
}
|
|
|
|
// child_item_type별로 ID 분리
|
|
$productIds = [];
|
|
$materialIds = [];
|
|
|
|
foreach ($bomData as $item) {
|
|
$childId = $item['child_item_id'] ?? null;
|
|
$childType = strtoupper($item['child_item_type'] ?? 'PRODUCT');
|
|
|
|
if ($childId === null) {
|
|
continue;
|
|
}
|
|
|
|
if ($childType === 'MATERIAL') {
|
|
$materialIds[] = $childId;
|
|
} else {
|
|
$productIds[] = $childId;
|
|
}
|
|
}
|
|
|
|
// Products에서 조회 (FG, PT)
|
|
$products = collect([]);
|
|
if (! empty($productIds)) {
|
|
$products = Product::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('id', $productIds)
|
|
->get(['id', 'code', 'name', 'unit'])
|
|
->keyBy('id');
|
|
}
|
|
|
|
// Materials에서 조회 (SM, RM, CS)
|
|
$materials = collect([]);
|
|
if (! empty($materialIds)) {
|
|
$materials = Material::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('id', $materialIds)
|
|
->get(['id', 'material_code', 'name', 'unit', 'specification'])
|
|
->keyBy('id');
|
|
}
|
|
|
|
// BOM 데이터 확장
|
|
$expanded = [];
|
|
foreach ($bomData as $item) {
|
|
$childId = $item['child_item_id'] ?? null;
|
|
$childType = strtoupper($item['child_item_type'] ?? 'PRODUCT');
|
|
|
|
if ($childId === null) {
|
|
continue;
|
|
}
|
|
|
|
$result = [
|
|
'child_item_id' => (int) $childId,
|
|
'child_item_type' => $childType,
|
|
'quantity' => $item['quantity'] ?? 1,
|
|
];
|
|
|
|
// child_item_type에 따라 조회
|
|
if ($childType === 'MATERIAL' && isset($materials[$childId])) {
|
|
$material = $materials[$childId];
|
|
$result['child_item_code'] = $material->material_code;
|
|
$result['child_item_name'] = $material->name;
|
|
$result['unit'] = $material->unit;
|
|
if ($material->specification) {
|
|
$result['specification'] = $material->specification;
|
|
}
|
|
} elseif ($childType === 'PRODUCT' && isset($products[$childId])) {
|
|
$product = $products[$childId];
|
|
$result['child_item_code'] = $product->code;
|
|
$result['child_item_name'] = $product->name;
|
|
$result['unit'] = $product->unit;
|
|
} else {
|
|
// 해당하는 품목이 없으면 null 처리
|
|
$result['child_item_code'] = null;
|
|
$result['child_item_name'] = null;
|
|
$result['unit'] = null;
|
|
}
|
|
|
|
$expanded[] = $result;
|
|
}
|
|
|
|
return $expanded;
|
|
}
|
|
|
|
/**
|
|
* 통합 품목 조회 (materials + products UNION)
|
|
*
|
|
* @param array $filters 필터 조건 (type, search, category_id, page, size)
|
|
* @param int $perPage 페이지당 항목 수
|
|
* @param bool $includeDeleted soft delete 포함 여부
|
|
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
|
|
*/
|
|
public function getItems(array $filters = [], int $perPage = 20, bool $includeDeleted = false)
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
// 필터 파라미터 추출
|
|
$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)) {
|
|
$types = array_map('trim', explode(',', $types));
|
|
}
|
|
|
|
// Product 타입 (FG, PT)
|
|
$productTypes = array_intersect(['FG', 'PT'], $types);
|
|
// Material 타입 (SM, RM, CS)
|
|
$materialTypes = array_intersect(['SM', 'RM', 'CS'], $types);
|
|
|
|
// Products 쿼리 (FG, PT 타입만)
|
|
$productsQuery = null;
|
|
if (! empty($productTypes)) {
|
|
$productsQuery = Product::query()
|
|
->where('tenant_id', $tenantId)
|
|
->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',
|
|
'name',
|
|
DB::raw('NULL as specification'),
|
|
'unit',
|
|
'category_id',
|
|
'product_type as type_code',
|
|
'attributes',
|
|
'is_active',
|
|
'created_at',
|
|
'deleted_at',
|
|
]);
|
|
|
|
// soft delete 포함
|
|
if ($includeDeleted) {
|
|
$productsQuery->withTrashed();
|
|
}
|
|
|
|
// 검색 조건
|
|
if ($search !== '') {
|
|
$productsQuery->where(function ($q) use ($search) {
|
|
$q->where('name', 'like', "%{$search}%")
|
|
->orWhere('code', 'like', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
// 카테고리 필터
|
|
if ($categoryId) {
|
|
$productsQuery->where('category_id', (int) $categoryId);
|
|
}
|
|
}
|
|
|
|
// Materials 쿼리 (SM, RM, CS 타입)
|
|
$materialsQuery = null;
|
|
if (! empty($materialTypes)) {
|
|
$materialsQuery = Material::query()
|
|
->where('tenant_id', $tenantId)
|
|
->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',
|
|
'name',
|
|
'specification',
|
|
'unit',
|
|
'category_id',
|
|
'material_type as type_code',
|
|
'attributes',
|
|
'is_active',
|
|
'created_at',
|
|
'deleted_at',
|
|
]);
|
|
|
|
// soft delete 포함
|
|
if ($includeDeleted) {
|
|
$materialsQuery->withTrashed();
|
|
}
|
|
|
|
// 검색 조건
|
|
if ($search !== '') {
|
|
$materialsQuery->where(function ($q) use ($search) {
|
|
$q->where('name', 'like', "%{$search}%")
|
|
->orWhere('item_name', 'like', "%{$search}%")
|
|
->orWhere('material_code', 'like', "%{$search}%")
|
|
->orWhere('search_tag', 'like', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
// 카테고리 필터
|
|
if ($categoryId) {
|
|
$materialsQuery->where('category_id', (int) $categoryId);
|
|
}
|
|
}
|
|
|
|
// 각 쿼리 실행 후 merge (UNION 바인딩 문제 방지)
|
|
$items = collect([]);
|
|
|
|
if ($productsQuery) {
|
|
$products = $productsQuery->get();
|
|
$items = $items->merge($products);
|
|
}
|
|
|
|
if ($materialsQuery) {
|
|
$materials = $materialsQuery->get();
|
|
$items = $items->merge($materials);
|
|
}
|
|
|
|
// 정렬 (created_at 기준 내림차순)
|
|
$items = $items->sortByDesc('created_at')->values();
|
|
|
|
// attributes 플랫 전개
|
|
$items = $items->map(function ($item) {
|
|
$data = $item->toArray();
|
|
$attributes = $data['attributes'] ?? [];
|
|
if (is_string($attributes)) {
|
|
$attributes = json_decode($attributes, true) ?? [];
|
|
}
|
|
unset($data['attributes']);
|
|
|
|
return array_merge($data, $attributes);
|
|
});
|
|
|
|
// 페이지네이션 처리
|
|
$page = request()->input('page', 1);
|
|
$offset = ($page - 1) * $perPage;
|
|
$total = $items->count();
|
|
|
|
$paginatedItems = $items->slice($offset, $perPage)->values();
|
|
|
|
return new \Illuminate\Pagination\LengthAwarePaginator(
|
|
$paginatedItems,
|
|
$total,
|
|
$perPage,
|
|
$page,
|
|
['path' => request()->url(), 'query' => request()->query()]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 단일 품목 조회
|
|
*
|
|
* @param int $id 품목 ID
|
|
* @param string $itemType 품목 유형 코드 (FG/PT/SM/RM/CS)
|
|
* @param bool $includePrice 가격 정보 포함 여부
|
|
* @param int|null $clientId 고객 ID (가격 조회 시)
|
|
* @param string|null $priceDate 기준일 (가격 조회 시)
|
|
* @return array 품목 데이터 (item_type, prices 포함)
|
|
*/
|
|
public function getItem(
|
|
int $id,
|
|
string $itemType = 'FG',
|
|
bool $includePrice = false,
|
|
?int $clientId = null,
|
|
?string $priceDate = null
|
|
): array {
|
|
$tenantId = $this->tenantId();
|
|
$itemType = strtoupper($itemType);
|
|
|
|
// item_type으로 source_table 결정
|
|
if (ItemTypeHelper::isMaterial($itemType, $tenantId)) {
|
|
$material = Material::query()
|
|
->with('category:id,name')
|
|
->where('tenant_id', $tenantId)
|
|
->find($id);
|
|
|
|
if (! $material) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$data = $this->flattenAttributes($material->toArray());
|
|
$data['item_type'] = $itemType;
|
|
$data['code'] = $material->material_code;
|
|
$data['type_code'] = $material->material_type;
|
|
|
|
// 가격 정보 추가
|
|
if ($includePrice) {
|
|
$data['prices'] = $this->fetchPrices('MATERIAL', $id, $clientId, $priceDate);
|
|
}
|
|
|
|
// 파일 정보 추가
|
|
$data['files'] = $this->getItemFiles($id, $tenantId);
|
|
|
|
return $data;
|
|
}
|
|
|
|
// Product (FG, PT)
|
|
$product = Product::query()
|
|
->with('category:id,name')
|
|
->where('tenant_id', $tenantId)
|
|
->find($id);
|
|
|
|
if (! $product) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$data = $this->flattenAttributes($product->toArray());
|
|
$data['item_type'] = $itemType;
|
|
$data['type_code'] = $product->product_type;
|
|
|
|
// BOM 데이터 확장 (child_item 상세 정보 포함)
|
|
if (! empty($data['bom'])) {
|
|
$data['bom'] = $this->expandBomData($data['bom'], $tenantId);
|
|
}
|
|
|
|
// 가격 정보 추가
|
|
if ($includePrice) {
|
|
$data['prices'] = $this->fetchPrices('PRODUCT', $id, $clientId, $priceDate);
|
|
}
|
|
|
|
// 파일 정보 추가
|
|
$data['files'] = $this->getItemFiles($id, $tenantId);
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* 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']);
|
|
|
|
// 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);
|
|
}
|
|
|
|
/**
|
|
* 품목의 판매가/매입가 조회
|
|
*
|
|
* @param string $itemType 'PRODUCT' | 'MATERIAL'
|
|
* @param int $itemId 품목 ID
|
|
* @param int|null $clientId 고객 ID
|
|
* @param string|null $priceDate 기준일
|
|
* @return array ['sale' => array, 'purchase' => array]
|
|
*/
|
|
private function fetchPrices(string $itemType, int $itemId, ?int $clientId, ?string $priceDate): array
|
|
{
|
|
// PricingService DI가 없으므로 직접 생성
|
|
$pricingService = app(\App\Services\Pricing\PricingService::class);
|
|
|
|
$salePrice = $pricingService->getPriceByType($itemType, $itemId, 'SALE', $clientId, $priceDate);
|
|
$purchasePrice = $pricingService->getPriceByType($itemType, $itemId, 'PURCHASE', $clientId, $priceDate);
|
|
|
|
return [
|
|
'sale' => $salePrice,
|
|
'purchase' => $purchasePrice,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 품목 생성 (타입에 따라 Products 또는 Materials 테이블에 저장)
|
|
*
|
|
* - FG, PT → Products 테이블
|
|
* - SM, RM, CS → Materials 테이블
|
|
*
|
|
* @param array $data 검증된 데이터
|
|
*/
|
|
public function createItem(array $data): Product|Material
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
$itemType = strtoupper($data['product_type'] ?? 'FG');
|
|
|
|
// Materials 타입 (SM, RM, CS)
|
|
if (in_array($itemType, ['SM', 'RM', 'CS'])) {
|
|
return $this->createMaterial($data, $tenantId, $userId, $itemType);
|
|
}
|
|
|
|
// Products 타입 (FG, PT) - 기본값
|
|
return $this->createProduct($data, $tenantId, $userId);
|
|
}
|
|
|
|
/**
|
|
* Product 생성 (FG, PT)
|
|
*/
|
|
private function createProduct(array $data, int $tenantId, int $userId): Product
|
|
{
|
|
// 품목 코드 중복 체크
|
|
$code = $data['code'] ?? '';
|
|
$existingProduct = Product::withTrashed()
|
|
->where('tenant_id', $tenantId)
|
|
->where('code', $code)
|
|
->first();
|
|
|
|
if ($existingProduct) {
|
|
throw new DuplicateCodeException($code, $existingProduct->id);
|
|
}
|
|
|
|
// 동적 필드를 options에 병합
|
|
$this->processDynamicOptions($data, 'products');
|
|
|
|
// BOM 데이터 처리 (child_item_id, quantity만 추출)
|
|
$bomData = $this->extractBomData($data['bom'] ?? null);
|
|
|
|
$payload = $data;
|
|
$payload['tenant_id'] = $tenantId;
|
|
$payload['created_by'] = $userId;
|
|
$payload['is_active'] = $payload['is_active'] ?? true;
|
|
$payload['is_sellable'] = $payload['is_sellable'] ?? true;
|
|
$payload['is_purchasable'] = $payload['is_purchasable'] ?? false;
|
|
$payload['is_producible'] = $payload['is_producible'] ?? false;
|
|
$payload['bom'] = $bomData;
|
|
|
|
return Product::create($payload);
|
|
}
|
|
|
|
/**
|
|
* Material 생성 (SM, RM, CS)
|
|
*/
|
|
private function createMaterial(array $data, int $tenantId, int $userId, string $materialType): Material
|
|
{
|
|
// 품목 코드 중복 체크
|
|
$code = $data['code'] ?? $data['material_code'] ?? '';
|
|
$existingMaterial = Material::withTrashed()
|
|
->where('tenant_id', $tenantId)
|
|
->where('material_code', $code)
|
|
->first();
|
|
|
|
if ($existingMaterial) {
|
|
throw new DuplicateCodeException($code, $existingMaterial->id);
|
|
}
|
|
|
|
// 동적 필드를 options에 병합
|
|
$this->processDynamicOptions($data, 'materials');
|
|
|
|
$payload = [
|
|
'tenant_id' => $tenantId,
|
|
'created_by' => $userId,
|
|
'material_type' => $materialType,
|
|
'material_code' => $code,
|
|
'name' => $data['name'],
|
|
'unit' => $data['unit'],
|
|
'category_id' => $data['category_id'] ?? null,
|
|
'item_name' => $data['item_name'] ?? $data['name'],
|
|
'specification' => $data['specification'] ?? $data['description'] ?? null,
|
|
'is_inspection' => $data['is_inspection'] ?? 'N',
|
|
'search_tag' => $data['search_tag'] ?? null,
|
|
'remarks' => $data['remarks'] ?? null,
|
|
'attributes' => $data['attributes'] ?? null,
|
|
'options' => $data['options'] ?? null,
|
|
'is_active' => $data['is_active'] ?? true,
|
|
];
|
|
|
|
return Material::create($payload);
|
|
}
|
|
|
|
/**
|
|
* 품목 수정 (Product/Material 통합)
|
|
*
|
|
* @param int $id 품목 ID
|
|
* @param array $data 검증된 데이터 (item_type 필수)
|
|
*/
|
|
public function updateItem(int $id, array $data): Product|Material
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$itemType = strtoupper($data['item_type'] ?? 'FG');
|
|
|
|
if (ItemTypeHelper::isMaterial($itemType, $tenantId)) {
|
|
return $this->updateMaterial($id, $data);
|
|
}
|
|
|
|
return $this->updateProduct($id, $data);
|
|
}
|
|
|
|
/**
|
|
* Product 수정 (FG, PT)
|
|
*/
|
|
private function updateProduct(int $id, array $data): Product
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$product = Product::query()
|
|
->where('tenant_id', $tenantId)
|
|
->find($id);
|
|
|
|
if (! $product) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
// 코드 변경 시 중복 체크
|
|
if (isset($data['code']) && $data['code'] !== $product->code) {
|
|
$existingProduct = Product::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('code', $data['code'])
|
|
->where('id', '!=', $product->id)
|
|
->first();
|
|
|
|
if ($existingProduct) {
|
|
throw new DuplicateCodeException($data['code'], $existingProduct->id);
|
|
}
|
|
}
|
|
|
|
// 동적 필드를 options에 병합
|
|
$this->processDynamicOptions($data, 'products');
|
|
|
|
// BOM 데이터 처리 (bom 키가 있을 때만)
|
|
if (array_key_exists('bom', $data)) {
|
|
$data['bom'] = $this->extractBomData($data['bom']);
|
|
}
|
|
|
|
// item_type은 DB 필드가 아니므로 제거
|
|
unset($data['item_type']);
|
|
$data['updated_by'] = $userId;
|
|
$product->update($data);
|
|
|
|
return $product->refresh();
|
|
}
|
|
|
|
/**
|
|
* Material 수정 (SM, RM, CS)
|
|
*/
|
|
private function updateMaterial(int $id, array $data): Material
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$material = Material::query()
|
|
->where('tenant_id', $tenantId)
|
|
->find($id);
|
|
|
|
if (! $material) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
// 코드 변경 시 중복 체크
|
|
$newCode = $data['material_code'] ?? $data['code'] ?? null;
|
|
if ($newCode && $newCode !== $material->material_code) {
|
|
$existingMaterial = Material::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('material_code', $newCode)
|
|
->where('id', '!=', $material->id)
|
|
->first();
|
|
|
|
if ($existingMaterial) {
|
|
throw new DuplicateCodeException($newCode, $existingMaterial->id);
|
|
}
|
|
$data['material_code'] = $newCode;
|
|
}
|
|
|
|
// 동적 필드를 options에 병합
|
|
$this->processDynamicOptions($data, 'materials');
|
|
|
|
// item_type, code는 DB 필드가 아니므로 제거
|
|
unset($data['item_type'], $data['code']);
|
|
$data['updated_by'] = $userId;
|
|
$material->update($data);
|
|
|
|
return $material->refresh();
|
|
}
|
|
|
|
/**
|
|
* 품목 삭제 (Product/Material 통합, Force Delete)
|
|
*
|
|
* - 이미 삭제된 품목은 404 반환
|
|
* - 다른 테이블에서 사용 중이면 삭제 불가
|
|
* - 사용 안함 → Force Delete (영구 삭제)
|
|
*
|
|
* @param int $id 품목 ID
|
|
* @param string $itemType 품목 유형 코드 (FG/PT/SM/RM/CS)
|
|
*/
|
|
public function deleteItem(int $id, string $itemType = 'FG'): void
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$itemType = strtoupper($itemType);
|
|
|
|
if (ItemTypeHelper::isMaterial($itemType, $tenantId)) {
|
|
$this->deleteMaterial($id);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->deleteProduct($id);
|
|
}
|
|
|
|
/**
|
|
* Product 삭제 (FG, PT) - Force Delete
|
|
*
|
|
* - 사용 중이면 삭제 불가 (에러)
|
|
* - 사용 안함 → 영구 삭제
|
|
*/
|
|
private function deleteProduct(int $id): void
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$product = Product::query()
|
|
->where('tenant_id', $tenantId)
|
|
->find($id);
|
|
|
|
if (! $product) {
|
|
throw new NotFoundHttpException(__('error.item.not_found'));
|
|
}
|
|
|
|
// 사용 여부 종합 체크
|
|
$usageResult = $this->checkProductUsage($tenantId, $id);
|
|
if ($usageResult['is_used']) {
|
|
$usageMessage = $this->formatUsageMessage($usageResult['usage']);
|
|
throw new BadRequestHttpException(__('error.item.in_use', ['usage' => $usageMessage]));
|
|
}
|
|
|
|
// 사용 안함 → Force Delete
|
|
$product->forceDelete();
|
|
}
|
|
|
|
/**
|
|
* Material 삭제 (SM, RM, CS) - Force Delete
|
|
*
|
|
* - 사용 중이면 삭제 불가 (에러)
|
|
* - 사용 안함 → 영구 삭제
|
|
*/
|
|
private function deleteMaterial(int $id): void
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$material = Material::query()
|
|
->where('tenant_id', $tenantId)
|
|
->find($id);
|
|
|
|
if (! $material) {
|
|
throw new NotFoundHttpException(__('error.item.not_found'));
|
|
}
|
|
|
|
// 사용 여부 종합 체크
|
|
$usageResult = $this->checkMaterialUsage($tenantId, $id);
|
|
if ($usageResult['is_used']) {
|
|
$usageMessage = $this->formatUsageMessage($usageResult['usage']);
|
|
throw new BadRequestHttpException(__('error.item.in_use', ['usage' => $usageMessage]));
|
|
}
|
|
|
|
// 사용 안함 → Force Delete
|
|
$material->forceDelete();
|
|
}
|
|
|
|
/**
|
|
* Product 사용 여부 종합 체크 (모든 참조 테이블)
|
|
*
|
|
* @param int $tenantId 테넌트 ID
|
|
* @param int $productId 제품 ID
|
|
* @return array ['is_used' => bool, 'usage' => ['table' => count, ...]]
|
|
*/
|
|
private function checkProductUsage(int $tenantId, int $productId): array
|
|
{
|
|
$usage = [];
|
|
|
|
// 1. BOM 구성품으로 사용 (product_components.ref_type=PRODUCT)
|
|
$bomComponentCount = \App\Models\Products\ProductComponent::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('ref_type', 'PRODUCT')
|
|
->where('ref_id', $productId)
|
|
->count();
|
|
if ($bomComponentCount > 0) {
|
|
$usage['bom_components'] = $bomComponentCount;
|
|
}
|
|
|
|
// 2. BOM 상위품목으로 사용 (product_components.parent_product_id)
|
|
$bomParentCount = \App\Models\Products\ProductComponent::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('parent_product_id', $productId)
|
|
->count();
|
|
if ($bomParentCount > 0) {
|
|
$usage['bom_parent'] = $bomParentCount;
|
|
}
|
|
|
|
// 3. BOM 템플릿 항목 (bom_template_items.ref_type=PRODUCT)
|
|
$bomTemplateCount = \App\Models\Design\BomTemplateItem::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('ref_type', 'PRODUCT')
|
|
->where('ref_id', $productId)
|
|
->count();
|
|
if ($bomTemplateCount > 0) {
|
|
$usage['bom_templates'] = $bomTemplateCount;
|
|
}
|
|
|
|
// 4. 주문 (orders.product_id)
|
|
$orderCount = \App\Models\Orders\Order::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('product_id', $productId)
|
|
->count();
|
|
if ($orderCount > 0) {
|
|
$usage['orders'] = $orderCount;
|
|
}
|
|
|
|
// 5. 주문 항목 (order_items.product_id)
|
|
$orderItemCount = \App\Models\Orders\OrderItem::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('product_id', $productId)
|
|
->count();
|
|
if ($orderItemCount > 0) {
|
|
$usage['order_items'] = $orderItemCount;
|
|
}
|
|
|
|
// 6. 견적 (quotes.product_id)
|
|
$quoteCount = \App\Models\Quote\Quote::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('product_id', $productId)
|
|
->count();
|
|
if ($quoteCount > 0) {
|
|
$usage['quotes'] = $quoteCount;
|
|
}
|
|
|
|
return [
|
|
'is_used' => ! empty($usage),
|
|
'usage' => $usage,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Material 사용 여부 종합 체크 (모든 참조 테이블)
|
|
*
|
|
* @param int $tenantId 테넌트 ID
|
|
* @param int $materialId 자재 ID
|
|
* @return array ['is_used' => bool, 'usage' => ['table' => count, ...]]
|
|
*/
|
|
private function checkMaterialUsage(int $tenantId, int $materialId): array
|
|
{
|
|
$usage = [];
|
|
|
|
// 1. BOM 구성품으로 사용 (product_components.ref_type=MATERIAL)
|
|
$bomComponentCount = \App\Models\Products\ProductComponent::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('ref_type', 'MATERIAL')
|
|
->where('ref_id', $materialId)
|
|
->count();
|
|
if ($bomComponentCount > 0) {
|
|
$usage['bom_components'] = $bomComponentCount;
|
|
}
|
|
|
|
// 2. BOM 템플릿 항목 (bom_template_items.ref_type=MATERIAL)
|
|
$bomTemplateCount = \App\Models\Design\BomTemplateItem::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('ref_type', 'MATERIAL')
|
|
->where('ref_id', $materialId)
|
|
->count();
|
|
if ($bomTemplateCount > 0) {
|
|
$usage['bom_templates'] = $bomTemplateCount;
|
|
}
|
|
|
|
// 3. 자재 입고 (material_receipts.material_id)
|
|
$receiptCount = \App\Models\Materials\MaterialReceipt::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('material_id', $materialId)
|
|
->count();
|
|
if ($receiptCount > 0) {
|
|
$usage['receipts'] = $receiptCount;
|
|
}
|
|
|
|
// 4. LOT (lots.material_id)
|
|
$lotCount = \App\Models\Qualitys\Lot::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('material_id', $materialId)
|
|
->count();
|
|
if ($lotCount > 0) {
|
|
$usage['lots'] = $lotCount;
|
|
}
|
|
|
|
return [
|
|
'is_used' => ! empty($usage),
|
|
'usage' => $usage,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 사용처 정보를 한글 메시지로 변환
|
|
*/
|
|
private function formatUsageMessage(array $usage): string
|
|
{
|
|
$labels = [
|
|
'bom_components' => 'BOM 구성품',
|
|
'bom_parent' => 'BOM 상위품목',
|
|
'bom_templates' => 'BOM 템플릿',
|
|
'orders' => '주문',
|
|
'order_items' => '주문 항목',
|
|
'quotes' => '견적',
|
|
'receipts' => '입고',
|
|
'lots' => 'LOT',
|
|
];
|
|
|
|
$parts = [];
|
|
foreach ($usage as $key => $count) {
|
|
$label = $labels[$key] ?? $key;
|
|
$parts[] = "{$label} {$count}건";
|
|
}
|
|
|
|
return implode(', ', $parts);
|
|
}
|
|
|
|
/**
|
|
* 품목 일괄 삭제 (Product/Material 통합, Force Delete)
|
|
*
|
|
* - 다른 테이블에서 사용 중인 품목은 삭제 불가
|
|
* - 사용 안함 → Force Delete (영구 삭제)
|
|
*
|
|
* @param array $ids 품목 ID 배열
|
|
* @param string $itemType 품목 유형 코드 (FG/PT/SM/RM/CS)
|
|
*/
|
|
public function batchDeleteItems(array $ids, string $itemType = 'FG'): void
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$itemType = strtoupper($itemType);
|
|
|
|
if (ItemTypeHelper::isMaterial($itemType, $tenantId)) {
|
|
$this->batchDeleteMaterials($ids);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->batchDeleteProducts($ids);
|
|
}
|
|
|
|
/**
|
|
* Product 일괄 삭제 (FG, PT) - Force Delete
|
|
*
|
|
* - 사용 중인 품목이 있으면 전체 삭제 실패
|
|
* - 모두 사용 안함 → 영구 삭제
|
|
*/
|
|
private function batchDeleteProducts(array $ids): void
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$products = Product::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('id', $ids)
|
|
->get();
|
|
|
|
if ($products->isEmpty()) {
|
|
throw new NotFoundHttpException(__('error.item.not_found'));
|
|
}
|
|
|
|
// 사용 중인 품목 체크 (하나라도 사용 중이면 전체 실패)
|
|
$inUseItems = [];
|
|
foreach ($products as $product) {
|
|
$usageResult = $this->checkProductUsage($tenantId, $product->id);
|
|
if ($usageResult['is_used']) {
|
|
$inUseItems[] = [
|
|
'id' => $product->id,
|
|
'code' => $product->code,
|
|
'usage' => $usageResult['usage'],
|
|
];
|
|
}
|
|
}
|
|
|
|
if (! empty($inUseItems)) {
|
|
$codes = array_column($inUseItems, 'code');
|
|
throw new BadRequestHttpException(__('error.item.batch_in_use', [
|
|
'codes' => implode(', ', $codes),
|
|
'count' => count($inUseItems),
|
|
]));
|
|
}
|
|
|
|
// 모두 사용 안함 → Force Delete
|
|
foreach ($products as $product) {
|
|
$product->forceDelete();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Material 일괄 삭제 (SM, RM, CS) - Force Delete
|
|
*
|
|
* - 사용 중인 자재가 있으면 전체 삭제 실패
|
|
* - 모두 사용 안함 → 영구 삭제
|
|
*/
|
|
private function batchDeleteMaterials(array $ids): void
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$materials = Material::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('id', $ids)
|
|
->get();
|
|
|
|
if ($materials->isEmpty()) {
|
|
throw new NotFoundHttpException(__('error.item.not_found'));
|
|
}
|
|
|
|
// 사용 중인 자재 체크 (하나라도 사용 중이면 전체 실패)
|
|
$inUseItems = [];
|
|
foreach ($materials as $material) {
|
|
$usageResult = $this->checkMaterialUsage($tenantId, $material->id);
|
|
if ($usageResult['is_used']) {
|
|
$inUseItems[] = [
|
|
'id' => $material->id,
|
|
'code' => $material->material_code,
|
|
'usage' => $usageResult['usage'],
|
|
];
|
|
}
|
|
}
|
|
|
|
if (! empty($inUseItems)) {
|
|
$codes = array_column($inUseItems, 'code');
|
|
throw new BadRequestHttpException(__('error.item.batch_in_use', [
|
|
'codes' => implode(', ', $codes),
|
|
'count' => count($inUseItems),
|
|
]));
|
|
}
|
|
|
|
// 모두 사용 안함 → Force Delete
|
|
foreach ($materials as $material) {
|
|
$material->forceDelete();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 품목의 파일 목록 조회 (field_key별 그룹핑)
|
|
*
|
|
* @param int $itemId 품목 ID
|
|
* @param int $tenantId 테넌트 ID
|
|
* @return array field_key별로 그룹핑된 파일 목록
|
|
*/
|
|
private function getItemFiles(int $itemId, int $tenantId): array
|
|
{
|
|
$files = File::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('document_type', '1') // ITEM_GROUP_ID
|
|
->where('document_id', $itemId)
|
|
->whereNull('deleted_at')
|
|
->orderBy('created_at', 'desc')
|
|
->get();
|
|
|
|
if ($files->isEmpty()) {
|
|
return [];
|
|
}
|
|
|
|
return $files->groupBy('field_key')->map(function ($group) {
|
|
return $group->map(fn ($file) => [
|
|
'id' => $file->id,
|
|
'file_name' => $file->display_name ?? $file->file_name,
|
|
'file_path' => $file->file_path,
|
|
])->values()->toArray();
|
|
})->toArray();
|
|
}
|
|
|
|
/**
|
|
* 품목 상세 조회 (code 기반, BOM 포함 옵션)
|
|
*
|
|
* Product 먼저 조회 → 없으면 Material 조회
|
|
*
|
|
* @param string $code 품목 코드
|
|
* @param bool $includeBom BOM 포함 여부
|
|
* @return array 품목 데이터 (attributes 플랫 전개)
|
|
*/
|
|
public function getItemByCode(string $code, bool $includeBom = false): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
// 1. Product에서 먼저 조회
|
|
$query = Product::query()
|
|
->with('category:id,name')
|
|
->where('tenant_id', $tenantId)
|
|
->where('code', $code);
|
|
|
|
if ($includeBom) {
|
|
$query->with('componentLines.childProduct:id,code,name,unit');
|
|
}
|
|
|
|
$product = $query->first();
|
|
|
|
if ($product) {
|
|
$data = $this->flattenAttributes($product->toArray());
|
|
$data['item_type'] = $product->product_type;
|
|
$data['type_code'] = $product->product_type;
|
|
|
|
return $data;
|
|
}
|
|
|
|
// 2. Material에서 조회
|
|
$material = Material::query()
|
|
->with('category:id,name')
|
|
->where('tenant_id', $tenantId)
|
|
->where('material_code', $code)
|
|
->first();
|
|
|
|
if ($material) {
|
|
$data = $this->flattenAttributes($material->toArray());
|
|
$data['item_type'] = $material->material_type;
|
|
$data['code'] = $material->material_code;
|
|
$data['type_code'] = $material->material_type;
|
|
|
|
return $data;
|
|
}
|
|
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
}
|