支持私人地址人工报价及海外入库托盘打印和短少确认

This commit is contained in:
2026-11-04 14:11:26 +08:00
parent 84dd9dcbb4
commit 20bde6c72d
11 changed files with 292 additions and 22 deletions
+1 -1
View File
@@ -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/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/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg | | 预约管理、预约列表、跨客户同仓预约 | `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 的更新工具 | | 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/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI | | 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
+2 -2
View File
@@ -15,7 +15,7 @@
| 测量数据审核 | `pages/MeasurementReviewsView.vue`、`review/MeasurementReviewPanel.vue`;`inbounds/measurement-reviews` | `review/api.ts`;`firstleg/service/MeasurementReviewService.java`、`firstleg/service/InitialInboundPricingService.java` | | 测量数据审核 | `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` | | 称重图片、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` | | 入库单列表、摘要与历史 | `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/`;仅一件代发订单展示 | | 一件代发托盘打印 | `core/DropshipPalletPrintPanel.vue` | `core/api.ts`;`print/`;仅一件代发订单展示 |
## 关键边界 ## 关键边界
@@ -23,7 +23,7 @@
- 客户四档箱规与默认算法(`ADD/MULTIPLY`)的规则正文在详细索引维护;本模块只按 `../dimension-config/` 消费,保存后以外箱尺寸、体积重和计费基数只认服务端返回值,页面不独立重算。 - 客户四档箱规与默认算法(`ADD/MULTIPLY`)的规则正文在详细索引维护;本模块只按 `../dimension-config/` 消费,保存后以外箱尺寸、体积重和计费基数只认服务端返回值,页面不独立重算。
- 费用确认的实际箱数、毛重和体积取值口径以详细索引“入库、测量”节(`actualMeasurementRows`)为准,本文件不复制该规则。 - 费用确认的实际箱数、毛重和体积取值口径以详细索引“入库、测量”节(`actualMeasurementRows`)为准,本文件不复制该规则。
- 称重卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或推导整票预留引导;删除图片须提示该槽位仍归本票保留,不按图片下标推导托盘或库位。 - 称重卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或推导整票预留引导;删除图片须提示该槽位仍归本票保留,不按图片下标推导托盘或库位。
- 人工定价仅 FBA、已有实际测量且价格未命中时开放,客户已确认费用禁止改价;`MANUAL_QUOTE` 不伪造价格簿关联。 - 人工定价要求已有实际测量。FBA 在价格未命中时开放,私人地址 PRIVATE 使用人工头程报价。客户已确认费用禁止改价,`MANUAL_QUOTE` 保留人工报价来源。
- 扣货与解除、凭证下载走 `../unified-orders/` 与 `../inbounds/` 的正式入口,本模块不另建凭证链路。 - 扣货与解除、凭证下载走 `../unified-orders/` 与 `../inbounds/` 的正式入口,本模块不另建凭证链路。
## 验证 ## 验证
@@ -164,6 +164,7 @@ onBeforeUnmount(() => {
:key="`fee-${revision}`" :key="`fee-${revision}`"
:tenant-id="customerId" :tenant-id="customerId"
:inbound-id="inboundId" :inbound-id="inboundId"
:destination-type="detail.summary.destinationType"
@changed="handleDomainChanged" @changed="handleDomainChanged"
/> />
</el-tab-pane> </el-tab-pane>
@@ -10,12 +10,18 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth' import { useAuthStore } from '@/core/auth/auth'
import { import {
adjustPublishedInboundFee, adjustPublishedInboundFee,
calculateInboundFee,
confirmInboundFee, confirmInboundFee,
createManualInboundQuote,
getLatestInboundFee, getLatestInboundFee,
previewInboundFee,
sendInboundFee, sendInboundFee,
} from './api' } from './api'
import type { InboundFeeSnapshot } from './types' import type { InboundFeeSnapshot } from './types'
import InboundFeePanel from './InboundFeePanel.vue' import InboundFeePanel from './InboundFeePanel.vue'
import { getInboundMeasurement } from '../measurement/api'
vi.mock('../measurement/api', () => ({ getInboundMeasurement: vi.fn() }))
vi.mock('./api', async () => { vi.mock('./api', async () => {
const actual = await vi.importActual<typeof import('./api')>('./api') const actual = await vi.importActual<typeof import('./api')>('./api')
@@ -23,6 +29,9 @@ vi.mock('./api', async () => {
...actual, ...actual,
getLatestInboundFee: vi.fn(), getLatestInboundFee: vi.fn(),
adjustPublishedInboundFee: vi.fn(), adjustPublishedInboundFee: vi.fn(),
calculateInboundFee: vi.fn(),
createManualInboundQuote: vi.fn(),
previewInboundFee: vi.fn(),
confirmInboundFee: vi.fn(), confirmInboundFee: vi.fn(),
sendInboundFee: vi.fn(), sendInboundFee: vi.fn(),
} }
@@ -66,11 +75,11 @@ function mountPanel(permissions = [
'firstleg:fba-pricing:adjust', 'firstleg:fba-pricing:adjust',
'firstleg:fba-pricing:confirm', 'firstleg:fba-pricing:confirm',
'firstleg:inbound:send-fee', 'firstleg:inbound:send-fee',
]) { ], extraProps: { destinationType?: string | null } = {}) {
const pinia = setupPermissions(permissions) const pinia = setupPermissions(permissions)
return mount(InboundFeePanel, { return mount(InboundFeePanel, {
attachTo: document.body, attachTo: document.body,
props: { customerId: '10001', inboundId: '88' }, props: { customerId: '10001', inboundId: '88', ...extraProps },
global: { plugins: [pinia, ElementPlus] }, global: { plugins: [pinia, ElementPlus] },
}) })
} }
@@ -83,6 +92,85 @@ describe('InboundFeePanel', () => {
vi.mocked(adjustPublishedInboundFee).mockResolvedValue({ ...snapshot, feeVersion: 3, feeNo: 'FBAF-88-3' }) 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(confirmInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CONFIRMED', confirmedAt: '2026-08-04T10:01:00', sentAt: null })
vi.mocked(sendInboundFee).mockResolvedValue(snapshot) vi.mocked(sendInboundFee).mockResolvedValue(snapshot)
vi.mocked(previewInboundFee).mockResolvedValue({ chargeableQuantity: 64.128, chargeUnit: 'KG',
currency: 'CNY', priceMatched: false } as Awaited<ReturnType<typeof previewInboundFee>>)
})
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<ReturnType<typeof getInboundMeasurement>>)
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<HTMLButtonElement>('button')]
.find(button => button.textContent?.includes('人工头程报价'))!.click()
await flushPromises()
const save = [...document.querySelectorAll<HTMLButtonElement>('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<HTMLInputElement>('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<HTMLTextAreaElement>('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<ReturnType<typeof getInboundMeasurement>>)
vi.mocked(previewInboundFee).mockRejectedValueOnce(new Error('计费量暂时不可读'))
mountPanel(['firstleg:fba-pricing:view', 'firstleg:inbound:confirm-send-fee'], { destinationType: 'PRIVATE' })
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.includes('人工头程报价'))!.click()
await flushPromises()
const save = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.includes('保存人工报价'))!
expect(save.disabled).toBe(true)
;[...document.querySelectorAll<HTMLButtonElement>('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 () => { it('shows the immutable calculation basis, versions, amount, and timestamps', async () => {
+111 -13
View File
@@ -19,12 +19,14 @@ import {
adjustPublishedInboundFee, adjustPublishedInboundFee,
calculateInboundFee, calculateInboundFee,
confirmInboundFee, confirmInboundFee,
createManualInboundQuote,
createFeeActionIdempotencyKey, createFeeActionIdempotencyKey,
createFeeAdjustmentIdempotencyKey, createFeeAdjustmentIdempotencyKey,
getLatestInboundFee, getLatestInboundFee,
previewInboundFee,
sendInboundFee, sendInboundFee,
} from './api' } from './api'
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types' import type { AdjustPublishedInboundFeeRequest, InboundFeePreview, InboundFeeSnapshot } from './types'
import { getInboundMeasurement } from '../measurement/api' import { getInboundMeasurement } from '../measurement/api'
import type { InboundMeasurementDetail } from '../measurement/types' import type { InboundMeasurementDetail } from '../measurement/types'
@@ -32,6 +34,7 @@ const props = defineProps<{
customerId?: string customerId?: string
tenantId?: string tenantId?: string
inboundId: string inboundId: string
destinationType?: string | null
compact?: boolean compact?: boolean
displayOnly?: boolean displayOnly?: boolean
}>() }>()
@@ -55,7 +58,17 @@ const adjustOpen = ref(false)
const trialOpen = ref(false) const trialOpen = ref(false)
const trialLoading = ref(false) const trialLoading = ref(false)
const measurement = ref<InboundMeasurementDetail | null>(null) const measurement = ref<InboundMeasurementDetail | null>(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<InboundFeePreview | null>(null)
const trialPreviewLoading = ref(false)
let trialPreviewSerial = 0
const adjustFormRef = ref<FormInstance>() const adjustFormRef = ref<FormInstance>()
let loadSequence = 0 let loadSequence = 0
let adjustmentFingerprint = '' 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 canConfirm = computed(() => access.can('firstleg:fba-pricing:confirm'))
const canSend = computed(() => access.can('firstleg:inbound:send-fee')) const canSend = computed(() => access.can('firstleg:inbound:send-fee'))
const canCalculate = computed(() => access.can('firstleg:fba-pricing:calculate')) 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 customerContextId = computed(() => props.customerId || props.tenantId || '')
const normalizedInboundId = computed(() => { const normalizedInboundId = computed(() => {
const identifier = String(props.inboundId ?? '').trim() const identifier = String(props.inboundId ?? '').trim()
@@ -107,12 +131,13 @@ const sourceOptions = computed(() => {
if (!value) return [] if (!value) return []
const itemWeight = value.items.reduce((sum, item) => sum + Number(item.totalWeightKg || 0), 0) 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) 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: 'CUSTOMER', label: '客户下单数据', weight: Number(value.originalGrossWeightKg || 0), volume: Number(value.originalVolumeCbm || 0) },
{ value: 'OUTER', label: '外箱规数据', weight: itemWeight, volume: itemVolume }, { value: 'OUTER', label: '外箱规数据', weight: itemWeight, volume: itemVolume },
{ value: 'ACTUAL', label: '实际测量数据', weight: Number(value.actualNetWeightKg || value.actualGrossWeightKg || 0), volume: Number(value.actualVolumeCbm || 0) }, { value: 'ACTUAL', label: '实际测量数据', weight: Number(value.actualNetWeightKg || value.actualGrossWeightKg || 0), volume: Number(value.actualVolumeCbm || 0) },
{ value: 'WAREHOUSE', label: '仓库建议数据', weight: itemWeight, volume: itemVolume }, { value: 'WAREHOUSE', label: '仓库建议数据', weight: itemWeight, volume: itemVolume },
] ]
return isPrivateAddress.value ? options.filter(item => item.value === 'ACTUAL') : options
}) })
function feeStatusInfo(status?: string) { function feeStatusInfo(status?: string) {
@@ -229,20 +254,72 @@ function openAdjustment() {
async function openTrial() { async function openTrial() {
trialLoading.value = true 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) } catch (error) { ElMessage.error(getApiErrorDetails(error, '测量数据加载失败,无法试算').message) }
finally { trialLoading.value = false } 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() { async function submitTrial() {
if (!canRunTrial.value || !manualQuoteValid.value) return
const weight = sourceOptions.value.find((item) => item.value === trial.weightSource) const weight = sourceOptions.value.find((item) => item.value === trial.weightSource)
const volume = sourceOptions.value.find((item) => item.value === trial.volumeSource) const volume = sourceOptions.value.find((item) => item.value === trial.volumeSource)
if (!weight?.weight || !volume?.volume) { ElMessage.warning('所选重量或体积没有有效数据'); return } if (!weight?.weight || !volume?.volume) { ElMessage.warning('所选重量或体积没有有效数据'); return }
adjustmentError.value = null
trialLoading.value = true trialLoading.value = true
try { try {
const result = await calculateInboundFee(customerContextId.value, normalizedInboundId.value, { selectedWeightKg: weight.weight, selectedVolumeCbm: volume.volume, weightSource: weight.value, volumeSource: volume.value }, createFeeActionIdempotencyKey('calculate')) const selection = { selectedWeightKg: weight.weight, selectedVolumeCbm: volume.volume }
snapshot.value = result; trialOpen.value = false; emit('changed', result); ElMessage.success('费用试算已更新') let result: InboundFeeSnapshot
} catch (error) { adjustmentError.value = getApiErrorDetails(error, '费用试算失败') } 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 } finally { trialLoading.value = false }
} }
@@ -401,12 +478,12 @@ defineExpose({ reload: loadFee })
><RefreshCw :size="16" :class="{ 'is-spinning': loading }" /></el-button> ><RefreshCw :size="16" :class="{ 'is-spinning': loading }" /></el-button>
</el-tooltip> </el-tooltip>
<el-button <el-button
v-if="!displayOnly && canCalculate" v-if="!displayOnly && canRunTrial"
type="primary" type="primary"
plain plain
:loading="trialLoading" :loading="trialLoading"
@click="openTrial" @click="openTrial"
><CircleDollarSign :size="16" />费用试算</el-button> ><CircleDollarSign :size="16" />{{ isPrivateAddress ? '人工头程报价' : '费用试算' }}</el-button>
<el-button <el-button
v-if="!displayOnly && canAdjust && adjustmentEligible" v-if="!displayOnly && canAdjust && adjustmentEligible"
data-testid="adjust-fee-button" data-testid="adjust-fee-button"
@@ -439,12 +516,33 @@ defineExpose({ reload: loadFee })
</div> </div>
</header> </header>
<AppDialog class="admin-workbench is-dialog" v-model="trialOpen" title="头程费用试算" width="min(760px, calc(100vw - 28px))" :confirm-loading="trialLoading" confirm-text="开始试算" @confirm="submitTrial"> <AppDialog
class="admin-workbench is-dialog"
v-model="trialOpen"
:title="isPrivateAddress ? '私人地址头程报价' : '头程费用试算'"
width="min(760px, calc(100vw - 28px))"
:confirm-loading="trialLoading"
:confirm-disabled="!canRunTrial || !manualQuoteValid"
:confirm-text="isPrivateAddress ? '保存人工报价' : '开始试算'"
@confirm="submitTrial"
>
<div class="trial-source-grid"> <div class="trial-source-grid">
<section><h4>计费重量(单选)<InlineHelp title="试算取值说明"><p>重量和体积各选择一项;默认选择实际测量数据。</p></InlineHelp></h4><el-radio-group v-model="trial.weightSource"><el-radio v-for="item in sourceOptions" :key="`weight-${item.value}`" :value="item.value" :disabled="item.weight <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.weight) }} KG</strong></el-radio></el-radio-group></section> <section><h4>计费重量(单选)<InlineHelp title="试算取值说明"><p>重量和体积各选择一项;默认选择实际测量数据。</p></InlineHelp></h4><el-radio-group v-model="trial.weightSource" @change="loadTrialPreview"><el-radio v-for="item in sourceOptions" :key="`weight-${item.value}`" :value="item.value" :disabled="item.weight <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.weight) }} KG</strong></el-radio></el-radio-group></section>
<section><h4>计费体积(单选)</h4><el-radio-group v-model="trial.volumeSource"><el-radio v-for="item in sourceOptions" :key="`volume-${item.value}`" :value="item.value" :disabled="item.volume <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.volume, 6) }} CBM</strong></el-radio></el-radio-group></section> <section><h4>计费体积(单选)</h4><el-radio-group v-model="trial.volumeSource" @change="loadTrialPreview"><el-radio v-for="item in sourceOptions" :key="`volume-${item.value}`" :value="item.value" :disabled="item.volume <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.volume, 6) }} CBM</strong></el-radio></el-radio-group></section>
</div> </div>
<el-form v-if="isPrivateAddress" label-position="top" class="adjustment-form" :disabled="trialLoading">
<el-form-item label="服务端计费量">
<span>{{ trialPreview ? `${formatNumber(trialPreview.chargeableQuantity, 6)} ${trialPreview.chargeUnit}` : '—' }}</span>
<el-button link :loading="trialPreviewLoading" @click="loadTrialPreview">刷新计费量</el-button>
</el-form-item>
<el-form-item :label="`人工单价(CNY / ${trialPreview?.chargeUnit || '—'})`" required>
<el-input-number v-model="trial.unitPrice" aria-label="人工单价" :min="0.0001" :max="999999999999.9999" :precision="4" controls-position="right" placeholder="请输入人工单价" />
</el-form-item>
<el-form-item label="报价原因" required>
<el-input v-model="trial.reason" aria-label="报价原因" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入报价原因,至少2个字" />
</el-form-item>
</el-form>
</AppDialog> </AppDialog>
<el-alert v-if="!canView" type="warning" :closable="false" show-icon> <el-alert v-if="!canView" type="warning" :closable="false" show-icon>
@@ -545,7 +643,7 @@ defineExpose({ reload: loadFee })
<dl> <dl>
<div><dt>起运仓</dt><dd>{{ snapshot.originWarehouseName }}</dd></div> <div><dt>起运仓</dt><dd>{{ snapshot.originWarehouseName }}</dd></div>
<div><dt>渠道</dt><dd>{{ snapshot.channelCode }}</dd></div> <div><dt>渠道</dt><dd>{{ snapshot.channelCode }}</dd></div>
<div><dt>FBA 目的仓</dt><dd>{{ snapshot.destinationCode }}</dd></div> <div><dt>{{ isPrivateAddress ? '目的地' : 'FBA 目的仓' }}</dt><dd>{{ isPrivateAddress ? '私人地址' : snapshot.destinationCode || '—' }}</dd></div>
<div><dt>价格有效期</dt><dd>{{ formatDateTime(snapshot.rateEffectiveFrom) }} 至 {{ formatDateTime(snapshot.rateEffectiveTo) }}</dd></div> <div><dt>价格有效期</dt><dd>{{ formatDateTime(snapshot.rateEffectiveFrom) }} 至 {{ formatDateTime(snapshot.rateEffectiveTo) }}</dd></div>
</dl> </dl>
</section> </section>
@@ -51,6 +51,17 @@ describe('入库短少确认', () => {
access.can.mockReturnValue(false) access.can.mockReturnValue(false)
const wrapper = render(); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount() 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) => { 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() const wrapper = render(detail(overrides)); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount()
}) })
@@ -22,7 +22,7 @@ const shortage = computed(() => (expected.value ?? 0) - (received.value ?? 0))
const eligible = computed(() => access.can(overseasWarehousePermissions.inboundOrderEdit) const eligible = computed(() => access.can(overseasWarehousePermissions.inboundOrderEdit)
&& props.detail.status === 'PENDING_RECONCILIATION' && props.detail.version != null && props.detail.status === 'PENDING_RECONCILIATION' && props.detail.version != null
&& props.detail.orderType !== 'ONE_CLICK_DROPSHIP' && props.detail.lines.length > 0 && 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) && expected.value != null && received.value != null && received.value > 0 && shortage.value > 0)
function show() { function show() {
@@ -5,6 +5,7 @@ import { getInboundPlan } from '../api/inboundPlans'
import InboundOrderDetailView from './InboundOrderDetailView.vue' import InboundOrderDetailView from './InboundOrderDetailView.vue'
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: '9007199254741001' } }), useRouter: () => ({ push: vi.fn() }) })) 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('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null })) vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { name: 'InboundShortageConfirmation', props: ['detail'], emits: ['refresh'], template: '<button data-shortage @click="$emit(\'refresh\')">确认短少入库</button>' } })) vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { name: 'InboundShortageConfirmation', props: ['detail'], emits: ['refresh'], template: '<button data-shortage @click="$emit(\'refresh\')">确认短少入库</button>' } }))
@@ -2,10 +2,14 @@ import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getInboundPlan } from '../api/inboundPlans' import { getInboundPlan } from '../api/inboundPlans'
import { getPalletLabelPdf } from '../api/pallets'
import InboundOrderDetailView from './InboundOrderDetailView.vue' import InboundOrderDetailView from './InboundOrderDetailView.vue'
const route = vi.hoisted(() => ({ params: { orderId: 'PLAN-1' }, query: { customerId: '9007199254741001' } as Record<string, string> })) const route = vi.hoisted(() => ({ params: { orderId: 'PLAN-1' }, query: { customerId: '9007199254741001' } as Record<string, string> }))
const access = vi.hoisted(() => ({ can: vi.fn(() => true) }))
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ push: vi.fn() }) })) 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('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null })) vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { template: '<span />' } })) vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { template: '<span />' } }))
@@ -15,6 +19,7 @@ vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: {
describe('InboundOrderDetailView fees', () => { describe('InboundOrderDetailView fees', () => {
beforeEach(() => { beforeEach(() => {
access.can.mockReturnValue(true)
route.query = { customerId: '9007199254741001' } route.query = { customerId: '9007199254741001' }
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ vi.mocked(getInboundPlan).mockReset().mockResolvedValue({
id: '9007199254741002', planNo: 'PLAN-1', customerId: '9007199254741001', sourceOrderId: '9007199254741003', id: '9007199254741002', planNo: 'PLAN-1', customerId: '9007199254741001', sourceOrderId: '9007199254741003',
@@ -41,4 +46,37 @@ describe('InboundOrderDetailView fees', () => {
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).exists()).toBe(false) expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).exists()).toBe(false)
wrapper.unmount() 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: '<div><div v-for="row in data" :key="row.id"><slot name="actions" :row="row" /></div></div>' },
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()
})
}) })
@@ -7,23 +7,30 @@ import { BusinessDocumentFees } from '@/modules/overseas-business'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.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 { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans'
import { listOverseasLocations, type OverseasLocationDto } from '../api/locations' import { listOverseasLocations, type OverseasLocationDto } from '../api/locations'
import { getPalletLabelPdf } from '../api/pallets'
import { overseasWarehousePermissions } from '../permissions'
import OverseasDetailShell from '../components/OverseasDetailShell.vue' import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import InboundShortageConfirmation from '../components/InboundShortageConfirmation.vue' import InboundShortageConfirmation from '../components/InboundShortageConfirmation.vue'
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation' import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
const route = useRoute(); const router = useRouter() const route = useRoute(); const router = useRouter()
const access = useAccessPreviewStore()
const planNo = computed(() => String(route.params.orderId || '')) const planNo = computed(() => String(route.params.orderId || ''))
const customerId = computed(() => String(route.query.customerId || '')) const customerId = computed(() => String(route.query.customerId || ''))
const detail = ref<InboundPlanDetailDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null) const detail = ref<InboundPlanDetailDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(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<OverseasLocationDto[]>([]) 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<OverseasLocationDto[]>([])
const moveLocationsLoading = ref(false); const moveLocationError = ref<ApiErrorDetails | null>(null); const moveError = ref<ApiErrorDetails | null>(null) const moveLocationsLoading = ref(false); const moveLocationError = ref<ApiErrorDetails | null>(null); const moveError = ref<ApiErrorDetails | null>(null)
const printingPalletId = ref('')
const printError = ref<ApiErrorDetails | null>(null)
const referenceValue = computed(() => detail.value?.planNo || (/^\d+$/.test(planNo.value) ? '—' : planNo.value)) const referenceValue = computed(() => detail.value?.planNo || (/^\d+$/.test(planNo.value) ? '—' : planNo.value))
function text(value: unknown) { return String(value ?? '').trim() || '—' } function text(value: unknown) { return String(value ?? '').trim() || '—' }
function receivingWarehouse(code: unknown, name: unknown) { function receivingWarehouse(code: unknown, name: unknown) {
@@ -47,7 +54,7 @@ const summaryGroups = [
] ]
const appointmentFields = ['预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注'] const appointmentFields = ['预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注']
const palletColumns: DataTableColumn[] = [ 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: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 }, { key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 },
{ key: 'putawayStatus', label: '上架状态', width: 110, prop: 'putawayStatus', afterColumn: 'palletNo' }, { 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), palletNo: text(allocation.palletNo),
palletOwner: allocation.palletOwnedByCurrentPlan ? `${allocation.palletOwnerPlanNo}(本计划)` : `${allocation.palletOwnerPlanNo}(跨计划合托)`, palletOwner: allocation.palletOwnedByCurrentPlan ? `${allocation.palletOwnerPlanNo}(本计划)` : `${allocation.palletOwnerPlanNo}(跨计划合托)`,
packageCount: Number(allocation.pickedPackageCount ?? allocation.countedPackageCount ?? allocation.plannedPackageCount ?? 0), 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' ? '已上架' : '待上架', 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')) 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]) { async function openMove(row: typeof palletRows.value[number]) {
if (moveSaving.value || moveLocationsLoading.value || row.palletVersion == null || row.palletNo === '—') return if (moveSaving.value || moveLocationsLoading.value || row.palletVersion == null || row.palletNo === '—') return
if (!detail.value?.overseasWarehouseCode) { ElMessage.warning('入库单未返回海外仓范围,暂不能移库'); return } if (!detail.value?.overseasWarehouseCode) { ElMessage.warning('入库单未返回海外仓范围,暂不能移库'); return }
@@ -99,7 +129,9 @@ onMounted(load)
<template><OverseasDetailShell workbench class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" :back-text="appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表'" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back"> <template><OverseasDetailShell workbench class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" :back-text="appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表'" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState> <LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in summaryGroups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section> <template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in summaryGroups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><InboundShortageConfirmation v-if="detail" :detail="detail" @refresh="load" /><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><div class="workbench-table"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="moveSaving || moveLocationsLoading || !(row as typeof palletRows[number]).palletNo || (row as typeof palletRows[number]).palletNo === '—' || (row as typeof palletRows[number]).palletVersion == null" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></div></section> <section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><InboundShortageConfirmation v-if="detail" :detail="detail" @refresh="load" /><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap">
<LoadErrorState v-if="printError" compact title="托盘标签打印失败" :message="printError.message" :show-retry="false"><template #details><ApiErrorMeta :details="printError" /></template></LoadErrorState>
<div class="workbench-table"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><RowActions :actions="palletActions(row as typeof palletRows[number])" text-actions @action="action => action === 'print' ? printPalletLabel(row as typeof palletRows[number]) : openMove(row as typeof palletRows[number])" /></template></DataTable></div></div></section>
<section class="detail-section appointment-section" aria-labelledby="inbound-appointment-heading"><div class="compact-section-heading"><h2 id="inbound-appointment-heading">预约与排车</h2></div><dl class="detail-fields appointment-fields density-fields"><div v-for="label in appointmentFields" :key="label" :class="{ 'is-wide': label === '备注' }"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section> <section class="detail-section appointment-section" aria-labelledby="inbound-appointment-heading"><div class="compact-section-heading"><h2 id="inbound-appointment-heading">预约与排车</h2></div><dl class="detail-fields appointment-fields density-fields"><div v-for="label in appointmentFields" :key="label" :class="{ 'is-wide': label === '备注' }"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section>
<section v-if="detail" class="detail-section fee-section" aria-label="入库单费用"><BusinessDocumentFees business-type="OVERSEAS_INBOUND" :business-no="detail.planNo" :customer-id="detail.customerId" :business-id="detail.id" /></section></template> <section v-if="detail" class="detail-section fee-section" aria-label="入库单费用"><BusinessDocumentFees business-type="OVERSEAS_INBOUND" :business-no="detail.planNo" :customer-id="detail.customerId" :business-id="detail.id" /></section></template>
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog admin-workbench is-dialog" :title="`托盘移库 · ${moveRow?.palletNo || '—'}`" width="min(560px, calc(100vw - 28px))" fullscreenable confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="moveLocationsLoading || !!moveLocationError || !targetLocationCode || !moveReason.trim()" @confirm="submitMove"><div class="density-dialog-sections"> </OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog admin-workbench is-dialog" :title="`托盘移库 · ${moveRow?.palletNo || '—'}`" width="min(560px, calc(100vw - 28px))" fullscreenable confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="moveLocationsLoading || !!moveLocationError || !targetLocationCode || !moveReason.trim()" @confirm="submitMove"><div class="density-dialog-sections">
@@ -2640,6 +2640,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
:key="`fee-${normalizedInboundId}-${feePanelRevision}`" :key="`fee-${normalizedInboundId}-${feePanelRevision}`"
:customer-id="String(detail.summary.tenantId)" :customer-id="String(detail.summary.tenantId)"
:inbound-id="normalizedInboundId" :inbound-id="normalizedInboundId"
:destination-type="detail.summary.destinationType"
compact compact
display-only display-only
@loaded="automaticFee = $event" @loaded="automaticFee = $event"