- 5130 레거시 시스템 분석 (00_OVERVIEW ~ 08_SAM_COMPARISON) - MES 프로젝트 문서 - API/프론트엔드 스펙 문서 - 가이드 및 레퍼런스 문서
21 KiB
21 KiB
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 마이그레이션 시 고려사항:
- 품목 타입 불일치: 5130의 23개 자재 테이블 → SAM의 materials 단일 테이블
- JSON 데이터 정규화: 5130의 TEXT 컬럼 JSON → SAM의 정규화된 테이블
- 공사-AS 분리 필요: 5130의 work 통합 테이블 → SAM의 projects + after_services 분리
- 계정과목 정규화: 5130의 JSON 파일 → SAM의 account_codes 테이블
마이그레이션 우선순위:
- 🔴 자재/품목 (materials/products) - 핵심 마스터
- 🔴 BOM 구조 (product_components/bom_templates)
- 🟡 견적/주문 (estimates → orders 변환)
- 🟡 출하 (output → shipments 변환)
- 🟢 회계 (account → journal_entries 정규화)
- 🟢 품질/AS (work.AS필드 → after_services 분리)
1. 도메인별 상세 비교
1.1 자재 (Material) 비교
5130 구조
-- 자재 유형별 개별 테이블 (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 구조
-- 단일 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 전략
// 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 구조
-- 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 구조
-- 설계 모델 (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 |
계산식으로 변환 |
계층 구조 평탄화 로직
// 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 구조
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 구조
-- 견적 헤더/상세 분리
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 데이터 정규화 전략
// 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 구조
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 구조 제안
-- 프로젝트 분리
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 분리 마이그레이션 전략
// 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 구조
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 구조 제안
-- 출하 헤더
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 구조
account (
num, registDate, dueDate, inoutsep, content, contentSub,
content_detail, amount, bankbook, secondordnum,
endorsementDate, parentEBNum,
first_writer, update_log, searchtag, is_deleted
)
-- accountContents.json (계정과목)
{
"수입": {"거래처 수금": {...}, "차입금": {...}, ...},
"지출": {"자재비": {...}, "급여": {...}, ...}
}
SAM 구조 제안
-- 계정과목 정규화
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 → 테이블 변환
// 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 변환
// 5130: is_deleted TINYINT → SAM: deleted_at TIMESTAMP
$deletedAt = $legacyData['is_deleted'] == 1
? Carbon::now()->toDateTimeString()
: null;
2.2 searchtag 변환
// 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 변환
// 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 → 정규화 테이블
// 공통 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 롤백 전략
// 트랜잭션 기반 마이그레이션
DB::transaction(function () use ($legacyData) {
// 마이그레이션 로직
}, 5); // 5회 재시도
// 실패 시 백업 테이블에서 복원
// CREATE TABLE _backup_materials AS SELECT * FROM materials;
4. 검증 체크리스트
4.1 데이터 정합성 검증
-- 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 핵심 권장사항
- 단계별 마이그레이션 필수: 한 번에 전체 마이그레이션은 리스크가 높음
- JSON 데이터 정규화 우선: 5130의 TEXT JSON 필드들을 SAM의 정규화된 테이블로 변환
- AS/클레임 분리: work 테이블의 AS 필드를 별도 테이블로 분리하여 이력 관리 강화
- 가격 시스템 활용: SAM의 price_histories 테이블로 가격 이력 통합 관리
- 감사 로그 마이그레이션: 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 다음 액션
- 마이그레이션 스크립트 프로토타입 작성
- 테스트 데이터로 검증
- 운영 데이터 샘플 마이그레이션
- 전체 마이그레이션 실행
문서 버전: 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