// ChemiFactory MES - Production Plan Gantt Chart & Simulator Logic const API_PLANS = "/plans"; const API_CUSTOMERS = "/customers"; const API_INVENTORY = "/inventory"; let allPlans = []; let allCustomers = []; let allYarns = []; let allBonds = []; // Gantt configuration const cellWidth = 40; // px let ganttStartDate = null; let ganttEndDate = null; let datesArray = []; let selectedEquipmentIds = []; let simResultData = null; document.addEventListener("DOMContentLoaded", () => { initEvents(); initGanttDates(); loadAllData(); initScrollSync(); }); function initGanttDates() { const today = new Date(); // 2 weeks before today const start = new Date(today); start.setDate(today.getDate() - 14); ganttStartDate = start; // 5 weeks after today const end = new Date(today); end.setDate(today.getDate() + 35); ganttEndDate = end; datesArray = []; let curr = new Date(start); while (curr <= end) { datesArray.push(new Date(curr)); curr.setDate(curr.getDate() + 1); } } async function loadAllData() { await fetchCustomers(); await fetchInventoryDropdowns(); await fetchPlans(); } async function fetchPlans() { try { const res = await fetch(`${API_PLANS}/`); if (!res.ok) throw new Error("Failed to fetch plans"); allPlans = await res.json(); renderGantt(); } catch (err) { console.error("DB 연결 불가로 인해 간트차트 레이아웃 확인용 데모 데이터를 렌더링합니다:", err); const todayStr = new Date().toISOString().substring(0, 10); // 데모용 생산계획 데이터 2건 추가 allPlans = [ { plan_id: 1, plan_name: "샘플고객사A - 원사 12K (데모)", customer_id: 1, customer_name: "샘플고객사A", yarn_inventory_id: 1, yarn_name: "원사 12K", bond_inventory_id: null, requested_quantity_kg: 850.5, start_date: todayStr, production_days: 14.5, assigned_equipment: [{equipment_id: 8}], works_on_saturday: true, works_on_sunday: false, yarn_diameter: 7.0, yarn_k: 12, ports: 40, cycle_time: 8.0, cut_length: 6.0, work_hours_day: 16, plan_estimated_sales: 12500000, plan_company_margin: 4200000, plan_total_material_cost: 8300000, plan_net_processing_cost: 3000000, plan_processing_fee: 7200000, plan_yarn_cost: 7500000, plan_bond_cost: 80000 }, { plan_id: 2, plan_name: "샘플고객사B - 원사 24K (데모)", customer_id: 2, customer_name: "샘플고객사B", yarn_inventory_id: 2, yarn_name: "원사 24K", bond_inventory_id: null, requested_quantity_kg: 1200.0, start_date: new Date(Date.now() + 86400000 * 5).toISOString().substring(0, 10), // 5일 뒤 시작 production_days: 20.0, assigned_equipment: [{equipment_id: 8}], works_on_saturday: true, works_on_sunday: true, yarn_diameter: 7.5, yarn_k: 24, ports: 40, cycle_time: 8.5, cut_length: 6.5, work_hours_day: 16, plan_estimated_sales: 18000000, plan_company_margin: 5500000, plan_total_material_cost: 12500000, plan_net_processing_cost: 4500000, plan_processing_fee: 10000000, plan_yarn_cost: 11000000, plan_bond_cost: 150000 } ]; renderGantt(); } } async function fetchCustomers() { try { const res = await fetch(`${API_CUSTOMERS}/`); if (!res.ok) throw new Error("Failed to fetch customers"); allCustomers = await res.json(); const select = document.getElementById("plan-customer"); select.innerHTML = ''; allCustomers.forEach(c => { select.innerHTML += ``; }); } catch (err) { console.error(err); } } async function fetchInventoryDropdowns() { try { const res = await fetch(`${API_INVENTORY}/`); if (!res.ok) throw new Error("Failed to fetch inventory dropdowns"); const inventories = await res.json(); // 대소문자 및 한글 구분 필터링 및 소진되지 않은(is_depleted !== 1) 재고 추출 allYarns = inventories.filter(i => i.material_type && (i.material_type.toLowerCase() === 'yarn' || i.material_type === '원사') && i.is_depleted !== 1); allBonds = inventories.filter(i => i.material_type && (i.material_type.toLowerCase() === 'bond' || i.material_type === '본드') && i.is_depleted !== 1); const yarnSelect = document.getElementById("plan-yarn"); yarnSelect.innerHTML = ''; allYarns.forEach(y => { const labelName = y.item_name || y.material_name || "미지정 원사"; yarnSelect.innerHTML += ``; }); const bondSelect = document.getElementById("plan-bond"); bondSelect.innerHTML = ''; allBonds.forEach(b => { const labelName = b.item_name || b.material_name || "미지정 본드"; bondSelect.innerHTML += ``; }); } catch (err) { console.error("Dropdown load error: ", err); } } // Gantt Rendering Logic function renderGantt() { renderGanttHeaders(); const leftBody = document.getElementById("gantt-left-body"); const rightBody = document.getElementById("gantt-right-body"); leftBody.innerHTML = ""; rightBody.innerHTML = ""; if (allPlans.length === 0) { leftBody.innerHTML = `
수립된 계획이 없습니다.
`; return; } allPlans.forEach(plan => { // Left Column Panel Row const leftRow = document.createElement("div"); leftRow.className = "gantt-row"; leftRow.innerHTML = `
${plan.plan_name} ${plan.customer_name} | ${plan.yarn_name}
`; leftBody.appendChild(leftRow); // Right Timeline Row const rightRow = document.createElement("div"); rightRow.className = "gantt-row"; rightRow.style.width = `${datesArray.length * cellWidth}px`; let gridHtml = `
`; datesArray.forEach(() => { gridHtml += `
`; }); gridHtml += '
'; rightRow.innerHTML = gridHtml; // Render schedule bar overlay const planStart = new Date(plan.start_date); const planEnd = new Date(plan.end_date); // Calculate offsets const startDiff = Math.ceil((planStart - ganttStartDate) / (1000 * 60 * 60 * 24)); const duration = Math.ceil((planEnd - planStart) / (1000 * 60 * 60 * 24)) + 1; // Draw only if it overlaps with our Gantt window if (planEnd >= ganttStartDate && planStart <= ganttEndDate) { const visibleStart = Math.max(0, startDiff); const visibleDuration = duration - (visibleStart - startDiff); const barContainer = document.createElement("div"); barContainer.className = "gantt-bar-container"; barContainer.style.left = `${visibleStart * cellWidth}px`; barContainer.style.width = `${visibleDuration * cellWidth}px`; const bar = document.createElement("div"); bar.className = "gantt-bar-item"; bar.innerText = `[${plan.requested_quantity_kg}kg] ${plan.plan_name}`; bar.addEventListener("click", () => openEditPlanModal(plan)); barContainer.appendChild(bar); rightRow.appendChild(barContainer); } rightBody.appendChild(rightRow); }); } function renderGanttHeaders() { const headerRow = document.getElementById("gantt-date-headers"); headerRow.style.width = `${datesArray.length * cellWidth}px`; headerRow.innerHTML = ""; const todayStr = new Date().toISOString().substring(0, 10); datesArray.forEach(d => { const day = d.getDate(); const dow = d.getDay(); const dateStr = d.toISOString().substring(0, 10); let cellClass = "gantt-day-header-cell"; if (dow === 6) cellClass += " day-sat"; else if (dow === 0) cellClass += " day-sun"; if (dateStr === todayStr) cellClass += " day-today"; headerRow.innerHTML += `
${d.getMonth() + 1}/${day} ${['일', '월', '화', '수', '목', '금', '토'][dow]}
`; }); } // Events Integration function initEvents() { const modal = document.getElementById("plan-modal"); modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => { btn.addEventListener("click", () => modal.style.display = "none"); }); document.getElementById("btn-add-plan").addEventListener("click", () => { document.getElementById("plan-form").reset(); document.getElementById("plan-id").value = ""; document.getElementById("plan-modal-title").innerText = "생산 계획 수립"; document.getElementById("plan-start-date").value = new Date().toISOString().substring(0, 10); document.getElementById("equip-assignment-box").style.display = "none"; document.getElementById("btn-save-plan").disabled = true; simResultData = null; selectedEquipmentIds = []; document.getElementById("sim-days").innerText = "-"; document.getElementById("sim-sales").innerText = "-"; document.getElementById("sim-margin").innerText = "-"; modal.style.display = "flex"; }); document.getElementById("btn-simulate").addEventListener("click", runSimulation); document.getElementById("plan-form").addEventListener("submit", savePlan); } // Simulation & Optimization async function runSimulation() { const yarnInvId = document.getElementById("plan-yarn").value; const qty = document.getElementById("plan-qty").value; if (!yarnInvId || !qty) { alert("원사 자재와 목표 생산량을 먼저 입력해 주십시오."); return; } const payload = { inputs: { yarn_inventory_id: parseInt(yarnInvId), bond_inventory_id: parseInt(document.getElementById("plan-bond").value) || null, yarn_diameter_micron: parseFloat(document.getElementById("plan-yarn-dia").value), yarn_k: parseInt(document.getElementById("plan-yarn-k").value), ports: parseInt(document.getElementById("plan-ports").value), cycle_time_hz: parseFloat(document.getElementById("plan-hz").value), cut_length_mm: parseFloat(document.getElementById("plan-cut").value), work_hours_day: parseInt(document.getElementById("plan-hours").value), work_days_month: 20.0, // Default constraint num_machines: 2.0, // Baseline machines bond_percentage: 3.0 }, targetProductionQuantity: parseFloat(qty) }; try { const res = await fetch("/inventory/analysis/calculate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); if (!res.ok) throw new Error("Calculation failure"); const data = await res.json(); simResultData = data; // Render results document.getElementById("sim-days").innerText = data.required_days_target.toFixed(1); document.getElementById("sim-sales").innerText = Math.round(data.plan_estimated_sales).toLocaleString(); document.getElementById("sim-margin").innerText = Math.round(data.plan_company_margin).toLocaleString(); // Query Available equipment const startDate = document.getElementById("plan-start-date").value; if (startDate) { await queryAvailableEquipment(startDate, data.required_days_target); } } catch (err) { console.error(err); alert("시뮬레이션 가동 계산 중 오류가 발생했습니다."); } } async function queryAvailableEquipment(startDateStr, requiredDays) { // ⚠️ 장비 배정 부분 연동 (간트 시각화 및 배정을 위해 가용 장비 1~4호기를 체크할 수 있도록 칩 형태로 조회) const startDate = new Date(startDateStr); const endDate = new Date(startDate); endDate.setDate(startDate.getDate() + Math.ceil(requiredDays) - 1); const sat = document.getElementById("plan-sat").checked; const sun = document.getElementById("plan-sun").checked; try { const url = `${API_PLANS}/find-available-equipment?start_date=${startDateStr}&end_date=${endDate.toISOString().substring(0, 10)}&works_on_saturday=${sat}&works_on_sunday=${sun}`; const res = await fetch(url); if (!res.ok) throw new Error("Equipment search failed"); const machines = await res.json(); const container = document.getElementById("available-machines-container"); container.innerHTML = ""; if (machines.length === 0) { container.innerHTML = "해당 일정에 가용한 설비가 없습니다."; document.getElementById("btn-save-plan").disabled = true; } else { machines.forEach(m => { const chip = document.createElement("div"); chip.className = "machine-chip"; if (selectedEquipmentIds.includes(m.id)) chip.classList.add("selected"); chip.innerText = m.name; chip.addEventListener("click", () => { if (selectedEquipmentIds.includes(m.id)) { selectedEquipmentIds = selectedEquipmentIds.filter(id => id !== m.id); chip.classList.remove("selected"); } else { selectedEquipmentIds.push(m.id); chip.classList.add("selected"); } document.getElementById("btn-save-plan").disabled = selectedEquipmentIds.length === 0; }); container.appendChild(chip); }); } document.getElementById("equip-assignment-box").style.display = "block"; } catch (err) { console.error(err); } } async function savePlan(e) { e.preventDefault(); if (!simResultData || selectedEquipmentIds.length === 0) return; const id = document.getElementById("plan-id").value; const custSelect = document.getElementById("plan-customer"); const yarnSelect = document.getElementById("plan-yarn"); const bondSelect = document.getElementById("plan-bond"); const payload = { plan_name: `${custSelect.options[custSelect.selectedIndex].text} - ${yarnSelect.options[yarnSelect.selectedIndex].text.split(' ')[0]}`, customer_id: parseInt(custSelect.value), customer_name: custSelect.options[custSelect.selectedIndex].text, yarn_inventory_id: parseInt(yarnSelect.value), yarn_name: yarnSelect.options[yarnSelect.selectedIndex].text.split(' ')[0], bond_inventory_id: parseInt(bondSelect.value) || null, bond_name: bondSelect.value ? bondSelect.options[bondSelect.selectedIndex].text.split(' ')[0] : "", requested_quantity_kg: parseFloat(document.getElementById("plan-qty").value), start_date: document.getElementById("plan-start-date").value, production_days: simResultData.required_days_target, // Sim data hourly_net_cost_per_machine: simResultData.hourly_net_cost_per_machine, hourly_profit_per_machine: simResultData.hourly_profit_per_machine, plan_total_material_cost: simResultData.plan_total_material_cost, yarn_diameter: parseFloat(document.getElementById("plan-yarn-dia").value), yarn_k: parseFloat(document.getElementById("plan-yarn-k").value), ports: parseFloat(document.getElementById("plan-ports").value), cycle_time: parseFloat(document.getElementById("plan-hz").value), cut_length: parseFloat(document.getElementById("plan-cut").value), work_hours_day: parseFloat(document.getElementById("plan-hours").value), work_days_month: 20.0, num_machines: parseFloat(selectedEquipmentIds.length), plan_net_processing_cost: simResultData.plan_net_processing_cost, plan_company_margin: simResultData.plan_company_margin, plan_processing_fee: simResultData.plan_processing_fee, plan_yarn_cost: simResultData.plan_yarn_cost, plan_bond_cost: simResultData.plan_bond_cost, plan_estimated_sales: simResultData.plan_estimated_sales, // Allocations equipment_ids: selectedEquipmentIds, works_on_saturday: document.getElementById("plan-sat").checked, works_on_sunday: document.getElementById("plan-sun").checked }; try { let res; if (id) { res = await fetch(`${API_PLANS}/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); } else { res = await fetch(`${API_PLANS}/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); } if (!res.ok) throw new Error("Plan saving failed"); document.getElementById("plan-modal").style.display = "none"; fetchPlans(); } catch (err) { console.error(err); alert("계획 등록에 실패했습니다."); } } window.openEditPlanModal = function(plan) { document.getElementById("plan-id").value = plan.plan_id; document.getElementById("plan-customer").value = plan.customer_id; document.getElementById("plan-yarn").value = plan.yarn_inventory_id; document.getElementById("plan-bond").value = plan.bond_inventory_id || ""; document.getElementById("plan-qty").value = plan.requested_quantity_kg; document.getElementById("plan-start-date").value = plan.start_date; document.getElementById("plan-sat").checked = plan.works_on_saturday || false; document.getElementById("plan-sun").checked = plan.works_on_sunday || false; document.getElementById("plan-yarn-dia").value = plan.yarn_diameter; document.getElementById("plan-yarn-k").value = plan.yarn_k; document.getElementById("plan-ports").value = plan.ports; document.getElementById("plan-hz").value = plan.cycle_time; document.getElementById("plan-cut").value = plan.cut_length; document.getElementById("plan-hours").value = plan.work_hours_day; selectedEquipmentIds = plan.assigned_equipment ? plan.assigned_equipment.map(ae => ae.equipment_id) : []; simResultData = { required_days_target: plan.production_days, plan_estimated_sales: plan.plan_estimated_sales, plan_company_margin: plan.plan_company_margin, hourly_net_cost_per_machine: plan.hourly_net_cost_per_machine, hourly_profit_per_machine: plan.hourly_profit_per_machine, plan_total_material_cost: plan.plan_total_material_cost, plan_net_processing_cost: plan.plan_net_processing_cost, plan_processing_fee: plan.plan_processing_fee, plan_yarn_cost: plan.plan_yarn_cost, plan_bond_cost: plan.plan_bond_cost }; document.getElementById("sim-days").innerText = plan.production_days.toFixed(1); document.getElementById("sim-sales").innerText = Math.round(plan.plan_estimated_sales).toLocaleString(); document.getElementById("sim-margin").innerText = Math.round(plan.plan_company_margin).toLocaleString(); queryAvailableEquipment(plan.start_date, plan.production_days); document.getElementById("plan-modal-title").innerText = "생산 계획 정보 조회 및 수정"; document.getElementById("btn-save-plan").disabled = false; document.getElementById("plan-modal").style.display = "flex"; }; function initScrollSync() { const leftBody = document.getElementById("gantt-left-body"); const rightBody = document.getElementById("gantt-right-body"); if (!leftBody || !rightBody) return; let isLeftScrolling = false; let isRightScrolling = false; leftBody.addEventListener("scroll", () => { if (isRightScrolling) { isRightScrolling = false; return; } isLeftScrolling = true; rightBody.scrollTop = leftBody.scrollTop; }); rightBody.addEventListener("scroll", () => { if (isLeftScrolling) { isLeftScrolling = false; return; } isRightScrolling = true; leftBody.scrollTop = rightBody.scrollTop; }); }