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:
297
app/Console/Commands/MapItemsToProcesses.php
Normal file
297
app/Console/Commands/MapItemsToProcesses.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user