feat: 품목 치수 정규화 Artisan 커맨드 추가

- items:normalize-dimensions 커맨드 신규 생성
- 101_specification_1/2/3에서 thickness/width/length 자동 추출
- --dry-run(미리보기) / --execute(실행) 모드 지원
- 기존 값이 있는 경우 안전하게 스킵

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 20:05:23 +09:00
parent 6dbcb5337d
commit 4ae7b438f1

View File

@@ -0,0 +1,218 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[AsCommand(name: 'items:normalize-dimensions', description: '품목 attributes에서 thickness/width/length 정규화')]
class NormalizeItemDimensions extends Command
{
protected $signature = 'items:normalize-dimensions
{--tenant_id=287 : Target tenant ID (default: 287 경동기업)}
{--dry-run : 기본 모드 - 변경 예정 목록만 출력}
{--execute : 실제 DB 업데이트 수행}';
protected $description = '101_specification_1/2/3에서 thickness/width/length를 추출하여 attributes에 정규화';
private int $updatedCount = 0;
private int $skippedCount = 0;
private array $changes = [];
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$execute = $this->option('execute');
$dryRun = ! $execute;
$this->info('=== 품목 치수 정규화 ===');
$this->info("Tenant ID: {$tenantId}");
$this->info('Mode: '.($dryRun ? 'DRY-RUN (미리보기)' : 'EXECUTE (실행)'));
$this->newLine();
$items = DB::table('items')
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->whereRaw("JSON_EXTRACT(attributes, '$.\"101_specification_1\"') IS NOT NULL")
->get();
$this->info("대상 품목: {$items->count()}건 (101_specification_1 존재)");
$this->newLine();
$bar = $this->output->createProgressBar($items->count());
$bar->start();
foreach ($items as $item) {
$this->processItem($item, $dryRun);
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->showResults($dryRun);
return self::SUCCESS;
}
private function processItem(object $item, bool $dryRun): void
{
$attributes = json_decode($item->attributes, true) ?? [];
$spec1 = $attributes['101_specification_1'] ?? null;
$spec2 = $attributes['102_specification_2'] ?? null;
$spec3 = $attributes['103_specification_3'] ?? null;
$existingThickness = $attributes['thickness'] ?? null;
$existingWidth = $attributes['width'] ?? null;
$existingLength = $attributes['length'] ?? null;
$changed = false;
$changeDetails = [];
// thickness 추출: spec1에서 숫자 추출 (t/T 제거)
if ($spec1 !== null && $spec1 !== '' && $existingThickness === null) {
$thickness = $this->extractThickness($spec1);
if ($thickness !== null) {
$attributes['thickness'] = $thickness;
$changeDetails[] = "thickness: {$spec1}{$thickness}";
$changed = true;
}
}
// width 추출: spec2에서 순수 숫자만
if ($spec2 !== null && $spec2 !== '' && $existingWidth === null) {
$width = $this->extractNumeric($spec2);
if ($width !== null) {
$attributes['width'] = $width;
$changeDetails[] = "width: {$spec2}{$width}";
$changed = true;
}
}
// length 추출: spec3에서 순수 숫자만 (c, P/L, 문자 포함 시 스킵)
if ($spec3 !== null && $spec3 !== '' && $existingLength === null) {
$length = $this->extractLength($spec3);
if ($length !== null) {
$attributes['length'] = $length;
$changeDetails[] = "length: {$spec3}{$length}";
$changed = true;
}
}
if ($changed) {
$this->changes[] = [
'id' => $item->id,
'name' => $item->name,
'changes' => implode(', ', $changeDetails),
];
if (! $dryRun) {
DB::table('items')
->where('id', $item->id)
->update(['attributes' => json_encode($attributes, JSON_UNESCAPED_UNICODE)]);
}
$this->updatedCount++;
} else {
$this->skippedCount++;
}
}
/**
* thickness 추출: "t1.2", "T1.2", "1.2", "egi1.17" → 숫자
* 패턴: 선행 문자(t/T/영문) 제거 후 숫자 추출
*/
private function extractThickness(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
$cleaned = trim($value);
// "t1.2", "T1.2" → "1.2"
$cleaned = preg_replace('/^[tT]/', '', $cleaned);
// "egi1.17", "sus1.2" → 영문자 제거 후 숫자 추출
if (preg_match('/(\d+(?:\.\d+)?)/', $cleaned, $matches)) {
return $matches[1];
}
return null;
}
/**
* 순수 숫자만 추출 (정수/소수)
* "1219" → "1219", "1219.5" → "1219.5"
* "c" → null, "" → null, "P/L" → null
*/
private function extractNumeric(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
$cleaned = trim($value);
// 순수 숫자 (정수/소수)만 허용
if (preg_match('/^\d+(?:\.\d+)?$/', $cleaned)) {
return $cleaned;
}
return null;
}
/**
* length 추출: "3000" → "3000", "3000 P/L" → "3000", "c" → null
* 선행 숫자가 있고 뒤에 공백+문자(P/L 등)가 붙는 경우 숫자만 추출
*/
private function extractLength(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
$cleaned = trim($value);
// 순수 숫자
if (preg_match('/^\d+(?:\.\d+)?$/', $cleaned)) {
return $cleaned;
}
// "3000 P/L" → "3000" (숫자로 시작하고 뒤에 공백+문자)
if (preg_match('/^(\d+(?:\.\d+)?)\s+/', $cleaned, $matches)) {
return $matches[1];
}
// "c", "P/L" 등 숫자 없는 경우
return null;
}
private function showResults(bool $dryRun): void
{
$this->info('=== 결과 ===');
$this->info("변경 대상: {$this->updatedCount}");
$this->info("스킵 (변경 불필요): {$this->skippedCount}");
$this->newLine();
if (! empty($this->changes)) {
$this->table(
['ID', '품목명', '변경 내용'],
array_map(fn ($c) => [$c['id'], mb_substr($c['name'], 0, 30), $c['changes']], $this->changes)
);
}
if ($dryRun) {
$this->newLine();
$this->warn('DRY-RUN 모드입니다. 실제 적용하려면 --execute 옵션을 사용하세요:');
$this->line(' php artisan items:normalize-dimensions --execute');
} else {
$this->newLine();
$this->info("{$this->updatedCount}건 업데이트 완료");
}
}
}