From bab284b86537eaf8eb4a09901240502940a4cb1e Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 10 Sep 2026 15:34:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- src/components.d.ts | 1 - .../overseas-warehouse/api/returnOrders.ts | 4 +- .../api/warehouseServices.ts | 25 ++- .../views/ReturnOrdersView.test.mjs | 23 +++ .../views/ReturnOrdersView.vue | 27 ++- .../views/WarehouseServiceView.vue | 162 +++++++++++++++--- .../views/relabelAttachments.test.mjs | 20 ++- .../views/warehouseTransferForm.test.mjs | 25 +++ 9 files changed, 240 insertions(+), 49 deletions(-) create mode 100644 src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs create mode 100644 src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs diff --git a/AGENTS.md b/AGENTS.md index bc3c597..26ff0ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ | 客户资金账户、授信与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际余额、冻结金额、授信额度、已用授信、冻结授信、总可用余额和不可变流水;数据加载前显示“—”,不得先伪装为零。充值只展示服务端启用的微信/支付宝静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 | | 海外采购、美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 三个路由均为 `active`,客户范围只取 Token;采购创建的 `customerName` 只取当前认证会话企业名称,不允许自由输入或冒用其他客户。Backend `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/` 客户接口 | | 海外仓入库订单 | `src/modules/order-management/routes.ts`、`views/InboundOrdersView.vue`、`api/inboundPlans.ts` | `orders/inbound` 为只读列表/详情,权限 `merchant:warehouse:inbound:list`;不展示或提交内部 ID | -| 海外仓贴标、仓储转运、货物废弃、退货换标 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 四个路由均为 `active`;贴标申请先从共享仓库主数据选择国外中转仓,并自动带出海外仓签收联系人和联系电话,再从当前客户已经完成入库且仍有可用库存的历史入仓单中单选。货物明细在同一页面通过“换标类型”单选区分只换 SKU 标签、只换箱唛、SKU+箱唛都换,避免两个 Tab 造成语义混淆:客户自行填写我方箱唛,同一箱唛的多条 SKU 明细聚合为一个箱任务,整箱只冻结一次,SKU 数量按件保存为子明细;历史入仓库存只提供大概位置和可用箱数,不能据此推断箱唛与具体托盘的绑定关系。PDA 扫我方箱唛定位任务,仓库人员在大概位置内找到实物后完成换标,返回新 FBA 箱唛和 SKU 子明细,照片完工并复核通过后释放原库存,后续仍进入既有排车出库。客户页面不暴露托盘、库位和内部库存 ID;换标申请必须提交正数 CNY `chargeAmount`,页面展示服务端钱包冻结/结算/释放状态及错误,不本地推导余额结果。Backend `lastleg-service/` 的 `relabel/`、`relabel/pda/`、`transfer/`、`discard/`,请求统一经 `src/api/overseasHttp.ts` 与 `gateway-overseas/` | +| 海外仓贴标、仓储转运、货物废弃、退货换标 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 四个路由均为 `active`;贴标申请先选国外中转仓和有库存历史入仓单,换标类型为只换 SKU、只换箱唛、SKU+箱唛。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID | ## 租户、认证与数据安全 diff --git a/src/components.d.ts b/src/components.d.ts index 7e1b2ec..befd71f 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -36,7 +36,6 @@ declare module 'vue' { ElInput: typeof import('element-plus/es')['ElInput'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElOption: typeof import('element-plus/es')['ElOption'] - ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup'] ElPagination: typeof import('element-plus/es')['ElPagination'] ElPopover: typeof import('element-plus/es')['ElPopover'] ElRadio: typeof import('element-plus/es')['ElRadio'] diff --git a/src/modules/overseas-warehouse/api/returnOrders.ts b/src/modules/overseas-warehouse/api/returnOrders.ts index bbcad64..a28af3e 100644 --- a/src/modules/overseas-warehouse/api/returnOrders.ts +++ b/src/modules/overseas-warehouse/api/returnOrders.ts @@ -2,7 +2,7 @@ import { overseasApiRequest } from '@/api/overseasHttp' import type { PageResult } from './warehouseServices' export interface ReturnOrderSummary { - returnNo: string; overseasWarehouseId: string; customerReferenceNo?: string; status: string + returnNo: string; overseasWarehouseId: string; warehouseContactName?: string; warehouseContactPhone?: string; customerReferenceNo?: string; status: string skuTypes: number; expectedQuantity: number; receivedQuantity: number; needRelabel: boolean; createdAt: string } export interface ReturnOrderItem { @@ -14,7 +14,7 @@ export interface ReturnReceipt { } export interface ReturnOrderDetail { order: ReturnOrderSummary; remark?: string; items: ReturnOrderItem[]; receipts: ReturnReceipt[] } export interface ReturnOrderCreate { - overseasWarehouseId: string; customerReferenceNo?: string; remark?: string; needRelabel: boolean; idempotencyKey: string + overseasWarehouseId: string; warehouseContactName: string; warehouseContactPhone: string; customerReferenceNo?: string; remark?: string; needRelabel: boolean; idempotencyKey: string items: Array<{ skuCode: string; productNameZh?: string; expectedQuantity: number }> } diff --git a/src/modules/overseas-warehouse/api/warehouseServices.ts b/src/modules/overseas-warehouse/api/warehouseServices.ts index c070e90..f3c3163 100644 --- a/src/modules/overseas-warehouse/api/warehouseServices.ts +++ b/src/modules/overseas-warehouse/api/warehouseServices.ts @@ -1,9 +1,11 @@ import { overseasApiRequest } from '@/api/overseasHttp' +export type RelabelDeliveryMethod = 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP' + export interface PageResult { records: T[]; page: number; size: number; total: number; pages: number } export interface LastlegOrderSummary { id: string; orderNo: string; inboundCode: string; orderType: string; packageCount: number; overseasWarehouseId?: string } -export interface TransferSummary { id: string; transferNo: string; inboundCode: string; lastlegOrderId: string; lastlegOrderNo: string; operatingWarehouseId: string; fbaWarehouseCode: string; packageCount: number; deliveryMethod: string; needRelabel: boolean; relabelNo?: string; status: string; estimatedArrivalAt: string; createdAt: string } -export interface RelabelSummary { id: string; relabelNo: string; lastlegOrderId: string; lastlegOrderNo: string; customerOrderNo: string; overseasWarehouseId: string; status: string; itemCount: number; totalPackageCount: number; notifiedPackageCount: number; skuCodes: string; createdAt: string } +export interface TransferSummary { id: string; transferNo: string; inboundCode: string; lastlegOrderId: string; lastlegOrderNo: string; operatingWarehouseId: string; fbaWarehouseCode?: string; packageCount: number; deliveryMethod: string; needRelabel: boolean; relabelNo?: string; status: string; estimatedArrivalAt: string; createdAt: string } +export interface RelabelSummary { id: string; relabelNo: string; lastlegOrderId: string; lastlegOrderNo: string; customerOrderNo: string; overseasWarehouseId: string; status: string; itemCount: number; totalPackageCount: number; notifiedPackageCount: number; skuCodes: string; createdAt: string; deliveryMethod?: RelabelDeliveryMethod; printShippingLabel?: boolean; shippingLabelFileId?: string; shippingLabelFileName?: string; shippingLabelFileUrl?: string; fulfillmentStatus?: string; outboundOrderNo?: string; customerConfirmedAt?: string } export interface RelabelItemDetail { id: string; skuCode?: string; productNameZh: string; totalPackageCount: number; notifiedPackageCount: number shipmentNo: string; trackingCode?: string; newSkuCode?: string; labelRequirement?: string; remark?: string @@ -19,6 +21,7 @@ export interface RelabelDetail { remark?: string; items: RelabelItemDetail[] history: Array<{ fromStatus?: string; toStatus?: string; operatorId?: string; remark?: string; createdAt: string }> tailOrders?: Array<{ id: string; orderNo: string; overseasWarehouseId: string; packageCount: number; status: string }> + completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string } } export interface DiscardSummary { id: string; discardNo: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string } export interface DiscardCandidate { orderId: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> } @@ -31,14 +34,14 @@ export interface RelabelInventoryCandidate { } export interface TransferCreate { - inboundCode: string; sourceLastlegOrderIds: string[]; operatingWarehouseId: string; fbaWarehouseCode: string + inboundCode: string; sourceLastlegOrderIds: string[]; operatingWarehouseId: string; fbaWarehouseCode?: string deliveryAddress: string; packageCount: number; volumeCbm: number; deliveryMethod: string; estimatedArrivalAt: string - fbaShipmentNo: string; trackingNo?: string; fbaBoxNos?: string; remark?: string; needRelabel: boolean; idempotencyKey: string + fbaShipmentNo?: string; trackingNo?: string; fbaBoxNos?: string; remark?: string; needRelabel: boolean; idempotencyKey: string items: Array<{ skuCode: string; productNameZh: string; packageCount: number; shippingMark?: string }> } export interface RelabelCreate { customerOrderNo: string; operationWarehouseId: string; labelDeadline: string; contactName: string; contactPhone: string; chargeAmount: number - remark?: string; idempotencyKey: string + remark?: string; idempotencyKey: string; deliveryMethod?: RelabelDeliveryMethod; printShippingLabel?: boolean items: Array<{ sourceInventoryBalanceId: string; sourceInventoryPositionId: string; sourceInboundOrderId: string; skuCode?: string; productNameZh: string; totalPackageCount: number; notifiedPackageCount: number; shipmentNo: string; trackingCode?: string; newSkuCode?: string; labelRequirement?: string cartonMark: string; fbaCartonMark?: string; destinationFbaWarehouseCode?: string; operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'; skuItems?: Array<{ skuCode: string; newSkuCode: string; quantity: number; remark?: string }>; remark?: string }> } @@ -69,7 +72,17 @@ export function listRelabelInventoryCandidates(operationWarehouseId: string, inb }) } export function createRelabel(payload: RelabelCreate) { - return overseasApiRequest>({ method: 'POST', url: '/merchant/v1/last-leg/relabel-applications', data: payload }) + return overseasApiRequest({ method: 'POST', url: '/merchant/v1/last-leg/relabel-applications', data: payload }) +} +export function uploadRelabelShippingLabel(relabelNo: string, file: File) { + const data = new FormData(); data.append('file', file) + return overseasApiRequest({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/shipping-label`, data, headers: { 'Content-Type': 'multipart/form-data' } }) +} +export function confirmRelabelCompletion(relabelNo: string, remark?: string) { + return overseasApiRequest({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/confirm-completion`, data: { idempotencyKey: crypto.randomUUID(), remark } }) +} +export function downloadRelabelCompletionPhoto(relabelNo: string, fileId: string) { + return overseasApiRequest({ method: 'GET', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/completion-photos/${encodeURIComponent(fileId)}/download`, responseType: 'blob' }) } export function listDiscards(params: Record) { return overseasApiRequest>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards', params }) diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs new file mode 100644 index 0000000..4b77e69 --- /dev/null +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8') + +test('新建退货预报选择中转仓后带出联系人和电话', () => { + assert.match(view, /@change="selectOperationWarehouse"/) + assert.match(view, /warehouseContactName = warehouse\?\.contactName \|\| ''/) + assert.match(view, /warehouseContactPhone = warehouse\?\.contactPhone \|\| ''/) + assert.match(view, /label="中转仓联系人"/) + assert.match(view, /label="中转仓联系电话"/) + assert.match(view, /:readonly="Boolean\(selectedOperationWarehouse\?\.contactName\)"/) + assert.match(view, /:readonly="Boolean\(selectedOperationWarehouse\?\.contactPhone\)"/) + assert.match(view, /该中转仓未维护,请手动填写/) + assert.match(view, /warehouseContactName: form\.warehouseContactName\.trim\(\)/) + assert.match(view, /warehouseContactPhone: form\.warehouseContactPhone\.trim\(\)/) +}) + +test('新建退货预报使用加宽的双列表单弹窗', () => { + assert.match(view, /title="新建退货预报" width="1120px"/) + assert.match(view, /return-create-form \{ display:grid; grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/) +}) diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue index 351b85d..e385c89 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue @@ -19,7 +19,8 @@ const query = reactive({ page: 1, size: 20, returnNo: '', trackingNo: '', skuCod interface SkuDraft { id: number; skuCode: string; productNameZh: string; expectedQuantity: number } let skuSequence = 0 function emptySku(): SkuDraft { return { id: ++skuSequence, skuCode: '', productNameZh: '', expectedQuantity: 1 } } -const form = reactive({ overseasWarehouseId: '', customerReferenceNo: '', remark: '', needRelabel: false, items: [emptySku()] }) +const form = reactive({ overseasWarehouseId: '', warehouseContactName: '', warehouseContactPhone: '', customerReferenceNo: '', remark: '', needRelabel: false, items: [emptySku()] }) +const selectedOperationWarehouse = computed(() => operationWarehouses.value.find(item => String(item.id) === form.overseasWarehouseId)) function normalizedSku(value: string) { return value.trim().toUpperCase() } const duplicateSkuIds = computed(() => { const grouped = new Map() @@ -29,8 +30,13 @@ const duplicateSkuIds = computed(() => { } return new Set([...grouped.values()].filter(ids => ids.length > 1).flat()) }) -function resetForm() { Object.assign(form, { overseasWarehouseId: '', customerReferenceNo: '', remark: '', needRelabel: false, items: [emptySku()] }) } +function resetForm() { Object.assign(form, { overseasWarehouseId: '', warehouseContactName: '', warehouseContactPhone: '', customerReferenceNo: '', remark: '', needRelabel: false, items: [emptySku()] }) } function warehouseLabel(warehouse: MerchantWarehouse) { return `${warehouse.warehouseName} · ${warehouse.warehouseCode}` } +function selectOperationWarehouse() { + const warehouse = operationWarehouses.value.find(item => String(item.id) === form.overseasWarehouseId) + form.warehouseContactName = warehouse?.contactName || '' + form.warehouseContactPhone = warehouse?.contactPhone || '' +} async function loadOperationWarehouses() { operationWarehousesLoading.value = true operationWarehousesError.value = '' @@ -60,10 +66,11 @@ async function openDetail(returnNo: string) { selected.value = await getReturnOr async function submit() { const items = form.items.map(({ skuCode, productNameZh, expectedQuantity }) => ({ skuCode: skuCode.trim(), productNameZh: productNameZh.trim(), expectedQuantity })) if (!form.overseasWarehouseId || items.some(item => !item.skuCode || item.expectedQuantity <= 0)) { ElMessage.warning('请填写操作仓、SKU 和预报数量'); return } + if (!form.warehouseContactName.trim() || !form.warehouseContactPhone.trim()) { ElMessage.warning('请填写中转仓联系人和联系电话'); return } if (duplicateSkuIds.value.size) { ElMessage.warning('同一退货单不能重复填写相同 SKU'); return } creating.value = true try { - const detail = await createReturnOrder({ overseasWarehouseId: form.overseasWarehouseId, customerReferenceNo: form.customerReferenceNo || undefined, remark: form.remark || undefined, needRelabel: form.needRelabel, idempotencyKey: crypto.randomUUID(), items: items.map(item => ({ ...item, productNameZh: item.productNameZh || undefined })) }) + const detail = await createReturnOrder({ overseasWarehouseId: form.overseasWarehouseId, warehouseContactName: form.warehouseContactName.trim(), warehouseContactPhone: form.warehouseContactPhone.trim(), customerReferenceNo: form.customerReferenceNo || undefined, remark: form.remark || undefined, needRelabel: form.needRelabel, idempotencyKey: crypto.randomUUID(), items: items.map(item => ({ ...item, productNameZh: item.productNameZh || undefined })) }) ElMessage.success('退货预报已创建'); showCreate.value = false; resetForm(); await load(); await openDetail(detail.order.returnNo) } finally { creating.value = false } } @@ -95,8 +102,14 @@ onMounted(load) - -
{{ operationWarehousesError }}
+ + +
{{ operationWarehousesError }}
+ + + + +
SKU 预报明细添加 SKU
换标需要换标
客户只需填写预计退回的 SKU 和数量;快递单号由仓库签收时按实际包裹扫描绑定。
@@ -108,11 +121,11 @@ onMounted(load)
- + diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index 45ecb44..d3e2590 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -5,12 +5,18 @@ import { ArrowLeft, Download, Eye, Plus, Search, Trash2, Upload } from '@lucide/ import SingleLineText from '@/components/common/SingleLineText.vue' import { createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, + confirmRelabelCompletion, downloadRelabelCompletionPhoto, uploadRelabelShippingLabel, listDiscardCandidates, listDiscards, listLastlegOrders, listRelabelInventoryCandidates, listRelabels, listTransfers, type RelabelInventoryCandidate, type RelabelCreate, type RelabelDetail, } from '../api/warehouseServices' -import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/api/firstLeg' +import { + getMerchantWarehouseDetail, + getMerchantWarehouses, + type MerchantWarehouse, + type MerchantWarehouseDetail, +} from '@/modules/first-leg/api/firstLeg' type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel' type PageMode = 'list' | 'create' | 'detail' @@ -22,21 +28,31 @@ interface ServiceConfig { title: string; createTitle: string; numberLabel: strin const props = defineProps<{ service: ServiceType }>() const configs: Record = { labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['操作', '贴标申请单号', '客户单号', '海外操作仓', '贴标要求', 'SKU 数', '总箱数', '状态', '创建时间'] }, - transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '进仓编号', '发货箱数', '操作仓', 'FBA 目的仓', '派送方式', '是否换标', '状态', '创建时间'] }, + transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '进仓编号', '入库箱数', '中转仓', 'FBA 目的仓', '入仓方式', '是否换标', '状态', '创建时间'] }, disposal: { title: '货物废弃', createTitle: '新增货物废弃申请', numberLabel: '废弃申请单号', emptyText: '暂无货物废弃申请', columns: ['操作', '废弃申请单号', '进仓编号', '操作仓', 'SKU 汇总', '废弃数量', '预估费用', '状态', '创建时间'] }, relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['操作', '退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '创建时间'] }, } const config = computed(() => configs[props.service]) const statusOptions = computed(() => props.service === 'labeling' - ? [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }, { label: '已拒绝', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] + ? [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '待客户确认', value: 'CUSTOMER_CONFIRM_PENDING' }, { label: '待出库', value: 'READY_FOR_OUTBOUND' }, { label: '已完成', value: 'COMPLETED' }, { label: '已拒绝', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] : props.service === 'disposal' ? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已通过', value: 'APPROVED' }, { label: '已拒绝', value: 'REJECTED' }] : [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已通过', value: 'APPROVED' }, { label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '已分配', value: 'ALLOCATED' }, { label: '已出库', value: 'OUTBOUNDED' }, { label: '部分收货', value: 'PARTIALLY_RECEIVED' }, { label: '已收货', value: 'RECEIVED' }, { label: '已拒绝', value: 'REJECTED' }]) const mode = ref('list') const filters = reactive({ number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '' }) const form = reactive({ customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', address: '', contact: '', phone: '', deliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', fbaShipmentNo: '', volumeCbm: 0.01, chargeAmount: 0 }) +const transferHasFbaDestination = ref(false) +const selectedOperationWarehouse = ref(null) +const operationWarehouseDetailLoading = ref(false) +const operationWarehouseAddress = computed(() => { + const address = selectedOperationWarehouse.value?.addresses[0] + if (!address) return '' + return [address.addressLine1, address.addressLine2, address.city, address.stateProvince, address.postalCode, address.countryCode] + .filter(Boolean).join(', ') +}) const cargoRows = ref([]) const labelingMode = ref('carton') +const shippingLabelFile = ref(null) const skuRelabelRows = ref([]) const labelingModeHint = computed(() => labelingMode.value === 'carton' ? '只替换外箱箱唛,不修改箱内商品 SKU 标签;按箱填写新的 FBA 箱唛和目的仓。' @@ -90,6 +106,8 @@ function resetForm() { selectedDiscardOrderId.value = '' relabelCandidates.value = [] selectedRelabelInboundOrderId.value = '' + transferHasFbaDestination.value = false + selectedOperationWarehouse.value = null } async function openCreate() { resetForm(); mode.value = 'create' @@ -109,6 +127,7 @@ function addSkuRelabelRow() { fbaCartonMark: '', destinationWarehouseId: source?.destinationWarehouseId || '', remark: '' }) } function removeSkuRelabelRow(id: number) { skuRelabelRows.value = skuRelabelRows.value.filter((row) => row.id !== id) } +function selectShippingLabel(event: Event) { shippingLabelFile.value = (event.target as HTMLInputElement).files?.[0] || null } function buildSkuRelabelItems(): RelabelCreate['items'] { const groups = new Map() for (const row of skuRelabelRows.value) { @@ -247,7 +266,8 @@ function warehouseDisplay(value?: string) { function statusLabel(value: string) { const labels: Record = { - PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', + PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认', + READY_FOR_OUTBOUND: '待出库', COMPLETED: '已完成', CANCELLED: '已取消', PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_ASSIGNMENT: '待分配', ALLOCATED: '已分配', OUTBOUNDED: '已出库', PARTIALLY_RECEIVED: '部分收货', RECEIVED: '已收货', @@ -255,10 +275,15 @@ function statusLabel(value: string) { return labels[value] || value || '—' } function deliveryMethodLabel(value: string) { - const labels: Record = { TRUCK: '卡车派送', EXPRESS: '快递派送' } + const labels: Record = { + TRUCK: '卡车派送', EXPRESS: '快递派送', TRUCK_PICKUP: '卡车自提', EXPRESS_PICKUP: '快递自提', + WAREHOUSE_PICKUP: '仓库上门提货', CUSTOMER_DELIVERY: '客户送货到仓', + // Keep the legacy value readable for historical applications created before the four-way split. + PICKUP: '仓库自提', + } return labels[value] || value || '—' } -function formatDateTime(value: string) { +function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 19) : '—' } async function load() { @@ -299,6 +324,13 @@ async function submit() { if (props.service === 'labeling' && (!form.labelDeadline || !form.contact || !form.phone || form.chargeAmount <= 0)) { ElMessage.warning('请填写贴标截止时间、联系人和贴标费用'); return } + if (props.service === 'labeling' && labelingMode.value !== 'sku' && !form.deliveryMethod) { + ElMessage.warning('请选择换标完成后的派送方式'); return + } + if (props.service === 'labeling' && labelingMode.value !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP') + && form.relabel && !shippingLabelFile.value) { + ElMessage.warning('选择打印面单时必须上传面单文件'); return + } if (props.service === 'labeling' && labelingMode.value === 'carton' && cargoRows.value.some((item) => !item.shipmentNo || !item.cartonMark || !item.fbaCartonMark)) { ElMessage.warning('请上传关联面单,确保每箱的我方箱唛和 FBA 箱唛完整'); return } @@ -308,9 +340,15 @@ async function submit() { if (props.service === 'disposal' && !form.reason) { ElMessage.warning('请选择废弃原因'); return } - if ((props.service === 'transfer' || props.service === 'relabel') && (!form.destination || !form.address || !form.deliveryMethod || !form.estimatedArrivalAt || !form.fbaShipmentNo || form.volumeCbm <= 0)) { + if (props.service === 'relabel' && (!form.destination || !form.address || !form.deliveryMethod || !form.estimatedArrivalAt || !form.fbaShipmentNo || form.volumeCbm <= 0)) { ElMessage.warning('请填写完整的派送信息、体积、预计到达时间和 FBA 货件号'); return } + if (props.service === 'transfer' && (!form.warehouse || !form.deliveryMethod || !form.address || !form.estimatedArrivalAt || form.volumeCbm <= 0)) { + ElMessage.warning(form.deliveryMethod === 'WAREHOUSE_PICKUP' ? '请填写中转仓、提货地址和预计入仓时间' : '请选择中转仓并确认预计入仓时间'); return + } + if (props.service === 'transfer' && transferHasFbaDestination.value && (!form.destination || !form.fbaShipmentNo)) { + ElMessage.warning('已选择有 FBA 去向,请填写 FBA 目的仓和货件编号'); return + } try { await ElMessageBox.confirm(`确认提交${config.value.title}吗?`, '提交确认', { confirmButtonText: '提交', cancelButtonText: '取消' }) } catch { return } saving.value = true try { @@ -320,7 +358,10 @@ async function submit() { const items = labelingMode.value === 'carton' ? cargoRows.value.map((item) => ({ sourceInventoryBalanceId: item.sourceInventoryBalanceId!, sourceInventoryPositionId: item.sourceInventoryPositionId!, sourceInboundOrderId: item.sourceInboundOrderId!, skuCode: item.sku, productNameZh: item.name, totalPackageCount: item.quantity, notifiedPackageCount: item.quantity, shipmentNo: item.shipmentNo, trackingCode: form.trackingNo || undefined, newSkuCode: undefined, labelRequirement: form.requirement || undefined, cartonMark: item.cartonMark, fbaCartonMark: item.fbaCartonMark, destinationFbaWarehouseCode: item.destinationWarehouseId, operationType: 'CARTON_LABEL' as const, skuItems: [], remark: item.remark || undefined })) : buildSkuRelabelItems() - await createRelabel({ customerOrderNo: sourceInboundOrders || '贴标申请', operationWarehouseId: form.warehouse, labelDeadline: form.labelDeadline, contactName: form.contact, contactPhone: form.phone, chargeAmount: form.chargeAmount, remark: form.remark || undefined, idempotencyKey, items }) + const created = await createRelabel({ customerOrderNo: sourceInboundOrders || '贴标申请', operationWarehouseId: form.warehouse, labelDeadline: form.labelDeadline, contactName: form.contact, contactPhone: form.phone, chargeAmount: form.chargeAmount, remark: form.remark || undefined, idempotencyKey, items, + deliveryMethod: labelingMode.value === 'sku' ? undefined : form.deliveryMethod as 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP', + printShippingLabel: labelingMode.value !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP') && form.relabel }) + if (shippingLabelFile.value) await uploadRelabelShippingLabel(created.application.relabelNo, shippingLabelFile.value) } else if (props.service === 'disposal') { const items = cargoRows.value.filter((item) => item.quantity > 0).map((item) => { if (!item.sourceOrderId || item.sourceOrderId !== selectedDiscardOrderId.value || item.quantity > Number(item.availableQuantity || 0)) { @@ -333,7 +374,7 @@ async function submit() { const sources = await listLastlegOrders(form.inboundNo) if (sources.records.length === 0) throw new Error('未找到该入仓码对应的尾程订单') const packageCount = cargoRows.value.reduce((sum, item) => sum + item.quantity, 0) - await createTransfer({ inboundCode: form.inboundNo, sourceLastlegOrderIds: sources.records.map((item) => item.id), operatingWarehouseId: primaryWarehouse, fbaWarehouseCode: form.destination, deliveryAddress: form.address, packageCount, volumeCbm: form.volumeCbm, deliveryMethod: form.deliveryMethod, estimatedArrivalAt: form.estimatedArrivalAt, fbaShipmentNo: form.fbaShipmentNo, trackingNo: form.trackingNo || undefined, remark: form.remark || undefined, needRelabel: props.service === 'relabel' || form.relabel, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, packageCount: item.quantity, shippingMark: item.shippingMark || undefined })) }) + await createTransfer({ inboundCode: form.inboundNo, sourceLastlegOrderIds: sources.records.map((item) => item.id), operatingWarehouseId: primaryWarehouse, fbaWarehouseCode: form.destination || undefined, deliveryAddress: form.address, packageCount, volumeCbm: form.volumeCbm, deliveryMethod: form.deliveryMethod, estimatedArrivalAt: form.estimatedArrivalAt, fbaShipmentNo: form.fbaShipmentNo || undefined, trackingNo: form.trackingNo || undefined, remark: form.remark || undefined, needRelabel: props.service === 'relabel' || form.relabel, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, packageCount: item.quantity, shippingMark: item.shippingMark || undefined })) }) } ElMessage.success(`${config.value.title}提交成功`); mode.value = 'list'; await load() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '提交失败') } @@ -347,6 +388,25 @@ async function openDetail(value: Record) { mode.value = 'detail' } catch (error) { ElMessage.error(error instanceof Error ? error.message : '详情加载失败') } } +async function confirmRelabel() { + const relabelNo = selectedRelabelDetail.value?.application.relabelNo + if (!relabelNo) return + try { + await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续出库流程吗?', '换标完成确认', { confirmButtonText: '确认并继续', cancelButtonText: '取消' }) + selectedRelabelDetail.value = await confirmRelabelCompletion(relabelNo) + ElMessage.success('已确认换标完成,订单已进入后续出库流程') + } catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '确认失败') } +} +async function previewCompletionPhoto(fileId: string) { + const relabelNo = selectedRelabelDetail.value?.application.relabelNo + if (!relabelNo) return + try { + const blob = await downloadRelabelCompletionPhoto(relabelNo, fileId) + const url = URL.createObjectURL(blob) + window.open(url, '_blank', 'noopener,noreferrer') + window.setTimeout(() => URL.revokeObjectURL(url), 60_000) + } catch (error) { ElMessage.error(error instanceof Error ? error.message : '完工照片加载失败') } +} async function loadWarehouses() { fbaWarehousesLoading.value = true fbaWarehousesError.value = '' @@ -364,6 +424,31 @@ async function loadWarehouses() { } finally { fbaWarehousesLoading.value = false } } function warehouseLabel(value: MerchantWarehouse) { return `${value.warehouseName} · ${value.warehouseCode}` } +async function selectTransferWarehouse() { + selectedOperationWarehouse.value = null + form.contact = '' + form.phone = '' + if (form.deliveryMethod === 'CUSTOMER_DELIVERY') form.address = '' + if (!form.warehouse) return + operationWarehouseDetailLoading.value = true + try { + const detail = await getMerchantWarehouseDetail(form.warehouse) + selectedOperationWarehouse.value = detail + form.contact = detail.warehouse.contactName || '' + form.phone = detail.warehouse.contactPhone || '' + if (form.deliveryMethod === 'CUSTOMER_DELIVERY') form.address = operationWarehouseAddress.value + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : '中转仓联系信息加载失败') + } finally { operationWarehouseDetailLoading.value = false } +} +function selectTransferInboundMethod(value: string | number | boolean | undefined) { + form.address = value === 'CUSTOMER_DELIVERY' ? operationWarehouseAddress.value : '' +} +function selectTransferFbaAvailability(value: string | number | boolean | undefined) { + if (value === true) return + form.destination = '' + form.fbaShipmentNo = '' +} async function selectOperationWarehouse() { cargoRows.value = [] skuRelabelRows.value = [] @@ -379,24 +464,20 @@ async function selectOperationWarehouse() { finally { relabelCandidatesLoading.value = false } } function selectRelabelInventory(inboundOrderId: string) { - const existing = new Map(cargoRows.value.map((row) => [row.sourceInventoryPositionId, row])) cargoRows.value = relabelCandidates.value .filter((candidate) => String(candidate.inboundOrderId) === inboundOrderId) - .flatMap((candidate) => candidate.positions.map((position) => { - const previous = existing.get(String(position.positionId)) - return { - id: previous?.id || ++cargoSequence, sku: candidate.skuCode || '', name: candidate.productNameZh || '库存货物', - quantity: Math.min(previous?.quantity || 1, position.availablePackages), availableQuantity: position.availablePackages, + .flatMap((candidate) => candidate.positions.flatMap((position) => Array.from({ length: Math.min(position.availablePackages, 500) }, () => ({ + id: ++cargoSequence, sku: candidate.skuCode || '', name: candidate.productNameZh || '库存货物', + quantity: 1, availableQuantity: 1, sourceInventoryBalanceId: String(candidate.inventoryBalanceId), sourceInventoryPositionId: String(position.positionId), sourceInboundOrderId: String(candidate.inboundOrderId), sourceInboundOrderNo: candidate.inboundOrderNo, sourceInboundCode: candidate.inboundCode, sourceWarehouseName: candidate.overseasWarehouseName, palletNo: position.palletNo, warehouseLocationCode: position.warehouseLocationCode, - fnsku: '', shipmentNo: previous?.shipmentNo || '', newSkuCode: previous?.newSkuCode || '', shippingMark: '', - cartonMark: previous?.cartonMark || '', fbaCartonMark: previous?.fbaCartonMark || '', - destinationWarehouseId: previous?.destinationWarehouseId || candidate.destinationFbaWarehouseCode || '', remark: previous?.remark || '', - } - })) + fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', cartonMark: '', fbaCartonMark: '', + destinationWarehouseId: candidate.destinationFbaWarehouseCode || '', remark: '', + })))).slice(0, 500) skuRelabelRows.value = [] + shippingLabelFile.value = null if (labelingMode.value !== 'carton') addSkuRelabelRow() } @@ -428,30 +509,43 @@ function selectRelabelInventory(inboundOrderId: string) {
/新增

{{ config.createTitle }}

返回列表
-

{{ props.service === 'relabel' ? '退货信息' : props.service === 'labeling' ? '订单信息' : '基础与派送信息' }}

+

{{ props.service === 'transfer' ? '入库与转运信息' : props.service === 'relabel' ? '退货信息' : props.service === 'labeling' ? '订单信息' : '基础信息' }}

- + - - +
*货物如何送到中转仓仓库上门提货客户送货到仓仓库上门提货:填写货物当前地址;客户送货到仓:下方自动展示中转仓收货信息。
+ +
+
送货地址{{ operationWarehouseAddress || '该中转仓暂未维护收货地址' }}
+
收货联系人{{ form.contact || '未维护' }}
+
联系电话{{ form.phone || '未维护' }}
+
+ + - + +
是否打印面单需要仓库打印面单
+ + - - + +
*是否已有 FBA 去向暂未确定已有 FBA 去向暂未确定也可以提交,货物先正常入中转仓,后续再补充转运指令。
+ + + -
是否换标需要换标
+
*是否需要换标不需要换标需要换标提交后将同时生成待处理的换标任务。
@@ -487,6 +581,16 @@ function selectRelabelInventory(inboundOrderId: string) {
/订单详情

{{ config.title }}详情

返回列表

订单概览

{{ column }}{{ selectedDetail?.[String(index)] || '—' }}
+
+

换标完成与出库

换标完成确认
+
+
派送方式{{ deliveryMethodLabel(selectedRelabelDetail?.application.deliveryMethod || '') || '无需出库' }}
+
履约状态{{ selectedRelabelDetail?.application.fulfillmentStatus || '—' }}
+
客户确认时间{{ formatDateTime(selectedRelabelDetail?.application.customerConfirmedAt) }}
+
+
仓库完工照片
照片 {{ index + 1 }}
仓库尚未上传完工照片
+

完工备注:{{ selectedRelabelDetail.completionEvidence.completionRemark }}

+

换标任务明细

@@ -501,8 +605,10 @@ function selectRelabelInventory(inboundOrderId: string) { diff --git a/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs b/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs index 243d8d9..77614de 100644 --- a/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs +++ b/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs @@ -42,11 +42,14 @@ test('贴标货物明细支持手动添加和编辑', () => { assert.match(view, / { - assert.match(view, /el-tab-pane label="箱唛换标" name="carton"/) - assert.match(view, /el-tab-pane label="SKU 换标" name="sku"/) +test('贴标货物明细在同一页面支持三种换标类型', () => { + assert.match(view, /el-radio-group v-model="labelingMode" aria-label="换标类型"/) + assert.match(view, /el-radio-button value="carton">只换箱唛/) + assert.match(view, /el-radio-button value="sku">只换 SKU 标签/) + assert.match(view, /el-radio-button value="carton_and_sku">SKU 和箱唛都换/) + assert.doesNotMatch(view, /el-tab-pane label="箱唛换标"/) + assert.doesNotMatch(view, /el-tab-pane label="SKU 换标"/) assert.match(view, /interface SkuRelabelRow/) - assert.match(view, /来源库存箱 \*/) assert.match(view, /我方箱唛 \*/) assert.match(view, /换标数量(件)\*/) assert.match(view, /buildSkuRelabelItems/) @@ -55,6 +58,15 @@ test('贴标货物明细拆分为箱唛换标和 SKU 换标页签', () => { assert.doesNotMatch(view, /SKU 换标提交待接口接入/) }) +test('箱唛相关换标必须选择四选一派送方式', () => { + assert.match(view, /label="卡车派送" value="TRUCK"/) + assert.match(view, /label="快递派送" value="EXPRESS"/) + assert.match(view, /label="卡车自提" value="TRUCK_PICKUP"/) + assert.match(view, /label="快递自提" value="EXPRESS_PICKUP"/) + assert.match(api, /RelabelDeliveryMethod = 'TRUCK' \| 'EXPRESS' \| 'TRUCK_PICKUP' \| 'EXPRESS_PICKUP'/) + assert.match(view, /labelingMode\.value !== 'sku' && !form\.deliveryMethod/) +}) + test('一次申请单选入仓单且每项保留原库存归属', () => { assert.doesNotMatch(view, /multiple filterable collapse-tags/) assert.match(view, /String\(candidate\.inboundOrderId\) === inboundOrderId/) diff --git a/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs new file mode 100644 index 0000000..d6313be --- /dev/null +++ b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8') +const api = readFileSync(new URL('../api/warehouseServices.ts', import.meta.url), 'utf8') + +test('仓储转运先选择入仓方式并按方式切换地址信息', () => { + assert.match(view, /value="WAREHOUSE_PICKUP">仓库上门提货/) + assert.match(view, /value="CUSTOMER_DELIVERY">客户送货到仓/) + assert.match(view, /form\.deliveryMethod === 'WAREHOUSE_PICKUP'.*提货地址/s) + assert.match(view, /form\.deliveryMethod === 'CUSTOMER_DELIVERY'.*送货地址.*收货联系人.*联系电话/s) + assert.match(view, /getMerchantWarehouseDetail\(form\.warehouse\)/) +}) + +test('仓储转运允许暂未确定 FBA 去向并显式选择是否换标', () => { + assert.match(view, /是否已有 FBA 去向/) + assert.match(view, /:value="false">暂未确定/) + assert.match(view, /transferHasFbaDestination.*目的 FBA 仓/s) + assert.match(view, /是否需要换标/) + assert.match(view, /:value="false">不需要换标/) + assert.match(view, /:value="true">需要换标/) + assert.match(api, /fbaWarehouseCode\?: string/) + assert.match(api, /fbaShipmentNo\?: string/) +})
换标模式我方箱唛新 FBA 箱唛FBA 目的仓任务箱数商品 SKUSKU 换标明细FBA 货件编号贴标要求备注