diff --git a/AGENTS.md b/AGENTS.md index 6771a53..c08d50d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,7 @@ | 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` | 个人中心可选择并持久化 `zh-CN`、`en`、`es`;当前业务翻译资源仍为空,页面业务文案继续使用中文,不得把语言选择入口误报为全量翻译完成 | | 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 签收前展开显示国内空仓、半仓、满仓、锁仓和带锁码五类整柜只读照片,收货中折叠查看,单图加载失败只允许重试且不阻断拆柜;海外三个固定照片角色各一张,支持现场拍照、鉴权预览及更换失败保留原图;多计划同柜直接展示服务端 `assigned/mergeAllowed/urgent/mergeHint`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,不在设备端重新分配。Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` | | 退货入库签收与合箱 | `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`、`src/api/return-receipt.js`、`src/api/return-packing.js`、`src/stores/warehouse-work.js` | 签收优先扫描退货单号,也可扫描 SKU 后选择候选退货单,按本包实际 SKU 数量生成退货箱与计费批次;合箱只选择同客户已完成退货单的可用库存并录入新箱箱规;公网前缀 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**` | +| 仓储转运测量、打托与标签 | `src/pages/warehouse-transfer/warehouse-transfer.vue`、`src/api/warehouse-transfers.js` | 工作台键 `warehouseTransfer`;候选→详情→逐托测量草稿→生成新托盘号→扫码绑定→照片与完成打托。API 模块路径 `/pda/v1/last-leg/warehouse-transfers` 经统一海外请求层处理;不是 Admin 转运审核或排车转派送。回归 `tests/warehouse-transfer-api.test.mjs` | | 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` | 扫码查询托盘内容,按服务端返回的托盘/库存版本执行组托、拆托、换托和解绑;公网前缀 `/api/pda/v1/last-leg/pallet-operations/**` | | 库存盘点(入口隐藏) | `src/pages/stocktake/stocktake.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 底层创建、恢复、计数与提交实现仍保留;当前 `src/data/overseas-workbench.js` 不注册库存盘点,不向现场开放入口 | | 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 | @@ -56,6 +57,14 @@ ## 海外业务接口边界 +- 仓储转运按托盘分别保存箱数、长宽高(cm)和毛重(kg),体积按本托尺寸计算;短收/超收仅展示差异,不以预报箱数强制核平。生成托盘号后锁定该卡测量数据,必须取得服务端绑定确认且至少有一张现场照片才能完成打托;完成请求只提交稳定幂等键和照片文件 ID,不重复提交整份托盘测量。重新进入从详情恢复托盘和鉴权照片;标签使用服务端 `labelDownloadPath` 下载 PDF,打开文档不等于真机打印完成。 + +- 换标目标确认即提交拆托合托,不再二次点击底部提交。`RelabelPalletWork.vue` 按当前轮次 `PALLET_BIND` 最新记录提供逐箱改绑,扫描新目标后弹窗确认,使用既有 `relabel-disassemble` 精确来源路径(不传 `actualSource`);同目标不发写请求。服务器持有库存、托盘版本与幂等约束,已排车或出库禁止直接改绑。离线回归 `tests/relabel-pallet-work.test.mjs`,不等同真机验收。 + +- SKU 正常作业固定显示一个旧SKU扫描框,通过 `relabel-tasks/{relabelNo}/skus/scan` 提交 itemId、skuIndex 和旧SKU,不要求扫描新标签;多箱同SKU先选择具体明细。服务端每条保存 `SKU_SCAN` 历史,页面按当前返工轮次隐藏已完成子项;全部SKU完成才完成箱的SKU阶段,混合单仍需箱唛阶段。箱唛待处理列表同样隐藏已完成项。回归 `tests/relabel-quick-sku.test.mjs`。 + +- 换标执行页内嵌 `src/components/RelabelPalletWork.vue`:`relabel-sources` 展示原入仓单各托盘箱数;选择来源库存行并扫描实托、输入拆出箱数、核对本批已完成换标箱,再扫描目标托盘合托。每次一个来源库存行,可分批处理多个托盘,无需为拆托额外逐箱扫码。`relabel-disassemble.actualSource` 原子调整本单冻结记账来源并移动库存,目标仍归原入仓单,不新建入库。仅同新目的仓合托;新增托盘不等待客户确认,409后刷新重扫。H5 提供标签打印,原生打印设备适配尚未完成;不能宣称生成托盘号即打印成功。回归 `tests/relabel-pallet-work.test.mjs`。 + - 换标任务标明类型,仅显示实际明细需要的 Tab。混合明细同时进入箱唛/SKU 作业,服务端分开保存两部分进度,`cartonProcessedPackages/skuProcessedPackages` 决定各 Tab 待处理数量;两项均完成后整箱计数一次,不能仅扫箱唛就视为 SKU 完成。 - 换标完工提交使 PDA 任务进入 `WAIT_REVIEW`,申请进入 `CUSTOMER_CONFIRM_PENDING`,客户确认原子完成复核并释放后续履约资格。PDA 复核池只读;平台通过专用 `/rework` 接口携带任务版本、幂等键和必填备注退回,历史事实归档后重新计数,PDA 展示 `reviewRemark` 并清空上一轮输入和照片草稿。 @@ -79,6 +88,7 @@ ## 质量门禁 - 安装:`npm install`;检查:`npm test`。 +- 仓储转运测量、打托、照片或标签入口变更运行 `node --test tests/warehouse-transfer-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;静态合同不替代真实扫码、绑定及打印验收。 - 退货签收、合箱或工作台入口变更先运行 `node --test tests/return-receipt-api.test.mjs tests/return-packing-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;离线检查不替代真实 API 联调与真机扫码验收。 - 当前 uni-app H5 编译链要求 `vue-i18n` 精确固定为 `9.1.9`;不得改回范围版本或单独升级。依赖变更必须同步 `package.json`、`package-lock.json` 与现有 `pnpm-lock.yaml`,并至少执行 H5 编译门禁。 - 正式产品形态是海外 PDA App;`npm run build:h5` 仅用于浏览器调试和模板编译门禁,App 资源编译使用 `npm run build:app`。 diff --git a/src/api/inbound.js b/src/api/inbound.js index 1d22e16..1a62731 100644 --- a/src/api/inbound.js +++ b/src/api/inbound.js @@ -14,8 +14,9 @@ export const getCurrentWarehouseProfile = () => { } return warehouseProfileRequest } -export const getReceivingContainer = async (containerNo) => normalizeReceivingSession(await overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}`)) -export const prepareReceivingContainer = async (containerNo, request) => normalizeReceivingSession(await overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare`, request)) +const receivingIdentityQuery = (sourceContainerBookingId) => `sourceContainerBookingId=${encodeURIComponent(sourceContainerBookingId)}` +export const getReceivingContainer = async (containerNo, sourceContainerBookingId) => normalizeReceivingSession(await overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}?${receivingIdentityQuery(sourceContainerBookingId)}`)) +export const prepareReceivingContainer = async (containerNo, sourceContainerBookingId, request) => normalizeReceivingSession(await overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare?${receivingIdentityQuery(sourceContainerBookingId)}`, request)) export const uploadCurrentUnloadPhoto = (filePath) => overseasUpload(current('/unload-photos/files'), filePath) export const downloadCurrentUnloadPhoto = (downloadPath) => overseasDownload(downloadPath, { expectedContentTypePrefix: 'image/' }) export const downloadCurrentDomesticLoadingPhoto = (downloadPath) => overseasDownload(downloadPath, { expectedContentTypePrefix: 'image/' }) diff --git a/src/api/overseas.js b/src/api/overseas.js index 44d70df..f1c7d97 100644 --- a/src/api/overseas.js +++ b/src/api/overseas.js @@ -42,6 +42,13 @@ async function tokenAfterUnauthorized(failedToken) { function overseasUrl(path) { const base = String(session.apiBase || '').replace(/\/$/, '') + const nativeRuntime = typeof plus !== 'undefined' + || (typeof location !== 'undefined' && location.protocol === 'file:') + if (nativeRuntime && !/^https?:\/\//i.test(base)) { + const error = new Error('海外服务地址未配置:App 必须设置绝对 HTTP(S) 地址 VITE_OVERSEAS_API_BASE_URL') + error.code = 'PDA_OVERSEAS_API_BASE_REQUIRED' + throw error + } const value = `/${String(path || '').replace(/^\/+/, '')}` const normalized = /\/(?:api|overseas-api)$/.test(base) ? value.replace(/^\/api(?=\/)/, '') @@ -79,14 +86,53 @@ export async function overseasRequest(method, path, data, retry = true) { } function rawUpload(path, filePath, name, token) { + const browserFile = typeof Blob !== 'undefined' && filePath instanceof Blob + if (!browserFile && !String(filePath || '').trim()) { + const error = new Error('未获取到可上传的照片路径,请重新拍摄') + error.code = 'PDA_UPLOAD_FILE_PATH_REQUIRED' + return Promise.reject(error) + } + let url + try { url = overseasUrl(path) } catch (error) { return Promise.reject(error) } + const h5Runtime = typeof window !== 'undefined' && typeof plus === 'undefined' + if (h5Runtime && browserFile) return new Promise((resolve, reject) => { + uni.uploadFile({ + url, files: [{ name, file: filePath }], + header: { Authorization: `Bearer ${token}` }, + success: resolve, + fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true, uploadUrl: url })) + }) + }) + if (h5Runtime && /^blob:/i.test(filePath)) { + const selectedFile = fetch(filePath).then((source) => { + if (!source.ok) throw new Error('无法读取已选择的照片') + return source.blob() + }) + return selectedFile.then(async (blob) => { + const form = new FormData() + const suffix = String(blob.type || '').split('/')[1] || 'jpg' + form.append(name, blob, blob.name || `pda-photo.${suffix}`) + if (typeof XMLHttpRequest !== 'undefined') return new Promise((resolve, reject) => { + const request = new XMLHttpRequest() + request.open('POST', url) + request.setRequestHeader('Authorization', `Bearer ${token}`) + request.onload = () => resolve({ statusCode: request.status, data: request.responseText, header: {} }) + request.onerror = () => reject(new Error('海外作业文件上传失败')) + request.send(form) + }) + const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form }) + return { statusCode: response.status, data: await response.text(), header: Object.fromEntries(response.headers.entries()) } + }) + .catch((cause) => { throw Object.assign(new Error(cause.message || '海外作业文件上传失败'), { network: true, uploadUrl: url }) }) + } return new Promise((resolve, reject) => { uni.uploadFile({ - url: overseasUrl(path), + url, filePath, name, header: { Authorization: `Bearer ${token}` }, success: resolve, - fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true })) + fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true, uploadUrl: url })) }) }) } diff --git a/src/api/pallet.js b/src/api/pallet.js index 8393493..d90081f 100644 --- a/src/api/pallet.js +++ b/src/api/pallet.js @@ -1,6 +1,9 @@ import { overseasRequest } from './overseas.js' const BASE = '/pda/v1/last-leg/pallet-operations' +export const findRelabelSources = (relabelNo) => overseasRequest('POST', `${BASE}/relabel-sources`, { relabelNo }) +export const createRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-pallet-label`, payload) +export const bindRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-disassemble`, payload) export function resolvePallet(code) { return overseasRequest('POST', `${BASE}/pallets/resolve`, { code: String(code || '').trim() }) diff --git a/src/api/relabel.js b/src/api/relabel.js index d0d24f0..f40d041 100644 --- a/src/api/relabel.js +++ b/src/api/relabel.js @@ -2,6 +2,7 @@ import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js const BASE = '/pda/v1/last-leg/relabel-tasks' const encoded = (value) => encodeURIComponent(String(value || '').trim()) +export const scanRelabelSku = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/skus/scan`, payload)) function asList(value) { return Array.isArray(value) ? value : [] } diff --git a/src/api/warehouse-transfers.js b/src/api/warehouse-transfers.js new file mode 100644 index 0000000..90e7479 --- /dev/null +++ b/src/api/warehouse-transfers.js @@ -0,0 +1,51 @@ +import { overseasDownload, overseasRequest, overseasUpload } from './overseas.js' + +const BASE = '/pda/v1/last-leg/warehouse-transfers' +const encoded = (value) => encodeURIComponent(String(value || '').trim()) + +export async function listWarehouseTransferCandidates() { + const value = await overseasRequest('GET', `${BASE}/candidates`) + return Array.isArray(value) ? value : (Array.isArray(value?.records) ? value.records : []) +} + +export function getWarehouseTransferDetail(transferNo) { + return overseasRequest('GET', `${BASE}/${encoded(transferNo)}`) +} + +export function palletizeWarehouseTransfer(transferNo, payload) { + return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/palletize`, payload) +} + +export function saveWarehouseTransferPalletDraft(transferNo, payload) { + return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/pallets/drafts`, payload) +} + +export function generateWarehouseTransferPallet(transferNo, palletId, payload) { + return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/pallets/${encoded(palletId)}/generate`, payload) +} + +export function bindWarehouseTransferPallet(transferNo, palletId, payload) { + return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/pallets/${encoded(palletId)}/bind`, payload) +} + +export function deleteWarehouseTransferPallet(transferNo, palletId) { + return overseasRequest('DELETE', `${BASE}/${encoded(transferNo)}/pallets/${encoded(palletId)}`) +} + +export function uploadWarehouseTransferMeasurementPhoto(transferNo, filePath) { + return overseasUpload(`${BASE}/${encoded(transferNo)}/measurement-photos/files`, filePath) +} + +export function listWarehouseTransferMeasurementPhotos(transferNo) { + return overseasRequest('GET', `${BASE}/${encoded(transferNo)}/measurement-photos/files`) +} + +export function downloadWarehouseTransferMeasurementPhoto(transferNo, fileId) { + return overseasDownload(`${BASE}/${encoded(transferNo)}/measurement-photos/files/${encoded(fileId)}/download`) +} + +export function downloadWarehouseTransferPalletLabel(labelDownloadPath) { + const path = String(labelDownloadPath || '').trim() + if (!path) return Promise.reject(new Error('服务端未返回托盘标签下载地址')) + return overseasDownload(path) +} diff --git a/src/components/RelabelPalletWork.vue b/src/components/RelabelPalletWork.vue new file mode 100644 index 0000000..3a417eb --- /dev/null +++ b/src/components/RelabelPalletWork.vue @@ -0,0 +1,155 @@ + + + diff --git a/src/config/runtime.js b/src/config/runtime.js index 26cdef6..6ab17d4 100644 --- a/src/config/runtime.js +++ b/src/config/runtime.js @@ -18,7 +18,10 @@ selectedOverseasApiRoot = runtimeEnvironment.VITE_H5_OVERSEAS_API_BASE_URL || '/ // root there, even if a stale H5 bundle or compiler flag was reused. if (nativeRuntime) { selectedAuthApiRoot = configuredAuthApiRoot || selectedAuthApiRoot - selectedOverseasApiRoot = configuredOverseasApiRoot || selectedOverseasApiRoot + // Native file:// builds cannot use the H5-only /overseas-api Vite proxy. + // Keep the missing value explicit so the request layer can report a useful + // configuration error instead of surfacing an opaque uploadFile:fail. + selectedOverseasApiRoot = configuredOverseasApiRoot || '' } export const API_ROOT = String(selectedAuthApiRoot).replace(/\/$/, '') diff --git a/src/data/overseas-workbench.js b/src/data/overseas-workbench.js index 6ea3219..77d37f4 100644 --- a/src/data/overseas-workbench.js +++ b/src/data/overseas-workbench.js @@ -1,6 +1,7 @@ export const MODULES = [ { key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '处理拆柜收货与客户退货实收入仓', descriptionKey: 'modules.inbound.description', operations: [ { key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true }, + { key: 'warehouseTransfer', name: '仓储转运测量', nameKey: 'modules.warehouseTransfer.name', icon: '转', iconType: 'compose', description: '回填箱规、箱数并完成托盘打托', descriptionKey: 'modules.warehouseTransfer.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer' }, { key: 'returnReceipt', name: '退货入库', nameKey: 'modules.returnReceipt.name', icon: '退', iconType: 'undo', description: '扫描退货单或 SKU,按本包实收数量生成退货库存', descriptionKey: 'modules.returnReceipt.description', available: true, route: '/pages/return-receipt/return-receipt' }, { key: 'returnPacking', name: '退货合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' } ] }, diff --git a/src/manifest.json b/src/manifest.json index 1c7949d..6153bb4 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,8 +2,8 @@ "name" : "柏悦物流 PDA 海外版", "appid" : "__UNI__936D079", "description" : "柏悦物流海外仓 PDA 作业端", - "versionName" : "0.0.2", - "versionCode" : 2, + "versionName" : "0.0.3", + "versionCode" : 3, "transformPx" : false, "app-plus" : { "usingComponents" : true, @@ -83,5 +83,7 @@ "devServer" : { "port" : 5176 } - } + }, + "locale" : "zh-Hans", + "fallbackLocale" : "zh-Hans" } diff --git a/src/pages.json b/src/pages.json index cd5d8bf..31803c6 100644 --- a/src/pages.json +++ b/src/pages.json @@ -4,6 +4,7 @@ { "path": "pages/home/home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓工作台", "backgroundColor": "#F5F7FA" } }, { "path": "pages/module/module", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓业务", "backgroundColor": "#F5F7FA" } }, { "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柜号入库与现场作业", "backgroundColor": "#F5F7FA" } }, + { "path": "pages/warehouse-transfer/warehouse-transfer", "style": { "navigationStyle": "custom", "navigationBarTitleText": "仓储转运测量", "backgroundColor": "#F5F7FA" } }, { "path": "pages/return-receipt/return-receipt", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货入库", "backgroundColor": "#F5F7FA" } }, { "path": "pages/return-packing/return-packing", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货合箱", "backgroundColor": "#F5F7FA" } }, { "path": "pages/pallet/pallet", "style": { "navigationStyle": "custom", "navigationBarTitleText": "托盘管理", "backgroundColor": "#F5F7FA" } }, diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue index 7d76efc..52de268 100644 --- a/src/pages/operation/operation.vue +++ b/src/pages/operation/operation.vue @@ -13,7 +13,7 @@ {{ candidateError.message || '待卸货集装箱加载失败' }}code={{ candidateError.code }}requestId={{ candidateError.requestId }}traceId={{ candidateError.traceId }} 正在刷新待卸货集装箱… 暂无待卸货集装箱 - + {{ item.containerNo }}计划到仓:{{ formatPlannedArrival(item.plannedArrivalAt) }}{{ item.operable ? statusLabel(item.status) : '待齐全' }}{{ item.operable ? (item.currentAccount ? '继续' : '详情') : '只读' }} › {{ item.totalOrderCount }}总订单{{ item.inboundCodeCount }}入仓码{{ item.planCount }}已生成计划{{ item.pendingPlanCount }}未操作 已生成 {{ item.planCount }} 个入库计划,{{ item.pendingPlanCount }} 个未操作 @@ -144,6 +144,8 @@ import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' import { completeCurrentReceiving, downloadCurrentDomesticLoadingPhoto, downloadCurrentUnloadPhoto, getCurrentReceiving, getCurrentWarehouseProfile, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, receiveCurrentWholePallet, resolveCurrentCarton, resolveCurrentPallet, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js' const MAX_UNLOAD_PHOTO_BYTES = 10 * 1024 * 1024 + const MAX_PACKAGES_PER_PALLET = 66 + const PALLET_CAPACITY_MESSAGE = '一个托盘最多只能收66箱,请拆分到其他托盘' export default { components: { PortalHeader, ReceivingSessionSummary, ScanTargetInput }, @@ -168,6 +170,7 @@ onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates')) this.bootstrap() }, onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() }, methods: { + candidateKey(item) { return `${item?.sourceContainerBookingId || ''}:${item?.containerNo || ''}` }, statusLabel(status) { return ({ WAIT_SIGN: '待签收', SIGNED: '已签收', RECEIVING: '收货中', COMPLETED: '已完成', COMPLETED_WITH_DIFFERENCE: '差异待核平' })[status] || status }, statusTone(status) { return status === 'RECEIVING' ? 'status-current' : status === 'COMPLETED' ? 'status-good' : 'status-wait' }, formatPlannedArrival(value) { const text = String(value || '').trim(); return text ? text.replace('T', ' ').slice(0, 16) : '待确认' }, @@ -190,9 +193,9 @@ async bootstrap() { if (this.loading) return; this.loading = true; this.error = null; this.detailOpen = false; try { await this.refreshBoundWarehouse(); try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.candidateError = null } catch (error) { if (!isNoCurrentReceiving(error)) throw error; resetReceivingDraft(); await this.loadCandidates(false, false) } this.bootstrapped = true } catch (error) { this.candidateError = error } finally { this.loading = false } }, async recoverCurrentAfterConflict(error, preserveEvidence = false) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.detailOpen = false; this.error = preserveEvidence ? error : null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { if (isNoCurrentReceiving(recoveryError)) { this.detailOpen = false; receiving.selected = null; await this.loadCandidates(false); this.candidateError = preserveEvidence ? error : null; this.error = preserveEvidence ? error : null; this.showToast('柜状态已变化,列表已刷新', 'warning'); return true } this.error = error; return false } }, async loadCandidates(feedback = true, refreshWarehouse = true) { if (this.candidateLoading) return; this.candidateLoading = true; this.candidateError = null; this.error = null; try { if (refreshWarehouse) await this.refreshBoundWarehouse(); const values = await listReceivingCandidates(); receiving.candidates = values.map((item) => this.validateReceivingWarehouse(item, `柜号 ${item.containerNo || '—'}`)); if (feedback) this.showToast('刷新待卸柜成功', 'success') } catch (error) { receiving.candidates = []; this.candidateError = error; if (feedback) { playErrorSound(); this.showToast(error.message || '刷新待卸柜失败', 'error') } } finally { this.candidateLoading = false } }, - async openContainer(candidate) { const containerNo = String(candidate?.containerNo || '').trim(); if (!containerNo || this.busy) return; this.error = null; try { this.validateReceivingWarehouse(candidate, `柜号 ${containerNo}`); if (!candidate.operable) { receiving.selected = candidate; this.detailOpen = true; return } const detail = await this.run('查询柜号', async () => this.validateReceivingWarehouse(await getReceivingContainer(containerNo), `柜号 ${containerNo}`)); receiving.selected = { ...candidate, ...detail, allPlansReady: candidate.allPlansReady, operable: candidate.operable, blockReason: candidate.blockReason }; this.detailOpen = true; playSuccessSound() } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) { this.error = error; this.showToast(error.message || '查询柜号失败', 'error') } } }, + async openContainer(candidate) { const containerNo = String(candidate?.containerNo || '').trim(); const sourceContainerBookingId = String(candidate?.sourceContainerBookingId || '').trim(); if (!containerNo || !sourceContainerBookingId || this.busy) return; this.error = null; try { this.validateReceivingWarehouse(candidate, `柜号 ${containerNo}`); if (!candidate.operable) { receiving.selected = candidate; this.detailOpen = true; return } const detail = await this.run('查询柜号', async () => this.validateReceivingWarehouse(await getReceivingContainer(containerNo, sourceContainerBookingId), `柜号 ${containerNo}`)); receiving.selected = { ...candidate, ...detail, sourceContainerBookingId, allPlansReady: candidate.allPlansReady, operable: candidate.operable, blockReason: candidate.blockReason }; this.detailOpen = true; playSuccessSound() } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) { this.error = error; this.showToast(error.message || '查询柜号失败', 'error') } } }, cancelDetail() { this.detailOpen = false; receiving.selected = null }, - async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { this.validateReceivingWarehouse(this.selected, `柜号 ${this.selected.containerNo}`); const value = await this.run('选择当前柜', async () => this.validateReceivingWarehouse(await prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value }), '新建接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.detailOpen = false } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) this.error = error } }, + async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.sourceContainerBookingId || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) return; const key = idempotencyKey('prepare', { sourceContainerBookingId: this.selected.sourceContainerBookingId, containerNo: this.selected.containerNo }); try { this.validateReceivingWarehouse(this.selected, `柜号 ${this.selected.containerNo}`); const value = await this.run('选择当前柜', async () => this.validateReceivingWarehouse(await prepareReceivingContainer(this.selected.containerNo, this.selected.sourceContainerBookingId, { idempotencyKey: key.value }), '新建接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.detailOpen = false } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) this.error = error } }, choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths, tempFiles }) => { const path = tempFilePaths?.[0]; const size = Number(tempFiles?.[0]?.size || 0); if (size > MAX_UNLOAD_PHOTO_BYTES) { const message = '照片不能超过10MB'; this.error = Object.assign(new Error(message), { code: 'A0400' }); this.showToast(message, 'error'); playErrorSound(); return } if (path) this.replacePhoto(index, path) } }) }, async replacePhoto(index, path) { const previous = receiving.photoSlots[index]; receiving.photoSlots.splice(index, 1, { ...(previous || {}), role: this.photoSteps[index].role, status: 'uploading', errorMessage: '' }); this.busy = true; this.error = null; try { const file = await uploadCurrentUnloadPhoto(path); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true, status: 'success' }); this.showToast(`${this.photoSteps[index].label}上传成功`, 'success') } catch (error) { receiving.photoSlots.splice(index, 1, previous?.fileId ? { ...previous, status: 'error', errorMessage: error.message || '重拍失败,已保留原图' } : { role: this.photoSteps[index].role, localPath: path, uploaded: false, status: 'error', errorMessage: error.message || '上传失败,请重试' }); this.error = error; this.showToast(error.message || '照片上传失败', 'error'); playErrorSound() } finally { this.busy = false } }, previewPhoto(index) { const path = this.photoSlots[index]?.localPath; if (!path) return; uni.previewImage({ urls: [path], current: path }) }, @@ -200,7 +203,7 @@ clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'carton'; setTimeout(() => this.drainScanQueue(), 0) }, async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark) return; if (this.busy) return this.handleHardwareScan(cartonMark); try { const outcome = await this.run('处理货物码', async () => { const value = await resolveCurrentCarton(cartonMark); if (value.operationMode !== 'DROPSHIP_PALLET') return { value }; const payload = { sourcePalletNo: value.sourcePalletNo || cartonMark }; const key = idempotencyKey('whole-pallet-receipt', { containerNo: this.current?.containerNo, ...payload }); const result = await receiveCurrentWholePallet({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); releaseIdempotencyKey(key.signature); return { value, result, current } }); if (outcome.result) { receiving.lastReceipt = outcome.result; await this.applyCurrentReceiving(outcome.current); this.clearReceiptContext(); playSuccessSound(); return } receiving.resolvedCarton = outcome.value; receiving.resolvedPallet = null; this.cartonInput = outcome.value.cartonMark; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (error) { console.error('[Overseas PDA cargo] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }) } }, async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim(); if (!palletCode || !this.activeInbound || this.busy) return; try { const value = await this.run('校验托盘', () => resolveCurrentPallet(palletCode, this.activeInbound.inboundCode)); receiving.resolvedPallet = value; this.palletInput = value.palletCode; this.packageCount = Number(value.currentReceivedPackages || 0) || ''; if (!value.compatible) throw Object.assign(new Error('托盘不匹配'), { code: 'PALLET_INCOMPATIBLE' }); receiving.activeScanTarget = this.activeInbound.destinationType === 'FBA' ? 'quantity' : 'carton'; playSuccessSound() } catch (error) { if (!this.error) this.error = error } }, - async submitReceipt() { if (!this.canSubmitReceipt) return; const isFba = this.activeInbound.destinationType === 'FBA'; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount), expectedPalletVersion: this.resolvedPallet.version } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', { containerNo: this.current?.containerNo, groupKey: this.activeInbound.groupKey, ...payload }); try { const result = await this.run('提交实收', async () => { const receipt = isFba ? await receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : await receiveCurrentCarton({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); return { receipt, current } }); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result.receipt; await this.applyCurrentReceiving(result.current); this.clearReceiptContext(); playSuccessSound() } catch (_) {} }, + async submitReceipt() { const isFba = this.activeInbound?.destinationType === 'FBA'; if (isFba && Number(this.packageCount) > MAX_PACKAGES_PER_PALLET) { this.error = Object.assign(new Error(PALLET_CAPACITY_MESSAGE), { code: 'A0409' }); playErrorSound(); this.showToast(PALLET_CAPACITY_MESSAGE, 'error'); return } if (!this.canSubmitReceipt) return; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount), expectedPalletVersion: this.resolvedPallet.version } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', { containerNo: this.current?.containerNo, groupKey: this.activeInbound.groupKey, ...payload }); try { const result = await this.run('提交实收', async () => { const receipt = isFba ? await receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : await receiveCurrentCarton({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); return { receipt, current } }); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result.receipt; await this.applyCurrentReceiving(result.current); this.clearReceiptContext(); playSuccessSound() } catch (_) {} }, async completeContainer() { if (!this.canFinishOnsite || this.activeTarget === 'quantity' || this.scanQueue.length) return; const payload = { allowDifference: !this.allInboundsReconciled }; const key = idempotencyKey('complete', { containerNo: this.current.containerNo, ...payload }); try { const value = await this.run(this.allInboundsReconciled ? '完成入库' : '结束现场收货', async () => this.validateReceivingWarehouse(await completeCurrentReceiving({ ...payload, idempotencyKey: key.value }), '当前接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value) } catch (_) {} }, async finishAndReturn() { resetReceivingDraft(); await this.loadCandidates(false) }, showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2600) } diff --git a/src/pages/relabel/relabel.vue b/src/pages/relabel/relabel.vue index 99088f2..ae78c6a 100644 --- a/src/pages/relabel/relabel.vue +++ b/src/pages/relabel/relabel.vue @@ -5,12 +5,14 @@ {{ title }}{{ stageSubtitle }} - {{ candidateHeading }} + 换标类型:{{ relabelTypeName(current.operationType) }} {{ current.relabelNo }}客户订单:{{ current.customerOrderNo || '—' }}标签截止:{{ formatTime(current.labelDeadline) }}{{ statusLabel(current.taskStatus || current.applicationStatus) }} @@ -22,28 +24,26 @@