diff --git a/AGENTS.md b/AGENTS.md index 0152fc8..af896c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ ## 作业流程原则 - 扫码是主路径:输入焦点、连续扫描、防抖、重复码、无效码、错箱和扫描反馈必须作为一等场景处理。 -- 普通散箱拣货的扫描身份是后端下发的稳定箱唛箱码 `boxCode`,一次有效且未重复的箱码扫描表示拣出一箱;一件代发已封托的 `SEALED_PALLET` 明细允许扫描系统 `palletNo`、扫描托内任一成员 `boxCode` 或在明细卡片手工确认整托,但三种入口都必须按计划快照中的 `palletNo` 以 `PICK_PALLET` 一次性提交,不能逐箱累计、部分拣货或跳拣。SKU、商品件数、入库单号和页面展示文案只用于辅助识别,不得作为扫码匹配键。 +- 普通散箱拣货的扫描身份是后端下发的稳定箱唛箱码 `boxCode`,一次有效且未重复的箱码扫描表示拣出一箱;一件代发已封托的 `SEALED_PALLET` 明细允许扫描系统 `palletNo`、扫描托内任一成员 `boxCode` 或在明细卡片手工确认整托,但三种入口都必须按计划快照中的 `palletNo` 以 `PICK_PALLET` 一次性提交,不能逐箱累计、部分拣货或跳拣;只按权威 `unitType=SEALED_PALLET` 识别整托,不能从资源 ID 或 `indivisible` 猜测。SKU、商品件数、入库单号和页面展示文案只用于辅助识别,不得作为扫码匹配键。 - 拣货明细卡片按现场找货优先级展示库位、托盘码或入仓码、待拣箱数和处理状态;库位从明细 `inventoryResources.locationCode` 去重汇总,缺失时明确显示“库位 —”。商品名称和 SKU 数据可以保留在响应模型中,但不占用 PDA 拣货卡片主区域。 - 进入任务时由服务端标记“拣货中”;每次有效箱码或托盘码扫描,以及确认拣出、部分拣货、跳拣操作,都必须立即写入服务端,设备持久化草稿只用于恢复页面状态。底部提交只原子收尾尚未处理的明细并进入装柜取证,不重复提交已落库动作。 - 允许部分拣货和跳拣,但每个未完成项必须填写异常原因;存在未解决异常时任务挂起,不能完成或推进 `LOADED`。 @@ -27,6 +27,8 @@ - 提交成功或明确放弃后清理草稿;退出应用或设备重启后同一作业员、任务和柜次可恢复,其他账号不得继承。 - PDA 仅允许现场拍照;Admin 可通过独立受权入口人工上传。五类证据各至少 1 张有效照片,同类型允许多张追加;错误或重复照片标记作废并保留原因、操作人和时间,不硬删除。设备端证据查询、上传、预览和作废统一按当前 `taskId + warehouseId` 走任务专用 API,服务端再绑定真实柜 `containerBookingId`;不得调用旧入仓单/客户作用域证据接口。 - 与管理后台头程订单、仓库任务和 `baiyue-firstleg` Controller/OpenAPI 对齐稳定标识、状态机、时间和异常码,禁止在前端自行发明另一套业务状态。 +- 一件代发清点组托使用独立 `/first-leg/pda-dropship` 任务链:箱码先经只读识别再确认实收;系统托盘码可先于或晚于箱码扫描。`count` 携托盘码时直接绑定,箱码先扫则进入待绑托区并由 `bind-pending` 批量绑定;换托只使用服务端 `cartonId`,封托后按整托不可拆。只消费 `platform:firstleg:pda:view` 与 `platform:firstleg:pda:work` 权限结果,不在 PDA 自建权限语义。 +- 清点组托使用测量审核预生成的 DP 托盘;扫描托盘通过 `bind-pending` 原子绑定待绑箱。幂等键按操作员、任务、载荷和版本稳定生成,失败重试复用;换托先移回待绑区再扫描目标托盘,封托不可拆分。装柜拣货扫描后等待正式整托 API 成功再更新本地结果。 ## 设备、安全与可用性 @@ -56,10 +58,10 @@ | 登录与会话 | `pages/login/`、`stores/session.js` | 远程鉴权适配和会话清理 | | 工作台、仓库选择与模块入口 | `pages/home/`、`components/PortalHeader.vue`、`stores/session.js`、`pages/module/`、`pages/operation/` | 左上角只提供启用国内仓选择,选择结果持久化;`data/demo-data.js` 的 `visible` 控制展示、`available` 控制可操作;隐藏能力不得删除源码或路由 | | 待处理任务、服务端就绪与领取 | `pages/tasks/tasks.vue` | `api/pda-loading.js` 的 `listLoadingOrders`、`getContainerPickingTask`、`claimContainerPickingTask`;合并任务和缺少柜号/仓库/版本的任务不可进入 | -| 一件代发 | `pages/dropship-tasks/dropship-tasks.vue`、`pages/dropship-palletization/dropship-palletization.vue` | `api/pda-dropship.js`;作为入库作业,测量审核按预计托数预生成 DP 托盘码,PDA 不展开全部托盘卡片,直接扫码并只展示当前操作托盘;全部预生成托盘码合并为每托一页的多页 A4 PDF,含 SKU 明细但不直接驱动打印机;PDA 明确执行逐箱清点并支持先扫箱或先扫托、归托和逐托封托;同一国内作业仓的多票可继续装入同一开放托盘,单票全部归托不等于托盘装满,实物装满后才封托;预计托数与已生成托盘数不一致时失败关闭;公网前缀 `/api/platform/v1/first-leg/pda-dropship/**` | +| 一件代发 | `pages/dropship-tasks/dropship-tasks.vue`、`pages/dropship-palletization/dropship-palletization.vue` | `api/pda-dropship.js`;作为入库作业,测量审核按预计托数预生成 DP 托盘码,PDA 不展开全部托盘卡片,直接扫码并只展示当前操作托盘;全部预生成托盘码合并为每托一页的多页 A4 PDF,含 SKU 明细但不直接驱动打印机;PDA 明确执行逐箱清点并支持先扫箱或先扫托、归托、换托/移回待绑托和逐托封托;同一国内作业仓的多票可继续装入同一开放托盘,单票全部归托不等于托盘装满,实物装满后才封托;预计托数与已生成托盘数不一致时失败关闭;公网前缀 `/api/platform/v1/first-leg/pda-dropship/**` | | 业务编号展示与错误脱敏 | `utils/user-facing.js`、`tests/user-facing-identifiers.test.mjs` | 任务和作业页优先显示真实集装箱号,缺失时显示柜次号,再缺失显示“集装箱号 —”;不得展示 taskId、inboundId、orderId、loadingPlanId、containerBookingId 或长整型内部 ID,错误仍保留 code、requestId、traceId | | 拣货任务与箱码扫码 | `pages/picking/`、`stores/picking.js` | `api/pda-loading.js`、`utils/scan-broadcast.js`、`components/ScanTargetInput.vue`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml` | -| 装柜取证与完成 | `pages/picking/picking.vue` 的 `evidence` 阶段 | 正式边界是 `api/pda-loading.js` 的证据查询、预览、上传、作废和 `completeContainerPickingTask` 全部按当前任务与仓库进入;不得保留按入仓单/客户作用域的旧证据调用。上传队列 `utils/evidence-upload-queue.js`,合同 `tests/pda-loading-api.test.mjs`、`tests/picking-evidence-stage.test.mjs` | +| 一件代发清点组托状态与草稿 | `pages/dropship-palletization/dropship-palletization.vue` | `stores/dropship-palletizing.js`、`utils/dropship-palletizing-state.js`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml`,合同 `tests/dropship-palletizing-*.test.mjs` | | 装柜取证与完成 | `pages/picking/picking.vue` 的 `evidence` 阶段 | 正式边界是 `api/pda-loading.js` 的证据查询、预览、上传、作废和 `completeContainerPickingTask` 全部按当前任务与仓库进入;不得保留按入仓单/客户作用域的旧证据调用。上传队列 `utils/evidence-upload-queue.js`,合同 `tests/pda-loading-api.test.mjs`、`tests/picking-evidence-stage.test.mjs`、`tests/seal-save-idempotency.test.mjs` | | 扫描诊断 | `pages/scanner-test/` | `utils/scan-broadcast.js` 与广播 Action/Extra | | 作业记录与人员 | `pages/history/`、`pages/profile/` | session 与本地敏感数据清理 | diff --git a/README.md b/README.md index 66c534b..d22491f 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ - `pages/scanner-test/scanner-test`:Android 硬件扫描广播诊断页; - `pages/picking/picking`:出库拣货完整交互。 +一件代发清点组托只调用 Firstleg 的 `/first-leg/pda-dropship` 正式接口。正式入口沿用 `pages/dropship-tasks/dropship-tasks` 和 `pages/dropship-palletization/dropship-palletization`。箱码先经 `cartons/resolve` 只读识别,确认后才调用 `cartons/count`;先扫托盘时确认箱码直接绑定当前托盘,先扫多箱时箱码进入待绑托区,后扫系统托盘码通过 `pallets/bind-pending` 批量绑定。所有写操作要求在线并携带稳定幂等标识,冲突后刷新权威任务;封托后的托盘只读并作为后续分柜、装柜的不可拆单元。 + 登录会话集中在 `stores/session.js` 并持久化到设备,拣货会话集中在 `stores/picking.js`。认证请求由 `api/http.js` 统一处理:Access Token 临近过期时使用 Refresh Token 轮换;并发请求共享一次刷新;401 刷新成功后只重放一次;Refresh Token 明确失效时清理会话和作业草稿并返回登录页,网络或服务异常不会误清会话。产品契约要求未提交拣货草稿持久化到设备,并绑定作业员、任务和柜次,其他账号不能继承;成功提交或明确放弃后才清理。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。 拣货页会在设备端保留当前任务草稿和最近扫码结果,但正式作业以服务端事实为准:进入任务后由后端标记“拣货中”,每次有效箱码/托盘码扫描及“确认拣出、部分拣货、跳拣”操作立即写入服务端,后台智能装柜据此锁定已发生实物拣货的明细。底部提交只负责收尾当前仍未处理的明细并进入装柜取证;网络失败时页面不得先显示成功。允许部分拣货或跳拣,但每个未完成项必须填写异常原因,并挂起任务、阻断完成和 `LOADED`。 diff --git a/api/pda-dropship.js b/api/pda-dropship.js index 67a9e00..a00706c 100644 --- a/api/pda-dropship.js +++ b/api/pda-dropship.js @@ -1,4 +1,5 @@ import { apiDownload, apiRequest } from '@/api/http.js' +import { stableOperationId } from '@/utils/idempotency.js' import { session } from '@/stores/session.js' const ROOT = '/first-leg/pda-dropship' @@ -7,9 +8,8 @@ function stableId(value) { return String(value ?? '').trim() } -function operationId(action, taskId) { - const random = Math.random().toString(36).slice(2, 10) - return `pda-dropship-${action}-${taskId}-${Date.now()}-${random}`.slice(0, 128) +function operationId(action, taskId, payload = {}) { + return stableOperationId('dropship-' + action, String(session.currentUser?.id) + '-' + taskId, payload) } export function listDropshipPalletizationTasks(keyword = '', warehouseId) { @@ -33,15 +33,15 @@ export function resolveDropshipCarton(taskId, warehouseId, boxCode) { } export function countDropshipCarton(taskId, payload) { - return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/cartons/count`, method: 'POST', data: { ...payload, warehouseId: stableId(payload.warehouseId), operationId: payload.operationId || operationId('count', taskId) } }) + return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/cartons/count`, method: 'POST', data: { ...payload, warehouseId: stableId(payload.warehouseId), operationId: payload.operationId || operationId('count', taskId, payload) } }) } -export function bindDropshipPendingCartons(taskId, warehouseId, palletNo) { - return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/bind-pending`, method: 'POST', data: { warehouseId: stableId(warehouseId), palletNo: String(palletNo || '').trim().toUpperCase(), operationId: operationId('bind', taskId) } }) +export function bindDropshipPendingCartons(taskId, warehouseId, palletNo, taskVersion) { + return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/bind-pending`, method: 'POST', data: { warehouseId: stableId(warehouseId), palletNo: String(palletNo || '').trim().toUpperCase(), operationId: operationId('bind', taskId, { warehouseId, palletNo, taskVersion }) } }) } export function sealDropshipPallet(taskId, warehouseId, pallet) { - return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/${encodeURIComponent(pallet.palletId)}/seal`, method: 'POST', data: { warehouseId: stableId(warehouseId), expectedVersion: pallet.version, operationId: operationId('seal', taskId) } }) + return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/${encodeURIComponent(pallet.palletId)}/seal`, method: 'POST', data: { warehouseId: stableId(warehouseId), expectedVersion: pallet.version, operationId: operationId('seal', taskId, { warehouseId, palletId: pallet.palletId, version: pallet.version }) } }) } export function downloadDropshipPalletLabel(taskId, warehouseId, palletId) { @@ -69,3 +69,23 @@ export function downloadAllDropshipPalletLabels(taskId, warehouseId) { return response.tempFilePath }) } + +function requireValue(value, message) { + if (value === null || value === undefined || value === '') throw new Error(message) +} + +function operationHeader(operationId) { + return operationId ? { 'Idempotency-Key': String(operationId) } : {} +} + + +export function moveDropshipCarton(taskId, cartonId, payload = {}) { + requireValue(cartonId, '缺少箱明细标识') + for (const [field, message] of [['warehouseId', '缺少作业仓库标识'], ['operationId', '缺少操作幂等标识']]) requireValue(payload[field], message) + return apiRequest({ + url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/cartons/${encodeURIComponent(cartonId)}/move`, + method: 'POST', + header: operationHeader(payload.operationId), + data: { warehouseId: payload.warehouseId, targetPalletNo: payload.targetPalletNo || null, operationId: payload.operationId } + }) +} diff --git a/api/pda-loading.js b/api/pda-loading.js index b8b7157..cd26ccc 100644 --- a/api/pda-loading.js +++ b/api/pda-loading.js @@ -126,6 +126,7 @@ export async function submitContainerPickingBatch(task, actions, { warehouseId, const items = (actions || []).map((action) => ({ itemId: action.itemId, action: action.type, + palletNo: action.type === 'PICK_PALLET' ? action.palletNo : null, pickedPackageCount: action.type === 'PICK' ? action.pickedPackageCount : null, finalPickedPackageCount: action.type === 'PARTIAL' ? action.finalPickedPackageCount : null, version: action.version, diff --git a/pages/dropship-palletization/dropship-palletization.vue b/pages/dropship-palletization/dropship-palletization.vue index 72d60f5..6baeef9 100644 --- a/pages/dropship-palletization/dropship-palletization.vue +++ b/pages/dropship-palletization/dropship-palletization.vue @@ -54,12 +54,13 @@ - 2. 托盘操作直接扫描托盘码,只显示当前操作的托盘 + 2. 托盘操作直接扫描托盘码,只显示当前操作的托盘;待绑托箱扫描目标托盘后批量归托 已生成 {{ task.generatedPalletCount || 0 }} 个托盘码 {{ currentPallet.palletNo }}{{ currentPallet.status === 'SEALED' ? '已封托' : '当前托盘' }} {{ currentPallet.cartonCount || 0 }} 箱 · {{ currentPallet.skuCount || 0 }} SKU · {{ currentPallet.weightKg || 0 }} kg · {{ currentPallet.volumeCbm || 0 }} m³ + {{ item.boxCode }} @@ -71,9 +72,13 @@