From 487e6518456af25e34e444f0f87e8f035aaeddc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 7 Feb 2026 03:27:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=AC=EC=A0=81=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=EB=B0=B8=EB=A6=AC=EB=8D=B0=EC=9D=B4=EC=85=98,=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=EC=A7=80=EC=8B=9C=20=ED=86=B5=EA=B3=84=20=EA=B3=B5?= =?UTF-8?q?=EC=A0=95=EB=B3=84=20=EC=B9=B4=EC=9A=B4=ED=8A=B8,=20=EC=9E=85?= =?UTF-8?q?=EA=B3=A0/=EC=9E=AC=EA=B3=A0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 견적확정 시 업체명/현장명/담당자/연락처 필수 검증 추가 (QuoteService) - 작업지시 stats API에 by_process 공정별 카운트 반환 추가 - 작업지시 목록/상세 쿼리에 수주 개소(rootNodes) 연관 로딩 - 작업지시 품목에 sourceOrderItem.node 관계 추가 - 입고관리 완료건 수정 허용 및 재고 차이 조정 - work_order_step_progress 테이블 마이그레이션 - receivings 테이블 options 컬럼 추가 Co-Authored-By: Claude Opus 4.6 --- LOGICAL_RELATIONSHIPS.md | 52 ++- app/Console/Commands/MapItemsToProcesses.php | 297 ++++++++++++++++++ app/Helpers/ApiResponse.php | 17 +- .../Api/V1/WorkOrderController.php | 30 ++ .../V1/Receiving/UpdateReceivingRequest.php | 3 + app/Models/Production/WorkOrder.php | 8 + app/Models/Production/WorkOrderItem.php | 9 + .../Production/WorkOrderStepProgress.php | 148 +++++++++ app/Models/Tenants/Receiving.php | 88 +++++- app/Services/DocumentService.php | 92 ++++-- app/Services/ItemService.php | 23 +- app/Services/MemberService.php | 13 + app/Services/OrderService.php | 58 +++- app/Services/Quote/QuoteService.php | 20 ++ app/Services/ReceivingService.php | 239 +++++++++++++- app/Services/Service.php | 12 + app/Services/StockService.php | 101 ++++++ app/Services/WorkOrderService.php | 208 +++++++++++- ...231557_add_options_to_receivings_table.php | 37 +++ ..._create_work_order_step_progress_table.php | 33 ++ lang/ko/error.php | 1 + routes/api/v1/production.php | 5 + 22 files changed, 1422 insertions(+), 72 deletions(-) create mode 100644 app/Console/Commands/MapItemsToProcesses.php create mode 100644 app/Models/Production/WorkOrderStepProgress.php create mode 100644 database/migrations/2026_02_05_231557_add_options_to_receivings_table.php create mode 100644 database/migrations/2026_02_06_100000_create_work_order_step_progress_table.php diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 0d0bc2b..31a48df 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-02-05 15:42:10 +> **자동 생성**: 2026-02-07 01:10:55 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -308,6 +308,36 @@ ### folders **모델**: `App\Models\Folder` +### interview_answers +**모델**: `App\Models\Interview\InterviewAnswer` + +- **session()**: belongsTo → `interview_sessions` +- **question()**: belongsTo → `interview_questions` +- **template()**: belongsTo → `interview_templates` + +### interview_categorys +**모델**: `App\Models\Interview\InterviewCategory` + +- **templates()**: hasMany → `interview_templates` +- **sessions()**: hasMany → `interview_sessions` + +### interview_questions +**모델**: `App\Models\Interview\InterviewQuestion` + +- **template()**: belongsTo → `interview_templates` + +### interview_sessions +**모델**: `App\Models\Interview\InterviewSession` + +- **category()**: belongsTo → `interview_categories` +- **answers()**: hasMany → `interview_answers` + +### interview_templates +**모델**: `App\Models\Interview\InterviewTemplate` + +- **category()**: belongsTo → `interview_categories` +- **questions()**: hasMany → `interview_questions` + ### custom_tabs **모델**: `App\Models\ItemMaster\CustomTab` @@ -469,6 +499,8 @@ ### orders - **item()**: belongsTo → `items` - **sale()**: belongsTo → `sales` - **items()**: hasMany → `order_items` +- **nodes()**: hasMany → `order_nodes` +- **rootNodes()**: hasMany → `order_nodes` - **histories()**: hasMany → `order_histories` - **versions()**: hasMany → `order_versions` - **workOrders()**: hasMany → `work_orders` @@ -484,6 +516,7 @@ ### order_items **모델**: `App\Models\Orders\OrderItem` - **order()**: belongsTo → `orders` +- **node()**: belongsTo → `order_nodes` - **item()**: belongsTo → `items` - **quote()**: belongsTo → `quotes` - **quoteItem()**: belongsTo → `quote_items` @@ -494,6 +527,14 @@ ### order_item_components - **orderItem()**: belongsTo → `order_items` +### order_nodes +**모델**: `App\Models\Orders\OrderNode` + +- **parent()**: belongsTo → `order_nodes` +- **order()**: belongsTo → `orders` +- **children()**: hasMany → `order_nodes` +- **items()**: hasMany → `order_items` + ### order_versions **모델**: `App\Models\Orders\OrderVersion` @@ -567,6 +608,7 @@ ### work_orders - **primaryAssignee()**: hasMany → `work_order_assignees` - **items()**: hasMany → `work_order_items` - **issues()**: hasMany → `work_order_issues` +- **stepProgress()**: hasMany → `work_order_step_progress` - **shipments()**: hasMany → `shipments` - **bendingDetail()**: hasOne → `work_order_bending_details` @@ -594,6 +636,14 @@ ### work_order_items - **workOrder()**: belongsTo → `work_orders` - **item()**: belongsTo → `items` +### work_order_step_progress +**모델**: `App\Models\Production\WorkOrderStepProgress` + +- **workOrder()**: belongsTo → `work_orders` +- **processStep()**: belongsTo → `process_steps` +- **workOrderItem()**: belongsTo → `work_order_items` +- **completedByUser()**: belongsTo → `users` + ### work_results **모델**: `App\Models\Production\WorkResult` diff --git a/app/Console/Commands/MapItemsToProcesses.php b/app/Console/Commands/MapItemsToProcesses.php new file mode 100644 index 0000000..5458032 --- /dev/null +++ b/app/Console/Commands/MapItemsToProcesses.php @@ -0,0 +1,297 @@ + [ + 'name' => '슬랫', + 'code_patterns' => [], + 'name_keywords' => ['철재용', '철재', '슬랫'], + 'name_excludes' => ['스크린', '가이드레일', '하단마감', '연기차단', '케이스'], // 재고생산 품목 제외 + ], + 'P-002' => [ + 'name' => '스크린', + 'code_patterns' => [], + 'name_keywords' => ['스크린용', '스크린', '원단', '실리카', '방충', '와이어'], + 'name_excludes' => ['가이드레일', '하단마감', '연기차단', '케이스'], // 재고생산 품목 제외 + ], + 'P-003' => [ + 'name' => '절곡', + 'code_patterns' => ['BD-%'], // BD 코드는 절곡 + 'name_keywords' => ['절곡'], // 절곡 키워드만 (나머지는 P-004로) + 'name_excludes' => [], + ], + 'P-004' => [ + 'name' => '재고생산', + 'code_patterns' => ['PT-%'], // PT 코드는 재고생산 부품 + 'name_keywords' => ['가이드레일', '케이스', '연기차단', 'L-Bar', 'L-BAR', 'LBar', '하단마감', '린텔', '하장바'], + 'name_excludes' => [], + 'code_excludes' => ['BD-%'], // BD 코드는 P-003으로 + ], + ]; + + public function handle(): int + { + $tenantId = $this->option('tenant'); + $dryRun = $this->option('dry-run'); + $clear = $this->option('clear'); + + $this->info('=== 5130 기준 품목-공정 매핑 (A+B+C 전략) ==='); + $this->info('A. 품목명 키워드: 스크린용→P-002, 철재용→P-001'); + $this->info('B. BD 코드: BD-* → P-003 절곡'); + $this->info('C. 재고생산: PT-* 또는 가이드레일/케이스/연기차단재/L-Bar → P-004'); + $this->newLine(); + + // 공정 조회 + $processQuery = Process::query(); + if ($tenantId) { + $processQuery->where('tenant_id', $tenantId); + } + $processes = $processQuery->whereIn('process_code', array_keys($this->mappingRules))->get()->keyBy('process_code'); + + if ($processes->isEmpty()) { + $this->error('매핑 대상 공정이 없습니다. (P-001, P-002, P-003, P-004)'); + + return self::FAILURE; + } + + $this->info('대상 공정:'); + foreach ($processes as $code => $process) { + $this->line(" - {$code}: {$process->process_name} (ID: {$process->id})"); + } + $this->newLine(); + + // 기존 매핑 삭제 (--clear 옵션) + if ($clear) { + $processIds = $processes->pluck('id')->toArray(); + $existingCount = ProcessItem::whereIn('process_id', $processIds)->count(); + + if ($dryRun) { + $this->warn("[DRY-RUN] 기존 매핑 {$existingCount}개 삭제 예정"); + } else { + ProcessItem::whereIn('process_id', $processIds)->delete(); + $this->warn("기존 매핑 {$existingCount}개 삭제 완료"); + } + $this->newLine(); + } + + // 매핑 결과 저장 + $results = [ + 'P-001' => ['items' => collect(), 'process' => $processes->get('P-001')], + 'P-002' => ['items' => collect(), 'process' => $processes->get('P-002')], + 'P-003' => ['items' => collect(), 'process' => $processes->get('P-003')], + 'P-004' => ['items' => collect(), 'process' => $processes->get('P-004')], + ]; + + // 품목 조회 및 분류 + $itemQuery = Item::query(); + if ($tenantId) { + $itemQuery->where('tenant_id', $tenantId); + } + $items = $itemQuery->get(); + + $this->info("전체 품목 수: {$items->count()}개"); + $this->newLine(); + + $mappedCount = 0; + $unmappedItems = collect(); + + foreach ($items as $item) { + $processCode = $this->classifyItem($item); + + if ($processCode && isset($results[$processCode])) { + $results[$processCode]['items']->push($item); + $mappedCount++; + } else { + $unmappedItems->push($item); + } + } + + // 결과 출력 + $this->info('=== 분류 결과 ==='); + $this->newLine(); + + $tableData = []; + foreach ($results as $code => $data) { + $count = $data['items']->count(); + $processName = $data['process']?->process_name ?? '(없음)'; + $tableData[] = [$code, $processName, $count]; + } + $tableData[] = ['-', '미분류', $unmappedItems->count()]; + $tableData[] = ['=', '합계', $items->count()]; + + $this->table(['공정코드', '공정명', '품목 수'], $tableData); + $this->newLine(); + + // 샘플 출력 + foreach ($results as $code => $data) { + if ($data['items']->isNotEmpty()) { + $this->info("[{$code} {$data['process']?->process_name}] 샘플 (최대 10개):"); + foreach ($data['items']->take(10) as $item) { + $this->line(" - {$item->code}: {$item->name}"); + } + $this->newLine(); + } + } + + // 미분류 샘플 + if ($unmappedItems->isNotEmpty()) { + $this->info("[미분류] 샘플 (최대 10개):"); + foreach ($unmappedItems->take(10) as $item) { + $this->line(" - {$item->code}: {$item->name}"); + } + $this->newLine(); + } + + // 실제 매핑 실행 + if (! $dryRun) { + $this->info('=== 매핑 실행 ==='); + + DB::transaction(function () use ($results) { + foreach ($results as $code => $data) { + $process = $data['process']; + if (! $process) { + continue; + } + + $priority = 0; + foreach ($data['items'] as $item) { + // 중복 체크 + $exists = ProcessItem::where('process_id', $process->id) + ->where('item_id', $item->id) + ->exists(); + + if (! $exists) { + ProcessItem::create([ + 'process_id' => $process->id, + 'item_id' => $item->id, + 'priority' => $priority++, + 'is_active' => true, + ]); + } + } + + $this->info(" {$code}: {$data['items']->count()}개 매핑 완료"); + } + }); + + $this->newLine(); + $this->info("총 {$mappedCount}개 품목 매핑 완료!"); + } else { + $this->newLine(); + $this->warn('[DRY-RUN] 실제 매핑은 수행되지 않았습니다.'); + $this->line('실제 실행: php artisan items:map-to-processes --clear'); + } + + return self::SUCCESS; + } + + /** + * 품목을 공정에 분류 (A+B+C 전략) + */ + private function classifyItem(Item $item): ?string + { + $code = $item->code ?? ''; + $name = $item->name ?? ''; + + // B. BD 코드 → P-003 절곡 (최우선) + if (str_starts_with($code, 'BD-')) { + return 'P-003'; + } + + // C. PT 코드 → P-004 재고생산 (코드 기반 우선) + if (str_starts_with($code, 'PT-')) { + return 'P-004'; + } + + // C. P-004 재고생산 키워드 체크 (가이드레일, 케이스, 연기차단재, L-Bar, 하단마감, 린텔) + foreach ($this->mappingRules['P-004']['name_keywords'] as $keyword) { + if (mb_stripos($name, $keyword) !== false) { + return 'P-004'; + } + } + + // A. 품목명 키워드 기반 분류 + // P-002 스크린 먼저 체크 (스크린용, 스크린 키워드) + foreach ($this->mappingRules['P-002']['name_keywords'] as $keyword) { + if (mb_stripos($name, $keyword) !== false) { + // 재고생산 품목 제외 + $excluded = false; + foreach ($this->mappingRules['P-002']['name_excludes'] as $exclude) { + if (mb_stripos($name, $exclude) !== false) { + $excluded = true; + break; + } + } + if (! $excluded) { + return 'P-002'; + } + } + } + + // P-001 슬랫 체크 (철재용, 철재, 슬랫 키워드) + foreach ($this->mappingRules['P-001']['name_keywords'] as $keyword) { + if (mb_stripos($name, $keyword) !== false) { + // 재고생산 품목 제외 + $excluded = false; + foreach ($this->mappingRules['P-001']['name_excludes'] as $exclude) { + if (mb_stripos($name, $exclude) !== false) { + $excluded = true; + break; + } + } + if (! $excluded) { + return 'P-001'; + } + } + } + + // P-003 절곡 키워드 체크 (BD 코드 외에 키워드로도 분류) + foreach ($this->mappingRules['P-003']['name_keywords'] as $keyword) { + if (mb_stripos($name, $keyword) !== false) { + return 'P-003'; + } + } + + return null; + } +} diff --git a/app/Helpers/ApiResponse.php b/app/Helpers/ApiResponse.php index d95b40e..b67d2b0 100644 --- a/app/Helpers/ApiResponse.php +++ b/app/Helpers/ApiResponse.php @@ -5,6 +5,7 @@ use App\Exceptions\DuplicateCodeException; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Symfony\Component\HttpKernel\Exception\HttpException; class ApiResponse @@ -245,6 +246,15 @@ public static function handle( return self::success($data, $responseTitle, $debug, $statusCode); } catch (\Throwable $e) { + // 모든 예외를 로깅 (디버깅용) + Log::error('API Exception', [ + 'message' => $e->getMessage(), + 'exception' => get_class($e), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'url' => request()->fullUrl(), + 'method' => request()->method(), + ]); // ValidationException - 422 Unprocessable Entity if ($e instanceof \Illuminate\Validation\ValidationException) { @@ -279,9 +289,12 @@ public static function handle( ); } - // 일반 예외는 500으로 처리, debug 모드에서만 스택 트레이스 포함 - return self::error('서버 에러', 500, [ + // 일반 예외는 500으로 처리, debug 모드에서만 상세 정보 포함 + $errorMessage = config('app.debug') ? $e->getMessage() : '서버 에러'; + + return self::error($errorMessage, 500, [ 'details' => config('app.debug') ? $e->getTraceAsString() : null, + 'exception' => config('app.debug') ? get_class($e) : null, ]); } } diff --git a/app/Http/Controllers/Api/V1/WorkOrderController.php b/app/Http/Controllers/Api/V1/WorkOrderController.php index e752496..b2f0d53 100644 --- a/app/Http/Controllers/Api/V1/WorkOrderController.php +++ b/app/Http/Controllers/Api/V1/WorkOrderController.php @@ -157,4 +157,34 @@ public function registerMaterialInput(Request $request, int $id) return $this->service->registerMaterialInput($id, $request->input('material_ids', [])); }, __('message.work_order.material_input_registered')); } + + /** + * 공정 단계 진행 현황 조회 + */ + public function stepProgress(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->getStepProgress($id); + }, __('message.work_order.fetched')); + } + + /** + * 공정 단계 완료 토글 + */ + public function toggleStepProgress(Request $request, int $id, int $progressId) + { + return ApiResponse::handle(function () use ($id, $progressId) { + return $this->service->toggleStepProgress($id, $progressId); + }, __('message.work_order.updated')); + } + + /** + * 자재 투입 이력 조회 + */ + public function materialInputHistory(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->getMaterialInputHistory($id); + }, __('message.work_order.fetched')); + } } diff --git a/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php b/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php index 4f32a9b..93a5789 100644 --- a/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php +++ b/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php @@ -28,6 +28,9 @@ public function rules(): array 'receiving_qty' => ['nullable', 'numeric', 'min:0'], 'receiving_date' => ['nullable', 'date'], 'lot_no' => ['nullable', 'string', 'max:50'], + 'inspection_status' => ['nullable', 'string', 'max:10'], + 'inspection_date' => ['nullable', 'date'], + 'inspection_result' => ['nullable', 'string', 'max:20'], ]; } diff --git a/app/Models/Production/WorkOrder.php b/app/Models/Production/WorkOrder.php index b4ec0b9..3cc2ced 100644 --- a/app/Models/Production/WorkOrder.php +++ b/app/Models/Production/WorkOrder.php @@ -195,6 +195,14 @@ public function issues(): HasMany return $this->hasMany(WorkOrderIssue::class); } + /** + * 공정 단계 진행 추적 + */ + public function stepProgress(): HasMany + { + return $this->hasMany(WorkOrderStepProgress::class); + } + /** * 출하 목록 */ diff --git a/app/Models/Production/WorkOrderItem.php b/app/Models/Production/WorkOrderItem.php index f59d99b..39bf08e 100644 --- a/app/Models/Production/WorkOrderItem.php +++ b/app/Models/Production/WorkOrderItem.php @@ -3,6 +3,7 @@ namespace App\Models\Production; use App\Models\Items\Item; +use App\Models\Orders\OrderItem; use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; @@ -72,6 +73,14 @@ public function item(): BelongsTo return $this->belongsTo(Item::class); } + /** + * 원본 수주 품목 + */ + public function sourceOrderItem(): BelongsTo + { + return $this->belongsTo(OrderItem::class, 'source_order_item_id'); + } + // ────────────────────────────────────────────────────────────── // 스코프 // ────────────────────────────────────────────────────────────── diff --git a/app/Models/Production/WorkOrderStepProgress.php b/app/Models/Production/WorkOrderStepProgress.php new file mode 100644 index 0000000..5c744e4 --- /dev/null +++ b/app/Models/Production/WorkOrderStepProgress.php @@ -0,0 +1,148 @@ + 'datetime', + ]; + + // ────────────────────────────────────────────────────────────── + // 상수 + // ────────────────────────────────────────────────────────────── + + public const STATUS_WAITING = 'waiting'; + + public const STATUS_IN_PROGRESS = 'in_progress'; + + public const STATUS_COMPLETED = 'completed'; + + public const STATUSES = [ + self::STATUS_WAITING, + self::STATUS_IN_PROGRESS, + self::STATUS_COMPLETED, + ]; + + // ────────────────────────────────────────────────────────────── + // 관계 + // ────────────────────────────────────────────────────────────── + + /** + * 작업지시 + */ + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + /** + * 공정 단계 + */ + public function processStep(): BelongsTo + { + return $this->belongsTo(ProcessStep::class); + } + + /** + * 작업지시 품목 (선택적) + */ + public function workOrderItem(): BelongsTo + { + return $this->belongsTo(WorkOrderItem::class); + } + + /** + * 완료 처리자 + */ + public function completedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'completed_by'); + } + + // ────────────────────────────────────────────────────────────── + // 스코프 + // ────────────────────────────────────────────────────────────── + + public function scopeCompleted($query) + { + return $query->where('status', self::STATUS_COMPLETED); + } + + public function scopeWaiting($query) + { + return $query->where('status', self::STATUS_WAITING); + } + + // ────────────────────────────────────────────────────────────── + // 헬퍼 메서드 + // ────────────────────────────────────────────────────────────── + + /** + * 완료 처리 + */ + public function markCompleted(?int $userId = null): void + { + $this->status = self::STATUS_COMPLETED; + $this->completed_at = now(); + $this->completed_by = $userId; + $this->save(); + } + + /** + * 대기 상태로 되돌리기 + */ + public function markWaiting(): void + { + $this->status = self::STATUS_WAITING; + $this->completed_at = null; + $this->completed_by = null; + $this->save(); + } + + /** + * 완료 여부 + */ + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } + + /** + * 완료 토글 + */ + public function toggle(?int $userId = null): void + { + if ($this->isCompleted()) { + $this->markWaiting(); + } else { + $this->markCompleted($userId); + } + } +} \ No newline at end of file diff --git a/app/Models/Tenants/Receiving.php b/app/Models/Tenants/Receiving.php index e81a97a..d5799e4 100644 --- a/app/Models/Tenants/Receiving.php +++ b/app/Models/Tenants/Receiving.php @@ -33,6 +33,7 @@ class Receiving extends Model 'receiving_manager', 'status', 'remark', + 'options', 'created_by', 'updated_by', 'deleted_by', @@ -45,8 +46,33 @@ class Receiving extends Model 'order_qty' => 'decimal:2', 'receiving_qty' => 'decimal:2', 'item_id' => 'integer', + 'options' => 'array', ]; + /** + * JSON 직렬화 시 자동 포함되는 접근자 + */ + protected $appends = [ + 'manufacturer', + 'material_no', + 'inspection_status', + 'inspection_date', + 'inspection_result', + ]; + + /** + * Options 키 상수 (확장 필드) + */ + public const OPTION_MANUFACTURER = 'manufacturer'; // 제조사 + + public const OPTION_MATERIAL_NO = 'material_no'; // 거래처 자재번호 + + public const OPTION_INSPECTION_STATUS = 'inspection_status'; // 수입검사 (적/부적/-) + + public const OPTION_INSPECTION_DATE = 'inspection_date'; // 검사일 + + public const OPTION_INSPECTION_RESULT = 'inspection_result'; // 검사결과 (합격/불합격) + /** * 상태 목록 */ @@ -82,12 +108,72 @@ public function getStatusLabelAttribute(): string return self::STATUSES[$this->status] ?? $this->status; } + /** + * Options에서 값 가져오기 + */ + public function getOption(string $key, mixed $default = null): mixed + { + return $this->options[$key] ?? $default; + } + + /** + * Options에 값 설정 + */ + public function setOption(string $key, mixed $value): self + { + $options = $this->options ?? []; + $options[$key] = $value; + $this->options = $options; + + return $this; + } + + /** + * 제조사 접근자 + */ + public function getManufacturerAttribute(): ?string + { + return $this->getOption(self::OPTION_MANUFACTURER); + } + + /** + * 거래처 자재번호 접근자 + */ + public function getMaterialNoAttribute(): ?string + { + return $this->getOption(self::OPTION_MATERIAL_NO); + } + + /** + * 수입검사 상태 접근자 + */ + public function getInspectionStatusAttribute(): ?string + { + return $this->getOption(self::OPTION_INSPECTION_STATUS); + } + + /** + * 검사일 접근자 + */ + public function getInspectionDateAttribute(): ?string + { + return $this->getOption(self::OPTION_INSPECTION_DATE); + } + + /** + * 검사결과 접근자 + */ + public function getInspectionResultAttribute(): ?string + { + return $this->getOption(self::OPTION_INSPECTION_RESULT); + } + /** * 수정 가능 여부 */ public function canEdit(): bool { - return $this->status !== 'completed'; + return true; } /** diff --git a/app/Services/DocumentService.php b/app/Services/DocumentService.php index 04d2bc4..36689cc 100644 --- a/app/Services/DocumentService.php +++ b/app/Services/DocumentService.php @@ -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) => [ diff --git a/app/Services/ItemService.php b/app/Services/ItemService.php index 1467dcf..4e74619 100644 --- a/app/Services/ItemService.php +++ b/app/Services/ItemService.php @@ -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))); } /** diff --git a/app/Services/MemberService.php b/app/Services/MemberService.php index eff19c6..be607e6 100644 --- a/app/Services/MemberService.php +++ b/app/Services/MemberService.php @@ -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, diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 7866440..c17af88 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -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, diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index 00b4f88..495ee33 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -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, diff --git a/app/Services/ReceivingService.php b/app/Services/ReceivingService.php index c00b1c0..b2eeab6 100644 --- a/app/Services/ReceivingService.php +++ b/app/Services/ReceivingService.php @@ -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; + } } diff --git a/app/Services/Service.php b/app/Services/Service.php index b84192b..543200e 100644 --- a/app/Services/Service.php +++ b/app/Services/Service.php @@ -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; + } } diff --git a/app/Services/StockService.php b/app/Services/StockService.php index 764a8b5..b337b09 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -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 조회 또는 생성 * diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 68191de..f17d8f8 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -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(); + } } diff --git a/database/migrations/2026_02_05_231557_add_options_to_receivings_table.php b/database/migrations/2026_02_05_231557_add_options_to_receivings_table.php new file mode 100644 index 0000000..722bbe5 --- /dev/null +++ b/database/migrations/2026_02_05_231557_add_options_to_receivings_table.php @@ -0,0 +1,37 @@ +json('options')->nullable()->after('remark')->comment('확장 필드 (제조사, 수입검사 등)'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('receivings', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } +}; diff --git a/database/migrations/2026_02_06_100000_create_work_order_step_progress_table.php b/database/migrations/2026_02_06_100000_create_work_order_step_progress_table.php new file mode 100644 index 0000000..cfafed1 --- /dev/null +++ b/database/migrations/2026_02_06_100000_create_work_order_step_progress_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->foreignId('work_order_id')->constrained('work_orders')->cascadeOnDelete()->comment('작업지시 ID'); + $table->foreignId('process_step_id')->constrained('process_steps')->cascadeOnDelete()->comment('공정 단계 ID'); + $table->unsignedBigInteger('work_order_item_id')->nullable()->comment('작업지시 품목 ID (특정 품목 연결 시)'); + $table->string('status', 20)->default('waiting')->comment('상태: waiting/in_progress/completed'); + $table->timestamp('completed_at')->nullable()->comment('완료 일시'); + $table->unsignedBigInteger('completed_by')->nullable()->comment('완료 처리자 ID'); + $table->timestamps(); + + // 인덱스 + $table->unique(['work_order_id', 'process_step_id', 'work_order_item_id'], 'uq_wo_step_progress'); + $table->index(['tenant_id', 'work_order_id'], 'idx_wo_step_tenant'); + $table->index(['work_order_id', 'status'], 'idx_wo_step_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('work_order_step_progress'); + } +}; \ No newline at end of file diff --git a/lang/ko/error.php b/lang/ko/error.php index 43740a1..7219270 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -177,6 +177,7 @@ 'quote_not_editable' => '현재 상태에서는 견적을 수정할 수 없습니다.', 'quote_not_deletable' => '현재 상태에서는 견적을 삭제할 수 없습니다.', 'quote_not_finalizable' => '현재 상태에서는 견적을 확정할 수 없습니다.', + 'quote_finalize_missing_fields' => '견적확정을 위해 다음 항목을 입력해주세요: :fields', 'quote_not_finalized' => '확정되지 않은 견적입니다.', 'quote_already_converted' => '이미 수주 전환된 견적입니다.', 'quote_not_convertible' => '현재 상태에서는 수주 전환할 수 없습니다.', diff --git a/routes/api/v1/production.php b/routes/api/v1/production.php index 5574549..9bcc74e 100644 --- a/routes/api/v1/production.php +++ b/routes/api/v1/production.php @@ -66,6 +66,11 @@ // 자재 관리 Route::get('/{id}/materials', [WorkOrderController::class, 'materials'])->whereNumber('id')->name('v1.work-orders.materials'); // 자재 목록 조회 Route::post('/{id}/material-inputs', [WorkOrderController::class, 'registerMaterialInput'])->whereNumber('id')->name('v1.work-orders.material-inputs'); // 자재 투입 등록 + Route::get('/{id}/material-input-history', [WorkOrderController::class, 'materialInputHistory'])->whereNumber('id')->name('v1.work-orders.material-input-history'); // 자재 투입 이력 + + // 공정 단계 진행 관리 + Route::get('/{id}/step-progress', [WorkOrderController::class, 'stepProgress'])->whereNumber('id')->name('v1.work-orders.step-progress'); // 단계 진행 조회 + Route::patch('/{id}/step-progress/{progressId}/toggle', [WorkOrderController::class, 'toggleStepProgress'])->whereNumber('id')->whereNumber('progressId')->name('v1.work-orders.step-progress.toggle'); // 단계 토글 }); // Work Result API (작업실적 관리)