From dd9b8f7a6381168942f13f8b63cd3787863a6814 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:38:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=A4=B4=E7=A8=8B?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E4=B8=8E=E8=AE=A1=E8=B4=B9=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inbounds/fee/InboundFeePanel.test.ts | 27 +++++- src/modules/inbounds/fee/InboundFeePanel.vue | 47 ++++++++-- src/modules/inbounds/fee/api.test.ts | 4 +- src/modules/inbounds/fee/types.ts | 6 ++ .../InboundMeasurementPanel.test.ts | 5 ++ .../measurement/InboundMeasurementPanel.vue | 15 ++++ src/modules/inbounds/measurement/types.ts | 3 + .../api/platformLoadingLists.test.ts | 4 +- .../loading-lists/api/platformLoadingLists.ts | 2 + .../components/LoadingListDetailDrawer.vue | 2 + .../components/LoadingListsView.test.ts | 1 + .../unified-orders/api/unifiedOrders.test.ts | 33 ++++++- .../unified-orders/api/unifiedOrders.ts | 53 ++++++++++- .../components/InboundFeeDetailManager.vue | 12 ++- .../pages/UnifiedOrdersView.test.ts | 3 + .../pages/UnifiedOrdersView.vue | 87 ++++++++++++++++--- 16 files changed, 273 insertions(+), 31 deletions(-) diff --git a/src/modules/inbounds/fee/InboundFeePanel.test.ts b/src/modules/inbounds/fee/InboundFeePanel.test.ts index b72aa05a..4a0096b0 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.test.ts +++ b/src/modules/inbounds/fee/InboundFeePanel.test.ts @@ -35,9 +35,11 @@ const snapshot: InboundFeeSnapshot = { carrierRateId: '202', rateCode: 'RATE-LAX', carrierRateVersion: 5, customerId: '303', customerName: '测试客户', originWarehouseId: '404', originWarehouseName: '义乌仓', businessType: 'FBA_SEA', channelCode: 'SEA-TAX', carrierId: '505', carrierName: '测试船司', - destinationCode: 'LAX9', taxMode: 'TAX_INCLUDED', chargeMode: 'KG', + destinationCode: 'LAX9', taxMode: 'TAX_REFUND', pricingTaxMode: 'TAX_INCLUDED', chargeMode: 'KG', conversionFactor: 167, roundingScale: 2, roundingMode: 'HALF_UP', - actualWeightKg: 100, actualVolumeCbm: 0.8, convertedQuantity: 133.6, chargeableQuantity: 133.6, + actualWeightKg: 100, actualVolumeCbm: 0.8, convertedQuantity: 133.6, + baselineChargeableQuantity: 133.6, lowerChargeableQuantity: 120.24, upperChargeableQuantity: 146.96, + tolerancePercent: 10, chargeableQuantity: 133.6, chargeUnit: 'KG', unitPrice: 8.5, subtotalAmount: 1135.6, additionalAmount: 100, totalAmount: 1235.6, currency: 'CNY', rateEffectiveFrom: '2026-08-01T00:00:00', rateEffectiveTo: null, @@ -88,6 +90,8 @@ describe('InboundFeePanel', () => { await flushPromises() expect(getLatestInboundFee).toHaveBeenCalledWith('10001', '88') + expect(document.body.textContent).toContain('订单报关:退税报关') + expect(document.body.textContent).toContain('报价税制:包税') expect(document.body.textContent).not.toContain('租户') expect(document.body.textContent).toContain('头程自动费用 · CNY') expect(document.body.textContent).toContain('¥1,235.60') @@ -237,6 +241,18 @@ describe('InboundFeePanel', () => { expect(wrapper.find('[data-testid="adjust-fee-button"]').exists()).toBe(true) }) + it('allows a published fee adjustment until the customer confirms it', async () => { + const wrapper = mountPanel() + await flushPromises() + expect(wrapper.get('[data-testid="adjust-fee-button"]').text()).toContain('调整已发布费用') + + document.body.innerHTML = '' + vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, customerConfirmationStatus: 'CONFIRMED' }) + const confirmed = mountPanel() + await flushPromises() + expect(confirmed.find('[data-testid="adjust-fee-button"]').exists()).toBe(false) + }) + it('confirms and then sends a calculated fee through the implemented backend transitions', async () => { vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null }) vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) @@ -294,7 +310,7 @@ describe('InboundFeePanel', () => { expect(adjustPublishedInboundFee).toHaveBeenCalledWith( '10001', '88', - { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费用' }, + { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费用', toleranceConfirmed: true }, expect.stringMatching(/^fee-adjust-/), ) expect(wrapper.emitted('changed')?.[0]?.[0]).toMatchObject({ feeVersion: 3 }) @@ -307,6 +323,11 @@ describe('InboundFeePanel', () => { expect(source).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))') expect(source).toContain('class="adjustment-unit"') expect(source).toContain('white-space: nowrap') + expect(source).toContain('snapshot.value?.lowerChargeableQuantity') + expect(source).toContain('snapshot.value?.upperChargeableQuantity') + expect(source).toContain('toleranceConfirmed: true') + expect(source).toContain("'再次确认计费量调整'") + expect(source).toContain('nextQuantity < adjustmentMinimum.value || nextQuantity > adjustmentMaximum.value') }) it('refreshes the latest snapshot after a conflict while retaining the dialog', async () => { diff --git a/src/modules/inbounds/fee/InboundFeePanel.vue b/src/modules/inbounds/fee/InboundFeePanel.vue index b51a25ce..28f91214 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.vue +++ b/src/modules/inbounds/fee/InboundFeePanel.vue @@ -86,11 +86,17 @@ const normalizedInboundId = computed(() => { return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : '' }) const adjustmentEligible = computed(() => ( - snapshot.value?.feeStatus === 'CALCULATED' + ['CALCULATED', 'CONFIRMED', 'SENT'].includes(snapshot.value?.feeStatus || '') && snapshot.value?.nextProcessStatus === 'WAIT_SPLIT' + && snapshot.value?.customerConfirmationStatus !== 'CONFIRMED' )) const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费') const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM') +const convertedQuantityLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '体积重量' : '重量折算体积') +const adjustmentBaseline = computed(() => Number(snapshot.value?.baselineChargeableQuantity || 0)) +const adjustmentMinimum = computed(() => Number(snapshot.value?.lowerChargeableQuantity || 0)) +const adjustmentMaximum = computed(() => Number(snapshot.value?.upperChargeableQuantity || 0)) +const adjustmentQuantityChanged = computed(() => Math.abs(Number(adjustmentForm.chargeableQuantity) - adjustmentBaseline.value) > 1e-8) const statusInfo = computed(() => feeStatusInfo(snapshot.value?.feeStatus)) const sourceOptions = computed(() => { const value = measurement.value @@ -145,6 +151,12 @@ function taxModeLabel(value: string) { return firstLegTaxModeLabel(value) } +function pricingTaxModeLabel(value: string | null | undefined) { + if (value === 'TAX_INCLUDED') return '包税' + if (value === 'TAX_EXCLUDED') return '不包税' + return '—' +} + function roundingModeLabel(value: string) { const labels: Record = { UP: '远离零方向', DOWN: '向零方向', CEILING: '向上', FLOOR: '向下', @@ -275,6 +287,7 @@ function adjustmentPayload(): AdjustPublishedInboundFeeRequest { unitPrice: Number(adjustmentForm.unitPrice), additionalAmount: Number(adjustmentForm.additionalAmount), reason: adjustmentForm.reason.trim(), + toleranceConfirmed: true, } } @@ -291,6 +304,22 @@ async function submitAdjustment() { if (!snapshot.value) return const valid = await adjustFormRef.value?.validate().catch(() => false) if (!valid) return + const nextQuantity = Number(adjustmentForm.chargeableQuantity) + if (nextQuantity < adjustmentMinimum.value || nextQuantity > adjustmentMaximum.value) { + ElMessage.warning(`调整后计费量须在基准值的 ±10% 范围内(${formatNumber(adjustmentMinimum.value, 4)}–${formatNumber(adjustmentMaximum.value, 4)} ${chargeUnitLabel.value})`) + return + } + if (adjustmentQuantityChanged.value) { + try { + await ElMessageBox.confirm( + `计费量将由 ${formatNumber(adjustmentBaseline.value, 4)} 调整为 ${formatNumber(nextQuantity, 4)} ${chargeUnitLabel.value},并生成新费用版本。是否继续?`, + '再次确认计费量调整', + { type: 'warning', confirmButtonText: '确认调整', cancelButtonText: '返回检查' }, + ) + } catch { + return + } + } const payload = adjustmentPayload() adjustmentError.value = null conflictRefreshed.value = false @@ -364,7 +393,7 @@ defineExpose({ reload: loadFee }) data-testid="adjust-fee-button" type="primary" @click="openAdjustment" - >调整初算费用 + >{{ snapshot?.feeStatus === 'CALCULATED' ? '调整初算费用' : '调整已发布费用' }}

计费依据

实际重量与体积重量取大后计费,金额以服务端快照为准。

- {{ taxModeLabel(snapshot.taxMode) }} +
+ 订单报关:{{ taxModeLabel(snapshot.taxMode) }} + 报价税制:{{ pricingTaxModeLabel(snapshot.pricingTaxMode) }} +
实际重量{{ formatNumber(snapshot.actualWeightKg, 3) }} KG
实际体积{{ formatNumber(snapshot.actualVolumeCbm, 4) }} CBM
-
体积重量{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}系数 {{ formatNumber(snapshot.conversionFactor, 4) }}
+
{{ convertedQuantityLabel }}{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}系数 {{ formatNumber(snapshot.conversionFactor, 4) }}
取大后的计费量{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}
@@ -517,6 +549,10 @@ defineExpose({ reload: loadFee }) @confirm="submitAdjustment" > + + +

系统基准 {{ formatNumber(adjustmentBaseline, 4) }} {{ chargeUnitLabel }},服务端允许范围 {{ formatNumber(adjustmentMinimum, 4) }}–{{ formatNumber(adjustmentMaximum, 4) }} {{ chargeUnitLabel }}(包含边界);超出范围将无法提交。

+

当前输入仍保留,请先核对最新版本后再提交。

@@ -524,7 +560,7 @@ defineExpose({ reload: loadFee })
- + {{ chargeUnitLabel }} @@ -571,6 +607,7 @@ defineExpose({ reload: loadFee }) .version-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } .calculation-section, .fee-timeline { padding: 10px 0; border-bottom: 1px solid var(--cc-border); } .section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.calculation-tags { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; } .section-title h4 { font-size: 14px; } .calculation-flow { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 7px; border-top: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); } .calculation-flow > div { display: flex; min-width: 0; min-height: 46px; flex-direction: row; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 10px; border-right: 1px solid var(--cc-border); } diff --git a/src/modules/inbounds/fee/api.test.ts b/src/modules/inbounds/fee/api.test.ts index 8c485311..95f7aab9 100644 --- a/src/modules/inbounds/fee/api.test.ts +++ b/src/modules/inbounds/fee/api.test.ts @@ -42,7 +42,7 @@ describe('inbound fee API', () => { it('adjusts a published snapshot with the required idempotency key', async () => { vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, feeVersion: 3 } }) - const payload = { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费' } + const payload = { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费', toleranceConfirmed: true as const } await expect(adjustPublishedInboundFee('10001', '88', payload, 'fee-adjust-1')) .resolves.toMatchObject({ feeVersion: 3 }) @@ -78,7 +78,7 @@ describe('inbound fee API', () => { const error = await adjustPublishedInboundFee( '10001', '88', - { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '价格修正' }, + { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '价格修正', toleranceConfirmed: true }, 'fee-adjust-2', ).catch((reason: unknown) => reason) diff --git a/src/modules/inbounds/fee/types.ts b/src/modules/inbounds/fee/types.ts index 305d247a..1e3b814b 100644 --- a/src/modules/inbounds/fee/types.ts +++ b/src/modules/inbounds/fee/types.ts @@ -26,6 +26,7 @@ export interface InboundFeeSnapshot { carrierName: string destinationCode: string taxMode: string + pricingTaxMode: string | null chargeMode: InboundChargeMode conversionFactor: number roundingScale: number @@ -33,6 +34,10 @@ export interface InboundFeeSnapshot { actualWeightKg: number actualVolumeCbm: number convertedQuantity: number + baselineChargeableQuantity: number + lowerChargeableQuantity: number + upperChargeableQuantity: number + tolerancePercent: number chargeableQuantity: number chargeUnit: string unitPrice: number | null @@ -58,4 +63,5 @@ export interface AdjustPublishedInboundFeeRequest { unitPrice: number additionalAmount: number reason: string + toleranceConfirmed: true } diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts index 7907255c..fced8f6c 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts +++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts @@ -89,6 +89,9 @@ function measurement(overrides: Partial = {}): Inbound items: [{ id: '9101', inboundDetailId: '8101', + inboundCode: 'RK202608040001', + fbaShipmentNo: 'FBA18ABC1234', + scannedShipmentId: 'FBA18SCAN5678', boxCode: 'RK202608040001#10-1', itemNo: '001', boxSpecification: '50×40×30cm', @@ -168,6 +171,8 @@ describe('InboundMeasurementPanel', () => { expect(getInboundMeasurement).toHaveBeenCalledWith('10001', '7001') expect(wrapper.text()).toContain('RK202608040001') + expect(wrapper.find('.measurement-table').text()).toContain('FBA18ABC1234') + expect(wrapper.find('.measurement-table').text()).toContain('FBA18SCAN5678') expect(wrapper.find('.measurement-table').text()).toContain('0.6') expect(wrapper.find('.measurement-table').text()).not.toContain('周长') await wrapper.find('.el-table__expand-icon').trigger('click') diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue index f423f66a..3ebab966 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue +++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue @@ -32,6 +32,9 @@ const emit = defineEmits<{ type EditableMeasurementItem = Omit & { itemId: string clientKey: string + inboundCode: string + fbaShipmentNo: string | null + scannedShipmentId: string | null serverValues: Pick chargeRuleId: string | null coefficientMode: 'ACTUAL' | 'ADD' | 'MULTIPLY' @@ -266,6 +269,9 @@ function editableItem(item: InboundMeasurementItem): EditableMeasurementItem { itemId: String(item.id), inboundDetailId: String(item.inboundDetailId || item.id), clientKey: nextItemKey(), + inboundCode: item.inboundCode || '—', + fbaShipmentNo: item.fbaShipmentNo || null, + scannedShipmentId: item.scannedShipmentId || null, boxCode: item.boxCode, itemNo: item.itemNo, boxSpecification: item.boxSpecification, @@ -701,6 +707,15 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may + + + + + + + + + diff --git a/src/modules/inbounds/measurement/types.ts b/src/modules/inbounds/measurement/types.ts index 52fce114..0f784174 100644 --- a/src/modules/inbounds/measurement/types.ts +++ b/src/modules/inbounds/measurement/types.ts @@ -13,7 +13,10 @@ export type InboundMeasurementProcessStatus = export interface InboundMeasurementItem { id: MeasurementIdentifier + inboundCode?: string | null inboundDetailId?: MeasurementIdentifier | null + fbaShipmentNo?: string | null + scannedShipmentId?: string | null boxCode: string | null itemNo: string boxSpecification: string diff --git a/src/modules/loading-lists/api/platformLoadingLists.test.ts b/src/modules/loading-lists/api/platformLoadingLists.test.ts index 1f46db2f..77046e85 100644 --- a/src/modules/loading-lists/api/platformLoadingLists.test.ts +++ b/src/modules/loading-lists/api/platformLoadingLists.test.ts @@ -48,7 +48,7 @@ const detail = { summary: preloadSummary, items: [{ itemId: 11, itemVersion: 4, accountId: 12, orderId: 15, orderNo: 'ORD-001', inboundId: 13, inboundDetailId: 14, containerNo: 'MSCU1234567', productQuantity: 20, - customerName: '测试客户', inboundCode: 'RK001', skuCode: 'SKU001', destinationWarehouse: 'ONT8', chineseName: '产品', englishName: null, + customerName: '测试客户', customsTaxMode: 'TAX_REFUND', taxRefundFlag: true, inboundCode: 'RK001', skuCode: 'SKU001', destinationWarehouse: 'ONT8', chineseName: '产品', englishName: null, goodsImageUrl: null, packageCount: 2, palletCount: 1, grossWeightKg: 10, netWeightKg: 9, volumeCbm: 1, dimensionalWeightKg: 2, unitsPerPackage: 10, fbaTrackingNo: null, shipmentNo: null, shippingMark: null, internalRemark: null, customerRemark: null, warehouseRemark: null, loadSequence: 1, frozenAt: null, @@ -75,7 +75,7 @@ describe('platform loading lists API', () => { vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: detail }) const result = await getPreloadPlan('100', '300') - expect(result.items[0]).toMatchObject({ itemId: '11', itemVersion: 4, accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', containerNo: 'MSCU1234567', productQuantity: '20' }) + expect(result.items[0]).toMatchObject({ itemId: '11', itemVersion: 4, accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', containerNo: 'MSCU1234567', productQuantity: '20', customsTaxMode: 'TAX_REFUND', taxRefundFlag: true }) expect(result.summary.containerBookingId).toBe('1900000000000000004') expect(result.histories[0]).toMatchObject({ historyId: '21', itemId: '11', operationId: 'transport-21', operatorId: '22' }) }) diff --git a/src/modules/loading-lists/api/platformLoadingLists.ts b/src/modules/loading-lists/api/platformLoadingLists.ts index 8fbc13c8..32ff36cd 100644 --- a/src/modules/loading-lists/api/platformLoadingLists.ts +++ b/src/modules/loading-lists/api/platformLoadingLists.ts @@ -80,6 +80,8 @@ export interface LoadingItem { itemVersion: number accountId: string customerName: string | null + customsTaxMode: 'TAX_REFUND' | 'BUY_DECLARATION' | null + taxRefundFlag: boolean | null orderId: string orderNo: string inboundId: string diff --git a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue index cb2ff581..c13f8f92 100644 --- a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue +++ b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue @@ -33,6 +33,7 @@ const baseItemColumns: DataTableColumn[] = [ { key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true }, { key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 }, + { key: 'taxRefundFlag', label: '是否退税', width: 96, align: 'center', slotName: 'taxRefundFlag' }, { key: 'chineseName', prop: 'chineseName', label: '中文品名', minWidth: 160 }, { key: 'englishName', prop: 'englishName', label: '英文品名', minWidth: 170 }, { key: 'netWeightKg', label: '净重(kg)', width: 110, align: 'right', slotName: 'netWeight' }, @@ -214,6 +215,7 @@ function formatNumber(value?: number | null, digits = 3) { + diff --git a/src/modules/loading-lists/components/LoadingListsView.test.ts b/src/modules/loading-lists/components/LoadingListsView.test.ts index a2d5c685..3842e1be 100644 --- a/src/modules/loading-lists/components/LoadingListsView.test.ts +++ b/src/modules/loading-lists/components/LoadingListsView.test.ts @@ -152,6 +152,7 @@ describe('LoadingListsView', () => { expect(detailSource.indexOf("key: 'orderNo'")).toBeLessThan(detailSource.indexOf("key: 'containerNo'")) expect(detailSource.indexOf("key: 'containerNo'")).toBeLessThan(detailSource.indexOf("key: 'skuCode'")) const fields = [ + 'taxRefundFlag', 'chineseName', 'englishName', 'netWeightKg', diff --git a/src/modules/unified-orders/api/unifiedOrders.test.ts b/src/modules/unified-orders/api/unifiedOrders.test.ts index d5446fff..8370b63a 100644 --- a/src/modules/unified-orders/api/unifiedOrders.test.ts +++ b/src/modules/unified-orders/api/unifiedOrders.test.ts @@ -3,6 +3,7 @@ import { http } from '@/shared/api/http' import { approvePlatformUnifiedOrder, batchApprovePlatformUnifiedOrders, + getPlatformUnifiedOrderBatchApprovalPreview, addPlatformUnifiedOrderNote, getPlatformUnifiedOrder, downloadPlatformProductImage, @@ -134,11 +135,39 @@ describe('unified orders API contract', () => { } }) await expect(batchApprovePlatformUnifiedOrders('2089176869176389634', [ - '2089223263733518337', '2089223263733518338', + { orderId: '2089223263733518337', orderVersion: 3 }, + { orderId: '2089223263733518338', orderVersion: 8 }, ])).resolves.toMatchObject({ successCount: 1, failureCount: 1 }) expect(http.post).toHaveBeenCalledWith( '/platform/v1/orders/first-leg/batch-approve', - { orderIds: ['2089223263733518337', '2089223263733518338'] }, + { items: [ + { orderId: '2089223263733518337', orderVersion: 3 }, + { orderId: '2089223263733518338', orderVersion: 8 }, + ] }, + { params: { tenantId: '2089176869176389634' } }, + ) + }) + + it('loads one batch-review preview with Chinese goods and normalizes long IDs', async () => { + vi.mocked(http.post).mockResolvedValue({ data: { + total: 1, + approvableCount: 1, + items: [{ + orderId: '2089223263733518337', orderVersion: '3', inboundCode: 'NYEXX2609080001', + customerId: '2089176869176389634', customerName: '测试客户', chineseGoodsNames: [' 蓝牙音箱 ', ''], + channelCode: 'NY_EXX', channelName: '纽约海运', destinationWarehouseCode: 'ONT8', + destinationWarehouseName: 'ONT8 亚马逊仓', canApprove: true, blockReason: null, + }], + } }) + const items = [{ orderId: '2089223263733518337', orderVersion: 3 }] + + await expect(getPlatformUnifiedOrderBatchApprovalPreview('2089176869176389634', items)).resolves.toMatchObject({ + total: 1, approvableCount: 1, + items: [{ orderId: '2089223263733518337', orderVersion: 3, chineseGoodsNames: ['蓝牙音箱'] }], + }) + expect(http.post).toHaveBeenCalledWith( + '/platform/v1/orders/first-leg/batch-review-preview', + { items }, { params: { tenantId: '2089176869176389634' } }, ) }) diff --git a/src/modules/unified-orders/api/unifiedOrders.ts b/src/modules/unified-orders/api/unifiedOrders.ts index 61fde28d..b5bc70d3 100644 --- a/src/modules/unified-orders/api/unifiedOrders.ts +++ b/src/modules/unified-orders/api/unifiedOrders.ts @@ -540,10 +540,59 @@ export interface UnifiedOrderBatchApprovalResult { failedOrders: Array<{ orderId: number | string; code: string; message: string }> } -export function batchApprovePlatformUnifiedOrders(tenantId: string, orderIds: string[]) { +export interface UnifiedOrderBatchReviewItem { + orderId: string + orderVersion: number +} + +export interface UnifiedOrderBatchApprovalPreviewItem extends UnifiedOrderBatchReviewItem { + inboundCode: string | null + customerId: string | null + customerName: string | null + chineseGoodsNames: string[] + channelCode: string | null + channelName: string | null + destinationWarehouseCode: string | null + destinationWarehouseName: string | null + canApprove: boolean + blockReason: string | null +} + +export interface UnifiedOrderBatchApprovalPreview { + total: number + approvableCount: number + items: UnifiedOrderBatchApprovalPreviewItem[] +} + +function normalizeBatchApprovalPreview(result: UnifiedOrderBatchApprovalPreview): UnifiedOrderBatchApprovalPreview { + return { + ...result, + total: integer(result.total, 0), + approvableCount: integer(result.approvableCount, 0), + items: (result.items || []).map((item) => ({ + ...item, + orderId: String(item.orderId), + orderVersion: integer(item.orderVersion, 0), + customerId: id(item.customerId), + chineseGoodsNames: (item.chineseGoodsNames || []).map((name) => name.trim()).filter(Boolean), + canApprove: Boolean(item.canApprove), + })), + } +} + +export async function getPlatformUnifiedOrderBatchApprovalPreview(tenantId: string, items: UnifiedOrderBatchReviewItem[]) { + const response = await http.post, ApiEnvelope>( + '/platform/v1/orders/first-leg/batch-review-preview', + { items }, + { params: { tenantId } }, + ) + return normalizeBatchApprovalPreview(unwrapApiResponse(response)) +} + +export function batchApprovePlatformUnifiedOrders(tenantId: string, items: UnifiedOrderBatchReviewItem[]) { return http.post, ApiEnvelope>( '/platform/v1/orders/first-leg/batch-approve', - { orderIds }, + { items }, { params: { tenantId } }, ).then(unwrapApiResponse) } diff --git a/src/modules/unified-orders/components/InboundFeeDetailManager.vue b/src/modules/unified-orders/components/InboundFeeDetailManager.vue index f0b7c07e..89fe5547 100644 --- a/src/modules/unified-orders/components/InboundFeeDetailManager.vue +++ b/src/modules/unified-orders/components/InboundFeeDetailManager.vue @@ -18,7 +18,7 @@ import type { InboundFeeSupplierOption, SaveInboundFeeDetailRequest, } from '@/modules/unified-orders/api/inboundFeeDetails' -const props = defineProps<{ accountId: string; inboundId: string }>() +const props = defineProps<{ accountId: string; inboundId: string; destinationType?: string | null }>() interface OtherFeeSummary { currency: string; approvedReceivable: number; approvedPayable: number } const emit = defineEmits<{ changed: []; summary: [value: OtherFeeSummary] }>() const access = useAccessPreviewStore() @@ -74,7 +74,11 @@ const directionOptions = computed(() => [ { label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers.value }, ]) -const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE' ? item.warehouseReceivable : item.warehousePayable)) +const privateDeliveryFeeCode = 'PRIVATE_LAST_MILE_DELIVERY' +const applicableItems = computed(() => feeItems.value.filter((item) => { + if (item.feeCode === privateDeliveryFeeCode && props.destinationType !== 'PRIVATE') return false + return form.direction === 'RECEIVABLE' ? item.warehouseReceivable : item.warehousePayable +})) const selectedFeeItem = computed(() => feeItems.value.find((item) => String(item.id) === String(form.feeItemId))) function isManual(row: InboundFeeDetail) { return row.sourceType === 'MANUAL' } @@ -298,7 +302,7 @@ onMounted(() => void load()) - + @@ -314,6 +318,7 @@ onMounted(() => void load()) + @@ -360,6 +365,7 @@ onMounted(() => void load()) .voucher-field { grid-column: 1 / -1; }.voucher-control { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; width: 100%; }.voucher-picker { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border: 1px solid var(--cc-border); border-radius: 6px; color: var(--el-color-primary); cursor: pointer; }.voucher-picker.is-disabled { cursor: not-allowed; opacity: .6; }.voucher-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; }.voucher-hint { color: var(--cc-muted); font-size: 12px; }.voucher-list { display: grid; width: 100%; gap: 6px; margin-top: 8px; }.voucher-list > span { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 9px; border-radius: 5px; background: var(--el-fill-color-light); }.voucher-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.voucher-list button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; } .fee-table-shell { min-width: 0; max-width: 100%; overflow: hidden; } .fee-table-shell :deep(.el-table) { width: 100%; max-width: 100%; } +.private-delivery-tag { margin-left: 6px; vertical-align: middle; } @keyframes fee-refresh-spin { to { transform: rotate(360deg); } } @media (max-width: 720px) { .fee-toolbar { align-items: flex-start; flex-direction: column; } .fee-form { grid-template-columns: 1fr; } .fee-form :deep(.el-form-item:last-child) { grid-column: auto; } } diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index 8bd4338e..c37c6617 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -108,6 +108,9 @@ describe('UnifiedOrdersView contract', () => { expect(source).not.toContain('批量审核接口待接入') expect(source).toContain('@selection-change="handleOrderSelectionChange"') expect(source).toContain('batchApprovePlatformUnifiedOrders') + expect(source).toContain('getPlatformUnifiedOrderBatchApprovalPreview') + expect(source).toContain('中文品名') + expect(source).toContain('确认批量审核') expect(source).toContain('批量审核通过') expect(source).toContain('title="订单列表暂时无法加载"') expect(source).toContain('empty-text="暂无符合条件的订单"') diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index 3ac9862d..b45ba476 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -8,6 +8,7 @@ import { downloadPlatformProductImage, approvePlatformUnifiedOrder, batchApprovePlatformUnifiedOrders, + getPlatformUnifiedOrderBatchApprovalPreview, addPlatformUnifiedOrderNote, listPlatformUnifiedOrders, updatePlatformUnifiedOrderPlanning, @@ -16,6 +17,8 @@ import { type UnifiedOrderCoLoadGroupSummary, type UnifiedOrderMainStatus, type UnifiedOrderResource, + type UnifiedOrderBatchApprovalPreviewItem, + type UnifiedOrderBatchReviewItem, } from '@/modules/unified-orders/api/unifiedOrders' import { cancelPlatformCoLoadGroup, createPlatformCoLoadGroup, getPlatformCoLoadGroupByInboundCode, type CoLoadBusinessSummary, type CoLoadGroup } from '@/modules/unified-orders/api/coLoadGroups' import OrderFulfillmentTreePanel from '@/modules/unified-orders/components/OrderFulfillmentTreePanel.vue' @@ -150,6 +153,12 @@ const approvalOpen = ref(false) const approvalLoadingId = ref('') const approvalSaving = ref(false) const batchApprovalSaving = ref(false) +const batchApprovalLoading = ref(false) +const batchApprovalOpen = ref(false) +const batchApprovalError = ref(null) +const batchApprovalPreviewRows = ref([]) +const batchApprovalSelectedRows = ref([]) +const batchApprovalTableRef = ref<{ toggleRowSelection: (row: UnifiedOrderBatchApprovalPreviewItem, selected?: boolean) => void }>() const exportSubmitting = ref(false) const selectedApprovalOrders = ref([]) const selectedOrders = ref([]) @@ -1809,27 +1818,62 @@ async function cancelCoLoadGroup() { } } -async function batchApproveOrders() { +function batchReviewItem(order: UnifiedOrderResource): UnifiedOrderBatchReviewItem { + return { orderId: String(order.orderId), orderVersion: Number(order.orderVersion || 0) } +} + +function handleBatchApprovalSelection(rows: UnifiedOrderBatchApprovalPreviewItem[]) { + batchApprovalSelectedRows.value = rows +} + +async function openBatchApproval() { const selected = [...selectedApprovalOrders.value] - if (!selected.length || batchApprovalSaving.value) return - batchApprovalSaving.value = true + if (!selected.length || batchApprovalLoading.value || batchApprovalSaving.value) return + batchApprovalOpen.value = true + batchApprovalLoading.value = true + batchApprovalError.value = null + batchApprovalPreviewRows.value = [] + batchApprovalSelectedRows.value = [] try { - await ElMessageBox.confirm( - `确认批量审核通过已勾选的 ${selected.length} 个订单吗?审核后订单将进入待测量。`, - '批量审核订单', - { confirmButtonText: '确认批量审核', cancelButtonText: '取消', type: 'warning' }, - ) const groups = new Map() selected.forEach((order) => { const tenantId = String(order.tenantId) groups.set(tenantId, [...(groups.get(tenantId) || []), order]) }) + const previews = await Promise.all([...groups].map(([tenantId, orders]) => ( + getPlatformUnifiedOrderBatchApprovalPreview(tenantId, orders.map(batchReviewItem)) + ))) + batchApprovalPreviewRows.value = previews.flatMap((preview) => preview.items) + await nextTick() + batchApprovalPreviewRows.value.forEach((row) => { + if (row.canApprove) batchApprovalTableRef.value?.toggleRowSelection(row, true) + }) + } catch (error) { + batchApprovalError.value = getApiErrorDetails(error, '批量审核预览加载失败,请刷新订单后重试') + } finally { + batchApprovalLoading.value = false + } +} + +async function batchApproveOrders() { + const selected = [...batchApprovalSelectedRows.value] + if (!selected.length || batchApprovalSaving.value) return + batchApprovalSaving.value = true + try { + const groups = new Map() + selected.forEach((order) => { + const tenantId = String(order.customerId) + groups.set(tenantId, [...(groups.get(tenantId) || []), order]) + }) let successCount = 0 let failureCount = 0 const failures: string[] = [] for (const [tenantId, orders] of groups) { try { - const result = await batchApprovePlatformUnifiedOrders(tenantId, orders.map((order) => String(order.orderId))) + const result = await batchApprovePlatformUnifiedOrders(tenantId, orders.map((order) => ({ + orderId: order.orderId, + orderVersion: order.orderVersion, + }))) successCount += result.successCount failureCount += result.failureCount failures.push(...result.failedOrders.map((failure) => failure.message)) @@ -1838,13 +1882,12 @@ async function batchApproveOrders() { failures.push(getApiErrorDetails(error, '该客户订单批量审核失败').message) } } + batchApprovalOpen.value = false await loadOrders() const reason = [...new Set(failures.filter(Boolean))].slice(0, 3).join(';') if (!failureCount) ElMessage.success(`已批量审核通过 ${successCount} 个订单`) else if (successCount) ElMessage.warning(`批量审核完成:成功 ${successCount} 个,失败 ${failureCount} 个${reason ? `。${reason}` : ''}`) else ElMessage.error(`批量审核失败:${failureCount} 个订单未通过${reason ? `。${reason}` : ''}`) - } catch (error) { - if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '批量审核失败').message) } finally { batchApprovalSaving.value = false } @@ -2243,6 +2286,7 @@ onBeforeUnmount(() => { @@ -2464,7 +2508,7 @@ onBeforeUnmount(() => { + + + + +
{{ row.inboundCode || '待生成入仓码' }}
@@ -2675,6 +2735,9 @@ onBeforeUnmount(() => { .subsequent-fulfillment { min-height: 72px; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--cc-border); } .subsequent-fulfillment h3 { margin: 0 0 10px; font-size: 15px; } .fulfillment-facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0 0 10px; border: 1px solid var(--cc-border); border-radius: 6px; overflow: hidden; } +.batch-approval-summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 12px 0 8px; color: var(--cc-muted); font-size: 13px; white-space: nowrap; } +.batch-approval-summary strong { color: var(--cc-text); } +.batch-approval-table :deep(.cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .fulfillment-facts > div { min-width: 0; padding: 9px 10px; border-right: 1px solid var(--cc-border); background: #fff; } .fulfillment-facts > div.wide { grid-column: span 2; } .fulfillment-facts dt { color: var(--cc-muted); font-size: 12px; }