From 492e4c02aa6e60be4922d29eb79baf6b8f5381f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 19 Feb 2026 22:29:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EC=A0=88=EA=B3=A1=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=EC=9D=BC=EC=A7=80=20=EC=99=84=EC=A0=84=20=EC=9E=AC?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20+=20=EC=8A=AC=EB=9E=AB=20=EC=9E=85?= =?UTF-8?q?=EA=B3=A0=20LOT=20NO=20=EA=B0=9C=EC=86=8C=EB=B3=84=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 절곡 작업일지: PHP viewBendingWork_slat.php 기준 4개 카테고리 섹션 구현 (가이드레일/하단마감재/셔터박스/연기차단재) + SUS/EGI 무게 계산 - 슬랫 작업일지: 입고 LOT NO가 모든 행에 동일하게 표시되던 버그 수정 → items.materialInputs.stockLot 데이터 활용하여 개소별 LOT 표시 - types.ts: WorkOrderItemApi에 material_inputs 필드 추가, WorkOrderItem에 bendingInfo/materialInputLots 필드 추가 및 transform 매핑 --- .../documents/BendingWorkLogContent.tsx | 152 ++--- .../documents/SlatWorkLogContent.tsx | 6 +- .../documents/bending/BottomBarSection.tsx | 70 +++ .../documents/bending/GuideRailSection.tsx | 118 ++++ .../bending/ProductionSummarySection.tsx | 44 ++ .../documents/bending/ShutterBoxSection.tsx | 102 ++++ .../documents/bending/SmokeBarrierSection.tsx | 68 +++ .../WorkOrders/documents/bending/types.ts | 130 ++++ .../WorkOrders/documents/bending/utils.ts | 567 ++++++++++++++++++ src/components/production/WorkOrders/types.ts | 18 + 10 files changed, 1198 insertions(+), 77 deletions(-) create mode 100644 src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx create mode 100644 src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx create mode 100644 src/components/production/WorkOrders/documents/bending/ProductionSummarySection.tsx create mode 100644 src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx create mode 100644 src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx create mode 100644 src/components/production/WorkOrders/documents/bending/types.ts create mode 100644 src/components/production/WorkOrders/documents/bending/utils.ts diff --git a/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx b/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx index bc285a92..c82cf060 100644 --- a/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx +++ b/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx @@ -3,16 +3,29 @@ /** * 절곡 작업일지 문서 콘텐츠 * - * 기획서 기준 구성: - * - 헤더: "작업일지 (절곡)" + 문서번호/작성일자 + 결재란(결재|작성/승인/승인/승인) - * - 신청업체(수주일,수주처,담당자,연락처) / 신청내용(현장명,작업일자,제품LOT NO,생산담당자,출고예정일) - * - 제품명 / 재질 / 마감 / 유형 테이블 - * - 작업내역: 유형명, 세부품명, 재질, 입고 & 생산 LOT NO, 길이/규격, 수량 - * - 생산량 합계 [kg]: SUS / EGI + * PHP viewBendingWork_slat.php와 동일한 구조로 완전 재구현 + * + * 구성: + * - 헤더: "작업일지 (절곡)" + 문서번호/작성일자 + 결재란 + * - 신청업체/신청내용 테이블 + * - 제품 정보 테이블 (제품명/재질/마감/유형) + * - 4개 카테고리 섹션: + * 1. 가이드레일 (벽면형/측면형) + * 2. 하단마감재 + * 3. 셔터박스 (방향별) + * 4. 연기차단재 (W50/W80) + * - 생산량 합계 [kg] SUS/EGI */ import type { WorkOrder } from '../types'; -import { SectionHeader, ConstructionApprovalTable } from '@/components/document-system'; +import type { BendingInfoExtended } from './bending/types'; +import { ConstructionApprovalTable } from '@/components/document-system'; +import { getMaterialMapping, calculateProductionSummary } from './bending/utils'; +import { GuideRailSection } from './bending/GuideRailSection'; +import { BottomBarSection } from './bending/BottomBarSection'; +import { ShutterBoxSection } from './bending/ShutterBoxSection'; +import { SmokeBarrierSection } from './bending/SmokeBarrierSection'; +import { ProductionSummarySection } from './bending/ProductionSummarySection'; interface BendingWorkLogContentProps { data: WorkOrder; @@ -43,22 +56,32 @@ export function BendingWorkLogContent({ data: order }: BendingWorkLogContentProp }).replace(/\. /g, '-').replace('.', '') : '-'; - // 빈 행 수 (기획서에 여러 빈 행 표시) - const EMPTY_ROWS = 4; + // 첫 번째 아이템의 bendingInfo에서 절곡 데이터 추출 + const firstBendingInfo = items.find(item => item.bendingInfo)?.bendingInfo as BendingInfoExtended | undefined; + + // bendingInfo가 없으면 빈 상태 표시 + const hasBendingData = !!firstBendingInfo?.productCode; + + // 재질 매핑 + const mapping = hasBendingData + ? getMaterialMapping(firstBendingInfo!.productCode, firstBendingInfo!.finishMaterial) + : null; + + // 생산량 합계 + const summary = hasBendingData && mapping + ? calculateProductionSummary(firstBendingInfo!, mapping) + : { susTotal: 0, egiTotal: 0, grandTotal: 0 }; return (
{/* ===== 헤더 영역 ===== */}
- {/* 좌측: 제목 + 문서번호 */}

작업일지 (절곡)

문서번호: {documentNo} | 작성일자: {fullDate}

- - {/* 우측: 결재란 */} -   -   -   -   + + {hasBendingData ? firstBendingInfo!.productCode : '-'} + + + {mapping?.bodyMaterial || '-'} + + + {hasBendingData ? firstBendingInfo!.finishMaterial : '-'} + + + {hasBendingData ? firstBendingInfo!.common.type : '-'} + - {/* ===== 작업내역 ===== */} - 작업내역 - - - - - - - - - - - - - {items.length > 0 ? ( - items.map((item, idx) => ( - - - - - - - - - )) - ) : ( - Array.from({ length: EMPTY_ROWS }).map((_, idx) => ( - - - - - - - - - )) - )} - -
유형명세부품명재질입고 & 생산 LOT NO길이/규격수량
{item.productName}--{order.lotNo}{item.specification || '-'}{item.quantity}
      
+ {/* ===== 4개 카테고리 섹션 ===== */} + {hasBendingData && mapping ? ( + <> + + + + + + ) : ( +
+ 절곡 데이터가 없습니다. (bending_info 미등록) +
+ )} - {/* ===== 생산량 합계 [kg] ===== */} - - - - - - - - + {/* ===== 생산량 합계 ===== */} + + + {/* ===== 비고 ===== */} +
생산량 합계 [kg]SUSEGI
- - - - - - - - + +
   
   비고 + {order.note || ''} +
diff --git a/src/components/production/WorkOrders/documents/SlatWorkLogContent.tsx b/src/components/production/WorkOrders/documents/SlatWorkLogContent.tsx index 6dfe356d..1da4edff 100644 --- a/src/components/production/WorkOrders/documents/SlatWorkLogContent.tsx +++ b/src/components/production/WorkOrders/documents/SlatWorkLogContent.tsx @@ -65,10 +65,6 @@ export function SlatWorkLogContent({ data: order, materialLots = [] }: SlatWorkL }).replace(/\. /g, '-').replace('.', '') : '-'; - // 투입 LOT 번호 (중복 제거) - const lotNoList = materialLots.map(lot => lot.lot_no).filter(Boolean); - const lotNoDisplay = lotNoList.length > 0 ? lotNoList.join(', ') : ''; - // 슬랫 계산: 매수(세로) = floor(height / 72) + 1 const calcSlatCount = (height?: number) => height ? Math.floor(height / 72) + 1 : 0; @@ -198,7 +194,7 @@ export function SlatWorkLogContent({ data: order, materialLots = [] }: SlatWorkL return ( {idx + 1} - {lotNoDisplay} + {item.materialInputLots?.join(', ') || ''} {glassQty > 0 ? fmt(glassQty) : '-'} {item.productName} {fmt(item.width)} diff --git a/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx b/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx new file mode 100644 index 00000000..c06fbe4e --- /dev/null +++ b/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx @@ -0,0 +1,70 @@ +'use client'; + +/** + * 하단마감재 섹션 + * + * ①하단마감재: 3000mm, 4000mm 수량 + * ④별도마감재: SUS마감 시에만 표시 + */ + +import type { BendingInfoExtended, MaterialMapping } from './types'; +import { buildBottomBarRows, getBendingImageUrl, fmt, fmtWeight } from './utils'; + +interface BottomBarSectionProps { + bendingInfo: BendingInfoExtended; + mapping: MaterialMapping; +} + +export function BottomBarSection({ bendingInfo, mapping }: BottomBarSectionProps) { + const rows = buildBottomBarRows(bendingInfo.bottomBar, mapping); + if (rows.length === 0) return null; + + return ( +
+
+ 2. 하단마감재 +
+ +
+ {/* 좌측: 이미지 */} +
+ 하단마감재 +
+ + {/* 우측: 테이블 */} +
+ + + + + + + + + + + + + {rows.map((row, idx) => ( + + + + + + + + + ))} + +
세부품명재질길이수량LOT NO무게(kg)
{row.partName}{row.material}{fmt(row.length)}{fmt(row.quantity)} + {row.lotPrefix}- + {fmtWeight(row.weight)}
+
+
+
+ ); +} diff --git a/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx b/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx new file mode 100644 index 00000000..83fb2051 --- /dev/null +++ b/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx @@ -0,0 +1,118 @@ +'use client'; + +/** + * 가이드레일 섹션 + * + * 1.1 벽면형 [130*75]: 이미지 + 세부품명/재질/길이/수량/LOT NO/무게 테이블 + * 1.2 측면형 [130*125]: 동일 구조 + */ + +import type { BendingInfoExtended, MaterialMapping, GuideRailPartRow } from './types'; +import { buildWallGuideRailRows, buildSideGuideRailRows, getBendingImageUrl, fmt, fmtWeight } from './utils'; + +interface GuideRailSectionProps { + bendingInfo: BendingInfoExtended; + mapping: MaterialMapping; + lotNo: string; +} + +function PartTable({ title, rows, imageUrl, lotNo, baseSize }: { + title: string; + rows: GuideRailPartRow[]; + imageUrl: string; + lotNo: string; + baseSize?: string; +}) { + if (rows.length === 0) return null; + + return ( +
+
{title}
+
+ {/* 좌측: 이미지 + LOT */} +
+ {title} +
+ 입고&생산 LOT NO:
+ _______________ +
+
+ + {/* 우측: 테이블 */} +
+ + + + + + + + + + + + + {rows.map((row, idx) => ( + + + + + + + + + ))} + +
세부품명재질길이수량LOT NO무게(kg)
{row.partName}{row.material} + {row.partName === '하부BASE' ? (baseSize || '-') : fmt(row.length)} + {fmt(row.quantity)} + {row.lotPrefix}- + {fmtWeight(row.weight)}
+
+
+
+ ); +} + +export function GuideRailSection({ bendingInfo, mapping, lotNo }: GuideRailSectionProps) { + const { wall, side } = bendingInfo.guideRail; + const productCode = bendingInfo.productCode; + + const wallRows = wall + ? buildWallGuideRailRows(wall.lengthData, wall.baseSize, mapping) + : []; + + const sideRows = side + ? buildSideGuideRailRows(side.lengthData, mapping) + : []; + + if (wallRows.length === 0 && sideRows.length === 0) return null; + + return ( +
+
+ 1. 가이드레일 +
+ + {wallRows.length > 0 && ( + + )} + + {sideRows.length > 0 && ( + + )} +
+ ); +} diff --git a/src/components/production/WorkOrders/documents/bending/ProductionSummarySection.tsx b/src/components/production/WorkOrders/documents/bending/ProductionSummarySection.tsx new file mode 100644 index 00000000..a2d7e3be --- /dev/null +++ b/src/components/production/WorkOrders/documents/bending/ProductionSummarySection.tsx @@ -0,0 +1,44 @@ +'use client'; + +/** + * 생산량 합계 섹션 + * + * SUS(7.93g/cm3) + EGI(7.85g/cm3) 무게 합계 + */ + +import type { ProductionSummary } from './types'; + +interface ProductionSummarySectionProps { + summary: ProductionSummary; +} + +export function ProductionSummarySection({ summary }: ProductionSummarySectionProps) { + return ( +
+ + + + + + + + + + + + + + + + + +
생산량 합계 [kg]SUSEGI합계
무게 + {summary.susTotal > 0 ? `${summary.susTotal.toFixed(2)} kg` : '-'} + + {summary.egiTotal > 0 ? `${summary.egiTotal.toFixed(2)} kg` : '-'} + + {summary.grandTotal > 0 ? `${summary.grandTotal.toFixed(2)} kg` : '-'} +
+
+ ); +} diff --git a/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx b/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx new file mode 100644 index 00000000..46fac236 --- /dev/null +++ b/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx @@ -0,0 +1,102 @@ +'use client'; + +/** + * 셔터박스 섹션 + * + * 방향별(양면/밑면/후면) 구성요소 + SVG 다이어그램 + * PHP에서는 GD로 동적 이미지 생성 → React에서는 source 이미지 + 치수 텍스트 오버레이 + */ + +import type { BendingInfoExtended, ShutterBoxData } from './types'; +import { buildShutterBoxRows, getBendingImageUrl, fmt, fmtWeight } from './utils'; + +interface ShutterBoxSectionProps { + bendingInfo: BendingInfoExtended; +} + +function ShutterBoxSubSection({ box, index }: { box: ShutterBoxData; index: number }) { + const rows = buildShutterBoxRows(box); + if (rows.length === 0) return null; + + const directionMap: Record = { + '양면': 'both', + '밑면': 'bottom', + '후면': 'rear', + }; + const imageType = directionMap[box.direction] || 'both'; + + return ( +
+
+ 3.{index + 1} 셔터박스 [{box.size}] {box.direction} +
+ +
+ {/* 좌측: 이미지 (source 이미지에 치수 오버레이) */} +
+
+ {`셔터박스 + {/* 치수 텍스트 오버레이 */} +
+ {box.size} ({box.direction}) +
+
+
+ 레일폭: {box.railWidth}mm +
+
+ + {/* 우측: 테이블 */} +
+ + + + + + + + + + + + + {rows.map((row, idx) => ( + + + + + + + + + ))} + +
구성요소재질길이/치수수량LOT NO무게(kg)
{row.partName}{row.material}{row.dimension}{fmt(row.quantity)} + {row.lotPrefix}- + {fmtWeight(row.weight)}
+
+
+
+ ); +} + +export function ShutterBoxSection({ bendingInfo }: ShutterBoxSectionProps) { + const boxes = bendingInfo.shutterBox; + if (!boxes || boxes.length === 0) return null; + + return ( +
+
+ 3. 셔터박스 +
+ + {boxes.map((box, idx) => ( + + ))} +
+ ); +} diff --git a/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx b/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx new file mode 100644 index 00000000..4e6ba5e3 --- /dev/null +++ b/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx @@ -0,0 +1,68 @@ +'use client'; + +/** + * 연기차단재 섹션 + * + * 레일용 [W50]: 길이별 수량 (2438, 3000, 3500, 4000, 4300mm) + * 케이스용 [W80]: 3000mm 고정 + * 재질: 모두 EGI 0.8T, 폭 26mm + */ + +import type { BendingInfoExtended } from './types'; +import { buildSmokeBarrierRows, getBendingImageUrl, fmt, fmtWeight } from './utils'; + +interface SmokeBarrierSectionProps { + bendingInfo: BendingInfoExtended; +} + +export function SmokeBarrierSection({ bendingInfo }: SmokeBarrierSectionProps) { + const rows = buildSmokeBarrierRows(bendingInfo.smokeBarrier); + if (rows.length === 0) return null; + + return ( +
+
+ 4. 연기차단재 +
+ +
+ {/* 좌측: 이미지 */} +
+ 연기차단재 +
+ + {/* 우측: 테이블 */} +
+ + + + + + + + + + + + + {rows.map((row, idx) => ( + + + + + + + + + ))} + +
파트재질길이수량LOT NO무게(kg)
{row.partName}{row.material}{fmt(row.length)}{fmt(row.quantity)}{row.lotCode}{fmtWeight(row.weight)}
+
+
+
+ ); +} diff --git a/src/components/production/WorkOrders/documents/bending/types.ts b/src/components/production/WorkOrders/documents/bending/types.ts new file mode 100644 index 00000000..91b171ca --- /dev/null +++ b/src/components/production/WorkOrders/documents/bending/types.ts @@ -0,0 +1,130 @@ +/** + * 절곡 작업일지 전용 타입 정의 + * + * PHP viewBendingWork_slat.php 기반 + * 가이드레일 / 하단마감재 / 셔터박스 / 연기차단재 4개 카테고리 + */ + +// 길이별 수량 데이터 +export interface LengthQuantity { + length: number; // mm (2438, 3000, 3500, 4000, 4300 등) + quantity: number; +} + +// 가이드레일 타입별 데이터 +export interface GuideRailTypeData { + lengthData: LengthQuantity[]; + baseSize: string; // "135*80" 또는 "135*130" +} + +// 셔터박스 데이터 +export interface ShutterBoxData { + size: string; // "500*380" 등 + direction: string; // "양면" | "밑면" | "후면" + railWidth: number; + frontBottom: number; + coverQty: number; // 상부덮개 수량 + finCoverQty: number; // 마구리 수량 + lengthData: LengthQuantity[]; +} + +// 확장된 bending_info 구조 (options JSON에 저장) +export interface BendingInfoExtended { + // 기존 필드 (유지) + drawingUrl?: string; + common: { + kind: string; // "혼합형 120X70" + type: string; // "혼합형(130*75)(130*125)" | "벽면형(130*75)" | "측면형(130*125)" + lengthQuantities: LengthQuantity[]; + }; + detailParts: Array<{ + partName: string; // "엘바", "하장바" + material: string; // "EGI 1.6T" + barcyInfo: string; // "16 I 75" + }>; + + // 신규 필드 + productCode: string; // "KTE01", "KQTS01", "KSE01", "KSS01", "KWE01" + finishMaterial: string; // "EGI마감", "SUS마감" + + guideRail: { + wall: GuideRailTypeData | null; + side: GuideRailTypeData | null; + }; + + bottomBar: { + material: string; // "EGI 1.55T" 또는 "SUS 1.5T" + extraFinish: string; // "SUS 1.2T" 또는 "없음" + length3000Qty: number; + length4000Qty: number; + }; + + shutterBox: ShutterBoxData[]; + + smokeBarrier: { + w50: LengthQuantity[]; // 레일용 W50 + w80Qty: number; // 케이스용 W80 수량 + }; +} + +// 재질 매핑 결과 +export interface MaterialMapping { + guideRailFinish: string; // ①②마감재 + bodyMaterial: string; // ③본체, ④C형, ⑤D형 + guideRailExtraFinish: string; // ⑥별도마감 (SUS마감 시만) + bottomBarFinish: string; // 하단마감재 + bottomBarExtraFinish: string; // 별도마감재 +} + +// 무게 계산 결과 +export interface WeightResult { + weight: number; // kg + type: 'SUS' | 'EGI'; +} + +// 가이드레일 파트 행 +export interface GuideRailPartRow { + partName: string; // ①②마감재, ③본체, ④C형, ⑤D형, ⑥별도마감, 하부BASE + lotPrefix: string; // XX, RT, RC, RD, RS (벽면) / SS, ST, SC, SD (측면) + material: string; // SUS 1.2T, EGI 1.55T + length: number; // mm + quantity: number; + weight: number; // kg +} + +// 하단마감재 파트 행 +export interface BottomBarPartRow { + partName: string; // ①하단마감재, ④별도마감재 + lotPrefix: string; // TE(EGI)/TS(SUS) + material: string; + length: number; // 3000 또는 4000 + quantity: number; + weight: number; +} + +// 셔터박스 구성요소 행 +export interface ShutterBoxPartRow { + partName: string; // ①전면부, ②린텔부 등 + lotPrefix: string; + material: string; // EGI 1.55T + dimension: string; // 길이 또는 치수 표시 + quantity: number; + weight: number; +} + +// 연기차단재 파트 행 +export interface SmokeBarrierPartRow { + partName: string; // 레일용 [W50], 케이스용 [W80] + material: string; // EGI 0.8T + length: number; + quantity: number; + weight: number; + lotCode: string; // GI-53, GI-54, GI-83, GI-84 +} + +// 생산량 합계 +export interface ProductionSummary { + susTotal: number; + egiTotal: number; + grandTotal: number; +} \ No newline at end of file diff --git a/src/components/production/WorkOrders/documents/bending/utils.ts b/src/components/production/WorkOrders/documents/bending/utils.ts new file mode 100644 index 00000000..946885a3 --- /dev/null +++ b/src/components/production/WorkOrders/documents/bending/utils.ts @@ -0,0 +1,567 @@ +/** + * 절곡 작업일지 유틸리티 함수 + * + * PHP viewBendingWork_slat.php의 calWeight, 재질매핑, 길이버킷팅 등을 TypeScript로 구현 + */ + +import type { + WeightResult, + MaterialMapping, + LengthQuantity, + GuideRailPartRow, + BottomBarPartRow, + ShutterBoxPartRow, + SmokeBarrierPartRow, + BendingInfoExtended, + ShutterBoxData, + ProductionSummary, +} from './types'; + +// ============================================================ +// 상수 +// ============================================================ + +const SUS_DENSITY = 7.93; // g/cm3 +const EGI_DENSITY = 7.85; // g/cm3 + +// 가이드레일 +const WALL_PART_WIDTH = 412; // mm - 벽면형 파트 폭 +const SIDE_PART_WIDTH = 462; // mm - 측면형 파트 폭 +const WALL_BASE_HEIGHT_MIXED = 80; // 혼합형 벽면 하부BASE 높이 +const WALL_BASE_HEIGHT_ONLY = 130; // 벽면형 단독 하부BASE 높이 +const SIDE_BASE_HEIGHT = 130; // 측면형 하부BASE 높이 +const BASE_WIDTH = 135; // 하부BASE 폭 + +// 하단마감재 +const BOTTOM_BAR_WIDTH = 184; // mm +const EXTRA_FINISH_WIDTH = 238; // mm + +// 연기차단재 +const SMOKE_BARRIER_WIDTH = 26; // mm + +// 셔터박스 +const BOX_FINISH_MATERIAL = 'EGI 1.55T'; +const BOX_COVER_LENGTH = 1219; // 상부덮개 고정 길이 + +// 길이 버킷 +const GUIDE_RAIL_LENGTH_BUCKETS = [2438, 3000, 3500, 4000, 4300]; +const SHUTTER_BOX_LENGTH_BUCKETS = [1219, 2438, 3000, 3500, 4000, 4150]; +const SMOKE_W50_LENGTH_BUCKETS = [2438, 3000, 3500, 4000, 4300]; + +// ============================================================ +// 핵심 함수: calWeight (PHP Lines 27-55) +// ============================================================ + +/** + * 무게 계산 + * volume_cm3 = (thickness * width * height) / 1000 + * weight_kg = (volume_cm3 * density) / 1000 + */ +export function calcWeight(material: string, width: number, height: number): WeightResult { + const thicknessMatch = material.match(/(\d+(\.\d+)?)/); + const thickness = thicknessMatch ? parseFloat(thicknessMatch[1]) : 0; + const isSUS = material.toUpperCase().includes('SUS'); + const density = isSUS ? SUS_DENSITY : EGI_DENSITY; + const volume_cm3 = (thickness * width * height) / 1000; + const weight_kg = (volume_cm3 * density) / 1000; + return { + weight: Math.round(weight_kg * 100) / 100, + type: isSUS ? 'SUS' : 'EGI', + }; +} + +// ============================================================ +// 제품코드별 재질 매핑 (PHP Lines 330-366) +// ============================================================ + +export function getMaterialMapping(productCode: string, finishMaterial: string): MaterialMapping { + // Group 1: KQTS01 - SUS 마감 + if (productCode === 'KQTS01') { + return { + guideRailFinish: 'SUS 1.2T', + bodyMaterial: 'EGI 1.55T', + guideRailExtraFinish: '', + bottomBarFinish: 'SUS 1.5T', + bottomBarExtraFinish: '없음', + }; + } + // Group 2: KTE01 - 마감유형에 따라 분기 + if (productCode === 'KTE01') { + const 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' : '없음', + }; + } + // 기타 제품코드 (KSE01, KSS01, KSS02, KWE01 등) - EGI 기본 + return { + guideRailFinish: 'EGI 1.55T', + bodyMaterial: 'EGI 1.55T', + guideRailExtraFinish: '', + bottomBarFinish: 'EGI 1.55T', + bottomBarExtraFinish: '없음', + }; +} + +// ============================================================ +// 이미지 URL 빌더 +// ============================================================ + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://api.sam.kr'; + +export function getBendingImageUrl( + category: 'guiderail' | 'bottombar' | 'smokebarrier' | 'box', + productCode: string, + type?: 'wall' | 'side' | 'both' | 'bottom' | 'rear', +): string { + switch (category) { + case 'guiderail': { + const size = ['KQTS01', 'KTE01'].includes(productCode) + ? (type === 'wall' ? '130x75' : '130x125') + : (type === 'wall' ? '120x70' : '120x120'); + return `${API_BASE}/images/bending/guiderail/guiderail_${productCode}_${type}_${size}.jpg`; + } + case 'bottombar': + return `${API_BASE}/images/bending/bottombar/bottombar_${productCode}.jpg`; + case 'smokebarrier': + return `${API_BASE}/images/bending/part/smokeban.jpg`; + case 'box': + return `${API_BASE}/images/bending/box/box_${type || 'both'}.jpg`; + default: + return ''; + } +} + +// ============================================================ +// getSLengthCode (PHP Lines 56-100) +// ============================================================ + +export function getSLengthCode(length: number, category: string): string | null { + if (category === '연기차단재50') { + return length === 3000 ? '53' : length === 4000 ? '54' : null; + } + if (category === '연기차단재80') { + return length === 3000 ? '83' : length === 4000 ? '84' : null; + } + const map: Record = { + 1219: '12', 2438: '24', 3000: '30', 3500: '35', + 4000: '40', 4150: '41', 4200: '42', 4300: '43', + }; + return map[length] || null; +} + +// ============================================================ +// 가이드레일 파트 행 생성 +// ============================================================ + +/** + * 벽면형 가이드레일 파트 행 생성 + * 파트: ①②마감재, ③본체, ④C형, ⑤D형, (⑥별도마감), 하부BASE + */ +export function buildWallGuideRailRows( + lengthData: LengthQuantity[], + baseSize: string, + mapping: MaterialMapping, +): GuideRailPartRow[] { + const rows: GuideRailPartRow[] = []; + const baseHeight = baseSize === '135*80' ? WALL_BASE_HEIGHT_MIXED : WALL_BASE_HEIGHT_ONLY; + + for (const ld of lengthData) { + if (ld.quantity <= 0) continue; + + // ①②마감재 + const finishW = calcWeight(mapping.guideRailFinish, WALL_PART_WIDTH, ld.length); + rows.push({ + partName: '①②마감재', lotPrefix: 'XX', material: mapping.guideRailFinish, + length: ld.length, quantity: ld.quantity, weight: Math.round(finishW.weight * ld.quantity * 100) / 100, + }); + + // ③본체 + const bodyW = calcWeight(mapping.bodyMaterial, WALL_PART_WIDTH, ld.length); + rows.push({ + partName: '③본체', lotPrefix: 'RT', material: mapping.bodyMaterial, + length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100, + }); + + // ④C형 + rows.push({ + partName: '④C형', lotPrefix: 'RC', material: mapping.bodyMaterial, + length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100, + }); + + // ⑤D형 + rows.push({ + partName: '⑤D형', lotPrefix: 'RD', material: mapping.bodyMaterial, + length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100, + }); + + // ⑥별도마감 (SUS마감 시만) + if (mapping.guideRailExtraFinish) { + const extraW = calcWeight(mapping.guideRailExtraFinish, WALL_PART_WIDTH, ld.length); + rows.push({ + partName: '⑥별도마감', lotPrefix: 'RS', material: mapping.guideRailExtraFinish, + length: ld.length, quantity: ld.quantity, weight: Math.round(extraW.weight * ld.quantity * 100) / 100, + }); + } + } + + // 하부BASE (길이 데이터와 무관하게 1행) + const totalQty = lengthData.reduce((sum, ld) => sum + ld.quantity, 0); + if (totalQty > 0) { + const baseW = calcWeight('EGI 1.55T', BASE_WIDTH, baseHeight); + rows.push({ + partName: '하부BASE', lotPrefix: 'XX', material: 'EGI 1.55T', + length: 0, quantity: totalQty, weight: Math.round(baseW.weight * totalQty * 100) / 100, + }); + } + + return rows; +} + +/** + * 측면형 가이드레일 파트 행 생성 + */ +export function buildSideGuideRailRows( + lengthData: LengthQuantity[], + mapping: MaterialMapping, +): GuideRailPartRow[] { + const rows: GuideRailPartRow[] = []; + + for (const ld of lengthData) { + if (ld.quantity <= 0) continue; + + const finishW = calcWeight(mapping.guideRailFinish, SIDE_PART_WIDTH, ld.length); + rows.push({ + partName: '①②마감재', lotPrefix: 'SS', material: mapping.guideRailFinish, + length: ld.length, quantity: ld.quantity, weight: Math.round(finishW.weight * ld.quantity * 100) / 100, + }); + + const bodyW = calcWeight(mapping.bodyMaterial, SIDE_PART_WIDTH, ld.length); + rows.push({ + partName: '③본체', lotPrefix: 'ST', material: mapping.bodyMaterial, + length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100, + }); + + rows.push({ + partName: '④C형', lotPrefix: 'SC', material: mapping.bodyMaterial, + length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100, + }); + + rows.push({ + partName: '⑤D형', lotPrefix: 'SD', material: mapping.bodyMaterial, + length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100, + }); + } + + // 하부BASE + const totalQty = lengthData.reduce((sum, ld) => sum + ld.quantity, 0); + if (totalQty > 0) { + const baseW = calcWeight('EGI 1.55T', BASE_WIDTH, SIDE_BASE_HEIGHT); + rows.push({ + partName: '하부BASE', lotPrefix: 'XX', material: 'EGI 1.55T', + length: 0, quantity: totalQty, weight: Math.round(baseW.weight * totalQty * 100) / 100, + }); + } + + return rows; +} + +// ============================================================ +// 하단마감재 파트 행 생성 +// ============================================================ + +export function buildBottomBarRows( + bottomBar: BendingInfoExtended['bottomBar'], + mapping: MaterialMapping, +): BottomBarPartRow[] { + const rows: BottomBarPartRow[] = []; + const lotPrefix = mapping.bottomBarFinish.includes('SUS') ? 'TS' : 'TE'; + + // ①하단마감재 - 3000mm + if (bottomBar.length3000Qty > 0) { + const w = calcWeight(mapping.bottomBarFinish, BOTTOM_BAR_WIDTH, 3000); + rows.push({ + partName: '①하단마감재', lotPrefix, material: mapping.bottomBarFinish, + length: 3000, quantity: bottomBar.length3000Qty, + weight: Math.round(w.weight * bottomBar.length3000Qty * 100) / 100, + }); + } + + // ①하단마감재 - 4000mm + if (bottomBar.length4000Qty > 0) { + const w = calcWeight(mapping.bottomBarFinish, BOTTOM_BAR_WIDTH, 4000); + rows.push({ + partName: '①하단마감재', lotPrefix, material: mapping.bottomBarFinish, + length: 4000, quantity: bottomBar.length4000Qty, + weight: Math.round(w.weight * bottomBar.length4000Qty * 100) / 100, + }); + } + + // ④별도마감재 (extraFinish !== '없음' 일 때만) + if (mapping.bottomBarExtraFinish !== '없음' && mapping.bottomBarExtraFinish) { + const extraLotPrefix = mapping.bottomBarExtraFinish.includes('SUS') ? 'TS' : 'TE'; + + if (bottomBar.length3000Qty > 0) { + const w = calcWeight(mapping.bottomBarExtraFinish, EXTRA_FINISH_WIDTH, 3000); + rows.push({ + partName: '④별도마감재', lotPrefix: extraLotPrefix, material: mapping.bottomBarExtraFinish, + length: 3000, quantity: bottomBar.length3000Qty, + weight: Math.round(w.weight * bottomBar.length3000Qty * 100) / 100, + }); + } + if (bottomBar.length4000Qty > 0) { + const w = calcWeight(mapping.bottomBarExtraFinish, EXTRA_FINISH_WIDTH, 4000); + rows.push({ + partName: '④별도마감재', lotPrefix: extraLotPrefix, material: mapping.bottomBarExtraFinish, + length: 4000, quantity: bottomBar.length4000Qty, + weight: Math.round(w.weight * bottomBar.length4000Qty * 100) / 100, + }); + } + } + + return rows; +} + +// ============================================================ +// 셔터박스 구성요소 행 생성 (방향별) +// ============================================================ + +function parseBoxSize(size: string): { width: number; height: number } { + const parts = size.split('*').map(Number); + return { width: parts[0] || 500, height: parts[1] || 380 }; +} + +export function buildShutterBoxRows(box: ShutterBoxData): ShutterBoxPartRow[] { + const rows: ShutterBoxPartRow[] = []; + const { width: boxWidth, height: boxHeight } = parseBoxSize(box.size); + const isStandard = box.size === '500*380'; + + for (const ld of box.lengthData) { + if (ld.quantity <= 0) continue; + + if (isStandard) { + // 표준 500*380 구성 + const parts = [ + { name: '①전면부', prefix: 'CF', dim: boxHeight + 122 }, + { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 }, + { name: '③⑤점검구', prefix: 'CP', dim: boxWidth - 200 }, + { name: '④후면코너부', prefix: 'CB', dim: 170 }, + ]; + for (const p of parts) { + const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length); + rows.push({ + partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL, + dimension: `${ld.length}`, quantity: ld.quantity, + weight: Math.round(w.weight * ld.quantity * 100) / 100, + }); + } + } else if (box.direction === '양면') { + const parts = [ + { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 }, + { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 }, + { name: '③점검구', prefix: 'XX', dim: boxWidth - 200 }, + { name: '④후면코너부', prefix: 'CB', dim: 170 }, + { name: '⑤점검구', prefix: 'XX', dim: boxHeight - 100 }, + ]; + for (const p of parts) { + const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length); + rows.push({ + partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL, + dimension: `${ld.length}`, quantity: ld.quantity, + weight: Math.round(w.weight * ld.quantity * 100) / 100, + }); + } + } else if (box.direction === '밑면') { + const parts = [ + { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 }, + { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 }, + { name: '③점검구', prefix: 'XX', dim: boxWidth - 200 }, + { name: '④후면부', prefix: 'CB', dim: boxHeight + 85 * 2 }, + ]; + for (const p of parts) { + const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length); + rows.push({ + partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL, + dimension: `${ld.length}`, quantity: ld.quantity, + weight: Math.round(w.weight * ld.quantity * 100) / 100, + }); + } + } else if (box.direction === '후면') { + const parts = [ + { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 }, + { name: '②린텔부', prefix: 'CL', dim: boxWidth + 85 * 2 }, + { name: '③점검구', prefix: 'XX', dim: boxHeight - 200 }, + { name: '④후면코너부', prefix: 'CB', dim: boxHeight + 85 * 2 }, + ]; + for (const p of parts) { + const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length); + rows.push({ + partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL, + dimension: `${ld.length}`, quantity: ld.quantity, + weight: Math.round(w.weight * ld.quantity * 100) / 100, + }); + } + } + } + + // 상부덮개 (비표준일 때) + if (!isStandard && box.coverQty > 0) { + const coverWidth = boxWidth - 111; + const w = calcWeight(BOX_FINISH_MATERIAL, coverWidth, BOX_COVER_LENGTH); + rows.push({ + partName: '⑥상부덮개', lotPrefix: 'XX', material: BOX_FINISH_MATERIAL, + dimension: `1219 * ${coverWidth}`, quantity: box.coverQty, + weight: Math.round(w.weight * box.coverQty * 100) / 100, + }); + } + + // 마구리 (비표준일 때) + if (!isStandard && box.finCoverQty > 0) { + const finWidth = boxWidth + 5; + const finHeight = boxHeight + 5; + const w = calcWeight(BOX_FINISH_MATERIAL, finWidth, finHeight); + rows.push({ + partName: '⑦측면부(마구리)', lotPrefix: 'XX', material: BOX_FINISH_MATERIAL, + dimension: `${finWidth} * ${finHeight}`, quantity: box.finCoverQty, + weight: Math.round(w.weight * box.finCoverQty * 100) / 100, + }); + } + + return rows; +} + +// ============================================================ +// 연기차단재 파트 행 생성 +// ============================================================ + +export function buildSmokeBarrierRows( + smokeBarrier: BendingInfoExtended['smokeBarrier'], +): SmokeBarrierPartRow[] { + const rows: SmokeBarrierPartRow[] = []; + + // 레일용 W50 + for (const ld of smokeBarrier.w50) { + if (ld.quantity <= 0) continue; + const w = calcWeight('EGI 0.8T', SMOKE_BARRIER_WIDTH, ld.length); + const code = getSLengthCode(ld.length, '연기차단재50'); + rows.push({ + partName: '레일용 [W50]', material: 'EGI 0.8T', + length: ld.length, quantity: ld.quantity, + weight: Math.round(w.weight * ld.quantity * 100) / 100, + lotCode: code ? `GI-${code}` : 'GI', + }); + } + + // 케이스용 W80 + if (smokeBarrier.w80Qty > 0) { + const w = calcWeight('EGI 0.8T', SMOKE_BARRIER_WIDTH, 3000); + const code = getSLengthCode(3000, '연기차단재80'); + rows.push({ + partName: '케이스용 [W80]', material: 'EGI 0.8T', + length: 3000, quantity: smokeBarrier.w80Qty, + weight: Math.round(w.weight * smokeBarrier.w80Qty * 100) / 100, + lotCode: code ? `GI-${code}` : 'GI', + }); + } + + return rows; +} + +// ============================================================ +// 생산량 합계 계산 +// ============================================================ + +export function calculateProductionSummary( + bendingInfo: BendingInfoExtended, + mapping: MaterialMapping, +): ProductionSummary { + let susTotal = 0; + let egiTotal = 0; + + function addWeight(material: string, width: number, height: number, qty: number) { + if (qty <= 0) return; + const { weight, type } = calcWeight(material, width, height); + const total = weight * qty; + if (type === 'SUS') susTotal += total; + else egiTotal += total; + } + + // 가이드레일 - 벽면형 + if (bendingInfo.guideRail.wall) { + const baseHeight = bendingInfo.guideRail.wall.baseSize === '135*80' + ? WALL_BASE_HEIGHT_MIXED : WALL_BASE_HEIGHT_ONLY; + for (const ld of bendingInfo.guideRail.wall.lengthData) { + if (ld.quantity <= 0) continue; + addWeight(mapping.guideRailFinish, WALL_PART_WIDTH, ld.length, ld.quantity); + addWeight(mapping.bodyMaterial, WALL_PART_WIDTH, ld.length, ld.quantity * 3); // 본체+C형+D형 + if (mapping.guideRailExtraFinish) { + addWeight(mapping.guideRailExtraFinish, WALL_PART_WIDTH, ld.length, ld.quantity); + } + } + const totalWallQty = bendingInfo.guideRail.wall.lengthData.reduce((s, l) => s + l.quantity, 0); + addWeight('EGI 1.55T', BASE_WIDTH, baseHeight, totalWallQty); + } + + // 가이드레일 - 측면형 + if (bendingInfo.guideRail.side) { + for (const ld of bendingInfo.guideRail.side.lengthData) { + if (ld.quantity <= 0) continue; + addWeight(mapping.guideRailFinish, SIDE_PART_WIDTH, ld.length, ld.quantity); + addWeight(mapping.bodyMaterial, SIDE_PART_WIDTH, ld.length, ld.quantity * 3); + } + const totalSideQty = bendingInfo.guideRail.side.lengthData.reduce((s, l) => s + l.quantity, 0); + addWeight('EGI 1.55T', BASE_WIDTH, SIDE_BASE_HEIGHT, totalSideQty); + } + + // 하단마감재 + if (bendingInfo.bottomBar.length3000Qty > 0) { + addWeight(mapping.bottomBarFinish, BOTTOM_BAR_WIDTH, 3000, bendingInfo.bottomBar.length3000Qty); + if (mapping.bottomBarExtraFinish !== '없음' && mapping.bottomBarExtraFinish) { + addWeight(mapping.bottomBarExtraFinish, EXTRA_FINISH_WIDTH, 3000, bendingInfo.bottomBar.length3000Qty); + } + } + if (bendingInfo.bottomBar.length4000Qty > 0) { + addWeight(mapping.bottomBarFinish, BOTTOM_BAR_WIDTH, 4000, bendingInfo.bottomBar.length4000Qty); + if (mapping.bottomBarExtraFinish !== '없음' && mapping.bottomBarExtraFinish) { + addWeight(mapping.bottomBarExtraFinish, EXTRA_FINISH_WIDTH, 4000, bendingInfo.bottomBar.length4000Qty); + } + } + + // 셔터박스 + for (const box of bendingInfo.shutterBox) { + const boxRows = buildShutterBoxRows(box); + for (const row of boxRows) { + if (row.weight > 0) egiTotal += row.weight; // 셔터박스는 항상 EGI + } + } + + // 연기차단재 + const smokeRows = buildSmokeBarrierRows(bendingInfo.smokeBarrier); + for (const row of smokeRows) { + if (row.weight > 0) egiTotal += row.weight; // 연기차단재는 항상 EGI + } + + susTotal = Math.round(susTotal * 100) / 100; + egiTotal = Math.round(egiTotal * 100) / 100; + + return { + susTotal, + egiTotal, + grandTotal: Math.round((susTotal + egiTotal) * 100) / 100, + }; +} + +// ============================================================ +// 숫자 포맷 헬퍼 +// ============================================================ + +export function fmt(v?: number): string { + return v != null && v > 0 ? v.toLocaleString() : '-'; +} + +export function fmtWeight(v: number): string { + return v > 0 ? v.toFixed(2) : '-'; +} diff --git a/src/components/production/WorkOrders/types.ts b/src/components/production/WorkOrders/types.ts index 40202016..caa1bf06 100644 --- a/src/components/production/WorkOrders/types.ts +++ b/src/components/production/WorkOrders/types.ts @@ -119,6 +119,8 @@ export interface WorkOrderItem { orderNodeId: number | null; // 개소 ID orderNodeName: string; // 개소명 slatInfo?: { length: number; slatCount: number; jointBar: number; glassQty: number }; // 슬랫 공정 정보 + bendingInfo?: Record; // 절곡 공정 정보 (bending_info JSON) + materialInputLots?: string[]; // 개소별 투입자재 LOT 번호 목록 } // 전개도 상세 (절곡용) @@ -272,6 +274,13 @@ export interface WorkOrderItemApi { code: string; } | null; } | null; + material_inputs?: Array<{ + id: number; + stock_lot_id: number; + item_id: number; + qty: number; + stock_lot?: { id: number; lot_no: string }; + }>; } // API 응답 - 벤딩 상세 @@ -473,6 +482,15 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder { const si = item.options.slat_info as { length?: number; slat_count?: number; joint_bar?: number; glass_qty?: number }; return { length: si.length || 0, slatCount: si.slat_count || 0, jointBar: si.joint_bar || 0, glassQty: si.glass_qty || 0 }; })() : undefined, + bendingInfo: item.options?.bending_info + ? (item.options.bending_info as Record) + : undefined, + materialInputLots: (() => { + const inputs = item.material_inputs as Array<{ stock_lot?: { lot_no?: string } }> | undefined; + if (!inputs || inputs.length === 0) return undefined; + const lots = inputs.map(mi => mi.stock_lot?.lot_no).filter((v): v is string => !!v); + return lots.length > 0 ? [...new Set(lots)] : undefined; + })(), })), bendingDetails: api.bending_detail ? transformBendingDetail(api.bending_detail) : undefined, issues: (api.issues || []).map(issue => ({