// 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;
});
}