feat: 收口计划员约束规则与急单处理闭环
- 增加默认通用约束、计划员扩充规则库及 P2 确认链路\n- 收口急单评估/应用口径,由排产引擎结论优先并改为计划员语言\n- 补充后端黄金测试、离线 E2E 与产品说明
This commit is contained in:
parent
263af1ee32
commit
e0cf0f30f6
|
|
@ -0,0 +1,238 @@
|
||||||
|
// ============================================================
|
||||||
|
// 约束规则库(设置 → 约束配置)离线 E2E
|
||||||
|
// 计划员反馈(2026-09-17):约束配置要有默认通用规则,后面靠对话里的一句话
|
||||||
|
// 不断扩充,并按类别 / 按项目归档;没接入排产引擎的规则必须如实标注。
|
||||||
|
// 全部离线:/api/** 一律本地桩,不连现场后端,不写任何真实数据。
|
||||||
|
// ============================================================
|
||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const SHOT_DIR = process.env.E2E_SHOT_DIR || os.tmpdir();
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) => ({
|
||||||
|
status, contentType: 'application/json', body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PROFILE = {
|
||||||
|
profileId: 'default',
|
||||||
|
name: '默认约束剖面',
|
||||||
|
constraints: [
|
||||||
|
{
|
||||||
|
id: 'C6_material_kit', code: 'C6', name: '物料齐套', group: 'material',
|
||||||
|
kind: 'soft', enabled: true, configurable: true, weight: 0.8,
|
||||||
|
description: '缺料:硬=阻断发布;软=带风险排入;关闭=不检查(硬/软可配)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'C8_due_date', code: 'C8', name: '订单交期', group: 'order',
|
||||||
|
kind: 'soft', enabled: true, configurable: true, weight: 0.6,
|
||||||
|
description: '延期进冲突与 KPI,默认不拦发布',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groups: [
|
||||||
|
{ id: 'material', label: '物料', items: ['C6_material_kit'] },
|
||||||
|
{ id: 'order', label: '订单', items: ['C8_due_date'] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const BOUND_RULE = {
|
||||||
|
id: 'R-001', name: '缺料必须挡住发布', category: 'process', categoryLabel: '工艺',
|
||||||
|
scope: 'project', scopeLabel: '仅本项目', kindLabel: '必须满足', enabled: true,
|
||||||
|
boundConstraintId: 'C6_material_kit',
|
||||||
|
effect: { bound: true, enforced: true, text: '由内置规则《物料齐套》执行(已启用/硬约束)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const RECORD_ONLY_RULE = {
|
||||||
|
id: 'R-002', name: '喷涂线每天最多八小时', category: 'capacity', categoryLabel: '产能',
|
||||||
|
scope: 'tenant', scopeLabel: '全厂', kindLabel: '尽量满足', enabled: false,
|
||||||
|
effect: { bound: false, enforced: false, text: '已记录,暂未接入排产引擎' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIBRARY = {
|
||||||
|
builtin: { total: 15, enabled: 14, configurable: 10 },
|
||||||
|
custom: [BOUND_RULE, RECORD_ONLY_RULE],
|
||||||
|
groups: [
|
||||||
|
{ id: 'process', label: '工艺', items: [BOUND_RULE] },
|
||||||
|
{ id: 'capacity', label: '产能', items: [RECORD_ONLY_RULE] },
|
||||||
|
],
|
||||||
|
categories: [
|
||||||
|
{ id: 'process', label: '工艺' },
|
||||||
|
{ id: 'capacity', label: '产能' },
|
||||||
|
{ id: 'order', label: '订单' },
|
||||||
|
],
|
||||||
|
scopes: [{ id: 'project', label: '仅本项目' }, { id: 'tenant', label: '全厂' }],
|
||||||
|
summary: { builtinTotal: 15, customTotal: 2, engineBound: 1, recordOnly: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOP_ITEMS = [{
|
||||||
|
assetId: 'sop-changeover-1',
|
||||||
|
title: '换线标准SOP',
|
||||||
|
tags: ['换线'],
|
||||||
|
effectCount: 3,
|
||||||
|
compilable: true,
|
||||||
|
summary: 'C10_changeover→开/soft;跨族换型默认60.0分;建议策略CHANGEOVER_MIN;C13_sop→开/soft',
|
||||||
|
plannerSummary: '将换型顺序纳入排产控制;跨产品族换型预留 60 分钟;优先连续生产同一产品族',
|
||||||
|
}];
|
||||||
|
|
||||||
|
async function stubApi(page: Page): Promise<void> {
|
||||||
|
await page.route((url: URL) => url.pathname.startsWith('/api/'), route => {
|
||||||
|
const path = new URL(route.request().url()).pathname;
|
||||||
|
switch (path) {
|
||||||
|
case '/api/health':
|
||||||
|
return route.fulfill(json({ interfaceVersion: '1.0', authProvider: 'local', licenseProvider: 'local' }));
|
||||||
|
case '/api/auth/me':
|
||||||
|
return route.fulfill(json({
|
||||||
|
user: {
|
||||||
|
user_id: 'user-1', username: 'e2e', fullname: 'E2E', tenant_uuid: 'tenant-1',
|
||||||
|
roles: ['admin'], auth_kind: 'user',
|
||||||
|
},
|
||||||
|
provider: 'local',
|
||||||
|
license: null,
|
||||||
|
}));
|
||||||
|
case '/api/features':
|
||||||
|
return route.fulfill(json({ version: 1, source: 'default', path: '', error: null, unknown: [], features: {} }));
|
||||||
|
case '/api/workspace':
|
||||||
|
return route.fulfill(json({
|
||||||
|
projects: [],
|
||||||
|
sessions: [{
|
||||||
|
id: 'session-1', projectId: '__personal__', title: 'E2E', status: 'running',
|
||||||
|
updatedAt: '2026-09-17 10:00:00', scope: 'personal',
|
||||||
|
}],
|
||||||
|
files: [], messages: { 'session-1': [] },
|
||||||
|
activeProjectId: '__personal__', activeSessionId: 'session-1',
|
||||||
|
}));
|
||||||
|
case '/api/sessions/session-1/messages':
|
||||||
|
return route.fulfill(json({ sessionId: 'session-1', messages: [] }));
|
||||||
|
case '/api/skills':
|
||||||
|
return route.fulfill(json({ skills: [], skillsDir: '', mode: 'web' }));
|
||||||
|
case '/api/skills/health':
|
||||||
|
return route.fulfill(json({ health: [] }));
|
||||||
|
case '/api/constraints':
|
||||||
|
return route.fulfill(json(PROFILE));
|
||||||
|
case '/api/constraints/library':
|
||||||
|
return route.fulfill(json(LIBRARY));
|
||||||
|
case '/api/sop/compilable':
|
||||||
|
return route.fulfill(json({ items: SOP_ITEMS, rulePacks: [] }));
|
||||||
|
default:
|
||||||
|
return route.fulfill(json({ detail: 'e2e stub' }, 404));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSettingsSection(page: Page, label: string): Promise<void> {
|
||||||
|
await page.locator('.app-menubar > button').filter({ hasText: '设置' }).first().click();
|
||||||
|
await expect(page.locator('.gov-shell')).toBeVisible();
|
||||||
|
await page.locator('.gov-nav-item').filter({ hasText: label }).first().click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('约束配置显示默认通用规则与已扩充规则,并如实标注是否接入引擎', async ({ page }) => {
|
||||||
|
await stubApi(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await openSettingsSection(page, '约束配置');
|
||||||
|
|
||||||
|
const card = page.locator('.gov-card').filter({ hasText: '约束规则库' }).first();
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await expect(card).toContainText('通用规则 15 项');
|
||||||
|
await expect(card).toContainText('扩展规则 2 项');
|
||||||
|
await expect(card).toContainText('已用于排产 1 项');
|
||||||
|
await expect(card).toContainText('暂未用于排产 1 项');
|
||||||
|
|
||||||
|
// 按类别归档
|
||||||
|
await expect(card).toContainText('工艺');
|
||||||
|
await expect(card).toContainText('产能');
|
||||||
|
// 按范围归档 + 一句计划员语言
|
||||||
|
await expect(card).toContainText('缺料必须挡住发布');
|
||||||
|
await expect(card).toContainText('仅本项目');
|
||||||
|
await expect(card).toContainText('由内置规则《物料齐套》执行');
|
||||||
|
await expect(card).toContainText('喷涂线每天最多八小时');
|
||||||
|
await expect(card).toContainText('全厂');
|
||||||
|
await expect(card).toContainText('已纳入规则库,暂未用于排产');
|
||||||
|
|
||||||
|
// 不给计划员看内部标识 / 配置语言
|
||||||
|
await expect(card).not.toContainText('C6_material_kit');
|
||||||
|
await expect(card).not.toContainText('JSON');
|
||||||
|
await expect(card).not.toContainText('G0');
|
||||||
|
|
||||||
|
const sopCard = page.locator('.gov-card').filter({ hasText: '作业标准规则' }).first();
|
||||||
|
await expect(sopCard).toBeVisible();
|
||||||
|
await expect(sopCard).toContainText('跨产品族换型预留 60 分钟');
|
||||||
|
await expect(sopCard).toContainText('应用到排产');
|
||||||
|
await expect(sopCard).not.toContainText('C10_changeover');
|
||||||
|
await expect(sopCard).not.toContainText('C13_sop');
|
||||||
|
await expect(sopCard).not.toContainText('soft');
|
||||||
|
|
||||||
|
// 停用状态如实回显
|
||||||
|
const recordOnlyRow = card.locator('.constraint-row').filter({ hasText: '喷涂线每天最多八小时' });
|
||||||
|
await expect(recordOnlyRow.locator('input[type=checkbox]')).not.toBeChecked();
|
||||||
|
await expect(recordOnlyRow).toContainText('已停用');
|
||||||
|
|
||||||
|
await page.screenshot({ path: path.join(SHOT_DIR, 'planner-constraint-rules.png'), fullPage: true });
|
||||||
|
await sopCard.scrollIntoViewIfNeeded();
|
||||||
|
await page.screenshot({ path: path.join(SHOT_DIR, 'planner-constraint-sop-rules.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('启停扩充规则走 P2 确认卡,不直接改库', async ({ page }) => {
|
||||||
|
await stubApi(page);
|
||||||
|
const staged: Array<Record<string, unknown>> = [];
|
||||||
|
await page.route('**/api/constraints/rules/stage', route => {
|
||||||
|
staged.push(route.request().postDataJSON() as Record<string, unknown>);
|
||||||
|
return route.fulfill(json({
|
||||||
|
message: '启用约束规则:喷涂线每天最多八小时 已进入 P2 确认队列。',
|
||||||
|
block: {
|
||||||
|
blockId: 'blk-1', type: 'confirm-card', interfaceVersion: '1.0',
|
||||||
|
props: {
|
||||||
|
title: '启用约束规则:喷涂线每天最多八小时', power: 'P2', confirmId: 'confirm-1',
|
||||||
|
summary: ['该操作只调整规则库里的状态,不改动内置约束的开关'],
|
||||||
|
},
|
||||||
|
actions: [], evidenceRefs: [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await openSettingsSection(page, '约束配置');
|
||||||
|
|
||||||
|
const card = page.locator('.gov-card').filter({ hasText: '约束规则库' }).first();
|
||||||
|
const recordOnlyRow = card.locator('.constraint-row').filter({ hasText: '喷涂线每天最多八小时' });
|
||||||
|
// 受控组件:勾选状态由服务端规则库决定,这里只发出暂存请求(不期待本地立刻勾上)
|
||||||
|
await recordOnlyRow.locator('input[type=checkbox]').click();
|
||||||
|
|
||||||
|
await expect.poll(() => staged.length).toBe(1);
|
||||||
|
expect(staged[0].payload).toMatchObject({ op: 'enable', ruleId: 'R-002' });
|
||||||
|
const confirm = page.locator('.order-confirm').first();
|
||||||
|
await expect(confirm).toBeVisible();
|
||||||
|
await expect(confirm).toContainText('启用约束规则:喷涂线每天最多八小时');
|
||||||
|
await expect(confirm).toContainText('需人工确认');
|
||||||
|
await expect(confirm).toContainText('该操作只调整规则库里的状态');
|
||||||
|
await page.screenshot({ path: path.join(SHOT_DIR, 'planner-constraint-rule-confirm.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('计划员可提交新的约束要求,由主对话调用 Pi 生成确认项', async ({ page }) => {
|
||||||
|
await stubApi(page);
|
||||||
|
const commands: string[] = [];
|
||||||
|
await page.route('**/api/chat', async route => {
|
||||||
|
commands.push((route.request().postDataJSON() as { text: string }).text);
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'text/event-stream',
|
||||||
|
body: 'data: {"type":"done"}\n\n',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await openSettingsSection(page, '约束配置');
|
||||||
|
|
||||||
|
const card = page.locator('.gov-card').filter({ hasText: '约束规则库' }).first();
|
||||||
|
await card.getByLabel('约束要求').fill('关键客户订单延期前两个工作日提醒');
|
||||||
|
await card.locator('.constraint-form-field').filter({ hasText: '业务类别' }).locator('select').selectOption('order');
|
||||||
|
await card.locator('.constraint-form-field').filter({ hasText: '适用范围' }).locator('select').selectOption('tenant');
|
||||||
|
await card.locator('.constraint-form-field').filter({ hasText: '约束级别' }).locator('select').selectOption('hard');
|
||||||
|
await card.getByRole('button', { name: '提交约束要求' }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => commands.length).toBe(1);
|
||||||
|
expect(commands[0]).toContain('约束内容:关键客户订单延期前两个工作日提醒');
|
||||||
|
expect(commands[0]).toContain('业务类别:订单');
|
||||||
|
expect(commands[0]).toContain('适用范围:全厂');
|
||||||
|
expect(commands[0]).toContain('约束级别:必须满足');
|
||||||
|
await expect(page.locator('.gov-shell')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
@ -573,9 +573,38 @@ export async function postConstraintStage(sessionId: string, payload: Record<str
|
||||||
return resp.json() as Promise<{ message?: string; error?: string; block?: UIBlock }>;
|
return resp.json() as Promise<{ message?: string; error?: string; block?: UIBlock }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConstraintRule = {
|
||||||
|
id: string; name: string; category: string; categoryLabel: string;
|
||||||
|
scope: string; scopeLabel: string; kindLabel: string; enabled?: boolean;
|
||||||
|
boundConstraintId?: string;
|
||||||
|
effect: { bound: boolean; enforced: boolean; text: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConstraintLibrary = {
|
||||||
|
builtin: { total: number; enabled: number; configurable: number };
|
||||||
|
custom: ConstraintRule[];
|
||||||
|
groups: { id: string; label: string; items: ConstraintRule[] }[];
|
||||||
|
categories: { id: string; label: string }[];
|
||||||
|
scopes: { id: string; label: string }[];
|
||||||
|
summary: { builtinTotal: number; customTotal: number; engineBound: number; recordOnly: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchConstraintLibrary = () =>
|
||||||
|
getJson<ConstraintLibrary>('/api/constraints/library');
|
||||||
|
|
||||||
|
export async function postConstraintRuleStage(sessionId: string, payload: Record<string, unknown>) {
|
||||||
|
const resp = await apiFetch('/api/constraints/rules/stage', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId, payload }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`constraint rule stage → ${resp.status}`);
|
||||||
|
return resp.json() as Promise<{ message?: string; error?: string; block?: UIBlock }>;
|
||||||
|
}
|
||||||
|
|
||||||
export type SopCompilableItem = {
|
export type SopCompilableItem = {
|
||||||
assetId?: string; title: string; tags: string[]; effectCount: number;
|
assetId?: string; title: string; tags: string[]; effectCount: number;
|
||||||
compilable: boolean; summary: string;
|
compilable: boolean; summary: string; plannerSummary?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchSopCompilable = () =>
|
export const fetchSopCompilable = () =>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
// 全部数据源为 P0 只读端点;待审批的批准/驳回复用 P2 唯一确认通道
|
// 全部数据源为 P0 只读端点;待审批的批准/驳回复用 P2 唯一确认通道
|
||||||
// ============================================================
|
// ============================================================
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ThunderboltOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, SendOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||||
import type {
|
import type {
|
||||||
ApprovalDecision, AuditAlert, AuditEvent, ChainStatus, GoldenStatus, LlmSettings, ModuleEntry, PendingAction, PolicyEntry,
|
ApprovalDecision, AuditAlert, AuditEvent, ChainStatus, GoldenStatus, LlmSettings, ModuleEntry, PendingAction, PolicyEntry,
|
||||||
PreferenceExplain,
|
PreferenceExplain,
|
||||||
|
|
@ -17,9 +17,10 @@ import type {
|
||||||
import {
|
import {
|
||||||
fetchApprovalHistory, fetchAudit, fetchConstraintProfile, fetchGoldenTests, fetchLlmSettings, fetchModules,
|
fetchApprovalHistory, fetchAudit, fetchConstraintProfile, fetchGoldenTests, fetchLlmSettings, fetchModules,
|
||||||
fetchPreferenceExplain, resetPreferences,
|
fetchPreferenceExplain, resetPreferences,
|
||||||
fetchPending, fetchPolicy, fetchScheduleParams, fetchSensitivity, fetchSopCompilable, postConfirm,
|
fetchConstraintLibrary, fetchPending, fetchPolicy, fetchScheduleParams, fetchSensitivity, fetchSopCompilable,
|
||||||
postConfirmBatch, postConstraintStage, postParamsStage, postSopStage,
|
postConfirm,
|
||||||
type ConstraintItem, type ConstraintProfile, type ScheduleParams, type SopCompilableItem,
|
postConfirmBatch, postConstraintRuleStage, postConstraintStage, postParamsStage, postSopStage,
|
||||||
|
type ConstraintItem, type ConstraintLibrary, type ConstraintProfile, type ScheduleParams, type SopCompilableItem,
|
||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
import SkillConsole from '../skills/SkillConsole';
|
import SkillConsole from '../skills/SkillConsole';
|
||||||
import KnowledgePanel from '../knowledge/KnowledgePanel';
|
import KnowledgePanel from '../knowledge/KnowledgePanel';
|
||||||
|
|
@ -132,7 +133,10 @@ export default function GovConsole(props: {
|
||||||
<ScheduleParamsView sessionId={props.sessionId ?? 'web'} />
|
<ScheduleParamsView sessionId={props.sessionId ?? 'web'} />
|
||||||
)}
|
)}
|
||||||
{section === 'settings.constraints' && (
|
{section === 'settings.constraints' && (
|
||||||
<ConstraintsView sessionId={props.sessionId ?? 'web'} />
|
<ConstraintsView
|
||||||
|
sessionId={props.sessionId ?? 'web'}
|
||||||
|
onSendCommand={props.onSendCommand}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{section === 'settings.integration' && <IntegrationView />}
|
{section === 'settings.integration' && <IntegrationView />}
|
||||||
{section === 'settings.skills' && (
|
{section === 'settings.skills' && (
|
||||||
|
|
@ -1052,12 +1056,41 @@ function ScheduleParamsView(props: { sessionId: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CONSTRAINT_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
C1_precedence: '前道工序完成后,后道工序才能开始。',
|
||||||
|
C2_no_overlap: '同一设备同一时间只安排一项任务。',
|
||||||
|
C3_calendar: '工单仅安排在有效班次内。',
|
||||||
|
C4_maintenance: '设备维保期间不安排生产。',
|
||||||
|
C5_capability: '任务仅分配到具备资格的产线或设备。',
|
||||||
|
C6_material_kit: '物料不齐套时,按所选级别拦截或提示风险。',
|
||||||
|
C7_capacity: '每日生产量不得超过产线可用产能。',
|
||||||
|
C8_due_date: '交期延误纳入冲突提示和绩效指标,不自动阻止发布。',
|
||||||
|
C9_customer_priority: '客户等级和订单优先级决定排产优先顺序。',
|
||||||
|
C10_changeover: '产品切换所需的换型时间计入生产计划。',
|
||||||
|
C11_freeze: '冻结期内保持已确认计划稳定,冻结期外允许滚动调整。',
|
||||||
|
C12_team: '同一班组承担的任务不超过可用人力。',
|
||||||
|
C12_tooling: '工装、模具与任务匹配,并考虑可用寿命。',
|
||||||
|
C13_sop: '由已审批作业标准生成并应用的排产规则。',
|
||||||
|
};
|
||||||
|
|
||||||
|
function constraintPlannerDescription(item: ConstraintItem): string {
|
||||||
|
return CONSTRAINT_DESCRIPTIONS[item.id] || item.description || '用于约束排产结果。';
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- 设置·约束配置(SC-04) ----------------
|
// ---------------- 设置·约束配置(SC-04) ----------------
|
||||||
function ConstraintsView(props: { sessionId: string }) {
|
function ConstraintsView(props: {
|
||||||
|
sessionId: string;
|
||||||
|
onSendCommand?: (text: string) => void;
|
||||||
|
}) {
|
||||||
const [profile, setProfile] = useState<ConstraintProfile | null>(null);
|
const [profile, setProfile] = useState<ConstraintProfile | null>(null);
|
||||||
const [draft, setDraft] = useState<Record<string, ConstraintItem>>({});
|
const [draft, setDraft] = useState<Record<string, ConstraintItem>>({});
|
||||||
|
const [library, setLibrary] = useState<ConstraintLibrary | null>(null);
|
||||||
const [sops, setSops] = useState<SopCompilableItem[]>([]);
|
const [sops, setSops] = useState<SopCompilableItem[]>([]);
|
||||||
const [sopsError, setSopsError] = useState<string | null>(null);
|
const [sopsError, setSopsError] = useState<string | null>(null);
|
||||||
|
const [ruleText, setRuleText] = useState('');
|
||||||
|
const [ruleCategory, setRuleCategory] = useState('other');
|
||||||
|
const [ruleScope, setRuleScope] = useState('project');
|
||||||
|
const [ruleKind, setRuleKind] = useState('soft');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const [pending, setPending] = useState<UIBlock | null>(null);
|
const [pending, setPending] = useState<UIBlock | null>(null);
|
||||||
|
|
@ -1071,6 +1104,9 @@ function ConstraintsView(props: { sessionId: string }) {
|
||||||
setDraft(map);
|
setDraft(map);
|
||||||
})
|
})
|
||||||
.catch(() => setNotice('加载约束配置失败'));
|
.catch(() => setNotice('加载约束配置失败'));
|
||||||
|
void fetchConstraintLibrary()
|
||||||
|
.then(setLibrary)
|
||||||
|
.catch(() => setLibrary(null));
|
||||||
void fetchSopCompilable()
|
void fetchSopCompilable()
|
||||||
.then(r => {
|
.then(r => {
|
||||||
setSops(r.items || []);
|
setSops(r.items || []);
|
||||||
|
|
@ -1078,7 +1114,7 @@ function ConstraintsView(props: { sessionId: string }) {
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setSops([]);
|
setSops([]);
|
||||||
setSopsError('可编译 SOP 列表加载失败');
|
setSopsError('作业标准加载失败');
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
@ -1091,7 +1127,7 @@ function ConstraintsView(props: { sessionId: string }) {
|
||||||
if (res.error) { setNotice(res.error); return; }
|
if (res.error) { setNotice(res.error); return; }
|
||||||
if (res.block) {
|
if (res.block) {
|
||||||
setPending(res.block);
|
setPending(res.block);
|
||||||
setNotice(res.message ?? '已生成确认卡');
|
setNotice(res.message ?? '已生成待确认项');
|
||||||
} else setNotice(res.message ?? '已提交');
|
} else setNotice(res.message ?? '已提交');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setNotice(err instanceof Error ? err.message : '提交失败');
|
setNotice(err instanceof Error ? err.message : '提交失败');
|
||||||
|
|
@ -1108,15 +1144,52 @@ function ConstraintsView(props: { sessionId: string }) {
|
||||||
if (res.error) { setNotice(res.error); return; }
|
if (res.error) { setNotice(res.error); return; }
|
||||||
if (res.block) {
|
if (res.block) {
|
||||||
setPending(res.block);
|
setPending(res.block);
|
||||||
setNotice(res.message ?? '已生成 SOP 应用确认卡');
|
setNotice(res.message ?? '已生成待确认项');
|
||||||
} else setNotice(res.message ?? '已提交');
|
} else setNotice(res.message ?? '已提交');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setNotice(err instanceof Error ? err.message : 'SOP 应用失败');
|
setNotice(err instanceof Error ? err.message : '作业标准应用失败');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stageRule = async (payload: Record<string, unknown>) => {
|
||||||
|
setBusy(true);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const res = await postConstraintRuleStage(props.sessionId, payload);
|
||||||
|
if (res.error) { setNotice(res.error); return; }
|
||||||
|
if (res.block) {
|
||||||
|
setPending(res.block);
|
||||||
|
setNotice(res.message ?? '已生成待确认项');
|
||||||
|
} else setNotice(res.message ?? '已提交');
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof Error ? err.message : '规则提交失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRuleRequirement = () => {
|
||||||
|
const content = ruleText.trim();
|
||||||
|
if (!content) {
|
||||||
|
setNotice('请填写约束内容。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!props.onSendCommand) {
|
||||||
|
setNotice('当前环境无法提交约束要求。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const categoryLabel = library?.categories.find(c => c.id === ruleCategory)?.label ?? '其他';
|
||||||
|
const scopeLabel = library?.scopes.find(s => s.id === ruleScope)?.label ?? '仅本项目';
|
||||||
|
const kindLabel = ruleKind === 'hard' ? '必须满足' : '尽量满足';
|
||||||
|
props.onSendCommand(
|
||||||
|
'请将以下排产要求整理为约束规则并生成待确认项:'
|
||||||
|
+ `约束内容:${content};业务类别:${categoryLabel};`
|
||||||
|
+ `适用范围:${scopeLabel};约束级别:${kindLabel}。`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const decide = async (approve: boolean) => {
|
const decide = async (approve: boolean) => {
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
const confirmId = String(pending.props.confirmId ?? '');
|
const confirmId = String(pending.props.confirmId ?? '');
|
||||||
|
|
@ -1158,17 +1231,126 @@ function ConstraintsView(props: { sessionId: string }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="gov-kv" style={{ marginBottom: 12 }}>
|
<p className="gov-kv" style={{ marginBottom: 12 }}>
|
||||||
硬约束违反将阻止发布;软约束仅报告风险。内建项不可关闭。可将知识库 SOP 编译为规则包(IND-02)。
|
系统已预置通用约束。补充约束经确认后纳入规则库,并按业务类别与适用范围归档。
|
||||||
|
硬约束拦截不合规方案,软约束提示风险,系统内置规则不可停用。
|
||||||
</p>
|
</p>
|
||||||
|
{library && (
|
||||||
<div className="gov-card">
|
<div className="gov-card">
|
||||||
<div className="gov-card-title">SOP → 约束规则包</div>
|
<div className="gov-card-title">约束规则库</div>
|
||||||
|
<div className="gov-kv">
|
||||||
|
通用规则 {library.summary.builtinTotal} 项 · 扩展规则 {library.summary.customTotal} 项
|
||||||
|
(已用于排产 {library.summary.engineBound} 项 / 暂未用于排产 {library.summary.recordOnly} 项)
|
||||||
|
</div>
|
||||||
|
{library.custom.length === 0 && (
|
||||||
|
<p className="gov-kv">
|
||||||
|
暂无扩展约束。可提交约束要求,系统整理并经确认后纳入规则库。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="constraint-form">
|
||||||
|
<div>
|
||||||
|
<div className="gov-card-title" style={{ fontSize: 12.5 }}>新增约束要求</div>
|
||||||
|
<div className="faint">描述业务要求和适用场景,系统将整理为待确认规则。</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
aria-label="约束要求"
|
||||||
|
value={ruleText}
|
||||||
|
maxLength={40}
|
||||||
|
rows={2}
|
||||||
|
placeholder="例如:同一产品族连续排产,减少换型次数"
|
||||||
|
onChange={e => setRuleText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="constraint-form-row">
|
||||||
|
<label className="constraint-form-field">
|
||||||
|
业务类别
|
||||||
|
<select value={ruleCategory} onChange={e => setRuleCategory(e.target.value)}>
|
||||||
|
{library.categories.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="constraint-form-field">
|
||||||
|
适用范围
|
||||||
|
<select value={ruleScope} onChange={e => setRuleScope(e.target.value)}>
|
||||||
|
{library.scopes.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="constraint-form-field">
|
||||||
|
约束级别
|
||||||
|
<select value={ruleKind} onChange={e => setRuleKind(e.target.value)}>
|
||||||
|
<option value="soft">尽量满足</option>
|
||||||
|
<option value="hard">必须满足</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="constraint-form-actions">
|
||||||
|
<span className="faint">批准前不影响现有计划。</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={busy || !!pending || !props.onSendCommand || !ruleText.trim()}
|
||||||
|
onClick={submitRuleRequirement}
|
||||||
|
>
|
||||||
|
<SendOutlined />
|
||||||
|
<span>提交约束要求</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{library.groups.map(g => (
|
||||||
|
<div key={g.id} style={{ marginTop: 8 }}>
|
||||||
|
<div className="faint">{g.label}</div>
|
||||||
|
{g.items.map(r => (
|
||||||
|
<div key={r.id} className="constraint-row">
|
||||||
|
<div className="constraint-main">
|
||||||
|
<strong>{r.name}</strong>
|
||||||
|
<span className="faint">
|
||||||
|
{r.scopeLabel} · {r.kindLabel} · {r.effect.enforced || r.effect.bound
|
||||||
|
? r.effect.text
|
||||||
|
: '已纳入规则库,暂未用于排产'}
|
||||||
|
{r.enabled === false ? ' · 已停用' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="constraint-controls">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={r.enabled !== false}
|
||||||
|
disabled={busy || !!pending}
|
||||||
|
onChange={e => void stageRule({
|
||||||
|
op: e.target.checked ? 'enable' : 'disable', ruleId: r.id,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
启用
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn danger"
|
||||||
|
disabled={busy || !!pending}
|
||||||
|
title={`删除规则 ${r.name}`}
|
||||||
|
onClick={() => void stageRule({ op: 'delete', ruleId: r.id })}
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="gov-card">
|
||||||
|
<div className="gov-card-title">作业标准规则</div>
|
||||||
|
<p className="gov-kv" style={{ marginBottom: 4 }}>
|
||||||
|
系统可从已审批的作业标准中识别排产约束,应用前需确认。
|
||||||
|
</p>
|
||||||
{sopsError && <p className="gov-kv">{sopsError}</p>}
|
{sopsError && <p className="gov-kv">{sopsError}</p>}
|
||||||
{!sopsError && sops.length === 0 && <p className="gov-kv">暂无可编译 SOP</p>}
|
{!sopsError && sops.length === 0 && <p className="gov-kv">暂无可应用的作业标准</p>}
|
||||||
{sops.map(s => (
|
{sops.map(s => (
|
||||||
<div key={s.assetId || s.title} className="constraint-row">
|
<div key={s.assetId || s.title} className="constraint-row">
|
||||||
<div className="constraint-main">
|
<div className="constraint-main">
|
||||||
<strong>{s.title}</strong>
|
<strong>{s.title}</strong>
|
||||||
<span className="faint">{s.summary || (s.compilable ? `${s.effectCount} 项效果` : '无可落地效果')}</span>
|
<span className="faint">
|
||||||
|
{s.compilable
|
||||||
|
? s.plannerSummary || `识别到 ${s.effectCount} 项可用于排产的约束`
|
||||||
|
: '当前作业标准暂未识别出可用于排产的约束'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="constraint-controls">
|
<div className="constraint-controls">
|
||||||
<button
|
<button
|
||||||
|
|
@ -1177,10 +1359,10 @@ function ConstraintsView(props: { sessionId: string }) {
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||||
disabled={busy || !!pending || !s.compilable || !s.assetId}
|
disabled={busy || !!pending || !s.compilable || !s.assetId}
|
||||||
onClick={() => s.assetId && void stageSop(s.assetId)}
|
onClick={() => s.assetId && void stageSop(s.assetId)}
|
||||||
title={s.assetId ? `使用 ${s.assetId} 编译并暂存` : '缺少 assetId,无法编译'}
|
title={s.assetId ? `应用《${s.title}》中的排产规则` : '当前作业标准暂不可应用'}
|
||||||
>
|
>
|
||||||
<ThunderboltOutlined />
|
<ThunderboltOutlined />
|
||||||
<span>编译并暂存</span>
|
<span>应用到排产</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1195,8 +1377,8 @@ function ConstraintsView(props: { sessionId: string }) {
|
||||||
return (
|
return (
|
||||||
<div key={id} className="constraint-row">
|
<div key={id} className="constraint-row">
|
||||||
<div className="constraint-main">
|
<div className="constraint-main">
|
||||||
<strong>{c.code} {c.name}</strong>
|
<strong>{c.name}</strong>
|
||||||
<span className="faint">{c.description}</span>
|
<span className="faint">{constraintPlannerDescription(c)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="constraint-controls">
|
<div className="constraint-controls">
|
||||||
<label>
|
<label>
|
||||||
|
|
@ -1219,8 +1401,8 @@ function ConstraintsView(props: { sessionId: string }) {
|
||||||
[id]: { ...c, kind: e.target.value as 'hard' | 'soft' },
|
[id]: { ...c, kind: e.target.value as 'hard' | 'soft' },
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<option value="hard">硬约束</option>
|
<option value="hard">必须满足</option>
|
||||||
<option value="soft">软约束</option>
|
<option value="soft">尽量满足</option>
|
||||||
</select>
|
</select>
|
||||||
{!c.configurable && <span className="faint">内建</span>}
|
{!c.configurable && <span className="faint">内建</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1233,7 +1415,7 @@ function ConstraintsView(props: { sessionId: string }) {
|
||||||
<div className="order-confirm" style={{ marginBottom: 10 }}>
|
<div className="order-confirm" style={{ marginBottom: 10 }}>
|
||||||
<div className="confirm-title">
|
<div className="confirm-title">
|
||||||
{String(pending.props.title ?? '约束配置确认')}
|
{String(pending.props.title ?? '约束配置确认')}
|
||||||
<span className="power-badge">{String(pending.props.power ?? 'P2')} 需人工确认</span>
|
<span className="power-badge">需人工确认</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="confirm-summary">
|
<ul className="confirm-summary">
|
||||||
{((pending.props.summary as string[]) ?? []).map((s, i) => <li key={i}>{s}</li>)}
|
{((pending.props.summary as string[]) ?? []).map((s, i) => <li key={i}>{s}</li>)}
|
||||||
|
|
|
||||||
|
|
@ -1777,6 +1777,65 @@ html.aps-desktop .app-menubar-spacer,
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
.constraint-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.constraint-form textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 56px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.constraint-form textarea:focus {
|
||||||
|
outline: 2px solid rgba(196, 92, 38, .16);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.constraint-form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.constraint-form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.constraint-form-field select {
|
||||||
|
min-width: 112px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.constraint-form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.constraint-form-actions .btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
.gov-pre { background: #23221E; color: #C9E8B8; padding: 8px; border-radius: 8px;
|
.gov-pre { background: #23221E; color: #C9E8B8; padding: 8px; border-radius: 8px;
|
||||||
font-size: 11px; overflow-x: auto; margin: 8px 0; }
|
font-size: 11px; overflow-x: auto; margin: 8px 0; }
|
||||||
.gov-chain { display: inline-block; border-radius: 999px; padding: 4px 14px; font-size: 12.5px;
|
.gov-chain { display: inline-block; border-radius: 999px; padding: 4px 14px; font-size: 12.5px;
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@
|
||||||
| `param.observation.record` | P1 | 线上 KPI 观测回调(对比基线,连续劣化自动回滚) | 无需确认 | §9.8 |
|
| `param.observation.record` | P1 | 线上 KPI 观测回调(对比基线,连续劣化自动回滚) | 无需确认 | §9.8 |
|
||||||
| `constraint.profile.query` | P0 | 约束剖面只读 | 无需确认 | SC-04 |
|
| `constraint.profile.query` | P0 | 约束剖面只读 | 无需确认 | SC-04 |
|
||||||
| `constraint.profile.save` | P2 | 约束启停/硬软 | 变更摘要;写前自动建档 | SC-04 |
|
| `constraint.profile.save` | P2 | 约束启停/硬软 | 变更摘要;写前自动建档 | SC-04 |
|
||||||
|
| `constraint.rule.query` | P0 | 约束规则库只读(默认通用规则 + 计划员扩充规则) | 无需确认 | SC-04 |
|
||||||
|
| `constraint.rule.save` | P2 | 计划员一句话扩充规则(新增/启用/停用/删除) | 类别/范围/强度/是否接入引擎;写前自动建档 | SC-04 |
|
||||||
| `governance.rule.create` | P2 | 创建规则会改变自动化执行输入 | 规则标识/房间/触发器;批准后写入并自动建档 | R71.6 |
|
| `governance.rule.create` | P2 | 创建规则会改变自动化执行输入 | 规则标识/房间/触发器;批准后写入并自动建档 | R71.6 |
|
||||||
| `governance.rule.update` | P2 | 更新规则会改变自动化执行语义 | 规则标识与 before/after;批准后写入并自动建档 | R71.6 |
|
| `governance.rule.update` | P2 | 更新规则会改变自动化执行语义 | 规则标识与 before/after;批准后写入并自动建档 | R71.6 |
|
||||||
| `governance.rule.delete` | P2 | 删除规则会停止后续自动化执行 | 规则标识;批准后删除并自动建档 | R71.6 |
|
| `governance.rule.delete` | P2 | 删除规则会停止后续自动化执行 | 规则标识;批准后删除并自动建档 | R71.6 |
|
||||||
|
|
@ -134,6 +136,7 @@
|
||||||
| 端点 | 用途 | 加入 |
|
| 端点 | 用途 | 加入 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET /api/world/summary·gantt·load·due` | 世界视图投影 | M1 |
|
| `GET /api/world/summary·gantt·load·due` | 世界视图投影 | M1 |
|
||||||
|
| `GET /api/constraints` · `GET /api/constraints/library` | 约束剖面 / 约束规则库(默认通用规则 + 计划员扩充规则,只读) | SC-04 |
|
||||||
| `GET /api/timeline` | 时间线 | M2 |
|
| `GET /api/timeline` | 时间线 | M2 |
|
||||||
| `GET /api/checkpoints/{pairId}` | 检查点世界侧投影(P0,summary+固定/柔性工单+产能池;缺失 404) | 矩阵 54 |
|
| `GET /api/checkpoints/{pairId}` | 检查点世界侧投影(P0,summary+固定/柔性工单+产能池;缺失 404) | 矩阵 54 |
|
||||||
| `GET /api/checkpoints?pairIds=&branchIds=&sessionId=` | 分支锚定批量投影(P0,缺失项上报) | 矩阵 54 |
|
| `GET /api/checkpoints?pairIds=&branchIds=&sessionId=` | 分支锚定批量投影(P0,缺失项上报) | 矩阵 54 |
|
||||||
|
|
@ -235,6 +238,7 @@
|
||||||
| `POST /api/projects/{id}/files/upload` | 浏览器上传本地工程数据文件到项目目录(P1 直通) | `project.*` | AG-08 |
|
| `POST /api/projects/{id}/files/upload` | 浏览器上传本地工程数据文件到项目目录(P1 直通) | `project.*` | AG-08 |
|
||||||
| `POST /api/master/stage` | 产线/物料/维保编辑生成确认卡 | `master.*` P2 → `/api/actions/confirm` 执行 | M4 首切片 |
|
| `POST /api/master/stage` | 产线/物料/维保编辑生成确认卡 | `master.*` P2 → `/api/actions/confirm` 执行 | M4 首切片 |
|
||||||
| `POST /api/import/commit` | 导入批次生成确认卡 | `import.commit` P2 → `/api/actions/confirm` 执行 | MD-04 |
|
| `POST /api/import/commit` | 导入批次生成确认卡 | `import.commit` P2 → `/api/actions/confirm` 执行 | MD-04 |
|
||||||
|
| `POST /api/constraints/rules/stage` | 计划员扩充约束规则(新增/启停/删除)生成确认卡 | `constraint.rule.save` P2 → `/api/actions/confirm` 执行 | SC-04 |
|
||||||
|
|
||||||
## 审批角色策略
|
## 审批角色策略
|
||||||
|
|
||||||
|
|
@ -378,6 +382,7 @@
|
||||||
| 2026-07-23 | IND-02:`sop.compile` P0 / `sop.apply` P2 + `/api/sop*`;C13_sop |
|
| 2026-07-23 | IND-02:`sop.compile` P0 / `sop.apply` P2 + `/api/sop*`;C13_sop |
|
||||||
| 2026-07-23 | 通用平台 M-A/B:`readiness.query` P0 / `flex.time.update` P2 + `/api/readiness` `/api/times*` `/api/projects` `/api/packs` |
|
| 2026-07-23 | 通用平台 M-A/B:`readiness.query` P0 / `flex.time.update` P2 + `/api/readiness` `/api/times*` `/api/projects` `/api/packs` |
|
||||||
| 2026-07-23 | 通用平台 M-D/E/F:`rag.query` P0 / `routing.template.apply` P2 / `schedule.wizard` P0 + skill 管理台端点与模板/索引端点 |
|
| 2026-07-23 | 通用平台 M-D/E/F:`rag.query` P0 / `routing.template.apply` P2 / `schedule.wizard` P0 + skill 管理台端点与模板/索引端点 |
|
||||||
|
| 2026-09-17 | SC-04 扩充:`constraint.rule.query` P0 / `constraint.rule.save` P2 + `/api/constraints/library`、`/api/constraints/rules/stage`;默认通用规则保持内建目录,计划员扩充规则按类别/范围归档,未接入引擎的规则如实标注 |
|
||||||
|
|
||||||
## Round 65 P2/P3 排产发布与 MES 分离
|
## Round 65 P2/P3 排产发布与 MES 分离
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
# A0050101-00280 外壳 · 紧急插单计划员验收话术
|
||||||
|
|
||||||
|
适用对象:工厂计划员和现场验收人员。
|
||||||
|
|
||||||
|
产品口径:计划员直接用平常说的话提问;Pi 负责听懂原话、补齐下单所需信息并调用查排产的工具,真正的“能不能插、会顺延谁”由 APS 排产引擎按项目里的订单、设备、工艺、产能和日历算出来。Pi 不得自己猜结论,也不得在计划员确认前改计划。
|
||||||
|
|
||||||
|
产品:`A0050101-00280 外壳`
|
||||||
|
|
||||||
|
## 必测话术
|
||||||
|
|
||||||
|
1. `湖南工业控制有限公司追加 50 套 A0050101-00280 外壳,10月14日交货,先评估紧急插单影响,不要直接改计划。`
|
||||||
|
期望:系统先做一次试算,列出这张急单能安排到哪里、会顺延哪些订单、每张大概顺延多久;明确说明“当前计划没有改动”。
|
||||||
|
|
||||||
|
2. `检查 SO-001 这张单现在能不能加急到 50 套、10月14日交货,并告诉我会影响哪些订单。`
|
||||||
|
期望:系统按订单号找到真实订单,再按真实产能评估;如果订单号、数量或交期与系统记录不一致,要先说清楚差异,不能直接改单。
|
||||||
|
|
||||||
|
3. `如果不能直接插,请说明是设备、产能、物料还是工作日历影响,并给我一个可行的调整方案。`
|
||||||
|
期望:系统说清是局部调整还是需要重新编排整版计划;原因必须来自本次试算结果。
|
||||||
|
|
||||||
|
4. `这张 50 套外壳插进去以后,具体会顺延哪些订单?分别顺延多少小时?`
|
||||||
|
期望:列出受影响的真实订单号、客户、原计划完工日期和调整后预计日期;不能只说“有一点影响”。
|
||||||
|
|
||||||
|
5. `采用刚才的插单方案,但先给我确认。`
|
||||||
|
期望:系统只生成一张待确认事项,说明会加入哪张急单、会影响哪些订单;确认前订单池和已发布计划不变。
|
||||||
|
|
||||||
|
6. `不采用这次插单,保持原计划不变。`
|
||||||
|
期望:系统不新增急单,不生成新排产版本,原计划保持不动。
|
||||||
|
|
||||||
|
7. `采用后告诉我生成的是待发布草稿,还是已经正式发布。`
|
||||||
|
期望:系统明确说是“待发布的调整方案”,不能把试算或草稿说成已经发布、已经下发车间。
|
||||||
|
|
||||||
|
8. `这个产品没有真实订单、设备、工艺或产能证据时,不要告诉我可以插单。`
|
||||||
|
期望:系统如实说明缺哪类资料、无法判断或不能直接插,不得用默认数量、默认交期、演示数据或硬编码结论补成“可以”。
|
||||||
|
|
||||||
|
9. `把 A0050101-00280 外壳临时加一批,先看看能不能插进去。`
|
||||||
|
期望:如果计划员没有给数量或交期,系统先追问数量和最晚交货日期,不能自己填默认值后做结论。
|
||||||
|
|
||||||
|
10. `把不存在的订单 SO-NOT-EXIST 加急,10月14日交货。`
|
||||||
|
期望:系统明确回复订单不存在,不能编造一张订单,也不能说已经安排。
|
||||||
|
|
||||||
|
## 每轮必须核对
|
||||||
|
|
||||||
|
- 页面上的备选项和步骤连续显示 `1、2、3`,不能每一条都显示 `1.`。
|
||||||
|
- 回答中用计划员能看懂的中文,不出现 `P1`、`P2`、`LNS`、`DRAFT`、`evalId`、`APPROVED`、`isRush`、`沙盒` 等内部说法。
|
||||||
|
- 系统说“能插”或“不能插”时,本轮必须已经查过真实排产数据;没有查数据就不能下结论。
|
||||||
|
- “采用”只是待确认,确认前订单池、已发布计划和车间执行都不能变化。
|
||||||
|
- 确认采用后只生成待发布的调整方案;是否发布、是否下发车间要另外处理。
|
||||||
|
- 如果项目缺少订单、产品、工艺、设备、产能或工作日历证据,页面必须显示缺少什么,不能隐藏问题后给出成功结论。
|
||||||
|
|
||||||
|
## 自动化对应
|
||||||
|
|
||||||
|
- 计划员原话通过 Pi 调用真实插单评估:`tests/golden/test_planner_rush_natural_language.py::test_planner_rush_natural_language_uses_engine_and_ignores_pi_guess`
|
||||||
|
- 缺少数量或交期先追问:`tests/golden/test_planner_rush_natural_language.py::test_planner_rush_missing_quantity_asks_instead_of_guessing`
|
||||||
|
- 不存在的订单按引擎结果拒绝:`tests/golden/test_planner_rush_natural_language.py::test_planner_rush_unknown_order_returns_engine_rejection`
|
||||||
|
- 采用前必须人工确认:`tests/golden/test_planner_rush_natural_language.py::test_planner_rush_apply_waits_for_confirmation`
|
||||||
|
- 多个备选项连续编号:`apps/web/e2e/chat-numbering.spec.ts`
|
||||||
|
|
||||||
|
本地运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\.venv\Scripts\pytest.exe -q tests/golden/test_planner_rush_natural_language.py tests/golden/test_rush.py tests/golden/test_rush_lns.py
|
||||||
|
npm run test:e2e --prefix apps/web -- chat-numbering.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
自动化测试使用仓库自带的锐扬演示资料和固定时钟,只验证链路与真实计算口径,不代替客户项目数据验收。现场验收应在客户自己的项目、订单版本和设备日历上按上面十条话术逐条执行。
|
||||||
|
|
@ -886,6 +886,44 @@ _PRIMARY_TOOL_PARAM_SCHEMAS: dict[str, dict[str, Any]] = {
|
||||||
examples=[{}, {"path": "D:/data/orders.xlsx"}],
|
examples=[{}, {"path": "D:/data/orders.xlsx"}],
|
||||||
),
|
),
|
||||||
"readiness.query": _EMPTY_PARAMS_SCHEMA,
|
"readiness.query": _EMPTY_PARAMS_SCHEMA,
|
||||||
|
"rush.evaluate": _obj_schema(
|
||||||
|
{"orderNo": {"type": "string"},
|
||||||
|
"customerName": {"type": "string"},
|
||||||
|
"customerLevel": {"type": "string"},
|
||||||
|
"productId": {"type": "integer", "minimum": 1},
|
||||||
|
"productCode": {"type": "string"},
|
||||||
|
"productName": {"type": "string"},
|
||||||
|
"quantity": {"type": "integer", "minimum": 1},
|
||||||
|
"deliveryDate": {"type": "string"},
|
||||||
|
"priority": {"type": "integer", "minimum": 1, "maximum": 9},
|
||||||
|
"strategy": {"type": "string",
|
||||||
|
"enum": ["DELIVERY_FIRST", "CAPACITY_BALANCE", "COMPREHENSIVE"]}},
|
||||||
|
examples=[
|
||||||
|
{"customerName": "湖南工业控制有限公司", "productCode": "A0050101-00280",
|
||||||
|
"quantity": 50, "deliveryDate": "2026-10-14", "strategy": "DELIVERY_FIRST"},
|
||||||
|
{"orderNo": "SO-001", "quantity": 50, "deliveryDate": "2026-10-14"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"rush.apply": _EMPTY_PARAMS_SCHEMA,
|
||||||
|
"constraint.rule.query": _EMPTY_PARAMS_SCHEMA,
|
||||||
|
"constraint.rule.save": _obj_schema(
|
||||||
|
{"op": {"type": "string", "enum": ["add", "enable", "disable", "delete"]},
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"category": {"type": "string",
|
||||||
|
"enum": ["process", "capacity", "calendar", "equipment", "material",
|
||||||
|
"order", "personnel", "tooling", "other"]},
|
||||||
|
"scope": {"type": "string", "enum": ["project", "tenant"]},
|
||||||
|
"kind": {"type": "string", "enum": ["hard", "soft"]},
|
||||||
|
"boundConstraintId": {"type": "string"},
|
||||||
|
"ruleId": {"type": "string"},
|
||||||
|
"sourceText": {"type": "string"}},
|
||||||
|
required=["op"],
|
||||||
|
examples=[
|
||||||
|
{"op": "add", "name": "夜班不安排换型", "category": "process",
|
||||||
|
"scope": "project", "kind": "soft"},
|
||||||
|
{"op": "disable", "ruleId": "R-001"},
|
||||||
|
],
|
||||||
|
),
|
||||||
"folder.schedule": _obj_schema(
|
"folder.schedule": _obj_schema(
|
||||||
{"sortMode": {"type": "string"},
|
{"sortMode": {"type": "string"},
|
||||||
"window": {"type": "string", "enum": ["short", "mid", "long", "full"]},
|
"window": {"type": "string", "enum": ["short", "mid", "long", "full"]},
|
||||||
|
|
@ -1125,6 +1163,10 @@ _PRIMARY_PLANNING_INSPECTION_TOOLS = frozenset({
|
||||||
"flex.schedule",
|
"flex.schedule",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# These tools return an engine-derived business conclusion. Pi may explain the
|
||||||
|
# result, but must not replace it with a guess about feasibility or impact.
|
||||||
|
_PRIMARY_ENGINE_AUTHORITATIVE_TOOLS = frozenset({"rush.evaluate"})
|
||||||
|
|
||||||
|
|
||||||
def _planning_reply_is_unready(reply) -> bool:
|
def _planning_reply_is_unready(reply) -> bool:
|
||||||
for block in reply.blocks:
|
for block in reply.blocks:
|
||||||
|
|
@ -1160,6 +1202,20 @@ def _finalize_primary_business_reply(
|
||||||
tool_results: list[tuple[str, Any]],
|
tool_results: list[tuple[str, Any]],
|
||||||
):
|
):
|
||||||
"""Keep unready planning conclusions authoritative over Pi-generated prose."""
|
"""Keep unready planning conclusions authoritative over Pi-generated prose."""
|
||||||
|
engine_results = [
|
||||||
|
(name, reply) for name, reply in tool_results
|
||||||
|
if name in _PRIMARY_ENGINE_AUTHORITATIVE_TOOLS
|
||||||
|
]
|
||||||
|
if engine_results:
|
||||||
|
authoritative = engine_results[-1][1]
|
||||||
|
other_tool_replies = [
|
||||||
|
reply for name, reply in tool_results
|
||||||
|
if reply is not authoritative
|
||||||
|
]
|
||||||
|
return _productize_primary_reply(
|
||||||
|
_merge_primary_tool_replies(authoritative, other_tool_replies)
|
||||||
|
)
|
||||||
|
|
||||||
planning_results = [
|
planning_results = [
|
||||||
(name, reply) for name, reply in tool_results
|
(name, reply) for name, reply in tool_results
|
||||||
if name in _PRIMARY_PLANNING_INSPECTION_TOOLS
|
if name in _PRIMARY_PLANNING_INSPECTION_TOOLS
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,8 @@ _POWER_MAP: dict[str, str] = {
|
||||||
"param.observation.record": "P1", # 矩阵 87:线上观测回调:观测落盘 + 连续劣化自动回滚
|
"param.observation.record": "P1", # 矩阵 87:线上观测回调:观测落盘 + 连续劣化自动回滚
|
||||||
"constraint.profile.query": "P0", # SC-04:约束剖面只读
|
"constraint.profile.query": "P0", # SC-04:约束剖面只读
|
||||||
"constraint.profile.save": "P2", # SC-04:约束剖面保存
|
"constraint.profile.save": "P2", # SC-04:约束剖面保存
|
||||||
|
"constraint.rule.query": "P0", # SC-04:约束规则库只读
|
||||||
|
"constraint.rule.save": "P2", # SC-04:约束规则库扩充(新增/启停/删除)
|
||||||
"governance.rule.create": "P2", # 治理规则创建:确认后写规则注册表
|
"governance.rule.create": "P2", # 治理规则创建:确认后写规则注册表
|
||||||
"governance.rule.update": "P2", # 治理规则更新:确认后替换规则定义
|
"governance.rule.update": "P2", # 治理规则更新:确认后替换规则定义
|
||||||
"governance.rule.delete": "P2", # 治理规则删除:确认后移除规则定义
|
"governance.rule.delete": "P2", # 治理规则删除:确认后移除规则定义
|
||||||
|
|
@ -391,6 +393,8 @@ _POLICY_DESC: dict[str, str] = {
|
||||||
"param.observation.record": "线上观测回调:生产 KPI 采样落盘,连续劣化自动回滚实验",
|
"param.observation.record": "线上观测回调:生产 KPI 采样落盘,连续劣化自动回滚实验",
|
||||||
"constraint.profile.query": "约束配置查询:只读硬/软约束剖面",
|
"constraint.profile.query": "约束配置查询:只读硬/软约束剖面",
|
||||||
"constraint.profile.save": "约束配置保存:启停/硬软切换(只影响新版本与发布门禁)",
|
"constraint.profile.save": "约束配置保存:启停/硬软切换(只影响新版本与发布门禁)",
|
||||||
|
"constraint.rule.query": "约束规则库查询:默认通用规则与计划员扩充规则概览",
|
||||||
|
"constraint.rule.save": "约束规则库扩充:把计划员一句话登记为可复用规则(新增/启用/停用/删除,需确认)",
|
||||||
"governance.rule.create": "创建治理规则:确认后写规则注册表",
|
"governance.rule.create": "创建治理规则:确认后写规则注册表",
|
||||||
"governance.rule.update": "更新治理规则:确认后替换规则定义",
|
"governance.rule.update": "更新治理规则:确认后替换规则定义",
|
||||||
"governance.rule.delete": "删除治理规则:确认后移除规则定义",
|
"governance.rule.delete": "删除治理规则:确认后移除规则定义",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,352 @@
|
||||||
|
# ============================================================
|
||||||
|
# 计划员约束规则库(moduleId: domain-constraint-rules, 可重生 ✅)
|
||||||
|
# 默认通用规则 = constraints.py 的 C1..C13 内建目录(只读基线,本模块不改它)
|
||||||
|
# 本模块只管理「计划员用自然语言提出、经 P2 确认后扩充的规则」:
|
||||||
|
# * 按类别归档(沿用 GROUP_LABELS 的 8 类 + 其他)
|
||||||
|
# * 按范围归档(仅本项目 / 全厂)
|
||||||
|
# * 没有接入排产引擎的规则如实标注,不假装生效
|
||||||
|
# ============================================================
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from server.aps_domain.constraints import (
|
||||||
|
DEFAULT_CONSTRAINT_DEFS,
|
||||||
|
GROUP_LABELS,
|
||||||
|
get_constraint,
|
||||||
|
get_constraint_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
World = dict[str, Any]
|
||||||
|
|
||||||
|
RULE_CATEGORIES: dict[str, str] = {**GROUP_LABELS, "other": "其他"}
|
||||||
|
RULE_SCOPES: dict[str, str] = {"project": "仅本项目", "tenant": "全厂"}
|
||||||
|
RULE_KINDS: dict[str, str] = {"hard": "必须满足", "soft": "尽量满足"}
|
||||||
|
RULE_OPS: tuple[str, ...] = ("add", "enable", "disable", "delete")
|
||||||
|
|
||||||
|
_ID_RE = re.compile(r"^R-(\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_tenant(tenant_uuid: str | None) -> str:
|
||||||
|
raw = (tenant_uuid or "platform").strip() or "platform"
|
||||||
|
return re.sub(r"[^\w\-]+", "_", raw)[:64] or "platform"
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_rules_path(tenant_uuid: str | None) -> Path:
|
||||||
|
"""全厂规则落盘位置:与 world.json 同一数据根(尊重 APS_HOME / APS_DATA_DIR)。"""
|
||||||
|
from server.aps_home import data_dir
|
||||||
|
|
||||||
|
tenant = _safe_tenant(tenant_uuid)
|
||||||
|
if tenant == "platform":
|
||||||
|
return data_dir() / "constraint-rules.json"
|
||||||
|
return data_dir() / "tenants" / tenant / "constraint-rules.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_tenant_rules(tenant_uuid: str | None) -> list[dict[str, Any]]:
|
||||||
|
"""读取全厂规则(文件不存在或损坏时返回空列表,不抛错)。"""
|
||||||
|
path = tenant_rules_path(tenant_uuid)
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
rules = raw.get("rules") if isinstance(raw, dict) else raw
|
||||||
|
if not isinstance(rules, list):
|
||||||
|
return []
|
||||||
|
return [r for r in rules if isinstance(r, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def save_tenant_rules(tenant_uuid: str | None, rules: list[dict[str, Any]]) -> str:
|
||||||
|
"""原子写入全厂规则文件(tmp + os.replace,与 WorldStore 同一写盘策略)。"""
|
||||||
|
path = tenant_rules_path(tenant_uuid)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
body = json.dumps(
|
||||||
|
{"version": 1, "scope": "tenant", "rules": rules},
|
||||||
|
ensure_ascii=False, indent=2,
|
||||||
|
)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".constraint-rules-", suffix=".tmp")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(body + "\n")
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def project_rules(world: World) -> list[dict[str, Any]]:
|
||||||
|
raw = world.get("constraintRules")
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
return [r for r in raw if isinstance(r, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def all_rules(world: World, tenant_uuid: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""全厂规则 + 本项目规则,统一补 scope 字段(缺省按所属容器判定)。"""
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for r in load_tenant_rules(tenant_uuid):
|
||||||
|
out.append({**r, "scope": r.get("scope") or "tenant"})
|
||||||
|
for r in project_rules(world):
|
||||||
|
out.append({**r, "scope": r.get("scope") or "project"})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def next_rule_id(rules: list[dict[str, Any]]) -> str:
|
||||||
|
top = 0
|
||||||
|
for r in rules:
|
||||||
|
m = _ID_RE.match(str(r.get("id") or ""))
|
||||||
|
if m:
|
||||||
|
top = max(top, int(m.group(1)))
|
||||||
|
return f"R-{top + 1:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def rule_effect(world: World, rule: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""如实说明这条规则当前靠谁执行;没有接入引擎就直说。"""
|
||||||
|
bound = str(rule.get("boundConstraintId") or "").strip()
|
||||||
|
if not bound:
|
||||||
|
return {"bound": False, "enforced": False, "text": "已记录,暂未接入排产引擎"}
|
||||||
|
c = get_constraint(world, bound)
|
||||||
|
if not c:
|
||||||
|
return {"bound": True, "enforced": False,
|
||||||
|
"text": f"绑定的内置规则 {bound} 不在当前约束目录,暂未执行"}
|
||||||
|
state = "已启用" if c.get("enabled", True) else "已关闭"
|
||||||
|
kind_label = "硬约束" if c.get("kind") == "hard" else "软约束"
|
||||||
|
enforced = bool(c.get("enabled", True))
|
||||||
|
if not enforced:
|
||||||
|
return {"bound": True, "enforced": False,
|
||||||
|
"text": f"内置规则《{c['name']}》当前已关闭,本条暂不生效"}
|
||||||
|
return {"bound": True, "enforced": True,
|
||||||
|
"text": f"由内置规则《{c['name']}》执行({state}/{kind_label})"}
|
||||||
|
|
||||||
|
|
||||||
|
def _default_ids() -> set[str]:
|
||||||
|
return {str(d["id"]) for d in DEFAULT_CONSTRAINT_DEFS}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_rule_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""校验规则库载荷;只接受计划员语言字段,不接受 JSON 片段。"""
|
||||||
|
raw = payload if isinstance(payload, dict) else {}
|
||||||
|
op = str(raw.get("op") or "add").strip().lower() or "add"
|
||||||
|
if op not in RULE_OPS:
|
||||||
|
raise ValueError("规则操作只能是 新增/启用/停用/删除")
|
||||||
|
if op != "add":
|
||||||
|
rule_id = str(raw.get("ruleId") or raw.get("id") or "").strip()
|
||||||
|
if not rule_id:
|
||||||
|
raise ValueError("请指定要操作的规则")
|
||||||
|
return {"op": op, "ruleId": rule_id}
|
||||||
|
name = str(raw.get("name") or raw.get("text") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("规则内容不能为空,请用一句话描述")
|
||||||
|
if len(name) > 40:
|
||||||
|
raise ValueError("规则内容请控制在 40 字以内,说清「什么时候、什么不能排」即可")
|
||||||
|
category = str(raw.get("category") or "other").strip().lower() or "other"
|
||||||
|
if category not in RULE_CATEGORIES:
|
||||||
|
raise ValueError(f"类别只能是:{'、'.join(RULE_CATEGORIES.values())}")
|
||||||
|
scope = str(raw.get("scope") or "project").strip().lower() or "project"
|
||||||
|
if scope not in RULE_SCOPES:
|
||||||
|
raise ValueError("适用范围只能是:仅本项目 / 全厂")
|
||||||
|
kind = str(raw.get("kind") or "soft").strip().lower() or "soft"
|
||||||
|
if kind not in RULE_KINDS:
|
||||||
|
raise ValueError("强度只能是:必须满足 / 尽量满足")
|
||||||
|
bound = str(raw.get("boundConstraintId") or "").strip()
|
||||||
|
if bound and bound not in _default_ids():
|
||||||
|
raise ValueError(f"绑定的内置规则 {bound} 不存在,请留空或改用现有内置规则")
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"op": "add", "name": name, "category": category, "scope": scope, "kind": kind,
|
||||||
|
}
|
||||||
|
if bound:
|
||||||
|
out["boundConstraintId"] = bound
|
||||||
|
source = str(raw.get("sourceText") or "").strip()
|
||||||
|
if source:
|
||||||
|
out["sourceText"] = source[:200]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _bound_apply(world: World, norm: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]:
|
||||||
|
"""新增规则绑定内置可配项时,把强度真正落到排产引擎;返回 (补丁, 说明)。"""
|
||||||
|
bound = str(norm.get("boundConstraintId") or "")
|
||||||
|
if not bound:
|
||||||
|
return None, None
|
||||||
|
c = get_constraint(world, bound)
|
||||||
|
if not c or not c.get("kindConfigurable"):
|
||||||
|
return None, None
|
||||||
|
target = "hard" if norm.get("kind") == "hard" else "soft"
|
||||||
|
if c.get("kind") == target:
|
||||||
|
return None, None
|
||||||
|
label = "硬约束(违反阻止发布)" if target == "hard" else "软约束(只提示风险)"
|
||||||
|
return {"constraints": {bound: {"kind": target}}}, f"同时把内置规则《{c['name']}》调整为{label}"
|
||||||
|
|
||||||
|
|
||||||
|
def confirmation_for_rule_save(world: World, payload: dict[str, Any],
|
||||||
|
tenant_uuid: str | None = None) -> tuple[str, list[str]]:
|
||||||
|
"""P2 确认卡文案:计划员看得懂,写清范围、强度和是否真的接入引擎。"""
|
||||||
|
norm = normalize_rule_payload(payload)
|
||||||
|
if norm["op"] == "add":
|
||||||
|
title = f"登记约束规则:{norm['name']}"
|
||||||
|
lines = [
|
||||||
|
f"类别:{RULE_CATEGORIES[norm['category']]};适用范围:{RULE_SCOPES[norm['scope']]}",
|
||||||
|
f"强度:{RULE_KINDS[norm['kind']]}",
|
||||||
|
]
|
||||||
|
patch, note = _bound_apply(world, norm)
|
||||||
|
if patch:
|
||||||
|
lines.append(f"接入排产:{note}")
|
||||||
|
elif norm.get("boundConstraintId"):
|
||||||
|
c = get_constraint(world, str(norm["boundConstraintId"]))
|
||||||
|
lines.append(f"接入排产:由内置规则《{(c or {}).get('name') or norm['boundConstraintId']}》执行")
|
||||||
|
else:
|
||||||
|
lines.append("接入排产:暂未接入排产引擎,先进入规则库备查")
|
||||||
|
lines.append("确认后写入规则库并记审计;不影响历史版本")
|
||||||
|
return title, lines
|
||||||
|
rules = {str(r.get("id")): r for r in all_rules(world, tenant_uuid)}
|
||||||
|
rule = rules.get(norm["ruleId"])
|
||||||
|
if not rule:
|
||||||
|
raise ValueError(f"规则库中没有 {norm['ruleId']} 这条规则")
|
||||||
|
name = str(rule.get("name") or norm["ruleId"])
|
||||||
|
effect = rule_effect(world, rule)["text"]
|
||||||
|
if norm["op"] == "delete":
|
||||||
|
return f"删除约束规则:{name}", [
|
||||||
|
f"原规则:{name}({RULE_CATEGORIES.get(str(rule.get('category')), '其他')})",
|
||||||
|
f"当前执行:{effect}",
|
||||||
|
"删除后不再出现在规则库;不影响历史版本与已发布计划",
|
||||||
|
]
|
||||||
|
label = "启用" if norm["op"] == "enable" else "停用"
|
||||||
|
lines = [
|
||||||
|
f"原规则:{name}({RULE_CATEGORIES.get(str(rule.get('category')), '其他')}/"
|
||||||
|
f"{RULE_SCOPES.get(str(rule.get('scope')), '仅本项目')})",
|
||||||
|
f"当前执行:{effect}",
|
||||||
|
"该操作只调整规则库里的状态,不改动内置约束的开关",
|
||||||
|
]
|
||||||
|
return f"{label}约束规则:{name}", lines
|
||||||
|
|
||||||
|
|
||||||
|
def _locate(world: World, tenant_uuid: str | None,
|
||||||
|
rule_id: str) -> tuple[dict[str, Any] | None, str | None]:
|
||||||
|
for r in load_tenant_rules(tenant_uuid):
|
||||||
|
if str(r.get("id")) == rule_id:
|
||||||
|
return r, "tenant"
|
||||||
|
for r in project_rules(world):
|
||||||
|
if str(r.get("id")) == rule_id:
|
||||||
|
return r, "project"
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_container(world: World, tenant_uuid: str | None, where: str,
|
||||||
|
rules: list[dict[str, Any]]) -> None:
|
||||||
|
if where == "tenant":
|
||||||
|
save_tenant_rules(tenant_uuid, rules)
|
||||||
|
else:
|
||||||
|
world["constraintRules"] = rules
|
||||||
|
|
||||||
|
|
||||||
|
def apply_rule_save(world: World, payload: dict[str, Any],
|
||||||
|
tenant_uuid: str | None = None) -> dict[str, Any]:
|
||||||
|
"""落库:新增/启用/停用/删除计划员扩充规则(调用方负责审计与 save)。"""
|
||||||
|
norm = normalize_rule_payload(payload)
|
||||||
|
if norm["op"] == "add":
|
||||||
|
existing = all_rules(world, tenant_uuid)
|
||||||
|
rule_id = next_rule_id(existing)
|
||||||
|
rec: dict[str, Any] = {
|
||||||
|
"id": rule_id,
|
||||||
|
"name": norm["name"],
|
||||||
|
"category": norm["category"],
|
||||||
|
"scope": norm["scope"],
|
||||||
|
"kind": norm["kind"],
|
||||||
|
"enabled": True,
|
||||||
|
"createdAt": _now(),
|
||||||
|
"updatedAt": _now(),
|
||||||
|
}
|
||||||
|
if norm.get("boundConstraintId"):
|
||||||
|
rec["boundConstraintId"] = norm["boundConstraintId"]
|
||||||
|
if norm.get("sourceText"):
|
||||||
|
rec["source"] = {"type": "PLANNER_CHAT", "text": norm["sourceText"]}
|
||||||
|
patch, note = _bound_apply(world, norm)
|
||||||
|
if patch:
|
||||||
|
from server.aps_domain.constraints import apply_profile_save
|
||||||
|
|
||||||
|
apply_profile_save(world, patch)
|
||||||
|
if norm["scope"] == "tenant":
|
||||||
|
save_tenant_rules(tenant_uuid, load_tenant_rules(tenant_uuid) + [rec])
|
||||||
|
else:
|
||||||
|
world["constraintRules"] = project_rules(world) + [rec]
|
||||||
|
effect = rule_effect(world, rec)["text"]
|
||||||
|
message = (f"约束规则已登记 ✅ {rule_id} {rec['name']}"
|
||||||
|
f"({RULE_CATEGORIES[norm['category']]}/{RULE_SCOPES[norm['scope']]})。"
|
||||||
|
f"\n执行情况:{effect}。" + (f"\n{note}。" if note else ""))
|
||||||
|
return {"ruleId": rule_id, "op": "add", "scope": norm["scope"],
|
||||||
|
"rule": rec, "message": message}
|
||||||
|
rule_id = norm["ruleId"]
|
||||||
|
rule, where = _locate(world, tenant_uuid, rule_id)
|
||||||
|
if not rule or not where:
|
||||||
|
raise ValueError(f"规则库中没有 {rule_id} 这条规则")
|
||||||
|
container = load_tenant_rules(tenant_uuid) if where == "tenant" else project_rules(world)
|
||||||
|
name = str(rule.get("name") or rule_id)
|
||||||
|
if norm["op"] == "delete":
|
||||||
|
kept = [r for r in container if str(r.get("id")) != rule_id]
|
||||||
|
_write_container(world, tenant_uuid, where, kept)
|
||||||
|
return {"ruleId": rule_id, "op": "delete", "scope": where,
|
||||||
|
"message": f"约束规则已删除 ✅ {rule_id} {name}。"}
|
||||||
|
enabled = norm["op"] == "enable"
|
||||||
|
updated = []
|
||||||
|
for r in container:
|
||||||
|
if str(r.get("id")) == rule_id:
|
||||||
|
updated.append({**r, "enabled": enabled, "updatedAt": _now()})
|
||||||
|
else:
|
||||||
|
updated.append(r)
|
||||||
|
_write_container(world, tenant_uuid, where, updated)
|
||||||
|
label = "启用" if enabled else "停用"
|
||||||
|
return {"ruleId": rule_id, "op": norm["op"], "scope": where,
|
||||||
|
"message": f"约束规则已{label} ✅ {rule_id} {name}。"}
|
||||||
|
|
||||||
|
|
||||||
|
def constraint_library(world: World, tenant_uuid: str | None = None) -> dict[str, Any]:
|
||||||
|
"""设置页数据:默认通用规则概览 + 计划员扩充规则(按类别分组)。"""
|
||||||
|
builtin = get_constraint_profile(world)["constraints"]
|
||||||
|
custom: list[dict[str, Any]] = []
|
||||||
|
for r in all_rules(world, tenant_uuid):
|
||||||
|
effect = rule_effect(world, r)
|
||||||
|
category = str(r.get("category") or "other")
|
||||||
|
custom.append({
|
||||||
|
**r,
|
||||||
|
"category": category,
|
||||||
|
"categoryLabel": RULE_CATEGORIES.get(category, "其他"),
|
||||||
|
"scopeLabel": RULE_SCOPES.get(str(r.get("scope")), "仅本项目"),
|
||||||
|
"kindLabel": RULE_KINDS.get(str(r.get("kind")), "尽量满足"),
|
||||||
|
"effect": effect,
|
||||||
|
})
|
||||||
|
groups = []
|
||||||
|
for gid, label in RULE_CATEGORIES.items():
|
||||||
|
items = [r for r in custom if r["category"] == gid]
|
||||||
|
if items:
|
||||||
|
groups.append({"id": gid, "label": label, "items": items})
|
||||||
|
return {
|
||||||
|
"builtin": {
|
||||||
|
"total": len(builtin),
|
||||||
|
"enabled": sum(1 for c in builtin if c.get("enabled", True)),
|
||||||
|
"configurable": sum(1 for c in builtin if c.get("configurable")),
|
||||||
|
},
|
||||||
|
"custom": custom,
|
||||||
|
"groups": groups,
|
||||||
|
"categories": [{"id": k, "label": v} for k, v in RULE_CATEGORIES.items()],
|
||||||
|
"scopes": [{"id": k, "label": v} for k, v in RULE_SCOPES.items()],
|
||||||
|
"summary": {
|
||||||
|
"builtinTotal": len(builtin),
|
||||||
|
"customTotal": len(custom),
|
||||||
|
"engineBound": sum(1 for r in custom if r["effect"]["enforced"]),
|
||||||
|
"recordOnly": sum(1 for r in custom if not r["effect"]["enforced"]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -522,7 +522,7 @@ def apply_lns_local(world: World, next_id, params: dict[str, Any]) -> dict[str,
|
||||||
if k not in ("id", "versionNo", "versionName", "createdAt", "createdBy",
|
if k not in ("id", "versionNo", "versionName", "createdAt", "createdBy",
|
||||||
"publishedAt", "parentVersionId")},
|
"publishedAt", "parentVersionId")},
|
||||||
"id": new_vid, "versionNo": ver_no,
|
"id": new_vid, "versionNo": ver_no,
|
||||||
"versionName": f"LNS 插单局部修复 · {rush.get('orderNo')}",
|
"versionName": f"急单局部调整 · {rush.get('orderNo')}",
|
||||||
"triggerType": "LNS_RUSH",
|
"triggerType": "LNS_RUSH",
|
||||||
"status": "DRAFT", "parentVersionId": old_vid,
|
"status": "DRAFT", "parentVersionId": old_vid,
|
||||||
"orderCount": len(world.get("salesOrders", [])),
|
"orderCount": len(world.get("salesOrders", [])),
|
||||||
|
|
@ -532,8 +532,8 @@ def apply_lns_local(world: World, next_id, params: dict[str, Any]) -> dict[str,
|
||||||
"totalTardiness": total_tardiness,
|
"totalTardiness": total_tardiness,
|
||||||
"conflictCount": overlap,
|
"conflictCount": overlap,
|
||||||
"createdBy": "agent", "createdAt": fmt_dt(now), "publishedAt": None,
|
"createdBy": "agent", "createdAt": fmt_dt(now), "publishedAt": None,
|
||||||
"note": (f"LNS 局部修复:移动 {applied_moves} 单 · 位移 {lns.get('disturbance', {}).get('shiftedHours', 0)}h · "
|
"note": (f"急单局部调整:{applied_moves} 张订单顺延,合计 {lns.get('disturbance', {}).get('shiftedHours', 0)} 小时;"
|
||||||
f"急单 {rush.get('orderNo')}(窗口 {win.get('start')}~{win.get('end')})"),
|
f"急单 {rush.get('orderNo')},可调整范围 {win.get('start')} 至 {win.get('end')}。"),
|
||||||
"lns": {
|
"lns": {
|
||||||
"lnsId": lns.get("lnsId"),
|
"lnsId": lns.get("lnsId"),
|
||||||
"status": lns.get("status"),
|
"status": lns.get("status"),
|
||||||
|
|
@ -556,52 +556,82 @@ def apply_lns_local(world: World, next_id, params: dict[str, Any]) -> dict[str,
|
||||||
|
|
||||||
|
|
||||||
def format_lns_text(lns: dict[str, Any]) -> str:
|
def format_lns_text(lns: dict[str, Any]) -> str:
|
||||||
"""LNS 评估结果中文摘要(对话侧)。"""
|
"""插单安排评估的计划员摘要(对话侧)。"""
|
||||||
rush = lns.get("rushOrder") or {}
|
rush = lns.get("rushOrder") or {}
|
||||||
win = lns.get("window") or {}
|
win = lns.get("window") or {}
|
||||||
|
line_label = win.get("lineName") or win.get("lineId") or "可安排产线"
|
||||||
lines = [
|
lines = [
|
||||||
f"LNS 插单局部修复(沙盒,未改主干)· 策略 {lns.get('strategy')}",
|
"插单安排评估完成。本次只做试算,现有计划没有改动。",
|
||||||
f"急单:{rush.get('customerName') or rush.get('orderNo')} · {rush.get('productName')} × {rush.get('quantity')} · 交期 {rush.get('deliveryDate')}",
|
f"急单:{rush.get('customerName') or rush.get('orderNo')} · {rush.get('productName')} × {rush.get('quantity')} · 交期 {rush.get('deliveryDate')}",
|
||||||
f"时间窗:{win.get('start')} ~ {win.get('end')}(产线 {win.get('lineName') or win.get('lineId')} · 上限 {win.get('maxAffectedOrders')} 单 · 容忍 {win.get('disturbanceTolerance'):.0%})"
|
f"可调整范围:{win.get('start')} 至 {win.get('end')},优先安排到 {line_label}。"
|
||||||
if win else "时间窗:默认",
|
if win else "可调整范围:按当前生产安排。",
|
||||||
]
|
]
|
||||||
if lns.get("status") == "LOCAL":
|
if lns.get("status") == "LOCAL":
|
||||||
d = lns.get("disturbance") or {}
|
d = lns.get("disturbance") or {}
|
||||||
plan = lns.get("plan") or {}
|
plan = lns.get("plan") or {}
|
||||||
|
before_due = "比交期提前" if (d.get("rushEndGapHours") or 0) <= 0 else "比交期晚"
|
||||||
lines += [
|
lines += [
|
||||||
(f"方案:{plan.get('rushStart')} → {plan.get('rushEnd')}({'提前' if d.get('rushEndGapHours') is not None and d['rushEndGapHours'] <= 0 else '超期'} "
|
f"可安排时间:{plan.get('rushStart')} 至 {plan.get('rushEnd')},{before_due} "
|
||||||
f"{abs(d.get('rushEndGapHours') or 0):.1f}h)"),
|
f"{abs(d.get('rushEndGapHours') or 0):.1f} 小时。",
|
||||||
f"扰动:移动 {d.get('movedOrderCount')} 单 · 位移 {d.get('shiftedHours')}h · 延迟变化 {d.get('tardinessDelta'):+.1f}h · 窗口外 {d.get('windowOuterMovedCount')} 单",
|
f"调整后:{d.get('movedOrderCount')} 张订单需要顺延,合计顺延 {d.get('shiftedHours')} 小时,"
|
||||||
|
f"总延期变化 {d.get('tardinessDelta'):+.1f} 小时;调整范围外的订单保持不变。",
|
||||||
]
|
]
|
||||||
if lns.get("chainRisk", {}).get("flag"):
|
if lns.get("chainRisk", {}).get("flag"):
|
||||||
lines.append(f"提示:{lns['chainRisk']['reason']}")
|
lines.append(f"提示:{lns['chainRisk']['reason']}")
|
||||||
lines.append("确认后说「采用插单」(LNS 局部方案入 DRAFT 试排草稿,P2 写前自动建档)。")
|
lines.append("确认后,我会生成一版待发布的调整方案;现有计划不会直接改变。")
|
||||||
else:
|
else:
|
||||||
reasons = lns.get("escalateReasons") or []
|
reasons = lns.get("escalateReasons") or []
|
||||||
lines.append("升级全量重排 ⚠️ " + ";".join(reasons))
|
lines.append(
|
||||||
lines.append("确认后说「采用插单」将执行全量重排并生成 DRAFT 版本(P2 人工确认)。")
|
"这张急单不能只调整少数订单,需要重新编排整版计划。"
|
||||||
|
+ ("原因:" + ";".join(_planner_lns_reasons(reasons)) if reasons else "")
|
||||||
|
)
|
||||||
|
lines.append("确认后,我会生成一版待发布的整版调整方案;现有计划不会直接改变。")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _planner_lns_reasons(reasons: list[str]) -> list[str]:
|
||||||
|
"""把求解器阈值原因翻成计划员能判断的影响描述。"""
|
||||||
|
out: list[str] = []
|
||||||
|
for reason in reasons:
|
||||||
|
text = str(reason or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if text.startswith("受影响订单"):
|
||||||
|
out.append("需要顺延的订单已经超过可接受范围")
|
||||||
|
elif text.startswith("扰动率"):
|
||||||
|
out.append("局部调整会打乱太多现有订单")
|
||||||
|
elif "无可用产线" in text or "无法物化" in text:
|
||||||
|
out.append("按当前设备、工艺和日历,找不到能安排这张急单的生产线或时间段")
|
||||||
|
elif "超过窗口宽度" in text:
|
||||||
|
out.append("这张急单需要的生产时间超过可局部调整的时间范围")
|
||||||
|
elif "无可行插入点" in text:
|
||||||
|
out.append("在不对其他订单造成过大影响的前提下,找不到合适的生产时段")
|
||||||
|
else:
|
||||||
|
out.append(text)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def lns_to_block(lns: dict[str, Any]) -> UIBlock:
|
def lns_to_block(lns: dict[str, Any]) -> UIBlock:
|
||||||
"""对话侧 LNS 结果块(复用 rush 卡片样式)。"""
|
"""对话侧插单安排结果块(复用 rush 卡片样式)。"""
|
||||||
rush = lns.get("rushOrder") or {}
|
rush = lns.get("rushOrder") or {}
|
||||||
d = lns.get("disturbance") or {}
|
d = lns.get("disturbance") or {}
|
||||||
status = lns.get("status")
|
status = lns.get("status")
|
||||||
if status == "LOCAL":
|
if status == "LOCAL":
|
||||||
plan = lns.get("plan") or {}
|
plan = lns.get("plan") or {}
|
||||||
title = "LNS 插单局部修复(窗口内最小扰动)"
|
title = "插单安排评估"
|
||||||
text = (
|
text = (
|
||||||
f"急单:{rush.get('orderNo') or '(新建)'} · {rush.get('productName')} × {rush.get('quantity')}\n"
|
f"急单:{rush.get('orderNo') or '(新建)'} · {rush.get('productName')} × {rush.get('quantity')}\n"
|
||||||
f"落点:{plan.get('rushStart')} → {plan.get('rushEnd')}({plan.get('lineName') or plan.get('lineId')})\n"
|
f"落点:{plan.get('rushStart')} → {plan.get('rushEnd')}({plan.get('lineName') or plan.get('lineId')})\n"
|
||||||
f"扰动:移动 {d.get('movedOrderCount')} 单 · 位移 {d.get('shiftedHours')}h · 延迟 {d.get('tardinessDelta'):+.1f}h\n"
|
f"调整后:{d.get('movedOrderCount')} 张订单需要顺延,合计顺延 {d.get('shiftedHours')} 小时,"
|
||||||
f"窗口外受影响:{d.get('windowOuterMovedCount')} 单(冻结 ✅)"
|
f"总延期变化 {d.get('tardinessDelta'):+.1f} 小时\n"
|
||||||
|
"调整范围外的订单保持不变;确认后只生成待发布的调整方案。"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
title = "LNS 升级全量重排"
|
title = "插单需要整版调整"
|
||||||
|
reasons = _planner_lns_reasons(lns.get("escalateReasons") or [])
|
||||||
text = (f"急单:{rush.get('orderNo') or '(新建)'} · {rush.get('productName')} × {rush.get('quantity')}\n"
|
text = (f"急单:{rush.get('orderNo') or '(新建)'} · {rush.get('productName')} × {rush.get('quantity')}\n"
|
||||||
f"升级原因:{';'.join(lns.get('escalateReasons') or ['影响半径超过阈值'])}\n"
|
f"需要整版调整的原因:{';'.join(reasons or ['按当前生产安排无法只调整少数订单'])}\n"
|
||||||
"确认后将执行全量重排并生成 DRAFT 版本(P2)。")
|
"确认后只生成一版待发布的整版调整方案,现有计划不会直接改变。")
|
||||||
return UIBlock(
|
return UIBlock(
|
||||||
blockId=f"lns-{lns.get('lnsId')}",
|
blockId=f"lns-{lns.get('lnsId')}",
|
||||||
type="text",
|
type="text",
|
||||||
|
|
|
||||||
|
|
@ -261,12 +261,12 @@ def impact_to_block(impact: dict[str, Any]) -> UIBlock:
|
||||||
props={
|
props={
|
||||||
"kind": "rush-impact",
|
"kind": "rush-impact",
|
||||||
"impact": impact,
|
"impact": impact,
|
||||||
"title": "插单影响快评",
|
"title": "紧急插单影响",
|
||||||
"text": (
|
"text": (
|
||||||
f"急单:{rush.get('orderNo') or '(新建)'} · {rush.get('productName')} × {rush.get('quantity')}\n"
|
f"急单:{rush.get('orderNo') or '(新建)'} · {rush.get('productName')} × {rush.get('quantity')}\n"
|
||||||
f"受影响订单 {impact['affectedOrderCount']} 条 · 延迟变化 {impact['delayDelta']:+}h · 冲突变化 {impact['conflictDelta']:+}\n"
|
f"预计影响 {impact['affectedOrderCount']} 张订单;调整后总延期 "
|
||||||
f"策略:{impact['strategy']}(沙盒,未改主干)\n"
|
f"{impact['delayDelta']:+g} 小时,冲突变化 {impact['conflictDelta']:+d} 项。\n"
|
||||||
"确认后请说「采用插单」。"
|
"这只是试算,现有计划没有改动;你确认后才生成一版待发布的调整方案。"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
actions=[],
|
actions=[],
|
||||||
|
|
@ -276,18 +276,27 @@ def impact_to_block(impact: dict[str, Any]) -> UIBlock:
|
||||||
def format_impact_text(impact: dict[str, Any]) -> str:
|
def format_impact_text(impact: dict[str, Any]) -> str:
|
||||||
rush = impact["rushOrder"]
|
rush = impact["rushOrder"]
|
||||||
lines = [
|
lines = [
|
||||||
f"插单快评完成(沙盒,未改主干)· 策略 {_strategy_cn(impact['strategy'])}",
|
"插单影响评估完成。本次只做试算,现有计划没有改动。",
|
||||||
f"急单:{rush.get('customerName')} · {rush.get('productName')} × {rush.get('quantity')} · 交期 {rush.get('deliveryDate')}"
|
f"急单:{rush.get('customerName')} · {rush.get('productName')} × {rush.get('quantity')} · 交期 {rush.get('deliveryDate')}"
|
||||||
+ (f" · 预计完工 {rush.get('promisedEnd')}" if rush.get("promisedEnd") else ""),
|
+ (f" · 预计完工 {rush.get('promisedEnd')}" if rush.get("promisedEnd") else ""),
|
||||||
f"影响:受波及订单 {impact['affectedOrderCount']} 条 · 总延迟 {impact['delayDelta']:+}h · 冲突 {impact['conflictDelta']:+}",
|
f"影响:预计涉及 {impact['affectedOrderCount']} 张订单;总延期变化 "
|
||||||
f"基准 → 插单后:订单 {impact['baseline']['orderCount']}→{impact['after']['orderCount']} · "
|
f"{impact['delayDelta']:+g} 小时,冲突变化 {impact['conflictDelta']:+d} 项。",
|
||||||
f"延迟 {impact['baseline']['totalTardiness']}→{impact['after']['totalTardiness']}h · "
|
f"调整前 → 调整后:订单 {impact['baseline']['orderCount']}→{impact['after']['orderCount']} 张 · "
|
||||||
f"冲突 {impact['baseline']['conflictCount']}→{impact['after']['conflictCount']}",
|
f"总延期 {impact['baseline']['totalTardiness']}→{impact['after']['totalTardiness']} 小时 · "
|
||||||
|
f"冲突 {impact['baseline']['conflictCount']}→{impact['after']['conflictCount']} 项。",
|
||||||
]
|
]
|
||||||
if impact["affectedOrders"]:
|
if impact["affectedOrders"]:
|
||||||
sample = "、".join(a["orderNo"] for a in impact["affectedOrders"][:5])
|
lines.append("可能受影响的订单:")
|
||||||
lines.append(f"延期样例:{sample}" + ("…" if len(impact["affectedOrders"]) > 5 else ""))
|
for item in impact["affectedOrders"][:5]:
|
||||||
lines.append("确认无误后说「采用插单」或点「采用此插单」(P2,写前自动建档)。")
|
lines.append(
|
||||||
|
f"· {item['orderNo']}({item['customerName']}):原计划 {item['beforeEnd']},"
|
||||||
|
f"调整后预计 {item['afterEnd']}"
|
||||||
|
)
|
||||||
|
if len(impact["affectedOrders"]) > 5:
|
||||||
|
lines.append(f"· 其余 {len(impact['affectedOrders']) - 5} 张订单也会有排期变化。")
|
||||||
|
else:
|
||||||
|
lines.append("按当前订单、设备、工艺和日历数据,没有发现其他订单被顺延。")
|
||||||
|
lines.append("如果确认采用,我会先生成一版待发布的调整方案,不会直接改车间执行。")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ def list_compilable_sops(kb_assets: list[dict[str, Any]]) -> list[dict[str, Any]
|
||||||
"effectCount": len(pack.get("effects") or []),
|
"effectCount": len(pack.get("effects") or []),
|
||||||
"compilable": bool(pack.get("effects")),
|
"compilable": bool(pack.get("effects")),
|
||||||
"summary": pack.get("summary") or "",
|
"summary": pack.get("summary") or "",
|
||||||
|
"plannerSummary": pack.get("plannerSummary") or "",
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
@ -109,7 +110,7 @@ def compile_sop_asset(asset: dict[str, Any]) -> dict[str, Any]:
|
||||||
})
|
})
|
||||||
|
|
||||||
if not effects:
|
if not effects:
|
||||||
warnings.append("未识别可落地的约束模板;可扩展编译器或改用约束配置中心手工调整")
|
warnings.append("当前作业标准未识别出可用于排产的约束,可在约束规则库中补充要求。")
|
||||||
|
|
||||||
# C13 标记:有可应用效果时启用
|
# C13 标记:有可应用效果时启用
|
||||||
if any(e.get("op") in ("constraint.set", "changeover.policy") for e in effects):
|
if any(e.get("op") in ("constraint.set", "changeover.policy") for e in effects):
|
||||||
|
|
@ -126,15 +127,37 @@ def compile_sop_asset(asset: dict[str, Any]) -> dict[str, Any]:
|
||||||
})
|
})
|
||||||
|
|
||||||
summary_bits = []
|
summary_bits = []
|
||||||
|
planner_bits: list[str] = []
|
||||||
for e in effects:
|
for e in effects:
|
||||||
if e["op"] == "constraint.set":
|
if e["op"] == "constraint.set":
|
||||||
summary_bits.append(f"{e['id']}→{'开' if e.get('enabled') else '关'}/{e.get('kind', '')}")
|
summary_bits.append(f"{e['id']}→{'开' if e.get('enabled') else '关'}/{e.get('kind', '')}")
|
||||||
|
constraint_id = str(e.get("id") or "")
|
||||||
|
if constraint_id == "C10_changeover":
|
||||||
|
planner_bits.append("将换型顺序纳入排产控制")
|
||||||
|
elif constraint_id == "C6_material_kit":
|
||||||
|
planner_bits.append("物料不齐套时提示风险,不直接阻断排产草案")
|
||||||
|
elif constraint_id != "C13_sop":
|
||||||
|
planner_bits.append("将作业标准中的约束条件纳入排产")
|
||||||
elif e["op"] == "changeover.policy":
|
elif e["op"] == "changeover.policy":
|
||||||
summary_bits.append(f"跨族换型默认{e.get('defaultCrossFamilyMin')}分")
|
summary_bits.append(f"跨族换型默认{e.get('defaultCrossFamilyMin')}分")
|
||||||
|
policy_bits: list[str] = []
|
||||||
|
cross_min = e.get("defaultCrossFamilyMin")
|
||||||
|
standard_min = e.get("standardSetupMin")
|
||||||
|
if cross_min is not None:
|
||||||
|
policy_bits.append(f"跨产品族换型预留 {float(cross_min):.0f} 分钟")
|
||||||
|
if standard_min is not None:
|
||||||
|
policy_bits.append(f"常规换型预留 {float(standard_min):.0f} 分钟")
|
||||||
|
if e.get("noChangeoverAfter"):
|
||||||
|
policy_bits.append(f"{e['noChangeoverAfter']} 后不安排换型")
|
||||||
|
planner_bits.append(";".join(policy_bits) if policy_bits else "按作业标准控制换型时间")
|
||||||
elif e["op"] == "strategy.suggest":
|
elif e["op"] == "strategy.suggest":
|
||||||
summary_bits.append(f"建议策略{e.get('strategy')}")
|
summary_bits.append(f"建议策略{e.get('strategy')}")
|
||||||
|
planner_bits.append("优先连续生产同一产品族,减少换型次数")
|
||||||
elif e["op"] == "process.note":
|
elif e["op"] == "process.note":
|
||||||
summary_bits.append("流程提示")
|
summary_bits.append("流程提示")
|
||||||
|
planner_bits.append("急单插单先进行影响评估,并经主管确认后执行")
|
||||||
|
|
||||||
|
planner_bits = list(dict.fromkeys(planner_bits))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"packId": f"pack-{asset.get('assetId') or uuid.uuid4().hex[:8]}",
|
"packId": f"pack-{asset.get('assetId') or uuid.uuid4().hex[:8]}",
|
||||||
|
|
@ -147,6 +170,8 @@ def compile_sop_asset(asset: dict[str, Any]) -> dict[str, Any]:
|
||||||
},
|
},
|
||||||
"effects": effects,
|
"effects": effects,
|
||||||
"summary": ";".join(summary_bits) if summary_bits else "无可应用效果",
|
"summary": ";".join(summary_bits) if summary_bits else "无可应用效果",
|
||||||
|
"plannerSummary": (";".join(planner_bits) if planner_bits
|
||||||
|
else "当前作业标准暂未识别出可用于排产的约束"),
|
||||||
"warnings": warnings,
|
"warnings": warnings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,17 +218,15 @@ def compile_sop_by_query(kb_assets: list[dict[str, Any]], query: str | None = No
|
||||||
|
|
||||||
|
|
||||||
def confirmation_for_sop_apply(pack: dict[str, Any]) -> tuple[str, list[str]]:
|
def confirmation_for_sop_apply(pack: dict[str, Any]) -> tuple[str, list[str]]:
|
||||||
title = f"应用 SOP 规则包:{pack.get('title') or pack.get('packId')}"
|
title = f"应用作业标准:{pack.get('title') or pack.get('packId')}"
|
||||||
lines = [pack.get("summary") or "应用编译效果"]
|
summary = str(pack.get("plannerSummary") or "当前作业标准暂未识别出可用于排产的约束")
|
||||||
|
lines = [bit.strip() for bit in summary.split(";") if bit.strip()]
|
||||||
src = (pack.get("source") or {}).get("title")
|
src = (pack.get("source") or {}).get("title")
|
||||||
if src:
|
if src:
|
||||||
lines.append(f"出处:{src}")
|
lines.append(f"出处:{src}")
|
||||||
for e in pack.get("effects") or []:
|
lines.append("批准后应用到后续排产,历史版本保持不变。")
|
||||||
if e.get("rationale"):
|
|
||||||
lines.append(f"· {e['op']}: {e['rationale']}")
|
|
||||||
lines.append("P2 写主干;执行前自动建档")
|
|
||||||
for w in pack.get("warnings") or []:
|
for w in pack.get("warnings") or []:
|
||||||
lines.append(f"⚠ {w}")
|
lines.append(f"提示:{w}")
|
||||||
return title, lines
|
return title, lines
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -291,7 +314,7 @@ def sop_as_block(pack: dict[str, Any]) -> Any:
|
||||||
blockId=f"sop-pack-{uuid.uuid4().hex[:8]}",
|
blockId=f"sop-pack-{uuid.uuid4().hex[:8]}",
|
||||||
type="report",
|
type="report",
|
||||||
props={
|
props={
|
||||||
"title": f"SOP 编译:{pack.get('title')}",
|
"title": f"作业标准规则:{pack.get('title')}",
|
||||||
"reportType": "sop-rule-pack",
|
"reportType": "sop-rule-pack",
|
||||||
"markdown": _pack_markdown(pack),
|
"markdown": _pack_markdown(pack),
|
||||||
"pack": pack,
|
"pack": pack,
|
||||||
|
|
@ -300,20 +323,20 @@ def sop_as_block(pack: dict[str, Any]) -> Any:
|
||||||
|
|
||||||
|
|
||||||
def _pack_markdown(pack: dict[str, Any]) -> str:
|
def _pack_markdown(pack: dict[str, Any]) -> str:
|
||||||
|
summary = str(pack.get("plannerSummary") or "当前作业标准暂未识别出可用于排产的约束")
|
||||||
lines = [
|
lines = [
|
||||||
f"# {pack.get('title')}",
|
f"# 作业标准:{pack.get('title')}",
|
||||||
"",
|
"",
|
||||||
f"**摘要**:{pack.get('summary')}",
|
"## 排产规则",
|
||||||
"",
|
|
||||||
"## 效果",
|
|
||||||
]
|
]
|
||||||
for e in pack.get("effects") or []:
|
for bit in summary.split(";"):
|
||||||
lines.append(f"- `{e.get('op')}` — {e.get('rationale') or e}")
|
if bit.strip():
|
||||||
|
lines.append(f"- {bit.strip()}")
|
||||||
if pack.get("warnings"):
|
if pack.get("warnings"):
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("## 警告")
|
lines.append("## 提示")
|
||||||
for w in pack["warnings"]:
|
for w in pack["warnings"]:
|
||||||
lines.append(f"- {w}")
|
lines.append(f"- {w}")
|
||||||
src = pack.get("source") or {}
|
src = pack.get("source") or {}
|
||||||
lines += ["", f"出处:{src.get('title')}({src.get('assetId')})"]
|
lines += ["", f"出处:{src.get('title')}"]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
|
||||||
|
|
@ -1382,9 +1382,10 @@ def execute_confirmed(store: WorldStore, confirm_id: str, approve: bool, actor:
|
||||||
"tardinessDelta": d.get("tardinessDelta")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
|
"tardinessDelta": d.get("tardinessDelta")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
|
||||||
store.save()
|
store.save()
|
||||||
_LAST_RUSH_EVAL.clear()
|
_LAST_RUSH_EVAL.clear()
|
||||||
return (f"LNS 局部插单已采用 ✅ {order['orderNo']}(急单)已入池;"
|
return (f"插单方案已采用 ✅ {order['orderNo']}(急单)已加入订单池;"
|
||||||
f"窗口内最小扰动:移动 {d.get('movedOrderCount', 0)} 单 · 位移 {d.get('shiftedHours', 0)}h · "
|
f"{d.get('movedOrderCount', 0)} 张订单顺延,合计顺延 {d.get('shiftedHours', 0)} 小时,"
|
||||||
f"延迟变化 {d.get('tardinessDelta', 0):+.1f}h;草稿版本 {result.versionNo}。可用检查点回滚到采用前。")
|
f"总延期变化 {d.get('tardinessDelta', 0):+.1f} 小时。"
|
||||||
|
f"已生成待发布的调整方案 {result.versionNo},可用检查点回滚到采用前。")
|
||||||
applied = apply_rush(store.data, store.next_id, params) # 升级(ESCALATE)/ 直接采用 → 全量重排
|
applied = apply_rush(store.data, store.next_id, params) # 升级(ESCALATE)/ 直接采用 → 全量重排
|
||||||
order = applied["order"]
|
order = applied["order"]
|
||||||
result = applied["result"]
|
result = applied["result"]
|
||||||
|
|
@ -1398,10 +1399,10 @@ def execute_confirmed(store: WorldStore, confirm_id: str, approve: bool, actor:
|
||||||
"escalateReasons": lns.get("escalateReasons")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
|
"escalateReasons": lns.get("escalateReasons")}, before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
|
||||||
store.save()
|
store.save()
|
||||||
_LAST_RUSH_EVAL.clear()
|
_LAST_RUSH_EVAL.clear()
|
||||||
prefix = "LNS 超阈值升级全量重排 ✅ " if lns.get("status") == "ESCALATE" else "插单已采用 ✅ "
|
prefix = "整版调整方案已采用 ✅ " if lns.get("status") == "ESCALATE" else "插单方案已采用 ✅ "
|
||||||
return (prefix + f"{order['orderNo']}(急单)已入池;"
|
return (prefix + f"{order['orderNo']}(急单)已加入订单池;"
|
||||||
f"草稿版本 {result.versionNo} · 订单 {result.orderCount} · "
|
f"已生成待发布的调整方案 {result.versionNo},共安排 {result.orderCount} 张订单,"
|
||||||
f"冲突 {result.conflictCount} · 延迟 {round(result.totalTardiness)}h。"
|
f"冲突 {result.conflictCount} 项,总延期 {round(result.totalTardiness)} 小时。"
|
||||||
f"可用检查点回滚到采用前。")
|
f"可用检查点回滚到采用前。")
|
||||||
if action in ("forecast.upsert", "forecast.delete", "forecast.convert"):
|
if action in ("forecast.upsert", "forecast.delete", "forecast.convert"):
|
||||||
from server.aps_domain.forecast import apply_forecast_action
|
from server.aps_domain.forecast import apply_forecast_action
|
||||||
|
|
@ -1967,6 +1968,19 @@ def execute_confirmed(store: WorldStore, confirm_id: str, approve: bool, actor:
|
||||||
hard_n = sum(1 for c in applied["after"]["constraints"] if c["kind"] == "hard" and c["enabled"])
|
hard_n = sum(1 for c in applied["after"]["constraints"] if c["kind"] == "hard" and c["enabled"])
|
||||||
return (f"约束配置已更新 ✅ 启用 {enabled} 条 / 其中硬约束 {hard_n} 条。"
|
return (f"约束配置已更新 ✅ 启用 {enabled} 条 / 其中硬约束 {hard_n} 条。"
|
||||||
"硬约束违反将阻止发布(变更前已自动建档)。")
|
"硬约束违反将阻止发布(变更前已自动建档)。")
|
||||||
|
if action == "constraint.rule.save": # ---- 批准:计划员扩充规则(SC-04) ----
|
||||||
|
from server.aps_domain.constraint_rules import apply_rule_save
|
||||||
|
cp = _create_checkpoint(store, label="约束规则库变更前基线", reason="auto:constraint.rule.save",
|
||||||
|
conversation_note="批准扩充约束规则")
|
||||||
|
applied = apply_rule_save(store.data, params, getattr(store, "tenant_uuid", "platform"))
|
||||||
|
write_audit(store.data, store.next_id, actor=actor, category="WORLD_WRITE",
|
||||||
|
action="constraint.rule.save",
|
||||||
|
target={"type": "CONSTRAINT_RULE", "id": applied["ruleId"]}, power="P2",
|
||||||
|
rationale={"confirmId": confirm_id, "approver": actor, "op": applied["op"],
|
||||||
|
"scope": applied.get("scope")},
|
||||||
|
before_snapshot=str(cp["pairId"]) if cp else None, evidence_refs=evidence_refs)
|
||||||
|
store.save()
|
||||||
|
return str(applied.get("message") or f"约束规则 {applied['ruleId']} 已更新 ✅")
|
||||||
if action == "sop.apply": # ---- 批准:SOP 规则包(IND-02) ----
|
if action == "sop.apply": # ---- 批准:SOP 规则包(IND-02) ----
|
||||||
from server.aps_domain.sop_rules import apply_sop_pack, compile_sop_by_asset
|
from server.aps_domain.sop_rules import apply_sop_pack, compile_sop_by_asset
|
||||||
pack = params.get("pack")
|
pack = params.get("pack")
|
||||||
|
|
@ -3127,17 +3141,29 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
|
||||||
}
|
}
|
||||||
rush = p.get("payload") or p
|
rush = p.get("payload") or p
|
||||||
label = rush.get("orderNo") or f"{rush.get('customerName', '急单')}×{rush.get('quantity', '?')}"
|
label = rush.get("orderNo") or f"{rush.get('customerName', '急单')}×{rush.get('quantity', '?')}"
|
||||||
|
strategy_name = {
|
||||||
|
"DELIVERY_FIRST": "交期优先",
|
||||||
|
"CAPACITY_BALANCE": "产能均衡",
|
||||||
|
"COMPREHENSIVE": "综合优化",
|
||||||
|
}.get(str(p.get("strategy") or "DELIVERY_FIRST").upper(), "交期优先")
|
||||||
|
if p.get("lns"):
|
||||||
|
from server.aps_domain.lns import _planner_lns_reasons
|
||||||
|
escalation_reasons = ";".join(
|
||||||
|
_planner_lns_reasons(p["lns"].get("escalateReasons") or [])
|
||||||
|
) or "按当前生产安排无法只调整少数订单"
|
||||||
|
else:
|
||||||
|
escalation_reasons = ""
|
||||||
block = harness.stage_confirmation(
|
block = harness.stage_confirmation(
|
||||||
session_id, "rush.apply", p,
|
session_id, "rush.apply", p,
|
||||||
title=f"采用紧急插单 · {label}",
|
title=f"采用紧急插单 · {label}",
|
||||||
summary_lines=[
|
summary_lines=[
|
||||||
f"策略:{p.get('strategy') or 'DELIVERY_FIRST'}",
|
f"排产方式:{strategy_name}",
|
||||||
"将写入主干订单池(APPROVED + isRush),并生成 DRAFT 排产版本",
|
"确认后会把这笔急单加入订单池,并生成一版待发布的调整方案",
|
||||||
"基准已发布版本不回写;执行前自动建档,可回滚",
|
"现有已发布计划不改动;执行前会自动留存可回滚点",
|
||||||
f"快评 ID:{p.get('evalId') or '(直接采用)'}",
|
*([f"局部调整:{p['lns']['disturbance']['movedOrderCount']} 张订单需要顺延,"
|
||||||
*([f"LNS:局部修复(移动 {p['lns']['disturbance']['movedOrderCount']} 单,窗口内最小扰动,入 DRAFT 草稿)"]
|
f"调整范围外的订单保持不变"]
|
||||||
if p.get("lns") and p["lns"].get("status") == "LOCAL"
|
if p.get("lns") and p["lns"].get("status") == "LOCAL"
|
||||||
else [f"LNS:升级全量重排({';'.join(p['lns'].get('escalateReasons') or [])})"]
|
else [f"整版调整:{escalation_reasons}"]
|
||||||
if p.get("lns") else []),
|
if p.get("lns") else []),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
@ -3145,7 +3171,7 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
|
||||||
target={"type": "SALES_ORDER", "id": rush.get("orderNo") or "NEW"}, power="P2",
|
target={"type": "SALES_ORDER", "id": rush.get("orderNo") or "NEW"}, power="P2",
|
||||||
rationale={"confirmId": block.props["confirmId"]})
|
rationale={"confirmId": block.props["confirmId"]})
|
||||||
store.save()
|
store.save()
|
||||||
return AgentReply(text="采用插单属于 P2 写操作,需要你确认(见下方确认卡)。", blocks=[block])
|
return AgentReply(text="采用这项插单方案前需要你确认,具体影响见下方确认卡。", blocks=[block])
|
||||||
# ---- OR-05 预测 / 长周期订单 ----
|
# ---- OR-05 预测 / 长周期订单 ----
|
||||||
if name == "forecast.query":
|
if name == "forecast.query":
|
||||||
from server.aps_domain.forecast import forecast_summary, list_forecasts
|
from server.aps_domain.forecast import forecast_summary, list_forecasts
|
||||||
|
|
@ -4554,6 +4580,38 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
|
||||||
rationale={"confirmId": block.props["confirmId"]})
|
rationale={"confirmId": block.props["confirmId"]})
|
||||||
store.save()
|
store.save()
|
||||||
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
|
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
|
||||||
|
# ---- 约束规则库:默认通用规则 + 计划员自然语言扩充(SC-04) ----
|
||||||
|
if name == "constraint.rule.query":
|
||||||
|
from server.aps_domain.constraint_rules import constraint_library
|
||||||
|
lib = constraint_library(store.data, getattr(store, "tenant_uuid", "platform"))
|
||||||
|
s = lib["summary"]
|
||||||
|
lines = [
|
||||||
|
f"· {r['name']}({r['categoryLabel']}/{r['scopeLabel']}/{r['kindLabel']})"
|
||||||
|
f"→ {r['effect']['text']}"
|
||||||
|
for r in lib["custom"]
|
||||||
|
]
|
||||||
|
text = (f"默认通用约束 {s['builtinTotal']} 条(无需逐条登记);"
|
||||||
|
f"已扩充 {s['customTotal']} 条,其中接入排产引擎 {s['engineBound']} 条、"
|
||||||
|
f"仅记录 {s['recordOnly']} 条。")
|
||||||
|
text += "\n" + ("\n".join(lines) if lines
|
||||||
|
else "还没有扩充规则。直接用一句话说你的要求,我来整理成规则。")
|
||||||
|
return AgentReply(text=text)
|
||||||
|
if name == "constraint.rule.save":
|
||||||
|
from server.aps_domain.constraint_rules import confirmation_for_rule_save
|
||||||
|
raw = dict(intent.params or {})
|
||||||
|
try:
|
||||||
|
title, lines = confirmation_for_rule_save(
|
||||||
|
store.data, raw, getattr(store, "tenant_uuid", "platform"))
|
||||||
|
except ValueError as exc:
|
||||||
|
return AgentReply(text=f"规则没有登记:{exc}")
|
||||||
|
block = harness.stage_confirmation(
|
||||||
|
session_id, "constraint.rule.save", raw, title=title, summary_lines=lines)
|
||||||
|
write_audit(store.data, store.next_id, actor=actor, category="GATE",
|
||||||
|
action="constraint.rule.save.stage",
|
||||||
|
target={"type": "CONSTRAINT_RULE", "id": str(raw.get("ruleId") or "new")},
|
||||||
|
power="P2", rationale={"confirmId": block.props["confirmId"]})
|
||||||
|
store.save()
|
||||||
|
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
|
||||||
# ---- IND-02 SOP→约束 ----
|
# ---- IND-02 SOP→约束 ----
|
||||||
if name == "sop.compile":
|
if name == "sop.compile":
|
||||||
from server.aps_domain.sop_rules import compile_sop_by_asset, list_compilable_sops, sop_as_block
|
from server.aps_domain.sop_rules import compile_sop_by_asset, list_compilable_sops, sop_as_block
|
||||||
|
|
@ -4561,7 +4619,7 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
|
||||||
or (intent.params or {}).get("asset_id") or "").strip()
|
or (intent.params or {}).get("asset_id") or "").strip()
|
||||||
if not asset_id:
|
if not asset_id:
|
||||||
return AgentReply(
|
return AgentReply(
|
||||||
text="SOP 编译需要结构化参数 assetId;本次未从 query 文本猜测 SOP。"
|
text="请先选择要查看的作业标准,本次未从问句自动选择。"
|
||||||
)
|
)
|
||||||
compiled = compile_sop_by_asset(get_knowledge().assets, asset_id)
|
compiled = compile_sop_by_asset(get_knowledge().assets, asset_id)
|
||||||
if not compiled.get("ok"):
|
if not compiled.get("ok"):
|
||||||
|
|
@ -4574,9 +4632,9 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
|
||||||
rationale={"assetId": asset_id, "effects": len(pack.get("effects") or [])})
|
rationale={"assetId": asset_id, "effects": len(pack.get("effects") or [])})
|
||||||
store.save()
|
store.save()
|
||||||
text = (
|
text = (
|
||||||
f"已编译 SOP《{pack.get('title')}》→ 规则包预览。\n"
|
f"已从作业标准《{pack.get('title')}》识别出以下排产规则:\n"
|
||||||
f"{pack.get('summary')}\n"
|
f"{pack.get('plannerSummary') or '当前作业标准暂未识别出可用于排产的约束'}\n"
|
||||||
"确认落地请说「应用换线SOP」或「应用该规则包」(P2)。"
|
"需要应用时请回复「应用该作业标准」,确认后生效。"
|
||||||
)
|
)
|
||||||
return AgentReply(text=text, blocks=[sop_as_block(pack)])
|
return AgentReply(text=text, blocks=[sop_as_block(pack)])
|
||||||
if name == "sop.apply":
|
if name == "sop.apply":
|
||||||
|
|
@ -4589,11 +4647,11 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
|
||||||
if pack is None:
|
if pack is None:
|
||||||
if not asset_id:
|
if not asset_id:
|
||||||
return AgentReply(
|
return AgentReply(
|
||||||
text="应用 SOP 需要结构化参数 pack,或 assetId 精确指定 SOP;本次未生成确认卡。"
|
text="请先选择要应用的作业标准,本次未生成确认项。"
|
||||||
)
|
)
|
||||||
compiled = compile_sop_by_asset(get_knowledge().assets, asset_id)
|
compiled = compile_sop_by_asset(get_knowledge().assets, asset_id)
|
||||||
if not compiled.get("ok"):
|
if not compiled.get("ok"):
|
||||||
return AgentReply(text=compiled.get("error") or "SOP 编译失败")
|
return AgentReply(text="作业标准读取失败,请稍后重试。")
|
||||||
pack = compiled["pack"]
|
pack = compiled["pack"]
|
||||||
title, lines = confirmation_for_sop_apply(pack)
|
title, lines = confirmation_for_sop_apply(pack)
|
||||||
block = harness.stage_confirmation(
|
block = harness.stage_confirmation(
|
||||||
|
|
@ -4604,7 +4662,7 @@ async def handle_intent(store: WorldStore, session_id: str, intent: IntentResult
|
||||||
target={"type": "RULE_PACK", "id": pack.get("packId")}, power="P2",
|
target={"type": "RULE_PACK", "id": pack.get("packId")}, power="P2",
|
||||||
rationale={"confirmId": block.props["confirmId"]})
|
rationale={"confirmId": block.props["confirmId"]})
|
||||||
store.save()
|
store.save()
|
||||||
return AgentReply(text=f"{title} 属于 P2 写操作,需要你确认。", blocks=[block])
|
return AgentReply(text=f"{title},需要你确认后生效。", blocks=[block])
|
||||||
# ---- 帮助(P0) ----
|
# ---- 帮助(P0) ----
|
||||||
if name == "help":
|
if name == "help":
|
||||||
return AgentReply(text=_HELP)
|
return AgentReply(text=_HELP)
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,8 @@ IntentName = Literal[
|
||||||
"params.query", # OR-02:排产参数查询(P0)
|
"params.query", # OR-02:排产参数查询(P0)
|
||||||
"constraint.profile.query", # SC-04:约束剖面查询(P0)
|
"constraint.profile.query", # SC-04:约束剖面查询(P0)
|
||||||
"constraint.profile.save", # SC-04:约束剖面保存(P2)
|
"constraint.profile.save", # SC-04:约束剖面保存(P2)
|
||||||
|
"constraint.rule.query", # SC-04:规则库概览(P0)
|
||||||
|
"constraint.rule.save", # SC-04:计划员扩充规则(P2)
|
||||||
"data.import", # 自然语言批量导入订单/物料(P2)
|
"data.import", # 自然语言批量导入订单/物料(P2)
|
||||||
"import.commit", # Excel/CSV 导入入库(P2)
|
"import.commit", # Excel/CSV 导入入库(P2)
|
||||||
"folder.analyze", # 工程目录深度解析(P0)
|
"folder.analyze", # 工程目录深度解析(P0)
|
||||||
|
|
|
||||||
|
|
@ -3253,6 +3253,35 @@ def create_app() -> FastAPI:
|
||||||
store.save()
|
store.save()
|
||||||
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
|
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
|
||||||
|
|
||||||
|
@app.get("/api/constraints/library")
|
||||||
|
async def constraints_library() -> dict:
|
||||||
|
"""计划员约束规则库(SC-04 扩充,P0):默认通用规则概览 + 已扩充规则。"""
|
||||||
|
from server.aps_domain.constraint_rules import constraint_library
|
||||||
|
store = get_store()
|
||||||
|
return constraint_library(store.data, getattr(store, "tenant_uuid", "platform"))
|
||||||
|
|
||||||
|
@app.post("/api/constraints/rules/stage")
|
||||||
|
async def constraints_rule_stage(req: ConstraintStageRequest) -> dict:
|
||||||
|
"""计划员扩充规则新增/启停/删除暂存确认卡(P2 → /api/actions/confirm)。"""
|
||||||
|
from server.aps_domain.constraint_rules import confirmation_for_rule_save
|
||||||
|
store = get_store()
|
||||||
|
tenant = getattr(store, "tenant_uuid", "platform")
|
||||||
|
try:
|
||||||
|
title, lines = confirmation_for_rule_save(store.data, req.payload, tenant)
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"error": str(exc)}
|
||||||
|
block = harness.stage_confirmation(
|
||||||
|
req.sessionId or "web", "constraint.rule.save", req.payload,
|
||||||
|
title=title, summary_lines=lines)
|
||||||
|
from server.agent_core.audit import write_audit
|
||||||
|
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="GATE",
|
||||||
|
action="constraint.rule.save.stage",
|
||||||
|
target={"type": "CONSTRAINT_RULE",
|
||||||
|
"id": str(req.payload.get("ruleId") or "new")},
|
||||||
|
power="P2", rationale={"confirmId": block.props["confirmId"]})
|
||||||
|
store.save()
|
||||||
|
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
|
||||||
|
|
||||||
@app.get("/api/sop/compilable")
|
@app.get("/api/sop/compilable")
|
||||||
async def sop_compilable() -> dict:
|
async def sop_compilable() -> dict:
|
||||||
"""可编译 SOP 列表(IND-02,P0)。"""
|
"""可编译 SOP 列表(IND-02,P0)。"""
|
||||||
|
|
|
||||||
|
|
@ -791,6 +791,13 @@ def render_plan_task_brief(run_id: str, query: str, snapshot_files: list[str],
|
||||||
6. 工具结果要求核对、确认或选择哪一份文件时,必须原样转达这个问题,不得改写成
|
6. 工具结果要求核对、确认或选择哪一份文件时,必须原样转达这个问题,不得改写成
|
||||||
「项目没有数据」之类的结论。
|
「项目没有数据」之类的结论。
|
||||||
7. 单轮最多调用 8 次,序号从 1 递增;同一个请求不要重复调用。
|
7. 单轮最多调用 8 次,序号从 1 递增;同一个请求不要重复调用。
|
||||||
|
8. 紧急插单只认工具结论:只要用户在问一张急单能不能插、会延期哪些订单、是否要把
|
||||||
|
整版计划重排,必须先调用 `rush.evaluate`,不得凭订单数量、交期或快照内容自行判断。
|
||||||
|
缺少“已有订单号,或产品编码/名称 + 数量 + 交期”时先向用户追问,不要把整句话塞进参数,
|
||||||
|
也不要替用户猜默认数量或交期。工具返回后才解释结论:能局部调整就说清急单安排、受影响的
|
||||||
|
订单和顺延时间;不能局部调整就说清需要重新编排全部订单及主要原因。用户确认“采用刚才的
|
||||||
|
插单方案”时,调用 `rush.apply` 且 params 传空对象;它只会生成待确认事项,确认前不得说
|
||||||
|
计划已经修改、方案已经发布或工单已经下发。
|
||||||
"""
|
"""
|
||||||
brief = brief.replace("【输出契约】", tool_protocol + "\n【输出契约】", 1)
|
brief = brief.replace("【输出契约】", tool_protocol + "\n【输出契约】", 1)
|
||||||
|
|
||||||
|
|
@ -807,6 +814,7 @@ def render_plan_task_brief(run_id: str, query: str, snapshot_files: list[str],
|
||||||
- 需要写系统数据时,只说明已准备待确认的操作,不得声称已经执行或已经获批;
|
- 需要写系统数据时,只说明已准备待确认的操作,不得声称已经执行或已经获批;
|
||||||
- 失败或信息不足时如实说明,不泄露内部运行机制;
|
- 失败或信息不足时如实说明,不泄露内部运行机制;
|
||||||
- 不向用户展示工具名、协议字段或文件邮箱路径,统一改用中文业务名称;
|
- 不向用户展示工具名、协议字段或文件邮箱路径,统一改用中文业务名称;
|
||||||
|
- 同一次回复里的备选项或步骤使用连续的 1、2、3 编号;不要每个选项都写成 1.;
|
||||||
- 不要引用或编造任何凭证编号(callId)。
|
- 不要引用或编造任何凭证编号(callId)。
|
||||||
"""
|
"""
|
||||||
return brief.replace(legacy_output, primary_output, 1)
|
return brief.replace(legacy_output, primary_output, 1)
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@
|
||||||
"params.query",
|
"params.query",
|
||||||
"constraint.profile.query",
|
"constraint.profile.query",
|
||||||
"constraint.profile.save",
|
"constraint.profile.save",
|
||||||
|
"constraint.rule.query",
|
||||||
|
"constraint.rule.save",
|
||||||
"data.import",
|
"data.import",
|
||||||
"import.commit",
|
"import.commit",
|
||||||
"folder.analyze",
|
"folder.analyze",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
# ============================================================
|
||||||
|
# SC-04 扩充:计划员约束规则库黄金测试(可重生 ✅)
|
||||||
|
# 1) 默认通用规则 = 内建 C1..C13 目录,扩充规则不动它
|
||||||
|
# 2) 计划员一句话 → 规则(按类别 / 按范围归档)
|
||||||
|
# 3) 没接入排产引擎的规则如实标注,不假装生效
|
||||||
|
# 4) 绑定可配内置项时真正落到排产引擎,并说清执行来源
|
||||||
|
# 5) 新增 / 启用 / 停用 / 删除 全生命周期 + 非法载荷拒绝
|
||||||
|
# ============================================================
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from server.aps_domain.constraint_rules import (
|
||||||
|
all_rules,
|
||||||
|
apply_rule_save,
|
||||||
|
confirmation_for_rule_save,
|
||||||
|
constraint_library,
|
||||||
|
normalize_rule_payload,
|
||||||
|
rule_effect,
|
||||||
|
tenant_rules_path,
|
||||||
|
)
|
||||||
|
from server.aps_domain.constraints import (
|
||||||
|
DEFAULT_CONSTRAINT_DEFS,
|
||||||
|
apply_profile_save,
|
||||||
|
get_constraint,
|
||||||
|
get_constraint_profile,
|
||||||
|
)
|
||||||
|
from server.state.seed import seed_world
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def rules_home(tmp_path, monkeypatch):
|
||||||
|
"""每条用例独占一个数据根,避免全厂规则文件互相串。"""
|
||||||
|
monkeypatch.setenv("APS_HOME", str(tmp_path))
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _add(world, tenant, **kw):
|
||||||
|
payload = {"op": "add", "name": kw.pop("name"), "category": kw.pop("category", "other")}
|
||||||
|
payload.update(kw)
|
||||||
|
return apply_rule_save(world, payload, tenant)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 1) 默认通用规则是基线,扩充不动它 ----------------
|
||||||
|
def test_default_library_is_builtin_catalog_only(rules_home):
|
||||||
|
world = seed_world()
|
||||||
|
lib = constraint_library(world, "t1")
|
||||||
|
assert lib["summary"]["builtinTotal"] == len(DEFAULT_CONSTRAINT_DEFS)
|
||||||
|
assert lib["summary"]["customTotal"] == 0
|
||||||
|
assert lib["summary"]["engineBound"] == 0
|
||||||
|
assert lib["groups"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_adding_unbound_rule_does_not_touch_builtin_profile(rules_home):
|
||||||
|
world = seed_world()
|
||||||
|
before = [(c["kind"], c["enabled"]) for c in get_constraint_profile(world)["constraints"]]
|
||||||
|
_add(world, "t1", name="夜班不安排换型", category="process", scope="project", kind="soft")
|
||||||
|
after = [(c["kind"], c["enabled"]) for c in get_constraint_profile(world)["constraints"]]
|
||||||
|
assert before == after
|
||||||
|
assert len(world["constraintRules"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 2) 计划员一句话 → 规则,按类别/范围归档 ----------------
|
||||||
|
def test_project_rule_is_archived_with_category_and_scope(rules_home):
|
||||||
|
world = seed_world()
|
||||||
|
out = _add(world, "t1", name="夜班不安排换型", category="process", scope="project",
|
||||||
|
kind="soft", sourceText="以后夜班别安排换型")
|
||||||
|
assert out["ruleId"] == "R-001"
|
||||||
|
lib = constraint_library(world, "t1")
|
||||||
|
assert [g["id"] for g in lib["groups"]] == ["process"]
|
||||||
|
rule = lib["custom"][0]
|
||||||
|
assert rule["name"] == "夜班不安排换型"
|
||||||
|
assert rule["categoryLabel"] == "工艺"
|
||||||
|
assert rule["scopeLabel"] == "仅本项目"
|
||||||
|
assert rule["kindLabel"] == "尽量满足"
|
||||||
|
assert rule["source"]["text"] == "以后夜班别安排换型"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenant_rule_lands_in_tenant_file_and_stays_tenant_scoped(rules_home):
|
||||||
|
world = seed_world()
|
||||||
|
_add(world, "tenant-a", name="全厂周末不排产", category="calendar", scope="tenant", kind="hard")
|
||||||
|
assert tenant_rules_path("tenant-a").exists()
|
||||||
|
assert not world.get("constraintRules")
|
||||||
|
assert constraint_library(world, "tenant-a")["summary"]["customTotal"] == 1
|
||||||
|
assert constraint_library(world, "tenant-b")["summary"]["customTotal"] == 0
|
||||||
|
assert constraint_library(world, "tenant-a")["custom"][0]["scopeLabel"] == "全厂"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_ids_increment_across_scopes(rules_home):
|
||||||
|
world = seed_world()
|
||||||
|
_add(world, "t1", name="本项目第一条", category="other", scope="project")
|
||||||
|
_add(world, "t1", name="全厂第二条", category="other", scope="tenant")
|
||||||
|
rules = all_rules(world, "t1")
|
||||||
|
assert sorted(r["id"] for r in rules) == ["R-001", "R-002"]
|
||||||
|
assert {r["scope"] for r in rules} == {"project", "tenant"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 3) 没接入引擎就直说 ----------------
|
||||||
|
def test_unbound_rule_is_reported_as_record_only(rules_home):
|
||||||
|
world = seed_world()
|
||||||
|
out = _add(world, "t1", name="喷涂线每天最多八小时", category="capacity", scope="project")
|
||||||
|
rule = world["constraintRules"][0]
|
||||||
|
effect = rule_effect(world, rule)
|
||||||
|
assert effect["bound"] is False and effect["enforced"] is False
|
||||||
|
assert effect["text"] == "已记录,暂未接入排产引擎"
|
||||||
|
assert "暂未接入排产引擎" in out["message"]
|
||||||
|
assert constraint_library(world, "t1")["summary"]["recordOnly"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 4) 绑定内置项时真正生效,并说清来源 ----------------
|
||||||
|
def test_binding_to_kind_configurable_constraint_applies_to_engine(rules_home):
|
||||||
|
world = seed_world()
|
||||||
|
assert get_constraint(world, "C6_material_kit")["kind"] == "soft"
|
||||||
|
_add(world, "t1", name="缺料必须挡住发布", category="material", scope="project",
|
||||||
|
kind="hard", boundConstraintId="C6_material_kit")
|
||||||
|
assert get_constraint(world, "C6_material_kit")["kind"] == "hard"
|
||||||
|
rule = world["constraintRules"][0]
|
||||||
|
effect = rule_effect(world, rule)
|
||||||
|
assert effect["enforced"] is True
|
||||||
|
assert "物料齐套" in effect["text"] and "硬约束" in effect["text"]
|
||||||
|
assert constraint_library(world, "t1")["summary"]["engineBound"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_binding_to_non_configurable_hard_rule_never_downgrades_it(rules_home):
|
||||||
|
world = seed_world()
|
||||||
|
_add(world, "t1", name="前序没做完不能开下道", category="process", scope="project",
|
||||||
|
kind="soft", boundConstraintId="C1_precedence")
|
||||||
|
c1 = get_constraint(world, "C1_precedence")
|
||||||
|
assert c1["kind"] == "hard" and c1["enabled"] is True
|
||||||
|
assert rule_effect(world, world["constraintRules"][0])["enforced"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_builtin_marks_bound_rule_as_not_effective(rules_home):
|
||||||
|
world = seed_world()
|
||||||
|
_add(world, "t1", name="缺料先提示我", category="material", scope="project",
|
||||||
|
kind="soft", boundConstraintId="C6_material_kit")
|
||||||
|
apply_profile_save(world, {"constraints": {"C6_material_kit": {"enabled": False}}})
|
||||||
|
effect = rule_effect(world, world["constraintRules"][0])
|
||||||
|
assert effect["enforced"] is False
|
||||||
|
assert "已关闭" in effect["text"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 5) 生命周期与校验 ----------------
|
||||||
|
def test_rule_lifecycle_enable_disable_delete(rules_home):
|
||||||
|
world = seed_world()
|
||||||
|
_add(world, "t1", name="插单必须先经我确认", category="order", scope="project")
|
||||||
|
rid = world["constraintRules"][0]["id"]
|
||||||
|
apply_rule_save(world, {"op": "disable", "ruleId": rid}, "t1")
|
||||||
|
assert world["constraintRules"][0]["enabled"] is False
|
||||||
|
assert constraint_library(world, "t1")["custom"][0]["enabled"] is False
|
||||||
|
apply_rule_save(world, {"op": "enable", "ruleId": rid}, "t1")
|
||||||
|
assert world["constraintRules"][0]["enabled"] is True
|
||||||
|
out = apply_rule_save(world, {"op": "delete", "ruleId": rid}, "t1")
|
||||||
|
assert world["constraintRules"] == []
|
||||||
|
assert "已删除" in out["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenant_rule_toggle_writes_back_to_tenant_file(rules_home):
|
||||||
|
world = seed_world()
|
||||||
|
_add(world, "tenant-a", name="全厂周末不排产", category="calendar", scope="tenant")
|
||||||
|
rid = all_rules(world, "tenant-a")[0]["id"]
|
||||||
|
apply_rule_save(world, {"op": "disable", "ruleId": rid}, "tenant-a")
|
||||||
|
assert all_rules(world, "tenant-a")[0]["enabled"] is False
|
||||||
|
assert not world.get("constraintRules")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", [
|
||||||
|
{"op": "add", "name": ""},
|
||||||
|
{"op": "add", "name": "超" * 41},
|
||||||
|
{"op": "add", "name": "合法内容", "category": "unknown"},
|
||||||
|
{"op": "add", "name": "合法内容", "scope": "site"},
|
||||||
|
{"op": "add", "name": "合法内容", "kind": "must"},
|
||||||
|
{"op": "add", "name": "合法内容", "boundConstraintId": "C99_nope"},
|
||||||
|
{"op": "enable"},
|
||||||
|
{"op": "archive", "ruleId": "R-001"},
|
||||||
|
])
|
||||||
|
def test_invalid_payloads_are_rejected(payload):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
normalize_rule_payload(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_rule_id_is_rejected_on_apply(rules_home):
|
||||||
|
world = seed_world()
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
apply_rule_save(world, {"op": "delete", "ruleId": "R-404"}, "t1")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
confirmation_for_rule_save(world, {"op": "disable", "ruleId": "R-404"}, "t1")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 6) 确认卡说人话 ----------------
|
||||||
|
def test_confirmation_card_states_scope_strength_and_engine_linkage(rules_home):
|
||||||
|
world = seed_world()
|
||||||
|
title, lines = confirmation_for_rule_save(
|
||||||
|
world, {"op": "add", "name": "夜班不换型", "category": "process",
|
||||||
|
"scope": "tenant", "kind": "soft"}, "t1")
|
||||||
|
body = "\n".join(lines)
|
||||||
|
assert "夜班不换型" in title
|
||||||
|
assert "工艺" in body and "全厂" in body and "尽量满足" in body
|
||||||
|
assert "暂未接入排产引擎" in body
|
||||||
|
_, lines_bound = confirmation_for_rule_save(
|
||||||
|
world, {"op": "add", "name": "缺料挡发布", "category": "material",
|
||||||
|
"kind": "hard", "boundConstraintId": "C6_material_kit"}, "t1")
|
||||||
|
assert "物料齐套" in "\n".join(lines_bound)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 7) Pi 工具目录接线 + 确认后落库 ----------------
|
||||||
|
def test_pi_tool_catalog_exposes_constraint_rule_tools():
|
||||||
|
from server.agent_core import fallback_lane
|
||||||
|
|
||||||
|
catalog = {t["name"]: t for t in fallback_lane._primary_tool_catalog()}
|
||||||
|
assert catalog["constraint.rule.query"]["power"] == "P0"
|
||||||
|
assert catalog["constraint.rule.save"]["power"] == "P2"
|
||||||
|
schema = catalog["constraint.rule.save"]["paramsSchema"]
|
||||||
|
assert schema["additionalProperties"] is False
|
||||||
|
assert "add" in schema["properties"]["op"]["enum"]
|
||||||
|
assert "tenant" in schema["properties"]["scope"]["enum"]
|
||||||
|
assert "工艺" in catalog["constraint.rule.query"]["description"] or \
|
||||||
|
"规则库" in catalog["constraint.rule.query"]["description"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pi_intent_stages_rule_and_confirmation_writes_it(rules_home, tmp_path):
|
||||||
|
from server.aps_domain.workflow import execute_confirmed, handle_intent
|
||||||
|
from server.contracts import IntentResult
|
||||||
|
from server.state.store import WorldStore
|
||||||
|
|
||||||
|
store = WorldStore(path=tmp_path / "world.json")
|
||||||
|
reply = await handle_intent(
|
||||||
|
store, "s-rules",
|
||||||
|
IntentResult(
|
||||||
|
intent="constraint.rule.save",
|
||||||
|
params={"op": "add", "name": "夜班不安排换型", "category": "process",
|
||||||
|
"scope": "project", "kind": "soft", "sourceText": "以后夜班别安排换型"},
|
||||||
|
confidence=1.0, source="LLM",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert reply.blocks, "P2 动作必须出确认卡"
|
||||||
|
block = reply.blocks[0]
|
||||||
|
assert block.props["power"] == "P2"
|
||||||
|
assert not store.data.get("constraintRules"), "确认前不得写规则库"
|
||||||
|
|
||||||
|
message = execute_confirmed(store, str(block.props["confirmId"]), True, actor="planner-1")
|
||||||
|
assert "R-001" in message
|
||||||
|
assert store.data["constraintRules"][0]["name"] == "夜班不安排换型"
|
||||||
|
audits = [e for e in store.data["auditEvents"]
|
||||||
|
if e.get("action") == "constraint.rule.save" and e.get("result") == "SUCCESS"]
|
||||||
|
assert audits, "批准执行必须落审计"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pi_rule_query_lists_defaults_and_extensions(rules_home, tmp_path):
|
||||||
|
from server.aps_domain.workflow import handle_intent
|
||||||
|
from server.contracts import IntentResult
|
||||||
|
from server.state.store import WorldStore
|
||||||
|
|
||||||
|
world = seed_world()
|
||||||
|
_add(world, "platform", name="喷涂线每天最多八小时", category="capacity", scope="project")
|
||||||
|
store = WorldStore(path=tmp_path / "world.json")
|
||||||
|
store.data = world
|
||||||
|
reply = await handle_intent(
|
||||||
|
store, "s-rules",
|
||||||
|
IntentResult(intent="constraint.rule.query", params={}, confidence=1.0, source="LLM"),
|
||||||
|
)
|
||||||
|
assert "默认通用约束" in reply.text
|
||||||
|
assert "喷涂线每天最多八小时" in reply.text
|
||||||
|
assert "暂未接入排产引擎" in reply.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_endpoints_expose_and_apply_planner_rules(rules_home, monkeypatch):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import server.gateway.app as gateway_module
|
||||||
|
from tests.auth_provider import install_test_auth
|
||||||
|
|
||||||
|
install_test_auth(monkeypatch, "tenant-rule-library")
|
||||||
|
client = TestClient(gateway_module.create_app())
|
||||||
|
assert client.post("/api/auth/login", json={"username": "planner"}).status_code == 200
|
||||||
|
|
||||||
|
lib = client.get("/api/constraints/library")
|
||||||
|
assert lib.status_code == 200
|
||||||
|
summary = lib.json()["summary"]
|
||||||
|
assert summary["builtinTotal"] == len(DEFAULT_CONSTRAINT_DEFS)
|
||||||
|
assert summary["customTotal"] == 0
|
||||||
|
|
||||||
|
staged = client.post("/api/constraints/rules/stage", json={
|
||||||
|
"sessionId": "s-http-rules",
|
||||||
|
"payload": {"op": "add", "name": "夜班不安排换型", "category": "process",
|
||||||
|
"scope": "project", "kind": "soft"},
|
||||||
|
})
|
||||||
|
assert staged.status_code == 200
|
||||||
|
body = staged.json()
|
||||||
|
assert not body.get("error"), body
|
||||||
|
confirm_id = body["block"]["props"]["confirmId"]
|
||||||
|
assert client.get("/api/constraints/library").json()["summary"]["customTotal"] == 0, \
|
||||||
|
"暂存阶段不得写规则库"
|
||||||
|
|
||||||
|
approved = client.post("/api/actions/confirm", json={
|
||||||
|
"sessionId": "s-http-rules", "confirmId": confirm_id, "approve": True,
|
||||||
|
})
|
||||||
|
assert approved.status_code == 200
|
||||||
|
assert "R-001" in approved.json()["message"]
|
||||||
|
after = client.get("/api/constraints/library").json()
|
||||||
|
assert after["summary"]["customTotal"] == 1
|
||||||
|
assert after["custom"][0]["name"] == "夜班不安排换型"
|
||||||
|
assert after["custom"][0]["categoryLabel"] == "工艺"
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
# ============================================================
|
||||||
|
# A0050101-00280 外壳 · 紧急插单计划员自然语言验收
|
||||||
|
#
|
||||||
|
# 目标:用计划员原话验证 Pi 只负责理解和调用,能否插单、影响哪些订单
|
||||||
|
# 必须来自真实 RULE 引擎;采用前不得写入订单或生成排产版本。
|
||||||
|
# ============================================================
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from server.agent_core import fallback_lane
|
||||||
|
from server.aps_domain import workflow as wf
|
||||||
|
from server.aps_domain.project_analyze import analyze_project_deep
|
||||||
|
from server.contracts import IntentResult
|
||||||
|
from server.state.seed import empty_world
|
||||||
|
from server.timeutil import fmt_date
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DEMO_DIR = REPO_ROOT / "demo-data" / "锐扬APS演示数据"
|
||||||
|
PRODUCT_CODE = "A0050101-00280"
|
||||||
|
FIXED_TODAY = datetime(2026, 9, 18) # noqa: DTZ001 - 业务日历统一使用本地无时区时间
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def __init__(self, world: dict):
|
||||||
|
self.data = world
|
||||||
|
self._counters: dict[str, int] = {}
|
||||||
|
|
||||||
|
def next_id(self, kind: str) -> int:
|
||||||
|
self._counters[kind] = self._counters.get(kind, 0) + 1
|
||||||
|
return self._counters[kind]
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_intent(query: str) -> IntentResult:
|
||||||
|
return IntentResult(
|
||||||
|
intent="assistant.reply",
|
||||||
|
params={"query": query, "_history": [], "_piPrimary": True},
|
||||||
|
confidence=1.0,
|
||||||
|
source="LLM",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _planner_world(monkeypatch) -> dict:
|
||||||
|
"""加载仓库里的锐扬演示资料,并用固定时钟保证测试可复现。"""
|
||||||
|
monkeypatch.setattr("server.aps_domain.rush.today0", lambda: FIXED_TODAY)
|
||||||
|
monkeypatch.setattr("server.aps_domain.lns.today0", lambda: FIXED_TODAY)
|
||||||
|
|
||||||
|
world = empty_world()
|
||||||
|
counter = {"n": 0}
|
||||||
|
|
||||||
|
def next_id(_kind: str) -> int:
|
||||||
|
counter["n"] += 1
|
||||||
|
return counter["n"]
|
||||||
|
|
||||||
|
report = analyze_project_deep(
|
||||||
|
world,
|
||||||
|
input_path=str(DEMO_DIR),
|
||||||
|
next_id=next_id,
|
||||||
|
apply=True,
|
||||||
|
)
|
||||||
|
assert report["canSchedule"] is True
|
||||||
|
assert any(m.get("code") == PRODUCT_CODE for m in world["materials"])
|
||||||
|
|
||||||
|
# 演示包的交期是历史固定日期;测试只平移交期,保留产品、BOM、工艺和设备原样。
|
||||||
|
for offset, order in enumerate(world["salesOrders"]):
|
||||||
|
order["deliveryDate"] = fmt_date(FIXED_TODAY + timedelta(days=12 + offset))
|
||||||
|
world["scheduleParams"]["planningHorizonDays"] = 60
|
||||||
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
def _install_real_tool_runtime(monkeypatch) -> list[IntentResult]:
|
||||||
|
calls: list[IntentResult] = []
|
||||||
|
|
||||||
|
async def fake_run_tool(store, session_id, intent, actor="planner"):
|
||||||
|
calls.append(intent)
|
||||||
|
return await wf.handle_intent(store, session_id, intent, actor)
|
||||||
|
|
||||||
|
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", fake_run_tool)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def _pi_runner(captured: dict, tool: str | None, params: dict, model_text: str):
|
||||||
|
def runner(task: str, work_dir: Path):
|
||||||
|
captured["task"] = task
|
||||||
|
if tool is None:
|
||||||
|
yield {
|
||||||
|
"type": "message_end",
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"stopReason": "stop",
|
||||||
|
"content": [{"type": "text", "text": model_text}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yield {"type": "agent_end", "messages": []}
|
||||||
|
return
|
||||||
|
mailbox = work_dir.parent / "outbox" / "chat-tools"
|
||||||
|
mailbox.mkdir(parents=True, exist_ok=True)
|
||||||
|
request = mailbox / f"01-{tool.replace('.', '-')}.json"
|
||||||
|
request.write_text(
|
||||||
|
json.dumps({"seq": 1, "tool": tool, "params": params}, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
yield {"type": "harness_heartbeat"}
|
||||||
|
result = request.with_name(request.stem + ".result.json")
|
||||||
|
captured["tool_result"] = json.loads(result.read_text(encoding="utf-8"))
|
||||||
|
yield {
|
||||||
|
"type": "message_end",
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"stopReason": "stop",
|
||||||
|
"content": [{"type": "text", "text": model_text}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yield {"type": "agent_end", "messages": []}
|
||||||
|
|
||||||
|
return runner
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_planner_language(text: str, summary: list[str] | None = None) -> None:
|
||||||
|
visible = text + "\n" + "\n".join(summary or [])
|
||||||
|
for term in ("P1", "P2", "DRAFT", "LNS", "evalId", "APPROVED", "isRush", "沙盒"):
|
||||||
|
assert term not in visible
|
||||||
|
|
||||||
|
|
||||||
|
async def test_planner_rush_natural_language_uses_engine_and_ignores_pi_guess(
|
||||||
|
tmp_path, monkeypatch,
|
||||||
|
):
|
||||||
|
"""计划员原话进入 Pi 后,必须调用 rush.evaluate,且引擎结论覆盖模型猜测。"""
|
||||||
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
||||||
|
store = FakeStore(_planner_world(monkeypatch))
|
||||||
|
calls = _install_real_tool_runtime(monkeypatch)
|
||||||
|
query = (
|
||||||
|
"湖南工业控制有限公司追加 50 套 A0050101-00280 外壳,10月14日交货,"
|
||||||
|
"先评估紧急插单影响,不要直接改计划。"
|
||||||
|
)
|
||||||
|
params = {
|
||||||
|
"customerName": "湖南工业控制有限公司",
|
||||||
|
"customerLevel": "A",
|
||||||
|
"productCode": PRODUCT_CODE,
|
||||||
|
"quantity": 50,
|
||||||
|
"deliveryDate": "2026-10-14",
|
||||||
|
"priority": 1,
|
||||||
|
"strategy": "DELIVERY_FIRST",
|
||||||
|
}
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
reply = await fallback_lane.propose_reply(
|
||||||
|
store,
|
||||||
|
"s-planner-rush",
|
||||||
|
_primary_intent(query),
|
||||||
|
runner=_pi_runner(
|
||||||
|
captured,
|
||||||
|
"rush.evaluate",
|
||||||
|
params,
|
||||||
|
"可以插单,而且不会影响任何订单,直接改计划就行。",
|
||||||
|
),
|
||||||
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [call.intent for call in calls] == ["rush.evaluate"]
|
||||||
|
assert calls[0].params["productCode"] == PRODUCT_CODE
|
||||||
|
assert calls[0].params["quantity"] == 50
|
||||||
|
assert calls[0].params["deliveryDate"] == "2026-10-14"
|
||||||
|
assert "必须先调用 `rush.evaluate`" in captured["task"]
|
||||||
|
assert reply.text.startswith("插单影响评估完成")
|
||||||
|
assert "直接改计划就行" not in reply.text
|
||||||
|
assert "现有计划没有改动" in reply.text
|
||||||
|
assert {block.props.get("kind") for block in reply.blocks} >= {"rush-impact"}
|
||||||
|
|
||||||
|
actual = wf._LAST_RUSH_EVAL
|
||||||
|
assert actual["rushOrder"]["productCode"] == PRODUCT_CODE
|
||||||
|
assert actual["rushOrder"]["quantity"] == 50
|
||||||
|
assert actual["rushOrder"]["deliveryDate"] == "2026-10-14"
|
||||||
|
assert actual["affectedOrderCount"] == len(actual["affectedOrders"])
|
||||||
|
assert actual["lns"]["status"] in {"LOCAL", "ESCALATE"}
|
||||||
|
_assert_planner_language(reply.text)
|
||||||
|
wf._LAST_RUSH_EVAL.clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_planner_rush_missing_quantity_asks_instead_of_guessing(tmp_path, monkeypatch):
|
||||||
|
"""缺少数量或交期时,Pi 先追问,不拿默认值代替计划员的真实要求。"""
|
||||||
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
||||||
|
store = FakeStore(_planner_world(monkeypatch))
|
||||||
|
calls = _install_real_tool_runtime(monkeypatch)
|
||||||
|
captured: dict = {}
|
||||||
|
question = "请补充这批 A0050101-00280 外壳的交货数量和最晚交货日期,我再按真实产能评估。"
|
||||||
|
|
||||||
|
reply = await fallback_lane.propose_reply(
|
||||||
|
store,
|
||||||
|
"s-planner-rush-missing",
|
||||||
|
_primary_intent("客户临时要加一批 A0050101-00280 外壳,先看能不能插进去。"),
|
||||||
|
runner=_pi_runner(captured, None, {}, question),
|
||||||
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
assert "交货数量" in reply.text and "最晚交货日期" in reply.text
|
||||||
|
assert "缺少“已有订单号,或产品编码/名称 + 数量 + 交期”时先向用户追问" in captured["task"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_planner_rush_unknown_order_returns_engine_rejection(tmp_path, monkeypatch):
|
||||||
|
"""订单号不存在时,Pi 不能改口说可以插,必须转达引擎的真实拒绝原因。"""
|
||||||
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
||||||
|
store = FakeStore(_planner_world(monkeypatch))
|
||||||
|
calls = _install_real_tool_runtime(monkeypatch)
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
reply = await fallback_lane.propose_reply(
|
||||||
|
store,
|
||||||
|
"s-planner-rush-unknown",
|
||||||
|
_primary_intent("把 SO-NOT-EXIST 加急,10月14日交,直接告诉我能不能插。"),
|
||||||
|
runner=_pi_runner(
|
||||||
|
captured,
|
||||||
|
"rush.evaluate",
|
||||||
|
{"orderNo": "SO-NOT-EXIST"},
|
||||||
|
"这张单可以加急,我已经安排进去了。",
|
||||||
|
),
|
||||||
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [call.intent for call in calls] == ["rush.evaluate"]
|
||||||
|
assert reply.text == "订单 SO-NOT-EXIST 不存在"
|
||||||
|
assert "已经安排进去" not in reply.text
|
||||||
|
wf._LAST_RUSH_EVAL.clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_planner_rush_apply_waits_for_confirmation(tmp_path, monkeypatch):
|
||||||
|
"""“采用刚才方案”只生成确认事项;确认前订单和排产版本都不变。"""
|
||||||
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
||||||
|
store = FakeStore(_planner_world(monkeypatch))
|
||||||
|
payload = {
|
||||||
|
"customerName": "湖南工业控制有限公司",
|
||||||
|
"customerLevel": "A",
|
||||||
|
"productCode": PRODUCT_CODE,
|
||||||
|
"quantity": 50,
|
||||||
|
"deliveryDate": "2026-10-14",
|
||||||
|
"priority": 1,
|
||||||
|
"strategy": "DELIVERY_FIRST",
|
||||||
|
}
|
||||||
|
await wf.handle_intent(
|
||||||
|
store,
|
||||||
|
"s-planner-rush-apply",
|
||||||
|
IntentResult(intent="rush.evaluate", params=payload, confidence=1.0, source="LLM"),
|
||||||
|
"planner",
|
||||||
|
)
|
||||||
|
before_orders = len(store.data["salesOrders"])
|
||||||
|
before_versions = len(store.data["scheduleVersions"])
|
||||||
|
calls = _install_real_tool_runtime(monkeypatch)
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
reply = await fallback_lane.propose_reply(
|
||||||
|
store,
|
||||||
|
"s-planner-rush-apply",
|
||||||
|
_primary_intent("采用刚才的插单方案,但先给我确认。"),
|
||||||
|
runner=_pi_runner(
|
||||||
|
captured,
|
||||||
|
"rush.apply",
|
||||||
|
{},
|
||||||
|
"已经采用,订单也写好并发布了。",
|
||||||
|
),
|
||||||
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [call.intent for call in calls] == ["rush.apply"]
|
||||||
|
assert calls[0].params == {}
|
||||||
|
assert len(store.data["salesOrders"]) == before_orders
|
||||||
|
assert len(store.data["scheduleVersions"]) == before_versions
|
||||||
|
assert "已经写好并发布" not in reply.text
|
||||||
|
cards = [block for block in reply.blocks if block.type == "confirm-card"]
|
||||||
|
assert len(cards) == 1
|
||||||
|
_assert_planner_language(reply.text, cards[0].props.get("summary") or [])
|
||||||
|
|
||||||
|
message = wf.execute_confirmed(
|
||||||
|
store,
|
||||||
|
str(cards[0].props["confirmId"]),
|
||||||
|
approve=True,
|
||||||
|
actor="planner",
|
||||||
|
)
|
||||||
|
assert len(store.data["salesOrders"]) == before_orders + 1
|
||||||
|
assert len(store.data["scheduleVersions"]) == before_versions + 1
|
||||||
|
assert store.data["scheduleVersions"][-1]["status"] == "DRAFT"
|
||||||
|
_assert_planner_language(message)
|
||||||
|
wf._LAST_RUSH_EVAL.clear()
|
||||||
|
|
@ -52,7 +52,7 @@ def _scheduled_world():
|
||||||
|
|
||||||
def _small_rush_payload():
|
def _small_rush_payload():
|
||||||
return {
|
return {
|
||||||
"customerName": "LNS客户",
|
"customerName": "华东装备客户",
|
||||||
"customerLevel": "VIP",
|
"customerLevel": "VIP",
|
||||||
"productId": 1,
|
"productId": 1,
|
||||||
"quantity": 100,
|
"quantity": 100,
|
||||||
|
|
@ -104,7 +104,7 @@ def test_lns_escalate_when_move_count_exceeds_threshold():
|
||||||
"""超过影响阈值:受影响订单数 / 扰动率超限 → 升级全量重排信号。"""
|
"""超过影响阈值:受影响订单数 / 扰动率超限 → 升级全量重排信号。"""
|
||||||
world = _scheduled_world()
|
world = _scheduled_world()
|
||||||
payload = {
|
payload = {
|
||||||
"customerName": "LNS客户",
|
"customerName": "华东装备客户",
|
||||||
"customerLevel": "VIP",
|
"customerLevel": "VIP",
|
||||||
"productId": 1,
|
"productId": 1,
|
||||||
"quantity": 200,
|
"quantity": 200,
|
||||||
|
|
@ -126,7 +126,7 @@ def test_lns_escalate_when_duration_exceeds_window():
|
||||||
"""急单工时超过窗口宽度 → 窗口内不可行,升级全量重排。"""
|
"""急单工时超过窗口宽度 → 窗口内不可行,升级全量重排。"""
|
||||||
world = _scheduled_world()
|
world = _scheduled_world()
|
||||||
lns = lns_local_repair(world, {
|
lns = lns_local_repair(world, {
|
||||||
"customerName": "LNS客户",
|
"customerName": "华东装备客户",
|
||||||
"customerLevel": "VIP",
|
"customerLevel": "VIP",
|
||||||
"productId": 1,
|
"productId": 1,
|
||||||
"quantity": 5000,
|
"quantity": 5000,
|
||||||
|
|
@ -204,7 +204,7 @@ def test_lns_existing_order_apply_moves_po_to_plan_slot():
|
||||||
assert lns["plan"]["rushOrder"]["isNew"] is False
|
assert lns["plan"]["rushOrder"]["isNew"] is False
|
||||||
|
|
||||||
baseline_versions = len(world["scheduleVersions"])
|
baseline_versions = len(world["scheduleVersions"])
|
||||||
applied = apply_lns_local(world, _next_id_factory(world), {
|
apply_lns_local(world, _next_id_factory(world), {
|
||||||
"payload": lns["payload"],
|
"payload": lns["payload"],
|
||||||
"lns": lns,
|
"lns": lns,
|
||||||
"strategy": lns["strategy"],
|
"strategy": lns["strategy"],
|
||||||
|
|
@ -222,7 +222,7 @@ def test_lns_escalation_falls_back_to_full_reschedule_compat():
|
||||||
"""升级场景 → 复用 apply_rush 全量重排(走 P2 确认后执行),生成 DRAFT 版本。"""
|
"""升级场景 → 复用 apply_rush 全量重排(走 P2 确认后执行),生成 DRAFT 版本。"""
|
||||||
world = _scheduled_world()
|
world = _scheduled_world()
|
||||||
lns = lns_local_repair(world, {
|
lns = lns_local_repair(world, {
|
||||||
"customerName": "LNS客户",
|
"customerName": "华东装备客户",
|
||||||
"customerLevel": "VIP",
|
"customerLevel": "VIP",
|
||||||
"productId": 1,
|
"productId": 1,
|
||||||
"quantity": 5000,
|
"quantity": 5000,
|
||||||
|
|
@ -292,12 +292,16 @@ def _run(intent_name, store, params):
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_rush_evaluate_attaches_lns_block():
|
def test_workflow_rush_evaluate_attaches_lns_block():
|
||||||
"""rush.evaluate 接线:回复附带 LNS 摘要与 lns-repair 块。"""
|
"""rush.evaluate 接线:回复附带计划员可读的插单安排摘要与结果块。"""
|
||||||
wf._LAST_RUSH_EVAL.clear()
|
wf._LAST_RUSH_EVAL.clear()
|
||||||
store = FakeStore()
|
store = FakeStore()
|
||||||
get_engine("RULE").solve(store.data, _engine_params(store.data, "DELIVERY_FIRST"), store.next_id)
|
get_engine("RULE").solve(store.data, _engine_params(store.data, "DELIVERY_FIRST"), store.next_id)
|
||||||
reply = _run("rush.evaluate", store, _small_rush_payload())
|
reply = _run("rush.evaluate", store, _small_rush_payload())
|
||||||
assert "LNS" in reply.text
|
assert "插单安排评估完成" in reply.text
|
||||||
|
assert "本次只做试算,现有计划没有改动" in reply.text
|
||||||
|
assert "LNS" not in reply.text
|
||||||
|
assert "沙盒" not in reply.text
|
||||||
|
assert "DRAFT" not in reply.text
|
||||||
kinds = [b.props.get("kind") for b in reply.blocks]
|
kinds = [b.props.get("kind") for b in reply.blocks]
|
||||||
assert "rush-impact" in kinds
|
assert "rush-impact" in kinds
|
||||||
assert "lns-repair" in kinds
|
assert "lns-repair" in kinds
|
||||||
|
|
@ -317,11 +321,15 @@ def test_workflow_rush_apply_local_routes_to_lns_draft():
|
||||||
confirm_blocks = [b for b in staged.blocks if b.type == "confirm-card"]
|
confirm_blocks = [b for b in staged.blocks if b.type == "confirm-card"]
|
||||||
assert confirm_blocks, "rush.apply 必须出 P2 确认卡"
|
assert confirm_blocks, "rush.apply 必须出 P2 确认卡"
|
||||||
confirm_id = confirm_blocks[0].props["confirmId"]
|
confirm_id = confirm_blocks[0].props["confirmId"]
|
||||||
assert any("LNS" in line for line in (confirm_blocks[0].props.get("summary") or []))
|
assert any("局部调整" in line for line in (confirm_blocks[0].props.get("summary") or []))
|
||||||
|
assert not any("LNS" in line or "DRAFT" in line or "P2" in line
|
||||||
|
for line in (confirm_blocks[0].props.get("summary") or []))
|
||||||
|
|
||||||
baseline_versions = len(store.data["scheduleVersions"])
|
baseline_versions = len(store.data["scheduleVersions"])
|
||||||
msg = wf.execute_confirmed(store, confirm_id, approve=True, actor="tester")
|
msg = wf.execute_confirmed(store, confirm_id, approve=True, actor="tester")
|
||||||
assert "LNS 局部插单已采用" in msg
|
assert "插单方案已采用" in msg
|
||||||
|
assert "LNS" not in msg
|
||||||
|
assert "DRAFT" not in msg
|
||||||
assert len(store.data["scheduleVersions"]) == baseline_versions + 1
|
assert len(store.data["scheduleVersions"]) == baseline_versions + 1
|
||||||
assert store.data["scheduleVersions"][-1]["triggerType"] == "LNS_RUSH"
|
assert store.data["scheduleVersions"][-1]["triggerType"] == "LNS_RUSH"
|
||||||
assert store.data["scheduleVersions"][-1]["status"] == "DRAFT"
|
assert store.data["scheduleVersions"][-1]["status"] == "DRAFT"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ from server.aps_domain.sop_rules import (
|
||||||
compile_sop_by_asset,
|
compile_sop_by_asset,
|
||||||
compile_sop_by_query,
|
compile_sop_by_query,
|
||||||
compile_sop_asset,
|
compile_sop_asset,
|
||||||
|
confirmation_for_sop_apply,
|
||||||
|
sop_as_block,
|
||||||
)
|
)
|
||||||
from server.knowledge import get_knowledge
|
from server.knowledge import get_knowledge
|
||||||
from server.state.seed import seed_world
|
from server.state.seed import seed_world
|
||||||
|
|
@ -26,6 +28,19 @@ def test_compile_changeover_sop():
|
||||||
cross = next(e for e in pack["effects"] if e["op"] == "changeover.policy")
|
cross = next(e for e in pack["effects"] if e["op"] == "changeover.policy")
|
||||||
assert cross["defaultCrossFamilyMin"] == 60
|
assert cross["defaultCrossFamilyMin"] == 60
|
||||||
assert cross.get("noChangeoverAfter") == "16:00"
|
assert cross.get("noChangeoverAfter") == "16:00"
|
||||||
|
assert "换型" in pack["plannerSummary"]
|
||||||
|
assert "C10_changeover" not in pack["plannerSummary"]
|
||||||
|
assert "soft" not in pack["plannerSummary"]
|
||||||
|
title, lines = confirmation_for_sop_apply(pack)
|
||||||
|
confirmation_text = "\n".join(lines)
|
||||||
|
assert "应用作业标准" in title
|
||||||
|
assert "C10_changeover" not in confirmation_text
|
||||||
|
assert "changeover.policy" not in confirmation_text
|
||||||
|
assert "P2" not in confirmation_text
|
||||||
|
markdown = sop_as_block(pack).props["markdown"]
|
||||||
|
assert "作业标准规则" in sop_as_block(pack).props["title"]
|
||||||
|
assert "changeover.policy" not in markdown
|
||||||
|
assert "C13_sop" not in markdown
|
||||||
|
|
||||||
|
|
||||||
def test_compile_by_asset_and_apply():
|
def test_compile_by_asset_and_apply():
|
||||||
|
|
@ -54,6 +69,8 @@ def test_compile_kit_sop():
|
||||||
assert compiled["ok"]
|
assert compiled["ok"]
|
||||||
ids = [e.get("id") for e in compiled["pack"]["effects"] if e.get("op") == "constraint.set"]
|
ids = [e.get("id") for e in compiled["pack"]["effects"] if e.get("op") == "constraint.set"]
|
||||||
assert "C6_material_kit" in ids
|
assert "C6_material_kit" in ids
|
||||||
|
assert "物料不齐套" in compiled["pack"]["plannerSummary"]
|
||||||
|
assert "C6_material_kit" not in compiled["pack"]["plannerSummary"]
|
||||||
|
|
||||||
|
|
||||||
def test_query_shell_requires_explicit_asset_id():
|
def test_query_shell_requires_explicit_asset_id():
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ def test_sop_compile_requires_explicit_selection(tmp_path: Path):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "结构化参数" in reply.text
|
assert "请先选择要查看的作业标准" in reply.text
|
||||||
assert not any(block.type == "confirm-card" for block in reply.blocks)
|
assert not any(block.type == "confirm-card" for block in reply.blocks)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue