Files
ShiErFeng/docs/superpowers/plans/2026-09-20-pda-business-workflow-implementation.md
T

722 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 原生 `<input>`,同时绑定 `@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` 不再包含旧默认密码或其他密码字面量。测试也要断言入口跳转路径与页面路径一致。
- [ ] **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 '旧默认密码' 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 和后端回归;每个任务都以显式路径提交。