- KyungdongFormulaHandler: product_type 자동 추론(item_category 기반), 철재 주자재 EGI코일로 변경, 조인트바 steel 공통 지원 - FormulaEvaluatorService: FG item_category에서 product_type 자동 판별 - MapItemsToProcesses: 경동 품목-공정 매핑 커맨드 정비 - KyungdongItemMasterSeeder: BOM child_item_id code 기반 재매핑 - ItemsBomController: ghost ID 유효성 검증 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
346 lines
12 KiB
PHP
346 lines
12 KiB
PHP
<?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
|
|
*/
|
|
/**
|
|
* FG(완제품), RM(원자재) 제외 - 공정별 생산 품목만 매핑
|
|
* EST-INSPECTION(검사비), EST-MOTOR/EST-CTRL(구매품)도 제외
|
|
*/
|
|
private array $globalExcludes = ['FG-%', 'RM-%', 'EST-INSPECTION'];
|
|
|
|
private array $mappingRules = [
|
|
'P-001' => [
|
|
'name' => '슬랫',
|
|
'code_patterns' => ['EST-RAW-슬랫-%'], // 슬랫 원자재 (방화/방범/조인트바)
|
|
'name_keywords' => ['슬랫'],
|
|
'name_excludes' => ['스크린', '가이드레일', '하단마감', '연기차단', '케이스'],
|
|
],
|
|
'P-002' => [
|
|
'name' => '스크린',
|
|
'code_patterns' => ['EST-RAW-스크린-%'], // 스크린 원자재 (실리카/와이어 등)
|
|
'name_keywords' => ['스크린용', '스크린', '원단', '실리카', '방충', '와이어'],
|
|
'name_excludes' => ['가이드레일', '하단마감', '연기차단', '케이스'],
|
|
],
|
|
'P-003' => [
|
|
'name' => '절곡',
|
|
'code_patterns' => ['BD-%'], // BD 코드는 절곡
|
|
'name_keywords' => ['절곡', '연기차단재'], // 연기차단재는 절곡 공정에서 조립
|
|
'name_excludes' => [],
|
|
],
|
|
'P-004' => [
|
|
'name' => '재고생산',
|
|
'code_patterns' => ['PT-%'], // PT 코드는 재고생산 부품
|
|
'name_keywords' => ['가이드레일', '케이스', 'L-Bar', 'L-BAR', 'LBar', '하단마감', '린텔', '하장바', '환봉', '감기샤프트', '각파이프', '앵글'],
|
|
'name_excludes' => [],
|
|
'code_excludes' => ['BD-%', 'EST-SMOKE-%'], // BD는 P-003, EST-SMOKE는 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 ?? '';
|
|
|
|
// 0. 글로벌 제외 (FG 완제품, RM 원자재, EST-INSPECTION 서비스)
|
|
foreach ($this->globalExcludes as $excludePattern) {
|
|
$prefix = rtrim($excludePattern, '-%');
|
|
if (str_starts_with($code, $prefix)) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// 1. 코드 패턴 우선 매핑 (정확한 매칭)
|
|
// EST-RAW-슬랫-* → P-001
|
|
if (str_starts_with($code, 'EST-RAW-슬랫-')) {
|
|
return 'P-001';
|
|
}
|
|
|
|
// EST-RAW-스크린-* → P-002
|
|
if (str_starts_with($code, 'EST-RAW-스크린-')) {
|
|
return 'P-002';
|
|
}
|
|
|
|
// BD-* → P-003 절곡
|
|
if (str_starts_with($code, 'BD-')) {
|
|
return 'P-003';
|
|
}
|
|
|
|
// EST-SMOKE-* → P-003 절곡 (연기차단재는 절곡 공정에서 조립)
|
|
if (str_starts_with($code, 'EST-SMOKE-')) {
|
|
return 'P-003';
|
|
}
|
|
|
|
// PT-* → P-004 재고생산
|
|
if (str_starts_with($code, 'PT-')) {
|
|
return 'P-004';
|
|
}
|
|
|
|
// EST-MOTOR/EST-CTRL → 구매품, 공정 없음
|
|
if (str_starts_with($code, 'EST-MOTOR-') || str_starts_with($code, 'EST-CTRL-')) {
|
|
return null;
|
|
}
|
|
|
|
// EST-SHAFT/EST-PIPE/EST-ANGLE → P-004 재고생산 (조달 품목)
|
|
if (str_starts_with($code, 'EST-SHAFT-') || str_starts_with($code, 'EST-PIPE-') || str_starts_with($code, 'EST-ANGLE-')) {
|
|
return 'P-004';
|
|
}
|
|
|
|
// 2. P-004 재고생산 키워드 체크
|
|
foreach ($this->mappingRules['P-004']['name_keywords'] as $keyword) {
|
|
if (mb_stripos($name, $keyword) !== false) {
|
|
// code_excludes 체크
|
|
$excluded = false;
|
|
foreach ($this->mappingRules['P-004']['code_excludes'] ?? [] as $excludePattern) {
|
|
$prefix = rtrim($excludePattern, '-%');
|
|
if (str_starts_with($code, $prefix)) {
|
|
$excluded = true;
|
|
break;
|
|
}
|
|
}
|
|
if (! $excluded) {
|
|
return 'P-004';
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. P-003 절곡 키워드 체크
|
|
foreach ($this->mappingRules['P-003']['name_keywords'] as $keyword) {
|
|
if (mb_stripos($name, $keyword) !== false) {
|
|
return 'P-003';
|
|
}
|
|
}
|
|
|
|
// 4. 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';
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. 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';
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|