From 6046f470f1ab41daa0d68db5033394b8b190f7dd Mon Sep 17 00:00:00 2001
From: "z.zhang"
Date: Mon, 14 Sep 2026 20:34:48 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B6=E5=8F=A3=E9=87=87=E9=9B=86?=
=?UTF-8?q?=E4=B8=8E=E6=8E=92=E4=BA=A7=E6=A8=A1=E6=9D=BF=E5=90=88=E5=90=8C?=
=?UTF-8?q?=E5=8F=8A=E9=89=B4=E6=9D=83=E4=B8=8B=E8=BD=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 采集模板:数据采集说明新增「填写要求」列,枚举取值与格式取自解析器同一份 profile,逐字段声明必填/类型/来源
- 排产模板:新增 build_contract_template 与 GET /api/reports/schedule-template,4 份合同导出表头骨架并复用 validate_workbook_contract 复核;柔性工作台新增「排产模板」入口
- 下载修复:订单方案/设备方案改走 downloadAuthenticatedFile,不再因缺少鉴权头落到 401 页面
- 测试:新增 schedule-template-download / flex-export-download 浏览器用例(含鉴权 fetch 头断言)与 node 静态用例;导航用例明细按钮断言兼容服务端口径「产品和物料」
- 文档:CHANGELOG 与 masterdata-configuration / demand-data-intake 同步
- 验证:真实 Pi + 真实工作簿 E2E 1 passed(PARTIAL_WITH_ASSUMPTIONS);planning-schedule-entry 3 passed;下载用例 3 passed;模板/文档黄金回归 20 passed
---
apps/web/e2e/flex-export-download.spec.ts | 118 ++++++++++++++++++
apps/web/e2e/planning-schedule-entry.spec.ts | 3 +-
.../e2e/schedule-template-download.spec.ts | 91 ++++++++++++++
apps/web/src/master/FlexPanel.tsx | 37 +++++-
docs/CHANGELOG.md | 9 +-
docs/product/demand-data-intake.md | 2 +-
docs/product/masterdata-configuration.md | 3 +-
server/aps_domain/report_contracts.py | 97 ++++++++++++++
server/gateway/app.py | 31 +++++
server/importers/template_workbook.py | 93 +++++++++++---
.../golden/test_schedule_template_contract.py | 95 ++++++++++++++
.../node/schedule-template-download.test.mjs | 29 +++++
12 files changed, 582 insertions(+), 26 deletions(-)
create mode 100644 apps/web/e2e/flex-export-download.spec.ts
create mode 100644 apps/web/e2e/schedule-template-download.spec.ts
create mode 100644 tests/node/schedule-template-download.test.mjs
diff --git a/apps/web/e2e/flex-export-download.spec.ts b/apps/web/e2e/flex-export-download.spec.ts
new file mode 100644
index 0000000..f242005
--- /dev/null
+++ b/apps/web/e2e/flex-export-download.spec.ts
@@ -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 {
+ 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>();
+ 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([]);
+});
diff --git a/apps/web/e2e/planning-schedule-entry.spec.ts b/apps/web/e2e/planning-schedule-entry.spec.ts
index 87eaaa6..74ccfa4 100644
--- a/apps/web/e2e/planning-schedule-entry.spec.ts
+++ b/apps/web/e2e/planning-schedule-entry.spec.ts
@@ -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 {
diff --git a/apps/web/e2e/schedule-template-download.spec.ts b/apps/web/e2e/schedule-template-download.spec.ts
new file mode 100644
index 0000000..281f080
--- /dev/null
+++ b/apps/web/e2e/schedule-template-download.spec.ts
@@ -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 {
+ 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 {
+ 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([]);
+});
diff --git a/apps/web/src/master/FlexPanel.tsx b/apps/web/src/master/FlexPanel.tsx
index af4cb1a..3b1b1c5 100644
--- a/apps/web/src/master/FlexPanel.tsx
+++ b/apps/web/src/master/FlexPanel.tsx
@@ -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(null);
const [cap, setCap] = useState(null);
const [busy, setBusy] = useState(false);
+ const [downloading, setDownloading] = useState(null);
const [message, setMessage] = useState(null);
const [pending, setPending] = useState(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('五模式对比完成(沙盒,未改主干)');
})}>五模式对比
+ } disabled={busy || downloading !== null}
+ onClick={() => void downloadWorkbook(
+ '/api/reports/schedule-template', '排产工作计划表模板.xlsx')}>
+ {downloading === '/api/reports/schedule-template' ? '下载中…' : '排产模板'}
+
@@ -458,13 +479,17 @@ export default function FlexPanel(props: {
{latestVersion.sortMode ? ` · ${latestVersion.sortMode}` : ''},导出文件显式绑定当前版本。
- }
- href={`/api/reports/schedule-order?versionId=${encodeURIComponent(String(latestVersion.id))}`}>
- 订单方案
+ } disabled={downloading !== null}
+ onClick={() => void downloadWorkbook(
+ `/api/reports/schedule-order?versionId=${encodeURIComponent(String(latestVersion.id))}`,
+ '排产工单方案.xlsx')}>
+ {downloading?.startsWith('/api/reports/schedule-order') ? '下载中…' : '订单方案'}
- }
- href={`/api/reports/schedule-equipment?versionId=${encodeURIComponent(String(latestVersion.id))}`}>
- 设备方案
+ } disabled={downloading !== null}
+ onClick={() => void downloadWorkbook(
+ `/api/reports/schedule-equipment?versionId=${encodeURIComponent(String(latestVersion.id))}`,
+ '排产设备方案.xlsx')}>
+ {downloading?.startsWith('/api/reports/schedule-equipment') ? '下载中…' : '设备方案'}
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 35874ef..dda58ea 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -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)
diff --git a/docs/product/demand-data-intake.md b/docs/product/demand-data-intake.md
index df726c4..61ee74f 100644
--- a/docs/product/demand-data-intake.md
+++ b/docs/product/demand-data-intake.md
@@ -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) 的「采集模板与排产导出模板」。
---
diff --git a/docs/product/masterdata-configuration.md b/docs/product/masterdata-configuration.md
index 7ec407e..56df09c 100644
--- a/docs/product/masterdata-configuration.md
+++ b/docs/product/masterdata-configuration.md
@@ -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()` 提供,作为文档、诊断和测试的共同来源。
diff --git a/server/aps_domain/report_contracts.py b/server/aps_domain/report_contracts.py
index 7d13ed4..290fc4d 100644
--- a/server/aps_domain/report_contracts.py
+++ b/server/aps_domain/report_contracts.py
@@ -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.
diff --git a/server/gateway/app.py b/server/gateway/app.py
index e69ed71..2b94700 100644
--- a/server/gateway/app.py
+++ b/server/gateway/app.py
@@ -5139,6 +5139,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,
diff --git a/server/importers/template_workbook.py b/server/importers/template_workbook.py
index d6d4e50..c9e463b 100644
--- a/server/importers/template_workbook.py
+++ b/server/importers/template_workbook.py
@@ -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"]),
diff --git a/tests/golden/test_schedule_template_contract.py b/tests/golden/test_schedule_template_contract.py
index 5ffd6f2..3a92945 100644
--- a/tests/golden/test_schedule_template_contract.py
+++ b/tests/golden/test_schedule_template_contract.py
@@ -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()
diff --git a/tests/node/schedule-template-download.test.mjs b/tests/node/schedule-template-download.test.mjs
new file mode 100644
index 0000000..d4d6f44
--- /dev/null
+++ b/tests/node/schedule-template-download.test.mjs
@@ -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\)/);
+});