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

@@ -0,0 +1,297 @@
<?php
namespace App\Console\Commands;
use App\Models\Items\Item;
use App\Models\Process;
use App\Models\ProcessItem;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 5130 기준 품목-공정 매핑 (A+B+C 전략)
*
* A. 품목명 키워드 기반:
* - "스크린용", "스크린" → P-002 스크린
* - "철재용", "철재", "슬랫" → P-001 슬랫
*
* B. BD 코드 기반:
* - BD-* → P-003 절곡
*
* C. 재고생산(LOT) 기반 (5130 lot 테이블 분석):
* - PT-* 코드 → P-004 재고생산
* - 가이드레일, 케이스, 연기차단재, L-Bar → P-004 재고생산
*/
class MapItemsToProcesses extends Command
{
protected $signature = 'items:map-to-processes
{--tenant= : 특정 테넌트 ID (기본: 모든 테넌트)}
{--dry-run : 실제 실행 없이 미리보기만}
{--clear : 기존 매핑 삭제 후 새로 매핑}';
protected $description = '5130 기준 품목-공정 자동 매핑 (A: 키워드 + B: BD코드 + C: 재고생산)';
/**
* 공정별 매핑 규칙 정의
*
* 5130 LOT 생산 품목 분류:
* - R: 가이드레일-벽면형, S: 가이드레일-측면형
* - C: 케이스 (린텔부, 전면부, 점검구, 후면코너부)
* - B: 하단마감재-스크린, T: 하단마감재-철재
* - G: 연기차단재
* - L: L-Bar
*/
private array $mappingRules = [
'P-001' => [
'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;
}
}