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('五模式对比完成(沙盒,未改主干)'); })}>五模式对比 + @@ -458,13 +479,17 @@ export default function FlexPanel(props: { {latestVersion.sortMode ? ` · ${latestVersion.sortMode}` : ''},导出文件显式绑定当前版本。

- -
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\)/); +});