From 20bde6c72dcfa9be87c3b5b99f5053131e1b6f14 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 4 Nov 2026 14:11:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=A7=81=E4=BA=BA=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E4=BA=BA=E5=B7=A5=E6=8A=A5=E4=BB=B7=E5=8F=8A=E6=B5=B7?= =?UTF-8?q?=E5=A4=96=E5=85=A5=E5=BA=93=E6=89=98=E7=9B=98=E6=89=93=E5=8D=B0?= =?UTF-8?q?=E5=92=8C=E7=9F=AD=E5=B0=91=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/business-index.md | 2 +- src/modules/inbounds/AGENTS.md | 4 +- .../components/InboundWorkflowWorkspace.vue | 1 + .../inbounds/fee/InboundFeePanel.test.ts | 92 ++++++++++++- src/modules/inbounds/fee/InboundFeePanel.vue | 124 ++++++++++++++++-- .../InboundShortageConfirmation.test.ts | 11 ++ .../InboundShortageConfirmation.vue | 2 +- .../InboundOrderDetailView.shortage.test.ts | 1 + .../pages/InboundOrderDetailView.test.ts | 38 ++++++ .../pages/InboundOrderDetailView.vue | 38 +++++- .../pages/UnifiedOrdersView.vue | 1 + 11 files changed, 292 insertions(+), 22 deletions(-) diff --git a/docs/business-index.md b/docs/business-index.md index b687483a..a645e035 100644 --- a/docs/business-index.md +++ b/docs/business-index.md @@ -18,7 +18,7 @@ | 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` | 三个入口 `/self-operated/sales`、`/self-operated/inventory`、`/self-operated/collection`;后端 `lastleg-service/selfoperated/analytics/`,正式合同见 `../../docs/自营业务/README.md`。 | | 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 | | 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg | -| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 | +| FBA 与私人地址人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。要求已有实际测量。FBA 价格未命中及私人地址 PRIVATE 允许人工头程报价,客户已确认费用禁止改价。`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 | | PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 | | 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 | | 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI | diff --git a/src/modules/inbounds/AGENTS.md b/src/modules/inbounds/AGENTS.md index 0f94bf45..aba04713 100644 --- a/src/modules/inbounds/AGENTS.md +++ b/src/modules/inbounds/AGENTS.md @@ -15,7 +15,7 @@ | 测量数据审核 | `pages/MeasurementReviewsView.vue`、`review/MeasurementReviewPanel.vue`;`inbounds/measurement-reviews` | `review/api.ts`;`firstleg/service/MeasurementReviewService.java`、`firstleg/service/InitialInboundPricingService.java` | | 称重图片、OCR 与库位槽状态 | `weight/components/WeightOcrPanel.vue`、`weight/components/WeightImageThumbnail.vue` | `weight/api/weightOcr.ts`;`firstleg/service/MeasurementLocationOccupationService.java`、`firstleg/service/MeasurementSubmissionService.java` | | 入库单列表、摘要与历史 | `pages/InboundOrdersView.vue`、`core/InboundSummaryPanel.vue`、`core/InboundHistoryPanel.vue`;`inbounds` | `core/api.ts`、`core/types.ts`;`firstleg/service/InboundOrderQueryService.java` | -| FBA 人工定价(无匹配价格) | `fee/InboundFeePanel.vue` | `fee/api.ts` 的 `/manual-quote`;`firstleg/service/FbaPricingService.java` | +| FBA 与私人地址人工定价 | `fee/InboundFeePanel.vue` | `fee/api.ts` 的 `/manual-quote`;`firstleg/service/FbaPricingService.java` | | 一件代发托盘打印 | `core/DropshipPalletPrintPanel.vue` | `core/api.ts`;`print/`;仅一件代发订单展示 | ## 关键边界 @@ -23,7 +23,7 @@ - 客户四档箱规与默认算法(`ADD/MULTIPLY`)的规则正文在详细索引维护;本模块只按 `../dimension-config/` 消费,保存后以外箱尺寸、体积重和计费基数只认服务端返回值,页面不独立重算。 - 费用确认的实际箱数、毛重和体积取值口径以详细索引“入库、测量”节(`actualMeasurementRows`)为准,本文件不复制该规则。 - 称重卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或推导整票预留引导;删除图片须提示该槽位仍归本票保留,不按图片下标推导托盘或库位。 -- 人工定价仅 FBA、已有实际测量且价格未命中时开放,客户已确认费用禁止改价;`MANUAL_QUOTE` 不伪造价格簿关联。 +- 人工定价要求已有实际测量。FBA 在价格未命中时开放,私人地址 PRIVATE 使用人工头程报价。客户已确认费用禁止改价,`MANUAL_QUOTE` 保留人工报价来源。 - 扣货与解除、凭证下载走 `../unified-orders/` 与 `../inbounds/` 的正式入口,本模块不另建凭证链路。 ## 验证 diff --git a/src/modules/inbounds/components/InboundWorkflowWorkspace.vue b/src/modules/inbounds/components/InboundWorkflowWorkspace.vue index 162b4634..6664f26d 100644 --- a/src/modules/inbounds/components/InboundWorkflowWorkspace.vue +++ b/src/modules/inbounds/components/InboundWorkflowWorkspace.vue @@ -164,6 +164,7 @@ onBeforeUnmount(() => { :key="`fee-${revision}`" :tenant-id="customerId" :inbound-id="inboundId" + :destination-type="detail.summary.destinationType" @changed="handleDomainChanged" /> diff --git a/src/modules/inbounds/fee/InboundFeePanel.test.ts b/src/modules/inbounds/fee/InboundFeePanel.test.ts index 729132ae..a24ba6f9 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.test.ts +++ b/src/modules/inbounds/fee/InboundFeePanel.test.ts @@ -10,12 +10,18 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' import { adjustPublishedInboundFee, + calculateInboundFee, confirmInboundFee, + createManualInboundQuote, getLatestInboundFee, + previewInboundFee, sendInboundFee, } from './api' import type { InboundFeeSnapshot } from './types' import InboundFeePanel from './InboundFeePanel.vue' +import { getInboundMeasurement } from '../measurement/api' + +vi.mock('../measurement/api', () => ({ getInboundMeasurement: vi.fn() })) vi.mock('./api', async () => { const actual = await vi.importActual('./api') @@ -23,6 +29,9 @@ vi.mock('./api', async () => { ...actual, getLatestInboundFee: vi.fn(), adjustPublishedInboundFee: vi.fn(), + calculateInboundFee: vi.fn(), + createManualInboundQuote: vi.fn(), + previewInboundFee: vi.fn(), confirmInboundFee: vi.fn(), sendInboundFee: vi.fn(), } @@ -66,11 +75,11 @@ function mountPanel(permissions = [ 'firstleg:fba-pricing:adjust', 'firstleg:fba-pricing:confirm', 'firstleg:inbound:send-fee', -]) { +], extraProps: { destinationType?: string | null } = {}) { const pinia = setupPermissions(permissions) return mount(InboundFeePanel, { attachTo: document.body, - props: { customerId: '10001', inboundId: '88' }, + props: { customerId: '10001', inboundId: '88', ...extraProps }, global: { plugins: [pinia, ElementPlus] }, }) } @@ -83,6 +92,85 @@ describe('InboundFeePanel', () => { vi.mocked(adjustPublishedInboundFee).mockResolvedValue({ ...snapshot, feeVersion: 3, feeNo: 'FBAF-88-3' }) vi.mocked(confirmInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CONFIRMED', confirmedAt: '2026-08-04T10:01:00', sentAt: null }) vi.mocked(sendInboundFee).mockResolvedValue(snapshot) + vi.mocked(previewInboundFee).mockResolvedValue({ chargeableQuantity: 64.128, chargeUnit: 'KG', + currency: 'CNY', priceMatched: false } as Awaited>) + }) + + it('saves a private-address manual quote through the existing fee dialog', async () => { + vi.mocked(getLatestInboundFee).mockRejectedValueOnce(new ApiResponseError('费用快照不存在', 'A0404', 404)) + vi.mocked(getInboundMeasurement).mockResolvedValue({ + items: [{ totalWeightKg: 70.5, totalVolumeCbm: 0.384 }], + actualNetWeightKg: 62.5, actualGrossWeightKg: 70.5, actualVolumeCbm: 0.384, + } as Awaited>) + vi.mocked(createManualInboundQuote).mockRejectedValueOnce(new Error('网络暂时不可用')) + .mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', rateCode: 'MANUAL_QUOTE' }) + const wrapper = mountPanel(['firstleg:fba-pricing:view', 'firstleg:inbound:confirm-send-fee'], { destinationType: 'PRIVATE' }) + await flushPromises() + ;[...document.querySelectorAll('button')] + .find(button => button.textContent?.includes('人工头程报价'))!.click() + await flushPromises() + const save = [...document.querySelectorAll('button')] + .find(button => button.textContent?.includes('保存人工报价'))! + expect(document.body.textContent).toContain('64.128 KG') + expect(document.querySelectorAll('.trial-source-grid input[type="radio"]')).toHaveLength(2) + expect(save.disabled).toBe(true) + const price = document.querySelector('input[aria-label="人工单价"]')! + price.value = '1.5' + price.dispatchEvent(new Event('input', { bubbles: true })) + price.dispatchEvent(new Event('change', { bubbles: true })) + const reason = document.querySelector('textarea[aria-label="报价原因"]')! + reason.value = '私人地址测试报价' + reason.dispatchEvent(new Event('input', { bubbles: true })) + await flushPromises() + expect(save.disabled).toBe(false) + save.click() + await flushPromises() + expect(wrapper.emitted('changed')).toBeUndefined() + const originalKey = vi.mocked(createManualInboundQuote).mock.calls[0]![3] + save.click() + await flushPromises() + expect(createManualInboundQuote).toHaveBeenCalledWith('10001', '88', { + selectedWeightKg: 62.5, selectedVolumeCbm: 0.384, unitPrice: 1.5, reason: '私人地址测试报价', + }, expect.any(String)) + expect(calculateInboundFee).not.toHaveBeenCalled() + expect(createManualInboundQuote).toHaveBeenCalledTimes(2) + expect(vi.mocked(createManualInboundQuote).mock.calls[1]![3]).toBe(originalKey) + expect(wrapper.emitted('changed')).toHaveLength(1) + }) + + it('requires the manual quote permission for private-address pricing', async () => { + mountPanel(['firstleg:fba-pricing:view', 'firstleg:fba-pricing:calculate'], { destinationType: 'PRIVATE' }) + await flushPromises() + expect([...document.querySelectorAll('button')].some(button => button.textContent?.includes('人工头程报价'))).toBe(false) + }) + + it('allows retrying a failed server quantity preview before a private quote', async () => { + vi.mocked(getInboundMeasurement).mockResolvedValue({ + items: [{ totalWeightKg: 70.5, totalVolumeCbm: 0.384 }], + actualNetWeightKg: 62.5, actualGrossWeightKg: 70.5, actualVolumeCbm: 0.384, + } as Awaited>) + vi.mocked(previewInboundFee).mockRejectedValueOnce(new Error('计费量暂时不可读')) + mountPanel(['firstleg:fba-pricing:view', 'firstleg:inbound:confirm-send-fee'], { destinationType: 'PRIVATE' }) + await flushPromises() + ;[...document.querySelectorAll('button')] + .find(button => button.textContent?.includes('人工头程报价'))!.click() + await flushPromises() + const save = [...document.querySelectorAll('button')] + .find(button => button.textContent?.includes('保存人工报价'))! + expect(save.disabled).toBe(true) + ;[...document.querySelectorAll('button')] + .find(button => button.textContent?.includes('刷新计费量'))!.click() + await flushPromises() + expect(previewInboundFee).toHaveBeenCalledTimes(2) + expect(document.body.textContent).toContain('64.128 KG') + expect(createManualInboundQuote).not.toHaveBeenCalled() + }) + + it('keeps confirmed private-address fees locked for repricing', async () => { + vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, customerConfirmationStatus: 'CONFIRMED' }) + mountPanel(['firstleg:fba-pricing:view', 'firstleg:inbound:confirm-send-fee'], { destinationType: 'PRIVATE' }) + await flushPromises() + expect([...document.querySelectorAll('button')].some(button => button.textContent?.includes('人工头程报价'))).toBe(false) }) it('shows the immutable calculation basis, versions, amount, and timestamps', async () => { diff --git a/src/modules/inbounds/fee/InboundFeePanel.vue b/src/modules/inbounds/fee/InboundFeePanel.vue index dc285b06..e7ca8670 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.vue +++ b/src/modules/inbounds/fee/InboundFeePanel.vue @@ -19,12 +19,14 @@ import { adjustPublishedInboundFee, calculateInboundFee, confirmInboundFee, + createManualInboundQuote, createFeeActionIdempotencyKey, createFeeAdjustmentIdempotencyKey, getLatestInboundFee, + previewInboundFee, sendInboundFee, } from './api' -import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types' +import type { AdjustPublishedInboundFeeRequest, InboundFeePreview, InboundFeeSnapshot } from './types' import { getInboundMeasurement } from '../measurement/api' import type { InboundMeasurementDetail } from '../measurement/types' @@ -32,6 +34,7 @@ const props = defineProps<{ customerId?: string tenantId?: string inboundId: string + destinationType?: string | null compact?: boolean displayOnly?: boolean }>() @@ -55,7 +58,17 @@ const adjustOpen = ref(false) const trialOpen = ref(false) const trialLoading = ref(false) const measurement = ref(null) -const trial = reactive({ weightSource: 'ACTUAL', volumeSource: 'ACTUAL' }) +const trial = reactive({ + weightSource: 'ACTUAL', + volumeSource: 'ACTUAL', + unitPrice: undefined as number | undefined, + reason: '', +}) +let manualQuoteIdempotencyKey = '' +let manualQuoteFingerprint = '' +const trialPreview = ref(null) +const trialPreviewLoading = ref(false) +let trialPreviewSerial = 0 const adjustFormRef = ref() let loadSequence = 0 let adjustmentFingerprint = '' @@ -84,6 +97,17 @@ const canAdjust = computed(() => access.can('firstleg:fba-pricing:adjust')) const canConfirm = computed(() => access.can('firstleg:fba-pricing:confirm')) const canSend = computed(() => access.can('firstleg:inbound:send-fee')) const canCalculate = computed(() => access.can('firstleg:fba-pricing:calculate')) +const isPrivateAddress = computed(() => props.destinationType === 'PRIVATE') +const canManualQuote = computed(() => access.can('firstleg:inbound:confirm-send-fee')) +const canRunTrial = computed(() => isPrivateAddress.value + ? canManualQuote.value && snapshot.value?.customerConfirmationStatus !== 'CONFIRMED' + : canCalculate.value) +const manualQuoteValid = computed(() => !isPrivateAddress.value || ( + Boolean(measurement.value?.items.length) && Number(trialPreview.value?.chargeableQuantity) > 0 + && !trialPreviewLoading.value && Number(trial.unitPrice) > 0 + && Number.isFinite(Number(trial.unitPrice)) && trial.reason.trim().length >= 2 + && trial.reason.trim().length <= 500 +)) const customerContextId = computed(() => props.customerId || props.tenantId || '') const normalizedInboundId = computed(() => { const identifier = String(props.inboundId ?? '').trim() @@ -107,12 +131,13 @@ const sourceOptions = computed(() => { if (!value) return [] const itemWeight = value.items.reduce((sum, item) => sum + Number(item.totalWeightKg || 0), 0) const itemVolume = value.items.reduce((sum, item) => sum + Number(item.totalVolumeCbm || 0), 0) - return [ + const options = [ { value: 'CUSTOMER', label: '客户下单数据', weight: Number(value.originalGrossWeightKg || 0), volume: Number(value.originalVolumeCbm || 0) }, { value: 'OUTER', label: '外箱规数据', weight: itemWeight, volume: itemVolume }, { value: 'ACTUAL', label: '实际测量数据', weight: Number(value.actualNetWeightKg || value.actualGrossWeightKg || 0), volume: Number(value.actualVolumeCbm || 0) }, { value: 'WAREHOUSE', label: '仓库建议数据', weight: itemWeight, volume: itemVolume }, ] + return isPrivateAddress.value ? options.filter(item => item.value === 'ACTUAL') : options }) function feeStatusInfo(status?: string) { @@ -229,20 +254,72 @@ function openAdjustment() { async function openTrial() { trialLoading.value = true - try { measurement.value = await getInboundMeasurement(customerContextId.value, normalizedInboundId.value); trial.weightSource = 'ACTUAL'; trial.volumeSource = 'ACTUAL'; trialOpen.value = true } + adjustmentError.value = null + try { + measurement.value = await getInboundMeasurement(customerContextId.value, normalizedInboundId.value) + trial.weightSource = 'ACTUAL' + trial.volumeSource = 'ACTUAL' + trial.unitPrice = undefined + trial.reason = '' + manualQuoteIdempotencyKey = '' + manualQuoteFingerprint = '' + trialOpen.value = true + await loadTrialPreview() + } catch (error) { ElMessage.error(getApiErrorDetails(error, '测量数据加载失败,无法试算').message) } finally { trialLoading.value = false } } +async function loadTrialPreview() { + if (!isPrivateAddress.value) return + adjustmentError.value = null + const serial = ++trialPreviewSerial + trialPreview.value = null + trialPreviewLoading.value = false + const weight = sourceOptions.value.find(item => item.value === trial.weightSource) + const volume = sourceOptions.value.find(item => item.value === trial.volumeSource) + if (!weight?.weight || !volume?.volume) return + trialPreviewLoading.value = true + try { + const result = await previewInboundFee(customerContextId.value, normalizedInboundId.value, { + selectedWeightKg: weight.weight, selectedVolumeCbm: volume.volume, + weightSource: weight.value, volumeSource: volume.value, selectedUnitPrice: null, + }) + if (serial === trialPreviewSerial) trialPreview.value = result + } catch (error) { + if (serial === trialPreviewSerial) adjustmentError.value = getApiErrorDetails(error, '计费量读取失败,请重试') + } finally { + if (serial === trialPreviewSerial) trialPreviewLoading.value = false + } +} + async function submitTrial() { + if (!canRunTrial.value || !manualQuoteValid.value) return const weight = sourceOptions.value.find((item) => item.value === trial.weightSource) const volume = sourceOptions.value.find((item) => item.value === trial.volumeSource) if (!weight?.weight || !volume?.volume) { ElMessage.warning('所选重量或体积没有有效数据'); return } + adjustmentError.value = null trialLoading.value = true try { - const result = await calculateInboundFee(customerContextId.value, normalizedInboundId.value, { selectedWeightKg: weight.weight, selectedVolumeCbm: volume.volume, weightSource: weight.value, volumeSource: volume.value }, createFeeActionIdempotencyKey('calculate')) - snapshot.value = result; trialOpen.value = false; emit('changed', result); ElMessage.success('费用试算已更新') - } catch (error) { adjustmentError.value = getApiErrorDetails(error, '费用试算失败') } + const selection = { selectedWeightKg: weight.weight, selectedVolumeCbm: volume.volume } + let result: InboundFeeSnapshot + if (isPrivateAddress.value) { + const request = { ...selection, unitPrice: Number(trial.unitPrice), reason: trial.reason.trim() } + const fingerprint = JSON.stringify([customerContextId.value, normalizedInboundId.value, request]) + if (!manualQuoteIdempotencyKey || fingerprint !== manualQuoteFingerprint) { + manualQuoteFingerprint = fingerprint + manualQuoteIdempotencyKey = createFeeActionIdempotencyKey('manual') + } + result = await createManualInboundQuote(customerContextId.value, normalizedInboundId.value, request, manualQuoteIdempotencyKey) + } else { + result = await calculateInboundFee(customerContextId.value, normalizedInboundId.value, + { ...selection, weightSource: weight.value, volumeSource: volume.value }, createFeeActionIdempotencyKey('calculate')) + } + snapshot.value = result + trialOpen.value = false + emit('changed', result) + ElMessage.success(isPrivateAddress.value ? '人工报价已保存' : '费用试算已更新') + } catch (error) { adjustmentError.value = getApiErrorDetails(error, isPrivateAddress.value ? '人工报价失败' : '费用试算失败') } finally { trialLoading.value = false } } @@ -401,12 +478,12 @@ defineExpose({ reload: loadFee }) > 费用试算 + >{{ isPrivateAddress ? '人工头程报价' : '费用试算' }} - +
-

计费重量(单选)

重量和体积各选择一项;默认选择实际测量数据。

{{ item.label }}{{ formatNumber(item.weight) }} KG
-

计费体积(单选)

{{ item.label }}{{ formatNumber(item.volume, 6) }} CBM
+

计费重量(单选)

重量和体积各选择一项;默认选择实际测量数据。

{{ item.label }}{{ formatNumber(item.weight) }} KG
+

计费体积(单选)

{{ item.label }}{{ formatNumber(item.volume, 6) }} CBM
+ + + {{ trialPreview ? `${formatNumber(trialPreview.chargeableQuantity, 6)} ${trialPreview.chargeUnit}` : '—' }} + 刷新计费量 + + + + + + + +
@@ -545,7 +643,7 @@ defineExpose({ reload: loadFee })
起运仓
{{ snapshot.originWarehouseName }}
渠道
{{ snapshot.channelCode }}
-
FBA 目的仓
{{ snapshot.destinationCode }}
+
{{ isPrivateAddress ? '目的地' : 'FBA 目的仓' }}
{{ isPrivateAddress ? '私人地址' : snapshot.destinationCode || '—' }}
价格有效期
{{ formatDateTime(snapshot.rateEffectiveFrom) }} 至 {{ formatDateTime(snapshot.rateEffectiveTo) }}
diff --git a/src/modules/overseas-warehouse/components/InboundShortageConfirmation.test.ts b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.test.ts index b4af8b14..bb0b95af 100644 --- a/src/modules/overseas-warehouse/components/InboundShortageConfirmation.test.ts +++ b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.test.ts @@ -51,6 +51,17 @@ describe('入库短少确认', () => { access.can.mockReturnValue(false) const wrapper = render(); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount() }) + it('allows ordinary overseas carton shortage confirmation with the same versioned payload', async () => { + const wrapper = render(detail({ lines: [{ id: '3', orderId: '2', fbaWarehouseCode: null, requiredPackageCount: 21, plannedPalletCount: 2 }] })) + await wrapper.get('button').trigger('click') + await wrapper.get('textarea').setValue('逐箱核对短少1箱') + await wrapper.get('[data-confirm]').trigger('click'); await flushPromises() + expect(confirmInboundShortage).toHaveBeenCalledWith('IP-1', expect.objectContaining({ + expectedPackageCount: 21, receivedPackageCount: 20, reason: '逐箱核对短少1箱', + }), expect.any(String)) + expect(wrapper.emitted('refresh')).toHaveLength(1) + wrapper.unmount() + }) it.each([{ status: 'PARTIAL_INBOUND' }, { pickedPackageCount: 21 }, { pickedPackageCount: 22 }, { pickedPackageCount: 0 }, { version: undefined }, { orderType: 'ONE_CLICK_DROPSHIP' }])('rejects ineligible facts %o', (overrides) => { const wrapper = render(detail(overrides)); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount() }) diff --git a/src/modules/overseas-warehouse/components/InboundShortageConfirmation.vue b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.vue index 3bf237c9..201b9cc1 100644 --- a/src/modules/overseas-warehouse/components/InboundShortageConfirmation.vue +++ b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.vue @@ -22,7 +22,7 @@ const shortage = computed(() => (expected.value ?? 0) - (received.value ?? 0)) const eligible = computed(() => access.can(overseasWarehousePermissions.inboundOrderEdit) && props.detail.status === 'PENDING_RECONCILIATION' && props.detail.version != null && props.detail.orderType !== 'ONE_CLICK_DROPSHIP' && props.detail.lines.length > 0 - && props.detail.lines.every(line => !!line.fbaWarehouseCode) + && (props.detail.lines.every(line => !!line.fbaWarehouseCode) || props.detail.lines.every(line => !line.fbaWarehouseCode)) && expected.value != null && received.value != null && received.value > 0 && shortage.value > 0) function show() { diff --git a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.shortage.test.ts b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.shortage.test.ts index b511def1..d9be3861 100644 --- a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.shortage.test.ts +++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.shortage.test.ts @@ -5,6 +5,7 @@ import { getInboundPlan } from '../api/inboundPlans' import InboundOrderDetailView from './InboundOrderDetailView.vue' vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: '9007199254741001' } }), useRouter: () => ({ push: vi.fn() }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() })) vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null })) vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { name: 'InboundShortageConfirmation', props: ['detail'], emits: ['refresh'], template: '' } })) diff --git a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.test.ts b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.test.ts index fda9278a..5fcc40ed 100644 --- a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.test.ts +++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.test.ts @@ -2,10 +2,14 @@ import ElementPlus from 'element-plus' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { getInboundPlan } from '../api/inboundPlans' +import { getPalletLabelPdf } from '../api/pallets' import InboundOrderDetailView from './InboundOrderDetailView.vue' const route = vi.hoisted(() => ({ params: { orderId: 'PLAN-1' }, query: { customerId: '9007199254741001' } as Record })) +const access = vi.hoisted(() => ({ can: vi.fn(() => true) })) vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ push: vi.fn() }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => access })) +vi.mock('../api/pallets', () => ({ getPalletLabelPdf: vi.fn() })) vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() })) vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null })) vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { template: '' } })) @@ -15,6 +19,7 @@ vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: { describe('InboundOrderDetailView fees', () => { beforeEach(() => { + access.can.mockReturnValue(true) route.query = { customerId: '9007199254741001' } vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254741002', planNo: 'PLAN-1', customerId: '9007199254741001', sourceOrderId: '9007199254741003', @@ -41,4 +46,37 @@ describe('InboundOrderDetailView fees', () => { expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).exists()).toBe(false) wrapper.unmount() }) + + it('prints the exact pre-reserved overseas pallet from the inbound plan before receiving', async () => { + vi.mocked(getInboundPlan).mockResolvedValue({ + id: '1', planNo: 'PLAN-1', customerId: '100', sourceOrderId: '2', overseasWarehouseId: '900', + status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, + lines: [{ id: '10', orderId: '2', inboundCode: 'IN-1', requiredPackageCount: 2, plannedPalletCount: 1 }], + pallets: [{ id: '20', warehousePalletId: '2001', palletNo: 'PLT-21', sequenceNo: 1, actualPackageCount: 0, version: 0 }], + allocations: [{ lineId: '10', palletId: '20', palletNo: 'PLT-21', palletOwnerPlanNo: 'PLAN-1', palletOwnedByCurrentPlan: true, + plannedPackageCount: 2, countedPackageCount: 0, pickedPackageCount: 0, version: 0 }], + }) + vi.mocked(getPalletLabelPdf).mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' })) + URL.createObjectURL = vi.fn(() => 'blob:reserved-pallet') + URL.revokeObjectURL = vi.fn() + const downloads: string[] = [] + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { downloads.push(this.download) }) + const options = { global: { plugins: [ElementPlus], stubs: { + DataTable: { props: ['data'], template: '
' }, + AppDialog: true, InboundShortageConfirmation: true, + } } } + const wrapper = mount(InboundOrderDetailView, options) + await flushPromises() + const print = wrapper.findAll('button').find(button => button.text() === '打印托盘码')! + expect(print.attributes('disabled')).toBeUndefined() + await print.trigger('click'); await flushPromises() + expect(getPalletLabelPdf).toHaveBeenCalledWith('900', '2001') + expect(downloads).toEqual(['PLT-21-托盘码.pdf']) + wrapper.unmount() + access.can.mockReturnValue(false) + const denied = mount(InboundOrderDetailView, options) + await flushPromises() + expect(denied.findAll('button').some(button => button.text() === '打印托盘码')).toBe(false) + denied.unmount(); click.mockRestore() + }) }) diff --git a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue index 677ed035..92a393e1 100644 --- a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue @@ -7,23 +7,30 @@ import { BusinessDocumentFees } from '@/modules/overseas-business' import { computed, onMounted, ref } from 'vue' import { ElMessage } from 'element-plus' import { useRoute, useRouter } from 'vue-router' +import { useAccessPreviewStore } from '@/core/access/accessPreview' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import AppDialog from '@/shared/components/AppDialog.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' +import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue' import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans' import { listOverseasLocations, type OverseasLocationDto } from '../api/locations' +import { getPalletLabelPdf } from '../api/pallets' +import { overseasWarehousePermissions } from '../permissions' import OverseasDetailShell from '../components/OverseasDetailShell.vue' import InboundShortageConfirmation from '../components/InboundShortageConfirmation.vue' import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation' const route = useRoute(); const router = useRouter() +const access = useAccessPreviewStore() const planNo = computed(() => String(route.params.orderId || '')) const customerId = computed(() => String(route.query.customerId || '')) const detail = ref(null); const loading = ref(false); const loadError = ref(null) const moveOpen = ref(false); const moveSaving = ref(false); const moveRow = ref<{ palletNo: string; palletVersion: number } | null>(null); const targetLocationCode = ref(''); const moveReason = ref('入库托盘上架'); const locationOptions = ref([]) const moveLocationsLoading = ref(false); const moveLocationError = ref(null); const moveError = ref(null) +const printingPalletId = ref('') +const printError = ref(null) const referenceValue = computed(() => detail.value?.planNo || (/^\d+$/.test(planNo.value) ? '—' : planNo.value)) function text(value: unknown) { return String(value ?? '').trim() || '—' } function receivingWarehouse(code: unknown, name: unknown) { @@ -47,7 +54,7 @@ const summaryGroups = [ ] const appointmentFields = ['预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注'] const palletColumns: DataTableColumn[] = [ - { key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, + { key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 }, { key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 }, { key: 'putawayStatus', label: '上架状态', width: 110, prop: 'putawayStatus', afterColumn: 'palletNo' }, @@ -67,11 +74,34 @@ const palletRows = computed(() => (detail.value?.allocations || []).map((allocat palletNo: text(allocation.palletNo), palletOwner: allocation.palletOwnedByCurrentPlan ? `${allocation.palletOwnerPlanNo}(本计划)` : `${allocation.palletOwnerPlanNo}(跨计划合托)`, packageCount: Number(allocation.pickedPackageCount ?? allocation.countedPackageCount ?? allocation.plannedPackageCount ?? 0), - palletId: String(allocation.palletId), palletVersion: pallet?.version ?? null, sourceLocationId: pallet?.warehouseLocationId || null, sourceLocationCode: locationCode, + palletId: String(allocation.palletId), warehousePalletId: pallet?.warehousePalletId || null, palletVersion: pallet?.version ?? null, sourceLocationId: pallet?.warehouseLocationId || null, sourceLocationCode: locationCode, sourceLocationVersion: null, location: locationCode || (pallet?.warehouseLocationId ? '—' : '虚拟收货位'), locationType: locationType === 'SHARED_OVERFLOW' ? 'A0共享溢出位' : locationType === 'NORMAL' ? '普通库位' : '虚拟收货位', putawayStatus: locationType === 'NORMAL' || locationType === 'SHARED_OVERFLOW' ? '已上架' : '待上架', } })) const availableLocations = computed(() => locationOptions.value.filter(item => item.status !== 'DISABLED')) +function palletActions(row: typeof palletRows.value[number]): RowActionItem[] { + return [ + { key: 'print', label: '打印托盘码', visible: access.can(overseasWarehousePermissions.palletList), + disabled: !!printingPalletId.value || !row.warehousePalletId || !detail.value?.overseasWarehouseId, + loading: !!row.warehousePalletId && printingPalletId.value === row.warehousePalletId }, + { key: 'move', label: '移库', disabled: moveSaving.value || moveLocationsLoading.value || row.palletNo === '—' || row.palletVersion == null }, + ] +} +async function printPalletLabel(row: typeof palletRows.value[number]) { + const warehouseId = detail.value?.overseasWarehouseId + if (!access.can(overseasWarehousePermissions.palletList) || printingPalletId.value || !warehouseId || !row.warehousePalletId) return + printingPalletId.value = row.warehousePalletId; printError.value = null + try { + const blob = await getPalletLabelPdf(warehouseId, row.warehousePalletId) + const url = URL.createObjectURL(blob) + try { + const link = document.createElement('a') + link.href = url; link.download = `${row.palletNo}-托盘码.pdf`; link.click() + } finally { window.setTimeout(() => URL.revokeObjectURL(url), 0) } + ElMessage.success('已生成 1 张托盘标签 PDF') + } catch (cause) { printError.value = getApiErrorDetails(cause, '托盘标签打印失败,请重试') } + finally { printingPalletId.value = '' } +} async function openMove(row: typeof palletRows.value[number]) { if (moveSaving.value || moveLocationsLoading.value || row.palletVersion == null || row.palletNo === '—') return if (!detail.value?.overseasWarehouseCode) { ElMessage.warning('入库单未返回海外仓范围,暂不能移库'); return } @@ -99,7 +129,9 @@ onMounted(load)