From b4403ab188a9a803c81453cf5d87049b39d09956 Mon Sep 17 00:00:00 2001 From: wujinliang <2318309077@qq.com> Date: Mon, 21 Sep 2026 19:36:37 +0800 Subject: [PATCH 01/36] 1 --- src/modules/first-leg/api/firstLeg.ts | 2 ++ .../components/InboundBillingPanel.test.mjs | 3 ++- .../components/InboundBillingPanel.vue | 3 +++ .../first-leg/store/firstLegOrders.test.mjs | 6 ++++-- src/modules/first-leg/store/firstLegOrders.ts | 21 ++++++++++++++----- .../first-leg/utils/inboundBilling.test.mjs | 7 +++++++ src/modules/first-leg/utils/inboundBilling.ts | 14 +++++++++++++ .../views/WarehouseServiceView.vue | 3 ++- .../views/warehouseTransferForm.test.mjs | 12 +++++++++++ 9 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts index 69d080b..a65d1f7 100644 --- a/src/modules/first-leg/api/firstLeg.ts +++ b/src/modules/first-leg/api/firstLeg.ts @@ -296,6 +296,8 @@ export interface MerchantFirstLegOrderRow { inboundedAt: string | null businessDate: string | null shipmentId: string | null + fulfillmentType?: 'STANDARD' | 'ONE_CLICK_DROPSHIP' | null + lxCode?: string | null createdAt: string cargoes?: Array<{ skuNo?: string | null }> } diff --git a/src/modules/first-leg/components/InboundBillingPanel.test.mjs b/src/modules/first-leg/components/InboundBillingPanel.test.mjs index d67fe10..649012a 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.test.mjs +++ b/src/modules/first-leg/components/InboundBillingPanel.test.mjs @@ -42,7 +42,8 @@ test('shows the published merchant inbound, measurement details and customer con assert.match(source, /latestFee\.rateCode/) assert.match(source, /latestFee\.chargeRuleVersion/) assert.match(source, /latestFee\.carrierRateVersion/) - assert.doesNotMatch(source, /箱规规则|calculationRuleVersion/) + assert.match(source, /箱规规则/) + assert.match(source, /getCalculationRuleLabel\(row\.calculationRuleVersion\)/) }) test('stacks core information for 720 and 360 pixel layouts', () => { diff --git a/src/modules/first-leg/components/InboundBillingPanel.vue b/src/modules/first-leg/components/InboundBillingPanel.vue index 5094542..24f81cc 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.vue +++ b/src/modules/first-leg/components/InboundBillingPanel.vue @@ -10,6 +10,7 @@ import { formatInboundMeasure, formatInboundMoney, formatInboundNumber, + getCalculationRuleLabel, getChargeModeLabel, getTaxModeLabel, } from '@/modules/first-leg/utils/inboundBilling' @@ -139,6 +140,7 @@ async function confirmFee() { + @@ -241,6 +243,7 @@ async function confirmFee() { + /) assert.match(apiSource, /apiRequest>/) assert.match(storeSource, /function mapFirstLegOrderRow/) - for (const field of ['inboundId', 'inboundCode', 'channelName', 'destinationWarehouseName', 'pickupMethod', 'packageCount', 'expressCompany', 'trackingNo', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'remark', 'shipmentId', 'referenceId', 'createdAt']) { + for (const field of ['inboundId', 'inboundCode', 'channelName', 'destinationWarehouseName', 'pickupMethod', 'packageCount', 'expressCompany', 'trackingNo', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'remark', 'shipmentId', 'fulfillmentType', 'lxCode', 'referenceId', 'createdAt']) { assert.match(storeSource, new RegExp(`row\\.${field}`)) } assert.match(storeSource, /row\.skuNos/) @@ -102,7 +102,9 @@ test('keeps unified order pagination valid when the store omits page and size', test('keeps drafts on the legacy read path without assigning a unified main status', () => { assert.match(storeSource, /getMerchantFirstLegOrders\(legacyQuery\(query, 'DRAFT'\)\)/) - assert.match(storeSource, /getById\(id\)\?\.status === 'DRAFT' \? loadLegacyDetail\(id\) : loadUnifiedDetail\(id\)/) + assert.match(storeSource, /const listOrder = getById\(id\)/) + assert.match(storeSource, /listOrder\?\.status === 'DRAFT' \? loadLegacyDetail\(id, listOrder\) : loadUnifiedDetail\(id\)/) + assert.match(storeSource, /detail\.order\.lxCode == null && listOrder\?\.lxCode/) assert.match(storeSource, /return loadLegacyDetail\(String\(createdId\)\)/) assert.match(storeSource, /draftLoadError/) }) diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index d202fc2..96ce9c5 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -359,7 +359,7 @@ function mapFirstLegOrderRow(row: MerchantFirstLegOrderRow): FirstLegOrder { const uniqueSkuNos = [...new Set(resolvedSkuNos)] return { id: String(row.orderId), orderNo: row.orderNo, referenceNo: row.referenceId ?? '', transportMode: null, - businessType: 'EXPORT', origin: '', destination: row.destinationWarehouseName ?? '', receivingWarehouseId: '', + businessType: 'EXPORT', fulfillmentType: row.fulfillmentType ?? 'STANDARD', lxCode: row.lxCode ?? '', origin: '', destination: row.destinationWarehouseName ?? '', receivingWarehouseId: '', receivingWarehouseName: '', receivingWarehouseSnapshot: null, expectedDepartureDate: '', etd: '', eta: '', shipperPartyId: '', shipperPartySnapshot: null, shipper: '', consigneePartyId: '', consigneePartySnapshot: null, consignee: row.receivingCompanyName ?? '', expressCompanyName: row.expressCompany ?? '', trackingNumber: row.trackingNo ?? '', @@ -542,9 +542,19 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () return mapped } - async function loadLegacyDetail(id: string) { + async function loadLegacyDetail(id: string, listOrder?: FirstLegOrder) { const detail = await getMerchantFirstLegOrder(id) - return upsertOrder(mapOrder(detail.order, warehouses.value, detail)) + const mapped = mapOrder(detail.order, warehouses.value, detail) + // Older detail responses may omit newly added dropship fields. Keep the + // values already returned by the list row so editing a draft does not + // clear the persisted LX code in the form. + if (detail.order.fulfillmentType == null && listOrder?.fulfillmentType) { + mapped.fulfillmentType = listOrder.fulfillmentType + } + if (detail.order.lxCode == null && listOrder?.lxCode) { + mapped.lxCode = listOrder.lxCode + } + return upsertOrder(mapped) } async function loadUnifiedDetail(id: string) { @@ -558,7 +568,8 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () } async function loadDetail(id: string) { - return getById(id)?.status === 'DRAFT' ? loadLegacyDetail(id) : loadUnifiedDetail(id) + const listOrder = getById(id) + return listOrder?.status === 'DRAFT' ? loadLegacyDetail(id, listOrder) : loadUnifiedDetail(id) } async function loadWarehouseDetail(id: string) { @@ -616,7 +627,7 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () } async function loadDraftDetail(id: string) { - return loadLegacyDetail(id) + return loadLegacyDetail(id, getById(id)) } return { orders, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, draftLoadError, activeCount, pendingCount, getById, load, loadDetail, loadDraftDetail, loadWarehouseDetail, loadWarehouseType, saveDraft, saveDraftBatch, submitSaved, submit } diff --git a/src/modules/first-leg/utils/inboundBilling.test.mjs b/src/modules/first-leg/utils/inboundBilling.test.mjs index fec95b4..5751d98 100644 --- a/src/modules/first-leg/utils/inboundBilling.test.mjs +++ b/src/modules/first-leg/utils/inboundBilling.test.mjs @@ -19,6 +19,13 @@ test('translates backend rounding modes into customer-facing calculation labels' assert.match(source, /return value \? labels\[value\] \|\| value : '-'/) }) +test('translates carton calculation rules into customer-facing labels', () => { + assert.match(source, /export function getCalculationRuleLabel/) + assert.match(source, /ACTUAL_DIMENSION_V1/) + assert.match(source, /按实际箱规计算/) + assert.match(source, /CUSTOMER_DIMENSION_\(SMALL\|MEDIUM\|LARGE\|EXTRA_LARGE\)_V/) +}) + test('renders conversion metadata as one complete label or a single empty marker', () => { assert.match(source, /export function formatConversionPrecision/) assert.match(source, /roundingScale === null \|\| roundingScale === undefined/) diff --git a/src/modules/first-leg/utils/inboundBilling.ts b/src/modules/first-leg/utils/inboundBilling.ts index 600fcad..b880932 100644 --- a/src/modules/first-leg/utils/inboundBilling.ts +++ b/src/modules/first-leg/utils/inboundBilling.ts @@ -46,6 +46,20 @@ export function getTaxModeLabel(value: string) { return value || '-' } +export function getCalculationRuleLabel(value: string | null | undefined) { + if (!value) return '-' + if (value === 'ACTUAL_DIMENSION_V1' || value === 'DIMENSION_ACTUAL_V1') return '按实际箱规计算' + const customerRule = /^CUSTOMER_DIMENSION_(SMALL|MEDIUM|LARGE|EXTRA_LARGE)_V(\d+)$/.exec(value) + if (customerRule) { + const sizeLabel: Record = { + SMALL: '小号', MEDIUM: '中号', LARGE: '大号', EXTRA_LARGE: '超大号', + } + return `客户箱规规则(${sizeLabel[customerRule[1]]},V${customerRule[2]})` + } + const warehouseRule = /^WAREHOUSE_OUTER_OVERRIDE_(.+)$/.exec(value) + return warehouseRule ? `仓库外箱规覆盖(${warehouseRule[1]})` : value +} + export function getRoundingModeLabel(value: string | null | undefined) { const labels: Record = { UP: '向上舍入', diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index eda096f..313c351 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -106,6 +106,7 @@ interface ServiceDetail extends Record { estimatedShipAt?: string; actualShipAt?: string; fbaShipmentNo?: string; trackingNo?: string remark?: string; needRelabel?: boolean; labelDeadline?: string; relabelContactName?: string relabelContactPhone?: string; relabelDeliveryMethod?: string + createdAt?: string items?: Array<{ skuCode?: string; productNameZh?: string; packageCount?: number; shippingMark?: string }> relabelBindings?: Array<{ operationType?: string; customerCartonMark?: string; originalSku?: string; newSku?: string; quantity?: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }> cartons?: TransferCarton[]; pallets?: TransferPallet[]; measurementPhotos?: TransferMeasurementPhoto[]; customerConfirmedAt?: string @@ -1138,7 +1139,7 @@ function selectRelabelInventory(inboundOrderId: string) { diff --git a/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs index b54ab9a..25675e9 100644 --- a/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs +++ b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs @@ -25,9 +25,14 @@ test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否 assert.match(view, /value="FBA">FBA 仓/) assert.match(view, /value="PRIVATE">私人仓/) assert.match(view, /transferDestinationType === 'PRIVATE'.*私人仓地址.*邮编.*私人仓联系人.*联系电话/s) - assert.match(view, />换标服务不需要/) - assert.match(view, /:value="true">需要/) + assert.match(view, />是否贴标不需要贴标/) + assert.match(view, /:value="true">需要贴标/) + assert.match(view, /FBA 仓和私人仓均可选择是否贴标/) + assert.doesNotMatch(view, /v-model="form\.relabel" disabled/) + assert.doesNotMatch(view, /value === 'FBA'\) form\.relabel = true/) + assert.doesNotMatch(view, /value === 'PRIVATE'.*form\.relabel = false/) + assert.match(view, /v-if="props\.service === 'transfer' && form\.relabel" class="warehouse-card warehouse-form-section warehouse-transfer-relabel"/) assert.match(api, /fbaWarehouseCode\?: string/) assert.match(api, /privateDestinationAddress\?: string; privateDestinationPostalCode\?: string/) assert.match(api, /fbaShipmentNo\?: string/) From bc5c56d0e4d99b15d4c646d3295c200a30a72ca1 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 23 Sep 2026 11:25:35 +0800 Subject: [PATCH 04/36] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E4=BC=98=E5=8C=960923?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/warehouseServices.ts | 7 +- .../views/WarehouseServiceView.vue | 170 +++++++++++++++--- .../views/relabelAttachments.test.mjs | 17 +- .../views/warehouseTransferForm.test.mjs | 105 ++++++++++- 4 files changed, 260 insertions(+), 39 deletions(-) diff --git a/src/modules/overseas-warehouse/api/warehouseServices.ts b/src/modules/overseas-warehouse/api/warehouseServices.ts index 590cd74..9708681 100644 --- a/src/modules/overseas-warehouse/api/warehouseServices.ts +++ b/src/modules/overseas-warehouse/api/warehouseServices.ts @@ -5,8 +5,8 @@ export type RelabelDeliveryMethod = 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPR 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 HistoricalOrderSummary { id: string | null; orderNo: string; sourceOrderNo?: string; outboundOrderNo?: string; relabelOrderNo?: string; orderType?: string; status?: string; packageCount?: number } -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; estimatedShipAt?: string; actualShipAt?: 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; totalFileId?: string; totalFileName?: string; totalFileUrl?: string; cartonTotalFileId?: string; cartonTotalFileName?: string; cartonTotalFileUrl?: string; skuTotalFileId?: string; skuTotalFileName?: string; skuTotalFileUrl?: string; deliveryMethod?: RelabelDeliveryMethod; printShippingLabel?: boolean; shippingLabelFileId?: string; shippingLabelFileName?: string; shippingLabelFileUrl?: string; fulfillmentStatus?: string; outboundOrderNo?: string; customerConfirmedAt?: 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; relabelStatus?: string; relabelFulfillmentStatus?: string; status: string; estimatedArrivalAt: string; estimatedShipAt?: string; actualShipAt?: 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; totalFileId?: string; totalFileName?: string; totalFileUrl?: string; cartonTotalFileId?: string; cartonTotalFileName?: string; cartonTotalFileUrl?: string; skuTotalFileId?: string; skuTotalFileName?: string; skuTotalFileUrl?: string; deliveryMethod?: RelabelDeliveryMethod; printShippingLabel?: boolean; shippingLabelFileId?: string; shippingLabelFileName?: string; shippingLabelFileUrl?: string; fulfillmentStatus?: string; outboundOrderNo?: string; customerConfirmedAt?: string; reviewChannel?: 'TAIL_ORDER' | 'RELABEL_ORDER'; reviewRemark?: string } export interface RelabelItemDetail { id: string; skuCode?: string; productNameZh: string; totalPackageCount: number; notifiedPackageCount: number shipmentNo: string; trackingCode?: string; newSkuCode?: string; labelRequirement?: string; remark?: string @@ -97,6 +97,9 @@ export function listRelabelInventoryCandidates(operationWarehouseId: string, inb export function createRelabel(payload: RelabelCreate) { return overseasApiRequest({ method: 'POST', url: '/merchant/v1/last-leg/relabel-applications', data: payload }) } +export function resubmitRelabel(relabelNo: string, payload: { labelDeadline: string; contactName: string; contactPhone: string; remark?: string; idempotencyKey: string }) { + return overseasApiRequest({ method: 'PUT', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/resubmit`, 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' } }) diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index c6cf869..94e4f5f 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -8,7 +8,7 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v import type { TableAction } from '@/components/common/tableActions' import { useAuthStore } from '@/modules/auth/public' import { - createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, + createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, resubmitRelabel, getTransferCartonLabel, submitTransfer, updateTransfer, confirmRelabelCompletion, confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, uploadRelabelShippingLabel, uploadRelabelFile, @@ -55,7 +55,7 @@ const canConfirmTransfer = computed(() => auth.hasPermission('merchant:overseas- const canCancelDiscard = computed(() => auth.hasPermission('merchant:overseas-warehouse:discard:cancel')) 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 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '创建时间'] }, } @@ -70,7 +70,7 @@ const listQueryModel = computed({ }), }) const statusOptions = computed(() => props.service === 'labeling' - ? [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }] + ? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }] : props.service === 'disposal' ? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待 PDA 废弃', value: 'APPROVED' }, { label: '待仓库确认完成', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已拒绝', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] : props.service === 'transfer' @@ -111,13 +111,14 @@ interface TransferPallet { id?: string | number; sequenceNo?: number; palletNo?: interface TransferMeasurementPhoto { fileId: string | number; fileName?: string; contentType?: string; sizeBytes?: number; uploadedAt?: string } interface ServiceDetail extends Record { transferNo?: string; status?: string; operatingWarehouseId?: string; fbaWarehouseCode?: string - destinationType?: 'FBA' | 'PRIVATE'; destinationWarehouseCode?: string; destinationWarehouseName?: string + destinationType?: 'NONE' | 'FBA' | 'PRIVATE'; destinationWarehouseCode?: string; destinationWarehouseName?: string privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string deliveryAddress?: string; pickupContactName?: string; pickupContactPhone?: string packageCount?: number; volumeCbm?: number; deliveryMethod?: string; estimatedArrivalAt?: string estimatedShipAt?: string; actualShipAt?: string; fbaShipmentNo?: string; trackingNo?: string remark?: string; needRelabel?: boolean; labelDeadline?: string; relabelContactName?: string - relabelContactPhone?: string; relabelDeliveryMethod?: string + relabelContactPhone?: string; relabelDeliveryMethod?: string; relabelNo?: string + relabelStatus?: string; relabelFulfillmentStatus?: string; createdAt?: string items?: Array<{ skuCode?: string; productNameZh?: string; packageCount?: number; shippingMark?: string }> relabelBindings?: Array<{ operationType?: string; customerCartonMark?: string; originalSku?: string; newSku?: string; quantity?: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }> cartons?: TransferCarton[]; pallets?: TransferPallet[]; measurementPhotos?: TransferMeasurementPhoto[]; customerConfirmedAt?: string @@ -158,7 +159,8 @@ const listQueryFields = computed(() => { }) const listColumns = computed(() => config.value.columns.map((label, index) => index === 0 ? { key: 'actions', label, slotName: 'actions', width: props.service === 'transfer' ? 210 : 160, fixed: 'left' } - : { key: String(index - 1), prop: String(index - 1), label, minWidth: index === 1 ? 190 : 130 })) + : { key: String(index - 1), prop: String(index - 1), label, minWidth: index === 1 || label === '换标单号' ? 190 : 130, + slotName: props.service === 'transfer' && label === '换标状态' ? 'transferRelabelStatus' : props.service === 'transfer' && label === '状态' ? 'transferStatus' : undefined })) const discardCandidates = ref([]) const selectedDiscardOrderId = ref('') const discardCandidatesLoading = ref(false) @@ -225,6 +227,13 @@ watch(() => form.relabel, (value) => { if (!transferRelabelRows.value.length) addTransferRelabelRow() } }) +watch(() => cargoRows.value.map((row) => row.sku), (skus) => { + if (props.service !== 'transfer' || !form.relabel) return + transferRelabelRows.value.forEach((row, index) => { + const sourceSku = skus[Math.min(index, Math.max(0, skus.length - 1))]?.trim() || '' + if (sourceSku) row.sku = sourceSku + }) +}) watch(labelingMode, (value) => { if (value !== 'carton' && !skuRelabelRows.value.length) addSkuRelabelRow() }) watch(() => form.destination, (value) => { if (props.service !== 'labeling') return @@ -397,7 +406,10 @@ function printTransferDraftCartonMarks() { printWindow.document.write(`仓储转运客户箱唛${sections}`) printWindow.document.close(); printWindow.focus(); printWindow.print() } -function addTransferRelabelRow() { transferRelabelRows.value.push(createCargoRow()) } +function addTransferRelabelRow() { + const source = cargoRows.value[Math.min(transferRelabelRows.value.length, Math.max(0, cargoRows.value.length - 1))] + transferRelabelRows.value.push({ ...createCargoRow(), sku: source?.sku || '' }) +} function clearTransferRelabelRows() { transferRelabelRows.value = [] } @@ -728,9 +740,12 @@ function warehouseDisplay(value?: string) { } function statusLabel(value: string) { if (props.service === 'labeling') { - if (value === 'PROCESSING') return '处理中' - if (value === 'CUSTOMER_CONFIRM_PENDING') return '待客户确认' - return ['READY_FOR_OUTBOUND', 'COMPLETED'].includes(value) ? '换标完成' : '待处理' + const relabelLabels: Record = { + WAIT_APPROVAL: '待审核', PENDING_REVIEW: '待审核', PENDING_PROCESSING: '待仓库处理', PROCESSING: '处理中', + CUSTOMER_CONFIRM_PENDING: '待客户确认', READY_FOR_OUTBOUND: '换标完成', COMPLETED: '已完成', + REJECTED: '已驳回', CANCELLED: '已取消', + } + return relabelLabels[value] || value || '—' } if (props.service === 'transfer') { const transferLabels: Record = { @@ -755,6 +770,55 @@ function statusLabel(value: string) { } return labels[value] || value || '—' } +type StatusTagType = 'primary' | 'success' | 'warning' | 'danger' | 'info' +function relabelStatusTagType(status: string): StatusTagType { + if (['READY_FOR_OUTBOUND', 'COMPLETED'].includes(status)) return 'success' + if (['REJECTED', 'CANCELLED'].includes(status)) return 'danger' + if (['WAIT_APPROVAL', 'PENDING_REVIEW', 'PENDING_PROCESSING', 'CUSTOMER_CONFIRM_PENDING'].includes(status)) return 'warning' + return 'primary' +} +function relabelFulfillmentLabel(value?: string) { + const labels: Record = { + WAIT_APPROVAL: '待审核', WAIT_PROCESSING: '待仓库处理', + WAIT_RELABEL: '待换标', WAIT_PRE_DISPATCH: '待排车', READY_FOR_DISPATCH: '待排车', + OUTBOUND_CREATED: '换标出库单已创建', OUTBOUNDED: '已出库', COMPLETED: '已完成', + } + return labels[value || ''] || value || '—' +} +function relabelFulfillmentTagType(status?: string): StatusTagType { + if (['OUTBOUNDED', 'COMPLETED'].includes(status || '')) return 'success' + if (['WAIT_APPROVAL', 'WAIT_PROCESSING', 'WAIT_RELABEL', 'WAIT_PRE_DISPATCH', 'READY_FOR_DISPATCH'].includes(status || '')) return 'warning' + return 'primary' +} +function transferStatusTagType(status: string): StatusTagType { + if (['RECEIVED', 'COMPLETED'].includes(status)) return 'success' + if (['REJECTED', 'CANCELLED'].includes(status)) return 'danger' + if (['DRAFT'].includes(status)) return 'info' + if (['PENDING_REVIEW', 'WAIT_CUSTOMER_CONFIRMATION', 'WAIT_RELABEL', 'WAIT_FBA_DESTINATION', 'READY_FOR_DISPATCH', 'WAIT_OUTBOUND', 'PARTIALLY_RECEIVED'].includes(status)) return 'warning' + return 'primary' +} +function transferRelabelStatus(item: { needRelabel?: boolean; relabelNo?: string; relabelStatus?: string; relabelFulfillmentStatus?: string }) { + if (!item.needRelabel) return { code: 'NOT_REQUIRED', label: '无需换标' } + if (!item.relabelNo) return { code: 'WAIT_RELABEL', label: '待换标' } + const code = item.relabelStatus || 'PENDING_PROCESSING' + const labels: Record = { + PENDING_REVIEW: '待审核', PENDING_PROCESSING: '待处理', PROCESSING: '换标中', + CUSTOMER_CONFIRM_PENDING: '待客户确认', READY_FOR_OUTBOUND: '已换标', COMPLETED: '已换标', + REJECTED: '已驳回', CANCELLED: '已取消', + } + const fulfillmentLabels: Record = { + WAIT_RELABEL: '待换标', WAIT_CUSTOMER_CONFIRMATION: '待客户确认', READY_FOR_DISPATCH: '已换标', + OUTBOUND_CREATED: '已换标', OUTBOUNDED: '已换标', NOT_REQUIRED: '无需换标', + } + return { code, label: labels[code] || fulfillmentLabels[item.relabelFulfillmentStatus || ''] || statusLabel(code) } +} +function transferRelabelStatusTagType(status: string): StatusTagType { + if (['READY_FOR_OUTBOUND', 'COMPLETED'].includes(status)) return 'success' + if (['REJECTED', 'CANCELLED'].includes(status)) return 'danger' + if (status === 'NOT_REQUIRED') return 'info' + if (['WAIT_RELABEL', 'PENDING_REVIEW', 'CUSTOMER_CONFIRM_PENDING'].includes(status)) return 'warning' + return 'primary' +} function deliveryMethodLabel(value: string) { const labels: Record = { TRUCK: '卡车派送', EXPRESS: '快递派送', TRUCK_PICKUP: '卡车自提', EXPRESS_PICKUP: '快递自提', @@ -764,6 +828,11 @@ function deliveryMethodLabel(value: string) { } return labels[value] || value || '—' } +function destinationTypeLabel(value?: string, fbaWarehouseCode?: string) { + if (value === 'PRIVATE') return '私人仓' + if (value === 'FBA' || fbaWarehouseCode) return 'FBA 仓' + return '暂未确定' +} function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 19) : '—' } @@ -786,8 +855,9 @@ async function load() { total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length submittedRows.value = result.records.map((item) => { const operationWarehouse = warehouseDisplay(item.operatingWarehouseId) + const relabelStatus = transferRelabelStatus(item) return props.service === 'transfer' - ? { ...row([item.transferNo, item.packageCount, operationWarehouse, item.fbaWarehouseCode, deliveryMethodLabel(item.deliveryMethod), item.needRelabel ? '是 · 待绑定换标单' : '否', statusLabel(item.status), formatDateTime(item.createdAt)]), __status: item.status } + ? { ...row([item.transferNo, item.packageCount, operationWarehouse, item.fbaWarehouseCode, deliveryMethodLabel(item.deliveryMethod), item.needRelabel ? '是' : '否', item.relabelNo, relabelStatus.label, statusLabel(item.status), formatDateTime(item.createdAt)]), __status: item.status, __statusLabel: statusLabel(item.status), __relabelStatus: relabelStatus.code, __relabelStatusLabel: relabelStatus.label } : row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', statusLabel(item.status), formatDateTime(item.createdAt)]) }) } @@ -964,8 +1034,13 @@ async function submit(action: 'draft' | 'submit' = 'submit') { const submitted = await submitTransfer(transferNo) if (form.relabel) { const relabelNo = String(submitted.relabelNo || created.relabelNo || '') + if (!relabelNo) { + ElMessage.success('转运单已提交审核;PDA 入库确认后将自动生成关联换标单') + await ElMessageBox.alert(`转运单 ${transferNo} 已提交。关联换标单会在 PDA 完成入库、客户确认实测库存后自动生成;届时请进入关联换标单详情上传贴标总文件。`, '转运单已提交', { confirmButtonText: '知道了' }) + await openDetail({ '0': transferNo }) + return + } try { - if (!relabelNo) throw new Error('转运单已提交,但未返回关联换标单号') if (cartonTotalFile.value) await uploadRelabelFile(relabelNo, 'CARTON_TOTAL', cartonTotalFile.value) if (skuTotalFile.value) await uploadRelabelFile(relabelNo, 'SKU_TOTAL', skuTotalFile.value) } catch (error) { @@ -1003,8 +1078,8 @@ async function openDetail(value: Record) { } catch (error) { selectedDetail.value = null; selectedRelabelDetail.value = null; detailError.value = error instanceof Error ? error.message : '详情加载失败'; mode.value = 'detail'; ElMessage.error(detailError.value) } } async function loadDetailPage() { - await loadWarehouses() await openDetail({ '0': detailNumber.value }) + await loadWarehouses() } async function confirmTransferMeasurements() { const transferNo = String(selectedDetail.value?.transferNo || selectedDetail.value?.['0'] || '') @@ -1145,6 +1220,34 @@ async function selectTransferWarehouse() { ElMessage.error(error instanceof Error ? error.message : '中转仓联系信息加载失败') } finally { operationWarehouseDetailLoading.value = false } } +async function confirmSelectedRelabel() { + const relabelNo = selectedRelabelDetail.value?.application.relabelNo + if (!relabelNo) return + try { + await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续出库流程吗?', '换标完成确认', { confirmButtonText: '确认并继续', cancelButtonText: '取消' }) + await confirmRelabelCompletion(relabelNo) + ElMessage.success('已确认换标完成,订单已进入后续出库流程') + await openDetail({ '0': relabelNo }) + } catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '确认失败') } +} +async function resubmitRejectedRelabel() { + const application = selectedRelabelDetail.value?.application + if (!application || application.status !== 'REJECTED') return + try { + const { value } = await ElMessageBox.prompt('可先在下方替换贴标文件,再补充本次修改说明。', '修改后重新提交审核', { + inputType: 'textarea', inputValue: selectedRelabelDetail.value?.remark || '', + inputPlaceholder: '修改说明(选填)', confirmButtonText: '重新提交', cancelButtonText: '取消', + }) + selectedRelabelDetail.value = await resubmitRelabel(application.relabelNo, { + labelDeadline: application.labelDeadline || '', contactName: application.contactName || '', + contactPhone: application.contactPhone || '', remark: value?.trim() || undefined, + idempotencyKey: crypto.randomUUID(), + }) + ElMessage.success('贴标单已重新提交审核') + } catch (error) { + if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '重新提交失败') + } +} const transferInventoryOptions = computed(() => { const grouped = new Map() for (const item of transferInventoryCandidates.value) { @@ -1264,6 +1367,8 @@ function selectRelabelInventory(inboundOrderId: string) { + + @@ -1304,7 +1409,7 @@ function selectRelabelInventory(inboundOrderId: string) { - +
*目的去向暂未确定FBA 仓私人仓
@@ -1328,9 +1433,9 @@ function selectRelabelInventory(inboundOrderId: string) {
1贴标总文件
{{ requiresBothTotalFiles ? '必传 · 两份文件' : '必传 · 二选一' }}
-
箱唛总文件
{{ cartonTotalFile ? '重新上传' : '选择文件' }}
{{ cartonTotalFile?.name || '尚未选择文件' }}
+
箱唛总文件
{{ cartonTotalFile ? '重新上传' : '选择文件' }}
{{ cartonTotalFile?.name || '尚未选择文件' }}
{{ requiresBothTotalFiles ? '和' : '或' }}
-
SKU 总文件
{{ skuTotalFile ? '重新上传' : '选择文件' }}
{{ skuTotalFile?.name || '尚未选择文件' }}
+
SKU 总文件
{{ skuTotalFile ? '重新上传' : '选择文件' }}
{{ skuTotalFile?.name || '尚未选择文件' }}
@@ -1346,7 +1451,7 @@ function selectRelabelInventory(inboundOrderId: string) {
- +
选择SKU中文品名库存总数本次贴标数量
{{ row.skuCode }}{{ row.productNameZh || '—' }}{{ row.availableQuantity }}
该退货单暂无可申请贴标的 SKU 库存
{{ row.skuCode }}{{ row.productNameZh || '—' }}{{ row.availableQuantity }}
该退货单暂无可申请贴标的 SKU 库存
@@ -1382,9 +1487,9 @@ function selectRelabelInventory(inboundOrderId: string) {
贴标总文件
{{ requiresBothTotalFiles ? '必传 · 两份文件' : '必传 · 二选一' }}
-
箱唛总文件
{{ cartonTotalFile ? '重新上传' : '选择文件' }}
{{ cartonTotalFile?.name || '尚未选择文件' }}
+
箱唛总文件
{{ cartonTotalFile ? '重新上传' : '选择文件' }}
{{ cartonTotalFile?.name || '尚未选择文件' }}
{{ requiresBothTotalFiles ? '和' : '或' }}
-
SKU 总文件
{{ skuTotalFile ? '重新上传' : '选择文件' }}
{{ skuTotalFile?.name || '尚未选择文件' }}
+
SKU 总文件
{{ skuTotalFile ? '重新上传' : '选择文件' }}
{{ skuTotalFile?.name || '尚未选择文件' }}
@@ -1407,10 +1512,17 @@ function selectRelabelInventory(inboundOrderId: string) {
{{ props.service === 'labeling' ? selectedRelabelDetail?.application.relabelNo : selectedDetail?.transferNo || selectedDetail?.['0'] || `${config.title}详情` }}{{ config.title }}详情
-
取消申请编辑确认测量数据{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}
+
修改后重新提交确认换标完成取消申请编辑确认测量数据{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}
-

订单概览

操作仓{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}
客户参考号{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}
SKU 数{{ selectedRelabelDetail?.application.itemCount ?? '—' }}
总箱数{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}
贴标截止时间{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}
仓库联系人{{ selectedRelabelDetail?.application.contactName || '—' }}
联系电话{{ selectedRelabelDetail?.application.contactPhone || '—' }}
创建时间{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}
{{ column }}{{ selectedDetail?.[String(index)] || '—' }}
+ +

订单概览

操作仓{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}
客户参考号{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}
SKU 数{{ selectedRelabelDetail?.application.itemCount ?? '—' }}
总箱数{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}
贴标截止时间{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}
仓库联系人{{ selectedRelabelDetail?.application.contactName || '—' }}
联系电话{{ selectedRelabelDetail?.application.contactPhone || '—' }}
创建时间{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}
状态{{ statusLabel(selectedDetail?.status || '') }}
目的去向{{ destinationTypeLabel(selectedDetail?.destinationType, selectedDetail?.fbaWarehouseCode) }}
FBA 目的仓{{ warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}
FBA 货件号{{ selectedDetail?.fbaShipmentNo || '—' }}
私人仓地址{{ selectedDetail?.privateDestinationAddress || '—' }}
邮编{{ selectedDetail?.privateDestinationPostalCode || '—' }}
私人仓联系人{{ selectedDetail?.privateDestinationContactName || '—' }}
联系电话{{ selectedDetail?.privateDestinationContactPhone || '—' }}
海外仓{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}
箱数{{ selectedDetail?.packageCount ?? '—' }}
入仓方式{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}
期望发货时间{{ formatDateTime(selectedDetail?.estimatedShipAt) }}
实际发货时间{{ formatDateTime(selectedDetail?.actualShipAt) }}
是否换标{{ selectedDetail?.needRelabel ? '是' : '否' }}
换标状态{{ transferRelabelStatus(selectedDetail || {}).label }}
换标单号{{ selectedDetail?.relabelNo || '—' }}
创建时间{{ formatDateTime(selectedDetail?.createdAt) }}
转运单号{{ selectedDetail?.transferNo || '—' }}
{{ column }}{{ selectedDetail?.[String(index)] || '—' }}
+
+

PDA 打托实测数据

PDA 作业完成即显示入库完成,客户可确认仓库实测数据
+
托盘号库位箱数长 × 宽 × 高(cm)毛重(kg)体积(m³)状态完成时间
{{ pallet.palletNo || `第${pallet.sequenceNo || '—'}托` }}{{ pallet.warehouseLocationCode || '—' }}{{ pallet.cartonCount ?? '—' }}{{ [pallet.lengthCm, pallet.widthCm, pallet.heightCm].every((value) => value != null) ? `${pallet.lengthCm} × ${pallet.widthCm} × ${pallet.heightCm}` : '—' }}{{ pallet.grossWeightKg ?? '—' }}{{ pallet.volumeCbm ?? '—' }}{{ statusLabel(pallet.status || '') }}{{ formatDateTime(pallet.completedAt) }}
+
暂无 PDA 打托测量数据
+
测量打托现场照片
照片 {{ index + 1 }} 加载中…
仓库尚未上传测量打托照片
+

货物明细

打印箱唛
商品 SKU中文品名预计箱数客户箱唛号
{{ item.skuCode || '—' }}{{ item.productNameZh || '—' }}{{ item.packageCount ?? '—' }}{{ item.shippingMark || '—' }}
暂无货物明细
@@ -1419,12 +1531,11 @@ function selectRelabelInventory(inboundOrderId: string) {

换标箱级去向

换标类型客户箱唛号旧 SKU新 SKU新 FBA 箱唛实际 FBA 目的仓数量备注
{{ binding.operationType === 'CARTON_AND_SKU' ? 'SKU 和箱唛都换' : binding.operationType === 'SKU_LABEL' ? '只换 SKU' : '只换箱唛' }}{{ binding.customerCartonMark || '—' }}{{ binding.originalSku || '—' }}{{ binding.newSku || '—' }}{{ binding.fbaCartonMark || '—' }}{{ binding.operationType === 'SKU_LABEL' ? '沿用原箱去向' : warehouseDisplay(binding.destinationWarehouseCode) }}{{ binding.quantity ?? '—' }}{{ binding.remark || '—' }}
暂无换标绑定明细
-

发货时间

预计发货时间{{ formatDateTime(selectedDetail?.estimatedShipAt) }}
实际发货时间{{ formatDateTime(selectedDetail?.actualShipAt) }}

实际发货时间由仓库出库或 PDA 完成后回填,客户不能手动修改。

换标完成与出库

派送方式{{ deliveryMethodLabel(selectedRelabelDetail?.application.deliveryMethod || '') || '无需出库' }}
-
履约状态{{ statusLabel(selectedRelabelDetail?.application.fulfillmentStatus || '') }}
+
履约状态{{ relabelFulfillmentLabel(selectedRelabelDetail?.application.fulfillmentStatus) }}
客户确认时间{{ formatDateTime(selectedRelabelDetail?.application.customerConfirmedAt) }}
仓库完工照片
照片 {{ index + 1 }} 加载中…
仓库尚未上传完工照片
@@ -1438,12 +1549,6 @@ function selectRelabelInventory(inboundOrderId: string) {
暂无贴标明细
-
-

PDA 打托实测数据

PDA 作业完成即显示入库完成,客户可确认仓库实测数据
-
托盘号库位箱数长 × 宽 × 高(cm)毛重(kg)体积(m³)状态完成时间
{{ pallet.palletNo || `第${pallet.sequenceNo || '—'}托` }}{{ pallet.warehouseLocationCode || '—' }}{{ pallet.cartonCount ?? '—' }}{{ [pallet.lengthCm, pallet.widthCm, pallet.heightCm].every((value) => value != null) ? `${pallet.lengthCm} × ${pallet.widthCm} × ${pallet.heightCm}` : '—' }}{{ pallet.grossWeightKg ?? '—' }}{{ pallet.volumeCbm ?? '—' }}{{ statusLabel(pallet.status || '') }}{{ formatDateTime(pallet.completedAt) }}
-
暂无 PDA 打托测量数据
-
测量打托现场照片
照片 {{ index + 1 }} 加载中…
仓库尚未上传测量打托照片
-

换标任务明细

@@ -1474,6 +1579,8 @@ function selectRelabelInventory(inboundOrderId: string) { .warehouse-service-page--return-relabel .warehouse-table-scroll:has(> .warehouse-return-relabel-table)~.warehouse-table-scroll{display:none} .warehouse-native-file{min-height:32px;padding:5px 8px;border:1px solid #dcdfe6;border-radius:4px;background:#fff;color:#606266}.warehouse-fulfillment-grid{margin-bottom:18px}.warehouse-evidence{display:flex;align-items:flex-start;gap:16px;padding:14px;border:1px solid #e7ebf1;border-radius:4px;background:#f8fafc}.warehouse-evidence>span{padding-top:6px;color:#71809a;font-size:12px;white-space:nowrap}.warehouse-evidence>strong{padding-top:6px;color:#8a95a5;font-size:12px;font-weight:400}.warehouse-photo-grid{display:grid;grid-template-columns:repeat(4,140px);gap:12px}.warehouse-photo-item{width:140px;height:105px;overflow:hidden;border:1px solid #dce3ed;border-radius:5px;background:#fff}.warehouse-photo-item :deep(.el-image){display:block;width:100%;height:100%;cursor:zoom-in}.warehouse-photo-placeholder{display:flex;width:100%;height:100%;align-items:center;justify-content:center;padding:10px;border:0;background:#f4f7fb;color:#8792a2;font-size:12px;text-align:center}.warehouse-photo-retry{cursor:pointer}.warehouse-completion-remark{margin:10px 0 0;color:#596579;font-size:12px} .warehouse-measurement-evidence{margin-top:16px} +.warehouse-measurement-photo-grid{flex:1;grid-template-columns:repeat(auto-fill,minmax(240px,1fr))} +.warehouse-measurement-photo-item{width:100%;height:180px} .warehouse-template-actions{flex-wrap:wrap;justify-content:flex-end}.warehouse-import-preview{margin:0 0 16px;padding:14px;border:1px solid #cfe0f5;border-radius:5px;background:#f8fbff}.warehouse-import-preview .warehouse-section-heading{margin-bottom:10px}.warehouse-import-preview .warehouse-section-heading>div{display:flex;align-items:center;gap:10px}.warehouse-import-preview p{margin:0;color:#7d8797;font-size:12px}.warehouse-binding-preview-table{min-width:980px;background:#fff} .warehouse-transfer-relabel .warehouse-labeling-heading>div:first-child{min-width:0}.warehouse-transfer-relabel .warehouse-labeling-heading h3{margin-bottom:6px}.warehouse-transfer-relabel .warehouse-labeling-heading p{margin:0 0 16px;color:#7d8797;font-size:12px}.warehouse-transfer-relabel-fields{margin-bottom:18px}.warehouse-transfer-file-groups{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);align-items:stretch;gap:14px;margin-bottom:14px}.warehouse-transfer-total-files,.warehouse-transfer-template-group{height:100%;margin:0}.warehouse-transfer-mark-table{min-width:1420px}.warehouse-transfer-mark-table td{vertical-align:top}.warehouse-transfer-mark-table :deep(.el-textarea){min-width:220px}.warehouse-transfer-mark-table :deep(.el-input-number){width:150px}.warehouse-count-error{color:#d64545} .warehouse-transfer-form{display:grid;grid-template-columns:minmax(0,1fr);gap:12px}.warehouse-transfer-form--relabel{grid-template-columns:minmax(0,1fr) minmax(0,1.35fr);align-items:start}.warehouse-transfer-form--relabel .warehouse-transfer-main-card{grid-column:1;grid-row:1}.warehouse-transfer-form--relabel .warehouse-transfer-cargo-card{grid-column:1;grid-row:2}.warehouse-transfer-form--relabel .warehouse-transfer-relabel{grid-column:2;grid-row:1 / span 2;margin-bottom:0}.warehouse-transfer-form--relabel .warehouse-transfer-submit-bar{grid-column:1 / -1}.warehouse-transfer-form :deep(.el-input__wrapper),.warehouse-transfer-form :deep(.el-textarea__inner),.warehouse-transfer-form :deep(.el-select__wrapper),.warehouse-transfer-form :deep(.el-date-editor.el-input__wrapper){min-height:32px}.warehouse-transfer-form .warehouse-form-section{padding:16px}.warehouse-transfer-form .warehouse-form-section h3{margin-bottom:12px;padding-bottom:9px;font-size:14px}.warehouse-transfer-form .warehouse-form-grid{gap:12px 16px}.warehouse-transfer-form .warehouse-choice-field{min-height:34px;padding:8px 10px;grid-template-columns:118px minmax(0,1fr);gap:10px}.warehouse-transfer-form .warehouse-choice-field>span{font-size:12px}.warehouse-transfer-form .warehouse-receiving-card{gap:12px;padding:10px 12px}.warehouse-transfer-form .warehouse-section-heading{margin-bottom:8px}.warehouse-transfer-form .warehouse-edit-table{font-size:11px}.warehouse-transfer-form .warehouse-edit-table th{height:36px;padding:0 9px}.warehouse-transfer-form .warehouse-edit-table td{height:42px;padding:6px 7px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-textarea){min-width:150px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-input-number){width:112px}.warehouse-transfer-form .warehouse-hint{margin-top:5px;font-size:11px}.warehouse-transfer-form .warehouse-labeling-mode{gap:9px;margin-bottom:10px;padding:9px 10px}.warehouse-transfer-form .warehouse-labeling-mode>span{font-size:12px}.warehouse-transfer-form .warehouse-labeling-mode :deep(.el-radio-button__inner){padding:6px 9px}.warehouse-transfer-form .warehouse-template-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:5px;padding-bottom:8px}.warehouse-transfer-form .warehouse-template-actions :deep(.el-button){padding:6px 8px;font-size:11px}.warehouse-transfer-form .warehouse-submit-bar{padding:4px 0 18px}.warehouse-transfer-form .warehouse-submit-bar :deep(.el-button){min-height:32px;padding:7px 14px}.warehouse-transfer-form .warehouse-transfer-relabel{overflow:hidden}.warehouse-transfer-form .warehouse-transfer-relabel-fields{margin-bottom:10px}.warehouse-step-heading{align-items:flex-start}.warehouse-step-heading h3{display:flex;align-items:center;gap:9px}.warehouse-step-number{display:inline-grid;flex:0 0 auto;width:24px;height:24px;border-radius:50%;background:#3478c8;color:#fff;font-size:12px;place-items:center}.warehouse-heading-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.warehouse-transfer-template-group{margin:0 0 14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#fbfcfe}.warehouse-binding-heading{margin-top:14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#f8faff}.warehouse-binding-heading strong{display:flex;align-items:center;gap:8px;color:#34445c;font-size:13px} @@ -1482,6 +1589,11 @@ function selectRelabelInventory(inboundOrderId: string) { @media(max-width:1180px){.warehouse-transfer-file-groups{grid-template-columns:minmax(0,1fr)}} .warehouse-transfer-form .pickup-address-field{grid-column:span 1} .warehouse-transfer-main-card .warehouse-form-grid{grid-template-columns:repeat(2,minmax(280px,640px))}.warehouse-transfer-cargo-card .warehouse-section-heading,.warehouse-transfer-cargo-card .warehouse-table-scroll{width:100%;max-width:1296px} +.warehouse-service-page--detail .warehouse-transfer-cargo-card .warehouse-section-heading,.warehouse-service-page--detail .warehouse-transfer-cargo-card .warehouse-table-scroll{max-width:none} +.warehouse-service-page--detail .warehouse-transfer-overview-grid{grid-template-columns:repeat(4,minmax(180px,1fr));gap:6px 28px;padding:10px 16px 12px}.warehouse-service-page--detail .warehouse-transfer-overview-grid>div{min-height:26px;padding:2px 0;border:0}.warehouse-service-page--detail .warehouse-transfer-overview-grid span{flex:0 0 auto;min-width:76px}.warehouse-service-page--detail .warehouse-transfer-overview-grid :deep(.el-tag){height:24px} +.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-detail-grid{grid-template-columns:repeat(4,minmax(180px,1fr));gap:6px 28px;padding:10px 16px 12px}.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-detail-grid>div{min-height:26px;padding:2px 0;border:0}.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-detail-grid span{flex:0 0 auto;min-width:76px} +.warehouse-service-page--detail .warehouse-transfer-overview-grid>.warehouse-overview-wide{grid-column:span 2} +@media(max-width:1100px){.warehouse-service-page--detail .warehouse-transfer-overview-grid{grid-template-columns:repeat(2,minmax(180px,1fr))}}@media(max-width:720px){.warehouse-service-page--detail .warehouse-transfer-overview-grid{grid-template-columns:1fr;gap:4px;padding:10px 14px}} /* Keep the count and controls visible together on narrow list pages. */ .warehouse-pagination { justify-content: flex-end; } .warehouse-pagination-total { color: #526176; font-size: 14px; } diff --git a/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs b/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs index 1d17eae..cc8b815 100644 --- a/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs +++ b/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs @@ -208,10 +208,11 @@ test('贴标详情展示箱唛、SKU 子明细和独立FBA目的仓', () => { assert.match(view, /item\.skuItems\?\.length/) }) -test('客户在换标列表确认完工且照片使用二进制下载', () => { +test('客户可在换标列表或详情确认完工且照片使用二进制下载', () => { assert.match(view, /item\.__status === 'CUSTOMER_CONFIRM_PENDING'/) assert.match(view, /confirmRelabelFromList\(item\['0'\]\)/) - assert.doesNotMatch(view, /selectedRelabelDetail\?\.application\.status === 'CUSTOMER_CONFIRM_PENDING'/) + assert.match(view, /selectedRelabelDetail\?\.application\.status === 'CUSTOMER_CONFIRM_PENDING'/) + assert.match(view, /@click="confirmSelectedRelabel">确认换标完成/) assert.match(api, /overseasBlobRequest/) }) @@ -224,6 +225,14 @@ test('客户详情进入页面后直接回显完工照片并支持大图预览', }) test('进入复核后客户订单回显换标完成状态', () => { - assert.match(view, /value === 'CUSTOMER_CONFIRM_PENDING'\) return '待客户确认'/) - assert.match(view, /\['READY_FOR_OUTBOUND', 'COMPLETED'\]\.includes\(value\) \? '换标完成'/) + assert.match(view, /CUSTOMER_CONFIRM_PENDING: '待客户确认'/) + assert.match(view, /READY_FOR_OUTBOUND: '换标完成'/) +}) + +test('审核驳回后展示原因并允许替换资料后重新提交', () => { + assert.match(api, /export function resubmitRelabel\(/) + assert.match(view, /贴标单审核已驳回/) + assert.match(view, /application\.reviewRemark/) + assert.match(view, /修改后重新提交/) + assert.match(view, /await resubmitRelabel\(/) }) diff --git a/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs index 25675e9..c06a0a9 100644 --- a/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs +++ b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs @@ -40,6 +40,17 @@ test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否 assert.doesNotMatch(view, /relabelChargeAmount:/) }) +test('换标详情待客户确认时提供确认入口并刷新详情', () => { + assert.match(view, /application\.status === 'CUSTOMER_CONFIRM_PENDING'[\s\S]*@click="confirmSelectedRelabel">确认换标完成/) + assert.match(view, /async function confirmSelectedRelabel\(\)[\s\S]*await confirmRelabelCompletion\(relabelNo\)[\s\S]*await openDetail\(\{ '0': relabelNo \}\)/) +}) + +test('仓储转运换标绑定明细自动同步只读的旧 SKU', () => { + assert.match(view, /watch\(\(\) => cargoRows\.value\.map\(\(row\) => row\.sku\)/) + assert.match(view, /if \(sourceSku\) row\.sku = sourceSku/) + assert.match(view, /transferRelabelRows\.value\.push\(\{ \.\.\.createCargoRow\(\), sku: source\?\.sku \|\| '' \}\)/) +}) + test('仓储转运换标整票只允许一个目的仓', () => { assert.match(view, /props\.service === 'transfer' && transferDestinationType === 'FBA'[^\n]+FBA 目的仓/) assert.match(view, /FBA 货件编号<\/span> assert.match(view, /uploadRelabelFile\(relabelNo, 'CARTON_TOTAL', cartonTotalFile\.value\)/) assert.match(view, /uploadRelabelFile\(relabelNo, 'SKU_TOTAL', skuTotalFile\.value\)/) assert.match(view, /转运单已提交,附件待补传/) + assert.match(transferRelabelSection, /native-type="button"[^>]+chooseCreateTotalFile\('CARTON_TOTAL'\)/) + assert.match(transferRelabelSection, /native-type="button"[^>]+chooseCreateTotalFile\('SKU_TOTAL'\)/) }) test('仓储转运换标总文件与明细模板在桌面端左右排列', () => { @@ -155,13 +168,17 @@ test('仓储转运列表操作仓使用海外仓接口数据', () => { assert.doesNotMatch(view, /美西洛杉矶仓/) }) -test('仓储转运预计发货时间可选并只读展示实际发货时间', () => { - assert.match(view, /预计发货时间/) +test('仓储转运期望发货时间可选并在订单概览回填发货时间', () => { + assert.doesNotMatch(view, /预计发货时间/) + assert.match(view, /期望发货时间/) assert.match(view, /v-model="form\.estimatedShipAt"/) assert.match(view, /v-model="form\.estimatedShipAt"[^>]+placeholder="选填"/) - assert.doesNotMatch(view, /!form\.estimatedShipAt && '预计发货时间'/) + assert.doesNotMatch(view, /!form\.estimatedShipAt/) assert.match(view, /estimatedShipAt: form\.estimatedShipAt/) - assert.match(view, /实际发货时间由仓库出库或 PDA 完成后回填/) + assert.match(view, /期望发货时间<\/span>\{\{ formatDateTime\(selectedDetail\?\.estimatedShipAt\) \}\}<\/strong>/) + assert.match(view, /实际发货时间<\/span>\{\{ formatDateTime\(selectedDetail\?\.actualShipAt\) \}\}<\/strong>/) + assert.doesNotMatch(view, /class="warehouse-card warehouse-form-section warehouse-transfer-times"/) + assert.match(view, /estimatedArrivalAt: detail\.estimatedArrivalAt \|\| '', estimatedShipAt: detail\.estimatedShipAt \|\| ''/) assert.match(api, /estimatedShipAt\?: string/) }) @@ -176,6 +193,66 @@ test('客户仓储转运展示每个实际流转状态的中文名称', () => { assert.match(view, /OUTBOUNDED: '已出库'/) }) +test('仓储转运列表展示换标单号、换标状态并为两类状态着色', () => { + const transferConfig = view.match(/transfer:\s*\{[^\n]+/)?.[0] || '' + assert.match(transferConfig, /'是否换标', '换标单号', '换标状态', '状态'/) + assert.match(api, /relabelNo\?: string; relabelStatus\?: string; relabelFulfillmentStatus\?: string/) + assert.match(view, /item\.needRelabel \? '是' : '否', item\.relabelNo, relabelStatus\.label/) + assert.match(view, /if \(!item\.relabelNo\) return \{ code: 'WAIT_RELABEL', label: '待换标' \}/) + assert.match(view, /READY_FOR_OUTBOUND: '已换标', COMPLETED: '已换标'/) + assert.match(view, /#transferRelabelStatus=.*transferRelabelStatusTagType/) + assert.match(view, /#transferStatus=.*transferStatusTagType/) + assert.doesNotMatch(view, /是 · 待绑定换标单/) +}) + +test('仓储转运详情订单概览使用详情接口命名字段回填', () => { + assert.match(view, /v-else-if="props\.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"/) + assert.doesNotMatch(view, /入库箱数/) + assert.match(view, /'转运单号', '箱数', '海外仓'/) + for (const field of ['transferNo', 'packageCount', 'operatingWarehouseId', 'deliveryMethod', 'needRelabel', 'relabelNo', 'status', 'createdAt']) { + assert.match(view, new RegExp(`selectedDetail\\?\\.${field}`)) + } + assert.match(view, /selectedDetail\?\.destinationWarehouseCode \|\| selectedDetail\?\.fbaWarehouseCode/) + assert.match(view, /transferRelabelStatus\(selectedDetail \|\| \{\}\)/) + assert.match(view, /transferStatusTagType\(selectedDetail\?\.status \|\| ''\)/) +}) + +test('仓储转运和贴标申请详情先展示主数据再串行补充仓库名称', () => { + const loader = view.match(/async function loadDetailPage\(\) \{[\s\S]*?\n\}/)?.[0] || '' + assert.match(loader, /await openDetail\(\{ '0': detailNumber\.value \}\)/) + assert.match(loader, /await loadWarehouses\(\)/) + assert.ok(loader.indexOf('await openDetail') < loader.indexOf('await loadWarehouses')) + assert.match(view, /props\.service === 'labeling' \? await getRelabel\(value\['0'\]\)/) + assert.match(view, /: props\.service === 'disposal' \? await getDiscard\(value\['0'\]\) : await getTransfer\(value\['0'\]\)/) +}) + +test('仓储转运详情展示目的去向和对应目的仓资料并让货物明细铺满', () => { + assert.match(view, />目的去向<\/span>\{\{ destinationTypeLabel\(selectedDetail\?\.destinationType, selectedDetail\?\.fbaWarehouseCode\) \}\}/) + assert.match(view, />FBA 目的仓<\/span>.*selectedDetail\?\.destinationWarehouseCode \|\| selectedDetail\?\.fbaWarehouseCode/) + assert.match(view, />FBA 货件号<\/span>\{\{ selectedDetail\?\.fbaShipmentNo \|\| '—' \}\}/) + for (const field of ['privateDestinationAddress', 'privateDestinationPostalCode', 'privateDestinationContactName', 'privateDestinationContactPhone']) { + assert.match(view, new RegExp(`selectedDetail\\?\\.${field}`)) + } + assert.match(view, /warehouse-service-page--detail \.warehouse-transfer-cargo-card \.warehouse-section-heading,[^}]+\.warehouse-table-scroll\{max-width:none\}/) +}) + +test('仓储转运订单概览使用紧凑左右信息布局', () => { + assert.match(view, /warehouse-detail-grid warehouse-transfer-overview-grid/) + assert.match(view, /warehouse-transfer-overview-grid">
状态<\/span>.*目的去向<\/span>/) + assert.match(view, /class="warehouse-overview-wide">私人仓地址/) + assert.match(view, /warehouse-transfer-overview-grid>\.warehouse-overview-wide\{grid-column:span 2\}/) + assert.match(view, /warehouse-transfer-overview-grid>div\{min-height:26px;padding:2px 0;border:0\}/) + assert.match(view, /warehouse-transfer-overview-grid span\{flex:0 0 auto;min-width:76px\}/) + assert.match(view, /warehouse-transfer-overview-grid\{grid-template-columns:1fr;gap:4px/) +}) + +test('贴标申请详情使用紧凑订单概览并中文展示换标状态', () => { + assert.match(view, /WAIT_APPROVAL: '待审核'/) + assert.match(view, /WAIT_PROCESSING: '待仓库处理'/) + assert.match(view, /relabelFulfillmentLabel\(selectedRelabelDetail\?\.application\.fulfillmentStatus\)/) + assert.match(view, />\.warehouse-detail-header\+\.warehouse-card \.warehouse-detail-grid\{grid-template-columns:repeat\(4,minmax\(180px,1fr\)\)/) +}) + test('PDA 打托完成后客户可从列表或详情确认并查看实测数据', () => { assert.match(view, /item\.__status === 'WAIT_CUSTOMER_CONFIRMATION'/) assert.match(view, /confirmTransferFromList\(item\['0'\]\)/) @@ -189,6 +266,20 @@ test('PDA 打托完成后客户可从列表或详情确认并查看实测数据' assert.doesNotMatch(view, /pallet\.items/) }) +test('仓储转运详情按订单概览、PDA 实测数据、货物明细排序并放大测量照片', () => { + const detailStart = view.indexOf('
') + const detailSource = view.slice(detailStart) + const overviewIndex = detailSource.indexOf('

订单概览

') + const measurementIndex = detailSource.indexOf('

PDA 打托实测数据

') + const cargoIndex = detailSource.indexOf('

货物明细

') + assert.ok(overviewIndex >= 0 && overviewIndex < measurementIndex) + assert.ok(measurementIndex < cargoIndex) + assert.match(view, /class="warehouse-photo-grid warehouse-measurement-photo-grid"/) + assert.match(view, /class="warehouse-photo-item warehouse-measurement-photo-item"/) + assert.match(view, /warehouse-measurement-photo-grid\{flex:1;grid-template-columns:repeat\(auto-fill,minmax\(240px,1fr\)\)\}/) + assert.match(view, /warehouse-measurement-photo-item\{width:100%;height:180px\}/) +}) + test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () => { assert.match(api, /downloadTransferMeasurementPhoto/) assert.match(api, /measurement-photos\/files\/\$\{encodeURIComponent\(fileId\)\}\/download/) @@ -229,6 +320,12 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提 assert.doesNotMatch(view, /

箱唛明细<\/h3>/) }) +test('转运贴标单在PDA确认库存前不误报附件上传失败', () => { + assert.match(view, /if \(!relabelNo\) \{[\s\S]*PDA 入库确认后将自动生成关联换标单/) + assert.match(view, /届时请进入关联换标单详情上传贴标总文件/) + assert.doesNotMatch(view, /if \(!relabelNo\) throw new Error\('转运单已提交,但未返回关联换标单号'\)/) +}) + test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强制旧 SKU', () => { assert.match(view, /商品 SKU\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/) assert.match(view, /中文品名\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/) From 5bfe284e22ef006af3b44372cebc51d8c0ef7c35 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 23 Sep 2026 17:47:56 +0800 Subject: [PATCH 05/36] =?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 | 4 +- .../components/InboundBillingPanel.test.mjs | 18 +++- .../components/InboundBillingPanel.vue | 86 +++++++++++-------- 3 files changed, 67 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 71eb0a5..9b66236 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ ## 模块快速查询 -退货预报与签收结果:`src/modules/overseas-warehouse/views/ReturnOrdersView.vue`、`api/returnOrders.ts`;菜单仍名为“退货换标”,正式路由 `return-relabel` 已使用独立页面,不再由 `WarehouseServiceView.vue` 承载。客户退货库存/箱号共用 `views/ReturnInventoryView.vue`、`api/returnInventory.ts`,`return-cartons` 传 `cartonOnly=true`。Backend 为 Lastleg `returnreceipt/`,退货单签收完成不等于换标或退货出库完成;后续换标、出库须按各自业务状态核对。 +退货预报与签收结果:`src/modules/overseas-warehouse/views/ReturnOrdersView.vue`、`api/returnOrders.ts`;菜单仍名为“退货换标”。列表路由 `return-relabel`、独立新建路由 `return-relabel/create` 与详情路由均复用该页面,并分别由 `createPage/detailPage` 明确模式;新建页直接填写海外仓、联系人、客户参考号及 SKU 预报明细,取消或提交后返回列表,不再由 `WarehouseServiceView.vue` 或列表内弹框承载。客户退货库存/箱号共用 `views/ReturnInventoryView.vue`、`api/returnInventory.ts`,`return-cartons` 传 `cartonOnly=true`。Backend 为 Lastleg `returnreceipt/`,退货单签收完成不等于换标或退货出库完成;后续换标、出库须按各自业务状态核对。 先从 `src/modules/registry.ts` 确认模块,再按目标模块的 `routes.ts → views/ → api/、store/、contracts.ts 或 public.ts` 查询。页面是否正式开放以 `routes.ts` 的 `release` 为准,不从任务或进度文档推断。 @@ -72,7 +72,7 @@ ## UI 与交互 -- 修改已有 Client 原型页面前先读 `../../docs/Admin-Client前端原型还原验收规则.md`,按页面和操作形成原型核对矩阵;本节维护 Client 的实现约束,不复制跨前端验收文档全文。 +- 视觉还原、布局/交互承载方式变更或真实页面验收时读取 `../../docs/Admin-Client前端原型还原验收规则.md`,只为受影响操作形成原型核对矩阵;纯文案、逻辑或测试修改不自动读取。自动检查与人工视觉验收分开报告,无视觉变化时标记“无需视觉验收”。 - 已向客户讲解或确认过的产品原型是页面视觉与布局实现的直接事实来源。字段顺序、字段分组、字段摆放、列宽、控件宽高、对齐方式、换行方式、区块层级、上下左右边距、内边距、间隙、留白、圆角、边框、阴影、背景色、文字颜色和字号均必须按原型还原,不得只参考大致风格后自行重新排版。 - 原型中的按钮位置、顺序、尺寸、圆角、边框、背景色、文字色、图标、悬停态、激活态、禁用态和危险语义必须直接按原型实现;不得擅自改为文字链接、不同颜色、不同按钮类型、折叠菜单或自创交互效果,也不得增加原型没有的上浮、缩放、渐变线、装饰图标或其他视觉元素。 - 原型已经给出明确图标、字符图标、图片资源或按钮内图标时,直接使用原型中的同一方案和资源,不得以 Lucide、Element Plus 默认图标、手绘图标或个人偏好替换。仅当原型没有定义对应图标时,才按本节的默认图标语义补齐。 diff --git a/src/modules/first-leg/components/InboundBillingPanel.test.mjs b/src/modules/first-leg/components/InboundBillingPanel.test.mjs index 649012a..d6ebbff 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.test.mjs +++ b/src/modules/first-leg/components/InboundBillingPanel.test.mjs @@ -25,14 +25,13 @@ test('supports a standalone dialog and keeps the unpushed billing-weight state v }) test('shows the published merchant inbound, measurement details and customer confirmation entry', () => { - for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '对客单价', '基础费用', '附加费用', '费用合计', '平台备注']) { - assert.match(source, new RegExp(label)) + for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '单价', '基础费用', '附加费用', '费用合计', '平台备注']) { + assert.ok(source.includes(label), `missing label: ${label}`) } assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/) assert.match(source, /formatConversionPrecision\(/) assert.match(source, /确认费用/) - assert.match(source, /二次确认费用/) - assert.match(source, /确认后该费用将锁定,平台不能再修改/) + assert.match(source, /确认后当前费用版本将锁定/) assert.match(source, /customerConfirmationStatus === 'CONFIRMED'/) assert.match(source, /confirmMerchantInboundFee/) assert.match(source, /measurementItems/) @@ -46,6 +45,17 @@ test('shows the published merchant inbound, measurement details and customer con assert.match(source, /getCalculationRuleLabel\(row\.calculationRuleVersion\)/) }) +test('matches the confirmed billing prototype summary, table, notice and footer', () => { + assert.match(source, /width="min\(1180px, calc\(100vw - 40px\)\)"/) + assert.match(source, /class="fee-dialog-summary" aria-label="计费汇总"/) + for (const label of ['计算重', '单价', '总金额', '箱规长(cm)', '箱规宽(cm)', '箱规高(cm)', '每箱体积', '总体积', '总材积重']) assert.ok(source.includes(label), `missing prototype label: ${label}`) + assert.match(source, /class="fee-confirm-notice"/) + assert.match(source, /请核对计算重、单价和总金额。确认后当前费用版本将锁定。/) + assert.match(source, /确认计费量和费用/) + assert.match(source, /\.fee-dialog-summary \{ display:grid; grid-template-columns:repeat\(3,minmax\(0,1fr\)\)/) + assert.match(source, /\.fee-dialog-table :deep\(\.el-table__body td\.el-table__cell\)/) +}) + test('stacks core information for 720 and 360 pixel layouts', () => { assert.match(source, /@media \(max-width: 720px\)/) assert.match(source, /\.inbound-overview, \.inbound-measurements, \.fee-calculation-grid \{ grid-template-columns: 1fr;/) diff --git a/src/modules/first-leg/components/InboundBillingPanel.vue b/src/modules/first-leg/components/InboundBillingPanel.vue index c3858bf..1027174 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.vue +++ b/src/modules/first-leg/components/InboundBillingPanel.vue @@ -1,7 +1,7 @@ + + + + diff --git a/src/modules/first-leg/views/SailingPricesView.vue b/src/modules/first-leg/views/SailingPricesView.vue new file mode 100644 index 0000000..17b13ed --- /dev/null +++ b/src/modules/first-leg/views/SailingPricesView.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/src/navigation/clientNavigation.test.mjs b/src/navigation/clientNavigation.test.mjs index 5674fbb..b767e1b 100644 --- a/src/navigation/clientNavigation.test.mjs +++ b/src/navigation/clientNavigation.test.mjs @@ -52,7 +52,7 @@ test('organises customer-relevant business pages under five task-oriented sideba { title: '首页', children: undefined }, { title: '订单中心', - children: ['头程订单', '海外采购', '美国本土卡', '美本土散板'], + children: ['头程订单', '报关资料', '船期价格', '海外采购', '美国本土卡', '美本土散板'], }, { title: '海外仓', @@ -86,6 +86,8 @@ test('flattens visible leaves with unique paths', () => { assert.deepEqual(paths, [ '/', '/orders/first-leg', + '/first-leg/customs-documents', + '/first-leg/sailing-prices', '/cross-border/procurement', '/cross-border/domestic-trucking', '/cross-border/pallet-shipping', @@ -109,7 +111,7 @@ test('flattens visible leaves with unique paths', () => { test('filters inaccessible leaves and empty navigation groups', () => { const visible = filterNavigationByPermissions(clientNavigation, ['account:profile:view', 'order:first-leg:list']) - assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/orders/first-leg']) + assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/orders/first-leg', '/first-leg/customs-documents', '/first-leg/sailing-prices']) assert.deepEqual(visible.map((item) => item.title), ['订单中心']) assert.equal(getFirstAccessiblePath(['account:profile:view']), '/account/profile') assert.equal(getFirstAccessiblePath([]), '/forbidden') diff --git a/src/navigation/clientNavigation.ts b/src/navigation/clientNavigation.ts index b664e2f..4d3319a 100644 --- a/src/navigation/clientNavigation.ts +++ b/src/navigation/clientNavigation.ts @@ -52,6 +52,8 @@ export const clientNavigation: ClientNavigationNode[] = [ icon: ClipboardList, children: [ requireLeaf('first-leg-orders', '头程订单'), + requireLeaf('first-leg-customs-documents', '报关资料'), + requireLeaf('first-leg-sailing-prices', '船期价格'), requireLeaf('cross-border-procurement'), requireLeaf('cross-border-domestic-trucking'), requireLeaf('cross-border-pallet-shipping'), From 2923b545d1514628e54a8e8ccf27d24975ed3330 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 25 Sep 2026 23:09:56 +0800 Subject: [PATCH 10/36] fix(client): use explicit sailing price radio values --- src/modules/first-leg/views/SailingPricesView.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/first-leg/views/SailingPricesView.vue b/src/modules/first-leg/views/SailingPricesView.vue index 17b13ed..b40e8a3 100644 --- a/src/modules/first-leg/views/SailingPricesView.vue +++ b/src/modules/first-leg/views/SailingPricesView.vue @@ -17,7 +17,7 @@ watch(originWarehouseId, () => { void loadChannels() }); onMounted(() => { void From 78d94c3cbc30cfeb9669d7883e3fc2a4e07b28bd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 25 Sep 2026 23:15:19 +0800 Subject: [PATCH 11/36] feat(client): add customs and pricing permissions --- src/modules/first-leg/routes.ts | 4 ++-- src/modules/tenant-management/contracts.ts | 4 ++++ src/navigation/clientNavigation.test.mjs | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/modules/first-leg/routes.ts b/src/modules/first-leg/routes.ts index 15cc38a..773cf29 100644 --- a/src/modules/first-leg/routes.ts +++ b/src/modules/first-leg/routes.ts @@ -49,7 +49,7 @@ export const firstLegModule = { name: 'first-leg-customs-documents', component: () => import('./views/CustomsDocumentsView.vue'), release: 'active', - meta: { title: '报关资料', description: '管理装柜后的退税报关资料与回执', permission: 'order:first-leg:list' }, + meta: { title: '报关资料', description: '管理装柜后的退税报关资料与回执', permission: 'order:first-leg:customs-list' }, navigation: { key: 'first-leg-customs-documents', title: '报关资料', icon: FileText }, }, { @@ -57,7 +57,7 @@ export const firstLegModule = { name: 'first-leg-sailing-prices', component: () => import('./views/SailingPricesView.vue'), release: 'active', - meta: { title: '船期价格', description: '查询渠道、参考船期与预估运费', permission: 'order:first-leg:list' }, + meta: { title: '船期价格', description: '查询渠道、参考船期与预估运费', permission: 'order:first-leg:pricing-list' }, navigation: { key: 'first-leg-sailing-prices', title: '船期价格', icon: Calculator }, }, ], diff --git a/src/modules/tenant-management/contracts.ts b/src/modules/tenant-management/contracts.ts index 23f80df..55855e0 100644 --- a/src/modules/tenant-management/contracts.ts +++ b/src/modules/tenant-management/contracts.ts @@ -49,6 +49,8 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [ label: '头程订单', items: [ { code: 'order:first-leg:list', label: '查看订单', description: '查看本企业头程订单列表和详情' }, + { code: 'order:first-leg:customs-list', label: '查看报关资料', description: '查看本企业装柜后的退税报关资料、文件版本和报关行回执' }, + { code: 'order:first-leg:pricing-list', label: '查看船期价格', description: '查看本企业可用渠道、参考船期和服务端预估价格' }, { code: 'order:first-leg:create', label: '创建订单', description: '新建并保存订单草稿' }, { code: 'order:first-leg:quick-create', label: '快速下单', description: '使用快速下单页面生成订单草稿,不包含提交订单权限' }, { code: 'order:first-leg:edit', label: '编辑订单', description: '编辑本企业尚未提交的订单草稿' }, @@ -134,6 +136,8 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly { }) test('filters inaccessible leaves and empty navigation groups', () => { - const visible = filterNavigationByPermissions(clientNavigation, ['account:profile:view', 'order:first-leg:list']) + const visible = filterNavigationByPermissions(clientNavigation, ['account:profile:view', 'order:first-leg:list', 'order:first-leg:customs-list', 'order:first-leg:pricing-list']) assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/orders/first-leg', '/first-leg/customs-documents', '/first-leg/sailing-prices']) assert.deepEqual(visible.map((item) => item.title), ['订单中心']) From 806c9f393b984b562477f860d60d2258e9ab4f25 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 25 Sep 2026 23:21:43 +0800 Subject: [PATCH 12/36] feat(client): make pricing pages standalone and quick order --- .../first-leg/views/FirstLegOrdersView.vue | 6 +++++ .../first-leg/views/SailingPricesView.vue | 27 ++++++++++++++++++- src/navigation/clientNavigation.test.mjs | 12 +++++---- src/navigation/clientNavigation.ts | 4 +-- 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 348de66..08792ed 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -429,6 +429,12 @@ function quickOrderDraft() { } catch { // Keep the standard empty row when quick-order data is malformed. } + const pricingTaxMode = String(route.query.pricingTaxMode ?? '') + if (pricingTaxMode === 'TAX_INCLUDED' || pricingTaxMode === 'TAX_EXCLUDED') draft.pricingTaxMode = pricingTaxMode + const grossWeightKg = Number(route.query.grossWeightKg) + const volumeCbm = Number(route.query.volumeCbm) + if (Number.isFinite(grossWeightKg) && grossWeightKg > 0) draft.items[0].grossWeightKg = grossWeightKg + if (Number.isFinite(volumeCbm) && volumeCbm > 0) draft.items[0].volumeCbm = volumeCbm return draft } diff --git a/src/modules/first-leg/views/SailingPricesView.vue b/src/modules/first-leg/views/SailingPricesView.vue index b40e8a3..ee76c31 100644 --- a/src/modules/first-leg/views/SailingPricesView.vue +++ b/src/modules/first-leg/views/SailingPricesView.vue @@ -2,9 +2,11 @@ import { computed, onMounted, ref, watch } from 'vue' import { Calculator, RefreshCcw, Search } from '@lucide/vue' import { ElMessage } from 'element-plus' +import { useRouter } from 'vue-router' import { estimateMerchantFirstLegPrice, getMerchantPricingChannels, getMerchantWarehouses, type MerchantPriceEstimate, type MerchantPricingChannel, type MerchantWarehouse } from '../api/firstLeg' const loading = ref(false); const querying = ref(false); const errorMessage = ref('') +const router = useRouter() const warehouses = ref([]); const channels = ref([]); const quotes = ref([]) const originWarehouseId = ref(''); const destinationCode = ref(''); const weight = ref(); const volume = ref(); const taxMode = ref<'TAX_INCLUDED' | 'TAX_EXCLUDED'>('TAX_INCLUDED') const hasInput = computed(() => Boolean(originWarehouseId.value && destinationCode.value.trim() && weight.value && volume.value)) @@ -12,13 +14,36 @@ function formatMoney(item: MerchantPriceEstimate) { return `${item.currency} ${i async function loadBase() { loading.value = true; errorMessage.value = ''; try { const result = await getMerchantWarehouses({ warehouseType: 'DOMESTIC' }); warehouses.value = result.records; if (result.records[0]) { originWarehouseId.value = result.records[0].id; await loadChannels() } } catch (error) { errorMessage.value = error instanceof Error ? error.message : '船期价格加载失败' } finally { loading.value = false } } async function loadChannels() { if (!originWarehouseId.value) return; channels.value = await getMerchantPricingChannels(originWarehouseId.value) } async function query() { if (!hasInput.value) { ElMessage.warning('请填写起运仓、目的仓、重量和体积'); return }; querying.value = true; quotes.value = []; try { const results = await Promise.allSettled(channels.value.map((channel) => estimateMerchantFirstLegPrice({ originWarehouseId: originWarehouseId.value, channelCode: channel.channelCode, destinationCode: destinationCode.value.trim(), destinationType: 'FBA', pricingTaxMode: taxMode.value, actualWeightKg: weight.value!, actualVolumeCbm: volume.value! }))); quotes.value = results.flatMap((item) => item.status === 'fulfilled' ? [item.value] : []) } finally { querying.value = false } } +function placeOrder(channelCode: string, channelName: string) { + if (!quotes.value.some((quote) => quote.channelCode === channelCode)) { + ElMessage.warning('请先查询该渠道价格') + return + } + router.push({ + name: 'first-leg-order-create', + query: { + quick: '1', + receivingWarehouseId: String(originWarehouseId.value), + channelCode, + channelName, + destinationType: 'FBA', + destinationWarehouseName: destinationCode.value.trim().toUpperCase(), + amazonWarehouseCode: destinationCode.value.trim().toUpperCase(), + destinationCountryCode: 'US', + pricingTaxMode: taxMode.value, + grossWeightKg: String(weight.value), + volumeCbm: String(volume.value), + cargo: JSON.stringify([{ sku: '', packageCount: 1 }]), + }, + }) +} watch(originWarehouseId, () => { void loadChannels() }); onMounted(() => { void loadBase() }) diff --git a/src/navigation/clientNavigation.test.mjs b/src/navigation/clientNavigation.test.mjs index 5da4aa3..494f794 100644 --- a/src/navigation/clientNavigation.test.mjs +++ b/src/navigation/clientNavigation.test.mjs @@ -42,7 +42,7 @@ test('keeps account and notification routes as fixed-header utilities', () => { ) }) -test('organises customer-relevant business pages under five task-oriented sidebar entries', () => { +test('keeps standalone business pages at the client sidebar root', () => { assert.deepEqual( clientNavigation.filter((node) => !node.hidden).map((node) => ({ title: node.title, @@ -52,8 +52,10 @@ test('organises customer-relevant business pages under five task-oriented sideba { title: '首页', children: undefined }, { title: '订单中心', - children: ['头程订单', '报关资料', '船期价格', '海外采购', '美国本土卡', '美本土散板'], + children: ['头程订单', '海外采购', '美国本土卡', '美本土散板'], }, + { title: '报关资料', children: undefined }, + { title: '船期价格', children: undefined }, { title: '海外仓', children: ['贴标申请', '仓储转运', '货物废弃', '退货换标'], @@ -86,11 +88,11 @@ test('flattens visible leaves with unique paths', () => { assert.deepEqual(paths, [ '/', '/orders/first-leg', - '/first-leg/customs-documents', - '/first-leg/sailing-prices', '/cross-border/procurement', '/cross-border/domestic-trucking', '/cross-border/pallet-shipping', + '/first-leg/customs-documents', + '/first-leg/sailing-prices', '/overseas-warehouse/labeling', '/overseas-warehouse/storage-transfer', '/overseas-warehouse/cargo-disposal', @@ -112,7 +114,7 @@ test('filters inaccessible leaves and empty navigation groups', () => { const visible = filterNavigationByPermissions(clientNavigation, ['account:profile:view', 'order:first-leg:list', 'order:first-leg:customs-list', 'order:first-leg:pricing-list']) assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/orders/first-leg', '/first-leg/customs-documents', '/first-leg/sailing-prices']) - assert.deepEqual(visible.map((item) => item.title), ['订单中心']) + assert.deepEqual(visible.map((item) => item.title), ['订单中心', '报关资料', '船期价格']) assert.equal(getFirstAccessiblePath(['account:profile:view']), '/account/profile') assert.equal(getFirstAccessiblePath([]), '/forbidden') }) diff --git a/src/navigation/clientNavigation.ts b/src/navigation/clientNavigation.ts index 4d3319a..be4b6c3 100644 --- a/src/navigation/clientNavigation.ts +++ b/src/navigation/clientNavigation.ts @@ -52,13 +52,13 @@ export const clientNavigation: ClientNavigationNode[] = [ icon: ClipboardList, children: [ requireLeaf('first-leg-orders', '头程订单'), - requireLeaf('first-leg-customs-documents', '报关资料'), - requireLeaf('first-leg-sailing-prices', '船期价格'), requireLeaf('cross-border-procurement'), requireLeaf('cross-border-domestic-trucking'), requireLeaf('cross-border-pallet-shipping'), ], }, + requireLeaf('first-leg-customs-documents', '报关资料'), + requireLeaf('first-leg-sailing-prices', '船期价格'), { key: 'overseas-warehouse-center', title: '海外仓', From df1ea86278c511e484952d311e7bf8e8f1da0eda Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 25 Sep 2026 23:23:56 +0800 Subject: [PATCH 13/36] fix(client): restore destination warehouse from pricing quote --- src/modules/first-leg/views/FirstLegOrdersView.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 08792ed..d3f2a7d 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -1273,6 +1273,11 @@ async function autofillWarehouseSelections() { if (form.receivingWarehouseId) { await loadSelectedWarehouse(form.receivingWarehouseId) } + if (form.destinationType !== 'PRIVATE' && !form.destinationWarehouseId && form.amazonWarehouseCode) { + const destinationCode = form.amazonWarehouseCode.trim().toUpperCase() + const matchedDestination = availableDestinationWarehouses.value.find((warehouse) => warehouse.code.trim().toUpperCase() === destinationCode) + if (matchedDestination) await selectDestinationWarehouse(matchedDestination.id) + } if (form.destinationType !== 'PRIVATE' && !form.destinationWarehouseId && availableDestinationWarehouses.value.length === 1) { await selectDestinationWarehouse(availableDestinationWarehouses.value[0].id) } From 5feb44abffe067614cce2df55e7812f00f446b4d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 12:08:28 +0800 Subject: [PATCH 14/36] test(client): update customs write contract count --- src/modules/first-leg/api/customs.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/first-leg/api/customs.test.mjs b/src/modules/first-leg/api/customs.test.mjs index 2e780ee..778d96d 100644 --- a/src/modules/first-leg/api/customs.test.mjs +++ b/src/modules/first-leg/api/customs.test.mjs @@ -22,7 +22,7 @@ test('sends multi-inbound scope, optimistic versions and idempotency for every w assert.match(source, /params\.append\('inboundIds', id\)/) assert.match(source, /expectedVersion: document\.source\.version/) assert.match(source, /params: \{ expectedVersion: document\.source\.version \}/) - assert.equal(source.match(/'Idempotency-Key'/g)?.length, 4) + assert.equal(source.match(/'Idempotency-Key'/g)?.length, 6) assert.match(source, /data\.append\('file', file\)/) assert.match(source, /file\.size > MAX_CUSTOMS_FILE_SIZE/) }) From 92e0586089856dd49b45c9c6dae8eb4c0bec771f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 13:28:33 +0800 Subject: [PATCH 15/36] feat(client): expose customs receipt and final files --- src/modules/first-leg/api/customs.ts | 20 +++++++++++++------ .../components/CustomsDocumentsDialog.vue | 16 ++++++++------- .../views/FirstLegOrdersView.test.mjs | 4 ++-- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/modules/first-leg/api/customs.ts b/src/modules/first-leg/api/customs.ts index 1f2b507..3f70920 100644 --- a/src/modules/first-leg/api/customs.ts +++ b/src/modules/first-leg/api/customs.ts @@ -32,9 +32,10 @@ export interface MerchantCustomsDocument { inboundIds: string[] inboundCodes: string[] frozen: boolean - completion: MerchantCustomsFile | null + receipt: MerchantCustomsFile | null receiptStatus: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null receiptRejectReason: string | null + finalDocument: MerchantCustomsFile | null } export interface MerchantCustomsContainer { @@ -186,10 +187,17 @@ export function downloadMerchantCustomsSource(document: MerchantCustomsDocument) return download(`/merchant/v1/customs/documents/${encodedId(document.documentId)}/download`, document.source.fileName) } -export function downloadMerchantCustomsCompletion(document: MerchantCustomsDocument) { +export function downloadMerchantCustomsReceipt(document: MerchantCustomsDocument) { return download( - `/merchant/v1/customs/documents/${encodedId(document.documentId)}/completion/download`, - document.completion?.fileName || `customs-completion-${document.documentId}`, + `/merchant/v1/customs/documents/${encodedId(document.documentId)}/receipt/download`, + document.receipt?.fileName || `customs-receipt-${document.documentId}`, + ) +} + +export function downloadMerchantCustomsFinal(document: MerchantCustomsDocument) { + return download( + `/merchant/v1/customs/documents/${encodedId(document.documentId)}/final/download`, + document.finalDocument?.fileName || `customs-final-${document.documentId}`, ) } @@ -198,7 +206,7 @@ export function confirmMerchantCustomsReceipt(document: MerchantCustomsDocument) method: 'POST', url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/receipt/confirm`, headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-receipt-confirm-${document.documentId}`) }, - data: { expectedVersion: document.completion?.version ?? 0, confirm: true, remark: null }, + data: { expectedVersion: document.receipt?.version ?? 0, confirm: true, remark: null }, }) } @@ -207,7 +215,7 @@ export function rejectMerchantCustomsReceipt(document: MerchantCustomsDocument, method: 'POST', url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/receipt/reject`, headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-receipt-reject-${document.documentId}`) }, - data: { expectedVersion: document.completion?.version ?? 0, confirm: false, remark: reason }, + data: { expectedVersion: document.receipt?.version ?? 0, confirm: false, remark: reason }, }) } diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.vue b/src/modules/first-leg/components/CustomsDocumentsDialog.vue index fdab2a1..b2e3477 100644 --- a/src/modules/first-leg/components/CustomsDocumentsDialog.vue +++ b/src/modules/first-leg/components/CustomsDocumentsDialog.vue @@ -6,7 +6,8 @@ import { MERCHANT_CUSTOMS_STATUS_LABELS, deleteMerchantCustomsDocument, confirmMerchantCustomsReceipt, - downloadMerchantCustomsCompletion, + downloadMerchantCustomsReceipt, + downloadMerchantCustomsFinal, downloadMerchantCustomsSource, getMerchantCustomsForOrder, merchantCustomsErrorMessage, @@ -194,13 +195,13 @@ function saveDownload(blob: Blob, fileName: string) { URL.revokeObjectURL(url) } -async function downloadFile(document: MerchantCustomsDocument, kind: 'source' | 'completion') { +async function downloadFile(document: MerchantCustomsDocument, kind: 'source' | 'receipt' | 'final') { const key = `${document.documentId}:${kind}` downloadingKey.value = key try { const result = kind === 'source' ? await downloadMerchantCustomsSource(document) - : await downloadMerchantCustomsCompletion(document) + : kind === 'receipt' ? await downloadMerchantCustomsReceipt(document) : await downloadMerchantCustomsFinal(document) saveDownload(result.blob, result.fileName) } catch (error) { ElMessage.error(merchantCustomsErrorMessage(error, '文件下载失败')) @@ -211,7 +212,7 @@ async function downloadFile(document: MerchantCustomsDocument, kind: 'source' | async function confirmReceipt(document: MerchantCustomsDocument) { try { - await ElMessageBox.confirm('确认已核对整份报关行回执吗?确认后将进入整柜报关完成汇总。', '确认报关行回执', { type: 'warning' }) + await ElMessageBox.confirm('确认已核对整份报关行回执吗?确认后平台会把确认结果发送给报关行执行。', '确认报关行回执', { type: 'warning' }) mutating.value = true await confirmMerchantCustomsReceipt(document) ElMessage.success('回执确认成功') @@ -318,12 +319,13 @@ function formatSize(bytes: number) {

源文件 - 报关行回执 - From b0d4e6ed33c2e107377ea4dce9a1eacb43c45fa8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 18:17:28 +0800 Subject: [PATCH 18/36] fix(client): fit sailing inquiry controls at desktop width --- src/modules/first-leg/views/SailingPricesView.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/first-leg/views/SailingPricesView.vue b/src/modules/first-leg/views/SailingPricesView.vue index 5d79196..9360c1b 100644 --- a/src/modules/first-leg/views/SailingPricesView.vue +++ b/src/modules/first-leg/views/SailingPricesView.vue @@ -48,6 +48,6 @@ watch(originWarehouseId, () => { void loadChannels() }); onMounted(() => { void From 682d75f201a31e789e54545a5e542084cc7fbb33 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 22:35:30 +0800 Subject: [PATCH 19/36] style(client): remove duplicate sailing page heading --- src/modules/first-leg/views/SailingPricesView.vue | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/modules/first-leg/views/SailingPricesView.vue b/src/modules/first-leg/views/SailingPricesView.vue index 9360c1b..60d2409 100644 --- a/src/modules/first-leg/views/SailingPricesView.vue +++ b/src/modules/first-leg/views/SailingPricesView.vue @@ -1,6 +1,6 @@ From 58c4be7fc0016af23188a24b7216deb2e5b08ad2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 22:49:17 +0800 Subject: [PATCH 20/36] feat(client): align sailing quote inquiry with prototype --- .../first-leg/views/SailingPricesView.vue | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/modules/first-leg/views/SailingPricesView.vue b/src/modules/first-leg/views/SailingPricesView.vue index 60d2409..2a18bfa 100644 --- a/src/modules/first-leg/views/SailingPricesView.vue +++ b/src/modules/first-leg/views/SailingPricesView.vue @@ -7,13 +7,22 @@ import { estimateMerchantFirstLegPrice, getMerchantPricingChannels, getMerchantW const loading = ref(false); const querying = ref(false); const errorMessage = ref('') const router = useRouter() -const warehouses = ref([]); const channels = ref([]); const quotes = ref([]) -const originWarehouseId = ref(''); const destinationCode = ref(''); const weight = ref(); const volume = ref(); const taxMode = ref<'TAX_INCLUDED' | 'TAX_EXCLUDED'>('TAX_INCLUDED') -const hasInput = computed(() => Boolean(originWarehouseId.value && destinationCode.value.trim() && weight.value && volume.value)) -function formatMoney(item: MerchantPriceEstimate) { return `${item.currency} ${item.estimatedAmount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` } -async function loadBase() { loading.value = true; errorMessage.value = ''; try { const result = await getMerchantWarehouses({ warehouseType: 'DOMESTIC' }); warehouses.value = result.records; if (result.records[0]) { originWarehouseId.value = result.records[0].id; await loadChannels() } } catch (error) { errorMessage.value = error instanceof Error ? error.message : '船期价格加载失败' } finally { loading.value = false } } -async function loadChannels() { if (!originWarehouseId.value) return; channels.value = await getMerchantPricingChannels(originWarehouseId.value) } -async function query() { if (!hasInput.value) { ElMessage.warning('请填写起运仓、目的仓、重量和体积'); return }; querying.value = true; quotes.value = []; try { const results = await Promise.allSettled(channels.value.map((channel) => estimateMerchantFirstLegPrice({ originWarehouseId: originWarehouseId.value, channelCode: channel.channelCode, destinationCode: destinationCode.value.trim(), destinationType: 'FBA', pricingTaxMode: taxMode.value, actualWeightKg: weight.value!, actualVolumeCbm: volume.value! }))); quotes.value = results.flatMap((item) => item.status === 'fulfilled' ? [item.value] : []) } finally { querying.value = false } } +const warehouses = ref([]); const destinationWarehouses = ref([]); const channels = ref([]); const quotes = ref([]) +const quoteDialogOpen = ref(false); const originWarehouseId = ref(''); const destinationWarehouseId = ref(''); const weight = ref(); const volume = ref(); const taxMode = ref<'TAX_INCLUDED' | 'TAX_EXCLUDED'>('TAX_INCLUDED') +const selectedDestinationWarehouse = computed(() => destinationWarehouses.value.find((item) => String(item.id) === String(destinationWarehouseId.value))) +const destinationCode = computed(() => selectedDestinationWarehouse.value?.warehouseCode || '') +const hasInput = computed(() => Boolean(originWarehouseId.value && destinationWarehouseId.value && weight.value && volume.value)) +const quoteCards = computed(() => quotes.value.map((quote) => ({ quote, channel: channels.value.find((item) => item.channelCode === quote.channelCode) })).filter((item): item is { quote: MerchantPriceEstimate; channel: MerchantPricingChannel } => Boolean(item.channel)).sort((a, b) => a.quote.estimatedAmount - b.quote.estimatedAmount).slice(0, 4)) +const priceWinner = computed(() => quoteCards.value.reduce((winner, item) => !winner || item.quote.estimatedAmount < winner.quote.estimatedAmount ? item : winner, undefined as (typeof quoteCards.value)[number] | undefined)) +const timeWinner = computed(() => quoteCards.value.reduce((winner, item) => { const days = transitDays(item.channel.referenceTransit); if (days === null) return winner; return !winner || days < transitDays(winner.channel.referenceTransit)! ? item : winner }, undefined as (typeof quoteCards.value)[number] | undefined)) +const resultSummary = computed(() => `${volume.value || 0} CBM · ${destinationCode.value || '—'} · 匹配 ${quoteCards.value.length} 个渠道`) +function transitDays(value: string | null) { const match = value?.match(/\d+/); return match ? Number(match[0]) : null } +function formatMoney(item: MerchantPriceEstimate) { const currency = item.currency === 'CNY' ? '¥' : `${item.currency} `; return `${currency}${item.estimatedAmount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` } +function formatUnitPrice(item: MerchantPriceEstimate) { const currency = item.currency === 'CNY' ? '¥' : `${item.currency} `; return `${currency}${item.unitPrice.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}/${item.chargeUnit || 'CBM'}` } +function recommendation(item: { quote: MerchantPriceEstimate; channel: MerchantPricingChannel }) { if (priceWinner.value?.quote.channelCode === item.quote.channelCode) return { label: '价格优选', className: 'price' }; if (timeWinner.value?.quote.channelCode === item.quote.channelCode) return { label: '时间优选', className: 'time' }; return undefined } +async function loadBase() { loading.value = true; errorMessage.value = ''; try { const [originResult, destinationResult] = await Promise.all([getMerchantWarehouses({ warehouseType: 'DOMESTIC' }), getMerchantWarehouses({ warehouseType: 'FBA' })]); warehouses.value = originResult.records; destinationWarehouses.value = destinationResult.records; if (originResult.records[0]) originWarehouseId.value = originResult.records[0].id; if (destinationResult.records[0]) destinationWarehouseId.value = destinationResult.records[0].id; await loadChannels() } catch (error) { errorMessage.value = error instanceof Error ? error.message : '船期价格加载失败' } finally { loading.value = false } } +async function loadChannels() { if (!originWarehouseId.value) return; try { channels.value = await getMerchantPricingChannels(originWarehouseId.value) } catch (error) { channels.value = []; errorMessage.value = error instanceof Error ? error.message : '船期渠道加载失败' } } +async function query() { const destination = selectedDestinationWarehouse.value; if (!hasInput.value || !destination) { ElMessage.warning('请选择目的 FBA 仓,并填写重量和体积'); return }; if (!channels.value.length) { ElMessage.warning('暂无可查询的船期渠道'); return }; querying.value = true; quotes.value = []; try { const results = await Promise.allSettled(channels.value.map((channel) => estimateMerchantFirstLegPrice({ originWarehouseId: originWarehouseId.value, channelCode: channel.channelCode, destinationCode: destination.warehouseCode, destinationType: 'FBA', destinationWarehouseId: destination.id, destinationName: destination.warehouseName, pricingTaxMode: taxMode.value, actualWeightKg: weight.value!, actualVolumeCbm: volume.value! }))); quotes.value = results.flatMap((item) => item.status === 'fulfilled' ? [item.value] : []); if (!quotes.value.length) { ElMessage.warning('当前条件暂无匹配报价'); return }; quoteDialogOpen.value = true } finally { querying.value = false } } function placeOrder(channelCode: string, channelName: string) { if (!quotes.value.some((quote) => quote.channelCode === channelCode)) { ElMessage.warning('请先查询该渠道价格') @@ -27,9 +36,10 @@ function placeOrder(channelCode: string, channelName: string) { channelCode, channelName, destinationType: 'FBA', - destinationWarehouseName: destinationCode.value.trim().toUpperCase(), - amazonWarehouseCode: destinationCode.value.trim().toUpperCase(), - destinationCountryCode: 'US', + destinationWarehouseId: String(selectedDestinationWarehouse.value!.id), + destinationWarehouseName: selectedDestinationWarehouse.value!.warehouseName, + amazonWarehouseCode: selectedDestinationWarehouse.value!.warehouseCode, + destinationCountryCode: selectedDestinationWarehouse.value!.countryCode || 'US', pricingTaxMode: taxMode.value, grossWeightKg: String(weight.value), volumeCbm: String(volume.value), @@ -41,12 +51,15 @@ watch(originWarehouseId, () => { void loadChannels() }); onMounted(() => { void From 8aed079d02405b22b0a3dd7207acea1ba03c406b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 22:51:14 +0800 Subject: [PATCH 21/36] fix(client): style sailing quote dialog root --- src/modules/first-leg/views/SailingPricesView.vue | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/modules/first-leg/views/SailingPricesView.vue b/src/modules/first-leg/views/SailingPricesView.vue index 2a18bfa..a5d384e 100644 --- a/src/modules/first-leg/views/SailingPricesView.vue +++ b/src/modules/first-leg/views/SailingPricesView.vue @@ -62,4 +62,7 @@ watch(originWarehouseId, () => { void loadChannels() }); onMounted(() => { void .quote-dialog { --el-dialog-bg-color:transparent; }.quote-dialog :deep(.el-dialog) { overflow:hidden; border-radius:22px; background:linear-gradient(125deg,#182e69 0%,#274cbd 55%,#416bf1 100%); box-shadow:0 24px 70px rgba(11,31,89,.34); }.quote-dialog :deep(.el-dialog__header) { margin:0; padding:0; }.quote-dialog :deep(.el-dialog__body) { padding:0 20px 22px; }.quote-dialog-head { display:flex; align-items:flex-start; justify-content:space-between; padding:22px 22px 16px; color:#fff; }.quote-dialog-head h3 { margin:0 0 3px; font-size:22px; }.quote-dialog-head span { color:rgba(255,255,255,.72); font-size:14px; }.quote-close { width:42px; height:42px; border:1px solid #cfddf2; border-radius:13px; background:#fff; color:#8392a8; font-size:31px; line-height:34px; cursor:pointer; }.quote-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:20px; }.quote-card { position:relative; min-height:300px; overflow:hidden; padding:28px 25px 21px; border:1px solid #d5deee; border-radius:20px; background:#f8f9fd; color:#263a55; box-shadow:0 8px 22px rgba(22,48,117,.12); }.quote-card.price { border:2px solid #ffae3c; }.quote-card.time { border:2px solid #27c28c; }.quote-ribbon { position:absolute; right:-42px; top:17px; width:145px; padding:7px 0; transform:rotate(43deg); background:#ff8d14; color:#fff; text-align:center; font-size:12px; font-weight:850; }.quote-card.time .quote-ribbon { background:#0db886; }.quote-card-title { display:flex; align-items:flex-start; justify-content:space-between; gap:8px; }.quote-card-title strong { font-size:19px; }.quote-card-title span { flex:none; padding:7px 10px; border-radius:18px; background:#edf5ff; color:#367ae0; font-size:11px; font-weight:750; }.quote-price { display:flex; align-items:baseline; gap:8px; margin:25px 0 22px; }.quote-price b { color:#ed5a2a; font-size:33px; line-height:1; }.quote-price small { color:#96a4b8; font-size:15px; }.quote-meta { display:grid; grid-template-columns:1fr 1fr; gap:17px 13px; }.quote-meta span { display:block; margin-bottom:5px; color:#93a0b2; font-size:12px; }.quote-meta strong { display:block; color:#344961; font-size:14px; line-height:1.45; }.quote-order { width:100%; margin-top:20px; border-radius:9px; }.quote-card:not(.price):not(.time) .quote-order { background:#fff; } @media (max-width:1200px) { .pricing-form { grid-template-columns:repeat(3,minmax(0,1fr)); }.quote-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } } @media (max-width:720px) { .quote-grid { grid-template-columns:1fr; }.pricing-form { grid-template-columns:1fr; } } +:global(.quote-dialog.el-dialog) { overflow:hidden; border-radius:22px; background:linear-gradient(125deg,#182e69 0%,#274cbd 55%,#416bf1 100%); box-shadow:0 24px 70px rgba(11,31,89,.34); } +:global(.quote-dialog.el-dialog .el-dialog__header) { margin:0; padding:0; } +:global(.quote-dialog.el-dialog .el-dialog__body) { padding:0 20px 22px; } From 7eda66bc4d37d521ab31581aa11f62146e4a366d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 22:53:19 +0800 Subject: [PATCH 22/36] fix(client): show chargeable quantity in quote summary --- src/modules/first-leg/views/SailingPricesView.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/modules/first-leg/views/SailingPricesView.vue b/src/modules/first-leg/views/SailingPricesView.vue index a5d384e..78ba0dd 100644 --- a/src/modules/first-leg/views/SailingPricesView.vue +++ b/src/modules/first-leg/views/SailingPricesView.vue @@ -15,7 +15,11 @@ const hasInput = computed(() => Boolean(originWarehouseId.value && destinationWa const quoteCards = computed(() => quotes.value.map((quote) => ({ quote, channel: channels.value.find((item) => item.channelCode === quote.channelCode) })).filter((item): item is { quote: MerchantPriceEstimate; channel: MerchantPricingChannel } => Boolean(item.channel)).sort((a, b) => a.quote.estimatedAmount - b.quote.estimatedAmount).slice(0, 4)) const priceWinner = computed(() => quoteCards.value.reduce((winner, item) => !winner || item.quote.estimatedAmount < winner.quote.estimatedAmount ? item : winner, undefined as (typeof quoteCards.value)[number] | undefined)) const timeWinner = computed(() => quoteCards.value.reduce((winner, item) => { const days = transitDays(item.channel.referenceTransit); if (days === null) return winner; return !winner || days < transitDays(winner.channel.referenceTransit)! ? item : winner }, undefined as (typeof quoteCards.value)[number] | undefined)) -const resultSummary = computed(() => `${volume.value || 0} CBM · ${destinationCode.value || '—'} · 匹配 ${quoteCards.value.length} 个渠道`) +const resultSummary = computed(() => { + const quoteUnit = (volume.value || 0) >= (weight.value || 0) / 167 ? 'CBM' : 'KG' + const quoteQuantity = quoteUnit === 'CBM' ? volume.value : weight.value + return `${quoteQuantity || 0} ${quoteUnit} · ${destinationCode.value || '—'} · 匹配 ${quoteCards.value.length} 个渠道` +}) function transitDays(value: string | null) { const match = value?.match(/\d+/); return match ? Number(match[0]) : null } function formatMoney(item: MerchantPriceEstimate) { const currency = item.currency === 'CNY' ? '¥' : `${item.currency} `; return `${currency}${item.estimatedAmount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` } function formatUnitPrice(item: MerchantPriceEstimate) { const currency = item.currency === 'CNY' ? '¥' : `${item.currency} `; return `${currency}${item.unitPrice.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}/${item.chargeUnit || 'CBM'}` } From 34cdf4fc4fdd03468baf16730cb65fe6f9122a0d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 22:55:32 +0800 Subject: [PATCH 23/36] fix(client): show broker execution state in customs documents --- src/modules/first-leg/api/customs.ts | 1 + src/modules/first-leg/components/CustomsDocumentsDialog.vue | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/first-leg/api/customs.ts b/src/modules/first-leg/api/customs.ts index 3f70920..2245062 100644 --- a/src/modules/first-leg/api/customs.ts +++ b/src/modules/first-leg/api/customs.ts @@ -54,6 +54,7 @@ export interface MerchantCustomsContainer { inbounds: MerchantCustomsInbound[] documents: MerchantCustomsDocument[] startedAt?: string | null + brokerSubmittedAt?: string | null completedAt?: string | null } diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.vue b/src/modules/first-leg/components/CustomsDocumentsDialog.vue index b2e3477..a3860a4 100644 --- a/src/modules/first-leg/components/CustomsDocumentsDialog.vue +++ b/src/modules/first-leg/components/CustomsDocumentsDialog.vue @@ -284,7 +284,7 @@ function formatSize(bytes: number) {
-
报关资料已锁定报关开始后仅可查看和下载,不提供退回或解锁。
+
{{ detail.brokerSubmittedAt ? '已发送报关行执行' : '报关资料已锁定' }}{{ detail.brokerSubmittedAt ? '平台已将客户确认后的回执发送给报关行,等待真实报关完税文件。' : '报关开始后仅可查看和下载,不提供退回或解锁。' }}
From 20ea6adc6f2a5186bd74bdde37af2fc9887d1f6b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 27 Sep 2026 00:09:50 +0800 Subject: [PATCH 24/36] fix(client): show only FBA warehouse code --- src/modules/first-leg/views/FirstLegOrdersView.vue | 4 ++-- src/modules/first-leg/views/QuickOrderView.vue | 2 +- src/modules/first-leg/views/SailingPricesView.vue | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index d3f2a7d..5010781 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -2216,8 +2216,8 @@ async function searchOrders(model: QueryModel) {
保存当前地址停用
- 平台暂未维护可用的{{ form.destinationType === 'FBA' ? ' FBA 仓' : '海外仓' }},请联系平台管理员。 -
{{ selectedDestinationWarehouse.name }} · {{ selectedDestinationWarehouse.code }} · {{ selectedDestinationWarehouse.countryCode }}仓库地址:{{ selectedDestinationWarehouse.province }}{{ selectedDestinationWarehouse.city }} {{ selectedDestinationWarehouse.address }}联系人:{{ selectedDestinationWarehouse.contactName || '-' }} {{ selectedDestinationWarehouse.contactPhone }}
+ 平台暂未维护可用的{{ form.destinationType === 'FBA' ? ' FBA 仓' : '海外仓' }},请联系平台管理员。 +
{{ form.destinationType === 'FBA' ? selectedDestinationWarehouse.code : `${selectedDestinationWarehouse.name} · ${selectedDestinationWarehouse.code}` }} · {{ selectedDestinationWarehouse.countryCode }}仓库地址:{{ selectedDestinationWarehouse.province }}{{ selectedDestinationWarehouse.city }} {{ selectedDestinationWarehouse.address }}联系人:{{ selectedDestinationWarehouse.contactName || '-' }} {{ selectedDestinationWarehouse.contactPhone }}
换标模式我方箱唛新 FBA 箱唛FBA 目的仓任务箱数商品 SKUSKU 换标明细FBA 货件编号贴标要求备注