- 수식 산출 결과 합계 옆에 'BOM 저장' 버튼 추가
- 클릭 시 산출된 전체 품목을 FG의 bom JSON 필드에 저장
- 저장 후 자동으로 BOM 탭 전환 + 트리 표시
- POST /api/admin/items/{id}/save-bom 엔드포인트 추가
450 lines
14 KiB
PHP
450 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Audit\AuditLog;
|
|
use App\Models\Items\Item;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class ItemManagementService
|
|
{
|
|
/**
|
|
* 품목 목록 조회 (검색, 유형 필터, 페이지네이션)
|
|
*/
|
|
public function getItemList(array $filters): LengthAwarePaginator
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
$search = $filters['search'] ?? null;
|
|
$itemType = $filters['item_type'] ?? null;
|
|
$perPage = $filters['per_page'] ?? 50;
|
|
|
|
return Item::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->search($search)
|
|
->active()
|
|
->when($itemType, function ($query, $types) {
|
|
$typeList = explode(',', $types);
|
|
$query->whereIn('item_type', $typeList);
|
|
})
|
|
->orderBy('code')
|
|
->paginate($perPage);
|
|
}
|
|
|
|
/**
|
|
* BOM 재귀 트리 조회
|
|
*/
|
|
public function getBomTree(int $itemId, int $maxDepth = 10): array
|
|
{
|
|
$item = Item::withoutGlobalScopes()
|
|
->where('tenant_id', session('selected_tenant_id'))
|
|
->with('details')
|
|
->findOrFail($itemId);
|
|
|
|
return $this->buildBomNode($item, 0, $maxDepth, []);
|
|
}
|
|
|
|
/**
|
|
* 품목 상세 조회 (1depth BOM + 파일 + 절곡정보)
|
|
*/
|
|
public function getItemDetail(int $itemId): array
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
$item = Item::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->with(['details', 'category', 'files'])
|
|
->findOrFail($itemId);
|
|
|
|
// BOM 1depth: 직접 연결된 자식 품목만
|
|
$bomChildren = [];
|
|
$bomData = $item->bom ?? [];
|
|
if (! empty($bomData)) {
|
|
$childIds = array_column($bomData, 'child_item_id');
|
|
$children = Item::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('id', $childIds)
|
|
->get(['id', 'code', 'name', 'item_type', 'unit'])
|
|
->keyBy('id');
|
|
|
|
foreach ($bomData as $bom) {
|
|
$child = $children->get($bom['child_item_id']);
|
|
if ($child) {
|
|
$bomChildren[] = [
|
|
'id' => $child->id,
|
|
'code' => $child->code,
|
|
'name' => $child->name,
|
|
'item_type' => $child->item_type,
|
|
'unit' => $child->unit,
|
|
'quantity' => $bom['quantity'] ?? 1,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'item' => $item,
|
|
'bom_children' => $bomChildren,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 수식 산출 결과를 FG 품목의 BOM으로 저장
|
|
*/
|
|
public function saveBomFromFormula(int $itemId, array $bomItems): array
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
$item = Item::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($itemId);
|
|
|
|
if ($item->item_type !== 'FG') {
|
|
return ['success' => false, 'error' => 'FG 품목만 BOM을 저장할 수 있습니다.'];
|
|
}
|
|
|
|
// child_item_id 유효성 검증
|
|
$childIds = array_column($bomItems, 'child_item_id');
|
|
$validIds = Item::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('id', $childIds)
|
|
->pluck('id')
|
|
->toArray();
|
|
|
|
$bom = [];
|
|
foreach ($bomItems as $bi) {
|
|
$childId = (int) $bi['child_item_id'];
|
|
if (! in_array($childId, $validIds)) {
|
|
continue;
|
|
}
|
|
$bom[] = [
|
|
'child_item_id' => $childId,
|
|
'child_item_code' => $bi['child_item_code'] ?? '',
|
|
'quantity' => round((float) ($bi['quantity'] ?? 1), 4),
|
|
'unit' => $bi['unit'] ?? '',
|
|
'category' => $bi['category'] ?? '',
|
|
];
|
|
}
|
|
|
|
$item->bom = $bom;
|
|
$item->updated_by = auth()->id();
|
|
$item->save();
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => "BOM {$item->name}에 ".count($bom).'건 저장 완료',
|
|
'count' => count($bom),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 품목 사용 현황 조회 (삭제 전 참조 체크)
|
|
*/
|
|
public function checkItemUsage(int $itemId): array
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
$usage = [];
|
|
|
|
// 다른 품목의 BOM에 포함되어 있는지 (JSON 검색)
|
|
$bomParents = Item::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->whereRaw("JSON_CONTAINS(bom, JSON_OBJECT('child_item_id', ?))", [$itemId])
|
|
->count();
|
|
if ($bomParents > 0) {
|
|
$usage[] = "다른 품목의 BOM 구성품 ({$bomParents}건)";
|
|
}
|
|
|
|
// 주문 항목 (order_items.item_id)
|
|
$orderItems = DB::table('order_items')->where('item_id', $itemId)->count();
|
|
if ($orderItems > 0) {
|
|
$usage[] = "수주 항목 ({$orderItems}건)";
|
|
}
|
|
|
|
// 입고 (receivings.item_id)
|
|
if (DB::getSchemaBuilder()->hasTable('receivings')) {
|
|
$receipts = DB::table('receivings')->where('item_id', $itemId)->count();
|
|
if ($receipts > 0) {
|
|
$usage[] = "입고 ({$receipts}건)";
|
|
}
|
|
}
|
|
|
|
// LOT (lots.item_id)
|
|
$lots = DB::table('lots')->where('item_id', $itemId)->count();
|
|
if ($lots > 0) {
|
|
$usage[] = "LOT ({$lots}건)";
|
|
}
|
|
|
|
// 작업지시 (work_order_items.item_id)
|
|
$workOrders = DB::table('work_order_items')->where('item_id', $itemId)->count();
|
|
if ($workOrders > 0) {
|
|
$usage[] = "작업지시 ({$workOrders}건)";
|
|
}
|
|
|
|
return $usage;
|
|
}
|
|
|
|
/**
|
|
* 품목 삭제 (Soft Delete)
|
|
*/
|
|
public function deleteItem(int $itemId): array
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
$item = Item::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($itemId);
|
|
|
|
// 사용 현황 체크
|
|
$usage = $this->checkItemUsage($itemId);
|
|
if (! empty($usage)) {
|
|
return [
|
|
'success' => false,
|
|
'error' => '사용 중인 품목은 삭제할 수 없습니다.',
|
|
'usage' => $usage,
|
|
];
|
|
}
|
|
|
|
$item->deleted_by = auth()->id();
|
|
$item->save();
|
|
$item->delete();
|
|
|
|
return ['success' => true, 'message' => "품목 '{$item->name}'이(가) 삭제되었습니다."];
|
|
}
|
|
|
|
/**
|
|
* 품목 이력 조회 (audit_logs + 생성/수정 정보)
|
|
*/
|
|
public function getItemHistory(int $itemId): array
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
$item = Item::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($itemId);
|
|
|
|
// audit_logs에서 해당 품목 이력
|
|
$auditLogs = AuditLog::where('tenant_id', $tenantId)
|
|
->where('target_type', 'item')
|
|
->where('target_id', $itemId)
|
|
->orderByDesc('created_at')
|
|
->limit(50)
|
|
->get();
|
|
|
|
// 사용자 ID → 이름 매핑
|
|
$actorIds = $auditLogs->pluck('actor_id')->filter()->unique()->values();
|
|
$actors = [];
|
|
if ($actorIds->isNotEmpty()) {
|
|
$actors = DB::table('users')
|
|
->whereIn('id', $actorIds)
|
|
->pluck('name', 'id')
|
|
->toArray();
|
|
}
|
|
|
|
$logs = $auditLogs->map(function ($log) use ($actors) {
|
|
return [
|
|
'id' => $log->id,
|
|
'action' => $log->action,
|
|
'action_label' => $this->getActionLabel($log->action),
|
|
'actor' => $actors[$log->actor_id] ?? '시스템',
|
|
'created_at' => $log->created_at->format('Y-m-d H:i:s'),
|
|
'before' => $log->before,
|
|
'after' => $log->after,
|
|
];
|
|
});
|
|
|
|
return [
|
|
'item' => [
|
|
'id' => $item->id,
|
|
'code' => $item->code,
|
|
'name' => $item->name,
|
|
'created_at' => $item->created_at?->format('Y-m-d H:i:s'),
|
|
'updated_at' => $item->updated_at?->format('Y-m-d H:i:s'),
|
|
],
|
|
'logs' => $logs,
|
|
];
|
|
}
|
|
|
|
private function getActionLabel(string $action): string
|
|
{
|
|
return match ($action) {
|
|
'created' => '생성',
|
|
'updated' => '수정',
|
|
'deleted' => '삭제',
|
|
'restored' => '복원',
|
|
'bom_updated' => 'BOM 변경',
|
|
'stock_increase' => '재고 증가',
|
|
'stock_decrease' => '재고 차감',
|
|
default => $action,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 절곡BOM 트리 조회 (SF-BND 품목 기반)
|
|
* FG 품목 선택 시: 해당 FG의 BOM에서 절곡 관련 품목만 필터
|
|
* 그 외: 전체 절곡 품목(SF-BND) 트리 반환
|
|
*/
|
|
public function getBendingBomTree(?int $itemId = null): array
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
// FG 품목이 선택된 경우: 해당 FG의 BOM에서 절곡 관련만 추출
|
|
if ($itemId) {
|
|
$item = Item::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->find($itemId);
|
|
|
|
if ($item && $item->item_type === 'FG') {
|
|
return $this->buildFgBendingTree($item, $tenantId);
|
|
}
|
|
}
|
|
|
|
// 전체 절곡 품목 트리
|
|
$bendingItems = Item::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->where('code', 'like', 'SF-BND%')
|
|
->active()
|
|
->orderBy('code')
|
|
->get();
|
|
|
|
$nodes = [];
|
|
foreach ($bendingItems as $bendingItem) {
|
|
$nodes[] = $this->buildBomNode($bendingItem, 0, 3, []);
|
|
}
|
|
|
|
return $nodes;
|
|
}
|
|
|
|
/**
|
|
* FG 품목의 BOM에서 절곡 관련 품목만 추출하여 트리 구성
|
|
*/
|
|
private function buildFgBendingTree(Item $fgItem, int $tenantId): array
|
|
{
|
|
$bomData = $fgItem->bom ?? [];
|
|
if (empty($bomData)) {
|
|
return [];
|
|
}
|
|
|
|
$childIds = array_column($bomData, 'child_item_id');
|
|
$children = Item::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('id', $childIds)
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
$nodes = [];
|
|
foreach ($bomData as $bom) {
|
|
$child = $children->get($bom['child_item_id']);
|
|
if (! $child) {
|
|
continue;
|
|
}
|
|
|
|
// 절곡 관련 품목이면 바로 추가
|
|
if (str_starts_with($child->code, 'SF-BND')) {
|
|
$node = $this->buildBomNode($child, 0, 3, []);
|
|
$node['quantity'] = $bom['quantity'] ?? 1;
|
|
$nodes[] = $node;
|
|
|
|
continue;
|
|
}
|
|
|
|
// SF 품목이면 그 하위에서 절곡 품목 탐색
|
|
if ($child->item_type === 'SF' || $child->item_type === 'PT') {
|
|
$subBending = $this->findBendingChildren($child, $tenantId, 1, []);
|
|
$nodes = array_merge($nodes, $subBending);
|
|
}
|
|
}
|
|
|
|
return $nodes;
|
|
}
|
|
|
|
/**
|
|
* 재귀적으로 절곡 관련 자식 품목 탐색
|
|
*/
|
|
private function findBendingChildren(Item $item, int $tenantId, int $depth, array $visited): array
|
|
{
|
|
if (in_array($item->id, $visited) || $depth >= 5) {
|
|
return [];
|
|
}
|
|
$visited[] = $item->id;
|
|
|
|
$bomData = $item->bom ?? [];
|
|
if (empty($bomData)) {
|
|
return [];
|
|
}
|
|
|
|
$childIds = array_column($bomData, 'child_item_id');
|
|
$children = Item::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('id', $childIds)
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
$nodes = [];
|
|
foreach ($bomData as $bom) {
|
|
$child = $children->get($bom['child_item_id']);
|
|
if (! $child) {
|
|
continue;
|
|
}
|
|
|
|
if (str_starts_with($child->code, 'SF-BND')) {
|
|
$node = $this->buildBomNode($child, 0, 3, []);
|
|
$node['quantity'] = $bom['quantity'] ?? 1;
|
|
$nodes[] = $node;
|
|
} elseif ($child->item_type === 'SF' || $child->item_type === 'PT') {
|
|
$subNodes = $this->findBendingChildren($child, $tenantId, $depth + 1, $visited);
|
|
$nodes = array_merge($nodes, $subNodes);
|
|
}
|
|
}
|
|
|
|
return $nodes;
|
|
}
|
|
|
|
// ── Private ──
|
|
|
|
private function buildBomNode(Item $item, int $depth, int $maxDepth, array $visited): array
|
|
{
|
|
// 순환 참조 방지: visited 배열 + maxDepth 이중 안전장치
|
|
if (in_array($item->id, $visited) || $depth >= $maxDepth) {
|
|
return $this->formatNode($item, $depth, []);
|
|
}
|
|
|
|
$visited[] = $item->id;
|
|
$children = [];
|
|
|
|
$bomData = $item->bom ?? [];
|
|
if (! empty($bomData)) {
|
|
$childIds = array_column($bomData, 'child_item_id');
|
|
$childItems = Item::withoutGlobalScopes()
|
|
->where('tenant_id', session('selected_tenant_id'))
|
|
->whereIn('id', $childIds)
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
foreach ($bomData as $bom) {
|
|
$childItem = $childItems->get($bom['child_item_id']);
|
|
if ($childItem) {
|
|
$childNode = $this->buildBomNode($childItem, $depth + 1, $maxDepth, $visited);
|
|
$childNode['quantity'] = $bom['quantity'] ?? 1;
|
|
$children[] = $childNode;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $this->formatNode($item, $depth, $children);
|
|
}
|
|
|
|
private function formatNode(Item $item, int $depth, array $children): array
|
|
{
|
|
return [
|
|
'id' => $item->id,
|
|
'code' => $item->code,
|
|
'name' => $item->name,
|
|
'item_type' => $item->item_type,
|
|
'unit' => $item->unit,
|
|
'depth' => $depth,
|
|
'has_children' => count($children) > 0,
|
|
'children' => $children,
|
|
];
|
|
}
|
|
}
|