- Migrate5130Bom: 완제품 BOM 템플릿 마이그레이션 (61건) - Migrate5130Orders: 주문 데이터 마이그레이션 - Migrate5130PriceItems: 품목 데이터 마이그레이션 - Verify5130Calculation: 견적 계산 검증 커맨드 - Legacy5130Calculator: 레거시 계산 헬퍼 - ContractFromBiddingRequest: 입찰→계약 전환 요청 - 마이그레이션: shipments.work_order_id, order_id_mappings 테이블
509 lines
17 KiB
PHP
509 lines
17 KiB
PHP
<?php
|
||
|
||
namespace App\Helpers;
|
||
|
||
/**
|
||
* 5130 레거시 시스템 호환 계산기
|
||
*
|
||
* 5130 시스템의 견적 계산 로직을 SAM에서 동일하게 재현하기 위한 헬퍼 클래스입니다.
|
||
* 데이터 마이그레이션 후 계산 결과 동일성 검증에 사용됩니다.
|
||
*
|
||
* 주요 기능:
|
||
* - 절곡품 단가 계산 (getBendPlatePrice)
|
||
* - 모터 용량 계산 (calculateMotorSpec)
|
||
* - 두께 매핑 (normalizeThickness)
|
||
* - 면적 계산 (calculateArea)
|
||
*
|
||
* @see docs/plans/5130-sam-data-migration-plan.md 섹션 4.5
|
||
*/
|
||
class Legacy5130Calculator
|
||
{
|
||
// =========================================================================
|
||
// 두께 매핑 상수
|
||
// =========================================================================
|
||
|
||
/**
|
||
* EGI(아연도금) 두께 매핑
|
||
* 5130: 1.15 → 1.2, 1.55 → 1.6
|
||
*/
|
||
public const EGI_THICKNESS_MAP = [
|
||
1.15 => 1.2,
|
||
1.55 => 1.6,
|
||
];
|
||
|
||
/**
|
||
* SUS(스테인리스) 두께 매핑
|
||
* 5130: 1.15 → 1.2, 1.55 → 1.5
|
||
*/
|
||
public const SUS_THICKNESS_MAP = [
|
||
1.15 => 1.2,
|
||
1.55 => 1.5,
|
||
];
|
||
|
||
// =========================================================================
|
||
// 모터 용량 상수
|
||
// =========================================================================
|
||
|
||
/**
|
||
* 스크린 모터 용량 테이블
|
||
* [min_weight, max_weight, min_bracket_inch, max_bracket_inch] => capacity
|
||
*/
|
||
public const SCREEN_MOTOR_CAPACITY = [
|
||
// 150K: 중량 ≤20, 브라켓인치 ≤6
|
||
['weight_max' => 20, 'bracket_max' => 6, 'capacity' => '150K'],
|
||
// 200K: 중량 ≤35, 브라켓인치 ≤8
|
||
['weight_max' => 35, 'bracket_max' => 8, 'capacity' => '200K'],
|
||
// 300K: 중량 ≤50, 브라켓인치 ≤10
|
||
['weight_max' => 50, 'bracket_max' => 10, 'capacity' => '300K'],
|
||
// 400K: 중량 ≤70, 브라켓인치 ≤12
|
||
['weight_max' => 70, 'bracket_max' => 12, 'capacity' => '400K'],
|
||
// 500K: 중량 ≤90, 브라켓인치 ≤14
|
||
['weight_max' => 90, 'bracket_max' => 14, 'capacity' => '500K'],
|
||
// 600K: 그 이상
|
||
['weight_max' => PHP_INT_MAX, 'bracket_max' => PHP_INT_MAX, 'capacity' => '600K'],
|
||
];
|
||
|
||
/**
|
||
* 철재 모터 용량 테이블
|
||
*/
|
||
public const STEEL_MOTOR_CAPACITY = [
|
||
// 300K: 중량 ≤40, 브라켓인치 ≤8
|
||
['weight_max' => 40, 'bracket_max' => 8, 'capacity' => '300K'],
|
||
// 400K: 중량 ≤60, 브라켓인치 ≤10
|
||
['weight_max' => 60, 'bracket_max' => 10, 'capacity' => '400K'],
|
||
// 500K: 중량 ≤80, 브라켓인치 ≤12
|
||
['weight_max' => 80, 'bracket_max' => 12, 'capacity' => '500K'],
|
||
// 600K: 중량 ≤100, 브라켓인치 ≤14
|
||
['weight_max' => 100, 'bracket_max' => 14, 'capacity' => '600K'],
|
||
// 800K: 중량 ≤150, 브라켓인치 ≤16
|
||
['weight_max' => 150, 'bracket_max' => 16, 'capacity' => '800K'],
|
||
// 1000K: 그 이상
|
||
['weight_max' => PHP_INT_MAX, 'bracket_max' => PHP_INT_MAX, 'capacity' => '1000K'],
|
||
];
|
||
|
||
/**
|
||
* 브라켓 사이즈 매핑 (용량 → 사이즈)
|
||
*/
|
||
public const BRACKET_SIZE_MAP = [
|
||
'150K' => ['width' => 450, 'height' => 280],
|
||
'200K' => ['width' => 480, 'height' => 300],
|
||
'300K' => ['width' => 530, 'height' => 320],
|
||
'400K' => ['width' => 530, 'height' => 320],
|
||
'500K' => ['width' => 600, 'height' => 350],
|
||
'600K' => ['width' => 600, 'height' => 350],
|
||
'800K' => ['width' => 690, 'height' => 390],
|
||
'1000K' => ['width' => 690, 'height' => 390],
|
||
];
|
||
|
||
// =========================================================================
|
||
// 두께 정규화
|
||
// =========================================================================
|
||
|
||
/**
|
||
* 두께 정규화 (5130 방식)
|
||
*
|
||
* @param string $material 재질 (EGI, SUS)
|
||
* @param float $thickness 입력 두께
|
||
* @return float 정규화된 두께
|
||
*/
|
||
public static function normalizeThickness(string $material, float $thickness): float
|
||
{
|
||
$material = strtoupper($material);
|
||
|
||
if ($material === 'EGI' && isset(self::EGI_THICKNESS_MAP[$thickness])) {
|
||
return self::EGI_THICKNESS_MAP[$thickness];
|
||
}
|
||
|
||
if ($material === 'SUS' && isset(self::SUS_THICKNESS_MAP[$thickness])) {
|
||
return self::SUS_THICKNESS_MAP[$thickness];
|
||
}
|
||
|
||
return $thickness;
|
||
}
|
||
|
||
// =========================================================================
|
||
// 면적 계산
|
||
// =========================================================================
|
||
|
||
/**
|
||
* 면적 계산 (mm² → m²)
|
||
*
|
||
* 5130 방식: (length × width) / 1,000,000
|
||
*
|
||
* @param float $length 길이 (mm)
|
||
* @param float $width 너비 (mm)
|
||
* @return float 면적 (m²)
|
||
*/
|
||
public static function calculateArea(float $length, float $width): float
|
||
{
|
||
return ($length * $width) / 1000000;
|
||
}
|
||
|
||
/**
|
||
* 면적 계산 (개구부 → 제작 사이즈)
|
||
*
|
||
* @param float $W0 개구부 폭 (mm)
|
||
* @param float $H0 개구부 높이 (mm)
|
||
* @param string $productType 제품 유형 (screen, steel)
|
||
* @return array ['W1' => 제작폭, 'H1' => 제작높이, 'area' => 면적(m²)]
|
||
*/
|
||
public static function calculateManufacturingSize(float $W0, float $H0, string $productType = 'screen'): array
|
||
{
|
||
$productType = strtolower($productType);
|
||
|
||
// 마진 값 결정
|
||
if ($productType === 'steel') {
|
||
$marginW = 110;
|
||
$marginH = 350;
|
||
} else {
|
||
// screen (기본값)
|
||
$marginW = 140;
|
||
$marginH = 350;
|
||
}
|
||
|
||
$W1 = $W0 + $marginW;
|
||
$H1 = $H0 + $marginH;
|
||
$area = self::calculateArea($W1, $H1);
|
||
|
||
return [
|
||
'W1' => $W1,
|
||
'H1' => $H1,
|
||
'area' => $area,
|
||
];
|
||
}
|
||
|
||
// =========================================================================
|
||
// 중량 계산
|
||
// =========================================================================
|
||
|
||
/**
|
||
* 중량 계산 (5130 방식)
|
||
*
|
||
* @param float $W0 개구부 폭 (mm)
|
||
* @param float $area 면적 (m²)
|
||
* @param string $productType 제품 유형 (screen, steel)
|
||
* @return float 중량 (kg)
|
||
*/
|
||
public static function calculateWeight(float $W0, float $area, string $productType = 'screen'): float
|
||
{
|
||
$productType = strtolower($productType);
|
||
|
||
if ($productType === 'steel') {
|
||
// 철재: 면적 × 25 kg/m²
|
||
return $area * 25;
|
||
}
|
||
|
||
// 스크린: (면적 × 2) + (폭(m) × 14.17)
|
||
return ($area * 2) + (($W0 / 1000) * 14.17);
|
||
}
|
||
|
||
// =========================================================================
|
||
// 절곡품 단가 계산
|
||
// =========================================================================
|
||
|
||
/**
|
||
* 절곡품 단가 계산 (5130 getBendPlatePrice 호환)
|
||
*
|
||
* @param string $material 재질 (EGI, SUS)
|
||
* @param float $thickness 두께 (mm)
|
||
* @param float $length 길이 (mm)
|
||
* @param float $width 너비 (mm)
|
||
* @param int $qty 수량
|
||
* @param float $unitPricePerM2 단위 면적당 단가 (원/m²)
|
||
* @return array ['area' => 면적, 'total' => 총액]
|
||
*/
|
||
public static function getBendPlatePrice(
|
||
string $material,
|
||
float $thickness,
|
||
float $length,
|
||
float $width,
|
||
int $qty,
|
||
float $unitPricePerM2
|
||
): array {
|
||
// 1. 두께 정규화
|
||
$normalizedThickness = self::normalizeThickness($material, $thickness);
|
||
|
||
// 2. 면적 계산 (mm² → m²)
|
||
$areaM2 = self::calculateArea($length, $width);
|
||
|
||
// 3. 총액 계산 (절삭 - Math.floor 호환)
|
||
$total = floor($unitPricePerM2 * $areaM2 * $qty);
|
||
|
||
return [
|
||
'material' => $material,
|
||
'original_thickness' => $thickness,
|
||
'normalized_thickness' => $normalizedThickness,
|
||
'length' => $length,
|
||
'width' => $width,
|
||
'area_m2' => $areaM2,
|
||
'qty' => $qty,
|
||
'unit_price_per_m2' => $unitPricePerM2,
|
||
'total' => $total,
|
||
];
|
||
}
|
||
|
||
// =========================================================================
|
||
// 모터 용량 계산
|
||
// =========================================================================
|
||
|
||
/**
|
||
* 모터 용량 계산 (5130 calculateMotorSpec 호환)
|
||
*
|
||
* @param float $weight 중량 (kg)
|
||
* @param float $bracketInch 브라켓 인치
|
||
* @param string $productType 제품 유형 (screen, steel)
|
||
* @return array ['capacity' => 용량, 'bracket_size' => 브라켓 사이즈]
|
||
*/
|
||
public static function calculateMotorSpec(float $weight, float $bracketInch, string $productType = 'screen'): array
|
||
{
|
||
$productType = strtolower($productType);
|
||
|
||
// 용량 테이블 선택
|
||
$capacityTable = ($productType === 'steel')
|
||
? self::STEEL_MOTOR_CAPACITY
|
||
: self::SCREEN_MOTOR_CAPACITY;
|
||
|
||
// 용량 결정 (경계값은 상위 용량 적용)
|
||
$capacity = null;
|
||
foreach ($capacityTable as $entry) {
|
||
if ($weight <= $entry['weight_max'] && $bracketInch <= $entry['bracket_max']) {
|
||
$capacity = $entry['capacity'];
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 기본값 (마지막 항목)
|
||
if ($capacity === null) {
|
||
$lastEntry = end($capacityTable);
|
||
$capacity = $lastEntry['capacity'];
|
||
}
|
||
|
||
// 브라켓 사이즈 조회
|
||
$bracketSize = self::BRACKET_SIZE_MAP[$capacity] ?? ['width' => 530, 'height' => 320];
|
||
|
||
return [
|
||
'product_type' => $productType,
|
||
'weight' => $weight,
|
||
'bracket_inch' => $bracketInch,
|
||
'capacity' => $capacity,
|
||
'bracket_size' => $bracketSize,
|
||
'bracket_dimensions' => "{$bracketSize['width']}×{$bracketSize['height']}",
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 품목 코드로 제품 유형 판별 (5130 방식)
|
||
*
|
||
* @param string $itemCode 품목 코드 (예: KS-001, ST-002)
|
||
* @return string 제품 유형 (screen, steel)
|
||
*/
|
||
public static function detectProductType(string $itemCode): string
|
||
{
|
||
$prefix = strtoupper(substr($itemCode, 0, 2));
|
||
|
||
// KS, KW로 시작하면 스크린
|
||
if (in_array($prefix, ['KS', 'KW'])) {
|
||
return 'screen';
|
||
}
|
||
|
||
// 그 외는 철재
|
||
return 'steel';
|
||
}
|
||
|
||
// =========================================================================
|
||
// 가이드레일/샤프트/파이프 계산
|
||
// =========================================================================
|
||
|
||
/**
|
||
* 가이드레일 수량 계산 (5130 calculateGuidrail 호환)
|
||
*
|
||
* @param float $height 높이 (mm)
|
||
* @param float $standardLength 기본 길이 (mm, 기본값 3490)
|
||
* @return int 가이드레일 수량
|
||
*/
|
||
public static function calculateGuiderailQty(float $height, float $standardLength = 3490): int
|
||
{
|
||
if ($standardLength <= 0) {
|
||
return 1;
|
||
}
|
||
|
||
return (int) ceil($height / $standardLength);
|
||
}
|
||
|
||
// =========================================================================
|
||
// 비인정 자재 단가 계산
|
||
// =========================================================================
|
||
|
||
/**
|
||
* 비인정 스크린 단가 계산
|
||
*
|
||
* @param float $width 너비 (mm)
|
||
* @param float $height 높이 (mm)
|
||
* @param int $qty 수량
|
||
* @param float $unitPricePerM2 단위 면적당 단가 (원/m²)
|
||
* @return array ['area' => 면적, 'total' => 총액]
|
||
*/
|
||
public static function calculateUnapprovedScreenPrice(
|
||
float $width,
|
||
float $height,
|
||
int $qty,
|
||
float $unitPricePerM2
|
||
): array {
|
||
$areaM2 = self::calculateArea($width, $height);
|
||
$total = floor($unitPricePerM2 * $areaM2 * $qty);
|
||
|
||
return [
|
||
'width' => $width,
|
||
'height' => $height,
|
||
'area_m2' => $areaM2,
|
||
'qty' => $qty,
|
||
'unit_price_per_m2' => $unitPricePerM2,
|
||
'total' => $total,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 철재 스라트 비인정 단가 계산
|
||
*
|
||
* @param string $type 유형 (방화셔터, 방범셔터, 단열셔터, 이중파이프, 조인트바)
|
||
* @param float $width 너비 (mm)
|
||
* @param float $height 높이 (mm)
|
||
* @param int $qty 수량
|
||
* @param float $unitPrice 단가
|
||
* @return array ['surang' => 수량, 'total' => 총액]
|
||
*/
|
||
public static function calculateUnapprovedSteelSlatPrice(
|
||
string $type,
|
||
float $width,
|
||
float $height,
|
||
int $qty,
|
||
float $unitPrice
|
||
): array {
|
||
// 면적 기준 유형
|
||
$areaBased = ['방화셔터', '방범셔터', '단열셔터', '이중파이프'];
|
||
|
||
if (in_array($type, $areaBased)) {
|
||
// 면적 × 수량
|
||
$areaM2 = self::calculateArea($width, $height);
|
||
$surang = $areaM2 * $qty;
|
||
} else {
|
||
// 수량 기준 (조인트바 등)
|
||
$surang = $qty;
|
||
}
|
||
|
||
$total = floor($unitPrice * $surang);
|
||
|
||
return [
|
||
'type' => $type,
|
||
'width' => $width,
|
||
'height' => $height,
|
||
'qty' => $qty,
|
||
'surang' => $surang,
|
||
'unit_price' => $unitPrice,
|
||
'total' => $total,
|
||
];
|
||
}
|
||
|
||
// =========================================================================
|
||
// 전체 견적 계산 (통합)
|
||
// =========================================================================
|
||
|
||
/**
|
||
* 전체 견적 계산 (5130 호환 모드)
|
||
*
|
||
* 5130의 계산 로직을 그대로 재현하여 동일한 결과를 반환합니다.
|
||
*
|
||
* @param float $W0 개구부 폭 (mm)
|
||
* @param float $H0 개구부 높이 (mm)
|
||
* @param int $qty 수량
|
||
* @param string $productType 제품 유형 (screen, steel)
|
||
* @return array 계산 결과
|
||
*/
|
||
public static function calculateEstimate(
|
||
float $W0,
|
||
float $H0,
|
||
int $qty,
|
||
string $productType = 'screen'
|
||
): array {
|
||
// 1. 제작 사이즈 계산
|
||
$size = self::calculateManufacturingSize($W0, $H0, $productType);
|
||
$W1 = $size['W1'];
|
||
$H1 = $size['H1'];
|
||
$area = $size['area'];
|
||
|
||
// 2. 중량 계산
|
||
$weight = self::calculateWeight($W0, $area, $productType);
|
||
|
||
// 3. 브라켓 인치 계산 (폭 기준, 25.4mm = 1inch)
|
||
$bracketInch = ceil($W1 / 25.4);
|
||
|
||
// 4. 모터 용량 계산
|
||
$motorSpec = self::calculateMotorSpec($weight, $bracketInch, $productType);
|
||
|
||
return [
|
||
'input' => [
|
||
'W0' => $W0,
|
||
'H0' => $H0,
|
||
'qty' => $qty,
|
||
'product_type' => $productType,
|
||
],
|
||
'calculated' => [
|
||
'W1' => $W1,
|
||
'H1' => $H1,
|
||
'area_m2' => round($area, 4),
|
||
'weight_kg' => round($weight, 2),
|
||
'bracket_inch' => $bracketInch,
|
||
],
|
||
'motor' => $motorSpec,
|
||
];
|
||
}
|
||
|
||
// =========================================================================
|
||
// 검증 유틸리티
|
||
// =========================================================================
|
||
|
||
/**
|
||
* 5130 계산 결과와 비교 검증
|
||
*
|
||
* @param array $samResult SAM 계산 결과
|
||
* @param array $legacy5130Result 5130 계산 결과
|
||
* @param float $tolerance 허용 오차 (기본 0.01 = 1%)
|
||
* @return array ['match' => bool, 'differences' => array]
|
||
*/
|
||
public static function validateAgainstLegacy(
|
||
array $samResult,
|
||
array $legacy5130Result,
|
||
float $tolerance = 0.01
|
||
): array {
|
||
$differences = [];
|
||
|
||
// 비교할 필드 목록
|
||
$compareFields = ['W1', 'H1', 'area_m2', 'weight_kg', 'total'];
|
||
|
||
foreach ($compareFields as $field) {
|
||
$samValue = $samResult[$field] ?? $samResult['calculated'][$field] ?? null;
|
||
$legacyValue = $legacy5130Result[$field] ?? null;
|
||
|
||
if ($samValue === null || $legacyValue === null) {
|
||
continue;
|
||
}
|
||
|
||
$diff = abs($samValue - $legacyValue);
|
||
$percentDiff = $legacyValue != 0 ? ($diff / abs($legacyValue)) : ($diff > 0 ? 1 : 0);
|
||
|
||
if ($percentDiff > $tolerance) {
|
||
$differences[$field] = [
|
||
'sam' => $samValue,
|
||
'legacy' => $legacyValue,
|
||
'diff' => $diff,
|
||
'percent_diff' => round($percentDiff * 100, 2).'%',
|
||
];
|
||
}
|
||
}
|
||
|
||
return [
|
||
'match' => empty($differences),
|
||
'differences' => $differences,
|
||
];
|
||
}
|
||
} |