diff --git a/AGENTS.md b/AGENTS.md index 6771a53..1aa1e2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,12 @@ ## 海外业务接口边界 +- 换标目标确认即提交拆托合托,不再二次点击底部提交。`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` 并清空上一轮输入和照片草稿。 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/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/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 @@