# 5130 vs SAM 비교 검증 리포트

**분석일**: 2025-12-04
**분석 범위**: 5130 레거시 시스템 ↔ SAM 멀티테넌트 시스템
**목적**: DB Upsert 전략 및 마이그레이션 가이드 도출

---

## Executive Summary

### 핵심 발견사항

**✅ SAM에서 잘 구현된 부분**:
- 멀티테넌트 격리 (tenant_id + BelongsToTenant 스코프)
- 가격 시스템 (price_histories: 시계열, 고객별, 다형성)
- 설계 워크플로우 (models → model_versions → bom_templates)
- 감사 로그 및 soft delete 일관성

**⚠️ 5130 → SAM 마이그레이션 시 고려사항**:
1. **품목 타입 불일치**: 5130의 23개 자재 테이블 → SAM의 materials 단일 테이블
2. **JSON 데이터 정규화**: 5130의 TEXT 컬럼 JSON → SAM의 정규화된 테이블
3. **공사-AS 분리 필요**: 5130의 work 통합 테이블 → SAM의 projects + after_services 분리
4. **계정과목 정규화**: 5130의 JSON 파일 → SAM의 account_codes 테이블

**마이그레이션 우선순위**:
1. 🔴 자재/품목 (materials/products) - 핵심 마스터
2. 🔴 BOM 구조 (product_components/bom_templates)
3. 🟡 견적/주문 (estimates → orders 변환)
4. 🟡 출하 (output → shipments 변환)
5. 🟢 회계 (account → journal_entries 정규화)
6. 🟢 품질/AS (work.AS필드 → after_services 분리)

---

## 1. 도메인별 상세 비교

### 1.1 자재 (Material) 비교

#### 5130 구조
```sql
-- 자재 유형별 개별 테이블 (23개)
i_SUSplate, i_GIplate, i_SUScoil, i_slatcoil, i_bendingcoil,
i_angle, i_pole, i_shaft, i_recpipe, i_motor, i_controller, ...

-- 공통 컬럼
num, lot_no, inspection_date, supplier, item_name, specification,
unit, received_qty, weight_kg, purchase_price_excl_vat,
searchtag, update_log, is_deleted
```

#### SAM 구조
```sql
-- 단일 materials 테이블
materials (
    id, tenant_id, category_id, name, item_name, material_code,
    specification, unit, attributes JSON, options JSON,
    is_inspection, search_tag,
    created_by, updated_by, deleted_by, created_at, updated_at, deleted_at
)

-- 카테고리로 유형 구분
categories (id, parent_id, name, level, attributes JSON)
```

#### 매핑 전략

| 5130 | SAM | 변환 로직 |
|------|-----|----------|
| `i_*` 테이블명 | `category_id` | 테이블명 → 카테고리 생성 |
| `num` | `id` | 시퀀스 리매핑 |
| `lot_no` | - | `lots` 테이블로 분리 |
| `supplier` | - | `clients` 테이블 참조 |
| `item_name` | `item_name` | 직접 매핑 |
| `specification` | `specification` | 직접 매핑 |
| `purchase_price` | - | `price_histories` 참조 |
| `searchtag` | `search_tag` | 직접 매핑 |
| `is_deleted` | `deleted_at` | 0→NULL, 1→timestamp |

#### DB Upsert 전략
```php
// 5130 → SAM 마이그레이션
public function upsertMaterial(array $data): Material
{
    // 1. 카테고리 매핑 (테이블명 → category_id)
    $category = $this->getCategoryByTableName($data['table_name']);

    // 2. Upsert 조건: tenant_id + material_code (자연키)
    return Material::updateOrCreate(
        [
            'tenant_id' => $this->tenantId(),
            'material_code' => $data['lot_no'] ?? $this->generateCode($data),
        ],
        [
            'category_id' => $category->id,
            'name' => $data['item_name'] ?? $data['name'],
            'item_name' => $data['item_name'],
            'specification' => $data['specification'],
            'unit' => $data['unit'],
            'search_tag' => $data['searchtag'],
            // 기타 필드...
        ]
    );
}
```

---

### 1.2 품목/BOM (Product/Model) 비교

#### 5130 구조
```sql
-- 3단계 계층 구조
models (model_id, model_name, major_category, finishing_type, guiderail_type)
parts (part_id, model_id, part_name, spec, unit, quantity, unitprice)
parts_sub (subpart_id, part_id, subpart_name, material, bendSum, plateSum, finalSum)
```

#### SAM 구조
```sql
-- 설계 모델 (Design)
models (id, tenant_id, code, name, category_id, lifecycle, is_active)
model_versions (id, model_id, version_no, status, effective_from, effective_to)
bom_templates (id, model_version_id, name, is_primary, calculation_schema)
bom_template_items (id, bom_template_id, ref_type, ref_id, qty, waste_rate, calculation_formula)

-- 제품 BOM (Production)
products (id, tenant_id, code, name, product_type, category_id, unit)
product_components (id, parent_product_id, ref_type, ref_id, quantity, sort_order)
```

#### 매핑 전략

| 5130 | SAM | 변환 로직 |
|------|-----|----------|
| `models` | `models` + `model_versions` | 버전 관리 추가 |
| `parts` | `bom_template_items` | 설계 BOM |
| `parts_sub` | `bom_template_items` (중첩) | 3단계 → 2단계 평탄화 |
| `unitprice` | `price_histories` | 가격 이력으로 분리 |
| `bendSum/plateSum` | `calculation_formula` | 계산식으로 변환 |

#### 계층 구조 평탄화 로직
```php
// 5130 3단계 → SAM 2단계 변환
public function flattenBomHierarchy(array $model): array
{
    $bomItems = [];

    foreach ($model['parts'] as $part) {
        // 2단계 부품
        $bomItems[] = [
            'ref_type' => 'PRODUCT',
            'ref_id' => $this->findOrCreateProduct($part)->id,
            'qty' => $part['quantity'],
            'sort_order' => count($bomItems),
        ];

        // 3단계 하위 부품 (평탄화)
        foreach ($part['parts_sub'] ?? [] as $subpart) {
            $bomItems[] = [
                'ref_type' => 'MATERIAL',
                'ref_id' => $this->findOrCreateMaterial($subpart)->id,
                'qty' => $subpart['quantity'],
                'sort_order' => count($bomItems),
                'calculation_formula' => $this->convertFormula($subpart),
            ];
        }
    }

    return $bomItems;
}
```

---

### 1.3 견적 (Estimate) 비교

#### 5130 구조
```sql
estimate (
    num, pjnum, indate, orderman, outworkplace,
    major_category, model_name, position, makeWidth, makeHeight,
    estimateList TEXT,           -- JSON: 스크린 견적 항목
    estimateSlatList TEXT,       -- JSON: 슬랫 견적 항목
    estimateList_auto TEXT,      -- JSON: 자동계산 항목
    estimateSlatList_auto TEXT,  -- JSON: 슬랫 자동계산
    estimateTotal, EstimateFirstSum, EstimateFinalSum,
    EstimateDiscountRate, EstimateDiscount,
    inspectionFee, steel, motor, warranty
)
```

#### SAM 구조
```sql
-- 견적 헤더/상세 분리
estimates (
    id, tenant_id, estimate_number, customer_id, project_name,
    estimate_date, valid_until, status, total_amount,
    discount_rate, discount_amount, final_amount, created_by
)

estimate_items (
    id, estimate_id, item_type, item_code, item_name,
    specification, unit, quantity, unit_price, amount, sort_order
)
```

#### JSON 데이터 정규화 전략
```php
// 5130 JSON → SAM 정규화 테이블
public function normalizeEstimateItems(array $legacyEstimate): void
{
    // 1. 견적 헤더 생성
    $estimate = Estimate::create([
        'tenant_id' => $this->tenantId(),
        'estimate_number' => $legacyEstimate['pjnum'],
        'customer_id' => $this->findCustomer($legacyEstimate['secondord']),
        'project_name' => $legacyEstimate['outworkplace'],
        'estimate_date' => $legacyEstimate['indate'],
        'total_amount' => $legacyEstimate['estimateTotal'],
        'discount_rate' => $legacyEstimate['EstimateDiscountRate'],
        'final_amount' => $legacyEstimate['EstimateFinalSum'],
    ]);

    // 2. JSON 파싱 및 상세 생성
    $estimateList = json_decode($legacyEstimate['estimateList'], true) ?? [];
    foreach ($estimateList as $index => $item) {
        EstimateItem::create([
            'estimate_id' => $estimate->id,
            'item_type' => 'manual',
            'item_code' => $item['item_code'] ?? null,
            'item_name' => $item['item_name'],
            'specification' => $item['specification'] ?? null,
            'unit' => $item['unit'],
            'quantity' => $item['quantity'],
            'unit_price' => $item['unit_price'],
            'amount' => $item['amount'],
            'sort_order' => $index,
        ]);
    }

    // 3. 자동계산 항목 (estimateList_auto)
    $autoList = json_decode($legacyEstimate['estimateList_auto'], true) ?? [];
    foreach ($autoList as $index => $item) {
        EstimateItem::create([
            'estimate_id' => $estimate->id,
            'item_type' => 'auto_calculated',
            // ... 나머지 필드
        ]);
    }
}
```

---

### 1.4 생산/공사 (Production/Work) 비교

#### 5130 구조
```sql
work (
    num, work_state, workplacename, address, chargedperson,
    -- 발주처 (1차/2차)
    firstord, firstordman, firstordmantel,
    secondord, secondordman, secondordmantel,
    -- 일정
    workday, endworkday, cableday, endcableday,
    -- 작업자
    worker, cablestaff,
    -- AS (통합)
    asday, asman, asendday, asproday, setdate,
    aslist TEXT, asresult TEXT, ashistory TEXT, as_state,
    -- 클레임
    claimperson, claimtel, claimList TEXT,
    -- 금액
    sum_estimate, sum_bill, sum_receivable, sum_deposit,
    -- 회계 JSON
    accountList TEXT, estimateList TEXT
)
```

#### SAM 구조 제안
```sql
-- 프로젝트 분리
projects (
    id, tenant_id, project_number, project_name, customer_id,
    status, contracted_date, start_date, end_date
)

project_phases (
    id, project_id, phase_type, planned_start, planned_end,
    actual_start, actual_end, assigned_team, status
)

-- AS 분리
after_services (
    id, tenant_id, project_id, as_number, status,
    received_date, received_by, requester_name, requester_phone,
    scheduled_date, completed_date, handler_id,
    fee_type, fee_amount, issue_description, result_description
)

-- 클레임 분리
claims (
    id, tenant_id, project_id, after_service_id,
    claim_number, claim_type, claim_date, description,
    status, resolution, claimed_amount, approved_amount
)
```

#### AS 분리 마이그레이션 전략
```php
// work 테이블의 AS 필드 → after_services 테이블
public function extractAfterServices(array $workData): void
{
    // AS 대상 여부 확인
    if ($workData['as_check'] != 1 && empty($workData['asday'])) {
        return;
    }

    // 프로젝트 먼저 생성/조회
    $project = $this->findOrCreateProject($workData);

    // AS 레코드 생성
    AfterService::create([
        'tenant_id' => $this->tenantId(),
        'project_id' => $project->id,
        'as_number' => $this->generateAsNumber(),
        'status' => $this->mapAsStatus($workData['as_state']),
        'received_date' => $workData['asday'],
        'scheduled_date' => $workData['asproday'],
        'setting_date' => $workData['setdate'],
        'completed_date' => $workData['asendday'],
        'requester_name' => $workData['asorderman'],
        'requester_phone' => $workData['asordermantel'],
        'handler_id' => $this->findUser($workData['asman']),
        'fee_type' => $workData['asfee'] == 0 ? 'free' : 'paid',
        'fee_amount' => $workData['asfee_estimate'] ?? 0,
        'issue_description' => $workData['aslist'],
        'result_description' => $workData['asresult'],
    ]);
}

// AS 상태 매핑
private function mapAsStatus(string $legacyStatus): string
{
    return match ($legacyStatus) {
        '미접수' => 'pending',
        '접수완료' => 'received',
        '처리예약', '세팅예약' => 'scheduled',
        '처리완료' => 'completed',
        default => 'pending',
    };
}
```

---

### 1.5 출하 (Shipping/Output) 비교

#### 5130 구조
```sql
output (
    num, outdate, indate, orderman, outworkplace, outputplace,
    receiver, phone, delivery,
    screen VARCHAR, screen_su, screen_m2, screenlist TEXT,
    slat VARCHAR, slat_su, slat_m2, slatlist TEXT,
    ACIregDate, ACIaskDate, ACIdoneDate, ACImemo, ACIgroupCode,
    deliveryfeeList TEXT,
    estimate_num, prodCode, lotNum, warrantyNum
)

output_extra (
    parent_num, detailJson TEXT, estimateList TEXT, estimateSlatList TEXT,
    estimateTotal, ET_unapproved, ET_total,
    motorList TEXT, bendList TEXT, controllerList TEXT, accountList TEXT
)
```

#### SAM 구조 제안
```sql
-- 출하 헤더
shipments (
    id, tenant_id, shipment_number, project_id, customer_id,
    ship_date, status, delivery_address, receiver_name, receiver_phone,
    carrier, tracking_number
)

-- 출하 상세
shipment_items (
    id, shipment_id, item_type, item_code, quantity, lot_number, note
)

-- ACI 분리
aci_inspections (
    id, shipment_id, inspection_number, request_date, inspection_date,
    result, inspector, certificate_number, documents JSON
)

-- 배송비 분리
delivery_fees (
    id, shipment_id, carrier, fee_amount, payment_type, paid_date
)
```

---

### 1.6 회계 (Accounting) 비교

#### 5130 구조
```sql
account (
    num, registDate, dueDate, inoutsep, content, contentSub,
    content_detail, amount, bankbook, secondordnum,
    endorsementDate, parentEBNum,
    first_writer, update_log, searchtag, is_deleted
)

-- accountContents.json (계정과목)
{
    "수입": {"거래처 수금": {...}, "차입금": {...}, ...},
    "지출": {"자재비": {...}, "급여": {...}, ...}
}
```

#### SAM 구조 제안
```sql
-- 계정과목 정규화
account_codes (
    id, tenant_id, code, name, parent_id, level,
    account_type, is_cash_account, is_receivable, is_payable,
    description, is_active, sort_order
)

-- 회계 전표
journal_entries (
    id, tenant_id, entry_number, entry_date, entry_type,
    account_code, sub_account_code,
    debit_amount, credit_amount,
    counterpart_id, counterpart_name, payment_method, bank_account,
    due_date, reference_type, reference_id, description, created_by
)

-- 채권 관리
receivables (
    id, tenant_id, receivable_number, customer_id,
    original_amount, paid_amount, balance,
    invoice_date, due_date, status, promised_date, promised_memo,
    project_id, invoice_id
)
```

#### 계정과목 JSON → 테이블 변환
```php
// accountContents.json → account_codes 테이블
public function migrateAccountCodes(array $jsonContent): void
{
    // 1. 대분류 (수입/지출)
    foreach ($jsonContent as $mainType => $accounts) {
        $mainAccount = AccountCode::create([
            'tenant_id' => $this->tenantId(),
            'code' => $mainType === '수입' ? 'INCOME' : 'EXPENSE',
            'name' => $mainType,
            'parent_id' => null,
            'level' => 1,
            'account_type' => $mainType === '수입' ? 'income' : 'expense',
        ]);

        // 2. 중분류
        foreach ($accounts as $accountName => $accountData) {
            $subAccount = AccountCode::create([
                'tenant_id' => $this->tenantId(),
                'code' => $this->generateAccountCode($accountName),
                'name' => $accountName,
                'parent_id' => $mainAccount->id,
                'level' => 2,
                'description' => $accountData['description'] ?? null,
            ]);

            // 3. 하위계정
            foreach ($accountData['하위계정'] ?? [] as $subAccountName => $subData) {
                AccountCode::create([
                    'tenant_id' => $this->tenantId(),
                    'code' => $this->generateAccountCode($subAccountName),
                    'name' => is_string($subAccountName) ? $subAccountName : $subData,
                    'parent_id' => $subAccount->id,
                    'level' => 3,
                ]);
            }
        }
    }
}
```

---

## 2. 공통 변환 패턴

### 2.1 Soft Delete 변환
```php
// 5130: is_deleted TINYINT → SAM: deleted_at TIMESTAMP
$deletedAt = $legacyData['is_deleted'] == 1
    ? Carbon::now()->toDateTimeString()
    : null;
```

### 2.2 searchtag 변환
```php
// 5130: searchtag (공백 구분) → SAM: search_tag (동일)
// SAM은 Laravel Scout + Meilisearch 권장
$searchTag = $legacyData['searchtag'];
// 또는 동적 생성
$searchTag = implode(' ', array_filter([
    $data['name'],
    $data['code'],
    $data['specification'],
    // ...
]));
```

### 2.3 update_log 변환
```php
// 5130: update_log TEXT (누적) → SAM: audit_logs 테이블
public function migrateUpdateLog(string $entityType, int $entityId, ?string $updateLog): void
{
    if (empty($updateLog)) return;

    // 로그 파싱 (형식: "2025-01-15 10:30:00 - 홍길동 내용")
    $lines = explode("&#10", $updateLog);
    foreach ($lines as $line) {
        if (preg_match('/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) - (\S+) (.*)$/', $line, $matches)) {
            AuditLog::create([
                'tenant_id' => $this->tenantId(),
                'target_type' => $entityType,
                'target_id' => $entityId,
                'action' => 'updated',
                'after' => ['note' => $matches[3]],
                'actor_id' => $this->findUserByName($matches[2]),
                'created_at' => $matches[1],
            ]);
        }
    }
}
```

### 2.4 JSON TEXT → 정규화 테이블
```php
// 공통 JSON 파싱 유틸
public function parseJsonField(?string $jsonText): array
{
    if (empty($jsonText)) return [];

    $decoded = json_decode($jsonText, true);
    if (json_last_error() !== JSON_ERROR_NONE) {
        Log::warning("JSON parse error: " . json_last_error_msg());
        return [];
    }

    return $decoded;
}
```

---

## 3. 마이그레이션 실행 전략

### 3.1 단계별 마이그레이션 계획

#### Phase 1: 마스터 데이터 (1-2주)
```
1. categories (5130 테이블명 → SAM 카테고리)
2. clients (5130 발주처 → SAM 거래처)
3. materials (5130 i_* 테이블들 → SAM materials)
4. products (5130 models → SAM products)
5. account_codes (5130 JSON → SAM account_codes)
```

#### Phase 2: 구조 데이터 (2-3주)
```
6. bom_templates (5130 parts → SAM bom_template_items)
7. product_components (5130 parts_sub → SAM product_components)
8. price_histories (5130 unitprice 필드들 → SAM price_histories)
```

#### Phase 3: 트랜잭션 데이터 (3-4주)
```
9. projects (5130 work → SAM projects)
10. project_phases (5130 work 일정필드 → SAM project_phases)
11. estimates (5130 estimate → SAM estimates + estimate_items)
12. shipments (5130 output → SAM shipments + shipment_items)
13. after_services (5130 work.AS필드 → SAM after_services)
14. journal_entries (5130 account → SAM journal_entries)
```

#### Phase 4: 검증 및 정리 (1-2주)
```
15. 데이터 정합성 검증
16. 누락 데이터 보정
17. 레거시 참조 정리
18. 성능 최적화
```

### 3.2 Upsert 전략 요약

| 도메인 | Upsert Key | 충돌 처리 |
|-------|-----------|----------|
| materials | tenant_id + material_code | UPDATE |
| products | tenant_id + code | UPDATE |
| categories | tenant_id + name | UPDATE |
| estimates | tenant_id + estimate_number | UPDATE |
| shipments | tenant_id + shipment_number | UPDATE |
| journal_entries | tenant_id + entry_number | UPDATE |
| after_services | tenant_id + as_number | UPDATE |

### 3.3 롤백 전략
```php
// 트랜잭션 기반 마이그레이션
DB::transaction(function () use ($legacyData) {
    // 마이그레이션 로직
}, 5); // 5회 재시도

// 실패 시 백업 테이블에서 복원
// CREATE TABLE _backup_materials AS SELECT * FROM materials;
```

---

## 4. 검증 체크리스트

### 4.1 데이터 정합성 검증
```sql
-- 1. 레코드 수 비교
SELECT '5130' as source, COUNT(*) FROM legacy.i_SUSplate
UNION ALL
SELECT 'SAM' as source, COUNT(*) FROM sam.materials WHERE category_id = ?;

-- 2. 금액 합계 비교
SELECT SUM(amount) FROM legacy.account WHERE is_deleted = 0
UNION ALL
SELECT SUM(debit_amount) FROM sam.journal_entries WHERE entry_type = 'expense';

-- 3. 참조 무결성 검증
SELECT pc.id FROM product_components pc
LEFT JOIN products p ON pc.ref_type = 'PRODUCT' AND pc.ref_id = p.id
LEFT JOIN materials m ON pc.ref_type = 'MATERIAL' AND pc.ref_id = m.id
WHERE p.id IS NULL AND m.id IS NULL;
```

### 4.2 비즈니스 로직 검증
- [ ] 견적 금액 계산 결과 일치
- [ ] BOM 전개 결과 일치
- [ ] AS 상태 흐름 정상 작동
- [ ] 미수금 계산 결과 일치
- [ ] 재고 수량 계산 결과 일치

### 4.3 성능 검증
- [ ] 목록 조회 응답 시간 < 500ms
- [ ] BOM 전개 응답 시간 < 1s
- [ ] 견적 산출 응답 시간 < 2s

---

## 5. 결론

### 5.1 핵심 권장사항

1. **단계별 마이그레이션 필수**: 한 번에 전체 마이그레이션은 리스크가 높음
2. **JSON 데이터 정규화 우선**: 5130의 TEXT JSON 필드들을 SAM의 정규화된 테이블로 변환
3. **AS/클레임 분리**: work 테이블의 AS 필드를 별도 테이블로 분리하여 이력 관리 강화
4. **가격 시스템 활용**: SAM의 price_histories 테이블로 가격 이력 통합 관리
5. **감사 로그 마이그레이션**: 5130의 update_log를 SAM의 audit_logs로 변환

### 5.2 예상 공수

| 단계 | 예상 공수 | 주요 작업 |
|-----|---------|----------|
| Phase 1 | 1-2주 | 마스터 데이터 마이그레이션 |
| Phase 2 | 2-3주 | 구조 데이터 (BOM, 가격) |
| Phase 3 | 3-4주 | 트랜잭션 데이터 |
| Phase 4 | 1-2주 | 검증 및 정리 |
| **합계** | **7-11주** | - |

### 5.3 다음 액션
1. 마이그레이션 스크립트 프로토타입 작성
2. 테스트 데이터로 검증
3. 운영 데이터 샘플 마이그레이션
4. 전체 마이그레이션 실행

---

**문서 버전**: v1.0
**작성일**: 2025-12-04
**작성자**: Claude Code
**참조 문서**:
- 5130 분석 문서 (01~07)
- SAM_Item_DB_API_Analysis_v3_FINAL.md
- SAM_Item_Management_DB_Modeling_Analysis.md
