feat: 견적확정 밸리데이션, 작업지시 통계 공정별 카운트, 입고/재고 개선

- 견적확정 시 업체명/현장명/담당자/연락처 필수 검증 추가 (QuoteService)
- 작업지시 stats API에 by_process 공정별 카운트 반환 추가
- 작업지시 목록/상세 쿼리에 수주 개소(rootNodes) 연관 로딩
- 작업지시 품목에 sourceOrderItem.node 관계 추가
- 입고관리 완료건 수정 허용 및 재고 차이 조정
- work_order_step_progress 테이블 마이그레이션
- receivings 테이블 options 컬럼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 03:27:07 +09:00
parent 6b3e5c3e87
commit 487e651845
22 changed files with 1422 additions and 72 deletions

View File

@@ -489,21 +489,12 @@ public function resolve(array $params): array
];
$categoryName = $categoryMapping[$category] ?? $category;
$template = DocumentTemplate::query()
$baseQuery = DocumentTemplate::query()
->where('tenant_id', $tenantId)
->where('is_active', true)
->where(function ($q) use ($category, $categoryName) {
// category 필드가 code 또는 name과 매칭
$q->where('category', $category)
->orWhere('category', $categoryName)
->orWhere('category', 'LIKE', "%{$categoryName}%");
})
->whereHas('links', function ($q) use ($itemId) {
// 해당 item_id가 연결된 템플릿만
$q->where('source_table', 'items')
->whereHas('linkValues', function ($q2) use ($itemId) {
$q2->where('linkable_id', $itemId);
});
->where(function ($q) use ($itemId) {
$q->whereJsonContains('linked_item_ids', (int) $itemId)
->orWhereJsonContains('linked_item_ids', (string) $itemId);
})
->with([
'approvalLines',
@@ -511,10 +502,22 @@ public function resolve(array $params): array
'sections.items',
'columns',
'sectionFields',
'links.linkValues',
])
]);
// 1차: category 매칭 + item_id
$template = (clone $baseQuery)
->where(function ($q) use ($category, $categoryName) {
$q->where('category', $category)
->orWhere('category', $categoryName)
->orWhere('category', 'LIKE', "%{$categoryName}%");
})
->first();
// 2차: category 무관, item_id 연결만으로 fallback
if (! $template) {
$template = $baseQuery->first();
}
if (! $template) {
throw new NotFoundHttpException(__('error.document.template_not_found'));
}
@@ -607,6 +610,19 @@ public function upsert(array $data): Document
*/
private function formatTemplateForReact(DocumentTemplate $template): array
{
// common_codes에서 inspection_method 코드 목록 조회 (code → name 매핑)
$tenantId = $this->tenantId();
$methodCodes = DB::table('common_codes')
->where('code_group', 'inspection_method')
->where('is_active', true)
->where(function ($q) use ($tenantId) {
$q->where('tenant_id', $tenantId)
->orWhereNull('tenant_id');
})
->orderByRaw('tenant_id IS NULL') // tenant 우선
->pluck('name', 'code')
->toArray();
return [
'id' => $template->id,
'name' => $template->name,
@@ -620,6 +636,8 @@ private function formatTemplateForReact(DocumentTemplate $template): array
'footer_judgement_options' => $template->footer_judgement_options,
'approval_lines' => $template->approvalLines->map(fn ($line) => [
'id' => $line->id,
'name' => $line->name,
'dept' => $line->dept,
'role' => $line->role,
'user_id' => $line->user_id,
'sort_order' => $line->sort_order,
@@ -648,23 +666,29 @@ private function formatTemplateForReact(DocumentTemplate $template): array
'id' => $section->id,
'name' => $section->name,
'sort_order' => $section->sort_order,
'items' => $section->items->map(fn ($item) => [
'id' => $item->id,
'field_values' => $item->field_values ?? [],
// 레거시 필드도 포함 (하위 호환)
'category' => $item->category,
'item' => $item->item,
'standard' => $item->standard,
'standard_criteria' => $item->standard_criteria,
'tolerance' => $item->tolerance,
'method' => $item->method,
'measurement_type' => $item->measurement_type,
'frequency' => $item->frequency,
'frequency_n' => $item->frequency_n,
'frequency_c' => $item->frequency_c,
'regulation' => $item->regulation,
'sort_order' => $item->sort_order,
])->toArray(),
'items' => $section->items->map(function ($item) use ($methodCodes) {
// method 코드를 한글 이름으로 변환
$methodName = $item->method ? ($methodCodes[$item->method] ?? $item->method) : null;
return [
'id' => $item->id,
'field_values' => $item->field_values ?? [],
// 레거시 필드도 포함 (하위 호환)
'category' => $item->category,
'item' => $item->item,
'standard' => $item->standard,
'standard_criteria' => $item->standard_criteria,
'tolerance' => $item->tolerance,
'method' => $item->method,
'method_name' => $methodName, // 검사방식 한글 이름 추가
'measurement_type' => $item->measurement_type,
'frequency' => $item->frequency,
'frequency_n' => $item->frequency_n,
'frequency_c' => $item->frequency_c,
'regulation' => $item->regulation,
'sort_order' => $item->sort_order,
];
})->toArray(),
])->toArray(),
'columns' => $template->columns->map(fn ($col) => [
'id' => $col->id,
@@ -707,9 +731,11 @@ private function formatDocumentForReact(Document $document): array
'description' => $a->description,
'file' => $a->file ? [
'id' => $a->file->id,
'original_name' => $a->file->original_name,
'original_name' => $a->file->original_name ?? $a->file->display_name ?? $a->file->stored_name,
'display_name' => $a->file->display_name,
'file_path' => $a->file->file_path,
'file_size' => $a->file->file_size,
'mime_type' => $a->file->mime_type,
] : null,
])->toArray(),
'approvals' => $document->approvals->map(fn ($ap) => [

View File

@@ -1029,23 +1029,34 @@ private function getItemsWithInspectionTemplate(array $itemIds): array
$tenantId = $this->tenantId();
// document_templates에서 category='incoming_inspection'이고
// linked_item_ids JSON 배열에 품목 ID가 포함된 템플릿 조회
// DocumentService::resolve()와 동일한 category 매칭 조건
$categoryCode = 'incoming_inspection';
$categoryName = '수입검사';
$templates = \DB::table('document_templates')
->where('tenant_id', $tenantId)
->where('category', 'incoming_inspection')
->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) ?? [];
$linkedItemIds = array_merge($linkedItemIds, $ids);
// int/string 타입 모두 매칭되도록 정수로 통일
foreach ($ids as $id) {
$linkedItemIds[] = (int) $id;
}
}
// 요청된 품목 ID와 연결된 품목 ID의 교집합
return array_values(array_intersect($itemIds, array_unique($linkedItemIds)));
// 요청된 품목 ID도 정수로 통일하여 교집합
$intItemIds = array_map('intval', $itemIds);
return array_values(array_intersect($intItemIds, array_unique($linkedItemIds)));
}
/**

View File

@@ -199,6 +199,7 @@ public static function getUserInfoForLogin(int $userId): array
'name' => $user->name,
'email' => $user->email,
'phone' => $user->phone,
'department' => null,
];
// 2. 활성 테넌트 조회 (1순위: is_default=1, 2순위: is_active=1 첫 번째)
@@ -221,6 +222,18 @@ public static function getUserInfoForLogin(int $userId): array
$defaultUserTenant = $userTenants->first();
$tenant = $defaultUserTenant->tenant;
// 2-1. 소속 부서 조회 (tenant_user_profiles → departments)
$profile = DB::table('tenant_user_profiles')
->where('user_id', $userId)
->where('tenant_id', $tenant->id)
->first();
if ($profile && $profile->department_id) {
$dept = DB::table('departments')->where('id', $profile->department_id)->first();
if ($dept) {
$userInfo['department'] = $dept->name;
}
}
// 3. 테넌트 정보 구성
$tenantInfo = [
'id' => $tenant->id,

View File

@@ -753,9 +753,9 @@ public function createProductionOrder(int $orderId, array $data)
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 수주 조회
// 수주 + 노드 조회
$order = Order::where('tenant_id', $tenantId)
->with('items')
->with(['items', 'rootNodes'])
->find($orderId);
if (! $order) {
@@ -776,16 +776,31 @@ public function createProductionOrder(int $orderId, array $data)
throw new BadRequestHttpException(__('error.order.production_order_already_exists'));
}
// order_items의 item_id를 기반으로 공정별 자동 분류
$itemIds = $order->items->pluck('item_id')->filter()->unique()->values()->toArray();
// order_nodes의 BOM 결과를 기반으로 공정별 자동 분류
$bomItemIds = [];
$nodesBomMap = []; // node_id => [item_name => bom_item]
foreach ($order->rootNodes as $node) {
$bomResult = $node->options['bom_result'] ?? [];
$bomItems = $bomResult['items'] ?? [];
foreach ($bomItems as $bomItem) {
if (! empty($bomItem['item_id'])) {
$bomItemIds[] = $bomItem['item_id'];
$nodesBomMap[$node->id][$bomItem['item_name']] = $bomItem;
}
}
}
$bomItemIds = array_unique($bomItemIds);
// process_items 테이블에서 item_id → process_id 매핑 조회
$itemProcessMap = [];
if (! empty($itemIds)) {
if (! empty($bomItemIds)) {
$processItems = DB::table('process_items as pi')
->join('processes as p', 'pi.process_id', '=', 'p.id')
->where('p.tenant_id', $tenantId)
->whereIn('pi.item_id', $itemIds)
->whereIn('pi.item_id', $bomItemIds)
->where('pi.is_active', true)
->select('pi.item_id', 'pi.process_id')
->get();
@@ -795,11 +810,25 @@ public function createProductionOrder(int $orderId, array $data)
}
}
// order_items를 공정별로 그룹화
// order_items를 공정별로 그룹화 (BOM item_id → process 매핑 활용)
$itemsByProcess = [];
foreach ($order->items as $orderItem) {
$processId = $itemProcessMap[$orderItem->item_id] ?? null;
$key = $processId ?? 'none'; // null은 'none' 키로 그룹화
$processId = null;
// 1. order_item의 item_id가 있으면 직접 매핑
if ($orderItem->item_id && isset($itemProcessMap[$orderItem->item_id])) {
$processId = $itemProcessMap[$orderItem->item_id];
}
// 2. item_id가 없으면 노드의 BOM에서 item_name으로 찾기
elseif ($orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) {
$nodeBom = $nodesBomMap[$orderItem->order_node_id];
$bomItem = $nodeBom[$orderItem->item_name] ?? null;
if ($bomItem && ! empty($bomItem['item_id']) && isset($itemProcessMap[$bomItem['item_id']])) {
$processId = $itemProcessMap[$bomItem['item_id']];
}
}
$key = $processId ?? 'none';
if (! isset($itemsByProcess[$key])) {
$itemsByProcess[$key] = [
@@ -810,7 +839,7 @@ public function createProductionOrder(int $orderId, array $data)
$itemsByProcess[$key]['items'][] = $orderItem;
}
return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess) {
return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess, $nodesBomMap) {
$workOrders = [];
foreach ($itemsByProcess as $key => $group) {
@@ -840,11 +869,18 @@ public function createProductionOrder(int $orderId, array $data)
// work_order_items에 아이템 추가
$sortOrder = 1;
foreach ($items as $orderItem) {
// item_id 결정: order_item에 있으면 사용, 없으면 BOM에서 가져오기
$itemId = $orderItem->item_id;
if (! $itemId && $orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) {
$bomItem = $nodesBomMap[$orderItem->order_node_id][$orderItem->item_name] ?? null;
$itemId = $bomItem['item_id'] ?? null;
}
DB::table('work_order_items')->insert([
'tenant_id' => $tenantId,
'work_order_id' => $workOrder->id,
'source_order_item_id' => $orderItem->id,
'item_id' => $orderItem->item_id,
'item_id' => $itemId,
'item_name' => $orderItem->item_name,
'specification' => $orderItem->specification,
'quantity' => $orderItem->quantity,

View File

@@ -526,6 +526,26 @@ public function finalize(int $id): Quote
throw new BadRequestHttpException(__('error.quote_not_finalizable'));
}
// 확정 시 필수 필드 검증 (업체명, 현장명, 담당자, 연락처)
$missing = [];
if (empty($quote->client_name)) {
$missing[] = '업체명';
}
if (empty($quote->site_name)) {
$missing[] = '현장명';
}
if (empty($quote->manager)) {
$missing[] = '담당자';
}
if (empty($quote->contact)) {
$missing[] = '연락처';
}
if (! empty($missing)) {
throw new BadRequestHttpException(
__('error.quote_finalize_missing_fields', ['fields' => implode(', ', $missing)])
);
}
$quote->update([
'status' => Quote::STATUS_FINALIZED,
'is_final' => true,

View File

@@ -16,7 +16,7 @@ public function index(array $params): LengthAwarePaginator
$tenantId = $this->tenantId();
$query = Receiving::query()
->with('creator:id,name')
->with(['creator:id,name', 'item:id,item_type,code,name'])
->where('tenant_id', $tenantId);
// 검색어 필터
@@ -57,8 +57,67 @@ public function index(array $params): LengthAwarePaginator
// 페이지네이션
$perPage = $params['per_page'] ?? 20;
$paginator = $query->paginate($perPage);
return $query->paginate($perPage);
// 수입검사 템플릿 연결 여부 계산
$itemIds = $paginator->pluck('item_id')->filter()->unique()->values()->toArray();
$itemsWithInspection = $this->getItemsWithInspectionTemplate($itemIds);
// has_inspection_template 필드 추가
$paginator->getCollection()->transform(function ($receiving) use ($itemsWithInspection) {
$receiving->has_inspection_template = $receiving->item_id
? in_array($receiving->item_id, $itemsWithInspection)
: false;
return $receiving;
});
return $paginator;
}
/**
* 수입검사 템플릿에 연결된 품목 ID 조회
*
* DocumentService::resolve()와 동일한 조건 사용:
* - category: 영문 코드('incoming_inspection'), 한글('수입검사'), 부분 매칭 모두 지원
* - linked_item_ids: int/string 타입 모두 매칭
*/
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)));
}
/**
@@ -103,7 +162,7 @@ public function show(int $id): Receiving
return Receiving::query()
->where('tenant_id', $tenantId)
->with(['creator:id,name'])
->with(['creator:id,name', 'item:id,item_type,code,name'])
->findOrFail($id);
}
@@ -119,12 +178,18 @@ public function store(array $data): Receiving
// 입고번호 자동 생성
$receivingNumber = $this->generateReceivingNumber($tenantId);
// item_id 조회 (전달되지 않은 경우 item_code로 조회)
$itemId = $data['item_id'] ?? null;
if (! $itemId && ! empty($data['item_code'])) {
$itemId = $this->findItemIdByCode($tenantId, $data['item_code']);
}
$receiving = new Receiving;
$receiving->tenant_id = $tenantId;
$receiving->receiving_number = $receivingNumber;
$receiving->order_no = $data['order_no'] ?? null;
$receiving->order_date = $data['order_date'] ?? null;
$receiving->item_id = $data['item_id'] ?? null;
$receiving->item_id = $itemId;
$receiving->item_code = $data['item_code'];
$receiving->item_name = $data['item_name'];
$receiving->specification = $data['specification'] ?? null;
@@ -134,6 +199,10 @@ public function store(array $data): Receiving
$receiving->due_date = $data['due_date'] ?? null;
$receiving->status = $data['status'] ?? 'order_completed';
$receiving->remark = $data['remark'] ?? null;
// options 필드 처리 (제조사, 수입검사 등 확장 필드)
$receiving->options = $this->buildOptions($data);
$receiving->created_by = $userId;
$receiving->updated_by = $userId;
$receiving->save();
@@ -167,6 +236,13 @@ public function update(int $id, array $data): Receiving
}
if (isset($data['item_code'])) {
$receiving->item_code = $data['item_code'];
// item_code 변경 시 item_id도 업데이트
if (! isset($data['item_id'])) {
$receiving->item_id = $this->findItemIdByCode($tenantId, $data['item_code']);
}
}
if (isset($data['item_id'])) {
$receiving->item_id = $data['item_id'];
}
if (isset($data['item_name'])) {
$receiving->item_name = $data['item_name'];
@@ -190,10 +266,13 @@ public function update(int $id, array $data): Receiving
$receiving->remark = $data['remark'];
}
// 입고완료(completed) 상태 변경 시 입고처리 로직 실행
$isCompletingReceiving = isset($data['status'])
&& $data['status'] === 'completed'
&& $receiving->status !== 'completed';
// 상태 변경 감지
$oldStatus = $receiving->status;
$newStatus = $data['status'] ?? $oldStatus;
$wasCompleted = $oldStatus === 'completed';
// 입고완료(completed) 상태로 신규 전환
$isCompletingReceiving = $newStatus === 'completed' && ! $wasCompleted;
if ($isCompletingReceiving) {
// 입고수량 설정 (없으면 발주수량 사용)
@@ -201,16 +280,44 @@ public function update(int $id, array $data): Receiving
$receiving->receiving_date = $data['receiving_date'] ?? now()->toDateString();
$receiving->lot_no = $data['lot_no'] ?? $this->generateLotNo();
$receiving->status = 'completed';
} elseif (isset($data['status'])) {
$receiving->status = $data['status'];
} else {
// 일반 필드 업데이트
if (isset($data['receiving_qty'])) {
$receiving->receiving_qty = $data['receiving_qty'];
}
if (isset($data['receiving_date'])) {
$receiving->receiving_date = $data['receiving_date'];
}
if (isset($data['lot_no'])) {
$receiving->lot_no = $data['lot_no'];
}
if (isset($data['status'])) {
$receiving->status = $data['status'];
}
}
// options 필드 업데이트 (제조사, 수입검사 등 확장 필드)
$receiving->options = $this->mergeOptions($receiving->options, $data);
$receiving->updated_by = $userId;
$receiving->save();
// 입고완료 시 재고 연동
if ($isCompletingReceiving && $receiving->item_id) {
app(StockService::class)->increaseFromReceiving($receiving);
// 재고 연동
if ($receiving->item_id) {
$stockService = app(StockService::class);
if ($isCompletingReceiving) {
// 대기 → 완료: 전량 재고 증가
$stockService->increaseFromReceiving($receiving);
} elseif ($wasCompleted) {
// 기존 완료 상태에서 수정: 차이만큼 조정
// 완료→완료(수량변경): newQty = 변경된 수량
// 완료→대기: newQty = 0 (전량 차감)
$newQty = $newStatus === 'completed'
? (float) $receiving->receiving_qty
: 0;
$stockService->adjustFromReceiving($receiving, $newQty);
}
}
return $receiving->fresh();
@@ -318,4 +425,110 @@ private function generateLotNo(): string
return "{$year}{$month}{$day}-{$seq}";
}
/**
* 품목코드로 품목 ID 조회
*/
private function findItemIdByCode(int $tenantId, string $itemCode): ?int
{
$item = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $itemCode)
->whereNull('deleted_at')
->first(['id']);
return $item?->id;
}
/**
* options 필드 빌드 (등록 시)
*/
private function buildOptions(array $data): ?array
{
$options = [];
// 제조사
if (isset($data['manufacturer'])) {
$options[Receiving::OPTION_MANUFACTURER] = $data['manufacturer'];
}
// 거래처 자재번호
if (isset($data['material_no'])) {
$options[Receiving::OPTION_MATERIAL_NO] = $data['material_no'];
}
// 수입검사 상태 (적/부적/-)
if (isset($data['inspection_status'])) {
$options[Receiving::OPTION_INSPECTION_STATUS] = $data['inspection_status'];
}
// 검사일
if (isset($data['inspection_date'])) {
$options[Receiving::OPTION_INSPECTION_DATE] = $data['inspection_date'];
}
// 검사결과 (합격/불합격)
if (isset($data['inspection_result'])) {
$options[Receiving::OPTION_INSPECTION_RESULT] = $data['inspection_result'];
}
// 추가 확장 필드가 있으면 여기에 계속 추가 가능
return ! empty($options) ? $options : null;
}
/**
* options 필드 병합 (수정 시)
*/
private function mergeOptions(?array $existing, array $data): ?array
{
$options = $existing ?? [];
// 제조사
if (array_key_exists('manufacturer', $data)) {
if ($data['manufacturer'] === null || $data['manufacturer'] === '') {
unset($options[Receiving::OPTION_MANUFACTURER]);
} else {
$options[Receiving::OPTION_MANUFACTURER] = $data['manufacturer'];
}
}
// 거래처 자재번호
if (array_key_exists('material_no', $data)) {
if ($data['material_no'] === null || $data['material_no'] === '') {
unset($options[Receiving::OPTION_MATERIAL_NO]);
} else {
$options[Receiving::OPTION_MATERIAL_NO] = $data['material_no'];
}
}
// 수입검사 상태
if (array_key_exists('inspection_status', $data)) {
if ($data['inspection_status'] === null || $data['inspection_status'] === '') {
unset($options[Receiving::OPTION_INSPECTION_STATUS]);
} else {
$options[Receiving::OPTION_INSPECTION_STATUS] = $data['inspection_status'];
}
}
// 검사일
if (array_key_exists('inspection_date', $data)) {
if ($data['inspection_date'] === null || $data['inspection_date'] === '') {
unset($options[Receiving::OPTION_INSPECTION_DATE]);
} else {
$options[Receiving::OPTION_INSPECTION_DATE] = $data['inspection_date'];
}
}
// 검사결과
if (array_key_exists('inspection_result', $data)) {
if ($data['inspection_result'] === null || $data['inspection_result'] === '') {
unset($options[Receiving::OPTION_INSPECTION_RESULT]);
} else {
$options[Receiving::OPTION_INSPECTION_RESULT] = $data['inspection_result'];
}
}
return ! empty($options) ? $options : null;
}
}

View File

@@ -38,4 +38,16 @@ protected function apiUserId(): int
return (int) $uid;
}
/**
* 서비스 컨텍스트 설정 (다른 서비스에서 호출 시 사용)
* tenant_id, api_user를 명시적으로 설정
*/
public function setContext(int $tenantId, int $userId): self
{
app()->instance('tenant_id', $tenantId);
app()->instance('api_user', $userId);
return $this;
}
}

View File

@@ -313,6 +313,107 @@ public function increaseFromReceiving(Receiving $receiving): StockLot
});
}
/**
* 입고 수정 시 재고 조정 (차이만큼 증감)
*
* - completed→completed 수량변경: 차이만큼 조정 (50→60 = +10)
* - completed→대기: 전량 차감 (newQty = 0)
*
* @param Receiving $receiving 입고 레코드
* @param float $newQty 새 수량 (상태가 completed가 아니면 0)
*/
public function adjustFromReceiving(Receiving $receiving, float $newQty): void
{
if (! $receiving->item_id) {
return;
}
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
DB::transaction(function () use ($receiving, $newQty, $tenantId, $userId) {
// 1. 해당 입고로 생성된 StockLot 조회
$stockLot = StockLot::where('tenant_id', $tenantId)
->where('receiving_id', $receiving->id)
->first();
if (! $stockLot) {
Log::warning('StockLot not found for receiving adjustment', [
'receiving_id' => $receiving->id,
]);
return;
}
$stock = Stock::where('id', $stockLot->stock_id)
->lockForUpdate()
->first();
if (! $stock) {
return;
}
$oldQty = (float) $stockLot->qty;
$diff = $newQty - $oldQty;
// 차이가 없으면 스킵
if (abs($diff) < 0.001) {
return;
}
// 2. StockLot 수량 조정
$stockLot->qty = $newQty;
$stockLot->available_qty = max(0, $newQty - $stockLot->reserved_qty);
$stockLot->updated_by = $userId;
if ($newQty <= 0) {
$stockLot->qty = 0;
$stockLot->available_qty = 0;
$stockLot->reserved_qty = 0;
$stockLot->status = 'used';
} else {
$stockLot->status = 'available';
}
$stockLot->save();
// 3. Stock 정보 갱신
$stock->refreshFromLots();
// 4. 거래 이력 기록
$this->recordTransaction(
stock: $stock,
type: $diff > 0 ? StockTransaction::TYPE_IN : StockTransaction::TYPE_OUT,
qty: $diff,
reason: StockTransaction::REASON_RECEIVING,
referenceType: 'receiving',
referenceId: $receiving->id,
lotNo: $receiving->lot_no,
stockLotId: $stockLot->id
);
// 5. 감사 로그 기록
$this->logStockChange(
stock: $stock,
action: $diff > 0 ? 'stock_increase' : 'stock_decrease',
reason: 'receiving_adjustment',
referenceType: 'receiving',
referenceId: $receiving->id,
qtyChange: $diff,
lotNo: $receiving->lot_no
);
Log::info('Stock adjusted from receiving modification', [
'receiving_id' => $receiving->id,
'item_id' => $receiving->item_id,
'stock_id' => $stock->id,
'old_qty' => $oldQty,
'new_qty' => $newQty,
'diff' => $diff,
]);
});
}
/**
* Stock 조회 또는 생성
*

View File

@@ -7,6 +7,7 @@
use App\Models\Production\WorkOrderAssignee;
use App\Models\Production\WorkOrderBendingDetail;
use App\Models\Production\WorkOrderItem;
use App\Models\Production\WorkOrderStepProgress;
use App\Models\Tenants\Shipment;
use App\Models\Tenants\ShipmentItem;
use App\Services\Audit\AuditLogger;
@@ -37,6 +38,7 @@ public function index(array $params)
$processCode = $params['process_code'] ?? null;
$assigneeId = $params['assignee_id'] ?? null;
$assignedToMe = isset($params['assigned_to_me']) && $params['assigned_to_me'];
$workerScreen = isset($params['worker_screen']) && $params['worker_screen'];
$teamId = $params['team_id'] ?? null;
$scheduledFrom = $params['scheduled_from'] ?? null;
$scheduledTo = $params['scheduled_to'] ?? null;
@@ -47,10 +49,12 @@ public function index(array $params)
'assignee:id,name',
'assignees.user:id,name',
'team:id,name',
'salesOrder:id,order_no,client_id,client_name',
'salesOrder' => fn ($q) => $q->select('id', 'order_no', 'client_id', 'client_name', 'site_name', 'quantity', 'received_at', 'delivery_date')->withCount('rootNodes'),
'salesOrder.client:id,name',
'process:id,process_name,process_code,department',
'items:id,work_order_id,item_name,quantity',
'items:id,work_order_id,item_id,item_name,specification,quantity,unit,status,options,sort_order,source_order_item_id',
'items.sourceOrderItem:id,order_node_id',
'items.sourceOrderItem.node:id,name,code',
]);
// 검색어
@@ -96,6 +100,35 @@ public function index(array $params)
});
}
// 작업자 화면용 캐스케이드 필터: 개인 배정 → 부서 → 전체
if ($workerScreen) {
$userId = $this->apiUserId();
// 1차: 개인 배정된 작업이 있는지 확인
$hasPersonal = (clone $query)->where(function ($q) use ($userId) {
$q->where('assignee_id', $userId)
->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId));
})->exists();
if ($hasPersonal) {
$query->where(function ($q) use ($userId) {
$q->where('assignee_id', $userId)
->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId));
});
} else {
// 2차: 사용자 소속 부서의 작업지시 필터
$departmentIds = DB::table('department_user')
->where('user_id', $userId)
->where('tenant_id', $tenantId)
->pluck('department_id');
if ($departmentIds->isNotEmpty()) {
$query->whereIn('team_id', $departmentIds);
}
// 3차: 부서도 없으면 필터 없이 전체 노출
}
}
// 팀 필터
if ($teamId !== null) {
$query->where('team_id', $teamId);
@@ -127,14 +160,34 @@ public function stats(): array
->pluck('count', 'status')
->toArray();
// 공정별 카운트 (탭 숫자 표시용)
$byProcess = WorkOrder::where('tenant_id', $tenantId)
->select('process_id', DB::raw('count(*) as count'))
->groupBy('process_id')
->pluck('count', 'process_id')
->toArray();
$total = array_sum($counts);
$noneCount = $byProcess[''] ?? $byProcess[0] ?? 0;
// null 키는 빈 문자열로 변환되므로 별도 처리
$processedByProcess = [];
foreach ($byProcess as $key => $count) {
if ($key === '' || $key === 0 || $key === null) {
$processedByProcess['none'] = $count;
} else {
$processedByProcess[(string) $key] = $count;
}
}
return [
'total' => array_sum($counts),
'total' => $total,
'unassigned' => $counts[WorkOrder::STATUS_UNASSIGNED] ?? 0,
'pending' => $counts[WorkOrder::STATUS_PENDING] ?? 0,
'waiting' => $counts[WorkOrder::STATUS_WAITING] ?? 0,
'in_progress' => $counts[WorkOrder::STATUS_IN_PROGRESS] ?? 0,
'completed' => $counts[WorkOrder::STATUS_COMPLETED] ?? 0,
'shipped' => $counts[WorkOrder::STATUS_SHIPPED] ?? 0,
'by_process' => $processedByProcess,
];
}
@@ -150,13 +203,16 @@ public function show(int $id)
'assignee:id,name',
'assignees.user:id,name',
'team:id,name',
'salesOrder:id,order_no,site_name,client_id,client_contact,received_at,writer_id,created_at,quantity',
'salesOrder' => fn ($q) => $q->select('id', 'order_no', 'site_name', 'client_id', 'client_contact', 'received_at', 'writer_id', 'created_at', 'quantity')->withCount('rootNodes'),
'salesOrder.client:id,name',
'salesOrder.writer:id,name',
'process:id,process_name,process_code,work_steps,department',
'items',
'process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order'),
'items.sourceOrderItem:id,order_node_id',
'items.sourceOrderItem.node:id,name,code',
'bendingDetail',
'issues' => fn ($q) => $q->orderByDesc('created_at'),
'stepProgress.processStep:id,process_id,step_code,step_name,sort_order,needs_inspection,connection_type,completion_type',
])
->find($id);
@@ -1291,4 +1347,146 @@ private function syncWorkOrderStatusFromItems(WorkOrder $workOrder): bool
return false;
}
// ──────────────────────────────────────────────────────────────
// 공정 단계 진행 관리
// ──────────────────────────────────────────────────────────────
/**
* 작업지시의 공정 단계 진행 현황 조회
*
* process_steps 마스터 기준으로 진행 레코드를 자동 생성(없으면)하고 반환
*/
public function getStepProgress(int $workOrderId): array
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with(['process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order')])
->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$processSteps = $workOrder->process?->steps ?? collect();
if ($processSteps->isEmpty()) {
return [];
}
// 기존 진행 레코드 조회
$existingProgress = WorkOrderStepProgress::where('work_order_id', $workOrderId)
->whereNull('work_order_item_id')
->get()
->keyBy('process_step_id');
// 없는 단계는 자동 생성
$result = [];
foreach ($processSteps as $step) {
if ($existingProgress->has($step->id)) {
$progress = $existingProgress->get($step->id);
} else {
$progress = WorkOrderStepProgress::create([
'tenant_id' => $tenantId,
'work_order_id' => $workOrderId,
'process_step_id' => $step->id,
'work_order_item_id' => null,
'status' => WorkOrderStepProgress::STATUS_WAITING,
]);
}
$result[] = [
'id' => $progress->id,
'process_step_id' => $step->id,
'step_code' => $step->step_code,
'step_name' => $step->step_name,
'sort_order' => $step->sort_order,
'needs_inspection' => $step->needs_inspection,
'connection_type' => $step->connection_type,
'completion_type' => $step->completion_type,
'status' => $progress->status,
'is_completed' => $progress->isCompleted(),
'completed_at' => $progress->completed_at?->toDateTimeString(),
'completed_by' => $progress->completed_by,
];
}
return $result;
}
/**
* 공정 단계 완료 토글
*/
public function toggleStepProgress(int $workOrderId, int $progressId): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$progress = WorkOrderStepProgress::where('id', $progressId)
->where('work_order_id', $workOrderId)
->first();
if (! $progress) {
throw new NotFoundHttpException(__('error.not_found'));
}
$before = ['status' => $progress->status];
$progress->toggle($userId);
$after = ['status' => $progress->status];
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
'step_progress_toggled',
$before,
$after
);
return [
'id' => $progress->id,
'status' => $progress->status,
'is_completed' => $progress->isCompleted(),
'completed_at' => $progress->completed_at?->toDateTimeString(),
'completed_by' => $progress->completed_by,
];
}
/**
* 자재 투입 이력 조회
*/
public function getMaterialInputHistory(int $workOrderId): array
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
// audit_logs에서 material_input 액션 이력 조회
$logs = DB::table('audit_logs')
->where('tenant_id', $tenantId)
->where('target_type', self::AUDIT_TARGET)
->where('target_id', $workOrderId)
->where('action', 'material_input')
->orderByDesc('created_at')
->get();
return $logs->map(function ($log) {
$after = json_decode($log->after_data ?? '{}', true);
return [
'id' => $log->id,
'materials' => $after['materials'] ?? [],
'created_at' => $log->created_at,
'actor_id' => $log->actor_id,
];
})->toArray();
}
}