Merge remote-tracking branch 'origin/main' into codex/integrate-optimize

This commit is contained in:
ssk 2026-09-16 15:30:39 +08:00
commit cc151c6fc3
12 changed files with 582 additions and 26 deletions

View File

@ -0,0 +1,118 @@
import { expect, test, type Page } from '@playwright/test';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
// 柔性工作台导出下载的浏览器验收:先用真实接口把「导入 → 采用 → 排产」铺好,
// 再验证「订单方案 / 设备方案」经由鉴权下载通道拿到真实 xlsx。这条路径以前用
// href 直开 /api 链接,浏览器带不上鉴权头只会拿到 401 页面,因此这里断言真实
// 下载事件、200 响应与字节一致,不依赖模型网关。
const SOURCE = process.env.ROUND87_SOURCE;
const REQUIRED = process.env.ROUND87_REQUIRED === '1';
const API_TARGET = process.env.E2E_API_TARGET || 'http://127.0.0.1:18787';
const apiUrl = new URL(API_TARGET);
if (!['127.0.0.1', 'localhost', '[::1]'].includes(apiUrl.hostname)
|| apiUrl.protocol !== 'http:' || !apiUrl.port
|| ['8000', '8003', '5173'].includes(apiUrl.port)) {
throw new Error('Flex export download acceptance requires an isolated loopback backend.');
}
const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const WORKBOOK_NAME = '湖南锐扬APS精简演示数据.xlsx';
async function seedScheduledVersion(page: Page): Promise<string> {
const login = await page.request.post('/api/auth/login', {
data: { tenantName: 'flex-export-download', username: 'planner', password: 'test' },
});
expect(login.ok(), await login.text()).toBeTruthy();
const created = await page.request.post('/api/projects', { data: { name: `导出下载-${Date.now()}` } });
expect(created.ok(), await created.text()).toBeTruthy();
const workspace = await (await page.request.get('/api/workspace')).json();
const sessionId = String(workspace.activeSessionId);
const previewResponse = await page.request.post('/api/import/preview', {
multipart: { file: { name: WORKBOOK_NAME, mimeType: XLSX_MIME, buffer: readFileSync(SOURCE!) } },
});
expect(previewResponse.ok(), await previewResponse.text()).toBeTruthy();
const preview = await previewResponse.json();
expect(preview.totalOk).toBeGreaterThan(0);
const commit = await page.request.post('/api/import/commit', {
data: { filename: WORKBOOK_NAME, sessionId, batches: preview.batches },
});
expect(commit.ok(), await commit.text()).toBeTruthy();
const confirmId = (await commit.json()).block?.props?.confirmId;
expect(confirmId, 'import commit must stage a P2 confirmation card').toBeTruthy();
const confirm = await page.request.post('/api/actions/confirm', {
data: { confirmId, approve: true, sessionId },
});
expect(confirm.ok(), await confirm.text()).toBeTruthy();
const schedule = await page.request.post('/api/flex/schedule', { data: { sessionId } });
expect(schedule.ok(), await schedule.text()).toBeTruthy();
const scheduleBody = await schedule.json();
const versionId = scheduleBody.result?.versionId;
expect(versionId, JSON.stringify(scheduleBody).slice(0, 400)).toBeTruthy();
return String(versionId);
}
test('flex workbench downloads order and equipment exports with authentication', async ({ page }) => {
test.skip(!SOURCE && !REQUIRED, 'Set ROUND87_SOURCE to the explicitly authorized original workbook.');
test.setTimeout(300_000);
page.setDefaultTimeout(25_000);
const health = await page.request.get(`${API_TARGET}/__e2e__/health`);
expect(health.ok(), 'start tests/e2e/round87_masterdata_server.py first').toBeTruthy();
expect(await health.json()).toMatchObject({ isolated: true, sourceUnchanged: true, productionCode: true });
const versionId = await seedScheduledVersion(page);
const errors: string[] = [];
page.on('pageerror', error => errors.push(`pageerror: ${error.message}`));
page.on('console', message => {
if (message.type() === 'error') errors.push(`console: ${message.text()}`);
});
// 导出必须走应用内的鉴权 fetch(会带上 X-APS-Visitor-ID 等请求头),
// 而不是浏览器直接导航到 /api 链接——后者在桌面/头鉴权部署里只会拿到 401。
const exportRequestHeaders = new Map<string, Record<string, string>>();
page.on('request', request => {
const path = new URL(request.url()).pathname;
if (path === '/api/reports/schedule-order' || path === '/api/reports/schedule-equipment') {
exportRequestHeaders.set(path, request.headers());
}
});
await page.goto('/');
await expect(page.locator('.composer textarea')).toBeVisible({ timeout: 60_000 });
await page.locator('.right-rail:not(.guest-right-rail) button[data-tip="柔性工作台"]').click();
const panel = page.locator('.flex-bench');
await expect(panel).toBeVisible();
await expect(panel).toContainText('导出文件显式绑定当前版本');
const hash = (value: Buffer) => createHash('sha256').update(value).digest('hex');
const cases = [
['订单方案', 'schedule-order'],
['设备方案', 'schedule-equipment'],
] as const;
for (const [label, reportType] of cases) {
// antd 图标会进入可访问名称,这里按文本匹配避免版本差异。
const button = panel.getByRole('button', { name: new RegExp(label) }).last();
await button.scrollIntoViewIfNeeded();
const responsePromise = page.waitForResponse(response =>
new URL(response.url()).pathname === `/api/reports/${reportType}`
&& response.request().method() === 'GET');
const downloadPromise = page.waitForEvent('download');
await button.click();
const [response, download] = await Promise.all([responsePromise, downloadPromise]);
expect(response.status(), `${label} must not fall back to an unauthenticated 401 page`).toBe(200);
const requestHeaders = exportRequestHeaders.get(`/api/reports/${reportType}`) || {};
expect(requestHeaders['x-aps-visitor-id'], `${label} must download through the authenticated fetch path`)
.toBeTruthy();
expect(download.suggestedFilename()).toMatch(/\.xlsx$/i);
const downloadPath = await download.path();
expect(downloadPath).toBeTruthy();
const browserBytes = readFileSync(downloadPath!);
expect(browserBytes.subarray(0, 2).toString()).toBe('PK');
// 走前端同源代理比对字节,避免不同端口上的直连请求丢失登录态。
const apiResponse = await page.request.get(
`/api/reports/${reportType}?versionId=${encodeURIComponent(versionId)}`);
expect(apiResponse.ok(), await apiResponse.text()).toBeTruthy();
expect(hash(browserBytes)).toBe(hash(await apiResponse.body()));
await expect(panel.locator('.ant-alert')).toContainText('已下载');
}
expect(errors, 'browser errors collected during flex export download').toEqual([]);
});

View File

@ -274,7 +274,8 @@ test.describe('new project navigation preserves immediate attachments', () => {
expect((await analysis).postDataJSON().sessionId).toBe(nextSession);
const card = newChat.locator('.planning-data-card').last();
await expect(card).toContainText('排产资料核对', { timeout: 90_000 });
await expect(card.getByRole('button', { name: '查看产品和材料明细' }).locator('strong')).toHaveText(String(EXPECTED.entityCounts.materials));
// 真实 Pi 路径用服务端口径「产品和物料」,纯前端兜底文案是「产品和材料」;两者都代表同一张卡。
await expect(card.getByRole('button', { name: /查看产品和(物料|材料)明细/ }).locator('strong')).toHaveText(String(EXPECTED.entityCounts.materials));
await expect(card.getByRole('button', { name: '开始排产', exact: true })).toBeVisible();
expect(createHash('sha256').update(readFileSync(SOURCE!)).digest('hex')).toBe(EXPECTED.sourceSha256);
} finally {

View File

@ -0,0 +1,91 @@
import { expect, test, type Page } from '@playwright/test';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
// 排产导出模板下载的浏览器验收:只在显式指定授权工作簿时运行,指向隔离后端。
const SOURCE = process.env.ROUND87_SOURCE;
const REQUIRED = process.env.ROUND87_REQUIRED === '1';
const API_TARGET = process.env.E2E_API_TARGET || 'http://127.0.0.1:18787';
const apiUrl = new URL(API_TARGET);
if (!['127.0.0.1', 'localhost', '[::1]'].includes(apiUrl.hostname)
|| apiUrl.protocol !== 'http:' || !apiUrl.port
|| ['8000', '8003', '5173'].includes(apiUrl.port)) {
throw new Error('Schedule template download acceptance requires an isolated loopback backend.');
}
async function checkIsolation(page: Page): Promise<void> {
const direct = await page.request.get(`${API_TARGET}/__e2e__/health`);
expect(direct.ok(), 'start tests/e2e/round87_masterdata_server.py first').toBeTruthy();
expect(await direct.json()).toMatchObject({ isolated: true, sourceUnchanged: true, productionCode: true });
const proxied = await page.request.get('/api/__e2e__/health');
expect(proxied.ok(), 'frontend proxy must target the isolated Round 87 host').toBeTruthy();
}
async function createProject(page: Page): Promise<void> {
await page.locator('.workspace-sidebar:not(.guest-sidebar) button[title="新建项目"]').click();
const dialog = page.getByRole('dialog', { name: '新建项目', exact: true });
await expect(dialog).toBeVisible();
await dialog.locator('input').first().fill(`排产模板下载-${Date.now()}`);
const created = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/projects' && response.request().method() === 'POST');
await dialog.getByRole('button', { name: '创建', exact: true }).click();
expect((await created).ok()).toBeTruthy();
await expect(dialog).toBeHidden();
await expect(page.locator('.composer textarea')).toBeVisible();
}
test('flex workbench downloads the contract-driven schedule template', async ({ page }) => {
test.skip(!SOURCE && !REQUIRED, 'Set ROUND87_SOURCE to the explicitly authorized original workbook.');
test.setTimeout(300_000);
page.setDefaultTimeout(25_000);
await checkIsolation(page);
const login = await page.request.post('/api/auth/login', {
data: { tenantName: 'schedule-template-download', username: 'planner', password: 'test' },
});
expect(login.ok(), await login.text()).toBeTruthy();
const errors: string[] = [];
page.on('pageerror', error => errors.push(`pageerror: ${error.message}`));
page.on('console', message => {
if (message.type() === 'error') errors.push(`console: ${message.text()}`);
});
page.on('response', response => {
if (response.status() >= 500 && new URL(response.url()).pathname.startsWith('/api/')) {
errors.push(`HTTP ${response.status()}: ${response.url()}`);
}
});
await page.goto('/');
await expect(page.locator('.composer textarea')).toBeVisible({ timeout: 60_000 });
await createProject(page);
await page.locator('.right-rail:not(.guest-right-rail) button[data-tip="柔性工作台"]').click();
const panel = page.locator('.flex-bench');
await expect(panel).toBeVisible();
// antd 图标会进入可访问名称(download 排产模板),这里按文本匹配避免版本差异。
const button = panel.getByRole('button', { name: /排产模板/ });
await button.scrollIntoViewIfNeeded();
const [download, response] = await Promise.all([
page.waitForEvent('download'),
page.waitForResponse(actual =>
new URL(actual.url()).pathname === '/api/reports/schedule-template'),
button.click(),
]);
expect(response.status()).toBe(200);
expect(response.headers()['x-aps-template-id']).toBe('schedule-plan.v1');
expect(response.headers()['x-aps-contract-digest']).toMatch(/^[0-9a-f]{64}$/);
expect(download.suggestedFilename()).toMatch(/\.xlsx$/i);
const path = await download.path();
expect(path).toBeTruthy();
const browserBytes = readFileSync(path!);
expect(browserBytes.subarray(0, 2).toString()).toBe('PK');
expect(browserBytes.length).toBeGreaterThan(4096);
// 模板不含业务行:UI 下载必须与鉴权接口逐字节一致,且同一合同重复下载稳定。
const apiTemplate = await page.request.get('/api/reports/schedule-template');
expect(apiTemplate.ok(), await apiTemplate.text()).toBeTruthy();
const hash = (value: Buffer) => createHash('sha256').update(value).digest('hex');
expect(hash(browserBytes)).toBe(hash(await apiTemplate.body()));
await expect(panel.locator('.ant-alert')).toContainText('已下载');
expect(errors, 'browser errors collected during schedule template download').toEqual([]);
});

View File

@ -22,6 +22,7 @@ import {
postSapInboundStage, postSapOutboundStage,
} from '../api/client';
import { WorldModeBadge } from '../viewport/WorldModeBadge'; // 矩阵 55:Explore 沙盒徽标
import { downloadAuthenticatedFile } from '../api/download';
import { AttributionPanel } from './AttributionPanel';
type Tab = 'schedule' | 'due' | 'exception' | 'resource' | 'conflicts' | 'sap' | 'mes' | 'peg';
@ -73,6 +74,7 @@ export default function FlexPanel(props: {
const [world, setWorld] = useState<FlexWorldData | null>(null);
const [cap, setCap] = useState<FlexCapacityData | null>(null);
const [busy, setBusy] = useState(false);
const [downloading, setDownloading] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [pending, setPending] = useState<UIBlock | null>(null);
const [compare, setCompare] = useState<{ rows: CompareRow[]; hint: string } | null>(null);
@ -167,6 +169,20 @@ export default function FlexPanel(props: {
finally { setBusy(false); }
};
// 导出统一走带鉴权头的下载通道,避免浏览器直接打开 /api 链接拿到 401 页面。
const downloadWorkbook = async (url: string, fallbackName: string) => {
setDownloading(url);
setMessage(null);
try {
const filename = await downloadAuthenticatedFile(url, fallbackName);
setMessage(`已下载${filename}`);
} catch (e) {
setMessage(e instanceof Error ? e.message : '导出失败');
} finally {
setDownloading(null);
}
};
const decide = async (approve: boolean) => {
const confirmId = String(pending?.props.confirmId ?? '');
if (!confirmId) return;
@ -447,6 +463,11 @@ export default function FlexPanel(props: {
setCompare(res);
setMessage('五模式对比完成(沙盒,未改主干)');
})}>五模式对比</Button>
<Button size="small" icon={<DownloadOutlined />} disabled={busy || downloading !== null}
onClick={() => void downloadWorkbook(
'/api/reports/schedule-template', '排产工作计划表模板.xlsx')}>
{downloading === '/api/reports/schedule-template' ? '下载中…' : '排产模板'}
</Button>
</div>
</div>
@ -458,13 +479,17 @@ export default function FlexPanel(props: {
{latestVersion.sortMode ? ` · ${latestVersion.sortMode}` : ''},导出文件显式绑定当前版本。
</p>
<div className="flex-action-btns">
<Button icon={<DownloadOutlined />}
href={`/api/reports/schedule-order?versionId=${encodeURIComponent(String(latestVersion.id))}`}>
订单方案
<Button icon={<DownloadOutlined />} disabled={downloading !== null}
onClick={() => void downloadWorkbook(
`/api/reports/schedule-order?versionId=${encodeURIComponent(String(latestVersion.id))}`,
'排产工单方案.xlsx')}>
{downloading?.startsWith('/api/reports/schedule-order') ? '下载中…' : '订单方案'}
</Button>
<Button icon={<DownloadOutlined />}
href={`/api/reports/schedule-equipment?versionId=${encodeURIComponent(String(latestVersion.id))}`}>
设备方案
<Button icon={<DownloadOutlined />} disabled={downloading !== null}
onClick={() => void downloadWorkbook(
`/api/reports/schedule-equipment?versionId=${encodeURIComponent(String(latestVersion.id))}`,
'排产设备方案.xlsx')}>
{downloading?.startsWith('/api/reports/schedule-equipment') ? '下载中…' : '设备方案'}
</Button>
</div>
</div>

View File

@ -7,8 +7,15 @@
- **主对话排产合同**:`render_plan_task_brief` 把「立即排产/开始排产/试排」这类明确要求固定为「先 `readiness.query` 复查,资料已采用时必须调用 `flex.schedule` 出草稿试排」;只给齐备度结论、补齐建议或「暂不建议排产」记为错误回答;真实阻断项必须写进试排假设与未安排说明,不得改写成「资料未通过检查,不能排产」。合同由 `test_primary_brief_pins_explicit_scheduling_contract` 锁定。
- **失败可诊断与超时预算**:闸 1 默认 90s → **180s**(`APS_FALLBACK_TIMEOUT_SEC` 仍可覆盖,`.env.example` 增补 `APS_FALLBACK_*` 说明)——真实 LLM 多步工具调用实测 45~80s,90s 会误杀排产并让计划员看到「本次处理未完成」;模型网关账号类失败(欠费/限流/鉴权,401/402/403/429)改报「智能助手服务暂不可用」,与 APS 自身失败话术分开,5xx 保持 `harness_error` 原语义。
- **采集模板条件请求**:`GET /api/import/template` 按 `If-None-Match` 返回 304,模板字节未变时不重传整份 Excel;模板与现场工作簿逐表逐列同构(Sheet 顺序 + 每张表物理表头)由 `test_intake_template_contract_matches_the_authorized_workbook` 校验。
- **排产模板定义可下载**:新增 `build_contract_template` 与 `GET /api/reports/schedule-template`,把 `schedule-plan.v1` / `schedule-order.v1` / `schedule-equipment.v1` / `schedule-blocked.v1` 直接导出成表头骨架(标题行、表头行、冻结窗格、筛选区域,无业务行),生成后仍由 `validate_workbook_contract` 复核,现场下载的模板与真正导出的文件共用同一份声明;响应带 `ETag` / `X-APS-Template-Id` / `X-APS-Contract-Digest`,支持 304,未注册类型 404。柔性工作台新增「排产模板」入口。
- **采集说明补全填写定义**:`数据采集说明` 新增「填写要求」列,逐字段写出允许取值(取自解析器同一份枚举配置,如「正式订单→FORMAL;沙盒插单→SANDBOX」)、日期格式与数值要求,现场回填不再靠猜;定义行由 `intake_definition_rows` 从配置生成,模板与解析器不得各写一套。
- **导出下载鉴权修复**:柔性工作台的「订单方案」「设备方案」原先用 `href` 直开 `/api` 链接,浏览器带不上鉴权头;现与采集模板一致改走 `downloadAuthenticatedFile`,下载失败时显示后端原因而不是 401 页面。
- **验证**:`test_schedule_template_contract.py` + `test_schedule_exports.py` + `test_workbook_profiles.py` + `test_fallback_lane.py` 共 **56 passed**;`tests/golden` 全量 **2382 passed / 53 skipped**(12m46s,带真实工作簿 `ROUND87_SOURCE`);`npm run test:node` **103 passed**;`npm run build --prefix apps/web` 通过(3185 modules);ruff 新增/修改文件 All checks passed。
- **真实工作簿闭环**:外部指定的参考工作簿(`湖南锐扬APS精简演示数据.xlsx`,路径只由调用方提供,不写入仓库)跑真实 Pi/LLM E2E **1 passed**(2 次 Pi 调用,源文件 SHA 未变,隔离临时数据目录):分析后由 Pi 触发试排,5 张正式订单排入 3 张、2 张阻断、15 个工单(10 个带假设),`solveStatus=PARTIAL_WITH_ASSUMPTIONS`;同文件独立验收脚本 PASS(导入计数、插单排除、在制与维保约束逐项核对)。缺人员技能、在制可信度与缺料问题如实进入阻断与假设项,未用默认值掩盖。 浏览器侧「分析并采用 → 立即排产 → 下载 Excel」桌面端与移动端各 **1 passed**(1.6m / 2.1m);同轮「新项目导航」用例失败于模型网关 **429 账号欠费**(`suspended due to insufficient balance`),属外部额度问题,需充值或更换 `LLM_API_KEY` 后复跑。
- **模板定义复核(同轮追加)**:排产模板与采集说明改为可校验定义后重跑——`test_schedule_template_contract.py`(含 4 份合同骨架、模板接口 304/404、采集说明取值定义)**16 passed**,`test_schedule_template_contract.py` + `test_schedule_exports.py` + `test_workbook_profiles.py` 共 **41 passed**,导入/主数据/合同相关 9 个文件 **85 passed / 2 skipped**,配置可移植性与文档漂移 **500 passed**;`tests/golden` 全量 **2386 passed / 53 skipped**(13m28s,带真实工作簿);`npm run test:node` **105 passed**(新增 `tests/node/schedule-template-download.test.mjs`);`npm run build --prefix apps/web` 通过;隔离后端(18787,真实工作簿)浏览器验收:采集模板下载 **1 passed**、新增的排产模板下载 **1 passed**(`schedule-template-download.spec.ts`)。ruff 对新增/修改模块 All checks passed,`server/gateway/app.py` 只余既有 I001,本轮新增行未新增告警。
- **真实工作簿闭环**:外部指定的参考工作簿(`湖南锐扬APS精简演示数据.xlsx`,路径只由调用方提供,不写入仓库)跑真实 Pi/LLM E2E **1 passed**(2 次 Pi 调用,源文件 SHA 未变,隔离临时数据目录):分析后由 Pi 触发试排,5 张正式订单排入 3 张、2 张阻断、15 个工单(10 个带假设),`solveStatus=PARTIAL_WITH_ASSUMPTIONS`;同文件独立验收脚本 PASS(导入计数、插单排除、在制与维保约束逐项核对)。缺人员技能、在制可信度与缺料问题如实进入阻断与假设项,未用默认值掩盖。 浏览器侧「分析并采用 → 立即排产 → 下载 Excel」桌面端与移动端各 **1 passed**(1.6m / 2.1m);同轮「新项目导航」用例失败于模型网关 **429 账号欠费**(`suspended due to insufficient balance`)。
- **真实 Pi 验收补跑(仅切换模型端点)**:工作区 `.env` 的 Moonshot key 仍欠费停用(429),本轮**不改动 `.env`**,只在调用方进程环境把 `LLM_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL` 指向本机另一把可用的 OpenAI 兼容端点(SiliconFlow `Pro/moonshotai/Kimi-K2.6`)复跑:真实 Pi CLI + 真实工作簿 E2E **1 passed**(2 次 Pi 调用、源 SHA 未变、`PARTIAL_WITH_ASSUMPTIONS`、3 张排入 / 2 张阻断 / 15 个工单、10 项试排假设);浏览器验收 `planning-schedule-entry.spec.ts` **3 passed**(桌面 2.6m、移动 2.1m、新项目导航 1.1m)——导航用例的明细按钮断言原写前端兜底文案「产品和材料」,真实 Pi 路径来自服务端口径「产品和物料」,已改为两者兼容;模板下载 `intake-template-download.spec.ts`、`schedule-template-download.spec.ts` 各 **1 passed**。默认 `.env` 是否切到该端点需交付方确认,本轮未替用户改配置。
- **导出下载回归用例**:新增 `apps/web/e2e/flex-export-download.spec.ts`——用真实接口铺「导入 → 采用 → 排产」,再点柔性工作台「订单方案 / 设备方案」,断言下载事件、HTTP 200、`PK` 文件头与接口字节逐字节一致,并校验请求带应用鉴权 fetch 的 `X-APS-Visitor-ID` 头(旧的 `href` 直开导航给不出该头,桌面/头鉴权部署只会拿到 401)。隔离后端 **1 passed(1.9s)**,`intake-template-download.spec.ts`、`schedule-template-download.spec.ts` 各 **1 passed**。
- **意图识别确已下线**:`/api/chat` 把每条自然语言统一包成 `assistant.reply` + `_piPrimary`,`handle_intent` 该分支只投递 Pi 结构化请求、不再做本地话术/关键词兜底(Pi 不可用时返回显式「智能助手服务暂不可用」)。锁此行为的 `tests/golden/test_pi_primary_chat.py -k routes` **2 passed**(「分析一下数据文件」「根据这些数据排产」两条真实说法都直达 Pi)。
## 2026-09-04 — Pi Agent 外向接入 P0/P1 真实接通(/api/agent/* + Agent Token + Pi E2E)

View File

@ -4,7 +4,7 @@
> **口径**:字段能对上即可(Excel/CSV 皆可),编码自定但**全表一致**。
> **当前**:仓库已按本模板合成一版高压线束/PDU 演示数据(`server/state/seed.py` 的 `flex*` 键);你用真实数据替换同名字段即可切换。
> **权威来源(2026-09-14 起)**:导入物理表与列名以工作簿配置 `server/importers/profiles/workbooks/default-planning.json` 为准,本文的角色说明仅作业务解释。可直接下载与配置逐字一致的采集模板:`GET /api/import/template`(主数据页「下载采集模板」),模板第一张表 `数据采集说明` 会列出角色、工作表、字段顺序、必填与映射摘要。排产导出的工作表/表头/行号合同见 [主数据配置与数据来源约定](masterdata-configuration.md) 的「采集模板与排产导出模板」。
> **权威来源(2026-09-14 起)**:导入物理表与列名以工作簿配置 `server/importers/profiles/workbooks/default-planning.json` 为准,本文的角色说明仅作业务解释。可直接下载与配置逐字一致的采集模板:`GET /api/import/template`(主数据页「下载采集模板」),模板第一张表 `数据采集说明` 会列出角色、工作表、字段顺序、必填、类型、允许取值与映射摘要。排产导出模板可直接下载表头骨架:`GET /api/reports/schedule-template`(柔性工作台「排产模板」);工作表/表头/行号合同见 [主数据配置与数据来源约定](masterdata-configuration.md) 的「采集模板与排产导出模板」。
---

View File

@ -48,7 +48,7 @@ profile规范化JSON计算摘要,随预览、批次、确认、已采用来源
- `GET /api/import/template` 下载当前配置的模板并返回 `ETag`、`X-APS-Template-Id`、`X-APS-Profile-Id`、`X-APS-Profile-Digest`;未注册的 `profileId` 返回 404,不静默换用其他配置。带 `If-None-Match` 且摘要一致时返回 304,不重传整份 Excel。
- 模板与现场工作簿(`湖南锐扬APS精简演示数据.xlsx`)逐表逐列同构:Sheet 顺序与每张表的物理表头都由同一份 profile 校验,不一致即报缺陷,见 `tests/golden/test_schedule_template_contract.py::test_intake_template_contract_matches_the_authorized_workbook`。
- 不带 `profileId` 时使用当前配置目录的兼容默认配置(`compatibilityDefault`):唯一标记为默认的配置优先,目录里只注册一份配置时即用该配置,多份配置且默认标记缺失或重复时直接报错,不按目录顺序猜测客户格式。
- 模板第一张表 `数据采集说明` 声明角色、工作表、字段顺序、必填/可选、类型和映射摘要;其余工作表表头与导入解析器读取的物理列逐字一致,因此模板是可直接回填的机器可读合同。
- 模板第一张表 `数据采集说明` 逐字段声明角色、工作表、字段顺序、必填/可选、类型、**填写要求(允许取值与格式)** 和映射摘要;允许取值直接来自解析器使用的枚举配置(例如订单分类的「正式订单→FORMAL;沙盒插单→SANDBOX」),因此模板不会和导入逻辑各写一套取值。其余工作表表头与导入解析器读取的物理列逐字一致,模板是可直接回填的机器可读合同。
- 默认下载空白模板,不写任何业务行;只有显式传入样例世界时才生成示例行。生成字节是确定性的,同一配置与同一输入得到同一 SHA-256。
- 模板结构与 profile 的往返校验见 `validate_intake_template`:表头与配置不一致时直接报出缺陷,不靠人工核对。
@ -62,6 +62,7 @@ profile规范化JSON计算摘要,随预览、批次、确认、已采用来源
| `schedule-blocked.v1` | 无工单可排时的 `plan` | 阻断分析 | 4 | 5 |
- 每列声明 `key`、中文表头、类型、单位、必填情形和取值来源;`build_plan_report`、`build_schedule_order_export`、`build_schedule_equipment_export` 和阻断工作簿统一引用同一合同,生成后由 `validate_workbook_contract` 复核工作表、表头、行号、冻结窗格和筛选区域。
- 合同本身可下载为空白模板:`GET /api/reports/schedule-template`(柔性工作台「排产模板」按钮,默认 `schedule-plan.v1`,`reportType` 支持 `schedule-plan` / `schedule-order` / `schedule-equipment` / `schedule-blocked.v1`)。模板只有标题行、表头行、冻结窗格与筛选区域,不含业务行;生成后同样通过 `validate_workbook_contract`,因此现场拿到的模板与真正导出的文件是同一份声明。响应带 `ETag`、`X-APS-Template-Id`、`X-APS-Contract-Digest` 与 `X-APS-Template-Sheets`,`If-None-Match` 命中返回 304,未注册类型返回 404。
- 没有柔性排产版本时是显式空态:`xlsxBytes`/`filename` 为 `None`,不产出一份看似成功但没有业务数据的工作簿;有版本但没有可执行工单时产出 `schedule-blocked.v1` 阻断清单。
- 工作计划、工单、设备三个导出都做 ZIP 归一化,同一输入重复导出字节一致,便于交付与验收比对。
- 合同摘要与摘要值由 `contract_summary()` / `contract_digest()` 提供;模板合同由 `template_contract()` 提供,作为文档、诊断和测试的共同来源。

View File

@ -257,6 +257,103 @@ def contract_for_report_type(report_type: str) -> ReportContract | None:
return REPORT_CONTRACTS.get(key) if key else None
def build_contract_template(report_type: str) -> dict[str, Any]:
"""把合同本身导出成一份表头级空白工作簿,供现场核对导出格式。
模板只写标题、表头、冻结窗格与筛选区域,不含任何业务行;生成后立刻用
``validate_workbook_contract`` 复核,因此下载到的模板与真正导出的文件由
同一份声明驱动,不会出现模板和导出对不上的情况。
"""
import hashlib
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from server.aps_domain.reports import _deterministic_xlsx_bytes
from server.timeutil import today0
contract = contract_for_report_type(report_type) or REPORT_CONTRACTS.get(report_type)
if contract is None:
raise ValueError(f"未注册的排产模板:{report_type}")
thin = Border(
left=Side(style="thin", color=TEMPLATE_HEADER_STYLE["borderColor"]),
right=Side(style="thin", color=TEMPLATE_HEADER_STYLE["borderColor"]),
top=Side(style="thin", color=TEMPLATE_HEADER_STYLE["borderColor"]),
bottom=Side(style="thin", color=TEMPLATE_HEADER_STYLE["borderColor"]),
)
head_fill = PatternFill("solid", fgColor=TEMPLATE_HEADER_STYLE["fillColor"])
head_font = Font(
color=TEMPLATE_HEADER_STYLE["fontColor"], bold=True, size=TEMPLATE_HEADER_STYLE["fontSize"]
)
title_font = Font(
bold=True, size=OVERVIEW_TITLE_STYLE["fontSize"], color=OVERVIEW_TITLE_STYLE["fontColor"]
)
workbook = Workbook()
for index, spec in enumerate(contract.sheets.values()):
sheet = workbook.active if index == 0 else workbook.create_sheet()
sheet.title = spec.name
if spec.title_row and spec.title:
sheet.cell(spec.title_row, 1, spec.title).font = title_font
note_row = (spec.title_row or 0) + 1
if note_row and (not spec.header_row or note_row < spec.header_row):
sheet.cell(
note_row, 1,
f"模板 {contract.id} · 表头行 {spec.header_row or '—'} · "
f"数据起始行 {spec.data_start_row} · 表头与列顺序不可改动",
)
if spec.header_row:
for column_index, column in enumerate(spec.columns, 1):
cell = sheet.cell(spec.header_row, column_index, column.header)
cell.fill = head_fill
cell.font = head_font
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.border = thin
sheet.column_dimensions[get_column_letter(column_index)].width = min(
28, max(10, len(column.header) * 2 + 4)
)
if spec.freeze_panes:
sheet.freeze_panes = spec.freeze_panes
if spec.auto_filter:
sheet.auto_filter.ref = (
f"A{spec.header_row}:"
f"{get_column_letter(spec.last_column_index)}{spec.header_row}"
)
workbook.properties.created = workbook.properties.modified = today0()
buffer = BytesIO()
workbook.save(buffer)
payload = _deterministic_xlsx_bytes(buffer.getvalue())
defects = validate_workbook_contract(payload, contract)
if defects:
raise RuntimeError(f"排产模板不符合 {contract.id}:" + ";".join(defects))
return {
"templateId": contract.id,
"contractId": contract.id,
"contractDigest": contract_digest(),
"schemaVersion": contract.schema_version,
"reportType": contract.report_type,
"label": f"{contract.label}模板",
"primarySheet": contract.primary_sheet,
"sheets": [
{
"key": key,
"name": spec.name,
"headerRow": spec.header_row,
"dataStartRow": spec.data_start_row,
"headers": list(spec.headers),
}
for key, spec in contract.sheets.items()
],
"xlsxBytes": payload,
"filename": f"{contract.label}模板_{contract.id}.xlsx",
"format": "xlsx",
"sha256": hashlib.sha256(payload).hexdigest(),
}
def validate_workbook_contract(payload: bytes, contract: ReportContract) -> list[str]:
"""Verify a generated workbook against the declared contract.

View File

@ -5141,6 +5141,37 @@ def create_app() -> FastAPI:
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(fname)}"},
)
@app.get("/api/reports/schedule-template")
async def schedule_template_export(request: Request, reportType: str | None = None) -> Any:
"""下载排产导出模板:表头级空白工作簿,与真实导出共用同一份合同。"""
import hashlib
from urllib.parse import quote
from fastapi.responses import Response
from server.aps_domain.report_contracts import build_contract_template
try:
template = build_contract_template(reportType or "schedule-plan")
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
etag = '"' + hashlib.sha256(template["xlsxBytes"]).hexdigest()[:32] + '"'
headers = {
"ETag": etag,
"X-APS-Template-Id": template["templateId"],
"X-APS-Contract-Digest": template["contractDigest"],
"X-APS-Template-Sheets": quote(",".join(sheet["name"] for sheet in template["sheets"])),
}
cached = [tag.strip() for tag in (request.headers.get("if-none-match") or "").split(",")]
if etag in cached or "*" in cached:
return Response(status_code=304, headers=headers)
fname = quote(template["filename"])
return Response(
content=template["xlsxBytes"],
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={**headers, "Content-Disposition": f"attachment; filename*=UTF-8''{fname}"},
)
@app.get("/api/reports/{report_type}")
async def report_export(
report_type: str,

View File

@ -39,6 +39,74 @@ _PLACEHOLDERS = {
}
}
# 采集说明里声明的允许取值来自解析器使用的同一份枚举配置,模板与读取逻辑不允许各写一套。
_ENUM_FIELDS: dict[tuple[str, str], tuple[str, str]] = {
("factoryResources", "resourceKind"): ("resourceKind", "资源类型:"),
("factoryResources", "status"): ("resourceStatus", "资源状态:"),
("equipment", "status"): ("equipmentStatus", "设备状态:"),
("calendar", "statusOrReason"): ("enabled", "班次状态:"),
("products", "type"): ("materialType", "物料类型:"),
("products", "sourcingType"): ("sourcingType", "供应方式:"),
("materials", "type"): ("materialType", "物料类型:"),
("materials", "sourcingType"): ("sourcingType", "供应方式:"),
("bom", "isKey"): ("yesNo", "是否关键:"),
("routing", "isExternal"): ("yesNo", "是否外协:"),
("partners", "partnerType"): ("partnerType", "伙伴类型:"),
("orders", "orderType"): ("orderType", "订单分类:"),
("orders", "status"): ("orderStatus", "订单状态:"),
("wip", "status"): ("wipStatus", "任务状态:"),
("planningParameters", "delivery"): ("enabled", "交期参数:"),
("planningParameters", "bottleneck"): ("enabled", "瓶颈参数:"),
}
DEFINITION_HEADERS = (
"角色", "角色名称", "工作表", "字段顺序", "业务字段", "是否必填", "类型", "填写要求", "来源说明",
)
def _enum_rule(profile: dict, role: str, field: str) -> str | None:
entry = _ENUM_FIELDS.get((role, field))
if not entry:
return None
enum_name, prefix = entry
options = profile["enums"].get(enum_name) or {}
if not options:
return None
rendered: list[str] = []
for source, canonical in options.items():
text = str(source)
if str(canonical) not in ("", text):
text = f"{text}→{canonical}"
if text not in rendered:
rendered.append(text)
return prefix + ";".join(rendered)
def _fill_rule(profile: dict, role: str, field: str) -> str:
enum_rule = _enum_rule(profile, role, field)
if enum_rule:
return enum_rule
kind = _field_kind(role, field)
if kind == "日期/时间":
return "日期 YYYY-MM-DD;时间 HH:MM"
if kind == "数值":
return "数字,不写单位"
return "—"
def intake_definition_rows(profile: dict) -> list[list[Any]]:
"""采集说明的逐字段定义:模板与解析器共用同一份 profile。"""
rows: list[list[Any]] = []
for role, spec in profile["sheets"].items():
required = set(spec.get("requiredColumns") or ())
for column_index, field in enumerate(spec["columns"], 1):
rows.append([
role, _ROLE_LABELS.get(role, role), spec["name"], column_index, field,
"必填" if field in required else "可选", _field_kind(role, field),
_fill_rule(profile, role, field), f"{spec['columns'][field]}(来源列)",
])
return rows
def _style():
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
@ -158,26 +226,16 @@ def _build_workbook(profile: dict, examples: Mapping[str, list[dict[str, Any]]])
)
intake["A3"] = "填写规则:所有工作表表头不要改动;未提供的行留空;缺项必须保持可见,系统不会自动补默认值。"
intake["A4"] = "能力与限制:" + "、".join(profile["capabilities"])
headers = ["角色", "角色名称", "工作表", "字段顺序", "业务字段", "是否必填", "类型", "来源说明"]
for column, header in enumerate(headers, 1):
for column, header in enumerate(DEFINITION_HEADERS, 1):
cell = intake.cell(6, column, header)
cell.fill = head_fill
cell.font = head_font
cell.border = thin
row_index = 7
for role, spec in profile["sheets"].items():
required = set(spec.get("requiredColumns") or ())
for column_index, field in enumerate(spec["columns"], 1):
values = [
role, _ROLE_LABELS.get(role, role), spec["name"], column_index, field,
"必填" if field in required else "可选", _field_kind(role, field),
f"{spec['columns'][field]}(来源列)",
]
for column, value in enumerate(values, 1):
cell = intake.cell(row_index, column, value)
cell.border = thin
row_index += 1
for column, width in enumerate([16, 14, 16, 10, 22, 10, 12, 26], 1):
for row_index, values in enumerate(intake_definition_rows(profile), 7):
for column, value in enumerate(values, 1):
cell = intake.cell(row_index, column, value)
cell.border = thin
for column, width in enumerate([16, 14, 16, 10, 22, 10, 12, 44, 26], 1):
intake.column_dimensions[get_column_letter(column)].width = width
intake.freeze_panes = "A7"
@ -223,6 +281,8 @@ def _build_workbook(profile: dict, examples: Mapping[str, list[dict[str, Any]]])
def _field_kind(role: str, field: str) -> str:
if (role, field) in _ENUM_FIELDS:
return "枚举"
if field.lower().endswith(("date", "time")) or field in {
"start", "end", "completionTime", "orderDate", "dueDate", "expectedArrivalDate", "shiftCode",
}:
@ -288,6 +348,7 @@ def template_contract() -> dict[str, Any]:
"id": "intake-template.v1",
"schemaVersion": 1,
"intakeSheet": "数据采集说明",
"definitionHeaders": list(DEFINITION_HEADERS),
"profileId": profile["id"],
"profileSchemaVersion": profile["schemaVersion"],
"roleOrder": list(profile["sheets"]),

View File

@ -15,6 +15,7 @@ from server.aps_domain.report_contracts import (
SCHEDULE_BLOCKED_CONTRACT,
SCHEDULE_EQUIPMENT_CONTRACT,
SCHEDULE_ORDER_CONTRACT,
build_contract_template,
contract_digest,
contract_summary,
validate_workbook_contract,
@ -27,6 +28,7 @@ from server.aps_domain.reports import (
from server.engines import PoolEngine
from server.importers.template_workbook import (
build_intake_template,
intake_definition_rows,
template_contract,
validate_intake_template,
)
@ -334,3 +336,96 @@ def test_intake_template_contract_matches_the_authorized_workbook():
assert physical[:len(sheet["columns"])] == sheet["columns"], sheet["name"]
finally:
workbook.close()
def test_schedule_export_templates_are_blank_header_skeletons_of_every_contract():
"""排产导出模板必须是合同的表头骨架:无业务行,且生成后即通过合同复核。"""
for report_type, contract in (
("plan", PLAN_REPORT_CONTRACT),
("schedule-order", SCHEDULE_ORDER_CONTRACT),
("schedule-equipment", SCHEDULE_EQUIPMENT_CONTRACT),
("schedule-blocked.v1", SCHEDULE_BLOCKED_CONTRACT),
):
template = build_contract_template(report_type)
assert template["templateId"] == contract.id
assert template["contractDigest"] == contract_digest()
assert template["sheets"][0]["name"] == contract.primary_sheet
assert validate_workbook_contract(template["xlsxBytes"], contract) == []
workbook = load_workbook(BytesIO(template["xlsxBytes"]))
try:
for spec in contract.sheets.values():
sheet = workbook[spec.name]
if not spec.header_row:
continue
headers = [cell.value for cell in sheet[spec.header_row]]
assert headers[:len(spec.headers)] == list(spec.headers), spec.name
assert sheet.cell(spec.data_start_row, 1).value is None, spec.name
finally:
workbook.close()
with pytest.raises(ValueError, match="未注册"):
build_contract_template("schedule-nothing")
def test_schedule_template_endpoint_serves_the_contract_with_conditional_requests(monkeypatch, tmp_path):
import server.gateway.app as gateway_module
import server.state.store as state_store
from tests.auth_provider import install_test_auth
install_test_auth(monkeypatch, "tenant-schedule-template")
store = _MemStore(empty_world(), str(tmp_path / "world.json"))
monkeypatch.setattr(gateway_module, "get_store", lambda: store)
monkeypatch.setattr(state_store, "get_store", lambda: store)
client = TestClient(gateway_module.create_app())
unauthenticated = client.get("/api/reports/schedule-template")
assert unauthenticated.status_code == 401
assert "AUTH_REQUIRED" in unauthenticated.text
login = client.post("/api/auth/login", json={"username": "planner", "password": "test"})
assert login.status_code == 200, login.text
response = client.get("/api/reports/schedule-template")
assert response.status_code == 200, response.text
assert response.headers["x-aps-template-id"] == "schedule-plan.v1"
assert response.headers["x-aps-contract-digest"] == contract_digest()
assert response.content[:2] == b"PK"
assert validate_workbook_contract(response.content, PLAN_REPORT_CONTRACT) == []
cached = client.get("/api/reports/schedule-template",
headers={"If-None-Match": response.headers["etag"]})
assert cached.status_code == 304
assert cached.content == b""
assert cached.headers["x-aps-template-id"] == "schedule-plan.v1"
orders = client.get("/api/reports/schedule-template", params={"reportType": "schedule-order"})
assert orders.status_code == 200, orders.text
assert orders.headers["x-aps-template-id"] == "schedule-order.v1"
assert validate_workbook_contract(orders.content, SCHEDULE_ORDER_CONTRACT) == []
unknown = client.get("/api/reports/schedule-template", params={"reportType": "not-registered"})
assert unknown.status_code == 404
assert "not-registered" in unknown.json()["detail"]
def test_intake_definition_declares_required_state_and_allowed_values():
"""采集说明必须把必填、单位/格式和允许取值讲清楚,且全部来自同一份配置。"""
profile = builtin_compatibility_profile()
rows = intake_definition_rows(profile)
by_field = {(row[0], row[4]): row for row in rows}
order_type = by_field[("orders", "orderType")]
assert order_type[5] == "必填"
assert order_type[7].startswith("订单分类:")
assert "正式订单" in order_type[7] and "SANDBOX" in order_type[7]
assert by_field[("equipment", "availabilityRate")][7] == "数字,不写单位"
assert by_field[("inventory", "expectedArrivalDate")][7].startswith("日期")
assert by_field[("products", "stock")][5] == "可选"
template = build_intake_template()
workbook = load_workbook(BytesIO(template["xlsxBytes"]))
try:
definition = workbook["数据采集说明"]
assert [cell.value for cell in definition[6]] == list(template_contract()["definitionHeaders"])
assert definition.cell(7, 8).value
assert definition.cell(definition.max_row, 8).value
finally:
workbook.close()

View File

@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const flexPanel = readFileSync(new URL('../../apps/web/src/master/FlexPanel.tsx', import.meta.url), 'utf8');
const backend = readFileSync(new URL('../../server/gateway/app.py', import.meta.url), 'utf8');
const contracts = readFileSync(new URL('../../server/aps_domain/report_contracts.py', import.meta.url), 'utf8');
test('flex workbench exports use the authenticated download helper', () => {
assert.match(flexPanel, /downloadAuthenticatedFile/);
assert.match(flexPanel, /\/api\/reports\/schedule-order\?versionId=/);
assert.match(flexPanel, /\/api\/reports\/schedule-equipment\?versionId=/);
assert.match(flexPanel, /\/api\/reports\/schedule-template/);
// 浏览器直接打开 /api 链接拿不到鉴权头,导出必须走 fetch 而不是 href。
assert.doesNotMatch(flexPanel, /href=\{\s*[`'"]\/api\//);
assert.doesNotMatch(flexPanel, /window\.location\s*=/);
});
test('schedule template endpoint serves the same contract the exports declare', () => {
const templateRoute = backend.indexOf('@app.get("/api/reports/schedule-template")');
const dynamicRoute = backend.indexOf('@app.get("/api/reports/{report_type}")');
assert.ok(templateRoute > 0, 'schedule-template route must exist');
assert.ok(dynamicRoute > templateRoute, 'template route must be declared before the dynamic route');
assert.match(backend, /build_contract_template\(reportType or "schedule-plan"\)/);
assert.match(backend, /X-APS-Contract-Digest/);
assert.match(backend, /status_code=404/);
assert.match(contracts, /def build_contract_template\(report_type: str\)/);
assert.match(contracts, /defects = validate_workbook_contract\(payload, contract\)/);
});