feat: [bending] 절곡품 전용 테이블 분리 API
- bending_items 전용 테이블 생성 (items.options → 정규 컬럼 승격) - bending_models 전용 테이블 생성 (가이드레일/케이스/하단마감재 통합) - bending_data JSON 통합 (별도 테이블 → bending_items.bending_data 컬럼) - bending_item_mappings 테이블 DROP (bending_items.code에 흡수) - BendingItemService/BendingCodeService → BendingItem 모델 전환 - GuiderailModelService component 이미지 자동 복사 - ItemsFileController bending_items/bending_models 폴백 지원 - Swagger 스키마 업데이트
This commit is contained in:
334
app/Console/Commands/BendingCleanReimport.php
Normal file
334
app/Console/Commands/BendingCleanReimport.php
Normal file
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\BendingItem;
|
||||
// bending_data는 bending_items.bending_data JSON 컬럼에 저장
|
||||
use App\Models\Commons\File;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* 클린 재이관: bending_items/bending_data 전체 삭제 → chandj.bending 직접 이관
|
||||
* 기존 R2 파일도 삭제 처리
|
||||
*
|
||||
* 실행: php artisan bending:clean-reimport [--dry-run] [--tenant_id=287]
|
||||
*/
|
||||
class BendingCleanReimport extends Command
|
||||
{
|
||||
protected $signature = 'bending:clean-reimport
|
||||
{--tenant_id=287 : 테넌트 ID}
|
||||
{--dry-run : 실행하지 않고 미리보기만}
|
||||
{--legacy-img-path=/tmp/bending_img : 레거시 이미지 경로}';
|
||||
|
||||
protected $description = 'bending_items 클린 재이관 (chandj.bending 직접)';
|
||||
|
||||
private int $tenantId;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->tenantId = (int) $this->option('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$legacyImgPath = $this->option('legacy-img-path');
|
||||
|
||||
// 1. 현재 상태
|
||||
$biCount = BendingItem::where('tenant_id', $this->tenantId)->count();
|
||||
$bdCount = BendingItem::where('tenant_id', $this->tenantId)
|
||||
->whereNotNull('bending_data')->count();
|
||||
$fileCount = File::where('field_key', 'bending_diagram')
|
||||
->where(function ($q) {
|
||||
$q->where('document_type', 'bending_item')
|
||||
->orWhere('document_type', '1');
|
||||
})->count();
|
||||
|
||||
$this->info("현재: bending_items={$biCount}, bending_data={$bdCount}, files={$fileCount}");
|
||||
|
||||
// chandj 유효 건수
|
||||
$chandjRows = DB::connection('chandj')->table('bending')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('is_deleted')->orWhere('is_deleted', 0);
|
||||
})
|
||||
->orderBy('num')
|
||||
->get();
|
||||
|
||||
$this->info("chandj 이관 대상: {$chandjRows->count()}건");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->preview($chandjRows);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (! $this->confirm("기존 데이터 전체 삭제 후 chandj에서 재이관합니다. 계속?")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($chandjRows) {
|
||||
// 2. 기존 파일 DB 레코드만 삭제 (R2 파일은 유지)
|
||||
$this->deleteFileRecords();
|
||||
|
||||
// 3. 기존 데이터 삭제
|
||||
BendingItem::where('tenant_id', $this->tenantId)->forceDelete();
|
||||
$this->info("기존 데이터 삭제 완료");
|
||||
|
||||
// 4. chandj에서 직접 이관
|
||||
$success = 0;
|
||||
$bdTotal = 0;
|
||||
|
||||
foreach ($chandjRows as $row) {
|
||||
try {
|
||||
$bi = $this->importItem($row);
|
||||
$bd = $this->importBendingData($bi, $row);
|
||||
$bdTotal += $bd;
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error(" ❌ #{$row->num} {$row->itemName}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("이관 완료: {$success}/{$chandjRows->count()}건, 전개도 {$bdTotal}행");
|
||||
});
|
||||
|
||||
// 5. 이미지 이관
|
||||
$this->importImages($legacyImgPath);
|
||||
|
||||
// 6. 최종 검증
|
||||
$this->verify();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function importItem(object $row): BendingItem
|
||||
{
|
||||
$code = $this->generateCode($row);
|
||||
|
||||
$bi = BendingItem::create([
|
||||
'tenant_id' => $this->tenantId,
|
||||
'code' => $code,
|
||||
'legacy_code' => "CHANDJ-{$row->num}",
|
||||
'legacy_bending_id' => $row->num,
|
||||
'item_name' => $row->itemName ?: "부품#{$row->num}",
|
||||
'item_sep' => $this->clean($row->item_sep),
|
||||
'item_bending' => $this->clean($row->item_bending),
|
||||
'material' => $this->clean($row->material),
|
||||
'item_spec' => $this->clean($row->item_spec),
|
||||
'model_name' => $this->clean($row->model_name ?? null),
|
||||
'model_UA' => $this->clean($row->model_UA ?? null),
|
||||
'rail_width' => $this->toNum($row->rail_width ?? null),
|
||||
'exit_direction' => $this->clean($row->exit_direction ?? null),
|
||||
'box_width' => $this->toNum($row->box_width ?? null),
|
||||
'box_height' => $this->toNum($row->box_height ?? null),
|
||||
'front_bottom' => $this->toNum($row->front_bottom_width ?? null),
|
||||
'options' => $this->buildOptions($row),
|
||||
'is_active' => true,
|
||||
'created_by' => 1,
|
||||
]);
|
||||
|
||||
$this->line(" ✅ #{$row->num} → {$bi->id} ({$row->itemName}) [{$code}]");
|
||||
|
||||
return $bi;
|
||||
}
|
||||
|
||||
private function importBendingData(BendingItem $bi, object $row): int
|
||||
{
|
||||
$inputs = json_decode($row->inputList ?? '[]', true) ?: [];
|
||||
if (empty($inputs)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$rates = json_decode($row->bendingrateList ?? '[]', true) ?: [];
|
||||
$sums = json_decode($row->sumList ?? '[]', true) ?: [];
|
||||
$colors = json_decode($row->colorList ?? '[]', true) ?: [];
|
||||
$angles = json_decode($row->AList ?? '[]', true) ?: [];
|
||||
|
||||
$data = [];
|
||||
$count = count($inputs);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$data[] = [
|
||||
'no' => $i + 1,
|
||||
'input' => (float) ($inputs[$i] ?? 0),
|
||||
'rate' => (string) ($rates[$i] ?? ''),
|
||||
'sum' => (float) ($sums[$i] ?? 0),
|
||||
'color' => (bool) ($colors[$i] ?? false),
|
||||
'aAngle' => (bool) ($angles[$i] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
$bi->update(['bending_data' => $data]);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function deleteFileRecords(): void
|
||||
{
|
||||
$count = File::where('field_key', 'bending_diagram')
|
||||
->where('document_type', 'bending_item')
|
||||
->forceDelete();
|
||||
|
||||
$this->info("파일 레코드 삭제: {$count}건 (R2 파일은 유지)");
|
||||
}
|
||||
|
||||
private function importImages(string $legacyImgPath): void
|
||||
{
|
||||
$chandjMap = DB::connection('chandj')->table('bending')
|
||||
->whereNotNull('imgdata')
|
||||
->where('imgdata', '!=', '')
|
||||
->pluck('imgdata', 'num');
|
||||
|
||||
$items = BendingItem::where('tenant_id', $this->tenantId)
|
||||
->whereNotNull('legacy_bending_id')
|
||||
->get();
|
||||
|
||||
$uploaded = 0;
|
||||
$notFound = 0;
|
||||
|
||||
foreach ($items as $bi) {
|
||||
$imgFile = $chandjMap[$bi->legacy_bending_id] ?? null;
|
||||
if (! $imgFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filePath = "{$legacyImgPath}/{$imgFile}";
|
||||
if (! file_exists($filePath)) {
|
||||
$notFound++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$extension = pathinfo($imgFile, PATHINFO_EXTENSION);
|
||||
$storedName = bin2hex(random_bytes(8)) . '.' . $extension;
|
||||
$directory = sprintf('%d/bending/%s/%s', $this->tenantId, date('Y'), date('m'));
|
||||
$r2Path = $directory . '/' . $storedName;
|
||||
|
||||
Storage::disk('r2')->put($r2Path, file_get_contents($filePath));
|
||||
|
||||
File::create([
|
||||
'tenant_id' => $this->tenantId,
|
||||
'display_name' => $imgFile,
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $r2Path,
|
||||
'file_size' => filesize($filePath),
|
||||
'mime_type' => mime_content_type($filePath),
|
||||
'file_type' => 'image',
|
||||
'field_key' => 'bending_diagram',
|
||||
'document_id' => $bi->id,
|
||||
'document_type' => 'bending_item',
|
||||
'is_temp' => false,
|
||||
'uploaded_by' => 1,
|
||||
'created_by' => 1,
|
||||
]);
|
||||
|
||||
$uploaded++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn(" ⚠️ 이미지 업로드 실패: #{$bi->legacy_bending_id} — {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("이미지 업로드: {$uploaded}건" . ($notFound > 0 ? " (파일없음 {$notFound}건)" : ''));
|
||||
}
|
||||
|
||||
private function generateCode(object $row): string
|
||||
{
|
||||
$bending = $row->item_bending ?? '';
|
||||
$sep = $row->item_sep ?? '';
|
||||
$material = $row->material ?? '';
|
||||
$name = $row->itemName ?? '';
|
||||
|
||||
$prodCode = match (true) {
|
||||
$bending === '케이스' => 'C',
|
||||
$bending === '하단마감재' && str_contains($sep, '철재') => 'T',
|
||||
$bending === '하단마감재' => 'B',
|
||||
$bending === '가이드레일' => 'R',
|
||||
$bending === '마구리' => 'X',
|
||||
$bending === 'L-BAR' => 'L',
|
||||
$bending === '연기차단재' => 'G',
|
||||
default => 'Z',
|
||||
};
|
||||
|
||||
$specCode = match (true) {
|
||||
str_contains($name, '전면') => 'F',
|
||||
str_contains($name, '린텔') => 'L',
|
||||
str_contains($name, '점검') => 'P',
|
||||
str_contains($name, '후면') => 'B',
|
||||
str_contains($name, '상부') || str_contains($name, '덮개') => 'X',
|
||||
str_contains($name, '본체') => 'M',
|
||||
str_contains($name, 'C형') || str_contains($name, '-C') => 'C',
|
||||
str_contains($name, 'D형') || str_contains($name, '-D') => 'D',
|
||||
str_contains($name, '마감') && str_contains($material, 'SUS') => 'S',
|
||||
str_contains($name, '하장바') && str_contains($material, 'SUS') => 'S',
|
||||
str_contains($name, '하장바') && str_contains($material, 'EGI') => 'E',
|
||||
str_contains($name, '보강') => 'H',
|
||||
str_contains($name, '절단') => 'T',
|
||||
str_contains($name, '비인정') => 'N',
|
||||
str_contains($name, '밑면') => 'P',
|
||||
str_contains($material, 'SUS') => 'S',
|
||||
str_contains($material, 'EGI') => 'E',
|
||||
default => 'Z',
|
||||
};
|
||||
|
||||
$date = $row->registration_date ?? now()->format('Y-m-d');
|
||||
$dateCode = date('ymd', strtotime($date));
|
||||
|
||||
$base = "{$prodCode}{$specCode}{$dateCode}";
|
||||
|
||||
// 중복 방지 일련번호
|
||||
$seq = 1;
|
||||
while (BendingItem::where('tenant_id', $this->tenantId)
|
||||
->where('code', $seq === 1 ? $base : "{$base}-" . str_pad($seq, 2, '0', STR_PAD_LEFT))
|
||||
->exists()) {
|
||||
$seq++;
|
||||
}
|
||||
|
||||
return $seq === 1 ? $base : "{$base}-" . str_pad($seq, 2, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function buildOptions(object $row): ?array
|
||||
{
|
||||
$opts = [];
|
||||
if (! empty($row->memo)) $opts['memo'] = $row->memo;
|
||||
if (! empty($row->author)) $opts['author'] = $row->author;
|
||||
if (! empty($row->search_keyword)) $opts['search_keyword'] = $row->search_keyword;
|
||||
if (! empty($row->registration_date)) $opts['registration_date'] = (string) $row->registration_date;
|
||||
|
||||
return empty($opts) ? null : $opts;
|
||||
}
|
||||
|
||||
private function verify(): void
|
||||
{
|
||||
$bi = BendingItem::where('tenant_id', $this->tenantId)->count();
|
||||
$bd = BendingItem::where('tenant_id', $this->tenantId)
|
||||
->whereNotNull('bending_data')->count();
|
||||
$mapped = BendingItem::where('tenant_id', $this->tenantId)
|
||||
->whereNotNull('legacy_bending_id')
|
||||
->distinct('legacy_bending_id')
|
||||
->count('legacy_bending_id');
|
||||
$files = File::where('field_key', 'bending_diagram')->count();
|
||||
|
||||
$this->newLine();
|
||||
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
$this->info("📊 최종 결과");
|
||||
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
$this->info(" bending_items: {$bi}건");
|
||||
$this->info(" bending_data: {$bd}행");
|
||||
$this->info(" chandj 매핑: {$mapped}건");
|
||||
$this->info(" 파일: {$files}건 (이미지 재업로드 필요)");
|
||||
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
}
|
||||
|
||||
private function preview($rows): void
|
||||
{
|
||||
$grouped = $rows->groupBy(fn ($r) => ($r->item_bending ?: '미분류') . '/' . ($r->item_sep ?: '미분류'));
|
||||
$this->table(['분류', '건수'], $grouped->map(fn ($g, $k) => [$k, $g->count()])->values());
|
||||
}
|
||||
|
||||
private function clean(?string $v): ?string
|
||||
{
|
||||
return ($v === null || $v === '' || $v === 'null') ? null : trim($v);
|
||||
}
|
||||
|
||||
private function toNum(mixed $v): ?float
|
||||
{
|
||||
return ($v === null || $v === '' || $v === 'null') ? null : (float) $v;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user