Compare commits

..

No commits in common. "d6764e56ba56b4e81133829a8c65f7244a3dc4a2" and "e0cf0f30f65de6de8761218340bf695164849ec7" have entirely different histories.

14 changed files with 16 additions and 558 deletions

View File

@ -604,75 +604,6 @@ 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;

View File

@ -34,9 +34,6 @@ 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) {

View File

@ -320,19 +320,6 @@ 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; // 最新柔性版本号
@ -340,7 +327,6 @@ 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)

View File

@ -11,14 +11,6 @@ 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;

View File

@ -3,7 +3,6 @@
// 替代 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: {
@ -72,7 +71,6 @@ 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);
@ -84,7 +82,6 @@ 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();
@ -94,11 +91,6 @@ 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);
}; };
@ -166,12 +158,6 @@ 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>
); );

View File

@ -1,181 +0,0 @@
// ============================================================
// 内嵌工程目录浏览器(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>
);
}

View File

@ -1699,37 +1699,6 @@ 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;

View File

@ -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 { 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; } .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; }
@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,11 +43,3 @@
.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; }

View File

@ -1,12 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import type { FlexGanttBlockedOrder, FlexGanttData, FlexGanttWO } from '../api/types'; import type { 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';
@ -57,7 +55,6 @@ 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);
@ -98,7 +95,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) || blockedOrders.length > 0; const hasRisk = workOrders.some(row => row.conflict || row.overdue);
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
)); ));
@ -156,40 +153,19 @@ 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: startMs, time: toMs(row.start),
text: `${row.orderNo} · ${row.operationName} 上机 ${row.equipmentCode}「${row.equipmentName}」×${row.quantity} · 加工${row.runMin}分${extras ? ` · ${extras}` : ''}`, text: `${row.orderNo} · ${row.operationName} 上机 ${row.equipmentCode}`,
kind: row.conflict || row.overdue ? 'risk' : 'start', kind: row.conflict || row.overdue ? 'risk' : 'start',
}); });
rows.push({ rows.push({
time: endMs, time: toMs(row.end),
text: `${row.orderNo} · ${row.operationName} 完成 · ${formatClock(startMs)}~${formatClock(endMs)} · 历时${durMin}分`, text: `${row.orderNo} · ${row.operationName} 完成`,
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) => {
@ -225,7 +201,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} 道工序回放{blockedOrders.length > 0 ? ` · ${blockedOrders.length} 张订单未排入` : ''},不产生新的排产结果 依据当前排产版本 {data.versionNo ?? '未命名'} · {data.sortMode ?? '默认策略'} · {workOrders.length} 道工序回放,不产生新的排产结果
</div> </div>
</div> </div>
<div className="vlr-clock-group"> <div className="vlr-clock-group">
@ -250,7 +226,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}{blockedOrders.length > 0 && ` · 阻断订单 ${blockedOrders.length}`} 冲突标记 {conflictMarkerCount} · 超期工序 {overdueMarkerCount}
</span> </span>
<span className="vlr-checkbar-note">依据当前工单标记回放,不替代 P2 发布校验。</span> <span className="vlr-checkbar-note">依据当前工单标记回放,不替代 P2 发布校验。</span>
</div> </div>
@ -267,25 +243,9 @@ 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, MAX_ORDER_LANES).map(order => { {orders.slice(0, 8).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;
@ -346,16 +306,11 @@ 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)}] 版本 {data.versionNo ?? '未命名'} 已载入:工单 {workOrders.length} 道 · 设备 {equipmentRows.length} 台{blockedOrders.length ? ` · 阻断 ${blockedOrders.length} 单` : ''},等待开始推演</div>} )) : <div className="vlr-event">[{formatClock(minTime)}] 排产方案已载入,等待开始推演</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>

View File

@ -226,53 +226,9 @@ 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,
@ -293,15 +249,13 @@ 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, sync_sales_orders_to_flex from server.aps_domain.orders import sync_flex_orders_to_sales
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 is_trial_world(store.data): if planning_context.get("trialOnly"):
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]
@ -325,10 +279,6 @@ 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,

View File

@ -62,12 +62,6 @@ 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
@ -79,7 +73,6 @@ 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"],

View File

@ -120,8 +120,7 @@ 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 []:
# SALES 源柔性单来自主干销售订单投影,不反向回投,避免重复建单 if str(order.get("source") or "").upper() in {"CLOSED_LOOP", "MRP"}:
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
@ -258,66 +257,6 @@ 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]] = []
@ -511,7 +450,6 @@ 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")
@ -711,22 +649,9 @@ 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": order_no, "orderNo": "SO" + today0().strftime("%Y%m%d") + f"{order_id:03d}",
"customerId": f"CUST{order_id:03d}", "customerId": f"CUST{order_id:03d}",
"customerName": p["customerName"], "customerName": p["customerName"],
"customerLevel": p["customerLevel"], "customerLevel": p["customerLevel"],

View File

@ -26,16 +26,6 @@ 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()

View File

@ -308,38 +308,11 @@ 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