diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index 449218e8..6b334a61 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -35,6 +35,33 @@ export async function getOrderSummary( const whereClause = conditions.join(" AND "); + // item_info에 lead_time 컬럼이 존재하는지 확인 + const leadTimeColCheck = await pool.query(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'item_info' AND column_name = 'lead_time' + ) AS has_lead_time + `); + const hasLeadTime = leadTimeColCheck.rows[0]?.has_lead_time === true; + + const itemLeadTimeCte = hasLeadTime + ? `item_lead_time AS ( + SELECT + item_number, + id AS item_id, + COALESCE(lead_time, 0) AS lead_time + FROM item_info + WHERE company_code = $1 + ),` + : `item_lead_time AS ( + SELECT + item_number, + id AS item_id, + 0 AS lead_time + FROM item_info + WHERE company_code = $1 + ),`; + const query = ` WITH order_summary AS ( SELECT @@ -49,6 +76,7 @@ export async function getOrderSummary( WHERE ${whereClause} GROUP BY so.part_code, so.part_name ), + ${itemLeadTimeCte} stock_info AS ( SELECT item_code, @@ -85,10 +113,12 @@ export async function getOrderSummary( os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0) - COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0), 0 - ) AS required_plan_qty + ) AS required_plan_qty, + COALESCE(ilt.lead_time, 0) AS lead_time FROM order_summary os LEFT JOIN stock_info si ON os.item_code = si.item_code LEFT JOIN plan_info pi ON os.item_code = pi.item_code + LEFT JOIN item_lead_time ilt ON (os.item_code = ilt.item_number OR os.item_code = ilt.item_id) ${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""} ORDER BY os.item_code; `; @@ -367,6 +397,7 @@ export async function previewSchedule( } const dailyCapacity = item.daily_capacity || 800; + const itemLeadTime = item.lead_time || 0; let requiredQty = item.required_qty; @@ -381,20 +412,32 @@ export async function previewSchedule( if (requiredQty <= 0) continue; - const productionDays = Math.ceil(requiredQty / dailyCapacity); - + // 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산 const dueDate = new Date(item.earliest_due_date); - const endDate = new Date(dueDate); - endDate.setDate(endDate.getDate() - safetyLeadTime); - const startDate = new Date(endDate); - startDate.setDate(startDate.getDate() - productionDays); + let startDate: Date; + let endDate: Date; + + if (itemLeadTime > 0) { + // 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임 + endDate = new Date(dueDate); + startDate = new Date(dueDate); + startDate.setDate(startDate.getDate() - itemLeadTime); + } else { + // 리드타임이 없으면 기존 로직 (생산능력 기반) + const productionDays = Math.ceil(requiredQty / dailyCapacity); + endDate = new Date(dueDate); + endDate.setDate(endDate.getDate() - safetyLeadTime); + startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - productionDays); + } const today = new Date(); today.setHours(0, 0, 0, 0); if (startDate < today) { + const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); startDate.setTime(today.getTime()); endDate.setTime(startDate.getTime()); - endDate.setDate(endDate.getDate() + productionDays); + endDate.setDate(endDate.getDate() + duration); } // 해당 품목의 수주 건수 확인 @@ -411,10 +454,11 @@ export async function previewSchedule( required_qty: requiredQty, daily_capacity: dailyCapacity, hourly_capacity: item.hourly_capacity || 100, - production_days: productionDays, + production_days: itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity), start_date: startDate.toISOString().split("T")[0], end_date: endDate.toISOString().split("T")[0], due_date: item.earliest_due_date, + lead_time: itemLeadTime, order_count: orderCount, status: "planned", }); @@ -490,25 +534,37 @@ export async function generateSchedule( // 필요 수량 계산 (삭제된 planned 수량을 복원) const dailyCapacity = item.daily_capacity || 800; + const itemLeadTime = item.lead_time || 0; let requiredQty = item.required_qty + deletedQtyForItem; if (requiredQty <= 0) continue; - const productionDays = Math.ceil(requiredQty / dailyCapacity); - - // 시작일 = 납기일 - 생산일수 - 안전리드타임 + // 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산 const dueDate = new Date(item.earliest_due_date); - const endDate = new Date(dueDate); - endDate.setDate(endDate.getDate() - safetyLeadTime); - const startDate = new Date(endDate); - startDate.setDate(startDate.getDate() - productionDays); + let startDate: Date; + let endDate: Date; + + if (itemLeadTime > 0) { + // 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임 + endDate = new Date(dueDate); + startDate = new Date(dueDate); + startDate.setDate(startDate.getDate() - itemLeadTime); + } else { + // 리드타임이 없으면 기존 로직 (생산능력 기반) + const productionDays = Math.ceil(requiredQty / dailyCapacity); + endDate = new Date(dueDate); + endDate.setDate(endDate.getDate() - safetyLeadTime); + startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - productionDays); + } // 시작일이 오늘보다 이전이면 오늘로 조정 const today = new Date(); today.setHours(0, 0, 0, 0); if (startDate < today) { + const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); startDate.setTime(today.getTime()); endDate.setTime(startDate.getTime()); - endDate.setDate(endDate.getDate() + productionDays); + endDate.setDate(endDate.getDate() + duration); } // 계획번호 생성 (YYYYMMDD-NNNN 형식) @@ -675,13 +731,24 @@ async function getBomChildItems( companyCode: string, itemCode: string ) { + // item_info에 lead_time 컬럼 존재 여부 확인 + const colCheck = await client.query(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'item_info' AND column_name = 'lead_time' + ) AS has_lead_time + `); + const hasLeadTime = colCheck.rows[0]?.has_lead_time === true; + const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time, 0)" : "0"; + const bomQuery = ` SELECT bd.child_item_id, ii.item_name AS child_item_name, ii.item_number AS child_item_code, bd.quantity AS bom_qty, - bd.unit + bd.unit, + ${leadTimeCol} AS child_lead_time FROM bom b JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code @@ -740,9 +807,12 @@ export async function previewSemiSchedule( if (requiredQty <= 0) continue; + // 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산 + const childLeadTime = parseInt(bomItem.child_lead_time) || 1; const semiDueDate = plan.start_date; + const semiEndDate = new Date(plan.start_date); const semiStartDate = new Date(plan.start_date); - semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1)); + semiStartDate.setDate(semiStartDate.getDate() - childLeadTime); previews.push({ parent_plan_id: plan.id, @@ -752,13 +822,14 @@ export async function previewSemiSchedule( item_name: bomItem.child_item_name || bomItem.child_item_id, plan_qty: requiredQty, bom_qty: parseFloat(bomItem.bom_qty) || 1, + lead_time: childLeadTime, start_date: semiStartDate.toISOString().split("T")[0], end_date: typeof semiDueDate === "string" ? semiDueDate.split("T")[0] - : new Date(semiDueDate).toISOString().split("T")[0], + : semiEndDate.toISOString().split("T")[0], due_date: typeof semiDueDate === "string" ? semiDueDate.split("T")[0] - : new Date(semiDueDate).toISOString().split("T")[0], + : semiEndDate.toISOString().split("T")[0], product_type: "반제품", status: "planned", }); @@ -839,10 +910,12 @@ export async function generateSemiSchedule( if (requiredQty <= 0) continue; + // 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산 + const childLeadTime = parseInt(bomItem.child_lead_time) || 1; const semiDueDate = plan.start_date; const semiEndDate = plan.start_date; const semiStartDate = new Date(plan.start_date); - semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1)); + semiStartDate.setDate(semiStartDate.getDate() - childLeadTime); // plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품) const planNoResult = await client.query( diff --git a/frontend/app/(main)/production/plan-management/page.tsx b/frontend/app/(main)/production/plan-management/page.tsx index a5b66640..6e1f70b3 100644 --- a/frontend/app/(main)/production/plan-management/page.tsx +++ b/frontend/app/(main)/production/plan-management/page.tsx @@ -109,7 +109,7 @@ export default function ProductionPlanManagementPage() { const [searchEndDate, setSearchEndDate] = useState(""); // 타임라인 옵션 - const [safetyLeadTime, setSafetyLeadTime] = useState(1); + // 리드타임은 품목정보(item_info)에서 관리 const [displayWeeks, setDisplayWeeks] = useState(4); const [recalculateUnstarted, setRecalculateUnstarted] = useState(true); @@ -333,6 +333,7 @@ export default function ProductionPlanManagementPage() { item_name: item.item_name, required_qty: Number(item.required_plan_qty), earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0], + lead_time: Number(item.lead_time) || 0, })); setGenerating(true); @@ -340,7 +341,7 @@ export default function ProductionPlanManagementPage() { const req: GenerateScheduleRequest = { items, options: { - safety_lead_time: safetyLeadTime, + safety_lead_time: 0, recalculate_unstarted: recalculateUnstarted, product_type: "완제품", }, @@ -357,7 +358,7 @@ export default function ProductionPlanManagementPage() { } finally { setGenerating(false); } - }, [selectedItemGroups, orderItems, safetyLeadTime, recalculateUnstarted]); + }, [selectedItemGroups, orderItems, recalculateUnstarted]); const handleApplySchedule = useCallback(async () => { if (selectedItemGroups.size === 0) return; @@ -369,6 +370,7 @@ export default function ProductionPlanManagementPage() { item_name: item.item_name, required_qty: Number(item.required_plan_qty), earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0], + lead_time: Number(item.lead_time) || 0, })); setGenerating(true); @@ -376,7 +378,7 @@ export default function ProductionPlanManagementPage() { const res = await generateSchedule({ items, options: { - safety_lead_time: safetyLeadTime, + safety_lead_time: 0, recalculate_unstarted: recalculateUnstarted, product_type: "완제품", }, @@ -393,7 +395,7 @@ export default function ProductionPlanManagementPage() { } finally { setGenerating(false); } - }, [selectedItemGroups, orderItems, safetyLeadTime, recalculateUnstarted, fetchPlans, fetchOrderSummary]); + }, [selectedItemGroups, orderItems, recalculateUnstarted, fetchPlans, fetchOrderSummary]); const handleClearTimeline = useCallback(async () => { if (finishedPlans.length === 0) { @@ -519,7 +521,7 @@ export default function ProductionPlanManagementPage() { const res = await generateSchedule({ items, options: { - safety_lead_time: safetyLeadTime, + safety_lead_time: 0, recalculate_unstarted: importMode === "new" ? false : recalculateUnstarted, product_type: "완제품", }, @@ -535,7 +537,7 @@ export default function ProductionPlanManagementPage() { } finally { setGenerating(false); } - }, [selectedItemGroups, orderItems, safetyLeadTime, importMode, recalculateUnstarted, fetchPlans, fetchOrderSummary]); + }, [selectedItemGroups, orderItems, importMode, recalculateUnstarted, fetchPlans, fetchOrderSummary]); const handleImportStockItems = useCallback(async () => { if (selectedStockItems.size === 0) { @@ -556,7 +558,7 @@ export default function ProductionPlanManagementPage() { const res = await generateSchedule({ items, options: { - safety_lead_time: safetyLeadTime, + safety_lead_time: 0, recalculate_unstarted: importMode === "new" ? false : true, product_type: "완제품", }, @@ -572,7 +574,7 @@ export default function ProductionPlanManagementPage() { } finally { setGenerating(false); } - }, [selectedStockItems, stockItems, safetyLeadTime, importMode, fetchPlans, fetchStockShortage]); + }, [selectedStockItems, stockItems, importMode, fetchPlans, fetchStockShortage]); // 숫자 포맷 const formatNumber = (num: number | string) => Number(num).toLocaleString(); @@ -822,6 +824,7 @@ export default function ProductionPlanManagementPage() { 기생산계획량 생산진행 필요생산계획 + 리드타임(일) @@ -846,6 +849,9 @@ export default function ProductionPlanManagementPage() { 0 ? "text-destructive" : "text-emerald-600 dark:text-emerald-400")} onClick={() => toggleItemExpand(item.item_code)}> {formatNumber(item.required_plan_qty)} + toggleItemExpand(item.item_code)}> + {Number(item.lead_time) > 0 ? `${item.lead_time}일` : "-"} + {expandedItems.has(item.item_code) && item.orders?.map((detail) => ( @@ -866,7 +872,7 @@ export default function ProductionPlanManagementPage() { {formatNumber(detail.order_qty)} {formatNumber(detail.ship_qty)} {formatNumber(detail.balance_qty)} - + 납기일: {detail.due_date || "-"} @@ -999,10 +1005,6 @@ export default function ProductionPlanManagementPage() {
-
- - setSafetyLeadTime(Number(e.target.value))} className="h-8 w-[100px] text-xs" min={0} max={10} /> -
setDisplayWeeks(Number(e.target.value))} className="h-8 w-[100px] text-xs" min={1} max={12} /> diff --git a/frontend/app/(main)/sales/claim/page.tsx b/frontend/app/(main)/sales/claim/page.tsx index 12d37472..333e8fc6 100644 --- a/frontend/app/(main)/sales/claim/page.tsx +++ b/frontend/app/(main)/sales/claim/page.tsx @@ -62,9 +62,11 @@ import { Check, ChevronsUpDown, Loader2, + FileSpreadsheet, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; // --- Types --- type ClaimType = "불량" | "교환" | "반품" | "배송지연" | "기타"; @@ -94,57 +96,7 @@ interface SalesOrderOption { status: string; } -// --- Sample Data --- -const initialData: Claim[] = [ - { - claimNo: "CLM-2025-004", - claimDate: "2025-11-09", - claimType: "불량", - claimStatus: "접수", - customerCode: "CUST-0001", - customerName: "주식회사 코아스포트", - managerName: "김철수", - orderNo: "SO-2025-0102", - claimContent: "제품 표면에 스크래치가 발견되었습니다.", - processContent: "", - }, - { - claimNo: "CLM-2025-001", - claimDate: "2025-01-05", - claimType: "불량", - claimStatus: "접수", - customerCode: "CUST-0002", - customerName: "(주)현상산업", - managerName: "김철수", - orderNo: "SO-2025-0102", - claimContent: "제품 불량", - processContent: "", - }, - { - claimNo: "CLM-2025-002", - claimDate: "2025-01-04", - claimType: "교환", - claimStatus: "처리중", - customerCode: "CUST-0003", - customerName: "대한전섬", - managerName: "이영희", - orderNo: "SO-2025-0095", - claimContent: "규격 불일치", - processContent: "교환 진행 중", - }, - { - claimNo: "CLM-2025-003", - claimDate: "2025-01-03", - claimType: "반품", - claimStatus: "완료", - customerCode: "CUST-0004", - customerName: "삼성전자", - managerName: "박민수", - orderNo: "SO-2024-1285", - claimContent: "수량 초과 납품", - processContent: "반품 완료", - }, -]; +const initialData: Claim[] = []; const getClaimTypeStyle = (type: ClaimType) => { switch (type) { @@ -193,6 +145,9 @@ export default function ClaimManagementPage() { const [searchCustomer, setSearchCustomer] = useState(""); const [searchClaimNo, setSearchClaimNo] = useState(""); + // 엑셀 업로드 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + // 모달 상태 const [isModalOpen, setIsModalOpen] = useState(false); const [isEditMode, setIsEditMode] = useState(false); @@ -563,9 +518,14 @@ export default function ClaimManagementPage() { {filteredData.length}건
- +
+ + +
@@ -1122,6 +1082,16 @@ export default function ClaimManagementPage() { + + {/* 엑셀 업로드 모달 */} + { + // TODO: 클레임 테이블 API 연동 후 데이터 새로고침 + }} + />
); } diff --git a/frontend/app/(main)/sales/shipping-order/page.tsx b/frontend/app/(main)/sales/shipping-order/page.tsx index ff05ffac..8b2c0dea 100644 --- a/frontend/app/(main)/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/sales/shipping-order/page.tsx @@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogD import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; -import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2 } from "lucide-react"; +import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react"; import { cn } from "@/lib/utils"; import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { @@ -24,6 +24,7 @@ import { getSalesOrderSource, getItemSource, } from "@/lib/api/shipping"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo"; @@ -84,6 +85,9 @@ export default function ShippingOrderPage() { const [searchDateFrom, setSearchDateFrom] = useState(""); const [searchDateTo, setSearchDateTo] = useState(""); + // 엑셀 업로드 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + // 모달 const [isModalOpen, setIsModalOpen] = useState(false); const [isEditMode, setIsEditMode] = useState(false); @@ -467,6 +471,9 @@ export default function ShippingOrderPage() { {orders.length}건
+ @@ -821,6 +828,16 @@ export default function ShippingOrderPage() { + + {/* 엑셀 업로드 모달 */} + { + fetchOrders(); + }} + />
); } diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index efcef2a4..706703da 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -26,6 +26,9 @@ import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/servi import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer"; import { useTabId } from "@/contexts/TabIdContext"; import { useTabStore } from "@/stores/tabStore"; +import { FileSpreadsheet, Loader2 as ExcelLoader } from "lucide-react"; +import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal"; +import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel"; export interface ScreenViewPageProps { screenIdProp?: number; @@ -96,6 +99,11 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = { // 데이터 전달에 의해 강제 활성화된 레이어 ID 목록 const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState([]); + // 엑셀 업로드 모달 상태 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + const [excelChainConfig, setExcelChainConfig] = useState(null); + const [excelDetecting, setExcelDetecting] = useState(false); + // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); const [editModalConfig, setEditModalConfig] = useState<{ @@ -650,8 +658,46 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
+ {/* 엑셀 업로드 버튼 (테이블이 있는 화면에서만 표시) */} + {!isPreviewMode && screen?.tableName && ( +
+ +
+ )} + {/* 레이아웃 준비 중 로딩 표시 */} {!layoutReady && (
@@ -801,6 +847,22 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = { }} /> + {/* 엑셀 업로드 모달 (멀티테이블 자동감지) */} + {excelChainConfig && ( + { + setExcelUploadOpen(open); + if (!open) setExcelChainConfig(null); + }} + config={excelChainConfig} + onSuccess={() => { + window.dispatchEvent(new CustomEvent("refreshTable")); + setTableRefreshKey((prev) => prev + 1); + }} + /> + )} + {/* 스케줄 생성 확인 다이얼로그 */} = ({ return true; }; + // 템플릿 다운로드: 테이블 스키마 기반으로 빈 엑셀 파일 생성 + const handleDownloadTemplate = async () => { + try { + const { exportToExcel } = await import("@/lib/utils/excelExport"); + const response = await getTableSchema(tableName); + if (!response.success || !response.data) { + toast.error("테이블 정보를 가져올 수 없습니다."); + return; + } + + const AUTO_COLS = ["id", "created_date", "updated_date", "writer", "company_code"]; + const columns = response.data.columns.filter( + (col) => !AUTO_COLS.includes(col.name.toLowerCase()) + ); + + // 필수 컬럼에 * 표시 + const headerRow: Record = {}; + for (const col of columns) { + const label = col.label || col.name; + const isRequired = !col.nullable; + headerRow[isRequired ? `${label} *` : label] = ""; + } + + await exportToExcel([headerRow], `${tableName}_템플릿.xlsx`, "Sheet1"); + toast.success("템플릿 파일이 다운로드되었습니다."); + } catch (error) { + console.error("템플릿 다운로드 실패:", error); + toast.error("템플릿 다운로드에 실패했습니다."); + } + }; + // 다음 단계 const handleNext = async () => { if (currentStep === 1 && !file) { @@ -1607,11 +1638,23 @@ export const ExcelUploadModal: React.FC = ({
)} - {/* 파일 선택 영역 */} + {/* 템플릿 다운로드 + 파일 선택 영역 */}
- +
+ + +