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

733 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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: '初始自动排产'
});
}
})();