# 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