Files
sam-api/app/Services/ItemsService.php
2026-01-20 20:43:38 +09:00

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