Files
sam-api/app/Console/Commands/BendingFillOptions.php
강영보 57133541d0 feat: [bending] 절곡품 기초관리 API 구현
- BendingItemController: CRUD + filters 엔드포인트 (pagination 메타 보존)
- BendingItemService: items 테이블 item_category=BENDING 필터 기반
- BendingItemResource: options → 최상위 필드 노출 + 계산값(width_sum, bend_count)
- FormRequest: Index/Store/Update 유효성 검증 (unique:items,code 포함)
- BendingFillOptions: BD-* prefix/분류 속성 자동 보강 커맨드
- BendingImportLegacy: chandj 레거시 전개도(bendingData) 임포트 커맨드 (125/170건 매칭)
- ensureContext: Bearer 토큰 없이 X-TENANT-ID 헤더로 컨텍스트 설정
2026-03-16 20:49:20 +09:00

354 lines
15 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* BD-* 품목의 options 속성 보강
*
* 1단계: BD-PREFIX-LEN 패턴(112건)에서 prefix/length 자동 추출
* 2단계: BD-한글 패턴(58건)에 item_sep/item_bending 등 분류 속성 추가
*
* 실행: php artisan bending:fill-options [--dry-run] [--tenant_id=287]
*/
#[AsCommand(name: 'bending:fill-options', description: 'BD-* 품목 options 속성 보강 (prefix/length + 분류 속성)')]
class BendingFillOptions extends Command
{
protected $signature = 'bending:fill-options
{--tenant_id=287 : Target tenant ID}
{--dry-run : 실제 저장 없이 미리보기}';
// PREFIX → 분류 속성 매핑
private const PREFIX_META = [
// 가이드레일 (벽면)
'RS' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'SUS마감재', 'material' => 'SUS 1.2T'],
'RE' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'EGI마감재', 'material' => 'EGI 1.55T'],
'RM' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => '본체', 'material' => 'EGI 1.55T'],
'RC' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'C형', 'material' => 'EGI 1.55T'],
'RD' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'D형', 'material' => 'EGI 1.55T'],
'RT' => ['item_sep' => '철재', 'item_bending' => '가이드레일', 'item_name' => '본체(철재)', 'material' => 'EGI 1.55T'],
// 가이드레일 (측면)
'SS' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'SUS마감재', 'material' => 'SUS 1.2T'],
'SE' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'EGI마감재', 'material' => 'EGI 1.55T'],
'SM' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => '본체', 'material' => 'EGI 1.55T'],
'SC' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'C형', 'material' => 'EGI 1.55T'],
'SD' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'D형', 'material' => 'EGI 1.55T'],
'ST' => ['item_sep' => '철재', 'item_bending' => '가이드레일', 'item_name' => '본체(철재)', 'material' => 'EGI 1.55T'],
'SU' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'SUS마감재2', 'material' => 'SUS 1.2T'],
// 하단마감재
'BE' => ['item_sep' => '스크린', 'item_bending' => '하단마감재', 'item_name' => '하단마감재', 'material' => 'EGI 1.55T'],
'BS' => ['item_sep' => '스크린', 'item_bending' => '하단마감재', 'item_name' => '하단마감재', 'material' => 'SUS 1.5T'],
'TS' => ['item_sep' => '철재', 'item_bending' => '하단마감재', 'item_name' => '하단마감재(철재)', 'material' => 'SUS 1.2T'],
'LA' => ['item_sep' => '스크린', 'item_bending' => 'L-BAR', 'item_name' => 'L-Bar', 'material' => 'EGI 1.55T'],
'HH' => ['item_sep' => '스크린', 'item_bending' => '보강평철', 'item_name' => '보강평철', 'material' => 'EGI 1.55T'],
// 셔터박스
'CF' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '전면부', 'material' => 'EGI 1.55T'],
'CL' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '린텔부', 'material' => 'EGI 1.55T'],
'CP' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '점검구', 'material' => 'EGI 1.55T'],
'CB' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '후면코너부', 'material' => 'EGI 1.55T'],
// 연기차단재
'GI' => ['item_sep' => '스크린', 'item_bending' => '연기차단재', 'item_name' => '연기차단재', 'material' => '화이바원단'],
// 공용
'XX' => ['item_sep' => '스크린', 'item_bending' => '공용', 'item_name' => '하부BASE/상부덮개/마구리', 'material' => 'EGI 1.55T'],
'YY' => ['item_sep' => '스크린', 'item_bending' => '별도마감', 'item_name' => '별도SUS마감', 'material' => 'SUS 1.2T'],
];
// 한글 패턴 → 분류 매핑
private const KOREAN_PATTERN_META = [
'BD-가이드레일' => ['item_sep' => null, 'item_bending' => '가이드레일'],
'BD-케이스' => ['item_sep' => null, 'item_bending' => '케이스'],
'BD-마구리' => ['item_sep' => null, 'item_bending' => '마구리'],
'BD-하단마감재' => ['item_sep' => null, 'item_bending' => '하단마감재'],
'BD-L-BAR' => ['item_sep' => '스크린', 'item_bending' => 'L-BAR'],
'BD-보강평철' => ['item_sep' => '스크린', 'item_bending' => '보강평철'],
];
private const LENGTH_MAP = [
'02' => 200, '12' => 1219, '24' => 2438, '30' => 3000,
'35' => 3500, '40' => 4000, '41' => 4150, '42' => 4200,
'43' => 4300, '53' => 3000, '54' => 4000, '83' => 3000, '84' => 4000,
];
private array $stats = [
'total' => 0,
'prefix_len_filled' => 0,
'korean_filled' => 0,
'already_complete' => 0,
'unknown_pattern' => 0,
];
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$this->info('=== BD-* 품목 options 보강 ===');
$this->info('Tenant: '.$tenantId.' | Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE'));
$this->newLine();
// BD-* 전체 품목 조회
$items = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', 'like', 'BD-%')
->whereNull('deleted_at')
->select('id', 'code', 'name', 'options')
->orderBy('code')
->get();
$this->stats['total'] = $items->count();
$this->info("BD-* 품목: {$items->count()}");
$this->newLine();
foreach ($items as $item) {
$options = json_decode($item->options ?? '{}', true) ?: [];
$code = $item->code;
$newOptions = $this->resolveOptions($code, $item->name, $options);
if ($newOptions === null) {
$this->stats['unknown_pattern']++;
$this->warn(" ❓ 미인식 패턴: {$code}");
continue;
}
// 변경 필요 여부 확인
$merged = array_merge($options, $newOptions);
if ($merged == $options) {
$this->stats['already_complete']++;
continue;
}
if (! $dryRun) {
$encoded = json_encode($merged, JSON_UNESCAPED_UNICODE);
if ($encoded === false) {
$this->error(" ❌ JSON 인코딩 실패: {$code}".json_last_error_msg());
continue;
}
DB::table('items')
->where('id', $item->id)
->update([
'options' => $encoded,
'updated_at' => now(),
]);
}
$pattern = $this->detectPattern($code);
if ($pattern === 'prefix_len') {
$this->stats['prefix_len_filled']++;
} else {
$this->stats['korean_filled']++;
}
$this->line("{$code}: +".implode(', ', array_keys($newOptions)));
}
$this->showStats($dryRun);
return self::SUCCESS;
}
/**
* 코드에서 options 속성 추출
*/
private function resolveOptions(string $code, string $name, array $existing): ?array
{
$new = [];
// item_category 보장
if (empty($existing['item_category'])) {
// item_category는 items 테이블 컬럼이므로 여기서는 skip
}
// 패턴 A: BD-PREFIX-LEN (예: BD-RS-30)
if (preg_match('/^BD-([A-Z]{2})-(\d{2})$/', $code, $m)) {
$prefix = $m[1];
$lengthCode = $m[2];
// prefix/length 기본값
if (empty($existing['prefix'])) {
$new['prefix'] = $prefix;
}
if (empty($existing['length_code'])) {
$new['length_code'] = $lengthCode;
}
if (empty($existing['length_mm']) && isset(self::LENGTH_MAP[$lengthCode])) {
$new['length_mm'] = self::LENGTH_MAP[$lengthCode];
}
// PREFIX 기반 분류 속성
$meta = self::PREFIX_META[$prefix] ?? null;
if ($meta) {
foreach ($meta as $key => $value) {
if (empty($existing[$key])) {
$new[$key] = $value;
}
}
}
return $new;
}
// 특수 코드 (패턴 미준수)
$specialCodes = [
'BD-가이드레일용 연기차단재' => ['item_bending' => '연기차단재'],
'BD-케이스용 연기차단재' => ['item_bending' => '연기차단재'],
];
if (isset($specialCodes[$code])) {
foreach ($specialCodes[$code] as $key => $value) {
if (empty($existing[$key])) {
$new[$key] = $value;
}
}
return $new;
}
// 패턴 B~G: 한글 패턴
foreach (self::KOREAN_PATTERN_META as $patternPrefix => $meta) {
// 정확한 접두사+구분자 매칭 (BD-케이스-xxx는 O, BD-케이스용xxx는 X)
if ($code === $patternPrefix || str_starts_with($code, $patternPrefix.'-')) {
// 분류 속성
foreach ($meta as $key => $value) {
if ($value !== null && empty($existing[$key])) {
$new[$key] = $value;
}
}
// 한글 패턴별 추가 파싱
$this->parseKoreanPattern($code, $patternPrefix, $existing, $new);
return $new;
}
}
return null;
}
/**
* 한글 패턴에서 모델/재질/규격 추출
*/
private function parseKoreanPattern(string $code, string $patternPrefix, array $existing, array &$new): void
{
$suffix = substr($code, strlen($patternPrefix) + 1); // "-" 제거
$parts = explode('-', $suffix);
switch ($patternPrefix) {
case 'BD-가이드레일':
// BD-가이드레일-KSS01-SUS-120*70
if (count($parts) >= 3) {
if (empty($existing['model_name'])) {
$new['model_name'] = $parts[0];
}
if (empty($existing['material'])) {
$material = $parts[1];
$new['material'] = str_contains($material, 'SUS') ? 'SUS 1.2T' : 'EGI 1.55T';
}
if (empty($existing['item_spec'])) {
$new['item_spec'] = $parts[2];
}
// item_sep 추론 (KTE → 철재)
if (empty($existing['item_sep'])) {
$new['item_sep'] = str_starts_with($parts[0], 'KTE') ? '철재' : '스크린';
}
}
break;
case 'BD-하단마감재':
// BD-하단마감재-KSS01-SUS-60*40
if (count($parts) >= 3) {
if (empty($existing['model_name'])) {
$new['model_name'] = $parts[0];
}
if (empty($existing['material'])) {
$material = $parts[1];
$new['material'] = str_contains($material, 'SUS') ? 'SUS 1.5T' : 'EGI 1.55T';
}
if (empty($existing['item_spec'])) {
$new['item_spec'] = $parts[2];
}
if (empty($existing['item_sep'])) {
$new['item_sep'] = str_starts_with($parts[0], 'KTE') ? '철재' : '스크린';
}
}
break;
case 'BD-케이스':
// BD-케이스-650*550
if (count($parts) >= 1 && ! empty($parts[0])) {
if (empty($existing['item_spec'])) {
$new['item_spec'] = $parts[0];
}
// 케이스는 대부분 철재
if (empty($existing['item_sep'])) {
$new['item_sep'] = '철재';
}
}
break;
case 'BD-마구리':
// BD-마구리-655*505
if (count($parts) >= 1 && ! empty($parts[0])) {
if (empty($existing['item_spec'])) {
$new['item_spec'] = $parts[0];
}
if (empty($existing['item_sep'])) {
$new['item_sep'] = '철재';
}
}
break;
case 'BD-L-BAR':
// BD-L-BAR-KSS01-17*60
if (count($parts) >= 2) {
if (empty($existing['model_name'])) {
$new['model_name'] = $parts[0];
}
if (empty($existing['item_spec'])) {
$new['item_spec'] = $parts[1];
}
}
break;
case 'BD-보강평철':
// BD-보강평철-50
if (count($parts) >= 1 && ! empty($parts[0])) {
if (empty($existing['item_spec'])) {
$new['item_spec'] = $parts[0];
}
}
break;
}
}
private function detectPattern(string $code): string
{
return preg_match('/^BD-[A-Z]{2}-\d{2}$/', $code) ? 'prefix_len' : 'korean';
}
private function showStats(bool $dryRun): void
{
$this->newLine();
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info('📊 결과'.($dryRun ? ' (DRY-RUN)' : ''));
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info(" 전체 BD-* 품목: {$this->stats['total']}");
$this->info(" PREFIX-LEN 업데이트: {$this->stats['prefix_len_filled']}");
$this->info(" 한글 패턴 업데이트: {$this->stats['korean_filled']}");
$this->info(" 이미 완료: {$this->stats['already_complete']}");
if ($this->stats['unknown_pattern'] > 0) {
$this->warn(" 미인식 패턴: {$this->stats['unknown_pattern']}");
}
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
if ($dryRun) {
$this->newLine();
$this->info('🔍 DRY-RUN 완료. --dry-run 제거하면 실제 반영됩니다.');
}
}
}