Files
sam-api/database/seeders/Kyungdong/KyungdongItemMasterSeeder.php
권혁성 10b1b26c1b fix: 경동 BOM 계산 수정 및 품목-공정 매핑
- 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>
2026-02-20 10:50:21 +09:00

676 lines
25 KiB
PHP

<?php
namespace Database\Seeders\Kyungdong;
use Database\Seeders\DummyDataSeeder;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* 경동기업(tenant_id=287) 품목 기준 데이터 배포용 시더
*
* DELETE + 재삽입 방식. AUTO_INCREMENT ID 사용으로 환경별 충돌 없음.
* 종속 테이블은 새 ID로 자동 매핑.
*
* 실행: php artisan db:seed --class="Database\Seeders\Kyungdong\KyungdongItemMasterSeeder"
*/
class KyungdongItemMasterSeeder extends Seeder
{
private int $tenantId;
private int $userId;
private string $dataPath;
/** @var array<int,int> 원본ID → 새ID */
private array $pageMap = [];
private array $sectionMap = [];
private array $fieldMap = [];
private array $categoryMap = [];
/** @var array<string,int> code → 새ID */
private array $itemMap = [];
/** 기대 건수 (검증용) */
private array $expectedCounts = [];
public function run(): void
{
$this->tenantId = DummyDataSeeder::TENANT_ID;
$this->userId = DummyDataSeeder::USER_ID;
$this->dataPath = database_path('seeders/data/kyungdong');
$this->command->info('=== 경동기업 품목 기준 데이터 시더 시작 ===');
$this->command->info(" tenant_id: {$this->tenantId}");
$this->command->newLine();
DB::transaction(function () {
$this->cleanup();
$this->command->info('--- Phase 1: 독립 테이블 ---');
$this->seedItemPages();
$this->seedItemSections();
$this->seedItemFields();
$this->seedEntityRelationships();
$this->updateDisplayConditions();
$this->command->info('--- Phase 2: 기반 테이블 ---');
$this->seedCategories();
$this->seedItems();
$this->command->info('--- Phase 3: 종속 테이블 ---');
$this->seedItemDetails();
$this->seedPrices();
});
$this->verify();
$this->printSummary();
}
// ──────────────────────────────────────────────────────────────────
// CLEANUP
// ──────────────────────────────────────────────────────────────────
private function cleanup(): void
{
$this->command->info('CLEANUP: tenant_id='.$this->tenantId.' 데이터 삭제');
// FK 역순 삭제
// prices - tenant_id로 직접 삭제
$c = DB::table('prices')->where('tenant_id', $this->tenantId)->delete();
$this->command->info(" prices: {$c}건 삭제");
// item_details - items를 통해 삭제
$itemIds = DB::table('items')->where('tenant_id', $this->tenantId)->pluck('id');
$c = DB::table('item_details')->whereIn('item_id', $itemIds)->delete();
$this->command->info(" item_details: {$c}건 삭제");
// items
$c = DB::table('items')->where('tenant_id', $this->tenantId)->delete();
$this->command->info(" items: {$c}건 삭제");
// categories - 자식 먼저 삭제
$catIds = DB::table('categories')->where('tenant_id', $this->tenantId)->pluck('id');
// 자식 (parent_id가 있는 것) 먼저
DB::table('categories')
->where('tenant_id', $this->tenantId)
->whereNotNull('parent_id')
->delete();
$c = DB::table('categories')->where('tenant_id', $this->tenantId)->delete();
$this->command->info(' categories: '.count($catIds).'건 삭제');
// entity_relationships
$c = DB::table('entity_relationships')->where('tenant_id', $this->tenantId)->delete();
$this->command->info(" entity_relationships: {$c}건 삭제");
// item_fields
$c = DB::table('item_fields')->where('tenant_id', $this->tenantId)->delete();
$this->command->info(" item_fields: {$c}건 삭제");
// item_sections
$c = DB::table('item_sections')->where('tenant_id', $this->tenantId)->delete();
$this->command->info(" item_sections: {$c}건 삭제");
// item_pages
$c = DB::table('item_pages')->where('tenant_id', $this->tenantId)->delete();
$this->command->info(" item_pages: {$c}건 삭제");
$this->command->newLine();
}
// ──────────────────────────────────────────────────────────────────
// PHASE 1: 독립 테이블
// ──────────────────────────────────────────────────────────────────
private function seedItemPages(): void
{
$rows = $this->loadJson('item_pages.json');
$this->expectedCounts['item_pages'] = count($rows);
foreach ($rows as $row) {
$originalId = $row['_original_id'];
$data = $this->stripMeta($row);
$data['tenant_id'] = $this->tenantId;
$this->setAuditFields($data);
$newId = DB::table('item_pages')->insertGetId($data);
$this->pageMap[$originalId] = $newId;
}
$this->command->info(' item_pages: '.count($rows).'건 삽입');
}
private function seedItemSections(): void
{
$rows = $this->loadJson('item_sections.json');
$this->expectedCounts['item_sections'] = count($rows);
foreach ($rows as $row) {
$originalId = $row['_original_id'];
$data = $this->stripMeta($row);
$data['tenant_id'] = $this->tenantId;
$this->setAuditFields($data);
$newId = DB::table('item_sections')->insertGetId($data);
$this->sectionMap[$originalId] = $newId;
}
$this->command->info(' item_sections: '.count($rows).'건 삽입');
}
private function seedItemFields(): void
{
$rows = $this->loadJson('item_fields.json');
$this->expectedCounts['item_fields'] = count($rows);
foreach ($rows as $row) {
$originalId = $row['_original_id'];
$data = $this->stripMeta($row);
$data['tenant_id'] = $this->tenantId;
$this->setAuditFields($data);
$newId = DB::table('item_fields')->insertGetId($data);
$this->fieldMap[$originalId] = $newId;
}
$this->command->info(' item_fields: '.count($rows).'건 삽입');
}
private function seedEntityRelationships(): void
{
$rows = $this->loadJson('entity_relationships.json');
$this->expectedCounts['entity_relationships'] = count($rows);
$inserted = 0;
foreach ($rows as $row) {
$data = $this->stripMeta($row);
$data['tenant_id'] = $this->tenantId;
$this->setAuditFields($data);
// parent_id 매핑
$data['parent_id'] = $this->mapEntityId($data['parent_type'], $data['parent_id']);
// child_id 매핑
$data['child_id'] = $this->mapEntityId($data['child_type'], $data['child_id']);
if ($data['parent_id'] === null || $data['child_id'] === null) {
$this->command->warn(" entity_relationships: 매핑 실패 건 스킵 (parent_type={$row['parent_type']}, child_type={$row['child_type']})");
continue;
}
DB::table('entity_relationships')->insert($data);
$inserted++;
}
$this->expectedCounts['entity_relationships'] = $inserted;
$this->command->info(" entity_relationships: {$inserted}건 삽입");
}
/**
* item_fields의 display_condition 내 targetFieldIds를 새 ID로 치환
*/
private function updateDisplayConditions(): void
{
if (empty($this->fieldMap)) {
return;
}
$fields = DB::table('item_fields')
->where('tenant_id', $this->tenantId)
->whereNotNull('display_condition')
->get(['id', 'display_condition']);
$updated = 0;
foreach ($fields as $field) {
$condition = json_decode($field->display_condition, true);
if (! is_array($condition)) {
continue;
}
$changed = false;
$condition = $this->remapDisplayCondition($condition, $changed);
if ($changed) {
DB::table('item_fields')
->where('id', $field->id)
->update(['display_condition' => json_encode($condition)]);
$updated++;
}
}
if ($updated > 0) {
$this->command->info(" display_condition 후처리: {$updated}건 업데이트");
}
}
/**
* display_condition 내 targetFieldIds 재귀적 치환
*/
private function remapDisplayCondition(array $condition, bool &$changed): array
{
if (isset($condition['targetFieldIds']) && is_array($condition['targetFieldIds'])) {
$newIds = [];
foreach ($condition['targetFieldIds'] as $oldId) {
if (isset($this->fieldMap[$oldId])) {
$newIds[] = $this->fieldMap[$oldId];
$changed = true;
} else {
$newIds[] = $oldId;
}
}
$condition['targetFieldIds'] = $newIds;
}
// targetFieldId (단일) 처리
if (isset($condition['targetFieldId']) && isset($this->fieldMap[$condition['targetFieldId']])) {
$condition['targetFieldId'] = $this->fieldMap[$condition['targetFieldId']];
$changed = true;
}
// conditions 배열 재귀 처리
if (isset($condition['conditions']) && is_array($condition['conditions'])) {
foreach ($condition['conditions'] as $i => $sub) {
$condition['conditions'][$i] = $this->remapDisplayCondition($sub, $changed);
}
}
return $condition;
}
// ──────────────────────────────────────────────────────────────────
// PHASE 2: 기반 테이블
// ──────────────────────────────────────────────────────────────────
private function seedCategories(): void
{
$rows = $this->loadJson('categories.json');
$this->expectedCounts['categories'] = count($rows);
// depth 순 삽입을 위해 parent_id 없는 것 먼저
$roots = array_filter($rows, fn ($r) => empty($r['parent_id']));
$children = array_filter($rows, fn ($r) => ! empty($r['parent_id']));
// 루트 카테고리 삽입
foreach ($roots as $row) {
$originalId = $row['_original_id'];
$data = $this->stripMeta($row);
$data['tenant_id'] = $this->tenantId;
$data['parent_id'] = null;
$this->setAuditFields($data);
$newId = DB::table('categories')->insertGetId($data);
$this->categoryMap[$originalId] = $newId;
}
// 자식 카테고리 삽입 (여러 depth 처리를 위해 최대 5회 반복)
$remaining = $children;
for ($pass = 0; $pass < 5 && ! empty($remaining); $pass++) {
$nextRemaining = [];
foreach ($remaining as $row) {
$originalId = $row['_original_id'];
$parentOriginalId = $row['parent_id'];
if (! isset($this->categoryMap[$parentOriginalId])) {
$nextRemaining[] = $row;
continue;
}
$data = $this->stripMeta($row);
$data['tenant_id'] = $this->tenantId;
$data['parent_id'] = $this->categoryMap[$parentOriginalId];
$this->setAuditFields($data);
$newId = DB::table('categories')->insertGetId($data);
$this->categoryMap[$originalId] = $newId;
}
$remaining = $nextRemaining;
}
if (! empty($remaining)) {
$this->command->warn(' categories: '.count($remaining).'건 매핑 실패 (depth 초과)');
}
$this->command->info(' categories: '.count($this->categoryMap).'건 삽입');
}
private function seedItems(): void
{
$rows = $this->loadJson('items.json');
$this->expectedCounts['items'] = count($rows);
$chunks = array_chunk($rows, 500);
$total = 0;
foreach ($chunks as $chunk) {
$insertData = [];
foreach ($chunk as $row) {
$data = $this->stripMeta($row);
$data['tenant_id'] = $this->tenantId;
$this->setAuditFields($data);
// category_id 매핑
if (! empty($data['category_id']) && isset($this->categoryMap[$data['category_id']])) {
$data['category_id'] = $this->categoryMap[$data['category_id']];
}
$insertData[] = $data;
}
DB::table('items')->insert($insertData);
$total += count($insertData);
}
// code로 itemMap 구축
$this->itemMap = DB::table('items')
->where('tenant_id', $this->tenantId)
->whereNull('deleted_at')
->pluck('id', 'code')
->toArray();
$this->command->info(" items: {$total}건 삽입 (itemMap: ".count($this->itemMap).'건)');
// BOM의 child_item_code → child_item_id 변환
$this->remapBomCodes();
}
/**
* BOM JSON의 child_item_code를 실제 child_item_id로 변환
*
* items.json의 bom 필드가 child_item_code(품목코드) 기반으로 저장되어 있으므로,
* INSERT 후 itemMap(code→id)을 이용해 child_item_id로 교체한다.
*/
private function remapBomCodes(): void
{
$bomItems = DB::table('items')
->where('tenant_id', $this->tenantId)
->whereNotNull('bom')
->get(['id', 'code', 'bom']);
$updated = 0;
foreach ($bomItems as $item) {
$bom = json_decode($item->bom, true);
if (! is_array($bom)) {
continue;
}
$changed = false;
foreach ($bom as &$entry) {
// child_item_code → child_item_id 변환
if (isset($entry['child_item_code'])) {
$code = $entry['child_item_code'];
if (isset($this->itemMap[$code])) {
$entry['child_item_id'] = $this->itemMap[$code];
unset($entry['child_item_code']);
$changed = true;
} else {
$this->command->warn(" BOM 매핑 실패: {$item->code}{$code} (품목 미존재)");
}
}
}
unset($entry);
if ($changed) {
DB::table('items')->where('id', $item->id)
->update(['bom' => json_encode($bom, JSON_UNESCAPED_UNICODE)]);
$updated++;
}
}
$this->command->info(" BOM code→id 매핑: {$updated}건 변환");
}
// ──────────────────────────────────────────────────────────────────
// PHASE 3: 종속 테이블
// ──────────────────────────────────────────────────────────────────
private function seedItemDetails(): void
{
$rows = $this->loadJson('item_details.json');
$this->expectedCounts['item_details'] = count($rows);
// items JSON에서 원본 item_id → code 매핑 구축
$itemsJson = $this->loadJson('items.json');
$originalIdToCode = [];
foreach ($itemsJson as $item) {
$originalIdToCode[$item['_original_id']] = $item['code'];
}
$insertData = [];
$skipped = 0;
foreach ($rows as $row) {
$data = $this->stripMeta($row);
$originalItemId = $data['item_id'];
// 원본 item_id → code → 새 item_id
$code = $originalIdToCode[$originalItemId] ?? null;
if ($code === null || ! isset($this->itemMap[$code])) {
$skipped++;
continue;
}
$data['item_id'] = $this->itemMap[$code];
$insertData[] = $data;
}
if (! empty($insertData)) {
foreach (array_chunk($insertData, 500) as $chunk) {
DB::table('item_details')->insert($chunk);
}
}
if ($skipped > 0) {
$this->command->warn(" item_details: {$skipped}건 매핑 실패 스킵");
}
$this->expectedCounts['item_details'] = count($insertData);
$this->command->info(' item_details: '.count($insertData).'건 삽입');
}
private function seedPrices(): void
{
$rows = $this->loadJson('prices.json');
$this->expectedCounts['prices'] = count($rows);
// items JSON에서 원본 item_id → code 매핑 구축
$itemsJson = $this->loadJson('items.json');
$originalIdToCode = [];
foreach ($itemsJson as $item) {
$originalIdToCode[$item['_original_id']] = $item['code'];
}
$insertData = [];
$skipped = 0;
foreach ($rows as $row) {
$data = $this->stripMeta($row);
$data['tenant_id'] = $this->tenantId;
$this->setAuditFields($data);
$originalItemId = $data['item_id'];
// 원본 item_id → code → 새 item_id
$code = $originalIdToCode[$originalItemId] ?? null;
if ($code === null || ! isset($this->itemMap[$code])) {
$skipped++;
continue;
}
$data['item_id'] = $this->itemMap[$code];
$insertData[] = $data;
}
if (! empty($insertData)) {
foreach (array_chunk($insertData, 500) as $chunk) {
DB::table('prices')->insert($chunk);
}
}
if ($skipped > 0) {
$this->command->warn(" prices: {$skipped}건 매핑 실패 스킵");
}
$this->expectedCounts['prices'] = count($insertData);
$this->command->info(' prices: '.count($insertData).'건 삽입');
}
// ──────────────────────────────────────────────────────────────────
// VERIFY & SUMMARY
// ──────────────────────────────────────────────────────────────────
private function verify(): void
{
$this->command->newLine();
$this->command->info('=== 검증 ===');
$tables = [
'item_pages' => fn () => DB::table('item_pages')->where('tenant_id', $this->tenantId)->whereNull('deleted_at')->count(),
'item_sections' => fn () => DB::table('item_sections')->where('tenant_id', $this->tenantId)->whereNull('deleted_at')->count(),
'item_fields' => fn () => DB::table('item_fields')->where('tenant_id', $this->tenantId)->whereNull('deleted_at')->count(),
'entity_relationships' => fn () => DB::table('entity_relationships')->where('tenant_id', $this->tenantId)->count(),
'categories' => fn () => DB::table('categories')->where('tenant_id', $this->tenantId)->whereNull('deleted_at')->count(),
'items' => fn () => DB::table('items')->where('tenant_id', $this->tenantId)->whereNull('deleted_at')->count(),
'item_details' => fn () => DB::table('item_details')->whereIn('item_id', DB::table('items')->where('tenant_id', $this->tenantId)->pluck('id'))->count(),
'prices' => fn () => DB::table('prices')->where('tenant_id', $this->tenantId)->whereNull('deleted_at')->count(),
];
$allOk = true;
foreach ($tables as $table => $countFn) {
$actual = $countFn();
$expected = $this->expectedCounts[$table] ?? '?';
$status = ($actual == $expected) ? 'OK' : 'MISMATCH';
if ($status === 'MISMATCH') {
$allOk = false;
$this->command->error(" {$table}: {$actual}건 (기대: {$expected}) [{$status}]");
} else {
$this->command->info(" {$table}: {$actual}건 [{$status}]");
}
}
// entity_relationships 참조 무결성 검증
$this->verifyRelationshipIntegrity();
if ($allOk) {
$this->command->newLine();
$this->command->info('모든 검증 통과!');
}
}
private function verifyRelationshipIntegrity(): void
{
$relationships = DB::table('entity_relationships')
->where('tenant_id', $this->tenantId)
->get();
$orphans = 0;
foreach ($relationships as $rel) {
$parentExists = $this->entityExists($rel->parent_type, $rel->parent_id);
$childExists = $this->entityExists($rel->child_type, $rel->child_id);
if (! $parentExists || ! $childExists) {
$orphans++;
}
}
if ($orphans > 0) {
$this->command->warn(" entity_relationships 참조 무결성: {$orphans}건 고아 레코드");
} else {
$this->command->info(' entity_relationships 참조 무결성: OK');
}
}
private function entityExists(string $type, int $id): bool
{
return match ($type) {
'page' => DB::table('item_pages')->where('id', $id)->exists(),
'section' => DB::table('item_sections')->where('id', $id)->exists(),
'field' => DB::table('item_fields')->where('id', $id)->exists(),
'bom' => DB::table('item_bom_items')->where('id', $id)->exists(),
default => false,
};
}
private function printSummary(): void
{
$this->command->newLine();
$this->command->info('=== 경동기업 품목 기준 데이터 시더 완료 ===');
$this->command->info(' 매핑 현황:');
$this->command->info(' pageMap: '.count($this->pageMap).'건');
$this->command->info(' sectionMap: '.count($this->sectionMap).'건');
$this->command->info(' fieldMap: '.count($this->fieldMap).'건');
$this->command->info(' categoryMap: '.count($this->categoryMap).'건');
$this->command->info(' itemMap: '.count($this->itemMap).'건');
}
// ──────────────────────────────────────────────────────────────────
// HELPERS
// ──────────────────────────────────────────────────────────────────
private function loadJson(string $filename): array
{
$path = $this->dataPath.'/'.$filename;
if (! file_exists($path)) {
$this->command->error("JSON 파일 없음: {$path}");
$this->command->error('먼저 php artisan kyungdong:export-item-master 를 실행하세요.');
throw new \RuntimeException("JSON 파일 없음: {$path}");
}
return json_decode(file_get_contents($path), true);
}
/**
* _original_id, id, created_at, updated_at, deleted_at 등 메타 제거
*/
private function stripMeta(array $row): array
{
unset(
$row['_original_id'],
$row['id'],
$row['created_at'],
$row['updated_at'],
$row['deleted_at'],
);
return $row;
}
private function setAuditFields(array &$data): void
{
$now = now();
$data['created_at'] = $now;
$data['updated_at'] = $now;
if (isset($data['created_by'])) {
$data['created_by'] = $this->userId;
}
if (isset($data['updated_by'])) {
$data['updated_by'] = $this->userId;
}
// deleted_by, deleted_at는 항상 제거
unset($data['deleted_by'], $data['deleted_at']);
}
/**
* entity type에 따라 원본 ID를 새 ID로 매핑
*/
private function mapEntityId(string $type, int $originalId): ?int
{
return match ($type) {
'page' => $this->pageMap[$originalId] ?? null,
'section' => $this->sectionMap[$originalId] ?? null,
'field' => $this->fieldMap[$originalId] ?? null,
default => $originalId, // bom 등은 그대로
};
}
}