733 lines
38 KiB
JavaScript
733 lines
38 KiB
JavaScript
|
|
/**
|
|||
|
|
* APS 公共脚本:布局、交互、图表、模拟排产引擎
|
|||
|
|
*/
|
|||
|
|
(function () {
|
|||
|
|
const $ = (sel, ctx) => (ctx || document).querySelector(sel);
|
|||
|
|
const $$ = (sel, ctx) => Array.from((ctx || document).querySelectorAll(sel));
|
|||
|
|
window.$ = $;
|
|||
|
|
window.$$ = $$;
|
|||
|
|
|
|||
|
|
// -------------- 页面初始化 --------------
|
|||
|
|
window.initPage = function (key) {
|
|||
|
|
renderHeader();
|
|||
|
|
renderSidebar(key);
|
|||
|
|
ensureModal();
|
|||
|
|
ensureToast();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function renderHeader() {
|
|||
|
|
const header = $('#header');
|
|||
|
|
if (!header) return;
|
|||
|
|
header.innerHTML = `
|
|||
|
|
<div class="brand"><div class="logo">APS</div><span>智能排产系统</span></div>
|
|||
|
|
<div class="userbar">
|
|||
|
|
<span id="clock"></span>
|
|||
|
|
<span>计划员:admin</span>
|
|||
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
|
|||
|
|
</div>`;
|
|||
|
|
setInterval(() => {
|
|||
|
|
const el = $('#clock');
|
|||
|
|
if (el) el.textContent = apsData.$utils.fmtDateTime(new Date());
|
|||
|
|
}, 1000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderSidebar(activeKey) {
|
|||
|
|
const sb = $('#sidebar');
|
|||
|
|
if (!sb) return;
|
|||
|
|
const isIndex = location.pathname.endsWith('index.html') || location.pathname.endsWith('/');
|
|||
|
|
const p = isIndex ? 'pages/' : '';
|
|||
|
|
const root = isIndex ? '' : '../';
|
|||
|
|
const nav = [
|
|||
|
|
{ group: '概览', items: [{ key: 'index', label: '工作台', icon: gridIcon, href: root + 'index.html' }] },
|
|||
|
|
{ group: '订单管理', items: [
|
|||
|
|
{ key: 'orders', label: '销售订单', icon: fileIcon, href: p + 'orders.html' },
|
|||
|
|
{ key: 'order-audit', label: '订单审核与优先级', icon: checkIcon, href: p + 'order-audit.html' },
|
|||
|
|
{ key: 'order-change', label: '订单变更与插单', icon: gitIcon, href: p + 'order-change.html' },
|
|||
|
|
{ key: 'order-pool', label: '订单池', icon: layersIcon, href: p + 'order-pool.html' },
|
|||
|
|
{ key: 'rush-order', label: '紧急插单', icon: alertIcon, href: p + 'rush-order.html' }
|
|||
|
|
]},
|
|||
|
|
{ group: '排产引擎', items: [
|
|||
|
|
{ key: 'scheduling', label: '排产策略', icon: cpuIcon, href: p + 'scheduling.html' },
|
|||
|
|
{ key: 'rule-engine', label: '规则引擎', icon: listIcon, href: p + 'rule-engine.html' },
|
|||
|
|
{ key: 'algorithm-engine', label: '智能算法引擎', icon: cpuIcon, href: p + 'algorithm-engine.html' },
|
|||
|
|
{ key: 'constraint-management', label: '约束条件管理', icon: checkIcon, href: p + 'constraint-management.html' },
|
|||
|
|
{ key: 'schedule-params', label: '排产参数设置', icon: listIcon, href: p + 'schedule-params.html' }
|
|||
|
|
]},
|
|||
|
|
{ group: '排产结果', items: [
|
|||
|
|
{ key: 'production-orders', label: '生产订单', icon: boxIcon, href: p + 'production-orders.html' },
|
|||
|
|
{ key: 'work-order-split', label: '工单拆分与下发', icon: toolIcon, href: p + 'work-order-split.html' },
|
|||
|
|
{ key: 'work-orders', label: '工单管理', icon: toolIcon, href: p + 'work-orders.html' },
|
|||
|
|
{ key: 'work-order-adjust', label: '工单拖拽调整', icon: calendarIcon, href: p + 'work-order-adjust.html' },
|
|||
|
|
{ key: 'calendar', label: '排产日历', icon: calendarIcon, href: p + 'calendar.html' }
|
|||
|
|
]},
|
|||
|
|
{ group: '约束与齐套', items: [
|
|||
|
|
{ key: 'kitting', label: '物料齐套检查', icon: checkIcon, href: p + 'kitting.html' },
|
|||
|
|
{ key: 'equipment-availability', label: '设备可用性检查', icon: monitorIcon, href: p + 'equipment-availability.html' },
|
|||
|
|
{ key: 'personnel-shift', label: '人员班次匹配', icon: usersIcon, href: p + 'personnel-shift.html' },
|
|||
|
|
{ key: 'changeover', label: '换线换模时间', icon: routeIcon, href: p + 'changeover.html' },
|
|||
|
|
{ key: 'conflicts', label: '冲突预警与解决', icon: alertIcon, href: p + 'conflicts.html' }
|
|||
|
|
]},
|
|||
|
|
{ group: '可视化监控', items: [
|
|||
|
|
{ key: 'resource-load', label: '资源负荷图', icon: mapIcon, href: p + 'resource-load.html' },
|
|||
|
|
{ key: 'order-progress', label: '订单进度追踪', icon: boxIcon, href: p + 'order-progress.html' }
|
|||
|
|
]},
|
|||
|
|
{ group: '版本与日志', items: [
|
|||
|
|
{ key: 'versions', label: '排产版本管理', icon: gitIcon, href: p + 'versions.html' },
|
|||
|
|
{ key: 'logs', label: '排产日志查询', icon: listIcon, href: p + 'logs.html' }
|
|||
|
|
]},
|
|||
|
|
{ group: '主数据', items: [
|
|||
|
|
{ key: 'factory-organization', label: '工厂与组织架构', icon: mapIcon, href: p + 'factory-organization.html' },
|
|||
|
|
{ key: 'factory-calendar', label: '工厂日历管理', icon: calendarIcon, href: p + 'factory-calendar.html' },
|
|||
|
|
{ key: 'lines', label: '产线与工位', icon: mapIcon, href: p + 'lines.html' },
|
|||
|
|
{ key: 'materials', label: '物料与BOM', icon: packageIcon, href: p + 'materials.html' },
|
|||
|
|
{ key: 'routing', label: '工艺路线', icon: routeIcon, href: p + 'routing.html' },
|
|||
|
|
{ key: 'equipment', label: '设备资源', icon: monitorIcon, href: p + 'equipment.html' },
|
|||
|
|
{ key: 'teams', label: '班组与班次', icon: usersIcon, href: p + 'teams.html' },
|
|||
|
|
{ key: 'personnel', label: '人员档案', icon: usersIcon, href: p + 'personnel.html' }
|
|||
|
|
]},
|
|||
|
|
{ group: '系统集成', items: [
|
|||
|
|
{ key: 'system-interfaces', label: '系统集成接口', icon: monitorIcon, href: p + 'system-interfaces.html' }
|
|||
|
|
]}
|
|||
|
|
];
|
|||
|
|
const activeGroupIdx = nav.findIndex(g => g.items.some(it => it.key === activeKey));
|
|||
|
|
sb.innerHTML = `
|
|||
|
|
<div class="nav-scroll">
|
|||
|
|
${nav.map((g, i) => `
|
|||
|
|
<div class="nav-group ${i === activeGroupIdx ? 'active' : ''}" data-idx="${i}">
|
|||
|
|
<span class="nav-title">${g.group}
|
|||
|
|
<svg class="caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
|||
|
|
</span>
|
|||
|
|
</div>`).join('')}
|
|||
|
|
</div>
|
|||
|
|
<div class="nav-dropdown-panel" id="topDropdown"></div>`;
|
|||
|
|
|
|||
|
|
const dropdown = $('#topDropdown');
|
|||
|
|
function showGroup(i) {
|
|||
|
|
const g = nav[i];
|
|||
|
|
dropdown.innerHTML = `
|
|||
|
|
<div class="nav-dropdown-title">${g.group}</div>
|
|||
|
|
<div class="nav-dropdown-grid">
|
|||
|
|
${g.items.map(it => `
|
|||
|
|
<a class="nav-item ${it.key === activeKey ? 'active' : ''}" href="${it.href}">
|
|||
|
|
${it.icon} <span>${it.label}</span>
|
|||
|
|
</a>`).join('')}
|
|||
|
|
</div>`;
|
|||
|
|
dropdown.classList.add('open');
|
|||
|
|
$$('.nav-group', sb).forEach((el, idx) => el.classList.toggle('open', idx === i));
|
|||
|
|
}
|
|||
|
|
function hideDropdown() {
|
|||
|
|
dropdown.classList.remove('open');
|
|||
|
|
$$('.nav-group', sb).forEach(el => el.classList.remove('open'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$$('.nav-group', sb).forEach((el, i) => {
|
|||
|
|
el.addEventListener('click', e => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
if (dropdown.classList.contains('open') && el.classList.contains('open')) {
|
|||
|
|
hideDropdown();
|
|||
|
|
} else {
|
|||
|
|
showGroup(i);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
dropdown.addEventListener('click', e => e.stopPropagation());
|
|||
|
|
document.addEventListener('click', () => hideDropdown());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// -------------- 图标(SVG 字符串) --------------
|
|||
|
|
function svg(path) { return `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`; }
|
|||
|
|
const gridIcon = svg('<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>');
|
|||
|
|
const fileIcon = svg('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>');
|
|||
|
|
const layersIcon = svg('<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/>');
|
|||
|
|
const cpuIcon = svg('<rect x="4" y="4" width="16" height="16" rx="2" ry="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/>');
|
|||
|
|
const calendarIcon = svg('<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>');
|
|||
|
|
const boxIcon = svg('<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>');
|
|||
|
|
const toolIcon = svg('<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>');
|
|||
|
|
const checkIcon = svg('<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>');
|
|||
|
|
const alertIcon = svg('<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>');
|
|||
|
|
const gitIcon = svg('<line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>');
|
|||
|
|
const listIcon = svg('<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>');
|
|||
|
|
const mapIcon = svg('<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>');
|
|||
|
|
const packageIcon = svg('<line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>');
|
|||
|
|
const routeIcon = svg('<circle cx="12" cy="5" r="3"/><line x1="12" y1="8" x2="12" y2="16"/><circle cx="6" cy="19" r="3"/><circle cx="18" cy="19" r="3"/><line x1="9.5" y1="17" x2="14.5" y2="17"/>');
|
|||
|
|
const monitorIcon = svg('<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>');
|
|||
|
|
const usersIcon = svg('<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>');
|
|||
|
|
|
|||
|
|
// -------------- Modal / Toast --------------
|
|||
|
|
function ensureModal() {
|
|||
|
|
if ($('#modalOverlay')) return;
|
|||
|
|
const div = document.createElement('div');
|
|||
|
|
div.id = 'modalOverlay';
|
|||
|
|
div.className = 'modal-overlay';
|
|||
|
|
div.innerHTML = `
|
|||
|
|
<div class="modal" id="modal" onclick="event.stopPropagation()">
|
|||
|
|
<div class="modal-header"><span id="modalTitle">标题</span><button class="modal-close" onclick="closeModal()">✕</button></div>
|
|||
|
|
<div class="modal-body" id="modalBody"></div>
|
|||
|
|
<div class="modal-footer" id="modalFooter"></div>
|
|||
|
|
</div>`;
|
|||
|
|
document.body.appendChild(div);
|
|||
|
|
div.addEventListener('click', () => closeModal());
|
|||
|
|
}
|
|||
|
|
window.openModal = function (title, bodyHtml, footerHtml, large) {
|
|||
|
|
ensureModal();
|
|||
|
|
$('#modalTitle').textContent = title;
|
|||
|
|
$('#modalBody').innerHTML = bodyHtml;
|
|||
|
|
$('#modalFooter').innerHTML = footerHtml || '<button class="btn" onclick="closeModal()">关闭</button>';
|
|||
|
|
$('#modal').classList.toggle('modal-lg', !!large);
|
|||
|
|
$('#modalOverlay').classList.add('show');
|
|||
|
|
};
|
|||
|
|
window.closeModal = function () { $('#modalOverlay').classList.remove('show'); };
|
|||
|
|
|
|||
|
|
function ensureToast() {
|
|||
|
|
if ($('#toastContainer')) return;
|
|||
|
|
const div = document.createElement('div');
|
|||
|
|
div.id = 'toastContainer';
|
|||
|
|
document.body.appendChild(div);
|
|||
|
|
}
|
|||
|
|
window.showToast = function (title, msg, type = 'info') {
|
|||
|
|
ensureToast();
|
|||
|
|
const t = document.createElement('div');
|
|||
|
|
t.className = `toast ${type}`;
|
|||
|
|
t.innerHTML = `<div class="toast-title">${title}</div><div class="toast-msg">${msg}</div>`;
|
|||
|
|
$('#toastContainer').appendChild(t);
|
|||
|
|
setTimeout(() => t.remove(), 3500);
|
|||
|
|
};
|
|||
|
|
window.confirmAction = function (msg, onOk) {
|
|||
|
|
openModal('确认操作', `<p>${msg}</p>`, `
|
|||
|
|
<button class="btn" onclick="closeModal()">取消</button>
|
|||
|
|
<button class="btn btn-danger" onclick="closeModal();(${onOk})()">确认</button>`);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// -------------- 状态标签 --------------
|
|||
|
|
window.statusBadge = function (status, map) {
|
|||
|
|
const cls = (map && map[status]) || 'default';
|
|||
|
|
const label = (window.STATUS_LABELS && window.STATUS_LABELS[status]) || status;
|
|||
|
|
return `<span class="badge badge-${cls}">${label}</span>`;
|
|||
|
|
};
|
|||
|
|
window.STATUS_LABELS = {
|
|||
|
|
DRAFT: '草稿', SUBMITTED: '待审核', CONFIRMED: '已确认', REJECTED: '已退回', IN_PRODUCTION: '生产中', COMPLETED: '已完成', CANCELLED: '已取消',
|
|||
|
|
PENDING: '待排产', ISSUED: '已下发', PAUSED: '已暂停', CLOSED: '已关闭',
|
|||
|
|
PASSED: '通过', PARTIAL: '部分', FAILED: '失败', FULFILLED: '齐套', SHORTAGE: '缺料',
|
|||
|
|
CRITICAL: '致命', MAJOR: '严重', MINOR: '警告', RESOLVED: '已解决',
|
|||
|
|
RULE: '规则引擎', CP: '约束规划', GA: '遗传算法', HYBRID: '混合引擎',
|
|||
|
|
RUNNING: '运行中', IDLE: '待机', FAULT: '故障', MAINTENANCE: '保养', OFFLINE: '离线'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// -------------- 查找器 --------------
|
|||
|
|
window.findById = function (arr, id) { return arr.find(x => String(x.id) === String(id)); };
|
|||
|
|
window.findLineWorkstations = function (lineId) { return apsData.workstations.filter(w => w.lineId == lineId).sort((a, b) => a.sequenceNo - b.sequenceNo); };
|
|||
|
|
window.findRoutingSteps = function (productId) {
|
|||
|
|
const routing = apsData.routings.find(r => r.productId == productId && r.isDefault);
|
|||
|
|
if (!routing) return [];
|
|||
|
|
return apsData.routingSteps.filter(s => s.routingId == routing.id).sort((a, b) => a.sequenceNo - b.sequenceNo);
|
|||
|
|
};
|
|||
|
|
window.findBomItems = function (productId) {
|
|||
|
|
const bom = apsData.boms.find(b => b.productId == productId && b.isDefault);
|
|||
|
|
if (!bom) return [];
|
|||
|
|
return apsData.bomItems.filter(i => i.bomId == bom.id);
|
|||
|
|
};
|
|||
|
|
window.findProductLines = function (productId) {
|
|||
|
|
return apsData.lineProducts.filter(lp => lp.productId == productId).sort((a, b) => a.priority - b.priority);
|
|||
|
|
};
|
|||
|
|
window.findWorkstationForOperation = function (lineId, operationId) {
|
|||
|
|
const wsIds = apsData.workstationOperations.filter(wo => wo.operationId == operationId).map(wo => wo.workstationId);
|
|||
|
|
return apsData.workstations.find(ws => ws.lineId == lineId && wsIds.includes(ws.id));
|
|||
|
|
};
|
|||
|
|
window.getShiftMinutes = function (shiftId, dateStr) {
|
|||
|
|
const shift = apsData.shifts.find(s => s.id == shiftId);
|
|||
|
|
if (!shift) return 0;
|
|||
|
|
const [sh, sm] = shift.startTime.split(':').map(Number);
|
|||
|
|
const [eh, em] = shift.endTime.split(':').map(Number);
|
|||
|
|
let total = (eh * 60 + em) - (sh * 60 + sm);
|
|||
|
|
(shift.breakPeriods || []).forEach(bp => {
|
|||
|
|
const [bsh, bsm] = bp.start.split(':').map(Number);
|
|||
|
|
const [beh, bem] = bp.end.split(':').map(Number);
|
|||
|
|
total -= (beh * 60 + bem) - (bsh * 60 + bsm);
|
|||
|
|
});
|
|||
|
|
return Math.max(total, 0);
|
|||
|
|
};
|
|||
|
|
window.getLineShifts = function (lineId, dateStr) {
|
|||
|
|
return apsData.shiftCalendar.filter(sc => sc.lineId == lineId && sc.date == dateStr && sc.isWorking).map(sc => apsData.shifts.find(s => s.id == sc.shiftId)).filter(Boolean);
|
|||
|
|
};
|
|||
|
|
window.getAvailableMinutes = function (lineId, dateStr) {
|
|||
|
|
return getLineShifts(lineId, dateStr).reduce((sum, s) => sum + getShiftMinutes(s.id, dateStr), 0);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// -------------- 图表 --------------
|
|||
|
|
function setupCanvas(canvasId) {
|
|||
|
|
const c = document.getElementById(canvasId);
|
|||
|
|
if (!c) return null;
|
|||
|
|
const rect = c.getBoundingClientRect();
|
|||
|
|
c.width = rect.width * 2;
|
|||
|
|
c.height = rect.height * 2;
|
|||
|
|
const ctx = c.getContext('2d');
|
|||
|
|
ctx.scale(2, 2);
|
|||
|
|
return { c, ctx, w: rect.width, h: rect.height };
|
|||
|
|
}
|
|||
|
|
window.drawBarChart = function (canvasId, labels, data, color = '#f97316') {
|
|||
|
|
const o = setupCanvas(canvasId); if (!o) return;
|
|||
|
|
const { ctx, w, h } = o;
|
|||
|
|
ctx.clearRect(0, 0, w, h);
|
|||
|
|
const pad = 28, bh = h - pad - 10, bw = (w - pad * 2) / labels.length * 0.6;
|
|||
|
|
const max = Math.max(...data, 1);
|
|||
|
|
labels.forEach((l, i) => {
|
|||
|
|
const val = data[i];
|
|||
|
|
const barH = (val / max) * (bh - 20);
|
|||
|
|
const x = pad + i * ((w - pad * 2) / labels.length) + bw * 0.33;
|
|||
|
|
const y = bh - barH;
|
|||
|
|
ctx.fillStyle = color;
|
|||
|
|
ctx.fillRect(x, y, bw, barH);
|
|||
|
|
ctx.fillStyle = '#374151';
|
|||
|
|
ctx.font = '11px sans-serif';
|
|||
|
|
ctx.textAlign = 'center';
|
|||
|
|
ctx.fillText(val, x + bw / 2, y - 5);
|
|||
|
|
ctx.fillStyle = '#6b7280';
|
|||
|
|
ctx.fillText(l, x + bw / 2, h - 6);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
window.drawLineChart = function (canvasId, labels, series) {
|
|||
|
|
const o = setupCanvas(canvasId); if (!o) return;
|
|||
|
|
const { ctx, w, h } = o;
|
|||
|
|
ctx.clearRect(0, 0, w, h);
|
|||
|
|
const pad = 28, max = Math.max(...series.flat(), 1);
|
|||
|
|
const stepX = (w - pad * 2) / (labels.length - 1 || 1);
|
|||
|
|
ctx.strokeStyle = '#e5e7eb';
|
|||
|
|
for (let i = 0; i < 5; i++) {
|
|||
|
|
const y = pad + i * ((h - pad * 2) / 4);
|
|||
|
|
ctx.beginPath(); ctx.moveTo(pad, y); ctx.lineTo(w - pad, y); ctx.stroke();
|
|||
|
|
}
|
|||
|
|
const colors = ['#f97316', '#16a34a', '#0ea5e9', '#eab308'];
|
|||
|
|
series.forEach((data, idx) => {
|
|||
|
|
ctx.strokeStyle = colors[idx % colors.length];
|
|||
|
|
ctx.lineWidth = 2;
|
|||
|
|
ctx.beginPath();
|
|||
|
|
data.forEach((v, i) => {
|
|||
|
|
const x = pad + i * stepX;
|
|||
|
|
const y = h - pad - (v / max) * (h - pad * 2);
|
|||
|
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|||
|
|
});
|
|||
|
|
ctx.stroke();
|
|||
|
|
});
|
|||
|
|
ctx.fillStyle = '#6b7280';
|
|||
|
|
ctx.textAlign = 'center';
|
|||
|
|
labels.forEach((l, i) => ctx.fillText(l, pad + i * stepX, h - 8));
|
|||
|
|
};
|
|||
|
|
window.drawPieChart = function (canvasId, data) {
|
|||
|
|
const o = setupCanvas(canvasId); if (!o) return;
|
|||
|
|
const { ctx, w, h } = o;
|
|||
|
|
ctx.clearRect(0, 0, w, h);
|
|||
|
|
const total = data.reduce((a, b) => a + b.value, 0);
|
|||
|
|
const cx = w / 2 - 60, cy = h / 2, r = Math.min(w, h) / 2 - 30;
|
|||
|
|
let start = -Math.PI / 2;
|
|||
|
|
data.forEach(d => {
|
|||
|
|
const ang = (d.value / total) * Math.PI * 2;
|
|||
|
|
ctx.fillStyle = d.color;
|
|||
|
|
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, r, start, start + ang); ctx.closePath(); ctx.fill();
|
|||
|
|
start += ang;
|
|||
|
|
});
|
|||
|
|
let ly = 30;
|
|||
|
|
ctx.textAlign = 'left';
|
|||
|
|
data.forEach(d => {
|
|||
|
|
ctx.fillStyle = d.color; ctx.fillRect(cx + r + 30, ly - 8, 12, 12);
|
|||
|
|
ctx.fillStyle = '#374151'; ctx.font = '12px sans-serif';
|
|||
|
|
ctx.fillText(`${d.label} ${d.value}`, cx + r + 48, ly + 2);
|
|||
|
|
ly += 22;
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// -------------- 排产引擎 --------------
|
|||
|
|
/**
|
|||
|
|
* params: { orderIds, engineType, strategyTemplate, planningHorizonDays, startDate, constraints, onProgress }
|
|||
|
|
*/
|
|||
|
|
window.runScheduling = function (params, callback) {
|
|||
|
|
const { orderIds, engineType, strategyTemplate, planningHorizonDays, startDate, constraints, onProgress } = params;
|
|||
|
|
const allItems = [];
|
|||
|
|
apsData.salesOrders.forEach(so => {
|
|||
|
|
if (orderIds && orderIds.length && !orderIds.includes(so.id)) return;
|
|||
|
|
if (so.status === 'CANCELLED' || so.status === 'COMPLETED') return;
|
|||
|
|
so.items.forEach(item => {
|
|||
|
|
if (item.status === 'COMPLETED') return;
|
|||
|
|
allItems.push({ so, item });
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 排序规则
|
|||
|
|
const sortFn = (a, b) => {
|
|||
|
|
if (strategyTemplate === 'DELIVERY_FIRST') {
|
|||
|
|
return a.so.deliveryDate.localeCompare(b.so.deliveryDate) || a.so.priority - b.so.priority;
|
|||
|
|
}
|
|||
|
|
if (strategyTemplate === 'FIFO') return a.so.orderDate.localeCompare(b.so.orderDate);
|
|||
|
|
if (strategyTemplate === 'CAPACITY_BALANCE') return a.so.priority - b.so.priority;
|
|||
|
|
if (strategyTemplate === 'COST_FIRST') return a.so.priority - b.so.priority;
|
|||
|
|
return a.so.priority - b.so.priority || a.so.deliveryDate.localeCompare(b.so.deliveryDate);
|
|||
|
|
};
|
|||
|
|
allItems.sort(sortFn);
|
|||
|
|
|
|||
|
|
const version = {
|
|||
|
|
id: apsData.$utils.nextId('scheduleVersion'),
|
|||
|
|
versionNo: 'V' + apsData.$utils.fmtDate(new Date()).replace(/-/g, '') + '-' + String(apsData.scheduleVersions.length + 1).padStart(3, '0'),
|
|||
|
|
versionName: params.name || ('手动排产 ' + apsData.$utils.fmtDateTime(new Date())),
|
|||
|
|
triggerType: params.triggerType || 'MANUAL',
|
|||
|
|
engineType: engineType || 'RULE',
|
|||
|
|
status: 'DRAFT',
|
|||
|
|
parentVersionId: apsData.scheduleVersions.length ? apsData.scheduleVersions[apsData.scheduleVersions.length - 1].id : null,
|
|||
|
|
orderCount: allItems.length,
|
|||
|
|
poCount: 0,
|
|||
|
|
woCount: 0,
|
|||
|
|
totalTardiness: 0,
|
|||
|
|
totalCost: 0,
|
|||
|
|
avgUtilization: 0,
|
|||
|
|
conflictCount: 0,
|
|||
|
|
resolvedCount: 0,
|
|||
|
|
createdBy: 'admin',
|
|||
|
|
createdAt: apsData.$utils.fmtDateTime(new Date()),
|
|||
|
|
publishedAt: null,
|
|||
|
|
note: params.note || ''
|
|||
|
|
};
|
|||
|
|
apsData.scheduleVersions.push(version);
|
|||
|
|
|
|||
|
|
// 生成生产订单与工单
|
|||
|
|
const horizon = planningHorizonDays || 30;
|
|||
|
|
const baseStart = startDate ? apsData.$utils.parseDate(startDate + ' 08:00') : apsData.$utils.addMinutes(apsData.$utils.today(), 24 * 60);
|
|||
|
|
const usedWorkstations = {}; // wsId -> [{start,end}]
|
|||
|
|
const usedLineMinutes = {}; // lineId_date -> minutes
|
|||
|
|
const conflicts = [];
|
|||
|
|
|
|||
|
|
allItems.forEach(({ so, item }) => {
|
|||
|
|
const product = apsData.materials.find(m => m.id == item.productId);
|
|||
|
|
const lineOptions = findProductLines(item.productId);
|
|||
|
|
if (!lineOptions.length) {
|
|||
|
|
conflicts.push({ conflictType: 'NO_LINE', severity: 'CRITICAL', productionOrderId: null, resourceType: 'LINE', description: `产品 ${product.name} 无可用产线配置`, suggestedSolution: '维护产线产品配置' });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
// 选择产线
|
|||
|
|
let chosenLineId = lineOptions[0].lineId;
|
|||
|
|
if (strategyTemplate === 'CAPACITY_BALANCE') {
|
|||
|
|
chosenLineId = lineOptions.sort((a, b) => (usedLineMinutes[a.lineId] || 0) - (usedLineMinutes[b.lineId] || 0))[0].lineId;
|
|||
|
|
}
|
|||
|
|
const line = apsData.lines.find(l => l.id == chosenLineId);
|
|||
|
|
const wsList = findRoutingSteps(item.productId).map(step => {
|
|||
|
|
const ws = findWorkstationForOperation(chosenLineId, step.operationId);
|
|||
|
|
return { step, ws };
|
|||
|
|
});
|
|||
|
|
if (wsList.some(x => !x.ws)) {
|
|||
|
|
conflicts.push({ conflictType: 'NO_WORKSTATION', severity: 'CRITICAL', productionOrderId: null, resourceType: 'WORKSTATION', description: `产品 ${product.name} 在产线 ${line.name} 缺少可用工位`, suggestedSolution: '维护工位工序配置' });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 物料齐套时间
|
|||
|
|
let materialReady = baseStart;
|
|||
|
|
if (constraints && constraints.materialKit !== false) {
|
|||
|
|
const bomItems = findBomItems(item.productId);
|
|||
|
|
bomItems.forEach(bi => {
|
|||
|
|
const mat = apsData.materials.find(m => m.id == bi.materialId);
|
|||
|
|
const need = bi.quantity * item.quantity;
|
|||
|
|
if (mat.stock < need && mat.stock + mat.inTransit >= need) {
|
|||
|
|
const eta = apsData.$utils.addMinutes(apsData.$utils.today(), 3 * 24 * 60);
|
|||
|
|
if (eta > materialReady) materialReady = eta;
|
|||
|
|
} else if (mat.stock + mat.inTransit < need) {
|
|||
|
|
conflicts.push({ conflictType: 'MATERIAL_SHORTAGE', severity: 'MAJOR', resourceType: 'MATERIAL', resourceName: mat.name, description: `${so.orderNo} ${mat.name} 缺料 ${Math.ceil(need - mat.stock - mat.inTransit)} ${mat.unit}`, suggestedSolution: '紧急采购或启用替代物料' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 逐个工序排产
|
|||
|
|
let cursor = new Date(Math.max(baseStart.getTime(), materialReady.getTime()));
|
|||
|
|
const poStart = cursor;
|
|||
|
|
let poEnd = cursor;
|
|||
|
|
const productionOrder = {
|
|||
|
|
id: apsData.$utils.nextId('productionOrder'),
|
|||
|
|
orderNo: 'PO' + so.orderNo.replace(/SO/, ''),
|
|||
|
|
salesOrderId: so.id,
|
|||
|
|
salesOrderNo: so.orderNo,
|
|||
|
|
salesOrderItemId: item.id,
|
|||
|
|
productId: item.productId,
|
|||
|
|
productName: product.name,
|
|||
|
|
productCode: product.code,
|
|||
|
|
quantity: item.quantity,
|
|||
|
|
unit: item.unit,
|
|||
|
|
plannedStartDate: null,
|
|||
|
|
plannedEndDate: null,
|
|||
|
|
lineId: line.id,
|
|||
|
|
lineName: line.name,
|
|||
|
|
status: 'DRAFT',
|
|||
|
|
priority: so.priority,
|
|||
|
|
schedulingEngine: engineType || 'RULE',
|
|||
|
|
schedulingVersionId: version.id,
|
|||
|
|
optimizationScore: 0,
|
|||
|
|
materialKitStatus: 'PASSED',
|
|||
|
|
constraintCheckStatus: 'PASSED',
|
|||
|
|
conflictCount: 0,
|
|||
|
|
isRushOrder: so.isRush,
|
|||
|
|
rushStrategy: so.rushStrategy
|
|||
|
|
};
|
|||
|
|
apsData.productionOrders.push(productionOrder);
|
|||
|
|
version.poCount++;
|
|||
|
|
|
|||
|
|
wsList.forEach(({ step, ws }, idx) => {
|
|||
|
|
const op = apsData.operations.find(o => o.id == step.operationId);
|
|||
|
|
const durationMin = step.setupTime + (item.quantity * step.runTimePerUnit) / (line.efficiencyFactor || 1);
|
|||
|
|
const placed = placeWorkOrder(cursor, chosenLineId, ws, durationMin, usedWorkstations, usedLineMinutes, productionOrder, apsData.$utils, constraints);
|
|||
|
|
const wo = {
|
|||
|
|
id: apsData.$utils.nextId('workOrder'),
|
|||
|
|
orderNo: productionOrder.orderNo + '-' + String(idx + 1).padStart(2, '0'),
|
|||
|
|
productionOrderId: productionOrder.id,
|
|||
|
|
productionOrderNo: productionOrder.orderNo,
|
|||
|
|
operationId: op.id,
|
|||
|
|
operationName: op.name,
|
|||
|
|
sequenceNo: step.sequenceNo,
|
|||
|
|
productId: item.productId,
|
|||
|
|
productName: product.name,
|
|||
|
|
quantity: item.quantity,
|
|||
|
|
unit: item.unit,
|
|||
|
|
completedQuantity: 0,
|
|||
|
|
lineId: line.id,
|
|||
|
|
lineName: line.name,
|
|||
|
|
workstationId: ws.id,
|
|||
|
|
workstationName: ws.name,
|
|||
|
|
plannedStartTime: apsData.$utils.fmtDateTime(placed.start),
|
|||
|
|
plannedEndTime: apsData.$utils.fmtDateTime(placed.end),
|
|||
|
|
status: 'PENDING',
|
|||
|
|
teamId: null,
|
|||
|
|
teamName: null,
|
|||
|
|
requiredMaterials: [],
|
|||
|
|
requiredEquipment: [],
|
|||
|
|
priority: so.priority,
|
|||
|
|
isFrozen: false,
|
|||
|
|
kitStatus: 'PASSED',
|
|||
|
|
progressPercent: 0,
|
|||
|
|
conflictCount: 0,
|
|||
|
|
note: ''
|
|||
|
|
};
|
|||
|
|
// 物料需求
|
|||
|
|
const bomItems = findBomItems(item.productId).filter(bi => bi.operationId == op.id);
|
|||
|
|
bomItems.forEach(bi => {
|
|||
|
|
const mat = apsData.materials.find(m => m.id == bi.materialId);
|
|||
|
|
const need = bi.quantity * item.quantity;
|
|||
|
|
let ks = 'PASSED';
|
|||
|
|
if (mat.stock < need) {
|
|||
|
|
if (mat.stock + mat.inTransit >= need) ks = 'PARTIAL'; else ks = 'FAILED';
|
|||
|
|
}
|
|||
|
|
if (ks !== 'PASSED' && wo.kitStatus === 'PASSED') wo.kitStatus = ks;
|
|||
|
|
else if (ks === 'FAILED') wo.kitStatus = 'FAILED';
|
|||
|
|
wo.requiredMaterials.push({ materialId: mat.id, name: mat.name, required: need, allocated: Math.min(need, mat.stock), available: mat.stock, status: ks });
|
|||
|
|
});
|
|||
|
|
if (wo.kitStatus !== 'PASSED') productionOrder.materialKitStatus = wo.kitStatus;
|
|||
|
|
|
|||
|
|
apsData.workOrders.push(wo);
|
|||
|
|
version.woCount++;
|
|||
|
|
cursor = apsData.$utils.addMinutes(placed.end, step.transferTime + step.waitTime);
|
|||
|
|
poEnd = placed.end;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
productionOrder.plannedStartDate = apsData.$utils.fmtDateTime(poStart);
|
|||
|
|
productionOrder.plannedEndDate = apsData.$utils.fmtDateTime(poEnd);
|
|||
|
|
const due = apsData.$utils.parseDate(so.deliveryDate + ' 18:00');
|
|||
|
|
if (poEnd > due) {
|
|||
|
|
const hoursLate = (poEnd - due) / 3600000;
|
|||
|
|
version.totalTardiness += hoursLate;
|
|||
|
|
conflicts.push({ conflictType: 'DELAY', severity: 'MAJOR', productionOrderId: productionOrder.id, resourceType: 'TIME', description: `${productionOrder.orderNo} 完成时间晚于交期 ${hoursLate.toFixed(1)} 小时`, suggestedSolution: '启用加班/替代产线/压缩准备时间' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 容量冲突检测
|
|||
|
|
apsData.lines.forEach(line => {
|
|||
|
|
const loads = {};
|
|||
|
|
apsData.workOrders.filter(wo => wo.lineId == line.id && !wo.isFrozen).forEach(wo => {
|
|||
|
|
const d = apsData.$utils.fmtDate(wo.plannedStartTime);
|
|||
|
|
const dur = (apsData.$utils.parseDate(wo.plannedEndTime) - apsData.$utils.parseDate(wo.plannedStartTime)) / 60000;
|
|||
|
|
loads[d] = (loads[d] || 0) + dur;
|
|||
|
|
});
|
|||
|
|
Object.entries(loads).forEach(([d, min]) => {
|
|||
|
|
const avail = getAvailableMinutes(line.id, d);
|
|||
|
|
if (min > avail * 1.05) {
|
|||
|
|
conflicts.push({ conflictType: 'CAPACITY', severity: 'CRITICAL', resourceType: 'LINE', resourceName: line.name, conflictTimeStart: d + ' 08:00', description: `${line.name} ${d} 负荷 ${Math.round(min)} 分钟,超出可用 ${Math.round(avail)} 分钟`, suggestedSolution: '分流至替代产线或启用加班' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 设备维保冲突
|
|||
|
|
apsData.maintenance.forEach(m => {
|
|||
|
|
apsData.workOrders.forEach(wo => {
|
|||
|
|
const eq = apsData.equipment.find(e => e.workstationId == wo.workstationId);
|
|||
|
|
if (!eq || eq.id != m.equipmentId) return;
|
|||
|
|
const ws = apsData.$utils.parseDate(wo.plannedStartTime);
|
|||
|
|
const we = apsData.$utils.parseDate(wo.plannedEndTime);
|
|||
|
|
const ms = apsData.$utils.parseDate(m.plannedStart);
|
|||
|
|
const me = apsData.$utils.parseDate(m.plannedEnd);
|
|||
|
|
if (ws < me && we > ms) {
|
|||
|
|
conflicts.push({ conflictType: 'EQUIPMENT', severity: 'CRITICAL', workOrderId: wo.id, resourceType: 'EQUIPMENT', resourceName: eq.name, description: `${wo.orderNo} 与设备 ${eq.name} 维保时间冲突`, suggestedSolution: '调整工单时间或启用替代设备' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 冲突入库
|
|||
|
|
conflicts.forEach(cf => {
|
|||
|
|
cf.id = apsData.$utils.nextId('conflict');
|
|||
|
|
cf.versionId = version.id;
|
|||
|
|
cf.isResolved = false;
|
|||
|
|
cf.resolutionAction = '';
|
|||
|
|
cf.resolvedBy = '';
|
|||
|
|
apsData.conflicts.push(cf);
|
|||
|
|
});
|
|||
|
|
version.conflictCount = conflicts.length;
|
|||
|
|
|
|||
|
|
// 更新关联工单冲突计数与 PO 状态
|
|||
|
|
apsData.productionOrders.filter(po => po.schedulingVersionId == version.id).forEach(po => {
|
|||
|
|
po.conflictCount = apsData.conflicts.filter(c => c.productionOrderId == po.id || (c.workOrderId && apsData.workOrders.find(wo => wo.id == c.workOrderId)?.productionOrderId == po.id)).length;
|
|||
|
|
if (po.conflictCount > 0) po.constraintCheckStatus = 'FAILED';
|
|||
|
|
else if (po.materialKitStatus !== 'PASSED') po.constraintCheckStatus = 'WARNING';
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 统计利用率
|
|||
|
|
const lineUtil = {};
|
|||
|
|
apsData.workOrders.filter(wo => !wo.isFrozen).forEach(wo => {
|
|||
|
|
const d = apsData.$utils.fmtDate(wo.plannedStartTime);
|
|||
|
|
const dur = (apsData.$utils.parseDate(wo.plannedEndTime) - apsData.$utils.parseDate(wo.plannedStartTime)) / 60000;
|
|||
|
|
lineUtil[wo.lineId] = lineUtil[wo.lineId] || { used: 0, avail: 0 };
|
|||
|
|
lineUtil[wo.lineId].used += dur;
|
|||
|
|
});
|
|||
|
|
apsData.lines.forEach(line => {
|
|||
|
|
for (let i = 0; i < horizon; i++) {
|
|||
|
|
const d = apsData.$utils.fmtDate(apsData.$utils.addMinutes(baseStart, i * 24 * 60));
|
|||
|
|
if (!lineUtil[line.id]) lineUtil[line.id] = { used: 0, avail: 0 };
|
|||
|
|
lineUtil[line.id].avail += getAvailableMinutes(line.id, d);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
const utilVals = Object.values(lineUtil).map(x => x.avail ? x.used / x.avail : 0);
|
|||
|
|
version.avgUtilization = utilVals.length ? utilVals.reduce((a, b) => a + b, 0) / utilVals.length : 0;
|
|||
|
|
version.totalCost = version.woCount * 120 + version.totalTardiness * 50;
|
|||
|
|
|
|||
|
|
// 日志
|
|||
|
|
apsData.logs.unshift({
|
|||
|
|
id: apsData.$utils.nextId('log'),
|
|||
|
|
type: 'INFO',
|
|||
|
|
category: 'SCHEDULE',
|
|||
|
|
operationType: 'SCHEDULE',
|
|||
|
|
targetType: 'SCHEDULE_VERSION',
|
|||
|
|
targetId: version.id,
|
|||
|
|
action: '执行排产',
|
|||
|
|
description: `版本 ${version.versionNo} 生成 ${version.poCount} 个生产订单 / ${version.woCount} 个工单,冲突 ${version.conflictCount}`,
|
|||
|
|
operator: 'admin',
|
|||
|
|
createdAt: apsData.$utils.fmtDateTime(new Date())
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
apsData.$utils.save(apsData);
|
|||
|
|
if (onProgress) onProgress(100, 'COMPLETED');
|
|||
|
|
if (callback) callback(version);
|
|||
|
|
return version;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function placeWorkOrder(cursor, lineId, ws, durationMin, usedWorkstations, usedLineMinutes, productionOrder, utils, constraints) {
|
|||
|
|
const maxDays = 30;
|
|||
|
|
for (let dayOffset = 0; dayOffset < maxDays; dayOffset++) {
|
|||
|
|
const day = utils.addMinutes(cursor, dayOffset * 24 * 60);
|
|||
|
|
const dateStr = utils.fmtDate(day);
|
|||
|
|
const shifts = getLineShifts(lineId, dateStr).filter(s => s);
|
|||
|
|
if (!shifts.length) continue;
|
|||
|
|
for (const shift of shifts) {
|
|||
|
|
const [sh, sm] = shift.startTime.split(':').map(Number);
|
|||
|
|
let candidate = utils.parseDate(dateStr + ' ' + String(sh).padStart(2, '0') + ':' + String(sm).padStart(2, '0'));
|
|||
|
|
if (candidate < cursor && dayOffset === 0) candidate = new Date(cursor);
|
|||
|
|
const [eh, em] = shift.endTime.split(':').map(Number);
|
|||
|
|
const shiftEnd = utils.parseDate(dateStr + ' ' + String(eh).padStart(2, '0') + ':' + String(em).padStart(2, '0'));
|
|||
|
|
const availableEnd = new Date(Math.min(shiftEnd.getTime(), candidate.getTime() + durationMin * 60000 + 24 * 60 * 60000));
|
|||
|
|
const start = findSlot(candidate, availableEnd, ws.id, durationMin, usedWorkstations);
|
|||
|
|
if (start) {
|
|||
|
|
const end = utils.addMinutes(start, durationMin);
|
|||
|
|
if (end > shiftEnd) continue;
|
|||
|
|
pushInterval(usedWorkstations, ws.id, start, end);
|
|||
|
|
const key = `${lineId}_${dateStr}`;
|
|||
|
|
usedLineMinutes[key] = (usedLineMinutes[key] || 0) + durationMin;
|
|||
|
|
return { start, end };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// fallback: 放在 cursor
|
|||
|
|
return { start: cursor, end: utils.addMinutes(cursor, durationMin) };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function findSlot(from, toEnd, wsId, durationMin, usedWorkstations) {
|
|||
|
|
let t = new Date(from);
|
|||
|
|
const intervals = usedWorkstations[wsId] || [];
|
|||
|
|
while (t <= toEnd) {
|
|||
|
|
const end = apsData.$utils.addMinutes(t, durationMin);
|
|||
|
|
const overlap = intervals.some(iv => t < iv.end && end > iv.start);
|
|||
|
|
if (!overlap) return t;
|
|||
|
|
t = apsData.$utils.addMinutes(t, 15);
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
function pushInterval(map, wsId, start, end) {
|
|||
|
|
map[wsId] = map[wsId] || [];
|
|||
|
|
map[wsId].push({ start, end });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// -------------- 拖拽校验 --------------
|
|||
|
|
window.checkMoveConflict = function (workOrder, newStartStr) {
|
|||
|
|
const newStart = apsData.$utils.parseDate(newStartStr);
|
|||
|
|
const dur = (apsData.$utils.parseDate(workOrder.plannedEndTime) - apsData.$utils.parseDate(workOrder.plannedStartTime)) / 60000;
|
|||
|
|
const newEnd = apsData.$utils.addMinutes(newStart, dur);
|
|||
|
|
const conflicts = [];
|
|||
|
|
const dateStr = apsData.$utils.fmtDate(newStart);
|
|||
|
|
const shifts = getLineShifts(workOrder.lineId, dateStr);
|
|||
|
|
if (!shifts.length) conflicts.push({ type: 'NO_SHIFT', severity: 'CRITICAL', message: '目标日期无工作班次' });
|
|||
|
|
// 设备维保
|
|||
|
|
const eq = apsData.equipment.find(e => e.workstationId == workOrder.workstationId);
|
|||
|
|
if (eq) {
|
|||
|
|
apsData.maintenance.filter(m => m.equipmentId == eq.id).forEach(m => {
|
|||
|
|
const ms = apsData.$utils.parseDate(m.plannedStart), me = apsData.$utils.parseDate(m.plannedEnd);
|
|||
|
|
if (newStart < me && newEnd > ms) conflicts.push({ type: 'EQUIPMENT', severity: 'CRITICAL', message: `与设备维保 ${m.description} 冲突` });
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
// 时间重叠
|
|||
|
|
const overlaps = apsData.workOrders.filter(wo => wo.id != workOrder.id && wo.workstationId == workOrder.workstationId && wo.status !== 'CANCELLED')
|
|||
|
|
.some(wo => {
|
|||
|
|
const ws = apsData.$utils.parseDate(wo.plannedStartTime), we = apsData.$utils.parseDate(wo.plannedEndTime);
|
|||
|
|
return newStart < we && newEnd > ws;
|
|||
|
|
});
|
|||
|
|
if (overlaps) conflicts.push({ type: 'OVERLAP', severity: 'CRITICAL', message: '目标时段与其他工单时间重叠' });
|
|||
|
|
// 容量
|
|||
|
|
const line = apsData.lines.find(l => l.id == workOrder.lineId);
|
|||
|
|
const avail = getAvailableMinutes(line.id, dateStr);
|
|||
|
|
if (dur > avail) conflicts.push({ type: 'CAPACITY', severity: 'MAJOR', message: `${line.name} 当日可用产能不足` });
|
|||
|
|
return { canApply: !conflicts.length, conflicts, newEnd: apsData.$utils.fmtDateTime(newEnd) };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// -------------- 全局工具 --------------
|
|||
|
|
window.fmtDuration = function (min) {
|
|||
|
|
if (min < 60) return Math.round(min) + '分钟';
|
|||
|
|
const h = Math.floor(min / 60);
|
|||
|
|
const m = Math.round(min % 60);
|
|||
|
|
return m ? `${h}小时${m}分` : `${h}小时`;
|
|||
|
|
};
|
|||
|
|
window.renderPagination = function (containerId, page, total, pageSize, onClick) {
|
|||
|
|
const c = $('#' + containerId);
|
|||
|
|
if (!c) return;
|
|||
|
|
const pages = Math.max(1, Math.ceil(total / pageSize));
|
|||
|
|
let html = `<button class="page-btn" ${page <= 1 ? 'disabled' : ''} onclick="${onClick}(${page - 1})">上一页</button>`;
|
|||
|
|
for (let i = 1; i <= pages; i++) {
|
|||
|
|
if (i === 1 || i === pages || Math.abs(i - page) <= 1) {
|
|||
|
|
html += `<button class="page-btn ${i === page ? 'active' : ''}" onclick="${onClick}(${i})">${i}</button>`;
|
|||
|
|
} else if (Math.abs(i - page) === 2) html += `<span class="text-muted">…</span>`;
|
|||
|
|
}
|
|||
|
|
html += `<button class="page-btn" ${page >= pages ? 'disabled' : ''} onclick="${onClick}(${page + 1})">下一页</button>`;
|
|||
|
|
c.innerHTML = html;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 首次访问时自动执行一次排产,生成初始测试效果
|
|||
|
|
if (apsData.scheduleVersions.length === 0) {
|
|||
|
|
const tomorrow = apsData.$utils.fmtDate(apsData.$utils.addMinutes(apsData.$utils.today(), 24 * 60));
|
|||
|
|
runScheduling({
|
|||
|
|
orderIds: [],
|
|||
|
|
engineType: 'HYBRID',
|
|||
|
|
strategyTemplate: 'COMPREHENSIVE',
|
|||
|
|
planningHorizonDays: 14,
|
|||
|
|
startDate: tomorrow,
|
|||
|
|
constraints: { materialKit: true, equipment: true, personnel: true, changeover: true },
|
|||
|
|
triggerType: 'AUTO_ROLLING',
|
|||
|
|
name: '初始自动排产'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
})();
|