From b9f0e24950a382948e96472f73c551630d269b73 Mon Sep 17 00:00:00 2001 From: kent Date: Mon, 12 Jan 2026 17:19:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EC=83=9D=EC=82=B0=EC=A7=80?= =?UTF-8?q?=EC=8B=9C=20=EA=B3=B5=EC=A0=95=EA=B4=80=EB=A6=AC=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EB=B0=8F=20=EA=B2=AC=EC=A0=81=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 생산지시 페이지에 공정관리 API 연동 - getProcessList API로 사용중 공정 목록 로드 - 품목-공정 매칭 함수 추가 (classificationRules 기반) - 하드코딩된 DEFAULT_PROCESSES 제거, API 데이터로 대체 - workSteps 없을 시 안내 메시지 표시 - 수주 등록 시 quote_id 미전달 버그 수정 - transformFrontendToApi에 quote_id 변환 로직 추가 - 견적 선택 후 수주 등록 시 견적번호 정상 표시 --- .../[id]/production-order/page.tsx | 534 +++++++----------- src/components/orders/actions.ts | 121 ++-- 2 files changed, 281 insertions(+), 374 deletions(-) diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx index 141a8817..5de99505 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx @@ -47,6 +47,8 @@ import { type Order, type CreateProductionOrderData, } from "@/components/orders/actions"; +import { getProcessList } from "@/components/process-management/actions"; +import type { Process } from "@/types/process"; import { formatAmount } from "@/utils/formatAmount"; // 수주 정보 타입 @@ -56,7 +58,6 @@ interface OrderInfo { siteName: string; dueDate: string; itemCount: number; - totalQuantity: string; creditGrade: string; status: string; } @@ -92,20 +93,17 @@ interface MaterialRequirement { status: "sufficient" | "insufficient"; } -// 스크린 품목 상세 타입 +// 스크린 품목 상세 타입 (order.items에서 변환) interface ScreenItemDetail { no: number; itemName: string; - location: string; - openWidth: number; - openHeight: number; - productWidth: number; - productHeight: number; - guideRail: string; - shaft: string; - capacity: string; - finish: string; + specification: string; quantity: number; + unit: string; + unitPrice: number; + supplyAmount: number; + taxAmount: number; + totalAmount: number; } // 가이드레일 BOM 타입 @@ -208,137 +206,94 @@ const STATUS_LABELS: Record = { cancelled: "취소", }; -// 샘플 작업지시 카드 -const SAMPLE_WORK_ORDER_CARDS: WorkOrderCard[] = [ - { - id: "1", - type: "스크린", - orderNumber: "KD-PL-251223-01", - itemCount: 3, - totalQuantity: "3EA", - processes: ["1. 원단절단", "2. 미싱", "3. 앤드락작업", "4. 중간검사", "5. 포장"], - }, - { - id: "2", - type: "절곡", - orderNumber: "KD-PL-251223-02", - itemCount: 3, - totalQuantity: "3EA", - processes: ["1. 절단", "2. 절곡", "3. 중간검사", "4. 포장"], - }, -]; +// 품목과 공정 매칭 함수 +function matchItemToProcess( + itemName: string, + itemCode: string | undefined, + processes: Process[] +): Process | null { + for (const process of processes) { + for (const rule of process.classificationRules) { + if (!rule.isActive) continue; -// 샘플 자재 소요량 -const SAMPLE_MATERIALS: MaterialRequirement[] = [ - { - materialCode: "SCR-MAT-001", - materialName: "스크린 원단", - unit: "㎡", - required: 45, - currentStock: 500, - status: "sufficient", - }, - { - materialCode: "SCR-MAT-002", - materialName: "앤드락", - unit: "EA", - required: 6, - currentStock: 800, - status: "sufficient", - }, - { - materialCode: "BND-MAT-001", - materialName: "철판", - unit: "KG", - required: 90, - currentStock: 2000, - status: "sufficient", - }, - { - materialCode: "BND-MAT-002", - materialName: "가이드레일", - unit: "M", - required: 18, - currentStock: 300, - status: "sufficient", - }, - { - materialCode: "BND-MAT-003", - materialName: "케이스", - unit: "EA", - required: 3, - currentStock: 100, - status: "sufficient", - }, -]; + // 패턴 매칭 규칙 + if (rule.registrationType === "pattern") { + let targetValue = ""; + if (rule.ruleType === "품목명") { + targetValue = itemName; + } else if (rule.ruleType === "품목코드" && itemCode) { + targetValue = itemCode; + } -// 샘플 스크린 품목 상세 -const SAMPLE_SCREEN_ITEMS: ScreenItemDetail[] = [ - { - no: 1, - itemName: "스크린 셔터 (프리미엄)", - location: "로비 I-01", - openWidth: 4500, - openHeight: 3500, - productWidth: 4640, - productHeight: 3900, - guideRail: "백면형 120-70", - shaft: '4"', - capacity: "160kg", - finish: "SUS마감", - quantity: 1, - }, - { - no: 2, - itemName: "스크린 셔터 (프리미엄)", - location: "카페 I-02", - openWidth: 4500, - openHeight: 3500, - productWidth: 4640, - productHeight: 3900, - guideRail: "백면형 120-70", - shaft: '4"', - capacity: "160kg", - finish: "SUS마감", - quantity: 1, - }, - { - no: 3, - itemName: "스크린 셔터 (프리미엄)", - location: "헬스장 I-03", - openWidth: 4500, - openHeight: 3500, - productWidth: 4640, - productHeight: 3900, - guideRail: "백면형 120-70", - shaft: '4"', - capacity: "160kg", - finish: "SUS마감", - quantity: 1, - }, -]; + if (!targetValue) continue; -// 샘플 가이드레일 BOM -const SAMPLE_GUIDE_RAIL_BOM: GuideRailBom[] = [ - { - type: "백면형", - spec: "120-70", - code: "KSE01/KWE01", - length: 3000, - quantity: 6, - }, -]; + let matched = false; + switch (rule.matchingType) { + case "startsWith": + matched = targetValue.startsWith(rule.conditionValue); + break; + case "endsWith": + matched = targetValue.endsWith(rule.conditionValue); + break; + case "contains": + matched = targetValue.includes(rule.conditionValue); + break; + case "equals": + matched = targetValue === rule.conditionValue; + break; + } -// 샘플 케이스(셔터박스) BOM -const SAMPLE_CASE_BOM: CaseBom[] = [ - { item: "케이스 본체", length: "L: 4000", quantity: 2 }, - { item: "측면 덮개", length: "500-355", quantity: 6 }, -]; + if (matched) return process; + } + } + } -// 샘플 하단 마감재 BOM -const SAMPLE_BOTTOM_FINISH_BOM: BottomFinishBom[] = [ - { item: "하단마감재", spec: "50-40", length: "L: 4000", quantity: 3 }, -]; + // 매칭되는 공정이 없으면 공정명으로 단순 매칭 시도 + // (예: 품목명에 "스크린"이 포함되면 "스크린" 공정 반환) + for (const process of processes) { + if (itemName.toLowerCase().includes(process.processName.toLowerCase())) { + return process; + } + } + + return null; +} + +// 수주 품목들에서 매칭되는 공정의 workSteps 추출 +function getWorkStepsForOrder( + items: Array<{ itemName: string; itemCode?: string }>, + processes: Process[] +): string[] { + // 첫 번째 품목으로 공정 매칭 시도 + if (items.length > 0 && processes.length > 0) { + const firstItem = items[0]; + const matchedProcess = matchItemToProcess( + firstItem.itemName, + firstItem.itemCode, + processes + ); + + if (matchedProcess && matchedProcess.workSteps.length > 0) { + // workSteps에 번호 추가하여 반환 + return matchedProcess.workSteps.map( + (step, idx) => `${idx + 1}. ${step}` + ); + } + } + + // 매칭된 공정이 없거나 workSteps가 없으면 첫 번째 공정의 workSteps 사용 + if (processes.length > 0) { + const firstProcess = processes[0]; + if (firstProcess.workSteps.length > 0) { + return firstProcess.workSteps.map( + (step, idx) => `${idx + 1}. ${step}` + ); + } + } + + // 공정이 없으면 빈 배열 반환 + return []; +} export default function ProductionOrderCreatePage() { const router = useRouter(); @@ -348,6 +303,7 @@ export default function ProductionOrderCreatePage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [order, setOrder] = useState(null); + const [processes, setProcesses] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); // 우선순위 상태 @@ -359,16 +315,25 @@ export default function ProductionOrderCreatePage() { const [generatedOrderNumber, setGeneratedOrderNumber] = useState(""); const [generatedWorkOrderId, setGeneratedWorkOrderId] = useState(null); - // 수주 데이터 로드 - const fetchOrder = useCallback(async () => { + // 수주 데이터 및 공정 목록 로드 + const fetchData = useCallback(async () => { setLoading(true); setError(null); try { - const result = await getOrderById(orderId); - if (result.success && result.data) { - setOrder(result.data); + // 수주 정보와 공정 목록을 병렬로 로드 + const [orderResult, processResult] = await Promise.all([ + getOrderById(orderId), + getProcessList({ status: "사용중" }), + ]); + + if (orderResult.success && orderResult.data) { + setOrder(orderResult.data); } else { - setError(result.error || "수주 정보 조회에 실패했습니다."); + setError(orderResult.error || "수주 정보 조회에 실패했습니다."); + } + + if (processResult.success && processResult.data) { + setProcesses(processResult.data.items); } } catch { setError("서버 오류가 발생했습니다."); @@ -378,8 +343,8 @@ export default function ProductionOrderCreatePage() { }, [orderId]); useEffect(() => { - fetchOrder(); - }, [fetchOrder]); + fetchData(); + }, [fetchData]); const handleCancel = () => { router.push(`/ko/sales/order-management-sales/${orderId}`); @@ -467,7 +432,20 @@ export default function ProductionOrderCreatePage() { } const selectedConfig = getSelectedPriorityConfig(); - const workOrderCount = SAMPLE_WORK_ORDER_CARDS.length; + const workOrderCount = 1; // 현재는 수주당 하나의 작업지시 생성 + + // order.items에서 스크린 품목 상세 데이터 변환 + const screenItems: ScreenItemDetail[] = (order.items || []).map((item, index) => ({ + no: item.serialNo || index + 1, + itemName: item.itemName, + specification: item.specification || "-", + quantity: item.quantity, + unit: item.unit || "EA", + unitPrice: item.unitPrice, + supplyAmount: item.supplyAmount, + taxAmount: item.taxAmount, + totalAmount: item.totalAmount, + })); // Order에서 UI에 표시할 데이터 변환 const orderInfo = { @@ -476,7 +454,6 @@ export default function ProductionOrderCreatePage() { siteName: order.siteName, dueDate: order.expectedShipDate || "-", itemCount: order.itemCount, - totalQuantity: `${order.itemCount}EA`, creditGrade: "B (관리)", // API에서 제공하지 않아 기본값 사용 status: STATUS_LABELS[order.status] || order.status, amount: order.amount, @@ -537,10 +514,6 @@ export default function ProductionOrderCreatePage() {

품목 수

{orderInfo.itemCount}건

-
-

총수량

-

{orderInfo.totalQuantity}

-

신용등급

@@ -662,98 +635,63 @@ export default function ProductionOrderCreatePage() {
- {SAMPLE_WORK_ORDER_CARDS.map((card) => ( -
-
- - {card.type} - - {card.orderNumber} -
-
-
-

품목 수

-

{card.itemCount}건

-
-
-

총 수량

-

{card.totalQuantity}

-
-
-
-

공정 순서

-
- {card.processes.map((process, idx) => ( + {/* 수주 데이터 기반 작업지시 카드 */} +
+
+ + 스크린 + + {order.lotNumber} +
+
+

품목 수

+

{screenItems.length}건

+
+
+

공정 순서

+
+ {(() => { + const workSteps = getWorkStepsForOrder( + (order.items || []).map(item => ({ + itemName: item.itemName, + itemCode: item.itemCode, + })), + processes + ); + + if (workSteps.length === 0) { + return ( + + 공정관리에서 세부 작업단계를 등록해 주세요. + + ); + } + + return workSteps.map((step, idx) => ( - {process} + {step} - ))} -
+ )); + })()}
- ))} +
- {/* 자재 소요량 및 재고 현황 */} + {/* 자재 소요량 및 재고 현황 - 추후 BOM API 연동 예정 */} 자재 소요량 및 재고 현황 -
- - - - 자재코드 - 자재명 - 단위 - 소요량 - 현재고 - 상태 - - - - {SAMPLE_MATERIALS.map((item) => ( - - - - {item.materialCode} - - - {item.materialName} - {item.unit} - {item.required} - {item.currentStock.toLocaleString()} - - - {item.status === "sufficient" ? "충분" : "부족"} - - - - ))} - -
+
+

BOM 데이터 연동 후 자재 소요량이 표시됩니다.

+

(추후 제공 예정)

@@ -761,7 +699,7 @@ export default function ProductionOrderCreatePage() { {/* 스크린 품목 상세 */} - 스크린 품목 상세 ({SAMPLE_SCREEN_ITEMS.length}건) + 스크린 품목 상세 ({screenItems.length}건)
@@ -770,37 +708,39 @@ export default function ProductionOrderCreatePage() { No 품목명 - 도면위치 - 개구폭 - 개구높이 - 제작폭 - 제작높이 - 가이드레일 - 샤프트 - 용량 - 마감 + 규격 수량 + 단위 + 단가 + 공급가 + 세액 + 합계 - {SAMPLE_SCREEN_ITEMS.map((item) => ( - - - {String(item.no).padStart(2, "0")} + {screenItems.length > 0 ? ( + screenItems.map((item) => ( + + + {String(item.no).padStart(2, "0")} + + {item.itemName} + {item.specification} + {item.quantity} + {item.unit} + {formatAmount(item.unitPrice)} + {formatAmount(item.supplyAmount)} + {formatAmount(item.taxAmount)} + {formatAmount(item.totalAmount)} + + )) + ) : ( + + + 품목 정보가 없습니다. - {item.itemName} - {item.location} - {item.openWidth.toLocaleString()} - {item.openHeight.toLocaleString()} - {item.productWidth.toLocaleString()} - {item.productHeight.toLocaleString()} - {item.guideRail} - {item.shaft} - {item.capacity} - {item.finish} - {item.quantity} - ))} + )}
@@ -832,91 +772,15 @@ export default function ProductionOrderCreatePage() {
- {/* 절곡물 BOM */} + {/* 절곡물 BOM - 추후 BOM API 연동 예정 */} 절곡물 BOM - - {/* 가이드레일 */} -
-

가이드레일

-
- - - - 형태 - 규격 - 코드 - 길이 - 수량 - - - - {SAMPLE_GUIDE_RAIL_BOM.map((item, index) => ( - - {item.type} - {item.spec} - {item.code} - {item.length.toLocaleString()} - {item.quantity} - - ))} - -
-
-
- - {/* 케이스(셔터박스) */} -
-

케이스(셔터박스) - 메인 규격: 500-330

-
- - - - 품목 - 길이 - 수량 - - - - {SAMPLE_CASE_BOM.map((item, index) => ( - - {item.item} - {item.length} - {item.quantity} - - ))} - -
-
-
- - {/* 하단 마감재 */} -
-

하단 마감재

-
- - - - 품목 - 규격 - 길이 - 수량 - - - - {SAMPLE_BOTTOM_FINISH_BOM.map((item, index) => ( - - {item.item} - {item.spec} - {item.length} - {item.quantity} - - ))} - -
-
+ +
+

BOM 데이터 연동 후 절곡물 정보가 표시됩니다.

+

(가이드레일, 케이스, 하단 마감재 - 추후 제공 예정)

@@ -943,7 +807,7 @@ export default function ProductionOrderCreatePage() {
diff --git a/src/components/orders/actions.ts b/src/components/orders/actions.ts index 79d3dffc..47751583 100644 --- a/src/components/orders/actions.ts +++ b/src/components/orders/actions.ts @@ -89,8 +89,7 @@ interface ApiQuoteForSelect { client?: { id: number; name: string; - grade?: string; - representative?: string; + contact_person?: string; // 담당자 phone?: string; } | null; items?: ApiQuoteItem[]; @@ -103,12 +102,16 @@ interface ApiQuoteItem { type_code?: string; symbol?: string; specification?: string; - quantity: number; + // QuoteItem 모델 필드명 (calculated_quantity, total_price) + calculated_quantity?: number; + quantity?: number; // fallback unit?: string; unit_price: number; - supply_amount: number; - tax_amount: number; - total_amount: number; + total_price?: number; + // 수주 품목에서 사용하는 필드명 + supply_amount?: number; + tax_amount?: number; + total_amount?: number; } interface ApiWorkOrder { @@ -327,7 +330,8 @@ export interface QuotationForSelect { id: string; quoteNumber: string; // KD-PR-XXXXXX-XX grade: string; // A(우량), B(관리), C(주의) - client: string; // 발주처 + clientId: string | null; // 발주처 ID + client: string; // 발주처명 siteName: string; // 현장명 amount: number; // 총 금액 itemCount: number; // 품목 수 @@ -399,7 +403,7 @@ function transformApiToFrontend(apiData: ApiOrder): Order { memo: apiData.memo ?? undefined, remarks: apiData.remarks ?? undefined, note: apiData.note ?? undefined, - items: apiData.items?.map(transformItemApiToFrontend), // 상세 페이지용 추가 필드 (API에서 매핑) + items: apiData.items?.map(transformItemApiToFrontend) || [], // 상세 페이지용 추가 필드 (API에서 매핑) manager: apiData.client?.representative ?? undefined, contact: apiData.client_contact ?? apiData.client?.phone ?? undefined, deliveryRequestDate: apiData.delivery_date ?? undefined, // delivery_date를 공유 @@ -435,33 +439,61 @@ function transformItemApiToFrontend(apiItem: ApiOrderItem): OrderItem { }; } -function transformFrontendToApi(data: OrderFormData): Record { +function transformFrontendToApi(data: OrderFormData | Record): Record { + // Handle both API OrderFormData and Registration form's OrderFormData + const formData = data as Record; + + // Get client_id - handle both string (form) and number (api) types + const clientId = formData.clientId; + const clientIdValue = clientId ? (typeof clientId === 'string' ? parseInt(clientId, 10) || null : clientId) : null; + + // Get items - handle both form's OrderItem[] and API's OrderItemFormData[] + const items = (formData.items as Array>) || []; + + // Get quote_id from selectedQuotation (견적에서 수주 생성 시) + const selectedQuotation = formData.selectedQuotation as { id?: string } | undefined; + const quoteIdValue = selectedQuotation?.id ? parseInt(selectedQuotation.id, 10) || null : null; + return { - order_type_code: data.orderTypeCode || 'ORDER', - category_code: data.categoryCode || null, - client_id: data.clientId || null, - client_name: data.clientName || null, - client_contact: data.clientContact || null, - site_name: data.siteName || null, - supply_amount: data.supplyAmount || 0, - tax_amount: data.taxAmount || 0, - total_amount: data.totalAmount || 0, - discount_rate: data.discountRate || 0, - discount_amount: data.discountAmount || 0, - delivery_date: data.deliveryDate || null, - delivery_method_code: data.deliveryMethodCode || null, - received_at: data.receivedAt || null, - memo: data.memo || null, - remarks: data.remarks || null, - note: data.note || null, - items: data.items?.map((item) => ({ - item_id: item.itemId || null, - item_name: item.itemName, - specification: item.specification || null, - quantity: item.quantity, - unit: item.unit || null, - unit_price: item.unitPrice, - })) || [], + quote_id: quoteIdValue, + order_type_code: formData.orderTypeCode || 'ORDER', + category_code: formData.categoryCode || null, + client_id: clientIdValue, + client_name: formData.clientName || null, + client_contact: formData.clientContact || formData.contact || null, + site_name: formData.siteName || null, + supply_amount: formData.supplyAmount || formData.subtotal || 0, + tax_amount: formData.taxAmount || 0, + total_amount: formData.totalAmount || 0, + discount_rate: formData.discountRate || 0, + discount_amount: formData.discountAmount || 0, + delivery_date: formData.deliveryDate || formData.deliveryRequestDate || null, + delivery_method_code: formData.deliveryMethodCode || formData.deliveryMethod || null, + received_at: formData.receivedAt || null, + memo: formData.memo || null, + remarks: formData.remarks || null, + note: formData.note || null, + items: items.map((item) => { + // Handle both form's OrderItem (id, spec) and API's OrderItemFormData (itemId, specification) + // 중요: 문자열로 전달될 수 있으므로 반드시 Number()로 변환 + const quantity = Number(item.quantity) || 0; + const unitPrice = Number(item.unitPrice) || 0; + const supplyAmount = quantity * unitPrice; + const taxAmount = Math.round(supplyAmount * 0.1); + + return { + item_id: item.itemId || null, + item_code: item.itemCode || null, + item_name: item.itemName, + specification: item.specification || item.spec || null, + quantity, + unit: item.unit || 'EA', + unit_price: unitPrice, + supply_amount: supplyAmount, + tax_amount: taxAmount, + total_amount: supplyAmount + taxAmount, + }; + }), }; } @@ -490,6 +522,7 @@ function transformQuoteForSelect(apiData: ApiQuoteForSelect): QuotationForSelect id: String(apiData.id), quoteNumber: apiData.quote_number, grade: apiData.client?.grade || 'B', // 기본값 B(관리) + clientId: apiData.client_id ? String(apiData.client_id) : null, client: apiData.client_name || apiData.client?.name || '', siteName: apiData.site_name || '', amount: apiData.total_amount, @@ -502,6 +535,14 @@ function transformQuoteForSelect(apiData: ApiQuoteForSelect): QuotationForSelect } function transformQuoteItemForSelect(apiItem: ApiQuoteItem): QuotationItem { + // QuoteItem 모델 필드명: calculated_quantity, total_price + // 수주 품목 필드명: quantity, total_amount (fallback) + // 중요: API에서 문자열로 반환될 수 있으므로 반드시 Number()로 변환 + const quantity = Number(apiItem.calculated_quantity ?? apiItem.quantity ?? 0); + const unitPrice = Number(apiItem.unit_price ?? 0); + // amount fallback: total_price → total_amount → 수량 * 단가 계산 + const amount = Number(apiItem.total_price ?? apiItem.total_amount ?? 0) || (quantity * unitPrice); + return { id: String(apiItem.id), itemCode: apiItem.item_code || '', @@ -509,10 +550,10 @@ function transformQuoteItemForSelect(apiItem: ApiQuoteItem): QuotationItem { type: apiItem.type_code || '', symbol: apiItem.symbol || '', spec: apiItem.specification || '', - quantity: apiItem.quantity, + quantity, unit: apiItem.unit || 'EA', - unitPrice: apiItem.unit_price, - amount: apiItem.total_amount, + unitPrice, + amount, }; } @@ -971,8 +1012,10 @@ export async function getQuotesForSelect(params?: { try { const searchParams = new URLSearchParams(); - // 확정(FINALIZED) 상태의 견적만 조회 - searchParams.set('status', 'FINALIZED'); + // 확정(finalized) 상태의 견적만 조회 + searchParams.set('status', 'finalized'); + // 품목 포함 (수주 전환용) + searchParams.set('with_items', 'true'); if (params?.q) searchParams.set('q', params.q); if (params?.page) searchParams.set('page', String(params.page)); if (params?.size) searchParams.set('size', String(params.size || 50));