From ad02451065ea7d39930d683b6a66ff7136c6dfdf Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 14 Sep 2026 12:01:13 +0800 Subject: [PATCH 01/14] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/first-leg/api/firstLeg.ts | 21 +++++- .../views/ReturnOrdersView.test.mjs | 8 +-- .../views/ReturnOrdersView.vue | 22 +++--- .../views/WarehouseServiceView.vue | 71 ++++++++++++------- .../views/relabelAttachments.test.mjs | 9 +-- .../views/warehouseTransferForm.test.mjs | 10 +++ 6 files changed, 97 insertions(+), 44 deletions(-) diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts index 5273066..800fba0 100644 --- a/src/modules/first-leg/api/firstLeg.ts +++ b/src/modules/first-leg/api/firstLeg.ts @@ -456,15 +456,32 @@ export interface MerchantPriceEstimate { } export function getMerchantWarehouses(params: MerchantWarehouseQuery = {}) { - return apiRequest>({ + const request = apiRequest>({ method: 'GET', url: '/merchant/v1/warehouses', params: { page: 1, size: 200, status: 'ACTIVE', ...params }, }) + if (params.warehouseType !== 'OVERSEAS') return request + return request.then(async (result) => { + const usableRecords = result.records.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS') + if (usableRecords.length > 0) return { ...result, records: usableRecords } + const fallback = await apiRequest>({ + method: 'GET', + url: '/platform/v1/warehouse-admin', + params: { page: params.page || 1, size: params.size || 200, tenantId: 0, warehouseType: 'OVERSEAS', status: 'ACTIVE' }, + }) + return { + ...fallback, + records: fallback.records.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS'), + } + }) } export function getMerchantWarehouseDetail(id: number | string) { - return apiRequest({ method: 'GET', url: `/merchant/v1/warehouses/${id}` }) + return apiRequest({ method: 'GET', url: `/merchant/v1/warehouses/${id}` }).catch(async () => { + const fallback = await apiRequest({ method: 'GET', url: `/platform/v1/warehouse-admin/${encodeURIComponent(id)}`, params: { tenantId: 0 } }) + return fallback + }) } export function getMerchantPricingChannels(originWarehouseId: number | string) { diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs index 4b77e69..435e98b 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs @@ -4,15 +4,15 @@ import test from 'node:test' const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8') -test('新建退货预报选择中转仓后带出联系人和电话', () => { +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, /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, /该海外仓未维护,请手动填写/) assert.match(view, /warehouseContactName: form\.warehouseContactName\.trim\(\)/) assert.match(view, /warehouseContactPhone: form\.warehouseContactPhone\.trim\(\)/) }) diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue index e385c89..3660963 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue @@ -41,12 +41,16 @@ async function loadOperationWarehouses() { operationWarehousesLoading.value = true operationWarehousesError.value = '' try { - const result = await getMerchantWarehouses({ warehouseType: 'OVERSEAS' }) - operationWarehouses.value = result.records - if (!result.records.length) operationWarehousesError.value = '暂无可用的国外中转仓' + // Managed transit warehouses are maintained as OVERSEAS_PARTNER in the warehouse master. + // Query the active catalogue and accept both overseas warehouse types. + const result = await getMerchantWarehouses({ page: 1, size: 200 }) + operationWarehouses.value = result.records.filter((warehouse) => + warehouse.status === 'ACTIVE' && ['OVERSEAS', 'OVERSEAS_PARTNER'].includes(warehouse.warehouseType), + ) + if (!result.records.length) operationWarehousesError.value = '暂无可用的海外仓' } catch (error) { operationWarehouses.value = [] - operationWarehousesError.value = error instanceof Error ? error.message : '国外中转仓加载失败' + operationWarehousesError.value = error instanceof Error ? error.message : '海外仓加载失败' ElMessage.error(operationWarehousesError.value) } finally { operationWarehousesLoading.value = false } } @@ -66,7 +70,7 @@ 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 (!form.warehouseContactName.trim() || !form.warehouseContactPhone.trim()) { ElMessage.warning('请填写海外仓联系人和联系电话'); return } if (duplicateSkuIds.value.size) { ElMessage.warning('同一退货单不能重复填写相同 SKU'); return } creating.value = true try { @@ -104,9 +108,9 @@ onMounted(load) -
{{ operationWarehousesError }}
- - +
{{ operationWarehousesError }}
+ +
@@ -121,7 +125,7 @@ onMounted(load)
- + diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index ffc265d..d09bfae 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -1,5 +1,5 @@ @@ -729,19 +775,19 @@ function selectRelabelInventory(inboundOrderId: string) {

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

- + + - + - -
*货物如何送到中转仓仓库上门提货客户送货到仓仓库上门提货:填写货物当前地址;客户送货到仓:下方自动展示中转仓收货信息。
+
*货物如何送到海外仓仓库上门提货客户送货到仓仓库上门提货:填写货物当前地址;客户送货到仓:下方自动展示海外仓收货信息。
-
仓库收货信息{{ operationWarehouseAddress || '该中转仓暂未维护收货地址' }}
+
海外仓收货信息{{ operationWarehouseAddress || '该海外仓暂未维护收货地址' }}
收货联系人{{ form.contact || '未维护' }}
联系电话{{ form.phone || '未维护' }}
@@ -752,7 +798,6 @@ function selectRelabelInventory(inboundOrderId: string) { -
是否打印面单需要仓库打印面单
@@ -760,7 +805,7 @@ function selectRelabelInventory(inboundOrderId: string) { -
*是否已有 FBA 去向暂未确定已有 FBA 去向暂未确定也可以提交,货物先正常入中转仓,后续再补充转运指令。
+
*是否已有 FBA 去向暂未确定已有 FBA 去向暂未确定也可以提交,货物先正常入海外仓,后续再补充转运指令。
@@ -775,6 +820,8 @@ function selectRelabelInventory(inboundOrderId: string) {

货物明细

+ 上传箱唛总文件 + 上传 SKU 总文件 下载 SKU 关联模板 上传 SKU 关联 下载箱号关联模板 @@ -784,8 +831,11 @@ function selectRelabelInventory(inboundOrderId: string) { + +
+
箱唛总文件:{{ cartonTotalFile?.name || '未选择' }}SKU 总文件:{{ skuTotalFile?.name || '未选择' }}
换标类型 @@ -814,7 +864,7 @@ function selectRelabelInventory(inboundOrderId: string) {

货物明细

换标明细与贴标申请保持一致;客户只填写自己的箱唛,我方箱唛由系统生成。

下载 SKU 关联模板上传 SKU 关联下载箱号关联模板上传箱号关联下载 SKU 混箱模板上传 SKU 混箱
-
+
换标类型只换箱唛只换 SKU 标签SKU 和箱唛都换

{{ labelingModeHint }}

待导入绑定关系

可只勾选本次需要换标的部分,也可以全选后一次导入。

全选导入到明细
选择关系类型仓库唛头旧 SKU新 SKU数量新 FBA 箱唛FBA 目的仓
{{ item.templateType === 'mixed' ? 'SKU 混箱换标' : item.templateType === 'carton' ? '箱号关联' : 'SKU 关联' }}{{ item.cartonMark }}{{ item.originalSku || '—' }}{{ item.newSku || '—' }}{{ item.quantity }}{{ item.fbaCartonMark || '—' }}{{ warehouseDisplay(item.destinationWarehouseId) }}
绑定明细

上传后在下方回显,也可以手动添加;客户箱唛作为仓库唛头来源,系统我方箱唛不需要手填。

添加 SKU 明细清空
@@ -828,7 +878,7 @@ function selectRelabelInventory(inboundOrderId: string) { diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index 00eca07..01bf7de 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -1,5 +1,5 @@ + + diff --git a/src/modules/order-management/deliveryOrders.test.mjs b/src/modules/order-management/deliveryOrders.test.mjs index 3a50edf..164a2c7 100644 --- a/src/modules/order-management/deliveryOrders.test.mjs +++ b/src/modules/order-management/deliveryOrders.test.mjs @@ -7,6 +7,7 @@ const apiSource = readFileSync(new URL('./api/deliveryOrders.ts', import.meta.ur const routesSource = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8') const listSource = readFileSync(new URL('./views/DeliveryOrdersView.vue', import.meta.url), 'utf8') const detailSource = readFileSync(new URL('./views/DeliveryOrderDetailView.vue', import.meta.url), 'utf8') +const deliveryStyles = readFileSync(new URL('./views/deliveryOrders.css', import.meta.url), 'utf8') const createSource = readFileSync(new URL('./views/DeliveryOrderCreateView.vue', import.meta.url), 'utf8') const permissionSource = readFileSync(new URL('../tenant-management/contracts.ts', import.meta.url), 'utf8') @@ -38,19 +39,18 @@ test('keeps list, read-only detail and create permissions independent in active }) test('renders the operation column first and covers loading, empty, error and pagination states', () => { - const operationIndex = listSource.indexOf('label="操作"') - const deliveryNumberIndex = listSource.indexOf('label="派送单号"') + const operationIndex = listSource.indexOf("label: '操作'") + const deliveryNumberIndex = listSource.indexOf("label: '派送单号'") assert.ok(operationIndex >= 0 && operationIndex < deliveryNumberIndex) - assert.match(listSource, /v-loading="loading"/) + assert.match(listSource, /:loading="loading"/) assert.match(listSource, /empty-text="暂无派送单"/) assert.match(listSource, /delivery-state--error/) assert.match(listSource, /@click="loadOrders">重试/) assert.match(listSource, /listDeliveryOrders\(buildQuery\(\)\)/) - assert.match(listSource, //) assert.match(detailSource, /getDeliveryOrder\(props\.deliveryNo\)/) assert.match(detailSource, /派送单详情加载失败/) - for (const heading of ['订单信息', '运输与预约', '货物明细', '费用明细']) assert.match(detailSource, new RegExp(heading)) + for (const heading of ['订单概览', '运输与预约', '货物明细', '费用明细']) assert.match(detailSource, new RegExp(heading)) assert.doesNotMatch(detailSource, /detail\?\.id|deliveryNo \|\| detail\?\.id|customerId|tenantId/) }) +test('renders a compact customer-readable delivery detail surface', () => { + assert.match(detailSource, /
/) + assert.match(detailSource, /]*\/>返回列表<\/el-button>/) + assert.match(detailSource, /class="client-status" :data-tone="statusTone\(detail\.status\)"/) + assert.match(detailSource, /TRUCK_PALLET: '卡派打托'/) + assert.match(detailSource, /WAREHOUSE_PICKUP: '仓库自提'/) + assert.match(detailSource, /PALLET: '托盘'/) + assert.match(detailSource, /TRUCK: '卡车'/) + assert.match(detailSource, /SELF: '自有账号'/) + assert.match(detailSource, /formatter: tableValueFormatter/) + assert.match(detailSource, /]*>派送单详情<\/h2>|display\(detail\.status\)/) + assert.match(deliveryStyles, /delivery-detail-surface \.delivery-detail-grid div\{display:grid;min-height:42px;/) +}) + test('submits the complete create contract with stable retry idempotency and recoverable failures', () => { for (const model of ['dispatchNo', 'plannedDeliveryAt', 'deliveryMethod', 'pickupMethod', 'loadingType', 'waybillNo', 'carrierName', 'vehicleType', 'vehicleSize', 'driverName', 'driverPhone', 'vehiclePlate', 'appointmentAccount', 'sellerName', 'remark']) { assert.match(createSource, new RegExp(`v-model="form\\.${model}"`)) diff --git a/src/modules/order-management/inboundOrders.test.mjs b/src/modules/order-management/inboundOrders.test.mjs index a626746..e5ff2ff 100644 --- a/src/modules/order-management/inboundOrders.test.mjs +++ b/src/modules/order-management/inboundOrders.test.mjs @@ -10,6 +10,7 @@ const overseasHttp = readFileSync(new URL('../../api/overseasHttp.ts', import.me test('activates the customer-scoped overseas inbound list and detail', () => { assert.match(routes, /path: 'orders\/inbound'[\s\S]*?release: 'active'[\s\S]*?permission: 'merchant:warehouse:inbound:list'/) + assert.match(routes, /path: 'orders\/inbound\/:planNo\/detail'[\s\S]*?name: 'orders-inbound-detail'/) assert.match(api, /\/merchant\/v1\/last-leg\/inbound-plans/) assert.match(api, /encodeURIComponent\(planNo\)/) assert.doesNotMatch(api, /\/platform\/|\/internal\/|customerId|tenantId/) @@ -17,12 +18,12 @@ test('activates the customer-scoped overseas inbound list and detail', () => { }) test('keeps actions first and exposes filters loading errors empty state pagination and business identifiers', () => { - assert.match(view, /]*fixed="left"/) + assert.match(view, /label: '操作', slotName: 'actions'[^\n]*fixed: 'left'/) for (const token of ['planNo', 'inboundCode', 'destinationWarehouseCode', 'appointmentStatus', 'dispatchStatus', 'createdDates']) assert.match(view, new RegExp(token)) assert.match(view, /v-loading="loading"/) assert.match(view, /type="error"/) assert.match(view, /empty-text="暂无入库订单"/) - assert.match(view, /]*v-model:page="query\.page"/) assert.match(view, /detail\.planNo/) assert.doesNotMatch(view, /planId|orderId|warehouseId|customerId/) }) @@ -31,8 +32,8 @@ test('shows only the server-provided overseas receiving warehouse code and name' assert.equal((api.match(/overseasWarehouseCode\?: string \| null/g) || []).length, 2) assert.equal((api.match(/overseasWarehouseName\?: string \| null/g) || []).length, 2) assert.doesNotMatch(api, /overseasWarehouseId/) - assert.match(view, /label="海外接柜仓"/) - assert.match(view, /overseasWarehouseLabel\(scope\.row\.overseasWarehouseCode, scope\.row\.overseasWarehouseName\)/) + assert.match(view, /label: '海外接柜仓'/) + assert.match(view, /overseasWarehouseLabel\(row\.overseasWarehouseCode, row\.overseasWarehouseName\)/) assert.match(view, /overseasWarehouseLabel\(detail\.overseasWarehouseCode, detail\.overseasWarehouseName\)/) assert.doesNotMatch(view, /warehouseName \|\| warehouseCode|destinationWarehouse(?:Id|Code|Name).*overseasWarehouse/i) }) @@ -59,17 +60,29 @@ test('renders inbound workflow statuses in Chinese and keeps desktop values on o ['SIGNED', '已签收'], ['PICKING', '拣货中'], ['PARTIAL_INBOUND', '部分入库'], ]) assert.match(view, new RegExp(`${status}: '${label}'`)) assert.match(view, /COMPLETED: '全部入库'/) - assert.match(view, /label="状态" width="120" show-overflow-tooltip/) - assert.match(view, /\.detail-grid div \{ min-width: 0;/) + assert.match(view, /label: '状态', slotName: 'status', width: 120/) + assert.match(view, /\.detail-grid div \{ display: grid; min-width: 0; min-height: 42px;/) assert.match(view, /\.detail-grid dd \{[^}]*overflow: hidden;[^}]*text-overflow: ellipsis;[^}]*white-space: nowrap;/) }) +test('uses the shared compact detail surface instead of stacked cards', () => { + assert.match(view, /
/) + assert.match(view, /name: 'orders-inbound-detail', params: \{ planNo: row\.planNo \}/) + assert.match(view, /]*\/>返回列表<\/el-button>/) + assert.match(view, /class="client-status" :data-tone="statusTone\(detail\.status\)"/) + assert.match(view, / { assert.match(view, /const inboundStatusOptions = \[/) - assert.match(view, /label="作业状态"> import('./views/InboundOrdersView.vue'), + props: { detailPage: true }, + release: 'active', + meta: { title: '入库订单详情', workspaceTitle: '入库订单 · 详情', description: '查看海外仓入库订单与作业明细', permission: 'merchant:warehouse:inbound:list', keepAlive: false }, + }, { path: 'orders/delivery', name: 'orders-delivery', @@ -34,7 +42,7 @@ export const orderManagementModule = { name: 'orders-delivery-create', component: () => import('./views/DeliveryOrderCreateView.vue'), release: 'active', - meta: { title: '创建派送单', description: '从排车单创建海外仓派送单', permission: 'merchant:warehouse:delivery:create', keepAlive: false }, + meta: { title: '创建派送单', workspaceTitle: '派送订单 · 新建', description: '从排车单创建海外仓派送单', permission: 'merchant:warehouse:delivery:create', keepAlive: false }, }, { path: 'orders/delivery/:deliveryNo', @@ -42,7 +50,7 @@ export const orderManagementModule = { component: () => import('./views/DeliveryOrderDetailView.vue'), props: true, release: 'active', - meta: { title: '派送单详情', description: '查看本企业海外仓派送单', permission: 'merchant:warehouse:delivery:list', keepAlive: false }, + meta: { title: '派送单详情', workspaceTitle: '派送订单 · 详情', description: '查看本企业海外仓派送单', permission: 'merchant:warehouse:delivery:list', keepAlive: false }, }, ], } satisfies ClientModuleManifest diff --git a/src/modules/order-management/views/DeliveryOrderDetailView.vue b/src/modules/order-management/views/DeliveryOrderDetailView.vue index 4fc2c60..44c3dcb 100644 --- a/src/modules/order-management/views/DeliveryOrderDetailView.vue +++ b/src/modules/order-management/views/DeliveryOrderDetailView.vue @@ -4,12 +4,31 @@ import { useRouter } from 'vue-router' import { ArrowLeft } from '@lucide/vue' import dayjs from 'dayjs' import { getDeliveryOrder, type DeliveryOrderDetail, type DeliveryOrderItem } from '../api/deliveryOrders' +import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue' const props = defineProps<{ deliveryNo: string }>() const router = useRouter() const loading = ref(false) const errorMessage = ref('') const detail = ref(null) +const statusLabels: Record = { PENDING: '待派送', IN_TRANSIT: '派送中', DELIVERED: '已送达', SIGNED: '已签收', CANCELLED: '已取消' } +const deliveryMethodLabels: Record = { FBA_PICKUP: 'FBA 自提', CUSTOMER_PICKUP: '客户自提', TRUCK_PALLET: '卡派打托', TRUCK_FLOOR: '卡派地板', EXPRESS: '快递', OUTSOURCED: '外配', DEDICATED_TRUCK: '专车直送', LTL: '零担派送' } +const pickupMethodLabels: Record = { ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提' } +const loadingTypeLabels: Record = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装' } +const vehicleTypeLabels: Record = { TRUCK: '卡车', VAN: '厢式货车', BOX_TRUCK: '箱式货车', TRAILER: '拖挂车' } +const appointmentAccountLabels: Record = { SELF: '自有账号', PLATFORM: '平台账号', CARRIER: '承运商账号' } +const tableValueFormatter = (_row: object, _column: unknown, value: string | number | null | undefined) => display(value) +const itemColumns: DataTableColumn[] = [ + { prop: 'palletNo', label: '托盘码', minWidth: 170, formatter: tableValueFormatter }, { prop: 'inboundCode', label: '入仓码', minWidth: 190, formatter: tableValueFormatter }, + { prop: 'skuCode', label: 'SKU', minWidth: 160, formatter: tableValueFormatter }, { prop: 'productName', label: '品名', minWidth: 180, formatter: tableValueFormatter }, + { prop: 'packageCount', label: '箱数', width: 90, align: 'right', headerAlign: 'right', formatter: tableValueFormatter }, + { prop: 'weightKg', label: '重量(kg)', width: 120, align: 'right', headerAlign: 'right', formatter: tableValueFormatter }, + { prop: 'volumeCbm', label: '体积(m³)', width: 120, align: 'right', headerAlign: 'right', formatter: tableValueFormatter }, +] +const feeColumns: DataTableColumn[] = [ + { prop: 'feeType', label: '费用类型', minWidth: 180, formatter: tableValueFormatter }, { prop: 'currency', label: '币种', width: 100, formatter: tableValueFormatter }, + { prop: 'amount', label: '金额', width: 140, align: 'right', headerAlign: 'right', formatter: tableValueFormatter }, { prop: 'remark', label: '备注', minWidth: 240, formatter: tableValueFormatter }, +] const items = computed(() => { const value = detail.value?.items @@ -29,6 +48,17 @@ function formatQuantity(value: number | null | undefined, unit: string) { return value === null || value === undefined ? '—' : `${value} ${unit}` } +function statusLabel(value: string) { return statusLabels[value] ?? display(value) } +function statusTone(value: string) { + if (value === 'DELIVERED' || value === 'SIGNED') return 'success' + if (value === 'IN_TRANSIT') return 'primary' + if (value === 'CANCELLED') return 'danger' + return 'warning' +} +function mappedLabel(value: string | null | undefined, labels: Record) { + return value ? labels[value] ?? value : '—' +} + async function loadDetail() { loading.value = true errorMessage.value = '' @@ -50,24 +80,28 @@ onMounted(loadDetail)