Files
sam-api/app/Services/Production/BendingInfoBuilder.php
권혁성 4dd38ab14d feat: [생산지시] 전용 API + 자재투입/공정 개선
- ProductionOrder 전용 엔드포인트 (목록/통계/상세)
- 재고생산 보조공정 일반 워크플로우에서 분리
- 자재투입 replace 모드 + bom_group_key 개별 저장
- 공정단계 options 컬럼 추가 (검사 설정/범위)
- 셔터박스 prefix isStandard 파라미터 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:57:59 +09:00

1172 lines
43 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Production;
use App\DTOs\Production\DynamicBomEntry;
use App\Models\Orders\Order;
use App\Models\Process;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* 수주 → 생산지시 시 절곡 공정용 bending_info JSON 자동 생성
*
* 입력: Order (rootNodes eager loaded) + processId
* 출력: ['bending_info' => array, 'context' => array] — bending_info + dynamic_bom 생성 컨텍스트
*
* @see react/src/components/production/WorkOrders/documents/bending/types.ts
*/
class BendingInfoBuilder
{
// 표준 원자재 길이 버킷 (5130 레거시 write_form.php 기준)
private const GUIDE_RAIL_LENGTHS = [2438, 3000, 3500, 4000, 4300];
private const SHUTTER_BOX_LENGTHS = [1219, 2438, 3000, 3500, 4000, 4150];
/**
* 수주의 노드/BOM 데이터로 bending_info JSON 생성
*/
/**
* @param array|null $nodeIds 특정 노드만 집계 (null이면 전체)
*/
public function build(Order $order, int $processId, ?array $nodeIds = null): ?array
{
// 1. 절곡 공정인지 확인
$process = Process::find($processId);
if (! $process || $process->process_name !== '절곡') {
return null;
}
// 2. 루트 노드 확인 (nodeIds가 있으면 해당 노드만 필터)
$nodes = $order->rootNodes;
if ($nodeIds !== null) {
$nodes = $nodes->whereIn('id', $nodeIds);
}
if ($nodes->isEmpty()) {
return null;
}
// 3. 첫 번째 루트 노드에서 product_code 파싱
$firstNode = $nodes->first();
$productCode = $firstNode->options['product_code'] ?? '';
if (empty($productCode)) {
return null;
}
$productInfo = $this->parseProductCode($productCode);
if (empty($productInfo['productCode'])) {
return null;
}
// 4. 재질 매핑
$materials = $this->getMaterialMapping(
$productInfo['productCode'],
$productInfo['finishMaterial']
);
// 5. 노드 집계 (치수별 그룹핑 + BOM 카테고리 분류)
$aggregated = $this->aggregateNodes($nodes);
// 6. bending_info 조립
$bendingInfo = $this->assembleBendingInfo($productInfo, $materials, $aggregated);
// 7. 셔터박스 크기 추출 (dynamic_bom 컨텍스트용)
$caseBom = $aggregated['bomCategories']['shutterBox_case'] ?? null;
$motorBom = $aggregated['bomCategories']['motor'] ?? null;
$boxSize = null;
if ($caseBom) {
$motorCapacity = $this->extractMotorCapacity($motorBom);
$boxSize = $motorCapacity ? $this->getShutterBoxSize($motorCapacity) : null;
if (! $boxSize) {
$boxSize = str_replace('BD-케이스-', '', $caseBom['item_code'] ?? '');
}
}
return [
'bending_info' => $bendingInfo,
'context' => [
'productCode' => $productInfo['productCode'],
'guideType' => $productInfo['guideType'],
'finishMaterial' => $productInfo['finishMaterial'],
'materials' => $materials,
'boxSize' => $boxSize,
'hasSmokeRail' => isset($aggregated['bomCategories']['smokeBarrier_rail']),
'hasSmokeCase' => isset($aggregated['bomCategories']['smokeBarrier_case']),
],
];
}
/**
* 개소(work_order_item) 단위 dynamic_bom 생성
*
* @param array $context build() 반환값의 'context'
* @param int $width 개소의 오픈폭 (mm)
* @param int $height 개소의 오픈높이 (mm)
* @param int $qty 개소 수량
* @param int $tenantId 테넌트 ID (item 조회용)
* @return array DynamicBomEntry::toArray() 배열
*/
public function buildDynamicBomForItem(array $context, int $width, int $height, int $qty, int $tenantId = 287): array
{
$resolver = new PrefixResolver;
$entries = [];
$productCode = $context['productCode'];
$guideType = $context['guideType'];
$finishMaterial = $context['finishMaterial'];
$materials = $context['materials'];
$boxSize = $context['boxSize'];
$hasExtraFinish = ! empty($materials['guideRailExtraFinish']);
// ─── 1. 가이드레일 세부품목 ───
$dimGroups = [['height' => $height, 'width' => $width, 'qty' => $qty]];
$heightData = $this->heightLengthData($dimGroups);
// 가이드레일은 개구부 양쪽 2개이므로 수량 ×2
foreach ($heightData as &$entry) {
$entry['quantity'] *= 2;
}
unset($entry);
$guideTypes = match ($guideType) {
'혼합형' => ['wall', 'side'],
'측면형' => ['side'],
default => ['wall'],
};
$guidePartTypes = ['finish', 'body', 'c_type', 'd_type'];
if ($hasExtraFinish) {
$guidePartTypes[] = 'extra_finish';
}
$guidePartTypes[] = 'base';
foreach ($guideTypes as $gType) {
foreach ($heightData as $ld) {
foreach ($guidePartTypes as $partType) {
$prefix = $resolver->resolveGuideRailPrefix($partType, $gType, $productCode);
if (empty($prefix)) {
continue;
}
$itemCode = $resolver->buildItemCode($prefix, $ld['length']);
if (! $itemCode) {
Log::warning('BendingInfoBuilder: lengthCode 변환 실패', ['prefix' => $prefix, 'length' => $ld['length']]);
continue;
}
$itemId = $resolver->resolveItemId($itemCode, $tenantId);
if (! $itemId) {
Log::warning('BendingInfoBuilder: 미등록 품목', ['code' => $itemCode]);
continue;
}
$entries[] = new DynamicBomEntry(
child_item_id: $itemId,
child_item_code: $itemCode,
lot_prefix: $prefix,
part_type: PrefixResolver::partTypeName($partType),
category: 'guideRail',
material_type: $this->resolvePartMaterial($partType, $gType, $materials),
length_mm: $ld['length'],
qty: $ld['quantity'],
);
}
}
}
// ─── 2. 하단마감재 세부품목 ───
[$qty3000, $qty4000] = $this->bottomBarDistribution($width);
$bottomLengths = array_filter([3000 => $qty3000 * $qty, 4000 => $qty4000 * $qty]);
$bottomPartTypes = ['main', 'lbar', 'reinforce'];
$hasBottomExtra = ! empty($materials['bottomBarExtraFinish']) && $materials['bottomBarExtraFinish'] !== '없음';
if ($hasBottomExtra) {
$bottomPartTypes[] = 'extra';
}
foreach ($bottomLengths as $length => $lengthQty) {
if ($lengthQty <= 0) {
continue;
}
foreach ($bottomPartTypes as $partType) {
$prefix = $resolver->resolveBottomBarPrefix($partType, $productCode, $finishMaterial);
$itemCode = $resolver->buildItemCode($prefix, $length);
if (! $itemCode) {
continue;
}
$itemId = $resolver->resolveItemId($itemCode, $tenantId);
if (! $itemId) {
Log::warning('BendingInfoBuilder: 미등록 하단마감재', ['code' => $itemCode]);
continue;
}
$entries[] = new DynamicBomEntry(
child_item_id: $itemId,
child_item_code: $itemCode,
lot_prefix: $prefix,
part_type: PrefixResolver::partTypeName($partType),
category: 'bottomBar',
material_type: $materials['bottomBarFinish'],
length_mm: $length,
qty: $lengthQty,
);
}
}
// ─── 3. 셔터박스 세부품목 ───
if ($boxSize) {
$dist = $this->shutterBoxDistribution($width);
// 상부덮개(top_cover), 마구리(fin_cover)는 1219mm 기준으로 별도 생성 (아래 256행~)
$shutterPartTypes = ['front', 'lintel', 'inspection', 'rear_corner'];
// 작업일지와 동일한 순서: 파트 → 길이
foreach ($shutterPartTypes as $partType) {
foreach ($dist as $length => $count) {
$totalCount = $count * $qty;
if ($totalCount <= 0) {
continue;
}
$prefix = $resolver->resolveShutterBoxPrefix($partType);
$itemCode = $resolver->buildItemCode($prefix, $length);
if (! $itemCode) {
continue;
}
$itemId = $resolver->resolveItemId($itemCode, $tenantId);
if (! $itemId) {
Log::warning('BendingInfoBuilder: 미등록 셔터박스', ['code' => $itemCode]);
continue;
}
$entries[] = new DynamicBomEntry(
child_item_id: $itemId,
child_item_code: $itemCode,
lot_prefix: $prefix,
part_type: PrefixResolver::partTypeName($partType),
category: 'shutterBox',
material_type: 'EGI',
length_mm: $length,
qty: $totalCount,
);
}
}
// 상부덮개 수량: ceil(width / 1219) × qty (1219mm 단위)
$coverQty = (int) ceil($width / 1219) * $qty;
if ($coverQty > 0) {
$coverPrefix = $resolver->resolveShutterBoxPrefix('top_cover');
$coverCode = $resolver->buildItemCode($coverPrefix, 1219);
if ($coverCode) {
$coverId = $resolver->resolveItemId($coverCode, $tenantId);
if ($coverId) {
$entries[] = new DynamicBomEntry(
child_item_id: $coverId,
child_item_code: $coverCode,
lot_prefix: $coverPrefix,
part_type: PrefixResolver::partTypeName('top_cover'),
category: 'shutterBox',
material_type: 'EGI',
length_mm: 1219,
qty: $coverQty,
);
}
}
}
// 마구리 수량: qty × 2
$finQty = $qty * 2;
if ($finQty > 0) {
$finPrefix = $resolver->resolveShutterBoxPrefix('fin_cover');
// 마구리는 박스 높이 기준이므로 셔터박스 최소 단위 사용
$finCode = $resolver->buildItemCode($finPrefix, 1219);
if ($finCode) {
$finId = $resolver->resolveItemId($finCode, $tenantId);
if ($finId) {
$entries[] = new DynamicBomEntry(
child_item_id: $finId,
child_item_code: $finCode,
lot_prefix: $finPrefix,
part_type: PrefixResolver::partTypeName('fin_cover'),
category: 'shutterBox',
material_type: 'EGI',
length_mm: 1219,
qty: $finQty,
);
}
}
}
}
// ─── 4. 연기차단재 세부품목 ───
if ($context['hasSmokeRail'] || $context['hasSmokeCase']) {
$smokePrefix = $resolver->resolveSmokeBarrierPrefix();
// W50 (레일용): open_height + 250 → 표준 길이
if ($context['hasSmokeRail']) {
$col24 = $height + 250;
$w50Length = $this->bucketToStandardLength($col24, [2438, 3000, 3500, 4000, 4300]);
if ($w50Length && $col24 <= 4300) {
$w50Code = $resolver->buildItemCode($smokePrefix, $w50Length, 'w50');
if ($w50Code) {
$w50Id = $resolver->resolveItemId($w50Code, $tenantId);
if ($w50Id) {
$entries[] = new DynamicBomEntry(
child_item_id: $w50Id,
child_item_code: $w50Code,
lot_prefix: $smokePrefix,
part_type: '연기차단재(W50)',
category: 'smokeBarrier',
material_type: 'GI',
length_mm: $w50Length,
qty: 2 * $qty,
);
}
}
}
}
// W80 (케이스용): floor((width+240)*2/3000 + 1) × qty
if ($context['hasSmokeCase']) {
$col38 = $width + 240;
$w80PerNode = (int) floor(($col38 * 2 / 3000) + 1);
$w80Qty = $w80PerNode * $qty;
if ($w80Qty > 0) {
// W80은 3000mm 기본 (레거시 동일)
$w80Code = $resolver->buildItemCode($smokePrefix, 3000, 'w80');
if ($w80Code) {
$w80Id = $resolver->resolveItemId($w80Code, $tenantId);
if ($w80Id) {
$entries[] = new DynamicBomEntry(
child_item_id: $w80Id,
child_item_code: $w80Code,
lot_prefix: $smokePrefix,
part_type: '연기차단재(W80)',
category: 'smokeBarrier',
material_type: 'GI',
length_mm: 3000,
qty: $w80Qty,
);
}
}
}
}
}
return DynamicBomEntry::toArrayList($entries);
}
/**
* 파트타입 + 가이드타입 → 실제 재질 결정
*/
private function resolvePartMaterial(string $partType, string $guideType, array $materials): string
{
return match ($partType) {
'finish' => $materials['guideRailFinish'],
'extra_finish' => $materials['guideRailExtraFinish'],
'body', 'c_type', 'd_type' => $materials['bodyMaterial'],
'base' => 'EGI',
default => '',
};
}
/**
* product_code 파싱
* "FG-KSS02-벽면형-SUS" → productCode, guideType, finishMaterial
*/
private function parseProductCode(string $fullCode): array
{
$parts = explode('-', $fullCode);
// FG 접두사 제거
if (($parts[0] ?? '') === 'FG') {
array_shift($parts);
}
$finish = $parts[2] ?? 'EGI';
return [
'productCode' => $parts[0] ?? '',
'guideType' => $parts[1] ?? '벽면형',
'finishMaterial' => $finish === 'SUS' ? 'SUS마감' : 'EGI마감',
];
}
/**
* BOM 아이템 카테고리 분류 (item_code/item_name 패턴 매칭)
*/
private function categorizeBomItem(array $bomItem): ?string
{
$code = $bomItem['item_code'] ?? '';
$name = $bomItem['item_name'] ?? '';
if (str_starts_with($code, 'BD-가이드레일')) {
return 'guideRail';
}
if (str_starts_with($code, 'BD-케이스')) {
return 'shutterBox_case';
}
if (str_starts_with($code, 'BD-마구리')) {
return 'shutterBox_finCover';
}
if (str_contains($name, '하장바')) {
return 'bottomBar';
}
if ($code === 'EST-SMOKE-레일용') {
return 'smokeBarrier_rail';
}
if ($code === 'EST-SMOKE-케이스용') {
return 'smokeBarrier_case';
}
if (str_starts_with($code, 'BD-L-BAR')) {
return 'detail_lbar';
}
if (str_starts_with($code, 'BD-보강평철')) {
return 'detail_reinforce';
}
if (str_starts_with($code, 'EST-MOTOR-')) {
return 'motor';
}
return null;
}
/**
* 노드 집계: 치수별 그룹핑 + BOM 카테고리 분류
*/
private function aggregateNodes(Collection $nodes): array
{
$dimensionGroups = [];
$totalNodeQty = 0;
$bomCategories = [];
foreach ($nodes as $node) {
$opts = $node->options ?? [];
// 절곡 원자재 길이는 오픈 사이즈 기준 (제조치수는 +offset 포함으로 부적합)
$width = (int) ($opts['open_width'] ?? $opts['width'] ?? 0);
$height = (int) ($opts['open_height'] ?? $opts['height'] ?? 0);
$nodeQty = $node->quantity ?? 1;
$totalNodeQty += $nodeQty;
// 치수별 그룹핑
$dimKey = "{$height}_{$width}";
if (! isset($dimensionGroups[$dimKey])) {
$dimensionGroups[$dimKey] = [
'height' => (int) $height,
'width' => (int) $width,
'qty' => 0,
];
}
$dimensionGroups[$dimKey]['qty'] += $nodeQty;
// BOM 아이템 카테고리 분류 (첫 노드에서 메타데이터 추출)
$bomItems = $opts['bom_result']['items'] ?? [];
foreach ($bomItems as $bom) {
$cat = $this->categorizeBomItem($bom);
if ($cat && ! isset($bomCategories[$cat])) {
$bomCategories[$cat] = $bom;
}
}
}
return [
'dimensionGroups' => array_values($dimensionGroups),
'totalNodeQty' => $totalNodeQty,
'bomCategories' => $bomCategories,
];
}
/**
* bending_info JSON 조립 (BendingInfoExtended 구조)
*/
private function assembleBendingInfo(array $productInfo, array $materials, array $agg): array
{
$guideRailBom = $agg['bomCategories']['guideRail'] ?? null;
$caseBom = $agg['bomCategories']['shutterBox_case'] ?? null;
$finCoverBom = $agg['bomCategories']['shutterBox_finCover'] ?? null;
$lbarBom = $agg['bomCategories']['detail_lbar'] ?? null;
$motorBom = $agg['bomCategories']['motor'] ?? null;
$dimGroups = $agg['dimensionGroups'];
// 가이드레일 baseSize 추출: BD-가이드레일-KSS01-SUS-120*70 → "120*70"
$baseSize = $guideRailBom ? $this->extractBaseSize($guideRailBom['item_code'] ?? '') : '';
return [
'productCode' => $productInfo['productCode'],
'finishMaterial' => $productInfo['finishMaterial'],
'common' => $this->buildCommon($productInfo['guideType'], $baseSize, $dimGroups),
'detailParts' => $this->buildDetailParts($guideRailBom, $lbarBom, $materials),
'guideRail' => $this->buildGuideRail($productInfo['guideType'], $baseSize, $materials, $dimGroups, $productInfo['productCode']),
'bottomBar' => $this->buildBottomBar($materials, $dimGroups),
'shutterBox' => $this->buildShutterBox($caseBom, $finCoverBom, $motorBom, $baseSize, $dimGroups),
'smokeBarrier' => $this->buildSmokeBarrier($dimGroups),
];
}
// ─────────────────────────────────────────────────
// common 섹션
// ─────────────────────────────────────────────────
private function buildCommon(string $guideType, string $baseSize, array $dimGroups): array
{
return [
'kind' => $guideType.' '.str_replace('*', 'X', $baseSize),
'type' => $guideType.'('.$baseSize.')',
'lengthQuantities' => $this->heightLengthQuantities($dimGroups),
];
}
// ─────────────────────────────────────────────────
// detailParts 섹션
// ─────────────────────────────────────────────────
private function buildDetailParts(?array $guideRailBom, ?array $lbarBom, array $materials): array
{
$parts = [];
if ($guideRailBom) {
$baseSize = $this->extractBaseSize($guideRailBom['item_code'] ?? '');
$parts[] = [
'partName' => '가이드레일',
'material' => $materials['guideRailFinish'],
'barcyInfo' => $baseSize,
];
}
if ($lbarBom) {
// BD-L-BAR-KSS01-17*60 → "17*60"
$lbarSize = $this->extractLastSize($lbarBom['item_code'] ?? '');
$parts[] = [
'partName' => 'L-BAR',
'material' => $this->extractFinishFromCode($lbarBom['item_code'] ?? '', $materials),
'barcyInfo' => $lbarSize,
];
}
return $parts;
}
// ─────────────────────────────────────────────────
// guideRail 섹션
// ─────────────────────────────────────────────────
private function buildGuideRail(string $guideType, string $baseSize, array $materials, array $dimGroups, string $productCode): array
{
$heightData = $this->heightLengthData($dimGroups);
// 가이드레일은 개구부 양쪽 2개이므로 수량 ×2
foreach ($heightData as &$entry) {
$entry['quantity'] *= 2;
}
unset($entry);
$baseDims = $this->getBaseDimensions($productCode);
if ($guideType === '혼합형') {
// 혼합형: 벽면+측면 둘 다
return [
'wall' => [
'baseSize' => $baseSize,
'baseDimension' => $baseDims['wall'],
'lengthData' => $heightData,
],
'side' => [
'baseSize' => $baseSize,
'baseDimension' => $baseDims['side'],
'lengthData' => $heightData,
],
];
}
if ($guideType === '측면형') {
return [
'wall' => null,
'side' => [
'baseSize' => $baseSize,
'baseDimension' => $baseDims['side'],
'lengthData' => $heightData,
],
];
}
// 벽면형 (기본)
return [
'wall' => [
'baseSize' => $baseSize,
'baseDimension' => $baseDims['wall'],
'lengthData' => $heightData,
],
'side' => null,
];
}
// ─────────────────────────────────────────────────
// bottomBar 섹션
// ─────────────────────────────────────────────────
private function buildBottomBar(array $materials, array $dimGroups): array
{
$length3000Qty = 0;
$length4000Qty = 0;
// 레거시 Slat_updateCol49to51() 동일 로직: 오픈폭 범위별 3000/4000 조합 배분
foreach ($dimGroups as $group) {
$width = $group['width'];
$qty = $group['qty'];
[$qty3000, $qty4000] = $this->bottomBarDistribution($width);
$length3000Qty += $qty3000 * $qty;
$length4000Qty += $qty4000 * $qty;
}
return [
'material' => $materials['bottomBarFinish'],
'extraFinish' => $materials['bottomBarExtraFinish'],
'length3000Qty' => $length3000Qty,
'length4000Qty' => $length4000Qty,
];
}
// ─────────────────────────────────────────────────
// shutterBox 섹션
// ─────────────────────────────────────────────────
private function buildShutterBox(?array $caseBom, ?array $finCoverBom, ?array $motorBom, string $guideBaseSize, array $dimGroups): array
{
if (! $caseBom) {
return [];
}
// 셔터박스 크기: 모터용량 → 브라켓 → 박스 매핑 (레거시 Slat_updateCol37)
$motorCapacity = $this->extractMotorCapacity($motorBom);
$boxSize = $motorCapacity ? $this->getShutterBoxSize($motorCapacity) : null;
if (! $boxSize) {
// fallback: BOM 케이스 item_code에서 추출 (BD-케이스-500*380 → "500*380")
$boxSize = str_replace('BD-케이스-', '', $caseBom['item_code'] ?? '');
}
$boxParts = explode('*', $boxSize);
$boxWidth = (int) ($boxParts[0] ?? 0);
$boxHeight = (int) ($boxParts[1] ?? 0);
// railWidth: 가이드레일 baseSize에서 첫 번째 숫자 (120*70 → 120)
$guideParts = explode('*', $guideBaseSize);
$railWidth = (int) ($guideParts[0] ?? 0);
// 상부덮개 수량: ceil(openWidth / 1219) × nodeQty (레거시 Slat_updateCol45)
$coverQty = 0;
// 마구리 수량: nodeQty × 2 (레거시 Slat_updateCol46: col15 * 2)
$finCoverQty = 0;
foreach ($dimGroups as $group) {
$coverQty += (int) ceil($group['width'] / 1219) * $group['qty'];
$finCoverQty += $group['qty'] * 2;
}
// lengthData: 표준 원자재 조합 배분 (레거시 Slat_updateCol39to43)
$widthData = $this->shutterBoxCombinedLengthData($dimGroups);
return [
[
'size' => $boxSize,
'direction' => '양면',
'railWidth' => $railWidth,
'frontBottom' => $boxHeight,
'coverQty' => $coverQty,
'finCoverQty' => $finCoverQty,
'lengthData' => $widthData,
],
];
}
/**
* dimGroups를 조합 배분하여 셔터박스 lengthData 생성
*/
private function shutterBoxCombinedLengthData(array $dimGroups): array
{
$combined = [];
foreach ($dimGroups as $group) {
$dist = $this->shutterBoxDistribution($group['width']);
foreach ($dist as $length => $count) {
if ($count > 0) {
$combined[$length] = ($combined[$length] ?? 0) + $count * $group['qty'];
}
}
}
$result = [];
ksort($combined);
foreach ($combined as $length => $qty) {
$result[] = ['length' => $length, 'quantity' => $qty];
}
return $result;
}
/**
* 오픈폭에 따른 셔터박스 표준 원자재 조합 배분
* 레거시 5130/estimate/common/common_addrowJS.php Slat_updateCol39to43() 동일
*
* @return array<int,int> [1219 => qty, 2438 => qty, 3000 => qty, 3500 => qty, 4000 => qty, 4150 => qty]
*/
private function shutterBoxDistribution(int $openWidth): array
{
$d = [1219 => 0, 2438 => 0, 3000 => 0, 3500 => 0, 4000 => 0, 4150 => 0];
// col39 (1219mm)
if ($openWidth <= 1219) {
$d[1219] = 1;
} elseif ($openWidth > 4150 && $openWidth <= 4219) {
$d[1219] = 1;
} elseif ($openWidth > 4219 && $openWidth <= 4719) {
$d[1219] = 1;
} elseif ($openWidth > 4876 && $openWidth <= 5219) {
$d[1219] = 1;
} elseif ($openWidth > 5219 && $openWidth <= 5369) {
$d[1219] = 1;
} elseif ($openWidth > 9026 && $openWidth <= 9219) {
$d[1219] = 1;
}
// col40 (2438mm)
if ($openWidth > 1219 && $openWidth <= 2438) {
$d[2438] = 1;
} elseif ($openWidth > 4719 && $openWidth <= 4876) {
$d[2438] = 2;
} elseif ($openWidth > 5369 && $openWidth <= 5938) {
$d[2438] = 1;
} elseif ($openWidth > 6000 && $openWidth <= 6438) {
$d[2438] = 1;
} elseif ($openWidth > 6500 && $openWidth <= 6588) {
$d[2438] = 1;
} elseif ($openWidth > 8300 && $openWidth <= 8376) {
$d[2438] = 2;
} elseif ($openWidth > 8376 && $openWidth <= 8438) {
$d[2438] = 1;
} elseif ($openWidth > 8438 && $openWidth <= 8876) {
$d[2438] = 2;
} elseif ($openWidth > 9000 && $openWidth <= 9026) {
$d[2438] = 2;
} elseif ($openWidth > 9219 && $openWidth <= 9438) {
$d[2438] = 1;
} elseif ($openWidth > 10150 && $openWidth <= 10738) {
$d[2438] = 1;
}
// col41 (3000mm)
if ($openWidth > 2438 && $openWidth <= 3000) {
$d[3000] = 1;
} elseif ($openWidth > 4150 && $openWidth <= 4219) {
$d[3000] = 1;
} elseif ($openWidth > 5369 && $openWidth <= 5438) {
$d[3000] = 1;
} elseif ($openWidth > 5938 && $openWidth <= 6000) {
$d[3000] = 2;
} elseif ($openWidth > 6438 && $openWidth <= 6500) {
$d[3000] = 1;
} elseif ($openWidth > 7000 && $openWidth <= 7150) {
$d[3000] = 1;
} elseif ($openWidth > 8376 && $openWidth <= 8438) {
$d[3000] = 2;
} elseif ($openWidth > 8876 && $openWidth <= 9000) {
$d[3000] = 3;
} elseif ($openWidth > 9438 && $openWidth <= 10150) {
$d[3000] = 2;
} elseif ($openWidth > 10738 && $openWidth <= 11000) {
$d[3000] = 1;
}
// col42 (3500mm)
if ($openWidth > 3000 && $openWidth <= 3500) {
$d[3500] = 1;
} elseif ($openWidth > 4219 && $openWidth <= 4719) {
$d[3500] = 1;
} elseif ($openWidth > 5438 && $openWidth <= 5938) {
$d[3500] = 1;
} elseif ($openWidth > 6438 && $openWidth <= 6500) {
$d[3500] = 1;
} elseif ($openWidth > 6588 && $openWidth <= 7000) {
$d[3500] = 2;
} elseif ($openWidth > 7150 && $openWidth <= 7650) {
$d[3500] = 1;
} elseif ($openWidth > 8300 && $openWidth <= 8376) {
$d[3500] = 1;
} elseif ($openWidth > 9219 && $openWidth <= 9438) {
$d[3500] = 2;
} elseif ($openWidth > 9438 && $openWidth <= 9500) {
$d[3500] = 1;
}
// col43 (4000mm)
if ($openWidth > 3500 && $openWidth <= 4000) {
$d[4000] = 1;
} elseif ($openWidth > 4876 && $openWidth <= 5219) {
$d[4000] = 1;
} elseif ($openWidth > 6000 && $openWidth <= 6438) {
$d[4000] = 1;
} elseif ($openWidth > 7150 && $openWidth <= 7500) {
$d[4000] = 1;
} elseif ($openWidth > 7650 && $openWidth <= 8000) {
$d[4000] = 2;
} elseif ($openWidth > 8000 && $openWidth <= 8150) {
$d[4000] = 1;
} elseif ($openWidth > 8438 && $openWidth <= 8876) {
$d[4000] = 1;
} elseif ($openWidth > 9026 && $openWidth <= 9219) {
$d[4000] = 2;
} elseif ($openWidth > 9500 && $openWidth <= 10000) {
$d[4000] = 1;
} elseif ($openWidth > 10150 && $openWidth <= 10438) {
$d[4000] = 2;
} elseif ($openWidth > 10738 && $openWidth <= 11000) {
$d[4000] = 2;
}
// col44 (4150mm)
if ($openWidth > 4000 && $openWidth <= 4150) {
$d[4150] = 1;
} elseif ($openWidth > 5219 && $openWidth <= 5369) {
$d[4150] = 1;
} elseif ($openWidth > 6500 && $openWidth <= 6588) {
$d[4150] = 1;
} elseif ($openWidth > 7000 && $openWidth <= 7150) {
$d[4150] = 1;
} elseif ($openWidth > 7500 && $openWidth <= 7650) {
$d[4150] = 1;
} elseif ($openWidth > 8000 && $openWidth <= 8150) {
$d[4150] = 1;
} elseif ($openWidth > 8150 && $openWidth <= 8300) {
$d[4150] = 2;
} elseif ($openWidth > 9000 && $openWidth <= 9026) {
$d[4150] = 1;
} elseif ($openWidth > 10000 && $openWidth <= 10150) {
$d[4150] = 1;
} elseif ($openWidth > 10438 && $openWidth <= 10738) {
$d[4150] = 2;
}
return $d;
}
// ─────────────────────────────────────────────────
// smokeBarrier 섹션
// ─────────────────────────────────────────────────
/**
* 연기차단재 섹션 (레거시 Slat_updateCol24~29, Slat_updateCol48 동일)
*
* W50 (레일용): col24 = open_height + 250 → 범위별 표준 길이, qty = 2 × 셔터수
* W80 (케이스용): col38 = open_width + 240, col48 = floor(col38*2/3000 + 1) × 셔터수
*/
private function buildSmokeBarrier(array $dimGroups): array
{
$w50Combined = [];
$w80Qty = 0;
foreach ($dimGroups as $group) {
$height = $group['height'];
$width = $group['width'];
$nodeQty = $group['qty'];
// W50: col24 = open_height + 250 → 범위별 표준 길이
$col24 = $height + 250;
$w50Length = null;
if ($col24 <= 2438) {
$w50Length = 2438;
} elseif ($col24 <= 3000) {
$w50Length = 3000;
} elseif ($col24 <= 3500) {
$w50Length = 3500;
} elseif ($col24 <= 4000) {
$w50Length = 4000;
} elseif ($col24 <= 4300) {
$w50Length = 4300;
}
// > 4300: W50 없음
if ($w50Length !== null) {
$w50Combined[$w50Length] = ($w50Combined[$w50Length] ?? 0) + 2 * $nodeQty;
}
// W80: col38 = open_width + 240, col48 = floor(col38*2/3000 + 1) × 셔터수
$col38 = $width + 240;
$w80PerNode = (int) floor(($col38 * 2 / 3000) + 1);
$w80Qty += $w80PerNode * $nodeQty;
}
// W50 결과 조립
$w50Result = [];
ksort($w50Combined);
foreach ($w50Combined as $length => $qty) {
if ($qty > 0) {
$w50Result[] = ['length' => $length, 'quantity' => $qty];
}
}
return [
'w50' => $w50Result,
'w80Qty' => $w80Qty,
];
}
// ─────────────────────────────────────────────────
// 재질 매핑 (프론트 getMaterialMapping 동일 로직)
// ─────────────────────────────────────────────────
/**
* @see react/src/components/production/WorkOrders/documents/bending/utils.ts:77
*/
private function getMaterialMapping(string $productCode, string $finishMaterial): array
{
// Group 1: SUS 전용 (KQTS01, KSS01, KSS02)
if (in_array($productCode, ['KQTS01', 'KSS01', 'KSS02'])) {
return [
'guideRailFinish' => 'SUS 1.2T',
'bodyMaterial' => 'EGI 1.55T',
'guideRailExtraFinish' => '',
'bottomBarFinish' => 'SUS 1.5T',
'bottomBarExtraFinish' => '없음',
];
}
// Group 2: KTE01 (마감유형 분기)
if ($productCode === 'KTE01') {
$isSUS = $finishMaterial === 'SUS마감';
return [
'guideRailFinish' => 'EGI 1.55T',
'bodyMaterial' => 'EGI 1.55T',
'guideRailExtraFinish' => $isSUS ? 'SUS 1.2T' : '',
'bottomBarFinish' => 'EGI 1.55T',
'bottomBarExtraFinish' => $isSUS ? 'SUS 1.2T' : '없음',
];
}
// Group 3: 기타 (KSE01, KWE01 등)
$isSUS = str_contains($finishMaterial, 'SUS');
return [
'guideRailFinish' => 'EGI 1.55T',
'bodyMaterial' => 'EGI 1.55T',
'guideRailExtraFinish' => $isSUS ? 'SUS 1.2T' : '',
'bottomBarFinish' => 'EGI 1.55T',
'bottomBarExtraFinish' => $isSUS ? 'SUS 1.2T' : '없음',
];
}
// ─────────────────────────────────────────────────
// 유틸리티 메서드
// ─────────────────────────────────────────────────
/**
* 모터 BOM item_code에서 용량 추출
* EST-MOTOR-220V-500K → "500K"
*/
private function extractMotorCapacity(?array $motorBom): ?string
{
if (! $motorBom) {
return null;
}
$code = $motorBom['item_code'] ?? '';
if (preg_match('/(\d+K)$/', $code, $matches)) {
return $matches[1];
}
return null;
}
/**
* 모터용량 → 셔터박스 조립 크기 매핑
* 레거시 체인: 모터용량(col20) → 브라켓(col21) → 박스크기(col37)
*
* @see 5130/estimate/common/common_addrowJS.php Slat_updateCol21(), Slat_updateCol37()
*/
private function getShutterBoxSize(string $motorCapacity): string
{
return match ($motorCapacity) {
'300K', '400K' => '650*550',
'500K', '600K' => '700*600',
'800K', '1000K' => '780*650',
default => '650*550',
};
}
/**
* 가이드레일 item_code에서 baseSize 추출
* BD-가이드레일-KSS01-SUS-120*70 → "120*70"
*/
private function extractBaseSize(string $itemCode): string
{
$parts = explode('-', $itemCode);
$last = end($parts);
// 마지막 세그먼트가 "숫자*숫자" 패턴인지 확인
if (preg_match('/^\d+\*\d+$/', $last)) {
return $last;
}
return '';
}
/**
* item_code 마지막 사이즈 세그먼트 추출
* BD-L-BAR-KSS01-17*60 → "17*60"
*/
private function extractLastSize(string $itemCode): string
{
return $this->extractBaseSize($itemCode);
}
/**
* item_code에서 마감재 정보 추출 (L-BAR 등)
*/
private function extractFinishFromCode(string $itemCode, array $materials): string
{
if (str_contains($itemCode, '-SUS-')) {
return 'SUS';
}
if (str_contains($itemCode, '-EGI-')) {
return 'EGI';
}
// BOM 코드에 마감재 표시 없으면 재질 매핑에서 추출
return str_contains($materials['guideRailFinish'], 'SUS') ? 'SUS' : 'EGI';
}
/**
* height 기준 lengthQuantities (common 섹션용)
*/
private function heightLengthQuantities(array $dimGroups): array
{
$result = [];
$grouped = [];
foreach ($dimGroups as $group) {
$h = $group['height'];
$grouped[$h] = ($grouped[$h] ?? 0) + $group['qty'];
}
foreach ($grouped as $length => $qty) {
$result[] = ['length' => $length, 'quantity' => $qty];
}
return $result;
}
/**
* height 기준 lengthData (guideRail, smokeBarrier 섹션용)
* 오픈높이를 표준 원자재 길이로 버킷팅
*/
private function heightLengthData(array $dimGroups): array
{
return $this->bucketedLengthData($dimGroups, 'height', self::GUIDE_RAIL_LENGTHS);
}
/**
* width 기준 lengthData (shutterBox 섹션용)
* 오픈폭을 표준 원자재 길이로 버킷팅
*/
private function widthLengthData(array $dimGroups): array
{
return $this->bucketedLengthData($dimGroups, 'width', self::SHUTTER_BOX_LENGTHS);
}
/**
* 치수를 표준 원자재 길이로 버킷팅하여 그룹핑
*/
private function bucketedLengthData(array $dimGroups, string $dimKey, array $buckets): array
{
$result = [];
$grouped = [];
foreach ($dimGroups as $group) {
$raw = (int) $group[$dimKey];
$bucketed = $this->bucketToStandardLength($raw, $buckets);
$grouped[$bucketed] = ($grouped[$bucketed] ?? 0) + $group['qty'];
}
foreach ($grouped as $length => $qty) {
$result[] = ['length' => $length, 'quantity' => $qty];
}
return $result;
}
/**
* 오픈폭에 따른 하단마감재(하장바) 3000/4000 수량 배분
* 레거시 5130/estimate/common/common_addrowJS.php Slat_updateCol49to51() 동일
*
* @return array{0: int, 1: int} [3000mm수량, 4000mm수량]
*/
private function bottomBarDistribution(int $openWidth): array
{
// [3000mm 기본수, 4000mm 기본수]
if ($openWidth <= 3000) {
return [1, 0];
}
if ($openWidth <= 4000) {
return [0, 1];
}
if ($openWidth <= 6000) {
return [2, 0];
}
if ($openWidth <= 7000) {
return [1, 1];
}
if ($openWidth <= 8000) {
return [0, 2];
}
if ($openWidth <= 9000) {
return [3, 0];
}
if ($openWidth <= 10000) {
return [2, 1];
}
if ($openWidth <= 11000) {
return [1, 2];
}
if ($openWidth <= 12000) {
return [0, 3];
}
// 12000 초과: 4000mm 기준 올림
return [0, (int) ceil($openWidth / 4000)];
}
/**
* 제품코드별 실제 하부BASE 물리 치수 반환
* 대형 프로파일 (KQTS01, KTE01): 벽면 BASE = 135*130
* 소형 프로파일 (KSS01, KSS02 등): 벽면 BASE = 135*80
* 측면형 BASE는 항상 135*130
*/
private function getBaseDimensions(string $productCode): array
{
// 레거시: 벽면형 BASE = 135*80 (line 519), 측면형 BASE = 135*130 (line 626)
// 제품코드와 무관하게 고정
return [
'wall' => '135*80',
'side' => '135*130',
];
}
/**
* 치수를 표준 원자재 길이로 변환 (올림 버킷팅)
* dimension 이상인 최소 표준 길이 반환, 초과 시 원본 반환
*/
private function bucketToStandardLength(int $dimension, array $buckets): int
{
foreach ($buckets as $bucket) {
if ($bucket >= $dimension) {
return $bucket;
}
}
return $dimension;
}
}