535 lines
22 KiB
JavaScript
535 lines
22 KiB
JavaScript
// 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 = '<option value="">-- 거래처 선택 --</option>';
|
|
allCustomers.forEach(c => {
|
|
select.innerHTML += `<option value="${c.id}">${c.name_kr}</option>`;
|
|
});
|
|
} 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 = '<option value="">-- 입고 원사 선택 --</option>';
|
|
allYarns.forEach(y => {
|
|
const labelName = y.item_name || y.material_name || "미지정 원사";
|
|
yarnSelect.innerHTML += `<option value="${y.id}">${labelName} (재고: ${y.stock || 0}kg)</option>`;
|
|
});
|
|
|
|
const bondSelect = document.getElementById("plan-bond");
|
|
bondSelect.innerHTML = '<option value="">-- 입고 본드 선택 (선택사항) --</option>';
|
|
allBonds.forEach(b => {
|
|
const labelName = b.item_name || b.material_name || "미지정 본드";
|
|
bondSelect.innerHTML += `<option value="${b.id}">${labelName} (재고: ${b.stock || 0}kg)</option>`;
|
|
});
|
|
} 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 = `<div style="padding: 20px; font-size:12px; color:var(--text-muted);">수립된 계획이 없습니다.</div>`;
|
|
return;
|
|
}
|
|
|
|
allPlans.forEach(plan => {
|
|
// Left Column Panel Row
|
|
const leftRow = document.createElement("div");
|
|
leftRow.className = "gantt-row";
|
|
leftRow.innerHTML = `
|
|
<div class="gantt-left-item" onclick="openEditPlanModal(${JSON.stringify(plan).replace(/"/g, '"')})">
|
|
<strong>${plan.plan_name}</strong>
|
|
<small>${plan.customer_name} | ${plan.yarn_name}</small>
|
|
</div>
|
|
`;
|
|
leftBody.appendChild(leftRow);
|
|
|
|
// Right Timeline Row
|
|
const rightRow = document.createElement("div");
|
|
rightRow.className = "gantt-row";
|
|
rightRow.style.width = `${datesArray.length * cellWidth}px`;
|
|
|
|
let gridHtml = `<div class="gantt-right-grid" style="width: ${datesArray.length * cellWidth}px;">`;
|
|
datesArray.forEach(() => {
|
|
gridHtml += `<div class="gantt-grid-cell"></div>`;
|
|
});
|
|
gridHtml += '</div>';
|
|
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 += `
|
|
<div class="${cellClass}">
|
|
<span style="font-size:10px; color:var(--text-secondary);">${d.getMonth() + 1}/${day}</span>
|
|
<span style="font-weight:700;">${['일', '월', '화', '수', '목', '금', '토'][dow]}</span>
|
|
</div>
|
|
`;
|
|
});
|
|
}
|
|
|
|
// 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 = "<span style='color:var(--accent-red); font-size:12px;'>해당 일정에 가용한 설비가 없습니다.</span>";
|
|
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;
|
|
});
|
|
}
|