Compare commits
6 Commits
e0cf0f30f6
...
d6764e56ba
| Author | SHA1 | Date |
|---|---|---|
|
|
d6764e56ba | |
|
|
27bf1f170e | |
|
|
d02d9d5fee | |
|
|
323eeb353c | |
|
|
5f983fb2c7 | |
|
|
1543e09eeb |
|
|
@ -604,6 +604,75 @@ ipcMain.handle('aps:pick-directory', async (event) => {
|
||||||
return result.filePaths[0];
|
return result.filePaths[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 工程目录内嵌浏览器(round 修复:原生文件夹选择框只显示文件夹,
|
||||||
|
// 用户看不到目录里的 Excel/PDF/SQL 等业务文件)。渲染进程按需列目录:
|
||||||
|
// 传 path 列该目录;path 为空 → Windows 列盘符、其他平台列根目录。
|
||||||
|
// 只读列举,绝不写文件系统;目录不可读显式返回 error(fail closed)。
|
||||||
|
const LIST_DIRECTORY_MAX_ENTRIES = 2000;
|
||||||
|
|
||||||
|
function listWindowsDrives() {
|
||||||
|
const drives = [];
|
||||||
|
for (let code = 67; code <= 90; code += 1) { // C..Z
|
||||||
|
const root = `${String.fromCharCode(code)}:\\`;
|
||||||
|
try {
|
||||||
|
fs.accessSync(root, fs.constants.R_OK);
|
||||||
|
drives.push(root);
|
||||||
|
} catch { /* 盘符不存在或不可读,跳过 */ }
|
||||||
|
}
|
||||||
|
return drives;
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('aps:list-directory', async (event, rawPath) => {
|
||||||
|
if (!isTrustedIpcEvent(event)) throw new Error('Untrusted IPC sender');
|
||||||
|
const target = typeof rawPath === 'string' ? rawPath.trim() : '';
|
||||||
|
if (!target) {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return {
|
||||||
|
path: '', parent: null,
|
||||||
|
entries: listWindowsDrives().map((root) => ({
|
||||||
|
name: root, path: root, type: 'dir', size: null, mtime: null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return listDirectoryEntries(path.parse(os.homedir()).root);
|
||||||
|
}
|
||||||
|
return listDirectoryEntries(path.resolve(target));
|
||||||
|
});
|
||||||
|
|
||||||
|
function listDirectoryEntries(dirPath) {
|
||||||
|
let items;
|
||||||
|
try {
|
||||||
|
items = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
|
} catch (err) {
|
||||||
|
return { path: dirPath, parent: path.dirname(dirPath), entries: [], error: `目录不可读:${err.message}` };
|
||||||
|
}
|
||||||
|
const entries = [];
|
||||||
|
for (const item of items.slice(0, LIST_DIRECTORY_MAX_ENTRIES)) {
|
||||||
|
const full = path.join(dirPath, item.name);
|
||||||
|
if (item.isDirectory()) {
|
||||||
|
entries.push({ name: item.name, path: full, type: 'dir', size: null, mtime: null });
|
||||||
|
} else if (item.isFile()) {
|
||||||
|
let stat = null;
|
||||||
|
try { stat = fs.statSync(full); } catch { /* 读取失败按 0 展示 */ }
|
||||||
|
entries.push({
|
||||||
|
name: item.name, path: full, type: 'file',
|
||||||
|
size: stat ? stat.size : null,
|
||||||
|
mtime: stat ? stat.mtimeMs : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.sort((a, b) => (a.type === b.type
|
||||||
|
? a.name.localeCompare(b.name, 'zh-Hans-CN', { sensitivity: 'base' })
|
||||||
|
: a.type === 'dir' ? -1 : 1)); // 目录在前、文件在后
|
||||||
|
const parent = path.dirname(dirPath);
|
||||||
|
return {
|
||||||
|
path: dirPath,
|
||||||
|
parent: parent === dirPath ? null : parent,
|
||||||
|
entries,
|
||||||
|
truncated: items.length > LIST_DIRECTORY_MAX_ENTRIES,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
ipcMain.on('aps:toggle-devtools', (event) => {
|
ipcMain.on('aps:toggle-devtools', (event) => {
|
||||||
if (!isTrustedIpcEvent(event)) return;
|
if (!isTrustedIpcEvent(event)) return;
|
||||||
const wc = event.sender;
|
const wc = event.sender;
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ try {
|
||||||
async pickDirectory() {
|
async pickDirectory() {
|
||||||
return ipcRenderer.invoke('aps:pick-directory');
|
return ipcRenderer.invoke('aps:pick-directory');
|
||||||
},
|
},
|
||||||
|
async listDirectory(dirPath) {
|
||||||
|
return ipcRenderer.invoke('aps:list-directory', dirPath || '');
|
||||||
|
},
|
||||||
|
|
||||||
/** 离线升级入口(updater.cjs):检查更新 / 应用升级(失败自动回滚)/ 手动回滚 */
|
/** 离线升级入口(updater.cjs):检查更新 / 应用升级(失败自动回滚)/ 手动回滚 */
|
||||||
checkForUpdate(options) {
|
checkForUpdate(options) {
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,19 @@ export interface FlexGanttWO {
|
||||||
dueDate?: string | null; // 交期(检查点投影富化)
|
dueDate?: string | null; // 交期(检查点投影富化)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 阻断订单(本版本有冲突但无工单,如缺料急单;排产推演显式展示)
|
||||||
|
export interface FlexGanttBlockedOrder {
|
||||||
|
orderNo: string; // 订单号
|
||||||
|
productCode: string; // 产品编码
|
||||||
|
productName: string; // 产品名
|
||||||
|
quantity: number | null; // 数量
|
||||||
|
dueDate: string | null; // 交期
|
||||||
|
priority: number | null; // 优先级
|
||||||
|
isRush: boolean; // 是否急单
|
||||||
|
severity: string; // 最重冲突等级
|
||||||
|
reasons: string[]; // 阻断原因清单
|
||||||
|
}
|
||||||
|
|
||||||
// 柔性甘特整体响应
|
// 柔性甘特整体响应
|
||||||
export interface FlexGanttData {
|
export interface FlexGanttData {
|
||||||
versionNo: string | null; // 最新柔性版本号
|
versionNo: string | null; // 最新柔性版本号
|
||||||
|
|
@ -327,6 +340,7 @@ export interface FlexGanttData {
|
||||||
zones: { code: string | null; name: string | null }[]; // 区域清单(分组)
|
zones: { code: string | null; name: string | null }[]; // 区域清单(分组)
|
||||||
equipment: { id: number; code: string; name: string; zone: string | null }[]; // 设备骨架
|
equipment: { id: number; code: string; name: string; zone: string | null }[]; // 设备骨架
|
||||||
workOrders: FlexGanttWO[]; // 最新版本柔性工单
|
workOrders: FlexGanttWO[]; // 最新版本柔性工单
|
||||||
|
blockedOrders?: FlexGanttBlockedOrder[]; // 未排入订单(冲突无工单)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 产能池仪表盘(/api/flex/capacity,EX-10)
|
// 产能池仪表盘(/api/flex/capacity,EX-10)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,14 @@ export interface ApsDesktopBridge {
|
||||||
openSkills?: () => void;
|
openSkills?: () => void;
|
||||||
openSessions?: () => void;
|
openSessions?: () => void;
|
||||||
pickDirectory?: () => Promise<string | null>;
|
pickDirectory?: () => Promise<string | null>;
|
||||||
|
/** 内嵌工程目录浏览器:列目录内容(目录 + 文件),path 为空时列盘符/根目录 */
|
||||||
|
listDirectory?: (dirPath?: string) => Promise<{
|
||||||
|
path: string;
|
||||||
|
parent: string | null;
|
||||||
|
entries: { name: string; path: string; type: 'dir' | 'file'; size: number | null; mtime: number | null }[];
|
||||||
|
truncated?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
toggleDevTools?: () => void;
|
toggleDevTools?: () => void;
|
||||||
toggleFullScreen?: () => void;
|
toggleFullScreen?: () => void;
|
||||||
leaveFullScreen?: () => void;
|
leaveFullScreen?: () => void;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// 替代 window.prompt / confirm:遮罩 + 卡片,风格对齐设计系统
|
// 替代 window.prompt / confirm:遮罩 + 卡片,风格对齐设计系统
|
||||||
// ============================================================
|
// ============================================================
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { FolderBrowserDialog } from './FolderBrowser';
|
||||||
|
|
||||||
/** 输入名对话框(新建对话/文件/重命名) */
|
/** 输入名对话框(新建对话/文件/重命名) */
|
||||||
export function NameDialog(props: {
|
export function NameDialog(props: {
|
||||||
|
|
@ -71,6 +72,7 @@ export function ProjectCreateDialog(props: {
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState('新工厂排产项目');
|
const [name, setName] = useState('新工厂排产项目');
|
||||||
const [workDir, setWorkDir] = useState('');
|
const [workDir, setWorkDir] = useState('');
|
||||||
|
const [browserOpen, setBrowserOpen] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const submittingRef = useRef(false);
|
const submittingRef = useRef(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
@ -82,6 +84,7 @@ export function ProjectCreateDialog(props: {
|
||||||
setName('新工厂排产项目');
|
setName('新工厂排产项目');
|
||||||
setWorkDir('');
|
setWorkDir('');
|
||||||
setError('');
|
setError('');
|
||||||
|
setBrowserOpen(false);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
inputRef.current?.select();
|
inputRef.current?.select();
|
||||||
|
|
@ -91,6 +94,11 @@ export function ProjectCreateDialog(props: {
|
||||||
if (!props.open) return null;
|
if (!props.open) return null;
|
||||||
|
|
||||||
const pickDir = async () => {
|
const pickDir = async () => {
|
||||||
|
// 优先内嵌浏览器(可见 Excel/PDF/SQL 等文件);旧版桌面桥回退原生选择框
|
||||||
|
if (window.apsDesktop?.listDirectory) {
|
||||||
|
setBrowserOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dir = await window.apsDesktop?.pickDirectory?.();
|
const dir = await window.apsDesktop?.pickDirectory?.();
|
||||||
if (dir) setWorkDir(dir);
|
if (dir) setWorkDir(dir);
|
||||||
};
|
};
|
||||||
|
|
@ -158,6 +166,12 @@ export function ProjectCreateDialog(props: {
|
||||||
{submitting ? '正在准备项目…' : error ? '重试' : '创建'}
|
{submitting ? '正在准备项目…' : error ? '重试' : '创建'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<FolderBrowserDialog
|
||||||
|
open={browserOpen}
|
||||||
|
initialPath={workDir}
|
||||||
|
onCancel={() => setBrowserOpen(false)}
|
||||||
|
onSelect={(dir) => { setWorkDir(dir); setBrowserOpen(false); }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
// ============================================================
|
||||||
|
// 内嵌工程目录浏览器(moduleId: web-folder-browser)
|
||||||
|
// 修复:原生文件夹选择框只显示文件夹,目录里的 Excel/PDF/SQL 等
|
||||||
|
// 业务文件不可见。本组件通过 apsDesktop.listDirectory 把目录内容
|
||||||
|
// (文件夹 + 文件)完整列出,文件夹可下钻,文件只读展示,
|
||||||
|
// 「选择此文件夹」返回当前路径。桌面桥不存在时不渲染入口。
|
||||||
|
// ============================================================
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
type DirEntry = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'dir' | 'file';
|
||||||
|
size: number | null;
|
||||||
|
mtime: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DirListing = {
|
||||||
|
path: string;
|
||||||
|
parent: string | null;
|
||||||
|
entries: DirEntry[];
|
||||||
|
truncated?: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 业务文件类型着色(排产常见格式) */
|
||||||
|
const EXT_STYLE: Record<string, { label: string; cls: string }> = {
|
||||||
|
xlsx: { label: 'Excel', cls: 'fb-ext-excel' },
|
||||||
|
xlsm: { label: 'Excel', cls: 'fb-ext-excel' },
|
||||||
|
xls: { label: 'Excel', cls: 'fb-ext-excel' },
|
||||||
|
csv: { label: 'CSV', cls: 'fb-ext-excel' },
|
||||||
|
pdf: { label: 'PDF', cls: 'fb-ext-pdf' },
|
||||||
|
sql: { label: 'SQL', cls: 'fb-ext-sql' },
|
||||||
|
dxf: { label: 'DXF', cls: 'fb-ext-dxf' },
|
||||||
|
txt: { label: '文本', cls: 'fb-ext-txt' },
|
||||||
|
json: { label: 'JSON', cls: 'fb-ext-txt' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function extOf(name: string): string {
|
||||||
|
const idx = name.lastIndexOf('.');
|
||||||
|
return idx > 0 ? name.slice(idx + 1).toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(size: number | null): string {
|
||||||
|
if (size == null) return '';
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(mtime: number | null): string {
|
||||||
|
if (mtime == null) return '';
|
||||||
|
const d = new Date(mtime);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计当前目录里的业务文件(摘要行:Excel×3 · PDF×2 · SQL×1) */
|
||||||
|
function summarizeFiles(entries: DirEntry[]): string {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.type !== 'file') continue;
|
||||||
|
const style = EXT_STYLE[extOf(e.name)];
|
||||||
|
if (!style) continue;
|
||||||
|
counts.set(style.label, (counts.get(style.label) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts.entries()].map(([label, n]) => `${label}×${n}`).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FolderBrowserDialog(props: {
|
||||||
|
open: boolean;
|
||||||
|
initialPath?: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSelect: (dirPath: string) => void;
|
||||||
|
}) {
|
||||||
|
const [listing, setListing] = useState<DirListing | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async (dirPath: string) => {
|
||||||
|
const bridge = window.apsDesktop;
|
||||||
|
if (!bridge?.listDirectory) {
|
||||||
|
setError('当前环境不支持目录浏览(仅桌面端可用),请直接粘贴路径。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await bridge.listDirectory(dirPath);
|
||||||
|
setListing(result);
|
||||||
|
if (result.error) setError(result.error);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '目录读取失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!props.open) return;
|
||||||
|
setListing(null);
|
||||||
|
void load(props.initialPath?.trim() || '');
|
||||||
|
}, [props.open, props.initialPath, load]);
|
||||||
|
|
||||||
|
const currentPath = listing?.path ?? '';
|
||||||
|
const summary = useMemo(
|
||||||
|
() => (listing ? summarizeFiles(listing.entries) : ''),
|
||||||
|
[listing],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!props.open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dlg-backdrop" onMouseDown={props.onCancel}>
|
||||||
|
<div className="dlg-card dlg-card-wide fb-card" role="dialog" aria-modal="true"
|
||||||
|
aria-label="选择工程目录" aria-busy={loading}
|
||||||
|
onMouseDown={e => e.stopPropagation()}>
|
||||||
|
<div className="dlg-title">选择工程目录</div>
|
||||||
|
|
||||||
|
<div className="fb-toolbar">
|
||||||
|
<button type="button" className="btn-ghost" title="此电脑(盘符)"
|
||||||
|
onClick={() => void load('')}>此电脑</button>
|
||||||
|
<button type="button" className="btn-ghost" title="上级目录"
|
||||||
|
disabled={!listing?.parent}
|
||||||
|
onClick={() => listing?.parent && void load(listing.parent)}>↑ 上级</button>
|
||||||
|
<div className="fb-path" title={currentPath}>
|
||||||
|
{currentPath || '请选择磁盘或粘贴路径'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fb-list" role="listbox" aria-label="目录内容">
|
||||||
|
{loading && <div className="fb-empty">正在读取…</div>}
|
||||||
|
{!loading && error && <div className="fb-empty" role="alert">{error}</div>}
|
||||||
|
{!loading && !error && listing && listing.entries.length === 0 && (
|
||||||
|
<div className="fb-empty">空目录(可直接选择此文件夹)</div>
|
||||||
|
)}
|
||||||
|
{!loading && listing?.entries.map(entry => (
|
||||||
|
entry.type === 'dir' ? (
|
||||||
|
<button key={entry.path} type="button" className="fb-row fb-row-dir"
|
||||||
|
onDoubleClick={() => void load(entry.path)}
|
||||||
|
onClick={() => void load(entry.path)}>
|
||||||
|
<span className="fb-icon" aria-hidden>📁</span>
|
||||||
|
<span className="fb-name">{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div key={entry.path} className="fb-row fb-row-file">
|
||||||
|
{(() => {
|
||||||
|
const style = EXT_STYLE[extOf(entry.name)];
|
||||||
|
return (
|
||||||
|
<span className={`fb-ext ${style?.cls ?? 'fb-ext-other'}`}>
|
||||||
|
{style?.label ?? (extOf(entry.name).toUpperCase() || '文件')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<span className="fb-name" title={entry.name}>{entry.name}</span>
|
||||||
|
<span className="fb-meta">{formatTime(entry.mtime)}</span>
|
||||||
|
<span className="fb-meta fb-size">{formatSize(entry.size)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
{listing?.truncated && (
|
||||||
|
<div className="fb-empty">条目过多,仅显示前 2000 项</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fb-summary">
|
||||||
|
{summary ? `本目录包含:${summary}` : '本目录暂无 Excel/PDF/SQL 等业务文件'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dlg-actions">
|
||||||
|
<button type="button" className="btn-ghost" onClick={props.onCancel}>取消</button>
|
||||||
|
<button type="button" className="btn-primary"
|
||||||
|
disabled={loading || !currentPath}
|
||||||
|
onClick={() => currentPath && props.onSelect(currentPath)}>
|
||||||
|
选择此文件夹
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1699,6 +1699,37 @@ html.aps-desktop .app-menubar-spacer,
|
||||||
.dlg-input:focus { border-color: var(--accent); background: #fff; box-shadow: 0 0 0 3px var(--accent-soft); }
|
.dlg-input:focus { border-color: var(--accent); background: #fff; box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||||
.dlg-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
.dlg-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
|
||||||
|
/* ---------- 内嵌工程目录浏览器(FolderBrowser) ---------- */
|
||||||
|
.fb-card { display: flex; flex-direction: column; max-height: min(70vh, 560px); position: relative; }
|
||||||
|
.fb-toolbar { display: flex; align-items: center; gap: 8px; margin: 4px 0 10px; }
|
||||||
|
.fb-toolbar .btn-ghost { flex: 0 0 auto; height: 34px; margin-top: 0; padding: 0 12px; font-size: 13px; }
|
||||||
|
.fb-path { flex: 1 1 auto; min-width: 0; font-size: 13px; color: var(--muted, #6b675f);
|
||||||
|
background: var(--panel, #f5f4f1); border: 1px solid var(--border, #e3e0d9); border-radius: 8px;
|
||||||
|
padding: 7px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; }
|
||||||
|
.fb-list { flex: 1 1 auto; min-height: 240px; max-height: 380px; overflow-y: auto;
|
||||||
|
border: 1px solid var(--border, #e3e0d9); border-radius: 10px; background: #fff; }
|
||||||
|
.fb-empty { padding: 28px 16px; text-align: center; color: var(--muted, #6b675f); font-size: 13px; }
|
||||||
|
.fb-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 12px;
|
||||||
|
border: none; background: none; border-bottom: 1px solid var(--border, #f0eee8);
|
||||||
|
font-size: 13px; text-align: left; }
|
||||||
|
.fb-row:last-child { border-bottom: none; }
|
||||||
|
.fb-row-dir { cursor: pointer; color: var(--text, #2b2925); }
|
||||||
|
.fb-row-dir:hover { background: var(--accent-soft, #fdeee2); }
|
||||||
|
.fb-row-file { color: var(--text, #2b2925); cursor: default; }
|
||||||
|
.fb-icon { flex: 0 0 auto; }
|
||||||
|
.fb-ext { flex: 0 0 44px; text-align: center; font-size: 11px; font-weight: 600;
|
||||||
|
border-radius: 6px; padding: 2px 0; }
|
||||||
|
.fb-ext-excel { background: #e7f4ea; color: #1e7d43; }
|
||||||
|
.fb-ext-pdf { background: #fdeaea; color: #c13530; }
|
||||||
|
.fb-ext-sql { background: #e8f0fd; color: #2f5fc4; }
|
||||||
|
.fb-ext-dxf { background: #f1eafd; color: #6d3fc0; }
|
||||||
|
.fb-ext-txt { background: #f0eee8; color: #6b675f; }
|
||||||
|
.fb-ext-other { background: #f0eee8; color: #8a867d; }
|
||||||
|
.fb-name { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.fb-meta { flex: 0 0 auto; color: var(--muted, #9a968d); font-size: 12px; }
|
||||||
|
.fb-size { width: 72px; text-align: right; }
|
||||||
|
.fb-summary { margin: 10px 0; font-size: 12px; color: var(--muted, #6b675f); }
|
||||||
|
|
||||||
/* ---------- 治理控制台(Codex 设置页式覆盖层,§6.10) ---------- */
|
/* ---------- 治理控制台(Codex 设置页式覆盖层,§6.10) ---------- */
|
||||||
.gov-backdrop { position: fixed; inset: 0; z-index: 900; background: rgba(31,30,27,.30);
|
.gov-backdrop { position: fixed; inset: 0; z-index: 900; background: rgba(31,30,27,.30);
|
||||||
backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center;
|
backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
.vlr-order-card strong { display:block; font-size:10px; line-height:12px; }.vlr-order-card span { display:block; font-size:9px; color:#64748b; }.vlr-order-card em { display:inline-block; margin:1px 1px 0; padding:0 3px; border-radius:3px; background:#fee2e2; color:#b91c1c; font-size:8px; font-style:normal; }.vlr-order-card.is-active { transform:translateX(-50%) scale(1.08); border-color:var(--order-color); box-shadow:0 0 0 3px color-mix(in srgb,var(--order-color) 14%,transparent),0 6px 14px rgba(15,23,42,.16); animation:vlr-pulse 1.2s ease-in-out infinite; }.vlr-order-card.is-waiting { opacity:.48; }.vlr-order-card.is-done { opacity:.85; background:#ecfdf5; border-color:#10b981; }.vlr-order-card.is-risk { border-color:#ef4444; background:#fff1f2; }.vlr-order-progress { position:absolute; left:0; bottom:-1px; height:2px; opacity:.65; transition:width .25s linear; }
|
.vlr-order-card strong { display:block; font-size:10px; line-height:12px; }.vlr-order-card span { display:block; font-size:9px; color:#64748b; }.vlr-order-card em { display:inline-block; margin:1px 1px 0; padding:0 3px; border-radius:3px; background:#fee2e2; color:#b91c1c; font-size:8px; font-style:normal; }.vlr-order-card.is-active { transform:translateX(-50%) scale(1.08); border-color:var(--order-color); box-shadow:0 0 0 3px color-mix(in srgb,var(--order-color) 14%,transparent),0 6px 14px rgba(15,23,42,.16); animation:vlr-pulse 1.2s ease-in-out infinite; }.vlr-order-card.is-waiting { opacity:.48; }.vlr-order-card.is-done { opacity:.85; background:#ecfdf5; border-color:#10b981; }.vlr-order-card.is-risk { border-color:#ef4444; background:#fff1f2; }.vlr-order-progress { position:absolute; left:0; bottom:-1px; height:2px; opacity:.65; transition:width .25s linear; }
|
||||||
.vlr-load-axis { display:flex; justify-content:space-between; padding:0 5px 5px 94px; color:#64748b; font-size:10px; font-variant-numeric:tabular-nums; }.vlr-load-grid { position:relative; border-top:1px solid #dbe4ee; border-bottom:1px solid #dbe4ee; background:repeating-linear-gradient(90deg,transparent 0,transparent calc(33.333% - 1px),#dbe4ee calc(33.333% - 1px),#dbe4ee 33.333%); }.vlr-load-row { display:grid; grid-template-columns:90px 1fr; min-height:38px; border-bottom:1px solid #e2e8f0; }.vlr-load-row:last-child { border-bottom:0; }.vlr-load-label { display:flex; flex-direction:column; justify-content:center; padding:3px 6px; background:rgba(248,250,252,.92); font-size:10px; }.vlr-load-label strong { line-height:12px; }.vlr-load-label span { color:#64748b; }.vlr-load-track { position:relative; min-width:0; }.vlr-load-bar { position:absolute; top:9px; height:20px; min-width:3px; border-radius:2px; opacity:.95; transition:opacity .2s,transform .2s; }.vlr-load-bar.is-future { opacity:.16; }.vlr-load-bar.is-active { transform:scaleY(1.22); opacity:1; box-shadow:0 0 0 2px rgba(255,255,255,.8),0 0 0 3px rgba(37,99,235,.35); z-index:2; }.vlr-load-bar.is-risk { background:#dc2626 !important; }
|
.vlr-load-axis { display:flex; justify-content:space-between; padding:0 5px 5px 94px; color:#64748b; font-size:10px; font-variant-numeric:tabular-nums; }.vlr-load-grid { position:relative; border-top:1px solid #dbe4ee; border-bottom:1px solid #dbe4ee; background:repeating-linear-gradient(90deg,transparent 0,transparent calc(33.333% - 1px),#dbe4ee calc(33.333% - 1px),#dbe4ee 33.333%); }.vlr-load-row { display:grid; grid-template-columns:90px 1fr; min-height:38px; border-bottom:1px solid #e2e8f0; }.vlr-load-row:last-child { border-bottom:0; }.vlr-load-label { display:flex; flex-direction:column; justify-content:center; padding:3px 6px; background:rgba(248,250,252,.92); font-size:10px; }.vlr-load-label strong { line-height:12px; }.vlr-load-label span { color:#64748b; }.vlr-load-track { position:relative; min-width:0; }.vlr-load-bar { position:absolute; top:9px; height:20px; min-width:3px; border-radius:2px; opacity:.95; transition:opacity .2s,transform .2s; }.vlr-load-bar.is-future { opacity:.16; }.vlr-load-bar.is-active { transform:scaleY(1.22); opacity:1; box-shadow:0 0 0 2px rgba(255,255,255,.8),0 0 0 3px rgba(37,99,235,.35); z-index:2; }.vlr-load-bar.is-risk { background:#dc2626 !important; }
|
||||||
.vlr-now-line { position:absolute; top:0; bottom:0; width:0; border-left:1px dashed #0f172a; z-index:4; pointer-events:none; transition:left .12s linear; }.vlr-now-line span { position:absolute; top:-18px; left:-29px; width:64px; text-align:center; color:#334155; font-size:9px; font-variant-numeric:tabular-nums; }
|
.vlr-now-line { position:absolute; top:0; bottom:0; width:0; border-left:1px dashed #0f172a; z-index:4; pointer-events:none; transition:left .12s linear; }.vlr-now-line span { position:absolute; top:-18px; left:-29px; width:64px; text-align:center; color:#334155; font-size:9px; font-variant-numeric:tabular-nums; }
|
||||||
.vlr-footer { display:grid; grid-template-columns:1fr 260px; gap:18px; align-items:end; min-height:78px; padding:10px 18px; color:#dbeafe; background:#0f172a; }.vlr-events { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:11px; line-height:18px; }.vlr-event { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.vlr-event time { color:#7dd3fc; }.vlr-event-risk { color:#fdba74; }.vlr-event-finish { color:#86efac; }.vlr-progress-area { color:#e2e8f0; font-size:11px; }.vlr-progress-area strong { display:block; margin-bottom:8px; text-align:right; }.vlr-progress { height:6px; border:1px solid #475569; background:#1e293b; }.vlr-progress span { display:block; height:100%; background:#2563eb; transition:width .12s linear; }
|
.vlr-footer { display:grid; grid-template-columns:1fr 260px; gap:18px; align-items:end; min-height:78px; padding:10px 18px; color:#dbeafe; background:#0f172a; }.vlr-events { max-height:108px; overflow-y:auto; font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:11px; line-height:18px; }.vlr-event { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.vlr-event time { color:#7dd3fc; }.vlr-event-risk { color:#fdba74; }.vlr-event-finish { color:#86efac; }.vlr-progress-area { color:#e2e8f0; font-size:11px; }.vlr-progress-area strong { display:block; margin-bottom:8px; text-align:right; }.vlr-progress { height:6px; border:1px solid #475569; background:#1e293b; }.vlr-progress span { display:block; height:100%; background:#2563eb; transition:width .12s linear; }
|
||||||
@keyframes vlr-pulse { 0%,100%{filter:saturate(1);transform:translateX(-50%) scale(1.06)} 50%{filter:saturate(1.35);transform:translateX(-50%) scale(1.1)} }
|
@keyframes vlr-pulse { 0%,100%{filter:saturate(1);transform:translateX(-50%) scale(1.06)} 50%{filter:saturate(1.35);transform:translateX(-50%) scale(1.1)} }
|
||||||
@media (max-width:1180px){ .vlr { min-width:760px; }.vlr-main { grid-template-columns:1fr; }.vlr-flow-panel { border-right:0; border-bottom:1px solid #dbe4ee; }.vlr-footer { grid-template-columns:1fr 220px; } }
|
@media (max-width:1180px){ .vlr { min-width:760px; }.vlr-main { grid-template-columns:1fr; }.vlr-flow-panel { border-right:0; border-bottom:1px solid #dbe4ee; }.vlr-footer { grid-template-columns:1fr 220px; } }
|
||||||
.flex-view-switch { display:inline-flex; gap:4px; margin:0 0 8px; padding:3px; border:1px solid #dbe4ee; border-radius:8px; background:#f8fafc; }
|
.flex-view-switch { display:inline-flex; gap:4px; margin:0 0 8px; padding:3px; border:1px solid #dbe4ee; border-radius:8px; background:#f8fafc; }
|
||||||
|
|
@ -43,3 +43,11 @@
|
||||||
.vlr-checkbar { flex-wrap:wrap; }
|
.vlr-checkbar { flex-wrap:wrap; }
|
||||||
.vlr-checkbar-note { width:100%; margin-left:0; }
|
.vlr-checkbar-note { width:100%; margin-left:0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vlr-blocked-strip { display:flex; flex-wrap:wrap; align-items:center; gap:6px; margin:0 6px 8px; padding:6px 8px; border:1px dashed #fca5a5; border-radius:8px; background:#fff1f2; }
|
||||||
|
.vlr-blocked-label { color:#b91c1c; font-size:11px; font-weight:800; white-space:nowrap; }
|
||||||
|
.vlr-blocked-card { display:inline-flex; align-items:center; gap:3px; max-width:300px; padding:3px 7px; border:1px solid #ef4444; border-radius:6px; background:#fff; color:#7f1d1d; font-size:10px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:help; }
|
||||||
|
.vlr-blocked-card strong { color:#b91c1c; }
|
||||||
|
.vlr-blocked-card em { padding:0 3px; border-radius:3px; background:#dc2626; color:#fff; font-size:8px; font-style:normal; }
|
||||||
|
.vlr-event-blocked { color:#fca5a5; }
|
||||||
|
.vlr-event-blocked time { color:#f87171; }
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { FlexGanttData, FlexGanttWO } from '../api/types';
|
import type { FlexGanttBlockedOrder, FlexGanttData, FlexGanttWO } from '../api/types';
|
||||||
import './VirtualLineReplay.css';
|
import './VirtualLineReplay.css';
|
||||||
|
|
||||||
const COLORS = ['#2563eb', '#f59e0b', '#0ea5e9', '#8b5cf6', '#ef4444', '#10b981', '#ec4899'];
|
const COLORS = ['#2563eb', '#f59e0b', '#0ea5e9', '#8b5cf6', '#ef4444', '#10b981', '#ec4899'];
|
||||||
const DEFAULT_OPERATION_ORDER = ['激光下料', '下料', '折弯', '焊接', '喷涂', '组装', '装配'];
|
const DEFAULT_OPERATION_ORDER = ['激光下料', '下料', '折弯', '焊接', '喷涂', '组装', '装配'];
|
||||||
const TARGET_REPLAY_MS = 45_000;
|
const TARGET_REPLAY_MS = 45_000;
|
||||||
|
const MAX_REPLAY_LOG_ROWS = 8; // 推演日志面板最多展示条数,更早的由滚动承接
|
||||||
|
const MAX_ORDER_LANES = 8; // 订单泳道最多展示条数,避免窄屏溢出
|
||||||
|
|
||||||
type ReplayStatus = 'loading' | 'running' | 'warning' | 'passed' | 'blocked';
|
type ReplayStatus = 'loading' | 'running' | 'warning' | 'passed' | 'blocked';
|
||||||
|
|
||||||
|
|
@ -55,6 +57,7 @@ const STATUS_META: Record<ReplayStatus, { label: string; detail: string }> = {
|
||||||
|
|
||||||
export default function VirtualLineReplay({ data }: { data: FlexGanttData }) {
|
export default function VirtualLineReplay({ data }: { data: FlexGanttData }) {
|
||||||
const workOrders = data.workOrders;
|
const workOrders = data.workOrders;
|
||||||
|
const blockedOrders: FlexGanttBlockedOrder[] = data.blockedOrders ?? [];
|
||||||
const minTime = Math.min(...workOrders.map(row => toMs(row.start)));
|
const minTime = Math.min(...workOrders.map(row => toMs(row.start)));
|
||||||
const maxTime = Math.max(...workOrders.map(row => toMs(row.end)));
|
const maxTime = Math.max(...workOrders.map(row => toMs(row.end)));
|
||||||
const duration = Math.max(maxTime - minTime, 60_000);
|
const duration = Math.max(maxTime - minTime, 60_000);
|
||||||
|
|
@ -95,7 +98,7 @@ export default function VirtualLineReplay({ data }: { data: FlexGanttData }) {
|
||||||
}, [duration, maxTime, playing, speed]);
|
}, [duration, maxTime, playing, speed]);
|
||||||
|
|
||||||
const progress = Math.min(Math.max((virtualTime - minTime) / duration, 0), 1);
|
const progress = Math.min(Math.max((virtualTime - minTime) / duration, 0), 1);
|
||||||
const hasRisk = workOrders.some(row => row.conflict || row.overdue);
|
const hasRisk = workOrders.some(row => row.conflict || row.overdue) || blockedOrders.length > 0;
|
||||||
const activeRisk = workOrders.some(row => (
|
const activeRisk = workOrders.some(row => (
|
||||||
(row.conflict || row.overdue) && toMs(row.start) <= virtualTime && toMs(row.end) >= virtualTime
|
(row.conflict || row.overdue) && toMs(row.start) <= virtualTime && toMs(row.end) >= virtualTime
|
||||||
));
|
));
|
||||||
|
|
@ -153,19 +156,40 @@ export default function VirtualLineReplay({ data }: { data: FlexGanttData }) {
|
||||||
|
|
||||||
const events = useMemo(() => {
|
const events = useMemo(() => {
|
||||||
const rows: { time: number; text: string; kind: 'start' | 'finish' | 'risk' }[] = [];
|
const rows: { time: number; text: string; kind: 'start' | 'finish' | 'risk' }[] = [];
|
||||||
|
const byOrder = new Map<string, FlexGanttWO[]>();
|
||||||
for (const row of workOrders) {
|
for (const row of workOrders) {
|
||||||
|
const startMs = toMs(row.start);
|
||||||
|
const endMs = toMs(row.end);
|
||||||
|
const durMin = Math.round((endMs - startMs) / 60_000);
|
||||||
|
const extras = [
|
||||||
|
row.changeoverMin ? `换型${row.changeoverMin}分` : '',
|
||||||
|
row.moveMin ? `移动${row.moveMin}分` : '',
|
||||||
|
].filter(Boolean).join(' · ');
|
||||||
rows.push({
|
rows.push({
|
||||||
time: toMs(row.start),
|
time: startMs,
|
||||||
text: `${row.orderNo} · ${row.operationName} 上机 ${row.equipmentCode}`,
|
text: `${row.orderNo} · ${row.operationName} 上机 ${row.equipmentCode}「${row.equipmentName}」×${row.quantity} · 加工${row.runMin}分${extras ? ` · ${extras}` : ''}`,
|
||||||
kind: row.conflict || row.overdue ? 'risk' : 'start',
|
kind: row.conflict || row.overdue ? 'risk' : 'start',
|
||||||
});
|
});
|
||||||
rows.push({
|
rows.push({
|
||||||
time: toMs(row.end),
|
time: endMs,
|
||||||
text: `${row.orderNo} · ${row.operationName} 完成`,
|
text: `${row.orderNo} · ${row.operationName} 完成 · ${formatClock(startMs)}~${formatClock(endMs)} · 历时${durMin}分`,
|
||||||
kind: row.conflict || row.overdue ? 'risk' : 'finish',
|
kind: row.conflict || row.overdue ? 'risk' : 'finish',
|
||||||
});
|
});
|
||||||
|
byOrder.set(row.orderNo, [...(byOrder.get(row.orderNo) ?? []), row]);
|
||||||
}
|
}
|
||||||
return rows.filter(row => row.time <= virtualTime).sort((a, b) => b.time - a.time).slice(0, 4);
|
// 订单级完工总结:整单最后一道工序结束时给一条可读的完结论
|
||||||
|
for (const [orderNo, orderWos] of byOrder.entries()) {
|
||||||
|
const last = [...orderWos].sort((a, b) => toMs(a.end) - toMs(b.end)).at(-1);
|
||||||
|
if (!last) continue;
|
||||||
|
const late = orderWos.some(row => row.overdue);
|
||||||
|
const risk = orderWos.some(row => row.conflict);
|
||||||
|
rows.push({
|
||||||
|
time: toMs(last.end),
|
||||||
|
text: `✓ ${orderNo} 全部 ${orderWos.length} 道工序完工 · ${last.productName} ×${last.quantity}${late ? ' · 超出交期' : ' · 按期'}`,
|
||||||
|
kind: late || risk ? 'risk' : 'finish',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows.filter(row => row.time <= virtualTime).sort((a, b) => b.time - a.time).slice(0, MAX_REPLAY_LOG_ROWS);
|
||||||
}, [virtualTime, workOrders]);
|
}, [virtualTime, workOrders]);
|
||||||
|
|
||||||
const orderPosition = (order: OrderTrack) => {
|
const orderPosition = (order: OrderTrack) => {
|
||||||
|
|
@ -201,7 +225,7 @@ export default function VirtualLineReplay({ data }: { data: FlexGanttData }) {
|
||||||
<div>
|
<div>
|
||||||
<div className="vlr-eyebrow">排产推演</div>
|
<div className="vlr-eyebrow">排产推演</div>
|
||||||
<div className="vlr-subtitle">
|
<div className="vlr-subtitle">
|
||||||
依据当前排产版本 {data.versionNo ?? '未命名'} · {data.sortMode ?? '默认策略'} · {workOrders.length} 道工序回放,不产生新的排产结果
|
依据当前排产版本 {data.versionNo ?? '未命名'} · {data.sortMode ?? '默认策略'} · {workOrders.length} 道工序回放{blockedOrders.length > 0 ? ` · ${blockedOrders.length} 张订单未排入` : ''},不产生新的排产结果
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="vlr-clock-group">
|
<div className="vlr-clock-group">
|
||||||
|
|
@ -226,7 +250,7 @@ export default function VirtualLineReplay({ data }: { data: FlexGanttData }) {
|
||||||
{checkState === 'running' ? '推演进行中' : hasRisk ? '存在待处理风险标记' : '未发现已标记风险'}
|
{checkState === 'running' ? '推演进行中' : hasRisk ? '存在待处理风险标记' : '未发现已标记风险'}
|
||||||
</strong>
|
</strong>
|
||||||
<span className="vlr-checkbar-counts">
|
<span className="vlr-checkbar-counts">
|
||||||
冲突标记 {conflictMarkerCount} · 超期工序 {overdueMarkerCount}
|
冲突标记 {conflictMarkerCount} · 超期工序 {overdueMarkerCount}{blockedOrders.length > 0 && ` · 阻断订单 ${blockedOrders.length}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="vlr-checkbar-note">依据当前工单标记回放,不替代 P2 发布校验。</span>
|
<span className="vlr-checkbar-note">依据当前工单标记回放,不替代 P2 发布校验。</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -243,9 +267,25 @@ export default function VirtualLineReplay({ data }: { data: FlexGanttData }) {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{blockedOrders.length > 0 && (
|
||||||
|
<div className="vlr-blocked-strip" aria-label="未排入订单">
|
||||||
|
<span className="vlr-blocked-label">未排入 {blockedOrders.length} 单</span>
|
||||||
|
{blockedOrders.map(order => (
|
||||||
|
<span
|
||||||
|
key={order.orderNo}
|
||||||
|
className={`vlr-blocked-card${order.isRush ? ' is-rush' : ''}`}
|
||||||
|
title={order.reasons.join('\n') || '本版本未排出'}
|
||||||
|
>
|
||||||
|
<strong>{shortCode(order.orderNo)}</strong>
|
||||||
|
{order.isRush && <em>急</em>}
|
||||||
|
· {order.productName} ×{order.quantity} · 交期 {order.dueDate || '—'}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="vlr-order-lanes">
|
<div className="vlr-order-lanes">
|
||||||
<span className="vlr-entry">订单入口</span>
|
<span className="vlr-entry">订单入口</span>
|
||||||
{orders.slice(0, 8).map(order => {
|
{orders.slice(0, MAX_ORDER_LANES).map(order => {
|
||||||
const pos = orderPosition(order);
|
const pos = orderPosition(order);
|
||||||
const left = stations.length <= 1 ? 50 : ((pos.stationIndex + 0.5) / stations.length) * 100;
|
const left = stations.length <= 1 ? 50 : ((pos.stationIndex + 0.5) / stations.length) * 100;
|
||||||
const risk = pos.focus.conflict || pos.focus.overdue;
|
const risk = pos.focus.conflict || pos.focus.overdue;
|
||||||
|
|
@ -306,11 +346,16 @@ export default function VirtualLineReplay({ data }: { data: FlexGanttData }) {
|
||||||
|
|
||||||
<footer className="vlr-footer">
|
<footer className="vlr-footer">
|
||||||
<div className="vlr-events" aria-live="polite">
|
<div className="vlr-events" aria-live="polite">
|
||||||
|
{blockedOrders.map(order => (
|
||||||
|
<div key={`blocked-${order.orderNo}`} className="vlr-event vlr-event-blocked" title={order.reasons.join('\n')}>
|
||||||
|
<time>[阻断]</time> ⚠ {order.orderNo} · {order.productName} ×{order.quantity} · 未排入:{order.reasons[0] ?? '资源/物料不满足'}{order.reasons.length > 1 ? `(共 ${order.reasons.length} 项,悬停查看全部)` : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
{events.length ? events.map(event => (
|
{events.length ? events.map(event => (
|
||||||
<div key={`${event.time}-${event.text}`} className={`vlr-event vlr-event-${event.kind}`}>
|
<div key={`${event.time}-${event.text}`} className={`vlr-event vlr-event-${event.kind}`}>
|
||||||
<time>[{formatClock(event.time)}]</time> {event.text}
|
<time>[{formatClock(event.time)}]</time> {event.text}
|
||||||
</div>
|
</div>
|
||||||
)) : <div className="vlr-event">[{formatClock(minTime)}] 排产方案已载入,等待开始推演</div>}
|
)) : <div className="vlr-event">[{formatClock(minTime)}] 版本 {data.versionNo ?? '未命名'} 已载入:工单 {workOrders.length} 道 · 设备 {equipmentRows.length} 台{blockedOrders.length ? ` · 阻断 ${blockedOrders.length} 单` : ''},等待开始推演</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="vlr-progress-area">
|
<div className="vlr-progress-area">
|
||||||
<strong>推演进度 {(progress * 100).toFixed(1)}%</strong>
|
<strong>推演进度 {(progress * 100).toFixed(1)}%</strong>
|
||||||
|
|
|
||||||
|
|
@ -226,9 +226,53 @@ def flex_gantt_view(world: World) -> dict:
|
||||||
"zones": [{"code": z.get("code"), "name": z.get("name")} for z in zones],
|
"zones": [{"code": z.get("code"), "name": z.get("name")} for z in zones],
|
||||||
"equipment": equipment,
|
"equipment": equipment,
|
||||||
"workOrders": wos_out,
|
"workOrders": wos_out,
|
||||||
|
"blockedOrders": _blocked_orders_view(world, wos_out, orders_by_no, products, latest_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
MAX_BLOCKED_ORDER_REASONS = 8 # 单张阻断订单展示的缺料原因行数上限,超出由冲突中心承接
|
||||||
|
|
||||||
|
|
||||||
|
def _blocked_orders_view(world: World, wos_out: list[dict], orders_by_no: dict,
|
||||||
|
products: dict, latest_id) -> list[dict]:
|
||||||
|
"""阻断订单投影:本版本有冲突但没有任何工单的订单(如缺料急单)。
|
||||||
|
|
||||||
|
供排产推演/甘特显式展示「未排入」及其原因,避免订单在回放里悄悄消失。
|
||||||
|
"""
|
||||||
|
scheduled_nos = {w["orderNo"] for w in wos_out}
|
||||||
|
reasons_by_order: dict[str, list[str]] = {}
|
||||||
|
severity_by_order: dict[str, str] = {}
|
||||||
|
for c in world.get("flexConflicts", []):
|
||||||
|
if latest_id and c.get("versionId") != latest_id:
|
||||||
|
continue
|
||||||
|
ono = str(c.get("orderNo") or "")
|
||||||
|
if not ono or ono in scheduled_nos:
|
||||||
|
continue
|
||||||
|
sev = str(c.get("severity") or "")
|
||||||
|
if severity_by_order.get(ono) != "CRITICAL":
|
||||||
|
severity_by_order[ono] = sev or severity_by_order.get(ono) or ""
|
||||||
|
text = str(c.get("description") or c.get("conflictType") or "")
|
||||||
|
if text and text not in reasons_by_order.setdefault(ono, []):
|
||||||
|
reasons_by_order[ono].append(text)
|
||||||
|
blocked: list[dict] = []
|
||||||
|
for ono, reasons in reasons_by_order.items():
|
||||||
|
o = orders_by_no.get(ono) or {}
|
||||||
|
prod = products.get(o.get("productCode"))
|
||||||
|
blocked.append({
|
||||||
|
"orderNo": ono,
|
||||||
|
"productCode": o.get("productCode") or "",
|
||||||
|
"productName": (prod["name"] if prod else None) or o.get("productName") or o.get("productCode") or ono,
|
||||||
|
"quantity": o.get("quantity"),
|
||||||
|
"dueDate": o.get("dueDate"),
|
||||||
|
"priority": o.get("priority"),
|
||||||
|
"isRush": bool(o.get("isRush")),
|
||||||
|
"severity": severity_by_order.get(ono) or "",
|
||||||
|
"reasons": reasons[:MAX_BLOCKED_ORDER_REASONS],
|
||||||
|
})
|
||||||
|
blocked.sort(key=lambda b: (b.get("dueDate") or "", b["orderNo"]))
|
||||||
|
return blocked
|
||||||
|
|
||||||
|
|
||||||
def run_flex_schedule(store, sort_mode: str | None = None, order_ids: list[int] | None = None,
|
def run_flex_schedule(store, sort_mode: str | None = None, order_ids: list[int] | None = None,
|
||||||
start_date: str | None = None, name: str | None = None,
|
start_date: str | None = None, name: str | None = None,
|
||||||
actor: str = "web", window: str | None = None,
|
actor: str = "web", window: str | None = None,
|
||||||
|
|
@ -249,13 +293,15 @@ def run_flex_schedule(store, sort_mode: str | None = None, order_ids: list[int]
|
||||||
from server.agent_core.audit import write_audit
|
from server.agent_core.audit import write_audit
|
||||||
from server.aps_domain.closed_loop_runtime import run_closed_loop_candidate
|
from server.aps_domain.closed_loop_runtime import run_closed_loop_candidate
|
||||||
from server.aps_domain.mrp import decompose_orders
|
from server.aps_domain.mrp import decompose_orders
|
||||||
from server.aps_domain.orders import sync_flex_orders_to_sales
|
from server.aps_domain.orders import sync_flex_orders_to_sales, sync_sales_orders_to_flex
|
||||||
from server.aps_domain.sourcing import annotate_world_sourcing
|
from server.aps_domain.sourcing import annotate_world_sourcing
|
||||||
from server.state.seed import ensure_flex_seed
|
from server.state.seed import ensure_flex_seed
|
||||||
|
|
||||||
ensure_flex_seed(store.data)
|
ensure_flex_seed(store.data)
|
||||||
|
from server.aps_domain.params import is_trial_world
|
||||||
|
|
||||||
planning_context = store.data.get("planningContext") or {}
|
planning_context = store.data.get("planningContext") or {}
|
||||||
if planning_context.get("trialOnly"):
|
if is_trial_world(store.data):
|
||||||
trial = True
|
trial = True
|
||||||
if start_date is None and planning_context.get("planStart"):
|
if start_date is None and planning_context.get("planStart"):
|
||||||
start_date = str(planning_context["planStart"])[:10]
|
start_date = str(planning_context["planStart"])[:10]
|
||||||
|
|
@ -279,6 +325,10 @@ def run_flex_schedule(store, sort_mode: str | None = None, order_ids: list[int]
|
||||||
|
|
||||||
def execute_pipeline() -> dict:
|
def execute_pipeline() -> dict:
|
||||||
if explicit_demo or trial:
|
if explicit_demo or trial:
|
||||||
|
# 试排世界:主干对话/手工单(含紧急插单)桥接进柔性订单池,否则排产看不到它们;
|
||||||
|
# 演示厂保持经典轨/柔性轨双轨隔离,不桥接
|
||||||
|
if trial:
|
||||||
|
sync_sales_orders_to_flex(store.data)
|
||||||
result = PoolEngine().solve(
|
result = PoolEngine().solve(
|
||||||
store.data,
|
store.data,
|
||||||
store.next_id,
|
store.next_id,
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,12 @@ def _normalize_order_row(world: World, row: Any, index: int) -> tuple[dict[str,
|
||||||
if level not in CUSTOMER_LEVELS:
|
if level not in CUSTOMER_LEVELS:
|
||||||
return None, f"第{index}行 customerLevel 必须是 VIP/A/B/C"
|
return None, f"第{index}行 customerLevel 必须是 VIP/A/B/C"
|
||||||
is_rush = bool(row.get("isRush", False))
|
is_rush = bool(row.get("isRush", False))
|
||||||
|
order_no = str(row.get("orderNo") or "").strip()
|
||||||
|
if order_no:
|
||||||
|
folded = order_no.casefold()
|
||||||
|
for pool in ("salesOrders", "flexOrders"):
|
||||||
|
if any(str(o.get("orderNo") or "").casefold() == folded for o in (world.get(pool) or [])):
|
||||||
|
return None, f"第{index}行 orderNo「{order_no}」已存在"
|
||||||
priority = row.get("priority")
|
priority = row.get("priority")
|
||||||
if priority is None:
|
if priority is None:
|
||||||
priority = 1 if is_rush else 5
|
priority = 1 if is_rush else 5
|
||||||
|
|
@ -73,6 +79,7 @@ def _normalize_order_row(world: World, row: Any, index: int) -> tuple[dict[str,
|
||||||
return {
|
return {
|
||||||
"customerName": customer,
|
"customerName": customer,
|
||||||
"customerLevel": level,
|
"customerLevel": level,
|
||||||
|
"orderNo": order_no,
|
||||||
"productId": int(product["id"]),
|
"productId": int(product["id"]),
|
||||||
"productCode": product["code"],
|
"productCode": product["code"],
|
||||||
"productName": product["name"],
|
"productName": product["name"],
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,8 @@ def sync_flex_orders_to_sales(world: World) -> int:
|
||||||
}
|
}
|
||||||
flex: list[dict[str, Any]] = []
|
flex: list[dict[str, Any]] = []
|
||||||
for order in world.get("flexOrders") or []:
|
for order in world.get("flexOrders") or []:
|
||||||
if str(order.get("source") or "").upper() in {"CLOSED_LOOP", "MRP"}:
|
# SALES 源柔性单来自主干销售订单投影,不反向回投,避免重复建单
|
||||||
|
if str(order.get("source") or "").upper() in {"CLOSED_LOOP", "MRP", "SALES"}:
|
||||||
continue
|
continue
|
||||||
if str(order.get("status") or "").upper() in {"DONE", "CANCELLED", "COMPLETED"}:
|
if str(order.get("status") or "").upper() in {"DONE", "CANCELLED", "COMPLETED"}:
|
||||||
continue
|
continue
|
||||||
|
|
@ -257,6 +258,66 @@ def sync_flex_orders_to_sales(world: World) -> int:
|
||||||
world["salesOrders"] = next_rows
|
world["salesOrders"] = next_rows
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
SALES_FLEX_SOURCE = "SALES" # 主干销售订单 → 柔性订单的桥接来源标记
|
||||||
|
|
||||||
|
|
||||||
|
def sync_sales_orders_to_flex(world: World) -> int:
|
||||||
|
"""把主干可排销售订单幂等投影为柔性订单(salesOrders → flexOrders)。
|
||||||
|
|
||||||
|
对话/手工新建的订单(含紧急插单)只落在主干 salesOrders;柔性 PoolEngine
|
||||||
|
只读 flexOrders,导致「订单已导入却排不进去」。每次柔性排产前调用本投影:
|
||||||
|
APPROVED/CONFIRMED 的主干单桥接为 RELEASED 柔性单;主干状态回撤
|
||||||
|
(取消/完成/退回审核)同步把柔性单移出可排集合。文件/SQL/闭环来源的
|
||||||
|
柔性单仍由各自通道维护,本函数只认领 source=SALES 的行。
|
||||||
|
"""
|
||||||
|
flex_orders = world.setdefault("flexOrders", [])
|
||||||
|
by_no = {str(o.get("orderNo") or ""): o for o in flex_orders}
|
||||||
|
next_fid = max(
|
||||||
|
(int(o.get("id")) for o in flex_orders if isinstance(o.get("id"), int)),
|
||||||
|
default=0,
|
||||||
|
) + 1
|
||||||
|
changed = 0
|
||||||
|
for so in world.get("salesOrders") or []:
|
||||||
|
order_no = str(so.get("orderNo") or "").strip()
|
||||||
|
if not order_no:
|
||||||
|
continue
|
||||||
|
existing = by_no.get(order_no)
|
||||||
|
if existing is not None and str(existing.get("source") or "").upper() != SALES_FLEX_SOURCE:
|
||||||
|
continue # 同名柔性单由文件/SQL/闭环通道维护,不覆盖
|
||||||
|
status = str(so.get("status") or "").upper()
|
||||||
|
if is_schedulable(status):
|
||||||
|
item = (so.get("items") or [{}])[0]
|
||||||
|
if not item.get("productCode"):
|
||||||
|
continue
|
||||||
|
values = {
|
||||||
|
"productCode": item.get("productCode"),
|
||||||
|
"productName": item.get("productName"),
|
||||||
|
"quantity": item.get("quantity"),
|
||||||
|
"dueDate": str(so.get("deliveryDate") or "")[:10],
|
||||||
|
"priority": int(so.get("priority") or 5),
|
||||||
|
"customerName": so.get("customerName"),
|
||||||
|
"customerLevel": so.get("customerLevel") or "B",
|
||||||
|
"isRush": bool(so.get("isRush")),
|
||||||
|
"status": "RELEASED",
|
||||||
|
"source": SALES_FLEX_SOURCE,
|
||||||
|
"salesOrderNo": order_no,
|
||||||
|
}
|
||||||
|
if existing is None:
|
||||||
|
flex_orders.append({"id": next_fid, "orderNo": order_no, **values})
|
||||||
|
next_fid += 1
|
||||||
|
changed += 1
|
||||||
|
elif any(existing.get(k) != v for k, v in values.items()):
|
||||||
|
existing.update(values)
|
||||||
|
changed += 1
|
||||||
|
elif existing is not None:
|
||||||
|
# 主干状态回撤:柔性投影同步退出可排集合
|
||||||
|
target = {"CANCELLED": "CANCELLED", "COMPLETED": "DONE"}.get(status, "ON_HOLD")
|
||||||
|
if existing.get("status") != target:
|
||||||
|
existing["status"] = target
|
||||||
|
changed += 1
|
||||||
|
return changed
|
||||||
|
|
||||||
def list_orders(world: World) -> list[dict[str, Any]]:
|
def list_orders(world: World) -> list[dict[str, Any]]:
|
||||||
"""销售订单列表投影(含已投影的 SQL/柔性单;只读兜底不写盘)。"""
|
"""销售订单列表投影(含已投影的 SQL/柔性单;只读兜底不写盘)。"""
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
|
|
@ -450,6 +511,7 @@ def normalize_order_payload(world: World, payload: dict[str, Any]) -> dict[str,
|
||||||
out["customerName"] = str(out.get("customerName") or "").strip()
|
out["customerName"] = str(out.get("customerName") or "").strip()
|
||||||
if not out["customerName"]:
|
if not out["customerName"]:
|
||||||
raise ValueError("客户名称不能为空")
|
raise ValueError("客户名称不能为空")
|
||||||
|
out["orderNo"] = str(out.get("orderNo") or "").strip()
|
||||||
out["customerLevel"] = str(out.get("customerLevel") or "A").upper()
|
out["customerLevel"] = str(out.get("customerLevel") or "A").upper()
|
||||||
if out["customerLevel"] not in CUSTOMER_LEVELS:
|
if out["customerLevel"] not in CUSTOMER_LEVELS:
|
||||||
raise ValueError("客户等级必须是 VIP/A/B/C")
|
raise ValueError("客户等级必须是 VIP/A/B/C")
|
||||||
|
|
@ -649,9 +711,22 @@ def apply_order_action(world: World, next_id, action: str, payload: dict[str, An
|
||||||
order_id = next_id("salesOrder")
|
order_id = next_id("salesOrder")
|
||||||
order_date = today0().strftime("%Y-%m-%d")
|
order_date = today0().strftime("%Y-%m-%d")
|
||||||
product = _product(world, p["productId"])
|
product = _product(world, p["productId"])
|
||||||
|
order_no = p.get("orderNo") or ""
|
||||||
|
if order_no:
|
||||||
|
# 计划员指定单号:保留原号并全池查重,冲突即拒绝
|
||||||
|
folded = order_no.casefold()
|
||||||
|
clash = any(
|
||||||
|
str(o.get("orderNo") or "").casefold() == folded
|
||||||
|
for pool in ("salesOrders", "flexOrders")
|
||||||
|
for o in (world.get(pool) or [])
|
||||||
|
)
|
||||||
|
if clash:
|
||||||
|
raise ValueError(f"订单号 {order_no} 已存在")
|
||||||
|
else:
|
||||||
|
order_no = "SO" + today0().strftime("%Y%m%d") + f"{order_id:03d}"
|
||||||
order = {
|
order = {
|
||||||
"id": order_id,
|
"id": order_id,
|
||||||
"orderNo": "SO" + today0().strftime("%Y%m%d") + f"{order_id:03d}",
|
"orderNo": order_no,
|
||||||
"customerId": f"CUST{order_id:03d}",
|
"customerId": f"CUST{order_id:03d}",
|
||||||
"customerName": p["customerName"],
|
"customerName": p["customerName"],
|
||||||
"customerLevel": p["customerLevel"],
|
"customerLevel": p["customerLevel"],
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,16 @@ def default_schedule_params() -> dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_trial_world(world: World) -> bool:
|
||||||
|
"""是否试排世界(文件夹快试导入):排产/重排统一走柔性能力池草稿轨。
|
||||||
|
|
||||||
|
轨道判定唯一入口:run_flex_schedule、rush.apply 等所有排产触发点
|
||||||
|
都经此函数分流,避免各处自写字段判断导致同一世界走出两条轨道。
|
||||||
|
"""
|
||||||
|
planning_context = world.get("planningContext") or {}
|
||||||
|
return bool(planning_context.get("trialOnly"))
|
||||||
|
|
||||||
|
|
||||||
def get_schedule_params(world: World) -> dict[str, Any]:
|
def get_schedule_params(world: World) -> dict[str, Any]:
|
||||||
"""合并默认值后的排产参数投影(P0)。"""
|
"""合并默认值后的排产参数投影(P0)。"""
|
||||||
base = default_schedule_params()
|
base = default_schedule_params()
|
||||||
|
|
|
||||||
|
|
@ -308,11 +308,38 @@ def _strategy_cn(s: str) -> str:
|
||||||
}.get(s, s)
|
}.get(s, s)
|
||||||
|
|
||||||
|
|
||||||
|
class _FlexSolveResultView:
|
||||||
|
"""PoolEngine 的 dict 结果适配成主轨结果对象的属性读取口径。
|
||||||
|
|
||||||
|
workflow 的 rush.apply 回复文案按属性读 versionNo/orderCount/conflictCount/
|
||||||
|
totalTardiness;柔性引擎返回 dict,这里做一次只读视图转换。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, payload: dict[str, Any]) -> None:
|
||||||
|
self.versionNo = payload.get("versionNo")
|
||||||
|
self.orderCount = int(payload.get("orderCount") or 0)
|
||||||
|
self.conflictCount = int(payload.get("conflictCount") or 0)
|
||||||
|
self.totalTardiness = float(payload.get("totalTardiness") or 0.0)
|
||||||
|
self.solveStatus = payload.get("solveStatus")
|
||||||
|
|
||||||
|
|
||||||
def apply_rush(world: World, next_id, payload: dict[str, Any]) -> dict[str, Any]:
|
def apply_rush(world: World, next_id, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""采用插单:写主干订单池 + 生成 DRAFT 排产版本。调用方负责 checkpoint/审计。"""
|
"""采用插单:写主干订单池 + 生成 DRAFT 排产版本。调用方负责 checkpoint/审计。"""
|
||||||
rush = resolve_rush_payload(world, payload.get("payload") or payload)
|
rush = resolve_rush_payload(world, payload.get("payload") or payload)
|
||||||
strategy = str(payload.get("strategy") or "DELIVERY_FIRST").upper()
|
strategy = str(payload.get("strategy") or "DELIVERY_FIRST").upper()
|
||||||
order = inject_rush_trunk(world, next_id, rush)
|
order = inject_rush_trunk(world, next_id, rush)
|
||||||
|
from server.aps_domain.params import is_trial_world
|
||||||
|
|
||||||
|
if is_trial_world(world):
|
||||||
|
# 试排世界必须走柔性能力池引擎:主轨 RULE 引擎读不到 flex* 主数据,
|
||||||
|
# 全量重排只会产出 0 工单的空壳版本,排产推演/查看排产结果都看不到急单。
|
||||||
|
# 改为先把主干急单桥接进柔性订单池,再重跑 PoolEngine 试排(与 run_flex_schedule 同轨)。
|
||||||
|
from server.aps_domain.orders import sync_sales_orders_to_flex
|
||||||
|
from server.engines import PoolEngine
|
||||||
|
|
||||||
|
sync_sales_orders_to_flex(world)
|
||||||
|
flex_result = PoolEngine().solve(world, next_id, trial=True)
|
||||||
|
return {"order": order, "result": _FlexSolveResultView(flex_result), "strategy": strategy}
|
||||||
# 正式试排写草稿版本
|
# 正式试排写草稿版本
|
||||||
from server.aps_domain.constraints import engine_constraint_flags
|
from server.aps_domain.constraints import engine_constraint_flags
|
||||||
from server.aps_domain.params import get_schedule_params
|
from server.aps_domain.params import get_schedule_params
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue