diff --git a/docs/superpowers/plans/2026-09-20-pda-business-workflow-implementation.md b/docs/superpowers/plans/2026-09-20-pda-business-workflow-implementation.md new file mode 100644 index 0000000..7bab33f --- /dev/null +++ b/docs/superpowers/plans/2026-09-20-pda-business-workflow-implementation.md @@ -0,0 +1,721 @@ +# PDA 托盘业务操作功能 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在不改变 FastAPI 业务契约的前提下,为 `shierfeng-fastapi-app` 增加可在 H5/开发环境验证的托盘入库、装填、发货、现场使用和退回操作。 + +**Architecture:** PDA 复用现有 `request`、JWT、传输加密和后端业务接口;API 封装按业务模块拆分,扫码输入和幂等键生成作为无业务状态的共享工具。五个页面各自维护表单、扫码会话、请求代数和服务端刷新状态,后端继续作为权限、库存、状态机、产线绑定和退回集合校验的唯一权威。 + +**Tech Stack:** uni-app Vue 3、Pinia、Tailwind CSS、现有 `src/utils/request.js`、Node 20 `node:test`、pnpm;不增加依赖。 + +**Spec:** `docs/superpowers/specs/2026-09-20-pda-business-workflow-design.md` + +## Global Constraints + +- 不打包、签名、安装或发布 APK;只运行 PDA 的 H5/开发构建。 +- 不调用摄像头扫码,不新增原生扫码 SDK;扫码输入必须兼容 HID 键盘逐字符输入后回车提交,并保留手工输入。 +- 不修改现有 FastAPI 业务接口的请求/响应契约,不在 PDA 前端计算最终库存或托盘状态。 +- 基础资料维护和六类报表继续使用 Web 后台,不复制到 PDA。 +- 不把管理员密码、Token、Cookie 或其他凭据写入源码、测试固定文本或日志;登录表单不得预填密码。 +- 不修改 `.env.dev`、`.env.prod`、前端环境文件、用户已有 APP 改动、后端日志和无关缓存。 +- 不安装、升级或删除依赖;使用仓库已有 `node_modules`、Node 20 和 pnpm 配置。 +- 所有业务写请求必须使用后端要求的 camelCase 字段和幂等键;网络失败后直接重试复用原键,改变业务输入后才生成新键。 +- 页面入口只按当前用户权限显示;页面内按钮权限与后端 401/403 校验同时保留,前端隐藏不能替代后端权限。 +- 每个提交只包含本任务明确列出的文件;暂存和提交使用显式路径,禁止 `git add -A`、`git clean`、重置或覆盖用户已有改动。 +- 每个任务完成后使用中文提交说明,并用 `git commit --only` 保护工作区中既有暂存内容。 + +--- + +## 文件结构与责任边界 + +### 新建文件 + +- `shierfeng-fastapi-app/src/api/business/inbound.js`:首次入库列表、选项、扫码、详情、草稿增改删和确认请求。 +- `shierfeng-fastapi-app/src/api/business/filling.js`:装填列表、菌种选项、扫码、详情和装填提交请求。 +- `shierfeng-fastapi-app/src/api/business/shipment.js`:发货列表、仓库/现场选项、扫码、详情、草稿增改删和确认请求。 +- `shierfeng-fastapi-app/src/api/business/usage.js`:使用列表、产线选项、扫码、详情和使用提交请求。 +- `shierfeng-fastapi-app/src/api/business/return.js`:退回列表、仓库选项、扫码、申请、详情和确认请求。 +- `shierfeng-fastapi-app/src/utils/palletScan.js`:扫码值规范化、短时重复判断和 Web Crypto UUID v4 幂等键生成;不保存业务状态。 +- `shierfeng-fastapi-app/src/components/business/PalletScanInput.vue`:HID/手工输入、回车提交、空值校验、短时重复提示和输入焦点管理。 +- `shierfeng-fastapi-app/src/pages/business/inbound/index.vue`:入库草稿编辑、扫码、删除和确认。 +- `shierfeng-fastapi-app/src/pages/business/filling/index.vue`:单托盘装填。 +- `shierfeng-fastapi-app/src/pages/business/shipment/index.vue`:发货草稿编辑、扫码、删除和确认。 +- `shierfeng-fastapi-app/src/pages/business/usage/index.vue`:现场使用、产线选择和服务端结果展示。 +- `shierfeng-fastapi-app/src/pages/business/return/index.vue`:退回申请和仓库确认两个独立操作区。 +- `shierfeng-fastapi-app/tests/business-contract.test.mjs`:Node 20 静态 API/页面/字段/安全契约与扫码工具测试。 + +### 修改文件 + +- `shierfeng-fastapi-app/package.json`:增加不依赖新包的 `test:business` 脚本。 +- `shierfeng-fastapi-app/src/pages.json`:注册五个业务页面路由。 +- `shierfeng-fastapi-app/src/pages/login.vue`:移除旧默认密码预填值,保留现有认证流程。 +- `shierfeng-fastapi-app/src/pages/work/index.vue`:增加按权限过滤的五类 PDA 业务入口。 + +### 明确不修改 + +- `shierfeng-fastapi-backend/module_business/**`:后端接口已存在,本计划只消费其当前契约。 +- `shierfeng-fastapi-frontend/**`:Web 端作为接口和交互参考,不复制改动。 +- `shierfeng-fastapi-app/src/utils/request.js`、传输加密、Pinia 用户会话和环境配置:除非测试证明共享层存在直接阻塞,否则保持现状。 + +## 已核实的后端接口与字段 + +| 模块 | 查询/选项/扫码 | 写请求 | 必须保持的写入字段 | +| --- | --- | --- | --- | +| 入库 | `GET /business/inbound/list`、`/warehouse-options`、`/scan/{qrToken}?warehouseId=`、`/{inboundId}` | `POST /business/inbound`、`PUT /business/inbound`、`DELETE /business/inbound/{ids}`、`PUT /business/inbound/{id}/confirm` | 草稿:`warehouseId,palletIds`;确认:`idempotencyKey` | +| 装填 | `GET /business/filling/list`、`/strain-options`、`/scan/{qrToken}`、`/{fillingId}` | `POST /business/filling` | `qrToken,warehouseId,strainId,batchNo,packageSpec,initialPackages,idempotencyKey` | +| 发货 | `GET /business/shipment/list`、`/warehouse-options`、`/site-options`、`/scan/{qrToken}?sourceWarehouseId=&targetSiteId=`、`/{shipmentId}` | `POST /business/shipment`、`PUT /business/shipment`、`DELETE /business/shipment/{ids}`、`PUT /business/shipment/{id}/confirm` | 草稿:`sourceWarehouseId,targetSiteId,palletIds`;确认:`idempotencyKey` | +| 使用 | `GET /business/usage/list`、`/line-options?siteId=`、`/scan/{qrToken}`、`/{usageId}` | `POST /business/usage` | `qrToken,lineId,usedPackages,idempotencyKey` | +| 退回 | `GET /business/return/list`、`/warehouse-options`、`/scan/{qrToken}?mode=&returnId=`、`/{returnId}` | `POST /business/return/request`、`PUT /business/return/{id}/confirm` | 申请:`sourceSiteId,targetWarehouseId,palletIds,idempotencyKey`;确认:`scannedPalletIds,idempotencyKey` | + +后端扫码响应统一使用 `data.palletId/palletCode/flowState/warehouseId/siteId/currentLineId/strainId/strainCode/strainName/batchNo/packageSpec/capacityPackages/remainingPackages/allowedActions`;列表响应使用 `rows` 和 `total`;详情使用 `data`。装填和使用写请求没有单独确认接口,成功后以列表、扫码结果或使用响应为准。 + +## Task 1: 用测试锁定 API 封装、扫码工具和幂等键 + +**Files:** + +- Create: `shierfeng-fastapi-app/tests/business-contract.test.mjs` +- Create: `shierfeng-fastapi-app/src/api/business/inbound.js` +- Create: `shierfeng-fastapi-app/src/api/business/filling.js` +- Create: `shierfeng-fastapi-app/src/api/business/shipment.js` +- Create: `shierfeng-fastapi-app/src/api/business/usage.js` +- Create: `shierfeng-fastapi-app/src/api/business/return.js` +- Create: `shierfeng-fastapi-app/src/utils/palletScan.js` +- Modify: `shierfeng-fastapi-app/package.json` + +**Interfaces:** + +- Produces API exports `listInbound`, `getInboundWarehouseOptions`, `scanInboundPallet`, `getInbound`, `addInbound`, `updateInbound`, `delInbound`, `confirmInbound`;其他四个模块使用已核实接口表中的同义导出名。 +- Produces `normalizeScanValue(value)`, `isDuplicateScan(value,lastValue,scannedAt,lastScannedAt,duplicateWindowMs)` 和 `createIdempotencyKey(cryptoProvider)`。 +- All API wrappers import `request` from `@/utils/request` and return its promise without改写响应结构。 + +各模块的完整导出名固定如下,页面不得自行拼接 URL: + +- `inbound.js`:`listInbound(query)`、`getInboundWarehouseOptions()`、`scanInboundPallet(qrToken,query)`、`getInbound(inboundId)`、`addInbound(data)`、`updateInbound(data)`、`delInbound(inboundIds)`、`confirmInbound(inboundId,data)`。 +- `filling.js`:`listFilling(query)`、`getFillingStrainOptions()`、`scanFillingPallet(qrToken)`、`getFilling(fillingId)`、`addFilling(data)`。 +- `shipment.js`:`listShipment(query)`、`getShipmentWarehouseOptions()`、`getShipmentSiteOptions()`、`scanShipmentPallet(qrToken,query)`、`getShipment(shipmentId)`、`addShipment(data)`、`updateShipment(data)`、`delShipment(shipmentIds)`、`confirmShipment(shipmentId,data)`。 +- `usage.js`:`listUsage(query)`、`getUsageLineOptions(siteId)`、`scanUsagePallet(qrToken)`、`getUsage(usageId)`、`addUsage(data)`。 +- `return.js`:`listReturn(query)`、`getReturnWarehouseOptions()`、`scanReturnPallet(qrToken,query)`、`requestReturn(data)`、`getReturn(returnId)`、`confirmReturn(returnId,data)`。 + +- [ ] **Step 1: 先写 API 与工具的失败测试** + +在测试文件中用 `readFile` 读取五个 API 文件,用正则逐个断言 URL、HTTP 方法和关键参数名称;同时为工具写可执行单元测试: + +```js +test('扫码值只去除首尾空白并拒绝空值', async () => { + const { normalizeScanValue } = await import('../src/utils/palletScan.js') + assert.equal(normalizeScanValue(' QR-001\n'), 'QR-001') + assert.throws(() => normalizeScanValue(' \n\t '), /二维码不能为空/) +}) + +test('幂等键使用 randomUUID 或安全随机字节,不使用时间和伪随机数', async () => { + const { createIdempotencyKey } = await import('../src/utils/palletScan.js') + const expected = '12345678-1234-4123-8123-123456789abc' + assert.equal(createIdempotencyKey({ randomUUID: () => expected }), expected) + assert.equal( + createIdempotencyKey({ + getRandomValues(bytes) { + bytes.fill(0) + return bytes + } + }), + '00000000-0000-4000-8000-000000000000' + ) +}) + +test('同一值只在重复时间窗口内被识别', async () => { + const { isDuplicateScan } = await import('../src/utils/palletScan.js') + assert.equal(isDuplicateScan('QR-001', 'QR-001', 1200, 1000, 500), true) + assert.equal(isDuplicateScan('QR-001', 'QR-002', 1200, 1000, 500), false) + assert.equal(isDuplicateScan('QR-001', 'QR-001', 1801, 1000, 500), false) +}) +``` + +API 契约测试至少覆盖以下请求:入库和发货的列表、选项、扫码、详情、草稿增改删、确认;装填和使用的列表、选项、扫码、详情、提交;退回的列表、选项、扫码、申请、详情、确认。测试还要断言确认/写请求的函数名和 `data`/`params` 位置,防止把 query 字段误放入 body。 + +- [ ] **Step 2: 运行失败测试确认缺口** + +工作目录:`shierfeng-fastapi-app` + +```bash +node --test tests/business-contract.test.mjs +``` + +预期:因五个 API 模块、扫码工具和测试脚本尚未存在而失败;此时不修改后端或依赖。 + +- [ ] **Step 3: 实现最小 API 封装和测试脚本** + +`package.json` 只增加以下脚本,不增加依赖: + +```json +"test:business": "node --test tests/business-contract.test.mjs" +``` + +每个 API 文件只做路径和参数映射。例如入库模块必须保持如下接口形状: + +```js +import request from '@/utils/request' + +export function listInbound(query) { + return request({ url: '/business/inbound/list', method: 'get', params: query }) +} + +export function scanInboundPallet(qrToken, query) { + return request({ url: '/business/inbound/scan/' + qrToken, method: 'get', params: query }) +} + +export function addInbound(data) { + return request({ url: '/business/inbound', method: 'post', data }) +} + +export function confirmInbound(inboundId, data) { + return request({ + url: '/business/inbound/' + inboundId + '/confirm', + method: 'put', + data, + headers: { repeatSubmit: false } + }) +} +``` + +其他函数按接口表逐一实现,不增加前端字段别名。`createIdempotencyKey` 优先使用 `crypto.randomUUID()`,否则用 `crypto.getRandomValues(new Uint8Array(16))` 按 UUID v4 设置版本和变体位;缺少安全随机源时抛出明确错误,不使用 `Date.now()`、`Math.random()` 或账号信息。 + +- [ ] **Step 4: 运行测试确认 API 与工具契约通过** + +```bash +pnpm test:business +``` + +预期:本任务新增的 API 路径、HTTP 方法、参数位置、扫码规范化、重复窗口和 UUID 测试通过;页面相关测试在后续任务加入。 + +- [ ] **Step 5: 只提交本任务文件** + +```bash +git add -- \ + shierfeng-fastapi-app/package.json \ + shierfeng-fastapi-app/tests/business-contract.test.mjs \ + shierfeng-fastapi-app/src/api/business/inbound.js \ + shierfeng-fastapi-app/src/api/business/filling.js \ + shierfeng-fastapi-app/src/api/business/shipment.js \ + shierfeng-fastapi-app/src/api/business/usage.js \ + shierfeng-fastapi-app/src/api/business/return.js \ + shierfeng-fastapi-app/src/utils/palletScan.js +git commit --only -m '新增:PDA 业务 API 与扫码幂等工具' -- \ + shierfeng-fastapi-app/package.json \ + shierfeng-fastapi-app/tests/business-contract.test.mjs \ + shierfeng-fastapi-app/src/api/business/inbound.js \ + shierfeng-fastapi-app/src/api/business/filling.js \ + shierfeng-fastapi-app/src/api/business/shipment.js \ + shierfeng-fastapi-app/src/api/business/usage.js \ + shierfeng-fastapi-app/src/api/business/return.js \ + shierfeng-fastapi-app/src/utils/palletScan.js +``` + +## Task 2: 先测试再实现 HID 扫码输入组件 + +**Files:** + +- Create: `shierfeng-fastapi-app/src/components/business/PalletScanInput.vue` +- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs` + +**Interfaces:** + +- Consumes `normalizeScanValue` and `isDuplicateScan` from `@/utils/palletScan`. +- Props: `disabled:Boolean=false`、`loading:Boolean=false`、`duplicateWindowMs:Number=500`。 +- Emits: `scan(normalizedValue)`、`invalid(message)`、`duplicate(normalizedValue)`。 +- Exposes no business state; parent controls request lock through `loading` and owns accepted pallet rows. + +- [ ] **Step 1: 写组件静态契约测试并确认 RED** + +在 `business-contract.test.mjs` 读取组件源码,断言存在 `@confirm="handleSubmit"`、`normalizeScanValue`、`isDuplicateScan`、三个 emit 名称、`:disabled="disabled || loading"` 或等价锁定逻辑,并断言没有 `uni.scanCode`、摄像头 API 或状态机字段判断。执行 `pnpm test:business`,预期组件测试失败。 + +- [ ] **Step 2: 实现组件输入状态机** + +组件使用 uni-app 原生 ``,同时绑定 `@confirm`(HID 回车和手工确认)与一个可点击的“提交扫描”按钮;输入框在 `disabled` 或 `loading` 时只读/禁用。提交逻辑必须保持以下顺序: + +```js +function handleSubmit() { + if (props.disabled || props.loading) return + let value + try { + value = normalizeScanValue(scanValue.value) + } catch (error) { + errorMessage.value = error.message + emit('invalid', error.message) + focusInput(true) + return + } + const scannedAt = Date.now() + if (isDuplicateScan(value, lastScanValue, scannedAt, lastScanAt, props.duplicateWindowMs)) { + scanValue.value = '' + emit('duplicate', value) + focusInput() + return + } + lastScanValue = value + lastScanAt = scannedAt + scanValue.value = '' + emit('scan', value) + focusInput() +} +``` + +`Date.now()`只用于比较短时重复输入,不得用于生成业务幂等键。组件自动聚焦、扫描完成后清空输入并重新聚焦;页面切换、请求进行中和权限不足时由父页面传入 `disabled/loading`。 + +- [ ] **Step 3: 运行组件契约和工具测试确认 GREEN** + +```bash +pnpm test:business +``` + +预期:组件的 HID 回车、手工提交、空值、重复值和请求锁静态契约通过。 + +- [ ] **Step 4: 提交组件** + +```bash +git add -- shierfeng-fastapi-app/src/components/business/PalletScanInput.vue shierfeng-fastapi-app/tests/business-contract.test.mjs +git commit --only -m '新增:PDA HID 扫码输入组件' -- \ + shierfeng-fastapi-app/src/components/business/PalletScanInput.vue \ + shierfeng-fastapi-app/tests/business-contract.test.mjs +``` + +## Task 3: 注册路由、移除密码预填并增加权限入口 + +**Files:** + +- Modify: `shierfeng-fastapi-app/src/pages.json` +- Modify: `shierfeng-fastapi-app/src/pages/login.vue` +- Modify: `shierfeng-fastapi-app/src/pages/work/index.vue` +- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs` + +**Interfaces:** + +- Produces routes `pages/business/inbound/index`、`pages/business/filling/index`、`pages/business/shipment/index`、`pages/business/usage/index`、`pages/business/return/index`。 +- Uses existing `$auth.hasPermiOr`/`$auth.hasPermiAnd` and `$tab.navigateTo`; does not change Pinia or permission storage. + +- [ ] **Step 1: 写路由、权限和凭据安全测试并确认 RED** + +测试必须断言五个页面路径和标题存在,工作台包含五类业务权限字符串,且 `src/pages/login.vue` 不再包含 `admin123` 或其他密码字面量。测试也要断言入口跳转路径与页面路径一致。 + +- [ ] **Step 2: 注册五个页面并实现入口过滤** + +在 `pages.json` 按工作台之后加入五个页面,使用中文标题“仓库入库”“托盘装填”“发货出库”“现场使用”“退回仓库”。工作台增加“现场业务”区块,入口权限使用以下 OR 集合: + +```js +const businessEntries = [ + { path: '/pages/business/inbound/index', permissions: ['business:inbound:list', 'business:inbound:query', 'business:inbound:add', 'business:inbound:edit', 'business:inbound:remove', 'business:inbound:confirm'] }, + { path: '/pages/business/filling/index', permissions: ['business:filling:list', 'business:filling:query', 'business:filling:add'] }, + { path: '/pages/business/shipment/index', permissions: ['business:shipment:list', 'business:shipment:query', 'business:shipment:add', 'business:shipment:edit', 'business:shipment:remove', 'business:shipment:confirm'] }, + { path: '/pages/business/usage/index', permissions: ['business:usage:list', 'business:usage:query', 'business:usage:add'] }, + { path: '/pages/business/return/index', permissions: ['business:return:list', 'business:return:query', 'business:return:request', 'business:return:confirm'] } +] +``` + +模板只渲染 `proxy.$auth.hasPermiOr(entry.permissions)` 为真的入口;点击前再次检查权限并调用 `$tab.navigateTo(entry.path)`。页面没有任何授权时不显示业务区块。保留现有非本任务系统入口,不扩展其他模块。 + +- [ ] **Step 3: 清除登录页密码默认值** + +将 `loginForm` 的 `username` 和 `password` 初始值改为空字符串,验证码和登录 API 流程保持不变;开发联调账号只在人工输入时使用,不写入源码、测试或日志。 + +- [ ] **Step 4: 运行测试确认 GREEN** + +```bash +pnpm test:business +``` + +预期:五个路由、权限入口、登录安全静态契约通过。 + +- [ ] **Step 5: 提交页面壳层改动** + +```bash +git add -- \ + shierfeng-fastapi-app/src/pages.json \ + shierfeng-fastapi-app/src/pages/login.vue \ + shierfeng-fastapi-app/src/pages/work/index.vue \ + shierfeng-fastapi-app/tests/business-contract.test.mjs +git commit --only -m '新增:PDA 业务页面路由与权限入口' -- \ + shierfeng-fastapi-app/src/pages.json \ + shierfeng-fastapi-app/src/pages/login.vue \ + shierfeng-fastapi-app/src/pages/work/index.vue \ + shierfeng-fastapi-app/tests/business-contract.test.mjs +``` + +## Task 4: 实现首次入库 PDA 页面 + +**Files:** + +- Create: `shierfeng-fastapi-app/src/pages/business/inbound/index.vue` +- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs` + +**Interfaces:** + +- Consumes all exports from `src/api/business/inbound.js`, `PalletScanInput`, `createIdempotencyKey` and `$auth`. +- Produces page methods `handleInboundScan`, `submitInboundDraft`, `saveInboundUpdate`, `confirmInboundDraft`, `removeScannedPallet`, `resetInboundOperation`。 +- Page state stores only scanned display rows, draft form values, request/loading/error state and confirm idempotency key; it never stores a client-calculated inventory state。 + +- [ ] **Step 1: 写入库页面契约测试并确认 RED** + +测试读取页面源码并断言: + +1. 使用 `PalletScanInput`,扫描调用 `scanInboundPallet(qrToken, { warehouseId })`。 +2. 只接受 `flowState === 'UNSTOCKED'`,按 `palletId` 去重。 +3. 草稿 payload 只含 `{ warehouseId, palletIds }`,编辑 payload 只含 `{ inboundId, warehouseId, palletIds }`。 +4. 确认调用 `confirmInbound(inboundId, { idempotencyKey })`,失败分支不清空当前键。 +5. 编辑/删除/确认按钮分别包含 `business:inbound:edit/remove/confirm`,入口操作需要同时具备 `query` 与对应写权限。 + +- [ ] **Step 2: 实现仓库选项、草稿扫码和编辑状态** + +页面加载授权仓库选项和第一页列表;新增时选择仓库后允许连续扫描。每次扫码携带当前仓库 ID,接受服务端返回的 `palletId/palletCode/flowState`,重复托盘只提示不追加。切换仓库清空当前扫描集合并使旧扫码响应失效。 + +编辑时先调用 `getInbound(inboundId)`,只有详情仍为 `DRAFT` 才填充仓库和明细;详情未返回前禁止扫码、保存和删除。列表刷新/页面 `onShow` 以服务端 `rows` 为准,不从本地草稿推导状态。 + +- [ ] **Step 3: 实现草稿写入和确认重试** + +```js +function submitInboundDraft() { + return addInbound({ + warehouseId: form.value.warehouseId, + palletIds: scannedPallets.value.map((item) => item.palletId) + }) +} + +function confirmInboundDraft(row) { + const idempotencyKey = ensureConfirmIdempotencyKey(row.inboundId) + confirmLoading.value = true + confirmInbound(row.inboundId, { idempotencyKey }) + .then(() => { + clearConfirmIdempotency() + loadInboundList() + }) + .catch(() => { + confirmError.value = '入库确认失败;再次确认将复用本次幂等键' + }) + .finally(() => { + confirmLoading.value = false + }) +} +``` + +后端新增草稿响应不保证返回单据 ID,因此保存后只关闭编辑区并重新读取列表,由用户从服务端返回的 `DRAFT` 行选择确认,不按时间或本地数组猜测刚创建的单据。确认成功后清除键;网络超时、401 以外的可重试失败保留键,401 交由现有 `request` 会话处理。 + +- [ ] **Step 4: 运行入库契约和构建前静态检查** + +```bash +pnpm test:business +``` + +预期:入库页面的字段、状态、权限、幂等键和服务端刷新契约通过。 + +- [ ] **Step 5: 提交入库页面** + +```bash +git add -- shierfeng-fastapi-app/src/pages/business/inbound/index.vue shierfeng-fastapi-app/tests/business-contract.test.mjs +git commit --only -m '新增:PDA 首次入库操作页' -- \ + shierfeng-fastapi-app/src/pages/business/inbound/index.vue \ + shierfeng-fastapi-app/tests/business-contract.test.mjs +``` + +## Task 5: 实现整托发货 PDA 页面 + +**Files:** + +- Create: `shierfeng-fastapi-app/src/pages/business/shipment/index.vue` +- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs` + +**Interfaces:** + +- Consumes `src/api/business/shipment.js`、`PalletScanInput` 和 `createIdempotencyKey`。 +- Produces `handleShipmentScan`, `submitShipmentDraft`, `saveShipmentUpdate`, `confirmShipmentDraft`, `removeScannedPallet`。 +- Scanned rows expose `palletCode`, `strainName/strainCode`, `batchNo`, `remainingPackages` as read-only display values。 + +- [ ] **Step 1: 写发货页面失败契约** + +断言页面加载仓库和现场选项,扫描调用 `scanShipmentPallet(qrToken, { sourceWarehouseId, targetSiteId })`,只接受 `flowState === 'FILLED_IN_WAREHOUSE' && remainingPackages > 0`,草稿/确认字段与接口表一致,并保留确认失败时的幂等键。 + +- [ ] **Step 2: 实现来源仓库、目标现场和集合扫码** + +来源仓库或目标现场改变时清空托盘集合并使旧请求失效。扫描期间锁定输入和提交按钮;同一 `palletId` 不重复追加。列表、详情和选项失败显示页面内可重试错误,不吞掉后端业务消息。 + +- [ ] **Step 3: 实现草稿和确认** + +```js +function submitShipmentDraft() { + return addShipment({ + sourceWarehouseId: form.value.sourceWarehouseId, + targetSiteId: form.value.targetSiteId, + palletIds: scannedPallets.value.map((item) => item.palletId) + }) +} + +function confirmShipmentDraft(row) { + const idempotencyKey = ensureConfirmIdempotencyKey(row.shipmentId) + return confirmShipment(row.shipmentId, { idempotencyKey }) +} +``` + +保存后重新读取列表;确认前要求 `DRAFT`,成功后重新加载列表和页面状态。错误重试只复用同一确认键,改变单据或托盘集合时清除并重新生成键;不在前端扣减 `remainingPackages`。 + +- [ ] **Step 4: 运行测试并提交** + +```bash +pnpm test:business +git add -- shierfeng-fastapi-app/src/pages/business/shipment/index.vue shierfeng-fastapi-app/tests/business-contract.test.mjs +git commit --only -m '新增:PDA 整托发货操作页' -- \ + shierfeng-fastapi-app/src/pages/business/shipment/index.vue \ + shierfeng-fastapi-app/tests/business-contract.test.mjs +``` + +预期:测试通过后才执行提交;提交只包含发货页面和契约测试增量。 + +## Task 6: 实现单托盘装填 PDA 页面 + +**Files:** + +- Create: `shierfeng-fastapi-app/src/pages/business/filling/index.vue` +- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs` + +**Interfaces:** + +- Consumes `listFilling`、`getFillingStrainOptions`、`scanFillingPallet`、`addFilling` 和 `PalletScanInput`。 +- Produces form fields `qrToken`, `warehouseId`, `strainId`, `batchNo`, `packageSpec`, `initialPackages` and one operation-scoped `idempotencyKey`。 + +- [ ] **Step 1: 写装填页面失败契约** + +断言页面只接受 `flowState === 'EMPTY_IN_WAREHOUSE'`,扫码成功后展示仓库和容量,提交 payload 包含且只包含后端 `FillingCreateModel` 字段,包数校验为严格正整数,成功/失败状态不伪造库存。 + +- [ ] **Step 2: 实现扫码和表单** + +扫码后保存服务端返回的 `qrToken`、`warehouseId`、`palletCode`、`capacityPackages`,加载菌种选项。菌种、批次、规格和包数在请求期间锁定;包数的前端校验只做正整数和非空检查,容量和托盘状态由后端最终判定。 + +- [ ] **Step 3: 实现幂等提交和成功刷新** + +```js +function submitFilling() { + const idempotencyKey = ensureFillingIdempotencyKey() + return addFilling({ + qrToken: form.value.qrToken, + warehouseId: form.value.warehouseId, + strainId: form.value.strainId, + batchNo: form.value.batchNo.trim(), + packageSpec: form.value.packageSpec.trim(), + initialPackages: form.value.initialPackages, + idempotencyKey + }) +} +``` + +提交成功后清空表单并刷新装填列表,再以扫码查询或列表结果显示服务端状态;刷新失败不能覆盖已经成功的写入提示。提交失败保留键;修改托盘、菌种、批次、规格或包数后清除键,下一次输入生成新键。 + +- [ ] **Step 4: 运行测试并提交** + +```bash +pnpm test:business +git add -- shierfeng-fastapi-app/src/pages/business/filling/index.vue shierfeng-fastapi-app/tests/business-contract.test.mjs +git commit --only -m '新增:PDA 托盘装填操作页' -- \ + shierfeng-fastapi-app/src/pages/business/filling/index.vue \ + shierfeng-fastapi-app/tests/business-contract.test.mjs +``` + +## Task 7: 实现客户现场使用 PDA 页面 + +**Files:** + +- Create: `shierfeng-fastapi-app/src/pages/business/usage/index.vue` +- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs` + +**Interfaces:** + +- Consumes `listUsage`、`getUsageLineOptions(siteId)`、`scanUsagePallet`、`addUsage` 和 `PalletScanInput`。 +- Produces `handleUsageScan`, `loadLineOptions`, `submitUsage`, `resetUsageOperation`。 +- Uses `UsageSuccessResponseModel` fields `usageId`, `remainingPackages`, `flowState` for result display only。 + +- [ ] **Step 1: 写现场使用失败契约** + +断言页面只接受 `AT_SITE_UNUSED` 或 `AT_SITE_PARTIAL` 且 `remainingPackages > 0`;没有 `currentLineId` 时按扫码响应的 `siteId` 获取产线,有绑定时锁定该产线;提交使用 `qrToken,lineId,usedPackages,idempotencyKey`,不在本地推算新状态。 + +- [ ] **Step 2: 实现扫码、产线锁定和正整数校验** + +每次新扫码先使旧产线请求失效、清空旧结果和幂等键。扫码成功后保存托盘、菌种、批次、余量和现场信息;首次使用显示可选产线,已有 `currentLineId` 时只能使用该产线。使用包数必须是严格正整数,超过当前展示余量时给出提示,但即使通过前端检查也仍由服务端校验。 + +- [ ] **Step 3: 实现使用提交和结果刷新** + +```js +function submitUsage() { + const idempotencyKey = ensureUsageIdempotencyKey() + return addUsage({ + qrToken: form.value.qrToken, + lineId: form.value.lineId, + usedPackages: form.value.usedPackages, + idempotencyKey + }).then((response) => { + usageResult.value = response.data + clearUsageIdempotencyKey() + return loadUsageList() + }) +} +``` + +网络失败时保留键并显示“再次提交将复用本次幂等键”;成功只采用 `response.data.remainingPackages` 和 `response.data.flowState`,然后清理表单或允许重新扫码。列表和扫码响应晚于新会话时必须被请求代数丢弃。 + +- [ ] **Step 4: 运行测试并提交** + +```bash +pnpm test:business +git add -- shierfeng-fastapi-app/src/pages/business/usage/index.vue shierfeng-fastapi-app/tests/business-contract.test.mjs +git commit --only -m '新增:PDA 客户现场使用操作页' -- \ + shierfeng-fastapi-app/src/pages/business/usage/index.vue \ + shierfeng-fastapi-app/tests/business-contract.test.mjs +``` + +## Task 8: 实现托盘退回申请与仓库确认 + +**Files:** + +- Create: `shierfeng-fastapi-app/src/pages/business/return/index.vue` +- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs` + +**Interfaces:** + +- Consumes all exports from `src/api/business/return.js` and two independent `PalletScanInput` instances。 +- Request side state: `sourceSiteId`, `targetWarehouseId`, `requestScannedPallets`, `requestIdempotencyKey`。 +- Confirm side state: `returnId`, `expectedPalletIds`, `confirmScannedPallets`, `confirmIdempotencyKey`。 +- Produces `handleRequestScan`, `submitReturnRequest`, `loadReturnDetail`, `handleConfirmScan`, `submitReturnConfirm`, `sameIdSet`。 + +- [ ] **Step 1: 写退回两阶段失败契约** + +断言测试覆盖: + +1. 申请扫码使用 `{ mode: 'request' }`,首个托盘确定 `sourceSiteId`,后续托盘必须属于同一现场。 +2. 申请 payload 为 `sourceSiteId,targetWarehouseId,palletIds,idempotencyKey`,申请键和确认键互不复用。 +3. 确认先读取详情,状态必须为 `REQUESTED`,扫描使用 `{ mode: 'confirm', returnId }`,扫码集合必须与详情托盘 ID 集合完全相同。 +4. 确认写请求前再次 `getReturn(returnId)`,比较最新状态和 ID 集合后才调用 `confirmReturn`。 +5. 请求或确认扫码/提交的旧响应不能覆盖新选择;失败不清除对应幂等键。 + +- [ ] **Step 2: 实现申请区** + +加载可访问目标仓库和退回列表。申请扫码成功后保存 `palletId,palletCode,siteId,remainingPackages`,同一托盘不重复,目标仓库或会话变化时清空集合和申请键。`requestReturn` 成功后清除申请键、清空申请区并刷新列表;不根据响应消息猜测新 `returnId`,确认入口从服务端返回的 `REQUESTED` 列表选择单据。 + +- [ ] **Step 3: 实现确认区的详情快照与整集合扫码** + +选择退回单后调用 `getReturn`,保存详情中的 `items[].palletId` 作为预期集合。确认扫码必须命中预期集合且不能重复;切换退回单使确认扫码代数、详情代数和确认键同时失效。实现集合比较时使用去重后的 `Set`,长度和成员都必须一致: + +```js +function sameIdSet(left, right) { + if (left.length !== right.length) return false + const leftSet = new Set(left) + const rightSet = new Set(right) + return leftSet.size === left.length && + rightSet.size === right.length && + right.every((id) => leftSet.has(id)) +} +``` + +- [ ] **Step 4: 实现确认前复核和幂等提交** + +```js +async function submitReturnConfirm() { + const returnId = confirmForm.value.returnId + const scannedPalletIds = confirmScannedPallets.value.map((item) => item.palletId) + if (!returnId || !sameIdSet(expectedPalletIds.value, scannedPalletIds)) { + confirmError.value = '必须逐个扫描并完整匹配退回单全部托盘' + return + } + const latest = (await getReturn(returnId)).data + if (latest.status !== 'REQUESTED' || + !sameIdSet(latest.items.map((item) => item.palletId), scannedPalletIds)) { + confirmError.value = '退回单状态或托盘明细已变化,请重新选择并扫码' + return + } + const idempotencyKey = ensureConfirmIdempotencyKey(returnId) + await confirmReturn(returnId, { scannedPalletIds, idempotencyKey }) +} +``` + +确认成功后清除确认键、刷新退回列表和详情;网络失败保留键。申请确认两个阶段的输入锁、错误状态和重试键必须分开,不能用一个全局 `loading` 或一个幂等键混用。 + +- [ ] **Step 5: 运行测试并提交** + +```bash +pnpm test:business +git add -- shierfeng-fastapi-app/src/pages/business/return/index.vue shierfeng-fastapi-app/tests/business-contract.test.mjs +git commit --only -m '新增:PDA 托盘退回两阶段操作页' -- \ + shierfeng-fastapi-app/src/pages/business/return/index.vue \ + shierfeng-fastapi-app/tests/business-contract.test.mjs +``` + +## Task 9: 完成全量契约验证、H5 构建和交付检查 + +**Files:** + +- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs`(仅在发现测试覆盖缺口时补充) +- Preserve: 所有后端、Web、环境、日志、缓存和用户已有 APP 文件 + +**Interfaces:** + +- Consumes all five PDA pages, five API modules, shared scanner and existing backend contract。 +- Produces a verified H5/development build;does not produce an APK。 + +- [ ] **Step 1: 完成静态契约覆盖清单** + +测试必须逐项覆盖: + +- 五个 API 文件的路径、方法、参数位置和导出函数。 +- 五个页面的路由存在、页面调用对应 API、camelCase payload 字段正确。 +- 五类状态条件:`UNSTOCKED`、`EMPTY_IN_WAREHOUSE`、`FILLED_IN_WAREHOUSE`、`AT_SITE_UNUSED/AT_SITE_PARTIAL`、退回 `REQUESTED`。 +- HID 回车、空值、重复扫码、扫描请求锁和手工输入。 +- 入库/发货确认、装填、使用、退回申请/确认的幂等键生成、保留和成功清理时机。 +- 页面权限标识和登录页无密码预填。 +- 不存在摄像头扫码调用、APK 构建脚本调用、管理员密码或 Token 字面量。 + +- [ ] **Step 2: 运行 PDA 契约测试** + +工作目录:`shierfeng-fastapi-app` + +```bash +pnpm test:business +``` + +预期:所有 Node 20 测试通过且零失败。 + +- [ ] **Step 3: 运行 H5 构建,不运行 APK 构建** + +```bash +pnpm run build:h5 +``` + +预期:uni-app H5 构建成功。禁止运行 `pnpm run build:app`、`pnpm run dev:app`、APK 签名、安装或发布命令;构建产物不加入提交。 + +- [ ] **Step 4: 运行已有后端业务回归** + +工作目录:`shierfeng-fastapi-backend`,使用当前已配置 Python 环境,不安装依赖: + +```bash +PYTHONDONTWRITEBYTECODE=1 python -m pytest tests/module_business -q +ruff format --check module_business tests/module_business +ruff check module_business tests/module_business +``` + +预期:已有后端业务测试、格式检查和静态检查通过;若本地 Python 环境无法执行,记录实际错误,不伪造通过结果。 + +- [ ] **Step 5: 做最终 diff 与敏感信息检查** + +仓库根目录执行: + +```bash +git diff --check HEAD~9..HEAD +rg -n --fixed-strings 'admin123' shierfeng-fastapi-app/src shierfeng-fastapi-app/tests +rg -n 'Bearer[[:space:]]+[A-Za-z0-9._-]+' shierfeng-fastapi-app/src shierfeng-fastapi-app/tests +git status --short +``` + +敏感信息检查应无命中;`git status` 中只允许出现本计划任务的 PDA 文件和用户原有改动,不能出现由构建生成且准备提交的 APK、环境文件、日志或缓存。若提交数量因任务合并而不同,将 `HEAD~9..HEAD` 改为本次 PDA 首个提交到当前 `HEAD` 的实际范围,不扩大检查目标。 + +- [ ] **Step 6: 按人工验收清单交付** + +在受控本地环境手工验证:管理员和受限普通用户登录;未授权入口不显示且直接接口返回 403;HID 扫码回车、重复扫码、空值和网络重试;库存余量、托盘状态、产线绑定、退回冻结与事件追溯均来自服务端;页面刷新和断网重试不重复扣减库存。管理员密码只在人工输入,不记录到终端输出、源码或日志。 + +## 计划自检 + +- 设计文档的范围、五类流程、认证安全、扫码输入、状态/幂等、验证、部署边界分别由 Tasks 1–9 覆盖。 +- 所有跨任务接口在“已核实的后端接口与字段”和各任务的 `Interfaces` 中定义,页面调用名称与 API 导出名称保持一致。 +- 没有新增依赖、后端契约改动、摄像头 SDK、APK 构建或凭据写入步骤。 +- 实现前先写失败契约,随后运行最小测试,再执行 H5 和后端回归;每个任务都以显式路径提交。