aps-agent/aps-frontend/js/common.js

733 lines
38 KiB
JavaScript
Raw Normal View History

2026-07-21 11:05:57 +08:00
/**
* 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: '初始自动排产'
});
}
})();