- 5130 레거시 시스템 분석 (00_OVERVIEW ~ 08_SAM_COMPARISON) - MES 프로젝트 문서 - API/프론트엔드 스펙 문서 - 가이드 및 레퍼런스 문서
696 lines
21 KiB
Markdown
696 lines
21 KiB
Markdown
# 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("
", $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
|