支持私人地址人工报价及海外入库托盘打印和短少确认
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user