Compare commits

...
Author SHA1 Message Date
ouyangxq 48d6c31397 test(admin): isolate merged inbound fees and shortage components 2026-10-26 17:23:38 +08:00
ouyangxq b06d46b0b3 merge: integrate TEST changes with fleet photo updates 2026-10-26 17:21:14 +08:00
admin 06d814e82f Merge pull request 'feat(admin): 同步档案照片带入与装车多图维护' (#29) from hotfix-gray-bugfix into main 2026-10-26 17:15:09 +08:00
ouyangxq acb90007de feat(admin): synchronize fleet photos and multi-image loading documents 2026-10-26 17:12:38 +08:00
admin e399f3f76f Merge pull request '隐藏送柜报关详情中的查询任务技术信息' (#28) from hotfix-gray-bugfix into main
Reviewed-on: https://git.buggoglobal.com/html/admin-web/pulls/28
2026-10-26 17:01:14 +08:00
ouyangxq 4ee3b541ac fix(admin): hide domestic container query diagnostics 2026-10-26 16:59:20 +08:00
admin 0ef04804c1 Merge pull request '修复灰度货柜与海外仓界面交互' (#27) from hotfix-gray-bugfix into main 2026-10-26 16:12:35 +08:00
ouyangxq cc02f4d907 fix(admin): simplify gray release warehouse workflows 2026-10-26 15:51:07 +08:00
admin 33cb18594e Merge pull request 'fix(admin): 入库单增加短少确认操作' (#26) from fix/inbound-shortage-confirmation into main 2026-10-26 15:47:30 +08:00
ouyangxq 9de72cbc5c fix(admin): add inbound shortage confirmation action 2026-10-26 15:46:14 +08:00
ouyangxq b5d8ccdffe Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-26 13:58:54 +08:00
ouyangxq 6580853ad3 优化 2026-10-26 13:58:52 +08:00
liuyihe 32c6a0ad7b Merge origin/release/test and preserve procurement direction with request unlock 2026-10-26 13:51:37 +08:00
liuyihe 18a78e5508 费用优化 2026-10-26 13:43:08 +08:00
admin 952abd5629 Merge pull request 'style(pallets): 国内托盘统计对齐海外卡片布局' (#25) from release/test into main 2026-10-26 11:37:35 +08:00
ouyangxq 7688f859c7 style(pallets): align domestic summary cards with overseas layout 2026-10-26 11:34:22 +08:00
admin 227c537099 Merge pull request 'fix(admin): 订舱列表港口名称为空时显示已保存代码' (#24) from release/test into main 2026-10-26 10:56:47 +08:00
ouyangxq f1dbb63251 fix(bookings): display saved port codes when names are empty 2026-10-26 10:48:08 +08:00
admin c853b919c0 灰度晋级:2026-10-23 晚间 apps/admin-web
User-authorized fixed-source promotion; deployment and data preservation verified separately.
2026-10-23 21:00:45 +08:00
ouyangxq 047cfb3d0b fix(admin): refine loading and fulfillment displays 2026-10-23 18:45:47 +08:00
admin ff071c0159 灰度晋级:Admin 散板发布入口及累计界面修复
User-authorized fixed-source promotion; deployment and data preservation verified separately.
2026-10-23 13:40:46 +08:00
ouyangxq 9542b71cc7 fix(dispatch): finalize smart dispatch publication contracts 2026-10-23 12:42:18 +08:00
ouyangxq 4f766a76bd 优化 2026-10-23 12:06:42 +08:00
ouyangxq 75bb0bf78e fix(admin): simplify booking documents and refine measurement review 2026-10-23 10:56:35 +08:00
admin 4b47e770a3 发布:将已验 TEST 功能更新至既有生产灰度(2026-10-23)
Promote the fixed, reviewed TEST source to the existing production release. Runtime deployment remains a separate controlled step.
2026-10-23 10:51:27 +08:00
ouyangxq 52045e5df2 fix(inbounds): align review weight index column width 2026-10-23 03:19:03 +08:00
ouyangxq cd86255b13 fix(dispatch): preview selected driver master license before saving 2026-10-23 00:55:21 +08:00
ouyangxq 4a3456756f fix: align audit and dispatch workflows and release failed requests 2026-10-22 23:10:09 +08:00
ouyangxq 9cdcb9c077 docs(booking): record restored arrival verification 2026-10-22 18:56:21 +08:00
ouyangxq a041650ba1 fix(booking): restore arrival time and release validation 2026-10-22 18:47:54 +08:00
ouyangxq 22e545fef9 fix(appointments): search dispatches by cabinet short number 2026-10-22 18:03:45 +08:00
ouyangxq 3ae9004b8b docs(booking): record simplified form verification 2026-10-22 16:53:04 +08:00
ouyangxq c3f7dcc6f1 fix(booking): simplify entry form and apply cargo defaults 2026-10-22 16:43:42 +08:00
admin eb1d75222a Merge pull request '生产更新:同步已提交的海外仓分析及业务修复(2026-10-16)' (#20) from release/test into main 2026-10-16 18:03:19 +08:00
admin 545205e28d Merge pull request '发布采购、拼车、预约及匹配端侧修复至灰度' (#19) from release/test into main 2026-10-12 10:20:54 +08:00
admin f9dd30b8b9 Merge pull request '发布已验收的跨客户同仓预约到灰度' (#18) from release/appointment-gray-20261009 into main 2026-10-09 13:34:39 +08:00
admin 76a63070c5 Merge pull request '修复车次同名卸货站目的地校验与提示' (#17) from fix/fleet-destination-gray-20261009 into main 2026-10-09 11:57:19 +08:00
ouyangxq cec1e2d77c fix(fleet): validate pallet destination identity before capacity confirmation 2026-10-09 11:48:53 +08:00
admin dd47143d6e Merge pull request '发布已通过 TEST 回归的 2026-10-08 批次至灰度' (#16) from release/test into main 2026-10-08 22:17:40 +08:00
admin 10da46beab Merge pull request '恢复装柜清单及货柜海外操作入口' (#15) from release/test into main 2026-10-08 11:26:58 +08:00
admin cc5ac2e68a Merge pull request 'Release internal fleet capacity pool to gray' (#14) from release/test into main 2026-10-08 08:13:24 +08:00
admin e8e7465e4e Merge pull request '统一司机、车次及出车记录列表样式' (#13) from release/test into main 2026-10-07 12:25:14 +08:00
admin b4b802a547 Merge pull request '发布当前测试版本至临时灰度环境' (#12) from release/test into main 2026-10-07 11:11:52 +08:00
246 changed files with 6030 additions and 2609 deletions
+1 -1
View File
@@ -37,7 +37,7 @@
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 规则见下文「货柜工作台规则」 |
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后运输/预报/关系方/柜型/附件只读;客服实货量经 `/actual-cargo` 按编辑权限、版本、幂等键和原因补录;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,托运单取委值,提单确认件取手工实值,未填显示“—”且不以委值回退。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后预计到港经 `/estimated-arrival` 按编辑权限和版本单独维护,其余运输/预报/关系方/柜型/附件只读;客服实货量经 `/actual-cargo` 按编辑权限、版本、幂等键和原因补录;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,托运单取委值,提单确认件取手工实值,未填显示“—”且不以委值回退。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 |
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
+1
View File
@@ -146,6 +146,7 @@ export const adminNavigation: AdminNavigationNode[] = [
icon: PrototypeOverseasBusinessIcon,
children: [
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' },
{ key: 'overseas-fees', title: '海外费用管理', icon: CircleDollarSign, path: '/overseas-business/fees', permission: 'platform:overseas-fee:list' },
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' },
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
{ key: 'fleet-capacity-offers', title: '散板需求', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
@@ -8,11 +8,11 @@ const event = (type: ScheduleEvent['type'], index = 0): ScheduleEvent => ({ key:
const render = (events: ScheduleEvent[] = [], initialDate = '2026-10-19') => mount(AppointmentCalendarPanel, { props: { initialDate, events },
global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
function reportedDispatch() {
const row: TripCalendarEntry = { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [] }
const row: TripCalendarEntry = { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: ['D1821', 'D1822'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [] }
return projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, { from: '2026-10-01', to: '2026-11-01' }).events
}
describe('four-type appointment calendar', () => {
it.each(['TSTU1234567', 'tstu1234567', 'TSTU7654321', ' tstu7654321 '])('finds the one shared dispatch by associated container %s on its origin-local date, not the Beijing date', async keyword => {
it.each(['TSTU1234567', 'tstu1234567', 'TSTU7654321', ' tstu7654321 ', 'D1821', ' d1821 ', 'D1822', '1821'])('finds the one shared dispatch by associated container %s on its origin-local date, not the Beijing date', async keyword => {
const wrapper = render(reportedDispatch(), '2026-10-23')
const search = wrapper.get('[aria-label="搜索当前日历"]')
expect(search.attributes('placeholder')).toBe('柜号、排车单号、仓库')
@@ -26,6 +26,7 @@ describe('four-type appointment calendar', () => {
expect(wrapper.get('.detail-event').text()).toContain('PC-CALENDAR-001')
expect(wrapper.get('.detail-event').text()).toContain('09:00')
expect(wrapper.get('.detail-event').text()).toContain('TSTU1234567、TSTU7654321')
expect(wrapper.get('.detail-event').text()).toContain('D1821、D1822')
expect(wrapper.get('.detail-event').text()).toContain('2026-10-22 09:00')
expect(wrapper.emitted('range')).toBeUndefined()
wrapper.unmount()
@@ -14,10 +14,10 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, plannedDate: '2026-10-21', isDefault: true, actualAt: null, version: 0, planCount: 0, canEdit: true }
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
function dispatchEntry(overrides: Partial<TripCalendarEntry> = {}): TripCalendarEntry {
return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides }
return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides }
}
export function renderSchedule() {
return mount(AppointmentCalendarView, { global: { stubs: {
@@ -34,7 +34,7 @@ export function renderSchedule() {
} } })
}
beforeEach(() => {
vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
vi.resetAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true })
vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse])
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
@@ -110,29 +110,24 @@ describe('simplified appointment entry', () => {
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0)
wrapper.unmount()
})
it('retries an unknown save with the same payload and key, then only reloads reads', async () => {
it('releases an unknown save and sends the edited date with a fresh key', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('提交结果尚未确认')
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise<boolean>
expect(await guard()).toBe(false)
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String))
expect(wrapper.find('[data-dialog]').exists()).toBe(false)
wrapper.unmount()
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-22T09:30'); await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-23T09:30'); await wrapper.get('[data-save]').trigger('click'); await flushPromises()
const calls = vi.mocked(saveWarehouseArrival).mock.calls
expect(calls[1]![2]).not.toBe(calls[0]![2]); expect(calls[1]![1].estimatedWarehouseArrivalAt).toBe('2026-10-23T09:30:00')
expect(wrapper.find('[data-dialog]').exists()).toBe(false); wrapper.unmount()
})
it('requires an explicit reread after a conflict and retains the entered date', async () => {
it('allows editing after a conflict and preserves input across an optional manual refresh', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input').setValue('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined()
expect(wrapper.get('[data-save]').attributes('disabled')).toBeUndefined()
vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }])
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
@@ -87,30 +87,27 @@ async function openSource(event: ScheduleEvent) {
await router.push(appointmentExternalTarget(event.sourcePath, context))
}
const editor = ref<ScheduleEvent | null>(null), plannedAt = ref(''), originalAt = ref(''), saving = ref(false)
const saveError = ref<ApiErrorDetails | null>(null), unknownResult = ref(false), conflict = ref(false)
let pending: { id: string; body: WarehouseArrivalChange; key: string } | null = null
const saveError = ref<ApiErrorDetails | null>(null), conflict = ref(false)
function edit(event: ScheduleEvent) {
if (!event.canEdit || !event.warehouse) return
editor.value = event; plannedAt.value = event.warehouse.plannedAt?.slice(0, 16) || ''; originalAt.value = plannedAt.value
pending = null; saveError.value = null; unknownResult.value = false; conflict.value = false
saveError.value = null; conflict.value = false
}
async function save() {
const row = editor.value?.warehouse
if (!row || saving.value || conflict.value || !plannedAt.value) return
pending ||= { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
if (!row || saving.value || !plannedAt.value) return
const request = { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
key: `calendar-arrival-${crypto.randomUUID()}` }
saving.value = true; saveError.value = null
try {
await saveWarehouseArrival(pending.id, pending.body, pending.key)
pending = null; unknownResult.value = false; editor.value = null
await saveWarehouseArrival(request.id, request.body, request.key)
editor.value = null
ElMessage.success('计划到仓已保存')
await load()
} catch (cause) {
saveError.value = getApiErrorDetails(cause, '计划到仓保存失败')
unknownResult.value = !rejectedCommand(cause)
const status = cause instanceof ApiResponseError ? cause.status : isAxiosError(cause) ? cause.response?.status : undefined
conflict.value = status === 409 || /409$/.test(saveError.value.code || '')
if (!unknownResult.value) pending = null
} finally { saving.value = false }
}
async function rereadEditor() {
@@ -128,7 +125,7 @@ async function rereadEditor() {
const guards = new Set<LeaveGuard>()
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
async function canLeave() {
if (saving.value || unknownResult.value) return false
if (saving.value) return false
for (const guard of guards) if (!await guard()) return false
if (editor.value && plannedAt.value !== originalAt.value) {
try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm admin-workbench is-dialog' }) }
@@ -179,18 +176,17 @@ onBeforeUnmount(() => { ++generation; controller?.abort() })
<AppDialog v-if="active && creating" :model-value="true" title="新增FBA送仓预约" class="admin-density-dialog admin-density business-dialog admin-workbench is-dialog" width="1080px" :show-footer="false" :before-close="closeCreate" :close-on-click-modal="true" append-to-body fullscreenable>
<AppointmentCreatePanel :can-write="canCreate" :can-leave="canLeave" @created="load()" />
</AppDialog>
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog admin-workbench is-dialog" width="520px" fullscreenable :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog admin-workbench is-dialog" width="520px" fullscreenable :confirm-loading="saving" :confirm-disabled="!plannedAt" :before-close="closeEditor" confirm-text="保存" @confirm="save">
<div v-if="editor" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="到仓业务信息">
<table class="density-table"><tbody><tr><th scope="row">业务</th><td>{{ editor.title }}</td></tr></tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="调整到仓时间">
<el-form class="density-form" label-position="left" label-width="150px" :disabled="saving || unknownResult">
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></template><el-date-picker v-model="plannedAt" type="datetime" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm" clearable /></el-form-item></td></tr></tbody></table>
<el-form class="density-form" label-position="left" label-width="150px" :disabled="saving">
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></template><el-date-picker v-model="plannedAt" type="datetime" :disabled="saving" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm" clearable /></el-form-item></td></tr></tbody></table>
</el-form>
</section>
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
<LoadErrorState v-if="saveError" title="计划到仓未保存" :message="saveError.message" :loading="saving" @retry="conflict ? rereadEditor() : save()"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-if="conflict" role="status">重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。</p>
</div>
@@ -82,9 +82,9 @@ async function saveArrangement() {
pendingCreate.value = null; batchPreview.value = null; selected.value = {}
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); ++externalRevision.value; emit('created'); await load()
} catch (cause) {
createError.value = getApiErrorDetails(cause, '保存结果待核实,请重试原请求')
createError.value = getApiErrorDetails(cause, '保存失败,请重新操作')
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(createError.value.code || '')) pendingCreate.value = null
} finally { creating.value = false }
} finally { pendingCreate.value = null; creating.value = false }
}
let followupRequest = 0
const query = ref<QueryModel>({ keyword: '' })
@@ -202,7 +202,7 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
</template>
</DataTable>
<AppointmentFollowupDialog v-if="following" :source="following.source" :coverage="following.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" @arrange="arrangeFollowing" @close="following = null" />
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="pendingCreate ? '重试本次保存' : '保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="closeBatch" :before-close="beforeClose">
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="'保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="closeBatch" :before-close="beforeClose">
<p>{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}</p>
<p>{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱</p>
<p v-if="batchPreview.group.readinessIncomplete">货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。</p>
@@ -78,24 +78,17 @@ describe('appointment impact handling', () => {
expect(wrapper.text()).toContain('认领跟进')
expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount()
})
it('freezes an uncertain command and retries exactly once with original version and key', async () => {
it('unlocks close and navigation after uncertainty and permits a fresh click', async () => {
vi.mocked(handleImpact).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...detail.impact, version: 2 })
vi.mocked(getImpact).mockResolvedValueOnce({...detail,transport:{trips:[{tripNo:'TRIP-1',status:'PLANNED',plannedDepartureAt:null}],sameTripBookings:[]}})
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises()
const original = vi.mocked(handleImpact).mock.calls[0]![1]
expect(original).toMatchObject({ action: 'CLAIM', expectedVersion: 1, forecastToken: 'current' })
await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toBeUndefined()
const done=vi.fn(); wrapper.findComponent({name:'AppDialog'}).props('beforeClose')(done); expect(done).not.toHaveBeenCalled()
expect(guards.register.mock.calls[0]![0]()).toBe(false)
await wrapper.findAll('button').find(b=>b.text()==='TRIP-1')!.trigger('click');expect(guards.push).not.toHaveBeenCalled()
for (const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
await wrapper.findAll('button').find(b => b.text() === '重试本次处理')!.trigger('click'); await flushPromises()
expect(handleImpact).toHaveBeenNthCalledWith(2, '1', original)
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(getImpact).toHaveBeenCalledTimes(2)
expect(guards.register.mock.calls[0]![0]()).toBe(true)
wrapper.unmount(); expect(guards.unregister).toHaveBeenCalledOnce()
await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toHaveLength(1)
const done = vi.fn(); wrapper.findComponent({name:'AppDialog'}).props('beforeClose')(done); expect(done).toHaveBeenCalledOnce()
await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises()
expect(vi.mocked(handleImpact).mock.calls[1]![1].idempotencyKey).not.toBe(original.idempotencyKey)
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('does not resolve without an actual handling result', async () => {
const wrapper = render(); await flushPromises()
@@ -26,8 +26,8 @@ async function save(action: ImpactCommand['action']) {
pending.value ||= { expectedVersion: detail.value.impact.version, forecastToken: detail.value.impact.forecastToken, idempotencyKey: crypto.randomUUID(), action, result: form.result.trim() || null, nextAction: form.nextAction.trim() || null, followupDate: form.followupDate || null }
saving.value = true; error.value = null
try { await handleImpact(props.containerId, pending.value); pending.value = null; emit('saved'); historyPage.value = 1; await load() }
catch (cause) { error.value = getApiErrorDetails(cause, '处理结果待核实,请重试原请求'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null }
finally { saving.value = false }
catch (cause) { error.value = getApiErrorDetails(cause, '操作失败,请重新操作'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null }
finally { pending.value = null; saving.value = false }
}
function canLeave() { if (saving.value || pending.value) { ElMessage.warning('请先核实本次处理结果,再关闭或切换'); return false } return true }
function close() { if (canLeave()) emit('close') }
@@ -65,7 +65,7 @@ onMounted(load)
<el-button v-if="!pending" :disabled="saving || loading" @click="save('CLAIM')">认领跟进</el-button>
<el-button v-if="!pending" :disabled="saving || loading" @click="save('RECORD')">记录处理</el-button>
<el-button v-if="!pending" type="primary" :disabled="saving || loading" @click="save('RESOLVE')">完成本次核对</el-button>
<el-button v-else type="primary" :loading="saving" @click="save(pending.action)">重试本次处理</el-button>
</div>
<details><summary>观察及处理历史({{ detail.history.total }} 条)</summary><div class="impact-list"><p v-for="row in detail.history.records" :key="row.version">{{ actions[row.action] || '核对记录' }} · {{ row.operatorName || '—' }} · {{ row.createdAt }} UTC<br>{{ row.result || '记录来源观察' }} · {{ row.nextAction || '—' }} · {{ row.followupDate || '—' }}</p></div><el-pagination v-model:current-page="historyPage" :page-size="20" :total="detail.history.total" layout="prev, pager, next" :disabled="loading || saving || !!pending" @current-change="load" /></details>
</template>
@@ -1,7 +1,7 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkDispatchForm from './WorkDispatchForm.vue'
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, type BatchDispatchCandidate, type Candidate, type CommandResult, type DispatchDetail, type DispatchDraft } from '@/modules/overseas-warehouse'
import { createDispatch, getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, type BatchDispatchCandidate, type Candidate, type CommandResult, type DispatchDetail, type DispatchDraft } from '@/modules/overseas-warehouse'
import { ApiResponseError, http } from '@/shared/api/http'
import { parseApiJsonResponse } from '@chongchong/shared'
import { getReceivingContext, type ReceivingContext } from './receivingApi'
@@ -12,7 +12,7 @@ const identity = vi.hoisted(() => ({ id: '' }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.allowed && !access.denied.includes(permission) }) }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), convertDispatchDraft: vi.fn(), releaseDispatchDraft: vi.fn() }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), createDispatch: vi.fn(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), convertDispatchDraft: vi.fn(), releaseDispatchDraft: vi.fn() }))
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
const batch = 'FBA_APPOINTMENT:1'
@@ -21,7 +21,7 @@ function row(id = '9007199254740993', customerId = 'customer-a', key = batch): B
function candidate(value = row()): Candidate { const base = { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] }; return { ...base, selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, selectionGroup: { groupKey: base.sourceKey, groupVersion: 'group-1', sources: [{ selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, sourceKey: base.sourceKey, customerId: base.customerId, customerName: base.customerName, sourceBusinessType: null, sourceBusinessNo: null, packages: base.availablePackages }], pallets: [{ warehousePalletId: base.selection.warehousePalletId, palletNo: base.palletNo, sourceKeys: [base.sourceKey], packageCount: base.availablePackages }], complete: true, blockReasons: [] } } }
function detail(no = 'DS/001', version = 1): DispatchDetail {
const review = { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }
return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
return { publishLooseboardDemand: false, dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], exceptions: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
}
function proof(key: string): CommandResult { return { dispatchNo: 'DS/001', action: 'CREATE', idempotencyKey: key, recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING', occurredAt: '2026-10-19T00:00:00Z', effect: 'APPLIED', counts: detail().counts, result: { dispatchNo: 'DS/001', changedItemIds: [] }, replayed: false, alreadyArranged: false } }
let guard: LeaveGuard, natives: Candidate[], draft: DispatchDraft
@@ -33,7 +33,7 @@ it.each([0, '0', '9007199254740993'])('preserves authoritative allocation versio
const wrapper = render()
try {
await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations[0]!.recordVersion).toBe(recordVersion)
expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations[0]!.recordVersion).toBe(recordVersion)
} finally { wrapper.unmount() }
})
it.each([undefined, null, 'bad', 9007199254740992])('blocks an existing-batch candidate lacking a valid authoritative version: %s', async recordVersion => {
@@ -49,6 +49,7 @@ beforeEach(() => {
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key))
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [row('9007199254740993', 'customer-a', key)], blockReasons: [] }))
vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ total: 1, page: 1, size: 20, pages: 1, candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
vi.mocked(createDispatch).mockImplementation(async body => proof(body.idempotencyKey))
vi.mocked(createDispatchDraft).mockImplementation(async () => clone(draft))
vi.mocked(createDispatchDraftContainer).mockImplementation(async body => { draft.recordVersion = 1; draft.containers = [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: body.capacity, sourceKeys: [] }]; return clone(draft) })
vi.mocked(lockDispatchDraftPallet).mockImplementation(async body => { const source = natives.find(value => value.selection.planPalletId === body.source.planPalletId)!; draft.recordVersion = Number(draft.recordVersion) + 1; draft.sources.push(clone(source)); draft.containers[0]!.sourceKeys.push(source.sourceKey); return clone(draft) })
@@ -64,8 +65,8 @@ it('creates the whole dispatch when native Long candidate quantities arrive as s
try {
await flushPromises(); expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(convertDispatchDraft).toHaveBeenCalledOnce(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations)
expect(vi.mocked(lockDispatchDraftPallet).mock.calls[0]![0].source.planPalletId).toBe('9007199254740993'); expect(wrapper.emitted('updated')).toHaveLength(1)
expect(createDispatch).toHaveBeenCalledOnce(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual(row().allocations)
expect(vi.mocked(createDispatch).mock.calls[0]![0].cargo[0]!.planPalletId).toBe('9007199254740993'); expect(wrapper.emitted('updated')).toHaveLength(1)
} finally { wrapper.unmount(); get.mockRestore() }
})
it('restores existing batch dispatches after remount and navigates to the same whole dispatch without any creation', async () => {
@@ -74,7 +75,7 @@ it('restores existing batch dispatches after remount and navigates to the same w
const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('DS/EXISTING')
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2FEXISTING']]); expect(wrapper.text()).not.toContain('创建整车排车单'); wrapper.unmount()
}
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(createDispatchDraft).not.toHaveBeenCalled(); expect(convertDispatchDraft).not.toHaveBeenCalled()
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(createDispatchDraft).not.toHaveBeenCalled(); expect(createDispatch).not.toHaveBeenCalled()
})
it('does not fall back to new creation when transport relations fail or their nested batch ownership differs', async () => {
vi.mocked(getReceivingContext).mockRejectedValueOnce(new Error('unavailable')); const failed = render(); await flushPromises(); expect(failed.text()).toContain('批次可排实物读取失败'); expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); failed.unmount()
@@ -84,71 +85,52 @@ it('does not fall back to new creation when transport relations fail or their ne
it('reads remaining physical cargo only after an explicit choice while retaining the original dispatch', async () => {
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key, [{ dispatchNo: 'DS-OLD', batchKey: key, customerId: 'customer-a', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
const wrapper = render(); await flushPromises(); expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises(); expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith(batch); expect(wrapper.text()).toContain('DS-OLD'); expect(convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises(); expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith(batch); expect(wrapper.text()).toContain('DS-OLD'); expect(createDispatch).not.toHaveBeenCalled(); wrapper.unmount()
})
it('uses a server-owned draft, explicit zero versions, native source hashes and exact allocations for one whole dispatch', async () => {
it('creates the whole dispatch with native source versions and exact batch allocations', async () => {
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(createDispatchDraft).toHaveBeenCalledWith({ warehouseId: 'origin-1', idempotencyKey: expect.any(String) })
expect(createDispatchDraftContainer).toHaveBeenCalledWith({ draftToken: 'server-owned-token', recordVersion: 0, idempotencyKey: expect.any(String), capacity: 30 })
expect(lockDispatchDraftPallet).toHaveBeenCalledWith({ draftToken: 'server-owned-token', draftContainerId: 'container-1', recordVersion: 1, idempotencyKey: expect.any(String), source: natives[0]!.selection })
const request = vi.mocked(convertDispatchDraft).mock.calls[0]![0]
expect(request).toMatchObject({ draftToken: 'server-owned-token', recordVersion: 2, groups: [{ draftContainerId: 'container-1', dispatch: { warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null } }] })
expect(createDispatch).toHaveBeenCalledWith(expect.objectContaining({ publishLooseboardDemand: false, warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null }))
expect(wrapper.text()).toContain('DS/001'); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true)
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); wrapper.unmount()
})
it('combines same-warehouse real customers in one container and keeps each batch source owner', async () => {
const second = row('9007199254740994', 'customer-b'); natives.push(candidate(second)); vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), second], blockReasons: [] })
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = [{ dispatch: vi.mocked(createDispatch).mock.calls[0]![0] }]
expect(groups).toHaveLength(1); expect(groups[0]!.dispatch.cargo).toEqual(natives.map(value => value.selection)); expect(groups[0]!.dispatch.batchAllocations.map(value => value.customerId)).toEqual(['customer-a', 'customer-b']); expect(wrapper.text()).not.toContain('共车'); wrapper.unmount()
})
it('preserves exact distinct batch allocations while combining selected same-warehouse batches', async () => {
const otherKey = 'FBA_APPOINTMENT:2', second = row('9007199254740994', 'customer-b', otherKey); natives.push(candidate(second))
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [key === batch ? row() : second], blockReasons: [] }))
const wrapper = render({ batchKeys: [batch, otherKey] }); await flushPromises(); for (const input of wrapper.findAll('input[type="checkbox"]')) await input.setValue(true)
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...row().allocations, ...second.allocations]); wrapper.unmount()
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual([...row().allocations, ...second.allocations]); wrapper.unmount()
})
it('keeps blocked, mismatched-customer, wrong-source and unknown-quantity cargo out of the payload', async () => {
const blocked = { ...row('2'), blockReasons: ['本托已被其他排车占用'] }, wrong = row('3'), unknown = { ...row('4'), packageCount: null }, wrongSource = row('5')
wrong.allocations[0]!.customerId = 'customer-b'; wrongSource.allocations[0]!.sourceKey = 'ORDER:unrelated'; natives.push(...[blocked, wrong, unknown, wrongSource].map(value => candidate(value)))
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), blocked, wrong, unknown, wrongSource], blockReasons: [] })
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false])
expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations); wrapper.unmount()
expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual(row().allocations); wrapper.unmount()
})
it('accepts partial pages but fails closed on missing versions and cross-warehouse batches', async () => {
vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ total: 1, page: 1, size: 20, pages: 1, candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount()
natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.text()).toContain('来源版本不完整'); missing.unmount()
vi.mocked(getReceivingContext).mockImplementation(async key => { const value = receiving(key); if (key !== batch) value.batch.originWarehouseId = 'another-warehouse'; return value })
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(convertDispatchDraft).not.toHaveBeenCalled(); cross.unmount()
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(createDispatch).not.toHaveBeenCalled(); cross.unmount()
})
it('retains the entire original conversion and skips all prior locks after an unknown response, even after remount with the work closed', async () => {
vi.mocked(convertDispatchDraft).mockRejectedValueOnce(new Error('lost response'))
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(convertDispatchDraft).mock.calls[0]![0])
expect(await guard()).toBe(false); wrapper.unmount()
const restored = render({ canWrite: false }); await flushPromises(); expect(restored.text()).toContain('原请求已保留'); await button(restored, '核对并重试原请求').trigger('click'); await flushPromises()
expect(convertDispatchDraft).toHaveBeenNthCalledWith(2, original); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(restored.emitted('updated')).toHaveLength(1); restored.unmount()
})
it('retries the exact container command after a lost response instead of generating another container key', async () => {
vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('unknown container'))
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(createDispatchDraftContainer).mock.calls[0]![0])
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(createDispatchDraftContainer).toHaveBeenNthCalledWith(2, original); expect(createDispatchDraft).toHaveBeenCalledTimes(1); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); wrapper.unmount()
})
it('keeps a proven CREATE across failed current-detail GET and only repeats the read', async () => {
it('shows the confirmed dispatch link even when its detail read fails', async () => {
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('GET unavailable'))
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(wrapper.emitted('updated')).toBeUndefined()
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(getDispatch).toHaveBeenCalledTimes(2); expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
})
it('never releases an unknown result for a later rejection, and releases only an explicitly rejected owned draft at its read-back version', async () => {
vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409))
const unknown = render(); await flushPromises(); await button(unknown, '创建整车排车单').trigger('click'); await flushPromises(); await button(unknown, '核对并重试原请求').trigger('click'); await flushPromises(); expect(unknown.text()).not.toContain('按明确拒绝结果释放'); expect(await guard()).toBe(false); unknown.unmount()
sessionStorage.clear(); vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409)); draft = { ...draft, draftToken: 'another-owned-token', containers: [], recordVersion: 0 }
const rejected = render(); await flushPromises(); await button(rejected, '创建整车排车单').trigger('click'); await flushPromises(); draft.recordVersion = 9
await button(rejected, '按明确拒绝结果释放本草稿并重新核对').trigger('click'); await flushPromises(); expect(releaseDispatchDraft).toHaveBeenCalledWith({ draftToken: 'another-owned-token', recordVersion: 9, idempotencyKey: expect.any(String) }); rejected.unmount()
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(createDispatch).toHaveBeenCalledTimes(1); expect(wrapper.emitted('updated')).toHaveLength(1)
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); expect(await guard()).toBe(true); wrapper.unmount()
})
it('preserves quantity-split navigation and uses create permission rather than the removed smart-dispatch permission', async () => {
access.denied = ['platform:overseas-warehouse:inbound-order:smart-dispatch']; const wrapper = render(); await flushPromises()
await button(wrapper, '按当前批次箱数分批').trigger('click'); expect(wrapper.emitted('batch')).toEqual([[batch]]); expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(createDispatch).toHaveBeenCalledTimes(1)
await button(wrapper, '为本车添加同仓货物').trigger('click'); expect(wrapper.getComponent({ name: 'WorkSharedTripForm' }).props('dispatchNo')).toBe('DS/001'); wrapper.unmount()
})
it('does not read scoped cargo without original permissions or create while only read access remains', async () => {
@@ -158,7 +140,7 @@ it('does not read scoped cargo without original permissions or create while only
it('shows batch-level blockers even without rows and refuses duplicate physical candidates across batches', async () => {
vi.mocked(getBatchDispatchCandidates).mockResolvedValueOnce({ batchKey: batch, version: 4, candidates: [], blockReasons: ['原批次已替代,请进入当前批次'] })
const blocked = render(); await flushPromises(); expect(blocked.get('[role="alert"]').text()).toContain('原批次已替代'); expect(createDispatchDraft).not.toHaveBeenCalled(); blocked.unmount()
const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(convertDispatchDraft).not.toHaveBeenCalled(); duplicate.unmount()
const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(createDispatch).not.toHaveBeenCalled(); duplicate.unmount()
})
it('keeps original batch allocations and selected cargo while paging through associated plan candidates', async () => {
@@ -176,8 +158,8 @@ it('keeps original batch allocations and selected cargo while paging through ass
expect(getBatchDispatchCandidates).toHaveBeenCalledTimes(1)
expect(listDispatchCandidates).toHaveBeenCalledTimes(2)
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...first.allocations, ...second.allocations])
expect(createDispatch).toHaveBeenCalledTimes(1)
expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual([...first.allocations, ...second.allocations])
} finally { wrapper.unmount() }
})
it('rejects a selected batch source whose group version changed on another candidate page', async () => {
@@ -195,3 +177,13 @@ it('rejects a selected batch source whose group version changed on another candi
expect(createDispatchDraft).not.toHaveBeenCalled()
} finally { wrapper.unmount() }
})
it('failure preserves selection, releases navigation and creates a fresh request on the next click', async () => {
vi.mocked(createDispatch).mockRejectedValueOnce(new Error('timeout'))
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
const original = vi.mocked(createDispatch).mock.calls[0]![0]
expect(wrapper.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect((wrapper.get('input[type=checkbox]').element as HTMLInputElement).checked).toBe(true)
expect(await guard()).toBe(true); expect(sessionStorage.length).toBe(0)
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(vi.mocked(createDispatch).mock.calls[1]![0].idempotencyKey).not.toBe(original.idempotencyKey); expect(getReceivingContext).toHaveBeenCalledTimes(1); wrapper.unmount()
})
@@ -6,7 +6,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
import { getDispatch, createDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, dispatchVehicleSpecs, DispatchInputError, requireBatchVersions, expandSelectionGroups, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, type BatchDispatchCandidate, type BatchDispatchCandidates, type DispatchDetail, type Candidate } from '@/modules/overseas-warehouse'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -17,11 +17,8 @@ import { useWorkDraft } from './useWorkDraft'
const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>()
const emit = defineEmits<{ updated: []; external: [path: string]; batch: [key: string] }>()
type BatchRow = BatchDispatchCandidate & { key: string; batchKey: string; native: Candidate | null; blockReasons: string[] }
type ReadyRow = BatchRow & { native: Candidate }
type Step = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'CONVERT' | 'RELEASE'; body: Record<string, unknown>; attempted: boolean; rejected: boolean }
type Preparation = { warehouseId: string; capacity: number; rows: ReadyRow[]; dispatchKey: string; draft: DispatchDraft | null; containerId: string | null; lockedKeys: string[]; step: Step | null; converted: ConvertedDraft | null; releasing: boolean }
const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || ''
const scope = JSON.stringify([...new Set(props.batchKeys)].sort()), storageKey = `appointment-dispatch:${owner}:${scope}`
const scope = JSON.stringify([...new Set(props.batchKeys)].sort())
const contexts = ref<BatchDispatchCandidates[]>([]), rows = ref<BatchRow[]>([]), selected = ref<string[]>([]), warehouseId = ref('')
const working = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error, listDispatchCandidates)
@@ -30,26 +27,24 @@ const loading = computed(() => working.value || candidatePage.loading.value)
const selectedRows = ref<Record<string, BatchRow>>({})
let receivingContexts: Awaited<ReturnType<typeof getReceivingContext>>[] = []
let nativeGeneration = 0
const capacity = ref(30), result = ref<DispatchDetail | null>(null), pending = ref<Preparation | null>(null), corrupted = ref(false)
const createdNo = ref('')
const capacity = ref(30), result = ref<DispatchDetail | null>(null)
const adding = ref(false), addingBusy = ref(false), arrangingRemaining = ref(false), existingReady = ref(false)
const existing = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), baseline = ref('[]')
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
const canCreate = computed(() => readable.value && access.can('platform:overseas-warehouse:dispatch:create'))
const writable = computed(() => props.canWrite && canCreate.value && !corrupted.value && (!existing.value.length || existingReady.value))
const writable = computed(() => props.canWrite && canCreate.value && (!existing.value.length || existingReady.value))
const chosen = computed(() => selected.value.map(key => selectedRows.value[key]).filter((row): row is BatchRow => Boolean(row)))
const unresolved = computed(() => saving.value || Boolean(pending.value) || corrupted.value)
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved)
const unresolved = computed(() => saving.value)
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !createdNo.value), unresolved)
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
let generation = 0
function ensureOwner() { if (!owner || auth.user?.id !== owner || JSON.stringify([...new Set(props.batchKeys)].sort()) !== scope) throw new DispatchInputError('排车登录身份或批次范围已变化,请重新进入原事项') }
function persist() { ensureOwner(); if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
try { const saved = owner && sessionStorage.getItem(storageKey); if (saved) pending.value = JSON.parse(saved) as Preparation }
catch { corrupted.value = true; saveError.value = { message: '原排车请求记录损坏,请保留现场核对,不能另建排车单' } }
function reasons(row: BatchRow) { return row.blockReasons }
function physicalCount(values: BatchRow[]) { return new Set(values.flatMap(row => row.native?.selectionGroup.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo || row.key) || [])).size }
function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
async function load() {
if (!readable.value || pending.value || corrupted.value) return
if (!readable.value) return
const current = ++generation, batches = [...new Set(props.batchKeys)]
working.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []; selectedRows.value = {}; candidatePage.reset(); nativeGeneration++
try {
@@ -122,105 +117,24 @@ function select(row: BatchRow, event: Event) {
}
input.checked = selected.value.includes(row.key)
}
function step(action: Step['action'], body: object) { pending.value!.step = { action, body: clone(body) as Record<string, unknown>, attempted: false, rejected: false }; persist() }
function common(draft: DispatchDraft) { return { recordVersion: requireVersion(draft.recordVersion), idempotencyKey: crypto.randomUUID(), draftToken: draft.draftToken } }
async function executeStep() {
const request = pending.value!, command = request.step!, previouslyAttempted = command.attempted
command.attempted = true; persist()
try {
let value: DispatchDraft | ConvertedDraft
if (command.action === 'CREATE') value = await createDispatchDraft(command.body as unknown as { idempotencyKey: string; warehouseId: string })
else if (command.action === 'CONTAINER') value = await createDispatchDraftContainer(command.body as unknown as VersionCommand & { draftToken: string; capacity: number })
else if (command.action === 'LOCK') value = await lockDispatchDraftPallet(command.body as unknown as DraftSelection)
else if (command.action === 'RELEASE') value = await releaseDispatchDraft(command.body as unknown as VersionCommand & { draftToken: string })
else value = await convertDispatchDraft(command.body as unknown as ConvertDraft)
ensureOwner()
if (!value.draftToken || command.body.draftToken && value.draftToken !== command.body.draftToken) throw new DispatchInputError('草稿结果与原请求不一致,请保留原请求')
if (command.action === 'CONVERT') {
const converted = value as ConvertedDraft
if (converted.vehicles.length !== 1 || converted.vehicles[0]?.draftContainerId !== request.containerId) throw new DispatchInputError('整车结果与原装车容器不一致')
validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey } })
request.converted = converted
} else {
const draft = value as DispatchDraft; requireVersion(draft.recordVersion)
if (draft.warehouseId !== request.warehouseId) throw new DispatchInputError('草稿起运仓与原批次不一致')
if (command.action === 'CONTAINER') {
if (draft.containers.length !== 1) throw new DispatchInputError('本次草稿装车容器不唯一,请保留原请求核对')
request.containerId = draft.containers[0]!.draftContainerId
}
if (command.action === 'LOCK') {
const selectedSource = command.body.source as DraftSelection['source']
const expected = request.rows.find(row => row.native.selection.planPalletId === selectedSource.planPalletId && row.native.selection.sourceType === selectedSource.sourceType && row.native.sourceVersion === selectedSource.sourceVersion)
const source = expected && draft.sources.find(row => row.sourceKey === expected.native.sourceKey && row.customerId === expected.customerId)
if (!source || !draft.containers.find(row => row.draftContainerId === request.containerId)?.sourceKeys.includes(source.sourceKey)) throw new DispatchInputError('原来源未确认进入本装车组,请核对原请求')
const group = requireSelectionGroup(source)
assertSelectionGroupVersions([expected!.native], [source])
const boundKeys = draft.containers.find(row => row.draftContainerId === request.containerId)!.sourceKeys
if (group.sources.some(required => !boundKeys.includes(required.sourceKey))) throw new DispatchInputError('完整选货组尚未原子进入本装车组,请核对原请求')
request.lockedKeys.push(...group.sources.map(required => required.sourceKey))
}
request.draft = draft
}
request.step = null; persist()
} catch (cause) {
ensureOwner(); if (!previouslyAttempted && isDefiniteDispatchRejection(cause)) command.rejected = true
persist(); throw cause
}
}
async function save() {
if (saving.value || loading.value || corrupted.value || !canCreate.value) return
saveError.value = null
if (saving.value || loading.value || !canCreate.value) return
saveError.value = null; saving.value = true
try {
ensureOwner()
if (!pending.value) {
if (!writable.value || error.value || result.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
pending.value = { warehouseId: warehouseId.value, capacity: capacity.value, rows: clone(chosen.value) as ReadyRow[], dispatchKey: crypto.randomUUID(), draft: null, containerId: null, lockedKeys: [], step: null, converted: null, releasing: false }; persist()
}
saving.value = true
const request = pending.value
while (!request.converted) {
if (!request.step) {
if (request.releasing) { step('RELEASE', common(request.draft!)) }
else if (!request.draft) step('CREATE', { idempotencyKey: crypto.randomUUID(), warehouseId: request.warehouseId })
else if (!request.containerId) step('CONTAINER', { ...common(request.draft), capacity: request.capacity })
else {
const next = request.rows.find(row => !request.lockedKeys.includes(row.native.sourceKey))
if (next) step('LOCK', { ...common(request.draft), draftContainerId: request.containerId, source: next.native.selection })
else {
const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key))
if (cargo.some(source => !source || source.warehouseId !== request.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion || request.rows.some(row => row.native.sourceKey === source.sourceKey && row.customerId !== source.customerId))) throw new DispatchInputError('草稿真实来源尚未完整恢复,请保留原请求核对')
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { publishLooseboardDemand: true, idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo as Candidate[]), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft)
}
}
}
await executeStep()
if (request.releasing) { pending.value = null; persist(); selected.value = []; await load(); return }
}
// Immutable CREATE proof is persisted before reading current facts; GET failure never reconverts.
const proof = request.converted!.vehicles[0]!.dispatch, latest = await getDispatch(proof.dispatchNo)
ensureOwner(); validateCurrentDispatch(latest, proof)
result.value = latest; pending.value = null; persist(); selected.value = []; baseline.value = '[]'
ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`); emit('updated')
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次整车结果待确认,请核对并重试原请求') }
if (!writable.value || error.value || createdNo.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
const body = { publishLooseboardDemand: false, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(chosen.value.map(row => row.native!)), batchAllocations: chosen.value.flatMap(row => row.allocations) }
const proof = await createDispatch(body); ensureOwner()
validateCommandResult(proof, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: body.idempotencyKey } })
createdNo.value = proof.dispatchNo; selected.value = []; baseline.value = '[]'; emit('updated')
const latest = await getDispatch(proof.dispatchNo); ensureOwner(); validateCurrentDispatch(latest, proof)
result.value = latest; selected.value = []; baseline.value = '[]'
ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`)
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车创建失败,请刷新核对当前货物后重新操作') }
finally { saving.value = false }
}
async function releaseRejected() {
if (saving.value || !pending.value?.step?.rejected || pending.value.converted) return
saving.value = true; saveError.value = null
try {
ensureOwner(); const request = pending.value
if (!request.draft) { pending.value = null; persist(); return }
const owned = (await listDispatchDrafts()).find(row => row.draftToken === request.draft!.draftToken)
ensureOwner()
if (!owned || owned.warehouseId !== request.warehouseId) throw new DispatchInputError('原拥有者草稿尚未读回,请保留现场核对')
request.draft = owned; request.releasing = true; request.step = null; persist()
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '原草稿版本读取失败') }
finally { saving.value = false }
if (pending.value?.releasing) await save()
else if (!pending.value) await load()
}
async function remaining() { if (!unresolved.value && !addingBusy.value) { result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
async function remaining() { if (!unresolved.value && !addingBusy.value) { createdNo.value = ''; result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
onMounted(load)
onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset() })
</script>
@@ -230,12 +144,12 @@ onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset()
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在核对批次、客户、起运仓与实物来源…</p>
<template v-if="existing.length && !loading && !error && !result && !pending">
<template v-if="existing.length && !loading && !error && !createdNo">
<WorkExistingDispatchForm :key="JSON.stringify(existing)" :dispatches="existing" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
<el-button v-if="!arrangingRemaining && writable" @click="remaining">核对本批剩余可排实物</el-button>
</template>
<template v-if="result"><p>已创建整车排车单:{{ result.dispatchNo }}</p><el-button @click="emit('external', detailPath(result.dispatchNo))">继续维护本车</el-button><el-button v-if="canWrite && access.can('platform:overseas-warehouse:dispatch:edit') && result.actions.ADD_CARGO?.allowed === true" @click="adding = true">为本车添加同仓货物</el-button><WorkSharedTripForm v-if="adding" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><el-button :disabled="addingBusy" @click="remaining">继续核对剩余实物</el-button></template>
<template v-else-if="!pending && !corrupted && readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
<template v-if="createdNo"><p>已创建整车排车单:{{ createdNo }}</p><el-button @click="emit('external', detailPath(createdNo))">继续维护本车</el-button><el-button v-if="canWrite && access.can('platform:overseas-warehouse:dispatch:edit') && result?.actions.ADD_CARGO?.allowed === true" @click="adding = true">为本车添加同仓货物</el-button><WorkSharedTripForm v-if="adding" :dispatch-no="createdNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><el-button :disabled="addingBusy" @click="remaining">继续核对剩余实物</el-button></template>
<template v-else-if="readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
<p v-for="reason in [...new Set(contexts.flatMap(context => context.blockReasons))]" :key="reason" role="alert">{{ reason }}</p>
<el-button v-if="batchKeys.length === 1" :disabled="!writable || unresolved" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p>部分箱数先按本批逐来源数量分批;不对应完整实物时,先在原库存作业拆托。</p>
<label>本组计划托位<select v-model.number="capacity" :disabled="!writable || unresolved" class="workbench-input"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
@@ -244,7 +158,6 @@ onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset()
<p>上方总数为关联计划的候选数;当前页展示其中属于本批次的实物。已选 {{ chosen.length }} 条(含其他页)。</p>
<el-button type="primary" :disabled="!writable || !chosen.length" :loading="saving" @click="save">创建整车排车单</el-button>
</template>
<p v-if="pending" role="status">本次草稿与原请求已保留,请勿改变来源、版本或另建排车单。<el-button :disabled="!canCreate || saving" :loading="saving" @click="save">核对并重试原请求</el-button></p>
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button v-if="pending?.step?.rejected && !pending.converted" :loading="saving" @click="releaseRejected">按明确拒绝结果释放本草稿并重新核对</el-button>
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
</section>
</template>
@@ -24,16 +24,16 @@ it('opens a real plan in the exact customer scope while leaving missing plans un
it('does not read the container source without its original permission', async () => {
state.canRead = false; const wrapper = render(); await flushPromises(); expect(getContainerPlanProgress).not.toHaveBeenCalled(); wrapper.unmount()
})
it('submits the actual preview snapshot and replays the same request after an unknown result', async () => {
it('submits the actual preview snapshot and permits a fresh click after an unknown result', async () => {
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(preview())
vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new Error('lost response')).mockResolvedValueOnce([])
const wrapper = render(); await flushPromises(); await button(wrapper, '核对本柜入库计划预览').trigger('click'); await flushPromises()
await button(wrapper, '确认生成本柜计划').trigger('click'); await flushPromises()
const original = vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[0]!
expect(original).toEqual([{ sourceContainerBookingId: '9007199254740993', snapshotVersion: 4, snapshotHash: 'snapshot-4' }, expect.any(String)])
expect(await guard()).toBe(false)
await button(wrapper, '重试本次计划生成').trigger('click'); await flushPromises()
expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]).toEqual(original)
expect(await guard()).toBe(true)
await button(wrapper, '确认生成本柜计划').trigger('click'); await flushPromises()
expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]![0]).toEqual(original[0]); expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]![1]).not.toBe(original[1])
expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
})
it('uses the original resource recovery snapshot and key instead of creating new plans', async () => {
@@ -35,9 +35,9 @@ async function createPlans() {
await convertLastMileContainerToInboundPlans(pending.value.body, pending.value.key)
pending.value = null; preview.value = null; ElMessage.success('本柜入库计划已确认,请查看原接仓记录'); emit('updated'); await load()
} catch (cause) {
saveError.value = getApiErrorDetails(cause, '入库计划生成结果待确认,请重试原请求')
saveError.value = getApiErrorDetails(cause, '入库计划生成失败,请重新操作')
if (rejectedCommand(cause)) { pending.value = null; preview.value = null }
} finally { saving.value = false }
} finally { pending.value = null; saving.value = false }
}
async function load() { if (!readable.value) return; loading.value = true; error.value = null; try { detail.value = await getContainerPlanProgress(props.containerBookingId) } catch (cause) { error.value = getApiErrorDetails(cause, '本柜入仓计划读取失败') } finally { loading.value = false } }
onMounted(load)
@@ -48,7 +48,7 @@ onMounted(load)
<template v-if="detail && !error"><p>{{ detail.summary.containerNo || '柜号待核实' }} · {{ detail.summary.overseasWarehouseName || '接仓仓库待核实' }}</p><p v-if="detail.summary.blockingReason">{{ detail.summary.blockingReason }}</p><table class="density-table"><thead><tr><th>操作</th><th>入库单号</th><th>入仓码 / 订单号</th></tr></thead><tbody><tr v-for="row in detail.orders" :key="`${row.customerId}:${row.orderId}`"><td><el-button :disabled="saving || !!pending || !row.planNo || !row.customerId || !row.planValid || !access.can('platform:overseas-warehouse:inbound-order:list')" @click="emit('external', `/overseas-warehouse/inbound-orders/${encodeURIComponent(row.planNo!)}?customerId=${encodeURIComponent(row.customerId)}`)">查看原接仓记录</el-button></td><td>{{ row.planNo || '尚未生成有效计划' }}</td><td>{{ row.inboundCode || row.orderNo || '—' }}</td></tr></tbody></table><p v-if="!detail.orders.length">本柜尚无可读取的入仓来源。</p></template>
<el-button v-if="writable && !preview && !pending" :loading="previewLoading" @click="loadPreview">核对本柜入库计划预览</el-button>
<LoadErrorState v-if="previewError" title="入库计划预览失败" :message="previewError.message" :loading="previewLoading" @retry="loadPreview"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState>
<div v-if="preview && !previewError"><h4>核对本柜计划与托盘</h4><p>{{ preview.containerNo || '柜号待核实' }} · {{ preview.overseasWarehouseName || '接仓仓库待核实' }} · {{ preview.pendingOrderCount }} 票待生成 · {{ preview.blockedOrderCount }} 票待处理</p><p>头程已有 {{ preview.sourcePalletCount }} 托;海外补充 {{ preview.supplementPalletCount }} 托;已保存计划 {{ preview.totalPlannedPalletCount }} 托。</p><table class="density-table"><thead><tr><th>客户</th><th>原票</th><th>目的地</th><th>箱数</th><th>目标托数</th><th>计划状态</th></tr></thead><tbody><tr v-for="row in preview.groups.flatMap(group => group.orders)" :key="`${row.customerId}:${row.orderId}`"><td>{{ row.customerName || '客户名称待核实' }}</td><td>{{ row.inboundCode || row.businessOrderNo || '—' }}</td><td>{{ row.destinationWarehouseName || row.fbaWarehouseCode || (row.destinationType === 'PRIVATE_ADDRESS' ? '客户地址' : '目的地待核实') }}</td><td>{{ row.packageCount }}</td><td>{{ row.targetPalletCount }}</td><td>{{ row.existingPlanNo || ({ READY: '可生成', RESOURCE_PENDING: '资源待确认', PLANNED: '已有计划', BLOCKED: '来源条件未就绪' }[row.readiness]) }}</td></tr></tbody></table><p v-if="!preview.ready">本柜尚不具备生成条件,请在原尾程订单核对阻塞来源。</p><el-button type="primary" :loading="saving" :disabled="!writable || (!pending && (!preview.ready || (!preview.resourceRetry && preview.pendingOrderCount <= 0)))" @click="createPlans">{{ pending ? '重试本次计划生成' : preview.resourceRetry ? '重试原资源确认' : '确认生成本柜计划' }}</el-button></div>
<div v-if="preview && !previewError"><h4>核对本柜计划与托盘</h4><p>{{ preview.containerNo || '柜号待核实' }} · {{ preview.overseasWarehouseName || '接仓仓库待核实' }} · {{ preview.pendingOrderCount }} 票待生成 · {{ preview.blockedOrderCount }} 票待处理</p><p>头程已有 {{ preview.sourcePalletCount }} 托;海外补充 {{ preview.supplementPalletCount }} 托;已保存计划 {{ preview.totalPlannedPalletCount }} 托。</p><table class="density-table"><thead><tr><th>客户</th><th>原票</th><th>目的地</th><th>箱数</th><th>目标托数</th><th>计划状态</th></tr></thead><tbody><tr v-for="row in preview.groups.flatMap(group => group.orders)" :key="`${row.customerId}:${row.orderId}`"><td>{{ row.customerName || '客户名称待核实' }}</td><td>{{ row.inboundCode || row.businessOrderNo || '—' }}</td><td>{{ row.destinationWarehouseName || row.fbaWarehouseCode || (row.destinationType === 'PRIVATE_ADDRESS' ? '客户地址' : '目的地待核实') }}</td><td>{{ row.packageCount }}</td><td>{{ row.targetPalletCount }}</td><td>{{ row.existingPlanNo || ({ READY: '可生成', RESOURCE_PENDING: '资源待确认', PLANNED: '已有计划', BLOCKED: '来源条件未就绪' }[row.readiness]) }}</td></tr></tbody></table><p v-if="!preview.ready">本柜尚不具备生成条件,请在原尾程订单核对阻塞来源。</p><el-button type="primary" :loading="saving" :disabled="!writable || (!pending && (!preview.ready || (!preview.resourceRetry && preview.pendingOrderCount <= 0)))" @click="createPlans">{{ preview.resourceRetry ? '重试原资源确认' : '确认生成本柜计划' }}</el-button></div>
<LoadErrorState v-if="saveError" title="入库计划尚未确认" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
</section>
</template>
@@ -4,15 +4,16 @@ import { http } from '@/shared/api/http'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data }))
const range = { from: '2026-10-01', to: '2026-11-01' }
function trip(dispatchNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
return { sourceType: 'DISPATCH', dispatchNo, containerNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
return { sourceType: 'DISPATCH', dispatchNo, containerNumbers: [], cabinetNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
}
describe('location-based trip calendar', () => {
it('places the reported UTC plan on October 22 at 09:00 in Los Angeles and exposes every container in one event', () => {
const row = { ...trip('PC-CALENDAR-001', '2026-10-22T16:00:00Z'), containerNumbers: ['TSTU1234567', 'TSTU7654321'] }
const row = { ...trip('PC-CALENDAR-001', '2026-10-22T16:00:00Z'), containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: ['D1821', 'D1822'] }
const projected = projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, range)
expect(projected.events).toHaveLength(1)
expect(projected.events[0]).toMatchObject({ key: 'dispatch-PC-CALENDAR-001', date: '2026-10-22', time: '09:00', sourcePath: '/overseas-warehouse/dispatches/PC-CALENDAR-001' })
expect(projected.events[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' })
expect(projected.events[0]!.fields).toContainEqual({ label: '关联柜次号', value: 'D1821、D1822' })
expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' })
expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(UTC)', value: '2026-10-22T16:00:00Z' })
const beijing = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
@@ -5,6 +5,7 @@ export interface TripCalendarEntry {
sourceType: 'DISPATCH'
dispatchNo: string
containerNumbers: string[]
cabinetNumbers: string[]
status: string
originWarehouseId: string | null
originWarehouseCode: string | null
@@ -81,6 +82,7 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
fields: [
{ label: '运输状态', value: labels[row.status] || '待核实' },
{ label: '关联柜号', value: row.containerNumbers.join('、') },
{ label: '关联柜次号', value: row.cabinetNumbers.join('、') },
{ label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode },
{ label: '地点时区', value: row.originTimezone },
{ label: '计划出发(起运仓当地)', value: planned ? `${planned.date} ${planned.time}` : null },
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { ApiResponseError, http } from '@/shared/api/http'
import { createDeclarationBatch, submitDeclarationBatch, recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms'
import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, downloadContainerCustomsFinal, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
const customs = {
aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n,
@@ -99,7 +99,42 @@ describe('platform container customs API', () => {
expect(get.mock.calls).toEqual([
[`/platform/v1/customs/loading-plans/documents/${documentId}/download`, { responseType: 'blob' }],
[`/platform/v1/customs/loading-plans/documents/${documentId}/receipt/download`, { responseType: 'blob' }],
[`/platform/v1/customs/loading-plans/documents/${documentId}/completion/download`, { responseType: 'blob' }],
])
})
it.each([downloadContainerCustomsDocument, downloadContainerCustomsReceipt, downloadContainerCustomsFinal])('拒绝 HTTP 200 中的真实 JSON Blob 业务错误 %#', async (download) => {
const body = new Blob([JSON.stringify({ code: 'A0404', message: '预录单不存在', data: null, requestId: 'request-customs-blob', traceId: 'trace-customs-blob' })], { type: 'application/json;charset=UTF-8' })
vi.spyOn(http, 'get').mockResolvedValue(body)
const error: unknown = await download('2092438843630391407').catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({ code: 'A0404', message: '预录单不存在', status: 200, requestId: 'request-customs-blob', traceId: 'trace-customs-blob' })
})
it('非 2xx 的 JSON Blob 也保留业务错误和诊断信息', async () => {
const body = new Blob([JSON.stringify({ code: 'A0403', message: '无权下载预录单', data: null, requestId: 'request-forbidden', traceId: 'trace-forbidden' })], { type: 'application/json' })
vi.spyOn(http, 'get').mockRejectedValue({ isAxiosError: true, response: { status: 403, data: body } })
await expect(downloadContainerCustomsReceipt('7')).rejects.toMatchObject({ code: 'A0403', message: '无权下载预录单', status: 403, requestId: 'request-forbidden', traceId: 'trace-forbidden' })
})
it.each([
['application/pdf', '%PDF-1.7 原文件'],
['application/octet-stream', '原始二进制'],
['application/json', '{"product":"报关资料","code":"ITEM-1"}'],
['application/json', '{invalid-json'],
])('保留原文件内容与对象:%s', async (type, contents) => {
const body = new Blob([contents], { type })
vi.spyOn(http, 'get').mockResolvedValue(body)
await expect(downloadContainerCustomsFinal('7')).resolves.toBe(body)
})
it('非 JSON 的传输错误沿用原始错误', async () => {
const error = { isAxiosError: true, response: { status: 502, data: new Blob(['bad gateway'], { type: 'text/html' }) } }
vi.spyOn(http, 'get').mockRejectedValue(error)
await expect(downloadContainerCustomsReceipt('7')).rejects.toBe(error)
})
})
@@ -1,4 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import axios from 'axios'
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED'
export type ContainerCustomsTaxMode = 'TAX_REFUND' | 'BUY_DECLARATION'
@@ -210,16 +211,45 @@ export async function getContainerCustoms(loadingPlanId: string) {
return value ? normalize(value) : null
}
// 报关资料允许下载原始 JSON 文件,仅把统一业务错误信封识别为失败。
async function customsDownloadError(blob: Blob, status: number): Promise<ApiResponseError | null> {
if (!blob.type.toLowerCase().includes('json')) return null
let payload: unknown
try { payload = JSON.parse(await blob.text()) } catch { return null }
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
const envelope = payload as Record<string, unknown>
if (typeof envelope.code !== 'string' || envelope.code === '200'
|| typeof envelope.message !== 'string' || !('data' in envelope)) return null
return new ApiResponseError(envelope.message, envelope.code, status, envelope.data,
typeof envelope.requestId === 'string' ? envelope.requestId : undefined,
typeof envelope.traceId === 'string' ? envelope.traceId : undefined)
}
async function downloadCustomsFile(path: string) {
try {
const blob = await http.get<Blob, Blob>(path, { responseType: 'blob' })
const error = await customsDownloadError(blob, 200)
if (error) throw error
return blob
} catch (error) {
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
const downloadError = await customsDownloadError(error.response.data, error.response.status)
if (downloadError) throw downloadError
}
throw error
}
}
export function downloadContainerCustomsDocument(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' })
return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/download`)
}
export function downloadContainerCustomsReceipt(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/receipt/download`, { responseType: 'blob' })
return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/completion/download`)
}
export function downloadContainerCustomsFinal(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`, { responseType: 'blob' })
return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`)
}
async function action(loadingPlanId: string, name: 'start' | 'send-to-broker' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) {
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus, { ElMessageBox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { ApiResponseError } from '@/shared/api/http'
import { ApiResponseError, http } from '@/shared/api/http'
import type { ContainerCustomsStatus, ContainerCustomsView, DeclarationBatch, ClearanceResultType } from '../api/platformContainerCustoms'
const api = vi.hoisted(() => ({
@@ -94,6 +94,8 @@ describe('ContainerCustomsPanel', () => {
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(wrapper.text()).toContain('ENTRY-001')
expect(wrapper.text()).not.toContain('版本')
await wrapper.findAll('button').find(button => button.text() === '标记海关异常/结果')!.trigger('click')
const state = (wrapper.vm.$ as unknown as { setupState: {
clearanceBatch: DeclarationBatch; clearanceResultType: ClearanceResultType; clearanceRemark: string; clearanceDialogVisible: boolean
@@ -167,11 +169,11 @@ describe('ContainerCustomsPanel', () => {
expect(wrapper.find('[aria-label="报关概况"]').exists()).toBe(false)
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('客户退税资料')
expect(wrapper.text()).toContain('报关行回执')
expect(wrapper.text()).toContain('预录单')
expect(wrapper.text()).not.toContain('客户报关资料')
expect(wrapper.find('[aria-label="下载客户退税资料 客户退税资料.pdf"]').exists()).toBe(true)
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
expect(buttonTexts(wrapper)).not.toContain('覆盖预录单')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
@@ -204,13 +206,13 @@ describe('ContainerCustomsPanel', () => {
it('allows completion upload and overwrite only while DECLARING with manage permission', async () => {
let wrapper = await render('DECLARING', true, false)
expect(wrapper.text()).toContain('首次发送报关行')
expect(wrapper.text()).toContain('首次发送后上传回执')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
expect(wrapper.text()).toContain('首次发送后上传预录单')
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
wrapper.unmount()
wrapper = await render('DECLARING', true, true)
expect(wrapper.text()).toContain('发送报关行执行')
expect(wrapper.text()).toContain('覆盖报关行回执')
expect(wrapper.text()).toContain('覆盖预录单')
expect(wrapper.text()).toContain('报关行回执.pdf')
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一')
@@ -218,8 +220,8 @@ describe('ContainerCustomsPanel', () => {
wrapper = await render('DECLARING', false, true)
expect(wrapper.text()).toContain('只读')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
expect(buttonTexts(wrapper)).not.toContain('覆盖预录单')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
@@ -235,14 +237,40 @@ describe('ContainerCustomsPanel', () => {
wrapper.unmount()
})
it('隐藏资料版本但保留预录单历史、原文件名和上传并发版本', async () => {
const value = customs('DECLARING', true)
const document = value.documents[0]!
document.receipt!.version = 7
document.receiptHistory = [{ version: 6, file: { ...document.receipt!, version: 6 }, operatorName: '报关员', createdAt: '2026-08-29T10:50:00' }]
api.get.mockResolvedValue(value)
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: value.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(wrapper.findAll('th').map(header => header.text())).toEqual(['操作', '客户退税资料', '预录单', '真实报关完税文件', '关联入仓码', '报关行状态', '客户', '预录单情况'])
expect(wrapper.text()).not.toMatch(/版本|退税资料 v|回执 v|完税 v/)
expect(wrapper.text()).toContain('预录单历史:2026/8/29 10:50:00 · 报关员')
expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一')
expect(wrapper.find('[aria-label="下载预录单 报关行回执.pdf"]').exists()).toBe(true)
const selected = new File(['receipt'], '新预录单.pdf', { type: 'application/pdf' })
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
onChange({ raw: selected })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '提交')!.trigger('click')
await flushPromises()
expect(api.upload).toHaveBeenCalledWith(document.documentId, selected, 7)
wrapper.unmount()
})
it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => {
const wrapper = await render('COMPLETED', true, true)
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('报关行回执.pdf')
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
expect(wrapper.text()).toContain('报关已完成,待其他离仓条件')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
expect(buttonTexts(wrapper)).not.toContain('覆盖预录单')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
@@ -253,7 +281,7 @@ describe('ContainerCustomsPanel', () => {
expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407')
expect(api.downloadReceipt).not.toHaveBeenCalled()
await wrapper.get('[aria-label="下载报关行回执 报关行回执.pdf"]').trigger('click')
await wrapper.get('[aria-label="下载预录单 报关行回执.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadReceipt).toHaveBeenCalledWith('2092438843630391407')
await wrapper.get('[aria-label="下载报关完税文件 真实报关完税文件.pdf"]').trigger('click')
@@ -277,4 +305,46 @@ describe('ContainerCustomsPanel', () => {
expect(wrapper.text()).toContain('trace-customs-download')
expect(api.downloadReceipt).not.toHaveBeenCalled()
})
it('下载预录单失败时透传正式名称与诊断信息,保留原文件名', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'A0404', message: '预录单不存在', data: null, requestId: 'request-pre-entry', traceId: 'trace-pre-entry' })], { type: 'application/json' }))
api.downloadReceipt.mockImplementationOnce(async (documentId: string) => {
const actual = await vi.importActual<typeof import('../api/platformContainerCustoms')>('../api/platformContainerCustoms')
return actual.downloadContainerCustomsReceipt(documentId)
})
const wrapper = await render('COMPLETED', false, true)
await wrapper.get('[aria-label="下载预录单 报关行回执.pdf"]').trigger('click')
await flushPromises()
await vi.waitFor(() => expect(wrapper.text()).toContain('预录单不存在'))
expect(wrapper.text()).toContain('request-pre-entry')
expect(wrapper.text()).toContain('trace-pre-entry')
expect(wrapper.text()).toContain('报关行回执.pdf')
expect(wrapper.text()).not.toContain('报关行回执不存在')
expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled()
expect(get).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/download', { responseType: 'blob' })
get.mockRestore()
wrapper.unmount()
})
it('上传冲突时展示服务端预录单提示并保留待上传文件', async () => {
const message = '客户确认的预录单已发送给报关行,不可再修改预录单'
api.upload.mockRejectedValueOnce(new ApiResponseError(message, 'A0409', 200, undefined, 'request-pre-entry-upload', 'trace-pre-entry-upload'))
const wrapper = await render('DECLARING', true, true)
const selected = new File(['receipt'], '预录单.pdf', { type: 'application/pdf' })
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
onChange({ raw: selected })
await flushPromises()
const submit = wrapper.findAll('button').find(button => button.text() === '提交')!
await submit.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain(message)
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('request-pre-entry-upload')
expect(wrapper.text()).toContain('trace-pre-entry-upload')
expect(submit.attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
})
@@ -79,9 +79,6 @@ const statusText: Record<ContainerCustomsStatus, string> = {
function documentCustomers(document: ContainerCustomsDocument) {
return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—'
}
function receiptHistoryLabel(document: ContainerCustomsDocument) {
return (document.receiptHistory || []).map((item) => `v${item.version}`).join('、') || '—'
}
function confirmationActionLabel(action: ContainerCustomsDocument['confirmationHistory'][number]['action']) {
return action === 'CONFIRM' ? '确认' : '打回'
}
@@ -126,7 +123,7 @@ function declarationStatusLabel(value: DeclarationBatch['status']) {
async function createFormalDeclaration() {
if (!customs.value || !['DECLARING', 'COMPLETED'].includes(customs.value.status) || !brokerSubmitted.value || openDeclarationBatch.value) return
try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', customsDialogTitle('创建正式申报批次'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
try { await ElMessageBox.confirm('当前柜的资料、预录单已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', customsDialogTitle('创建正式申报批次'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { await createDeclarationBatch(props.loadingPlanId, customs.value.version); await load(); ElMessage.success('正式申报批次已创建') }
catch (error) { actionError.value = getApiErrorDetails(error, '正式申报批次创建失败') }
@@ -194,15 +191,15 @@ async function start() {
async function uploadReceipt(document: ContainerCustomsDocument) {
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
const selected = receiptFiles[document.documentId]
if (!selected) { ElMessage.warning('请选择对应的报关行回执'); return }
if (!selected) { ElMessage.warning('请选择对应的预录单'); return }
actionLoading.value = true; actionError.value = null
try {
await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0)
receiptFiles[document.documentId] = null
await load()
emit('refreshed')
ElMessage.success('报关行回执已上传,等待客户确认')
} catch (error) { actionError.value = getApiErrorDetails(error, '报关行回执上传失败') }
ElMessage.success('预录单已上传,等待客户确认')
} catch (error) { actionError.value = getApiErrorDetails(error, '预录单上传失败') }
finally { actionLoading.value = false }
}
@@ -239,9 +236,9 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
? await downloadContainerCustomsDocument(document.documentId)
: kind === 'receipt' ? await downloadContainerCustomsReceipt(document.documentId) : await downloadContainerCustomsFinal(document.documentId)
saveBlob(blob, target.fileName)
ElMessage.success(`${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '报关行回执' : '报关完税文件'}下载已开始`)
ElMessage.success(`${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '预录单' : '报关完税文件'}下载已开始`)
} catch (error) {
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载失败`)
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '预录单' : '报关完税文件'}下载失败`)
} finally {
downloadingKey.value = ''
}
@@ -249,7 +246,7 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
async function submitToBroker() {
if (!customs.value || customs.value.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value || !allReceiptsConfirmed.value) return
try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', customsDialogTitle('发送报关行执行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
try { await ElMessageBox.confirm('确认已核对全部预录单并把确认结果发送给报关行执行?', customsDialogTitle('发送报关行执行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await submitContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已发送给报关行执行') }
catch (error) { actionError.value = getApiErrorDetails(error, '发送报关行执行失败') }
@@ -258,9 +255,9 @@ async function submitToBroker() {
async function sendToBroker() {
if (!customs.value || customs.value.status !== 'DECLARING' || brokerSubmission.value || brokerSubmitted.value) return
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', customsDialogTitle('首次发送报关行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回预录单,关联范围按当前快照处理。', customsDialogTitle('首次发送报关行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待回执') }
try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待预录单') }
catch (error) { actionError.value = getApiErrorDetails(error, '首次发送报关行失败') }
finally { actionLoading.value = false }
}
@@ -315,7 +312,7 @@ onMounted(load)
<el-empty v-if="!customs.hasActualCargo" description="当前装柜计划没有实际装柜货物,无需报关" :image-size="56" />
<template v-else>
<dl v-if="brokerSubmission" class="density-fields customs-summary" aria-label="报关概况">
<div class="submission-batch"><dt>首次发送批次</dt><dd>{{ brokerSubmission.batchNo }}</dd></div><div><dt>发送状态</dt><dd>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</dd></div><div><dt>资料版本</dt><dd>v{{ brokerSubmission.sourceVersion }}</dd></div>
<div class="submission-batch"><dt>首次发送批次</dt><dd>{{ brokerSubmission.batchNo }}</dd></div><div><dt>发送状态</dt><dd>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</dd></div>
<div class="submission-batch"><dt>发送人</dt><dd>{{ brokerSubmission.sentByName || '—' }}</dd></div><div class="submission-batch"><dt>发送时间</dt><dd>{{ formatDateTime(brokerSubmission.sentAt) }}</dd></div>
</dl>
<div v-if="missingInbounds.length" class="compact-table-wrap">
@@ -323,14 +320,14 @@ onMounted(load)
<div class="workbench-evidence"><table class="compact-table density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table></div>
</div>
<div v-if="customs.documents.length" class="compact-table-wrap">
<h3>已冻结退税资料、报关行回执与完税文件</h3>
<div class="workbench-evidence"><table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>报关行回执</th><th>真实报关完税文件</th><th>关联入仓码</th><th>报关行状态</th><th>版本</th><th>客户</th><th>回执情况</th></tr></thead><tbody>
<h3>已冻结退税资料、预录单与完税文件</h3>
<div class="workbench-evidence"><table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>预录单</th><th>真实报关完税文件</th><th>关联入仓码</th><th>报关行状态</th><th>客户</th><th>预录单情况</th></tr></thead><tbody>
<tr v-for="document in customs.documents" :key="document.documentId">
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectReceipt(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传回执' : document.receipt ? '覆盖报关行回执' : '上传报关行回执' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFiles[document.documentId]" :loading="actionLoading" @click="uploadReceipt(document)">提交</el-button><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectReceipt(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传预录单' : document.receipt ? '覆盖预录单' : '上传预录单' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFiles[document.documentId]" :loading="actionLoading" @click="uploadReceipt(document)">提交</el-button><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')">下载</el-button></div></td>
<td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载报关行回执 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td>
<td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载预录单 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td>
<td><div v-if="document.finalDocument" class="file-cell"><span :title="document.finalDocument.fileName">{{ document.finalDocument.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `final:${document.documentId}`" :aria-label="`下载报关完税文件 ${document.finalDocument.fileName}`" @click="downloadFile(document, 'final')">下载</el-button></div><span v-else>未上传</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待回执确认' : '待首次发送报关行' }}</td><td>退税资料 v{{ document.source.version }}<span v-if="document.receipt"> / 回执 v{{ document.receipt.version }}</span><span v-if="document.finalDocument"> / 完税 v{{ document.finalDocument.version }}</span><small v-if="document.receiptHistory?.length" class="receipt-history-note">回执历史:{{ receiptHistoryLabel(document) }}</small><small v-for="history in document.confirmationHistory" :key="history.id" class="confirmation-history-note">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small></td><td>{{ documentCustomers(document) }}</td><td>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : document.receiptStatus === 'REJECTED' ? '客户已打回回执' : '待客户确认回执') : '待上传报关行回执' }}</td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}</td><td>{{ documentCustomers(document) }}</td><td><small v-for="history in document.confirmationHistory" :key="history.id" class="confirmation-history-note">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small><small v-for="history in document.receiptHistory" :key="history.version" class="receipt-history-note">预录单历史:{{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}</small>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认预录单' : document.receiptStatus === 'REJECTED' ? '客户已打回预录单' : '待客户确认预录单') : '待上传预录单' }}</td>
</tr>
</tbody></table></div>
</div>
@@ -339,7 +336,7 @@ onMounted(load)
<el-empty v-if="!declarationBatches.length" description="尚未创建正式申报批次" :image-size="48" />
<div v-else class="declaration-batch-list">
<article v-for="batch in declarationBatches" :key="batch.id" class="declaration-batch-card">
<div class="declaration-batch-main"><strong>{{ batch.batchNo }}</strong><span>报关单号:{{ batch.customsEntryNo || '—' }} · 版本 {{ batch.version }}</span></div>
<div class="declaration-batch-main"><strong>{{ batch.batchNo }}</strong><span>报关单号:{{ batch.customsEntryNo || '—' }}</span></div>
<el-tag size="small" :type="batch.status === 'CLOSED' ? 'success' : batch.status === 'CANCELLED' ? 'info' : 'warning'">{{ declarationStatusLabel(batch.status) }}</el-tag>
<div class="declaration-batch-actions" v-if="canManage"><el-button v-if="batch.status === 'READY'" size="small" type="primary" :loading="actionLoading" @click="submitFormalDeclaration(batch)">提交正式申报</el-button><el-button v-if="['SUBMITTED','ACKNOWLEDGED','RESULT_PENDING'].includes(batch.status)" size="small" plain :loading="actionLoading" @click="openFormalResultDialog(batch)">标记海关异常/结果</el-button></div>
<div v-if="batch.results.length" class="declaration-result-list"><div v-for="result in batch.results" :key="result.id" class="declaration-result-row"><span>{{ clearanceResultLabel(result.resultType) }}</span><el-tag size="small" :type="result.resultStatus === 'RESOLVED' ? 'success' : 'warning'">{{ result.resultStatus === 'RESOLVED' ? '已处理' : result.resultStatus === 'OPEN' ? '待处理' : '已取消' }}</el-tag><small>{{ result.resolution || '—' }}</small><el-button v-if="canManage && result.resultStatus === 'OPEN'" link type="primary" size="small" :loading="actionLoading" @click="resolveFormalResult(batch, result)">处理</el-button></div></div>
@@ -188,7 +188,7 @@ onMounted(load)
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="containerDialogTitle(editing ? '调整尾程费用' : '新增尾程费用', props)" width="min(920px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
<section class="density-dialog-section">
<el-form class="density-form" label-position="left">
<div class="workbench-evidence"><table class="density-table fee-editor-table" aria-label="尾程费用填写"><tbody>
<div class="workbench-evidence"><table class="density-table density-table--editable fee-editor-table" aria-label="尾程费用填写"><tbody>
<tr><td><el-form-item label-position="left" label-width="110px" label="客户" required><span v-if="editing">{{ customerName(form.customerId) || editing.customerName || '—' }}</span><el-select v-else v-model="form.customerId" :disabled="voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label-position="left" label-width="110px" label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
<tr><td :colspan="form.direction === 'PAYABLE' ? 1 : 2"><el-form-item label-position="left" label-width="110px" label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item></td><td v-if="form.direction === 'PAYABLE'"><el-form-item label-position="left" label-width="110px" label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item></td></tr>
@@ -47,6 +47,9 @@ describe('OceanBookingLinkDialog', () => {
it.each([
[' mscu1234567 ', 'MSCU1234567'],
['local-1', 'LOCAL-1'],
['MSCU6639871', 'MSCU6639871'],
['ABCU123456789012', 'ABCU123456789012'],
['', null],
])('normalizes and submits the optional real container number %#', async (input, expected) => {
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
@@ -1,10 +1,10 @@
import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect } from 'element-plus'
import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect, ElUpload } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerDetailView from './ContainerDetailView.vue'
import { getContainer, getContainerOverview, linkContainerOceanBooking } from '../api/platformContainerLoading'
import { getContainer, getContainerOverview, linkContainerOceanBooking, getContainerClearance, uploadContainerClearance, uploadContainerDocument, completeContainerClearance } from '../api/platformContainerLoading'
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, scheduleTerminalDrayagePickup, selectTerminalDrayageFleet, recordTerminalPickup } from '../api/terminalDrayages'
import { listSuppliers } from '@/modules/suppliers'
@@ -59,7 +59,7 @@ function accessibleText(wrapper: { element: Element } | undefined) {
describe('ContainerDetailView empty return', () => {
it('shows the same default warehouse date as the calendar, with no fabricated midnight', async () => {
vi.mocked(getContainer).mockResolvedValue({ ...base, estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' } } as never)
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0, plannedDate: '2026-10-23', isDefault: true }])
const wrapper = await mountDetail()
const value = wrapper.findAll('dt').find(item => item.text() === '预计到仓')!.element.nextElementSibling!.textContent
expect(value).toBe('2026-10-23(默认推算)')
@@ -100,7 +100,8 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(recordDirectDelivery).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...row, version: 1, actualAt: '2026-10-16T01:00:00' })
const wrapper = await mountDetail()
expect(getDirectDeliveries).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
expect(wrapper.findAll('button').some(button => button.text() === '直送卸货清单')).toBe(false)
await (wrapper.vm as unknown as { openDirect(): Promise<void> }).openDirect(); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
await dialog.findAll('button').find(button => button.text() === '登记本票')!.trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
@@ -123,7 +124,8 @@ describe('ContainerDetailView empty return', () => {
canAccess.mockImplementation(code => code !== 'platform:firstleg:terminal-drayage:direct-unload:confirm')
vi.mocked(getDirectDeliveries).mockResolvedValue([{ requirementKey: 'INBOUND:9', inboundCode: 'IN-TEST', orderNo: null, destinationName: '测试客户仓', packageCount: null, version: 1, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '签收人', evidenceNo: 'POD-001', remark: null }])
const wrapper = await mountDetail()
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
expect(wrapper.findAll('button').some(button => button.text() === '直送卸货清单')).toBe(false)
await (wrapper.vm as unknown as { openDirect(): Promise<void> }).openDirect(); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
await dialog.findAll('button').find(button => button.text() === '查看签收')!.trigger('click'); await flushPromises()
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('签收事实只读')
@@ -206,17 +208,20 @@ describe('ContainerDetailView empty return', () => {
granted.value = true
await flushPromises()
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('已确认离仓')
expect(wrapper.find('[aria-label="国内出仓门禁"]').exists()).toBe(false)
expect((wrapper.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED')
expect(wrapper.text()).not.toContain('展开可查看去重后的完整门禁')
expect(wrapper.text()).not.toContain('全部门禁已通过,可确认整柜离仓')
})
it('reloads authoritative departure state once on each newly opened domestic detail', async () => {
const first = await mountDetail('DOMESTIC')
expect(first.text()).toContain('已确认离仓')
expect(first.find('[aria-label="国内出仓门禁"]').exists()).toBe(false)
expect((first.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED')
first.unmount()
const reopened = await mountDetail('DOMESTIC')
expect(reopened.text()).toContain('已确认离仓')
expect(reopened.find('[aria-label="国内出仓门禁"]').exists()).toBe(false)
expect((reopened.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED')
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2)
})
@@ -437,3 +442,54 @@ describe('domestic container loading list and booking link', () => {
expect(routerPush).not.toHaveBeenCalled()
})
})
describe('destination document automatic upload', () => {
const uploaded = { id: 'doc-1', fileId: 'file-1', fileName: '资料.pdf', version: 1, completedAt: null }
beforeEach(() => {
canAccess.mockReset().mockReturnValue(true)
routeState.query = {}
vi.mocked(getContainer).mockReset().mockResolvedValue({ ...base, outboundConfirmedAt: '2026-10-20T08:00:00', allowedActions: ['MANAGE_DESTINATION_DOCUMENTS'] } as never)
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
vi.mocked(getContainerClearance).mockReset().mockResolvedValue(null as never)
vi.mocked(uploadContainerDocument).mockReset().mockResolvedValue(uploaded as never)
vi.mocked(uploadContainerClearance).mockReset().mockResolvedValue(uploaded as never)
vi.mocked(completeContainerClearance).mockReset()
})
it.each(['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'])('saves %s on selection without completing clearance', async type => {
const wrapper = await mountDetail()
await (wrapper.vm as unknown as { openDocument(type: string): void }).openDocument(type)
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())!
expect(dialog.props('showConfirm')).toBe(false)
const file = new File(['data'], '资料.pdf', { type: 'application/pdf' })
const select = dialog.findComponent(ElUpload).props('onChange')!
select({ raw: file } as never, [])
select({ raw: file } as never, [])
await flushPromises()
const upload = type === 'DELIVERY_ORDER' ? uploadContainerDocument : uploadContainerClearance
expect(upload).toHaveBeenCalledTimes(1)
expect(upload).toHaveBeenCalledWith(...(type === 'DELIVERY_ORDER' ? ['9001', type, file, 0, expect.any(String)] : ['9001', file, 0, expect.any(String)]))
expect(completeContainerClearance).not.toHaveBeenCalled()
expect(dialog.props('modelValue')).toBe(false)
})
it.each(['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'])('retains a failed %s upload and retries the same file', async type => {
const upload = type === 'DELIVERY_ORDER' ? uploadContainerDocument : uploadContainerClearance
vi.mocked(upload).mockRejectedValueOnce(new Error('网络中断'))
const wrapper = await mountDetail()
await (wrapper.vm as unknown as { openDocument(type: string): void }).openDocument(type)
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())!
const file = new File(['data'], '资料.pdf', { type: 'application/pdf' })
dialog.findComponent(ElUpload).props('onChange')!({ raw: file } as never, [])
await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.text()).toContain('上传失败')
await dialog.findAll('button').find(button => button.text() === '重试上传')!.trigger('click')
await flushPromises()
expect(upload).toHaveBeenCalledTimes(2)
expect(vi.mocked(upload).mock.calls[1]).toEqual(vi.mocked(upload).mock.calls[0])
expect(dialog.props('modelValue')).toBe(false)
expect(completeContainerClearance).not.toHaveBeenCalled()
})
})
@@ -19,10 +19,8 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
import { ContainerFeesPanel, useLoadingListDetail, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingListMode } from '@/modules/loading-lists'
import {
confirmContainerOutboundDispatch,
getContainerOutboundDispatch,
type ContainerOutboundDispatchDetail,
type ContainerOutboundGateItem,
} from '@/modules/outbound-dispatch'
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
@@ -244,7 +242,6 @@ async function saveDirect() {
}
const canListCustoms = computed(() => access.can('platform:firstleg:customs:list'))
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const canViewCurrentLoadingList = computed(() => {
@@ -274,6 +271,7 @@ const pickupCodeSaving = ref(false)
const clearanceOpen = ref(false)
const clearanceDocument = ref<BusinessDocument | null>(null)
const clearanceFile = ref<File | null>(null)
const clearanceUploadKey = ref(0)
const clearanceLoading = ref(false)
const clearanceSaving = ref(false)
const clearanceError = ref<ApiErrorDetails | null>(null)
@@ -282,10 +280,8 @@ const exceptionReason = ref('')
const exceptionSaving = ref(false)
const exceptionError = ref<ApiErrorDetails | null>(null)
const outboundLoading = ref(false)
const outboundConfirming = ref(false)
const outboundDetail = ref<ContainerOutboundDispatchDetail | null>(null)
const outboundError = ref<ApiErrorDetails | null>(null)
const showPassedGates = ref(false)
const carrierRefreshKey = ref(0)
const { detail: loadingDetail, loading: loadingDetailLoading, error: loadingDetailError, load: loadListDetail, reset: resetLoadingDetail } = useLoadingListDetail()
const emptyReturnOpen = ref(false)
@@ -337,32 +333,6 @@ const emptyReturnWarningText = computed(() => {
return `${overdue}${warning.deadlineAt ? ` · 截止 ${formatTime(warning.deadlineAt)}` : ''}`
})
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
const referenceOnlyGateCodes = new Set(['TRUCK_DEPARTED', 'SAILING_SCHEDULE'])
const outboundGateRows = computed<AggregatedGateRow[]>(() => {
const grouped = new Map<string, AggregatedGateRow>()
for (const inbound of outboundDetail.value?.aggregate.gate.inbounds || []) {
for (const item of inbound.gate.items || []) {
if (referenceOnlyGateCodes.has(item.code)) continue
const key = JSON.stringify([item.code, item.passed, item.actual ?? null, item.requirement ?? null, item.name])
const existing = grouped.get(key)
if (existing) {
if (!existing.inboundCodes.includes(inbound.inboundCode)) existing.inboundCodes.push(inbound.inboundCode)
} else grouped.set(key, { ...item, key, inboundCodes: [inbound.inboundCode] })
}
}
return [...grouped.values()]
})
const visibleOutboundGateRows = computed(() => showPassedGates.value ? outboundGateRows.value : outboundGateRows.value.filter((row) => !row.passed))
const outboundGateSummary = computed(() => ({
total: outboundGateRows.value.length,
passed: outboundGateRows.value.filter((row) => row.passed).length,
blocked: outboundGateRows.value.filter((row) => !row.passed).length,
}))
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
&& hasAction('CONFIRM_OUTBOUND')
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
&& outboundGateSummary.value.blocked === 0)
const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES' | 'UNKNOWN'>(() =>
!outboundDetail.value ? 'UNKNOWN'
: outboundDetail.value.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
@@ -469,7 +439,7 @@ watch(
async function loadOutboundDispatch() {
if (!containerId.value || !canViewOutboundDispatch.value) return
outboundLoading.value = true; outboundError.value = null; showPassedGates.value = false
outboundLoading.value = true; outboundError.value = null
try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) }
catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') }
finally { outboundLoading.value = false }
@@ -488,37 +458,6 @@ async function refreshAfterCustoms() {
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 报关操作已成功,保留组件返回事实 */ }
}
async function confirmDeparture() {
const aggregate = outboundDetail.value?.aggregate
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
outboundConfirming.value = true
try {
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', containerDialogTitle('确认国内出仓', detail.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
} catch { outboundConfirming.value = false; return }
outboundError.value = null
try {
outboundDetail.value = await confirmContainerOutboundDispatch(containerId.value, {
expectedVersion: aggregate.version,
remark: '货柜详情确认国内出仓',
})
carrierRefreshKey.value += 1
ElMessage.success('整柜国内出仓已确认,船节点查询任务正在刷新')
try { overview.value = await getContainerOverview(containerId.value) }
catch (cause) { outboundError.value = getApiErrorDetails(cause, '离仓已确认,但货柜履约摘要刷新失败') }
} catch (cause) {
const details = getApiErrorDetails(cause, '确认国内出仓失败')
if (details.code === 'A0409') await loadOutboundDispatch()
outboundError.value = details
} finally { outboundConfirming.value = false }
}
function outboundStatusText(status?: string | null) {
return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record<string, string>)[status || ''] || status || '—'
}
function truckReferenceText(status?: string | null) {
return ({ SCHEDULED: '已预约', ARRIVED: '已到仓', DEPARTED: '已离仓', COMPLETED: '已完成' } as Record<string, string>)[status || ''] || status || '未关联'
}
function openDocument(type: string) {
if (type === 'DESTINATION_CLEARANCE') { if (canManageDestinationDocuments.value) void openClearance(); return }
if (type !== 'DELIVERY_ORDER' || !canManageDestinationDocuments.value) return
@@ -526,9 +465,14 @@ function openDocument(type: string) {
documentType.value = type as UploadDocumentType
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
}
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
function selectDocumentFile(file: UploadFile) {
if (documentSaving.value) return
documentFile.value = file.raw || null; documentError.value = null
if (documentFile.value) void saveDocument()
}
function removeDocumentFile() { documentFile.value = null }
async function saveDocument() {
if (documentSaving.value || !canManageDestinationDocuments.value) return
if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return }
documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null
try {
@@ -601,14 +545,20 @@ async function saveException() {
}
async function openClearance() {
clearanceUploadKey.value += 1
clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; documentRefreshWarning.value = null; clearanceLoading.value = true
try { clearanceDocument.value = await getContainerClearance(containerId.value) }
catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details }
finally { clearanceLoading.value = false }
}
function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw || null; clearanceError.value = null }
function selectClearanceFile(file: UploadFile) {
if (clearanceSaving.value || clearanceLoading.value) return
clearanceFile.value = file.raw || null; clearanceError.value = null
if (clearanceFile.value) void uploadClearance()
}
function removeClearanceFile() { clearanceFile.value = null }
async function uploadClearance() {
if (clearanceSaving.value || clearanceLoading.value || !canManageDestinationDocuments.value || clearanceDocument.value?.completedAt) return
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return }
clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null
try {
@@ -697,7 +647,7 @@ onMounted(async () => {
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-voyage-heading">
<header class="detail-section__heading"><h2 id="container-voyage-heading">船名航次信息</h2><ContainerPortSchedulePanel :container="detail" @saved="load" /></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense hide-technical-details :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-customs-heading">
@@ -710,26 +660,10 @@ onMounted(async () => {
<section v-if="isDomestic" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-receiving-heading">
<h2 id="container-receiving-heading">装柜作业与国内出仓</h2>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" />
<div v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel" aria-label="国内出仓门禁">
<header class="outbound-panel__header"><div><h2>国内出仓门禁<InlineHelp title="离仓核验规则"><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></InlineHelp></h2></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
<template v-else-if="outboundDetail">
<div class="gate-summary"><span>总门禁 <strong>{{ outboundGateSummary.total }}</strong></span><span class="gate-summary__passed">已通过 <strong>{{ outboundGateSummary.passed }}</strong></span><span :class="{ 'gate-summary__blocked': outboundGateSummary.blocked }">阻断 <strong>{{ outboundGateSummary.blocked }}</strong></span><span class="gate-summary__reference">拖车状态(参考):{{ truckReferenceText(outboundDetail.aggregate.truckAppointmentStatus) }}</span><el-button v-if="outboundGateSummary.passed" link type="primary" @click="showPassedGates = !showPassedGates">{{ showPassedGates ? '收起已通过项' : `查看全部门禁(${outboundGateSummary.total})` }}</el-button></div>
<div class="gate-list">
<article v-for="row in visibleOutboundGateRows" :key="row.key" class="gate-row">
<el-tag size="small" :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '通过' : '阻断' }}</el-tag><strong>{{ row.name }}</strong><span class="gate-row__actual">{{ row.actual || '暂无事实' }}</span><small>要求:{{ row.requirement || '—' }}</small>
<div class="gate-row__coverage"><span>{{ row.passed ? '覆盖' : '影响' }} {{ row.inboundCodes.length }} 个入库码</span><el-tag v-for="code in row.inboundCodes.slice(0, 3)" :key="code" size="small" effect="plain">{{ code }}</el-tag><span v-if="row.inboundCodes.length > 3">+{{ row.inboundCodes.length - 3 }}</span></div>
</article>
</div>
</template>
</div>
</section>
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-overseas-receiving-heading">
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canReadDirect" @click="openDirect">直送卸货清单</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ expectedArrivalText }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
</section>
@@ -753,9 +687,9 @@ onMounted(async () => {
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" :show-confirm="!!documentError && !!documentFile" confirm-text="重试上传" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :disabled="documentSaving" :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button :loading="documentSaving"><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="!clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading || !clearanceFile" @click="uploadClearance">上传资料</el-button></div></template></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="false" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :key="clearanceUploadKey" :disabled="clearanceSaving || clearanceLoading" :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button :loading="clearanceSaving"><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="clearanceError && clearanceFile && !clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading" @click="uploadClearance">重试上传</el-button></div></template></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
@@ -10,6 +10,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import SmartLoadingDialog from '../components/SmartLoadingDialog.vue'
import { loadSmartLoadingPlanCoLoadGroups } from '../smartLoadingPlanGroups'
import type { ContainerBookingResource } from '../types'
import {
assignPendingReviewSmartLoadingOrder,
@@ -210,9 +211,9 @@ describe('GlobalSmartLoadingView', () => {
wrapper.unmount()
})
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
it('uses one-line shared text clamps for block reasons and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
expect(source).toContain('<LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" />')
expect(source).toContain('<LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain("row.unitType === 'SEALED_PALLET'")
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
@@ -608,9 +609,11 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('CLG-2093539779795795969 · 2 票')
expect(wrapper.text()).toContain('3 个入仓片段')
expect(wrapper.text()).toContain('ORD-201:NYEXX2608290051、NYEXX2608290051-B')
const candidatePane = wrapper.get('.candidate-pane')
expect(candidatePane.get('.candidate-group').text()).toBe('合柜报关 · 2票整组不可拆')
expect(candidatePane.text()).toContain('NYEXX2608290051、NYEXX2608290051-B、NYEXX2608290052')
expect(candidatePane.text()).not.toContain('CLG-2093539779795795969')
expect(candidatePane.text()).not.toContain('ORD-201')
const buttons = wrapper.findAll('.el-table__body-wrapper button').filter((button) => button.text() === '加入')
expect(buttons).toHaveLength(1)
await buttons[0].trigger('click')
@@ -623,6 +626,8 @@ describe('GlobalSmartLoadingView', () => {
expect(contextRows[1]!.findAll('td').map(cell => cell.text())).toEqual(['义乌仓', '美森正班'])
await assignmentDialog().get('[aria-label="展开全屏"]').trigger('click')
expect(assignmentDialog().get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('NYEXX2608290051-B')
expect(assignmentDialog().get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('ORD-201:NYEXX2608290051、NYEXX2608290051-B')
expect(assignmentDialog().text()).toContain('3 个入仓片段')
expect(document.body.textContent).not.toContain('本次分配箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
await flushPromises()
@@ -684,7 +689,7 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('CLG-9900 · 3 票')
expect(wrapper.text()).toContain('合柜报关 · 3票')
expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票')
const button = wrapper.findAll('.el-table__body-wrapper button').find((item) => item.text() === '加入')!
expect(button.attributes('disabled')).toBeDefined()
@@ -749,16 +754,36 @@ describe('GlobalSmartLoadingView', () => {
wrapper.unmount()
})
it('uses one loading-rule column and expandable group details in both workbenches', () => {
it('uses one loading-rule column in both workbenches and keeps global list summaries compact', () => {
for (const page of ['GlobalSmartLoadingView.vue', 'SmartLoadingWorkbenchView.vue']) {
const source = readFileSync(resolve(process.cwd(), `src/modules/container-loading/pages/${page}`), 'utf8')
expect(source.match(/label: '装柜规则'/g)).toHaveLength(2)
expect(source).not.toContain("label: '分配单位'")
expect(source).not.toContain('<template #unitType=')
expect(source.match(/<summary>整组加入 · 不可拆<\/summary>/g)).toHaveLength(2)
expect(source.match(/'整托加入 · 不可拆' : '按箱加入'/g)).toHaveLength(2)
expect(source).toContain("row.memberOrderSummaries.join(';')")
}
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source.match(/slotName: 'coLoadGroup', showOverflowTooltip: false/g)).toHaveLength(2)
expect(source).not.toContain("row.memberOrderSummaries.join(';')")
})
it('shows compact locked plan groups with all inbound codes and fixed package totals', async () => {
const group = { groupId: 'G-1', groupNo: 'CLG-1', lockedPlanId: plan.id, memberOrderCount: 2, inboundFragmentCount: 3, fullPackageCount: 18 }
vi.mocked(loadSmartLoadingPlanCoLoadGroups).mockReturnValueOnce(new Map(['2', '3', '4'].map(id => [id, group])))
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
...plan,
items: ['2', '3', '4'].map((inboundId, index) => ({ ...plan.items[0]!, id: `item-${inboundId}`, inboundId, inboundCode: `IN-${inboundId}`, orderId: index < 2 ? 'order-1' : 'order-2' })),
})
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const pane = wrapper.get('.plan-pane')
expect(pane.get('.candidate-group').text()).toBe('合柜报关 · 2票整组不可拆 · 已锁定')
expect(pane.text()).toContain('IN-2、IN-3、IN-4')
expect(pane.text()).toContain('18 箱(整组固定)')
expect(pane.text()).not.toContain('CLG-1')
expect(pane.text()).not.toContain('ORD-1')
expect(pane.findAll('.el-table__body-wrapper tr')).toHaveLength(1)
})
it('searches any shared-pallet inbound, retains its complete composition across pages, and resets the search', async () => {
@@ -157,7 +157,7 @@ const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
const planPalletMembersByPalletId = computed(() => new Map(
(plan.value?.palletMembers || []).map((entry) => [entry.palletId, entry.members]),
))
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; palletMembers: SmartLoadingCandidateGroupMember[]; memberInboundCodes: string[]; memberOrderSummaries: string[] }
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; palletMembers: SmartLoadingCandidateGroupMember[]; memberInboundCodes: string[] }
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
const result: SmartLoadingPlanRow[] = []
@@ -165,7 +165,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
for (const row of inboundRows) {
const group = planGroupsByInboundId.value.get(row.inboundId)
if (!group) {
result.push({ ...row, coLoadGroup: null, palletMembers: row.palletId ? planPalletMembersByPalletId.value.get(row.palletId) || [] : [], memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
result.push({ ...row, coLoadGroup: null, palletMembers: row.palletId ? planPalletMembersByPalletId.value.get(row.palletId) || [] : [], memberInboundCodes: [row.inboundCode || '入仓码暂缺'] })
continue
}
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
@@ -191,7 +191,6 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
coLoadGroup: group,
palletMembers: [],
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(rows),
})
}
return result
@@ -247,7 +246,7 @@ const candidateColumns: DataTableColumn[] = [
{ key: 'totalGrossWeightKg', label: '重量', width: 100, slotName: 'candidateTotalWeight' },
{ key: 'totalVolumeCbm', label: '体积', width: 100, slotName: 'candidateTotalVolume' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup', showOverflowTooltip: false },
{ key: 'unitGrossWeightKg', label: '单位毛重', width: 90, slotName: 'unitGrossWeight' },
{ key: 'averageUnitVolumeCbm', label: '单位体积', width: 90, slotName: 'averageUnitVolume' },
{ key: 'estimatedVolumeCbm', label: '预估体积', width: 100, slotName: 'estimatedVolume' },
@@ -260,7 +259,7 @@ const itemColumns: DataTableColumn[] = [
{ key: 'grossWeightKg', label: '毛重', width: 90, slotName: 'weight' },
{ key: 'volumeCbm', label: '体积', width: 90, slotName: 'volume' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup', showOverflowTooltip: false },
]
function formatNumber(value: number, digits = 3) {
@@ -753,9 +752,16 @@ onMounted(() => { void load(); void loadCustomers() })
@click="openAssign(row)"
>加入</el-button>
</template>
<template #candidateInbounds="{ row }"><SmartLoadingCandidateInbounds :candidate="row as SmartLoadingCandidate" /></template>
<template #candidateInbounds="{ row }"><span v-if="row.coLoadGroupId">{{ row.memberInboundCodes.join('、') }}</span><SmartLoadingCandidateInbounds v-else :candidate="row as SmartLoadingCandidate" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroupId" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
<template #coLoadGroup="{ row }">
<div v-if="row.coLoadGroupId" class="candidate-group">
<el-tag size="small" type="warning">合柜报关 · {{ row.memberOrderCount }}票</el-tag>
<span>整组不可拆{{ row.locked ? ' · 已锁定' : '' }}</span>
<LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" />
</div>
<LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" />
</template>
<template #orderStatusSnapshot="{ row }"><el-tag size="small" effect="plain">{{ candidateStatusLabel(row.orderStatusSnapshot) }}</el-tag></template>
<template #unitGrossWeight="{ row }">{{ formatMeasurement(row.unitGrossWeightKg, 'kg', 1) }}</template>
<template #candidateTotalWeight="{ row }">{{ formatMeasurement(row.totalGrossWeightKg, 'kg', 1) }}</template>
@@ -828,9 +834,15 @@ onMounted(() => { void load(); void loadCustomers() })
</div>
<div class="workbench-table"><DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="danger" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #planInbounds="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
<template #planInbounds="{ row }"><span v-if="row.coLoadGroup">{{ row.memberInboundCodes.join('、') }}</span><SmartLoadingCandidateInbounds v-else :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroup" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
<template #coLoadGroup="{ row }">
<div v-if="row.coLoadGroup" class="candidate-group">
<el-tag size="small" type="warning">合柜报关 · {{ row.coLoadGroup.memberOrderCount }}票</el-tag>
<span>整组不可拆 · {{ row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定' }}</span>
</div>
<LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" />
</template>
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
+24 -1
View File
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { refundFinanceConsumption } from './finance'
import { actionReconciliationStatement, adjustFinanceAccount, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, downloadFinanceRechargeVoucher, editFinanceFeeSource, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReceivableStatistics, getReconciliationStatement, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinanceFeeSources, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceRechargeRequests, listFinanceStatements, reviewFinanceFeeSource, reviewFinanceRechargeRequest, updateFinanceBill, updateFinanceCreditLimit, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance'
describe('finance api', () => {
@@ -88,7 +89,7 @@ describe('finance api', () => {
})
it('uses the formal G1 account ledger path and fixed page DTO', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-ledger', traceId: 'trace-ledger', data: [{ ledgerId: 81, occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: 91, documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款', businessNo: 'AC202608110001', changeType: 'RECHARGE', amount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceType: 'MANUAL_ADJUSTMENT', sourceNo: 'RC-91', createdAt: '2026-08-11T12:00:00' }], page: { page: 1, pageSize: 50, total: 1 } })
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-ledger', traceId: 'trace-ledger', data: [{ ledgerId: 81, occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: 91, documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款', walletBusinessNo: 'AC202608110001', changeType: 'RECHARGE', walletAmount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceType: 'MANUAL_ADJUSTMENT', sourceNo: 'RC-91', createdAt: '2026-08-11T12:00:00' }], page: { page: 1, pageSize: 50, total: 1 } })
const result = await listFinanceAccountLedger('10001', { page: 1, size: 50, businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' })
@@ -96,6 +97,22 @@ describe('finance api', () => {
expect(result.records[0]).toMatchObject({ ledgerId: '81', businessNo: 'AC202608110001', changeType: 'RECHARGE', amount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceNo: 'RC-91' })
})
it('preserves storage rent freeze and settlement amounts from the account ledger DTO', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: [
{ ledgerId: '61', documentType: 'WALLET_LEDGER', walletBusinessNo: 'AC202610230003', changeType: 'CONSUMPTION', walletAmount: -10.01, actualBalanceAfter: 9905.22, frozenBalanceAfter: 35.47, availableBalanceAfter: 9969.73 },
{ ledgerId: '60', documentType: 'WALLET_LEDGER', walletBusinessNo: 'AC202610230002', changeType: 'FREEZE', walletAmount: -10.01, actualBalanceAfter: 9915.23, frozenBalanceAfter: 45.48, availableBalanceAfter: 9969.73 },
{ ledgerId: '59', documentType: 'WALLET_LEDGER', walletBusinessNo: 'AC202610230001', changeType: 'FREEZE', walletAmount: 0 },
{ ledgerId: '58', documentType: 'SETTLEMENT' },
], page: { total: 4 } })
const { records } = await listFinanceAccountLedger('10001')
expect(records[0]).toMatchObject({ businessNo: 'AC202610230003', amount: '-10.01', actualBalanceAfter: '9905.22', frozenBalanceAfter: '35.47', availableBalanceAfter: '9969.73' })
expect(records[1]).toMatchObject({ businessNo: 'AC202610230002', amount: '-10.01', actualBalanceAfter: '9915.23', frozenBalanceAfter: '45.48', availableBalanceAfter: '9969.73' })
expect(records[2].amount).toBe('0')
expect(records[3].amount).toBeNull()
})
it('uses frozen recharge review and account adjustment contracts', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ requestId: 91, requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: 81, voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', createdAt: '2026-09-04T10:00:00' }], page: { page: 1, pageSize: 200, total: 1 } })
const post = vi.spyOn(http, 'post')
@@ -311,3 +328,9 @@ describe('finance api', () => {
})
})
it('submits linked consumption refund with the caller retry key', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { changeType: 'REFUND', referenceNo: 'AC-61' } })
await expect(refundFinanceConsumption('9007199254740993', '61', { amount: '12.30', reason: '多扣' }, 'stable-key')).resolves.toMatchObject({ referenceNo: 'AC-61' })
expect(post).toHaveBeenCalledWith('/platform/finance/v1/accounts/9007199254740993/ledger/61/refunds', { amount: '12.30', reason: '多扣' }, { headers: { 'Idempotency-Key': 'stable-key' } })
})
+6 -1
View File
@@ -155,7 +155,7 @@ export async function listFinanceAccountLedger(customerId: string, params: { pag
documentType: stringValue(item.documentType), documentId: stringValue(item.documentId), documentNo: stringValue(item.documentNo),
receivableChange: numberValue(item.receivableChange), payableChange: numberValue(item.payableChange),
outstandingReceivableAfter: numberValue(item.outstandingReceivableAfter), operatorName: stringValue(item.operatorName), remark: stringValue(item.remark),
businessNo: nullableStringValue(item.businessNo), changeType: nullableStringValue(item.changeType), amount: nullableStringValue(item.amount),
businessNo: nullableStringValue(item.walletBusinessNo), changeType: nullableStringValue(item.changeType), amount: nullableStringValue(item.walletAmount),
actualBalanceAfter: nullableStringValue(item.actualBalanceAfter), frozenBalanceAfter: nullableStringValue(item.frozenBalanceAfter),
availableBalanceAfter: nullableStringValue(item.availableBalanceAfter), sourceType: nullableStringValue(item.sourceType),
creditLimitAfter: nullableStringValue(item.creditLimitAfter), creditUsedAfter: nullableStringValue(item.creditUsedAfter),
@@ -208,6 +208,11 @@ export async function updateFinanceCreditLimit(customerId: string, payload: { cr
return unwrapApiResponse(response)
}
export async function refundFinanceConsumption(customerId: string, ledgerId: string, payload: { amount: string; reason: string }, key: string) {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/accounts/${encodeURIComponent(customerId)}/ledger/${encodeURIComponent(ledgerId)}/refunds`, payload, { headers: { 'Idempotency-Key': key } })
return unwrapApiResponse(response)
}
export async function listFinanceFeeSources(params: {
page?: number
size?: number
@@ -6,7 +6,7 @@ import { ElMessage } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import { listPlatformTenants } from '@/modules/iam'
import * as api from '../api/finance'
import type { FinanceAccountSummary, FinanceRechargeRequest } from '../types'
import type { FinanceAccountSummary, FinanceLedgerEntry, FinanceRechargeRequest } from '../types'
import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue'
const { default: ElementPlus, ElDialog } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
@@ -17,7 +17,7 @@ vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
vi.mock('../api/finance', () => ({
listFinanceAccounts: vi.fn(), listFinanceAccountLedger: vi.fn(), listFinanceRechargeRequests: vi.fn(),
reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), updateFinanceCreditLimit: vi.fn(),
reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), refundFinanceConsumption: vi.fn(), updateFinanceCreditLimit: vi.fn(),
downloadFinanceRechargeVoucher: vi.fn(), getFinanceCustomerTerms: vi.fn(), updateFinanceCustomerTerms: vi.fn(),
}))
const account: FinanceAccountSummary = {
@@ -287,3 +287,105 @@ describe('customer wallet native dialogs', () => {
expect(activeDialog(wrapper).find('.voucher-preview__image').exists()).toBe(true)
})
})
const consumption: FinanceLedgerEntry = {
ledgerId: '61', businessNo: 'AC-CONSUME-61', changeType: 'CONSUMPTION', amount: '-80.00',
sourceNo: 'RELABEL-1', occurredAt: '', businessType: 'ADJUSTMENT', documentType: '', documentId: '', documentNo: '',
receivableChange: 0, payableChange: 0, outstandingReceivableAfter: 0, operatorName: '', remark: '',
}
async function openRefundDialog() {
vi.mocked(api.listFinanceAccountLedger).mockResolvedValue({ records: [consumption], total: 1, pages: 1 })
const wrapper = await mountPage()
await click(wrapper, '退款', '.ledger-table button')
return { wrapper, dialog: activeDialog(wrapper) }
}
describe('linked original consumption refunds', () => {
it.each(['', '0', '-1', '80.01', '1.001', 'NaN'])('rejects invalid refund %s', async amount => {
const { dialog } = await openRefundDialog()
await dialog.get('input[placeholder="请输入退款金额"]').setValue(amount)
await dialog.get('textarea').setValue('测试退款')
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).not.toHaveBeenCalled()
})
it('requires a reason and keeps the original consumption visible', async () => {
const { dialog } = await openRefundDialog()
expect(dialog.text()).toContain('AC-CONSUME-61')
expect(dialog.text()).toContain('RELABEL-1')
await dialog.get('input[placeholder="请输入退款金额"]').setValue('10')
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).not.toHaveBeenCalled()
})
it('locks a pending refund, preserves failures, and retries the identical payload with the same key', async () => {
const pending = deferred<Record<string, unknown>>()
vi.mocked(api.refundFinanceConsumption).mockReturnValueOnce(pending.promise).mockResolvedValueOnce({})
const { wrapper, dialog } = await openRefundDialog()
await dialog.get('input[placeholder="请输入退款金额"]').setValue('12.3')
await dialog.get('textarea').setValue(' 原单多扣 ')
await click(dialog, '确认退款')
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
await click(dialog, '取消')
expect(dialog.props('modelValue')).toBe(true)
pending.reject(failure())
await flushPromises()
expect(dialog.text()).toContain('测试操作未成功')
const first = vi.mocked(api.refundFinanceConsumption).mock.calls[0]!
expect(first).toEqual([account.customerId, '61', { amount: '12.30', reason: '原单多扣' }, expect.stringMatching(/^finance-refund-/)])
await click(dialog, '确认退款')
expect(vi.mocked(api.refundFinanceConsumption).mock.calls[1]).toEqual(first)
expect(api.listFinanceAccountLedger).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('账户流水')
})
it('creates a new key for changed amount after a rejected request', async () => {
vi.mocked(api.refundFinanceConsumption).mockRejectedValue(failure())
const { dialog } = await openRefundDialog()
await dialog.get('input[placeholder="请输入退款金额"]').setValue('10')
await dialog.get('textarea').setValue('退款')
await click(dialog, '确认退款')
await dialog.get('input[placeholder="请输入退款金额"]').setValue('11')
await click(dialog, '确认退款')
const calls = vi.mocked(api.refundFinanceConsumption).mock.calls
expect(calls[0]![3]).not.toBe(calls[1]![3])
})
it('hides refunds without adjustment permission and labels refund original references', async () => {
can.mockImplementation(permission => permission !== 'platform:finance:customer-account:adjust')
vi.mocked(api.listFinanceAccountLedger).mockResolvedValue({ records: [consumption, { ...consumption, ledgerId: '62', changeType: 'REFUND', amount: '10.00', referenceNo: 'AC-CONSUME-61' }], total: 2, pages: 1 })
const wrapper = await mountPage()
expect(wrapper.findAll('.ledger-table button').some(button => button.text() === '退款')).toBe(false)
expect(wrapper.text()).toContain('消费退款')
expect(wrapper.text()).toContain('原消费:')
})
})
describe('customer wallet detail error recovery', () => {
it.each(['ACCOUNT', 'LEDGER'] as const)('reloads the current detail after %s fails without same-route navigation', async failurePoint => {
if (failurePoint === 'ACCOUNT') vi.mocked(api.listFinanceAccounts).mockRejectedValueOnce(failure())
else vi.mocked(api.listFinanceAccountLedger).mockRejectedValueOnce(failure())
const wrapper = await mountPage()
expect(wrapper.get('.load-error-state').text()).toContain('测试操作未成功')
const pending = deferred<Awaited<ReturnType<typeof api.listFinanceAccounts>>>()
vi.mocked(api.listFinanceAccounts).mockReturnValueOnce(pending.promise)
await click(wrapper, '重新加载', '.load-error-state button')
expect(api.listFinanceAccounts).toHaveBeenCalledTimes(2)
pending.resolve({ records: [{ ...account, actualBalance: '90.00' }], total: 1, pages: 1 })
await flushPromises()
expect(wrapper.find('.load-error-state').exists()).toBe(false)
expect(wrapper.get('[aria-label="客户资金账户余额"]').text()).toContain('¥90.00')
expect(wrapper.find('.ledger-table').exists()).toBe(true)
expect(api.listFinanceAccountLedger).toHaveBeenCalledTimes(failurePoint === 'ACCOUNT' ? 1 : 2)
expect(api.listFinanceRechargeRequests).toHaveBeenCalledTimes(failurePoint === 'ACCOUNT' ? 1 : 2)
})
})
it('enforces the refund contract reason limit before submitting', async () => {
const { wrapper, dialog } = await openRefundDialog()
expect(dialog.get('textarea').attributes('maxlength')).toBe('500')
await dialog.get('input[placeholder="请输入退款金额"]').setValue('10')
const vm = wrapper.vm as unknown as { refundForm: { reason: string } }
vm.refundForm.reason = '退'.repeat(501)
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).not.toHaveBeenCalled()
vm.refundForm.reason = '退'.repeat(500)
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).toHaveBeenCalledWith(account.customerId, consumption.ledgerId, { amount: '10.00', reason: '退'.repeat(500) }, expect.any(String))
})
@@ -18,7 +18,7 @@ vi.mock('../api/finance', () => ({
listFinanceAccountLedger: vi.fn(),
listFinanceRechargeRequests: vi.fn(),
reviewFinanceRechargeRequest: vi.fn(),
adjustFinanceAccount: vi.fn(),
adjustFinanceAccount: vi.fn(), refundFinanceConsumption: vi.fn(),
updateFinanceCreditLimit: vi.fn(),
downloadFinanceRechargeVoucher: vi.fn(),
getFinanceCustomerTerms: vi.fn(),
@@ -22,7 +22,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance'
import { refundFinanceConsumption, adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance'
import { financeBusinessTypeLabel } from '../status'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry, FinanceRechargeRequest } from '../types'
@@ -45,6 +45,7 @@ const saving = ref(false)
const selected = ref<FinanceAccountSummary | null>(null)
const ledger = ref<FinanceLedgerEntry[]>([])
const ledgerLoading = ref(false)
const detailLoading = ref(false)
const ledgerError = ref<ApiErrorDetails | null>(null)
const selectedTerms = ref<FinanceCustomerTerms | null>(null)
const creditDays = ref(30)
@@ -57,6 +58,12 @@ const rechargeLoading = ref(false)
const rechargeError = ref<ApiErrorDetails | null>(null)
const accountExportBusy = ref(false)
const ledgerExportBusy = ref(false)
const refundOpen = ref(false)
const refundSaving = ref(false)
const refundSource = ref<{ customerId: string; entry: FinanceLedgerEntry } | null>(null)
const refundForm = reactive({ amount: '', reason: '' })
const refundError = ref<ApiErrorDetails | null>(null)
let refundAttempt = { payload: '', key: '' }
const adjustmentOpen = ref(false)
const adjustmentSaving = ref(false)
const adjustmentAccount = ref<FinanceAccountSummary | null>(null)
@@ -114,6 +121,7 @@ const ledgerQueryFields: QueryField[] = [
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
]
const ledgerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'ledgerId', label: '流水号', width: 180, slotName: 'ledgerId' }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 140, slotName: 'businessType' },
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, slotName: 'amount' },
{ key: 'paymentMethod', label: '支付方式', width: 130, slotName: 'ledgerPaymentMethod' },
@@ -204,7 +212,15 @@ async function refreshWalletDetail() {
if (!selected.value) return
await Promise.all([loadRechargeRequests(), loadLedger()])
}
async function retryDrawer() { if (selected.value) await openAccount(selected.value as unknown as Record<string, unknown>, editing.value) }
async function retryDrawer() {
if (!canView.value || detailLoading.value) return
if (editing.value) {
if (selected.value) await openAccount(selected.value as unknown as Record<string, unknown>, true)
return
}
const customerId = String(route.params.customerId || selected.value?.customerId || '')
if (customerId) await restoreAccountDetail(customerId)
}
async function saveTerms() {
if (!selected.value || !Number.isInteger(creditDays.value) || creditDays.value < 0 || creditDays.value > 3650) { ElMessage.warning('账期必须是 0 到 3650 的整数天'); return }
saving.value = true
@@ -237,7 +253,41 @@ function paymentMethodLabel(value: unknown) {
return ({ BANK_TRANSFER: '银行转账', WECHAT: '微信', ALIPAY: '支付宝', CASH: '现金', OTHER: '其他' } as Record<string, string>)[String(value)] || '其他'
}
function walletBusinessType(value: unknown) {
return ({ RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费结算', CREDIT_LIMIT: '授信调整' } as Record<string, string>)[String(value)] || ledgerBusinessType(value)
return ({ RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费结算', REFUND: '消费退款', CREDIT_LIMIT: '授信调整' } as Record<string, string>)[String(value)] || ledgerBusinessType(value)
}
function canRefund(entry: FinanceLedgerEntry) {
return canAdjust.value && entry.changeType === 'CONSUMPTION' && !!entry.ledgerId && Number(entry.amount) < 0
}
function openRefund(entry: FinanceLedgerEntry) {
if (!selected.value || !canRefund(entry) || refundSaving.value) return
refundSource.value = { customerId: selected.value.customerId, entry: { ...entry } }
Object.assign(refundForm, { amount: '', reason: '' })
refundError.value = null
refundAttempt = { payload: '', key: '' }
refundOpen.value = true
}
async function submitRefund() {
const source = refundSource.value
if (refundSaving.value || !source || !canRefund(source.entry) || selected.value?.customerId !== source.customerId) return
const raw = refundForm.amount.trim()
const amount = Number(raw)
if (!/^\d+(?:\.\d{1,2})?$/.test(raw) || !Number.isFinite(amount) || amount <= 0 || amount > Math.abs(Number(source.entry.amount))) {
ElMessage.warning('退款金额必须大于 0、最多两位小数,且不能超过原消费金额'); return
}
const reason = refundForm.reason.trim()
if (!reason || reason.length > 500) { ElMessage.warning('请填写退款说明(最多 500 字)'); return }
const payload = { amount: amount.toFixed(2), reason }
const signature = JSON.stringify([source.customerId, source.entry.ledgerId, payload])
if (refundAttempt.payload !== signature) refundAttempt = { payload: signature, key: 'finance-refund-' + crypto.randomUUID() }
refundSaving.value = true
refundError.value = null
try {
await refundFinanceConsumption(source.customerId, source.entry.ledgerId, payload, refundAttempt.key)
refundOpen.value = false
ElMessage.success('消费退款已入账')
if (selected.value?.customerId === source.customerId) await refreshWalletDetail()
} catch (error) { refundError.value = getApiErrorDetails(error, '退款失败,请重试') }
finally { refundSaving.value = false }
}
function openAdjustment(account: FinanceAccountSummary, type: 'RECHARGE' | 'DEDUCTION') {
adjustmentAccount.value = account
@@ -425,12 +475,15 @@ async function closeDetail() {
if (route.name === 'finance-customer-account-detail') await router.push({ name: 'finance-customer-accounts' })
}
async function restoreAccountDetail(customerId: string) {
if (!canView.value || detailLoading.value) return
detailLoading.value = true
detailOpen.value = true; editing.value = false; ledgerError.value = null
try {
selected.value = (await listFinanceAccounts({ page: 1, size: 1, customerId })).records.find(row => row.customerId === customerId) || null
if (!selected.value) { ledgerError.value = { message: '未找到该客户账户,可能已停用或无权访问' }; return }
await Promise.all([loadLedger(), loadRechargeRequests()])
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户详情加载失败,请重试') }
finally { detailLoading.value = false }
}
onMounted(() => {
const customerId = String(route.params.customerId || '')
@@ -468,7 +521,7 @@ onBeforeUnmount(clearVoucherPreview)
<DetailPageLayout class="finance-account-detail__layout finance-density-detail" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><h1 class="density-detail-title">{{ selected ? customerName(selected.customerId, selected.customerName) : '客户账户' }}</h1><span class="finance-detail-context">{{ editing ? '维护客户账期' : 'CNY 资金账户' }}</span></div><div v-if="selected && !editing" class="finance-detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button></div></div></template>
<template v-if="selected && !ledgerError" #summary><dl class="density-detail-overview account-metrics" aria-label="客户资金账户余额"><div class="account-metric is-actual"><dt>实际余额</dt><dd :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }} <small>CNY</small></dd></div><div class="account-metric is-frozen"><dt>冻结金额</dt><dd>{{ money(selected.frozenBalance) }} <small>CNY</small></dd></div><div class="account-metric is-available"><dt>总可用余额</dt><dd>{{ money(selected.availableBalance) }} <small>CNY</small></dd></div><div class="account-metric"><dt>授信额度</dt><dd>{{ money(selected.creditLimit) }} <small>{{ creditExpiryLabel(selected) }}</small></dd></div><div class="account-metric is-frozen"><dt>已用授信</dt><dd>{{ money(selected.creditUsed) }} <small>CNY</small></dd></div><div class="account-metric"><dt>可用授信</dt><dd>{{ money(selected.availableCredit) }} <small>CNY</small></dd></div></dl></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading || detailLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<FormSection class="customer-profile-section density-form-section" title="客户信息" :bordered="false"><el-descriptions border class="density-descriptions" :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
<FormSection v-if="!editing" class="recharge-section density-form-section" title="充值申请" :bordered="false">
@@ -483,7 +536,7 @@ onBeforeUnmount(clearVoucherPreview)
</FormSection>
<FormSection v-if="!editing" class="ledger-section density-form-section" title="账户流水" :bordered="false">
<QueryToolbar v-model="ledgerFilters" class="ledger-query workbench-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" />
<div class="detail-table-scroll workbench-table"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
<div class="detail-table-scroll workbench-table"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #actions="{ row }"><el-button v-if="canRefund(row as FinanceLedgerEntry)" link type="primary" @click="openRefund(row as FinanceLedgerEntry)">退款</el-button><span v-else>—</span></template><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span v-if="row.changeType === 'REFUND'">原消费:</span><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
</FormSection>
<FormSection v-else class="term-section density-form-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" placeholder="请输入账期天数" /><span class="term-unit">天</span></div></div></FormSection>
</template>
@@ -491,6 +544,21 @@ onBeforeUnmount(clearVoucherPreview)
</DetailPageLayout>
</section>
</AdminPage>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="refundOpen" title="原消费退款" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="确认退款" :confirm-loading="refundSaving" @confirm="submitRefund">
<div class="density-dialog-sections">
<section class="density-dialog-section workbench-evidence" data-tone="source" aria-label="原消费资料"><h3 class="density-dialog-section__title">原消费(CNY)</h3><table class="density-table wallet-summary"><tbody>
<tr><th scope="row">原消费流水</th><td>{{ refundSource?.entry.businessNo || refundSource?.entry.ledgerId }}</td></tr>
<tr><th scope="row">关联业务单据</th><td>{{ refundSource?.entry.sourceNo || refundSource?.entry.documentNo || '—' }}</td></tr>
<tr><th scope="row">原消费金额</th><td>{{ money(refundSource?.entry.amount) }}</td></tr>
</tbody></table></section>
<el-alert title="退款会生成独立流水并关联原消费;累计可退金额以服务端校验为准。" type="info" :closable="false" />
<div v-if="refundError"><el-alert :title="refundError.message" type="error" :closable="false" /><ApiErrorMeta :details="refundError" /></div>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="退款登记"><el-form class="density-form" label-position="left" label-width="100px" :disabled="refundSaving"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="退款金额" required><el-input v-model="refundForm.amount" inputmode="decimal" placeholder="请输入退款金额" /></el-form-item></td></tr>
<tr><td><el-form-item label="退款说明" required><el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写退款说明" /></el-form-item></td></tr>
</tbody></table></el-form></section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="adjustmentOpen" :title="`${adjustmentForm.type === 'RECHARGE' ? '手工充值' : '手工扣款'} · ${adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable :confirm-text="adjustmentForm.type === 'RECHARGE' ? '确认充值' : '确认扣款'" :confirm-type="adjustmentForm.type === 'RECHARGE' ? 'primary' : 'danger'" :confirm-loading="adjustmentSaving" @confirm="submitAdjustment">
<div class="density-dialog-sections">
<section class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前账户资料">
@@ -69,6 +69,7 @@ describe('InboundTaskList measurement scope', () => {
expect(source).toContain(':show-actions="false"')
expect(source).toContain('<template #footer="{ close }">')
expect(source).toContain('reviewPanelRef?.confirmApprove()')
expect(source).not.toContain('修改重量')
})
it('loads only active customers and exposes a searchable name-only select', () => {
@@ -469,7 +469,6 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
<div class="review-dialog-footer">
<el-button :disabled="reviewNavigationLoading" @click="close">关闭</el-button>
<template v-if="reviewPanelRef?.isPending && reviewPanelRef?.canReview">
<el-button type="primary" plain @click="reviewPanelRef?.enableWeightCorrection()">修改重量</el-button>
<el-button type="primary" :loading="reviewPanelRef?.actionLoading === 'approve' || reviewNavigationLoading" :disabled="reviewApproved" @click="reviewPanelRef?.confirmApprove()">{{ reviewApproved ? '审核已通过' : reviewPanelRef?.approvalButtonLabel }}</el-button>
</template>
</div>
@@ -62,13 +62,19 @@ describe('MeasurementReviewPanel', () => {
vi.mocked(listMeasurementReviewHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
})
it('keeps customer context and pending actions in one sticky toolbar while reviewing long content', async () => {
it('keeps context in a scrolling toolbar and moves weight actions and totals to the weight details', async () => {
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const toolbar = wrapper.get('.review-toolbar')
expect(toolbar.text()).toContain('待审核')
expect(toolbar.text()).toContain('修改重量')
expect(toolbar.text()).not.toContain('修改重量')
const weightHeading = wrapper.get('.review-weight-items header')
expect(weightHeading.text()).toContain('修改重量')
expect(weightHeading.text()).toContain('实测毛重 120 kg')
expect(weightHeading.text()).toContain('货物重量 110 kg')
expect(weightHeading.find('.lucide-scale').exists()).toBe(true)
expect(weightHeading.find('.lucide-pencil').exists()).toBe(true)
expect(toolbar.findAll('button').map(button => button.text())).toContain('通过')
expect(toolbar.text()).toContain('刷新')
expect(toolbar.text()).toContain('客户:柏悦客户')
@@ -83,7 +89,7 @@ describe('MeasurementReviewPanel', () => {
expect(wrapper.text()).not.toContain('审核历史')
expect(listMeasurementReviewHistory).not.toHaveBeenCalled()
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
expect(source).toContain('position: sticky')
expect(source).not.toContain('position: sticky')
expect(source).toContain('review-toolbar__context')
expect(source.indexOf('class="review-toolbar"')).toBeLessThan(source.indexOf('<WeightOcrPanel'))
expect(source).not.toContain('reasonDialog')
@@ -102,7 +108,7 @@ describe('MeasurementReviewPanel', () => {
expect(wrapper.text()).not.toContain('通过并进入')
await wrapper.findAll('button').find((button) => button.text() === '通过')!.trigger('click')
await flushPromises()
expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('进入待一件代发组托'), '确认测量审核通过', expect.objectContaining({ confirmButtonText: '通过' }))
expect(confirmation).toHaveBeenCalledWith('审核通过后,入库单将进入待分柜', '确认测量', expect.objectContaining({ confirmButtonText: '通过' }))
expect(initialPricingSucceeded ? success : warning).toHaveBeenCalledWith(expect.stringContaining('入库单已进入待一件代发组托'))
expect(approveMeasurement).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('changed')).toHaveLength(1)
@@ -193,7 +199,6 @@ describe('MeasurementReviewPanel', () => {
const weightPanel = wrapper.getComponent({ name: 'WeightOcrPanel' })
expect(weightPanel.props('reviewMode')).toBe(true)
expect(weightPanel.props('tableMode')).toBe(true)
expect(weightPanel.props('correctionMode')).toBe(false)
await wrapper.findAll('button').find((button) => button.text().includes('修改重量'))?.trigger('click')
expect(weightPanel.props('correctionMode')).toBe(true)
@@ -229,7 +234,7 @@ describe('MeasurementReviewPanel', () => {
expect(wrapper.find('.review-toolbar__actions').exists()).toBe(false)
expect(wrapper.get('.review-toolbar__context').text()).toContain('客户:柏悦客户')
expect(wrapper.vm.approvalButtonLabel).toBe('通过')
wrapper.vm.enableWeightCorrection()
await wrapper.get('.review-weight-summary button').trigger('click')
await flushPromises()
expect(wrapper.getComponent({ name: 'WeightOcrPanel' }).props('correctionMode')).toBe(true)
await wrapper.vm.confirmApprove()
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Pencil, RotateCcw } from '@lucide/vue'
import { Check, Pencil, RotateCcw, Ruler, Scale } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -13,6 +13,7 @@ import { downloadInboundMeasurementImage } from '../measurement/api'
import MeasurementEvidenceThumbnail from '../measurement/components/MeasurementEvidenceThumbnail.vue'
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
import type { WeightResultResource } from '../weight/types'
import { approveMeasurement, getMeasurementReviewDetail } from './api'
import type { MeasurementReviewDetail } from './types'
@@ -38,6 +39,7 @@ const loading = ref(false)
const actionLoading = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const correctionMode = ref(false)
const weightPanelRef = ref<InstanceType<typeof WeightOcrPanel> | null>(null)
const requestSerial = ref(0)
const imagePreviewOpen = ref(false)
const imagePreviewTitle = ref('测量图片')
@@ -125,7 +127,7 @@ async function loadReview() {
}
async function reload() {
await loadReview()
await Promise.all([loadReview(), weightPanelRef.value?.reload()])
}
async function handleWeightChanged() {
@@ -140,8 +142,8 @@ async function confirmApprove() {
if (!detail.value || !isPending.value || !canReview.value || actionLoading.value || props.postApproveLoading || props.approvalCompleted) return
try {
await ElMessageBox.confirm(
`审核通过后,入库单将进入${approvalTargetLabel.value}。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?`,
'确认测量审核通过',
'审核通过后,入库单将进入待分柜',
'确认测量',
{ customClass: 'admin-density-confirm', confirmButtonText: approvalButtonLabel.value, cancelButtonText: '返回', type: 'warning' },
)
} catch {
@@ -175,7 +177,7 @@ onMounted(() => {
})
onBeforeUnmount(releaseImagePreview)
defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPending, canReview, actionLoading })
defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, actionLoading })
</script>
<template>
@@ -183,7 +185,6 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
<h2 id="measurement-review-title" class="sr-only">测量审核内容</h2>
<div class="review-toolbar" aria-label="测量审核操作">
<div v-if="showActions" class="review-toolbar__actions">
<el-button v-if="isPending && canReview" type="primary" plain @click="enableWeightCorrection"><Pencil :size="16" />修改重量</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve' || postApproveLoading" :disabled="approvalCompleted" @click="confirmApprove"><Check :size="16" />{{ approvalCompleted ? '审核已通过' : approvalButtonLabel }}</el-button>
</div>
<div class="review-toolbar__status">
@@ -220,18 +221,13 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
<section class="review-items density-dialog-section" data-tone="source" aria-labelledby="review-items-title">
<header class="review-section-heading density-dialog-section__title">
<div>
<h3 id="review-items-title">箱规测量明细</h3>
</div>
<h3 id="review-items-title" class="review-detail-heading"><Ruler :size="18" aria-hidden="true" />箱规测量明细</h3>
<span>{{ measurementCount }} 条</span>
</header>
<div v-if="detail.items.length" class="review-table-scroll">
<el-table :data="detail.items" border stripe table-layout="fixed" class="review-table">
<el-table-column type="index" label="#" width="44" align="center" />
<el-table-column label="箱号" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.boxCode || row.itemNo || '—' }}</template>
</el-table-column>
<el-table-column label="测量图片" width="98" align="center">
<el-table-column label="测量图片" width="112" align="center">
<template #default="{ row }">
<MeasurementEvidenceThumbnail
:customer-id="customerId"
@@ -243,6 +239,9 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
/>
</template>
</el-table-column>
<el-table-column label="箱号" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.boxCode || row.itemNo || '—' }}</template>
</el-table-column>
<el-table-column label="尺寸 长×宽×高(cm)" min-width="200" align="center">
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
</el-table-column>
@@ -258,7 +257,7 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
<el-table-column label="体积重(kg)" width="96" align="right">
<template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template>
</el-table-column>
<el-table-column label="测量时间" width="190" class-name="review-time-cell">
<el-table-column label="测量时间" width="220" class-name="review-time-cell">
<template #default="{ row }">{{ formatDate(row.deviceMeasuredAt) }}</template>
</el-table-column>
</el-table>
@@ -267,32 +266,45 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
</section>
<section class="review-items review-weight-items density-dialog-section" data-tone="source" aria-labelledby="review-weight-title">
<header class="review-section-heading density-dialog-section__title"><h3 id="review-weight-title">称重明细</h3><span>{{ weightCount }} 条</span></header>
<div v-if="weightCount" class="review-table-scroll">
<el-table :data="detail.weightResults" border stripe table-layout="fixed" class="review-table review-weight-table">
<el-table-column type="index" label="#" width="44" align="center" />
<el-table-column label="称重图片" width="112" align="center"><template #default="{ row }"><MeasurementEvidenceThumbnail :customer-id="customerId" :inbound-id="inboundId" :file-id="row.fileId" :alt="`${row.palletCode || '托盘'}称重图片`" empty-text="无图片" @open="openImagePreview(row.fileId, row.palletCode || '托盘')" /></template></el-table-column>
<el-table-column prop="palletCode" label="托盘编码" min-width="120" />
<el-table-column prop="palletType" label="托盘类型" min-width="120" />
<el-table-column label="库位" min-width="130"><template #default="{ row }"><span class="review-location">{{ row.locationCode || '—' }}<template v-if="row.locationCode"> · {{ row.slotStatus === 'BOUND' ? '待上架' : row.slotStatus || '待上架' }}</template></span></template></el-table-column>
<el-table-column label="毛重(KG)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.grossWeightKg, 3) }}</template></el-table-column>
<el-table-column label="托盘皮重(kg)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.palletTareWeightKg, 3) }}</template></el-table-column>
<el-table-column label="货物重量(kg)" min-width="120" align="right"><template #default="{ row }"><strong class="review-weight-value">{{ formatNumber(row.netWeightKg, 3) }}</strong></template></el-table-column>
<el-table-column label="称重时间" min-width="150"><template #default="{ row }">{{ formatDate(row.confirmedAt || row.adjustedAt) }}</template></el-table-column>
</el-table>
</div>
<el-empty v-else description="暂无称重明细" :image-size="48" />
<header class="review-section-heading density-dialog-section__title">
<h3 id="review-weight-title" class="review-detail-heading"><Scale :size="18" aria-hidden="true" />称重明细</h3>
<div class="review-weight-summary">
<span>实测毛重 <strong>{{ formatNumber(detail.actualGrossWeightKg) }}</strong> kg</span>
<span>货物重量 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span>
<el-button v-if="isPending && canReview" type="primary" plain @click="enableWeightCorrection"><Pencil :size="16" />修改重量</el-button>
<span>{{ weightCount }} 条</span>
</div>
</header>
<WeightOcrPanel
ref="weightPanelRef"
review-mode
:correction-mode="correctionMode && isPending && canReview"
:customer-id="customerId"
:inbound-id="inboundId"
@changed="handleWeightChanged"
>
<template #heading><span id="weight-ocr-title" class="sr-only">称重图片预览与修正</span></template>
<template #results="{ rows, openPreview, canCorrect, busy }">
<div v-if="rows.length" class="review-table-scroll">
<el-table :data="rows" border stripe table-layout="fixed" class="review-table review-weight-table">
<el-table-column v-if="canCorrect" label="操作" width="100">
<template #default="{ row, $index }"><el-button v-if="row.recognitionStatus === 'CONFIRMED'" link type="primary" :aria-label="`修改重量 ${$index + 1}`" :disabled="busy" @click="openPreview(row as WeightResultResource)">修改重量</el-button><span v-else>—</span></template>
</el-table-column>
<el-table-column type="index" label="#" width="44" align="center" />
<el-table-column label="称重图片" width="112" align="center"><template #default="{ row }"><MeasurementEvidenceThumbnail :customer-id="customerId" :inbound-id="inboundId" :file-id="row.fileId" :alt="`${row.palletCode || '托盘'}称重图片`" empty-text="无图片" @open="openPreview(row as WeightResultResource)" /></template></el-table-column>
<el-table-column prop="palletCode" label="托盘编码" width="160" show-overflow-tooltip />
<el-table-column prop="palletType" label="托盘类型" min-width="120" />
<el-table-column label="库位" min-width="130"><template #default="{ row }"><span class="review-location">{{ row.locationCode || '—' }}<template v-if="row.locationCode"> · {{ row.slotStatus === 'BOUND' ? '待上架' : row.slotStatus || '待上架' }}</template></span></template></el-table-column>
<el-table-column label="毛重(KG)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.grossWeightKg, 3) }}</template></el-table-column>
<el-table-column label="托盘皮重(kg)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.palletTareWeightKg, 3) }}</template></el-table-column>
<el-table-column label="货物重量(kg)" min-width="120" align="right"><template #default="{ row }"><strong class="review-weight-value">{{ formatNumber(row.netWeightKg, 3) }}</strong></template></el-table-column>
<el-table-column label="称重时间" width="220" class-name="review-time-cell"><template #default="{ row }">{{ formatDate(row.confirmedAt || row.adjustedAt) }}</template></el-table-column>
</el-table>
</div>
<el-empty v-else-if="!busy" description="暂无称重明细" :image-size="48" />
</template>
</WeightOcrPanel>
</section>
<WeightOcrPanel
review-mode
table-mode
:correction-mode="correctionMode"
:customer-id="customerId"
:inbound-id="inboundId"
@changed="handleWeightChanged"
:class="{ 'review-weight-editor--hidden': !correctionMode }"
/>
</template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="imagePreviewOpen" :title="imagePreviewTitle" width="min(760px, 92vw)" fullscreenable :show-footer="false" :close-on-click-modal="true" destroy-on-close append-to-body @closed="releaseImagePreview">
@@ -310,8 +322,8 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
</template>
<style scoped>
.review-panel { display: grid; gap: var(--admin-space-5, 12px); color: var(--workbench-text); }
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); position: sticky; top: 0; z-index: 3; flex-wrap: wrap; padding: var(--admin-space-3, 8px) 0; border-bottom: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
.review-panel { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--admin-space-5, 12px); color: var(--workbench-text); }
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); flex-wrap: wrap; padding: var(--admin-space-3, 8px) 0; border-bottom: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
.review-toolbar__status, .review-toolbar__actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); }
.review-toolbar__status > span { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
.review-toolbar__actions { justify-content: flex-end; }
@@ -323,6 +335,8 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
.review-section-heading > div { min-width: 0; }
.review-section-heading h3 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section, 14px); line-height: 1.35; }
.review-section-heading > span { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); white-space: nowrap; }
.review-items > .review-section-heading { min-height: var(--admin-control-height, 32px); }
.review-weight-items :deep(.weight-list) { margin-top: 0; }
.review-comparison { table-layout: fixed; }
.review-extra-facts { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--admin-space-5, 12px); margin-top: var(--admin-space-3, 8px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
.review-extra-facts strong { color: var(--workbench-text); }
@@ -332,7 +346,10 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
.review-table :deep(.measurement-evidence-thumb) { min-width: 72px; border-radius: var(--admin-radius, 4px); }
.review-weight-value { color: var(--el-color-success-dark-2); font-weight: 600; }
.review-location { white-space: nowrap; }
.review-weight-editor--hidden { display: none; }
.review-detail-heading, .review-weight-summary { display: flex; align-items: center; gap: var(--admin-space-5, 12px); }
.review-detail-heading > svg { flex-shrink: 0; }
.review-weight-summary { flex-wrap: wrap; color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
.review-weight-summary strong { color: var(--el-color-success-dark-2); }
.review-image-preview { display: grid; min-height: 240px; place-items: center; }
.review-image-preview img { display: block; width: auto; max-width: 100%; max-height: 70vh; object-fit: contain; }
.review-image-preview__error { display: grid; justify-items: center; gap: var(--admin-space-3, 8px); color: var(--el-color-danger); }
@@ -22,6 +22,10 @@ import {
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
import WeightOcrPanel from './WeightOcrPanel.vue'
import InboundMeasurementDialog from '../../measurement/InboundMeasurementDialog.vue'
import MeasurementReviewPanel from '../../review/MeasurementReviewPanel.vue'
import { getMeasurementReviewDetail } from '../../review/api'
vi.mock('../../review/api', () => ({ getMeasurementReviewDetail: vi.fn(), approveMeasurement: vi.fn() }))
vi.mock('../api/weightOcr', async () => {
const actual = await vi.importActual<typeof import('../api/weightOcr')>('../api/weightOcr')
@@ -100,6 +104,17 @@ const workspace: WeightWorkspaceResource = {
},
}
const confirmedReviewWorkspace: WeightWorkspaceResource = {
...workspace,
processStatus: 'MEASURE_PENDING',
actualGrossWeightKg: 120,
actualNetWeightKg: 108,
weightResults: [{
...pendingWeight, recognitionStatus: 'CONFIRMED', adjustedWeightValue: 120,
adjustedWeightUnit: 'KG', grossWeightKg: 120, netWeightKg: 108, version: 1,
}],
}
function palletPage(records = [{
palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12,
}]) {
@@ -128,7 +143,7 @@ function mountPanel(permissions = allWeightPermissions, props: Record<string, un
})
}
async function openWeightDialog(wrapper: VueWrapper) {
async function openWeightDialog(wrapper: Pick<VueWrapper, 'get'>) {
await wrapper.get('button[aria-label="查看称重图片 1"]').trigger('click')
await flushPromises()
}
@@ -496,7 +511,59 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(wrapper.text()).toContain('1747.500 KG')
})
it('opens a table image and atomically corrects and confirms an approved weight during measurement review', async () => {
it('corrects a weight in the single review details table and refreshes totals without adding an image section', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue(confirmedReviewWorkspace)
const reviewDetail = {
inboundId: '20001', inboundCode: 'RK-20001', companyName: '测试客户', channelCode: 'SEA', channelName: '海运',
processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: null,
reviewReason: null, reviewerId: null, reviewerName: null, measurementId: '70001', sourceType: 'DEVICE',
measurementStatus: 'SUBMITTED', inboundVersion: 1, version: 1, originalDocumentNo: null,
originalGrossWeightKg: 100, originalVolumeCbm: 1, originalPackageCount: 1, actualPackageCount: 1,
actualGrossWeightKg: 120, actualNetWeightKg: 108, actualVolumeCbm: 1, volumeWeightKg: 167,
actualGoodsName: null, items: [], weightResults: confirmedReviewWorkspace.weightResults,
}
vi.mocked(getMeasurementReviewDetail).mockResolvedValue(reviewDetail)
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = [...allWeightPermissions, 'firstleg:inbound:measurement-review']
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(MeasurementReviewPanel, {
attachTo: document.body,
props: { customerId: '10001', inboundId: '20001', showActions: false },
global: { plugins: [pinia, ElementPlus] },
})
await flushPromises()
expect(wrapper.findAll('.review-weight-table')).toHaveLength(1)
expect(wrapper.find('.weight-data-table').exists()).toBe(false)
expect(wrapper.find('.weight-heading').exists()).toBe(false)
expect(wrapper.find('button[aria-label="修改重量 1"]').exists()).toBe(false)
await wrapper.get('.review-weight-summary button').trigger('click')
await wrapper.get('button[aria-label="修改重量 1"]').trigger('click')
await flushPromises()
await dialogWeightInput().setValue('125')
await new DOMWrapper(document.body.querySelector<HTMLTextAreaElement>('#weight-correction-reason')!).setValue('复核磅秤读数')
const updatedWeight = { ...confirmedReviewWorkspace.weightResults[0], grossWeightKg: 125, netWeightKg: 113, version: 2 }
vi.mocked(confirmWeightResult).mockResolvedValue(updatedWeight)
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...confirmedReviewWorkspace, actualGrossWeightKg: 125, actualNetWeightKg: 113, weightResults: [updatedWeight] })
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...reviewDetail, actualGrossWeightKg: 125, actualNetWeightKg: 113, inboundVersion: 2 })
await dialogConfirmButton('保存重量修正').trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
version: 1, adjustedWeightValue: 125, adjustedWeightUnit: 'KG', adjustmentReason: '复核磅秤读数',
})
expect(wrapper.get('.review-weight-summary').text()).toContain('实测毛重 125 kg')
expect(wrapper.get('.review-weight-summary').text()).toContain('货物重量 113 kg')
expect(wrapper.get('.review-weight-table').text()).toContain('125')
expect(wrapper.findAll('.review-weight-table')).toHaveLength(1)
const workspaceCalls = vi.mocked(getWeightWorkspace).mock.calls.length
await wrapper.get('.review-refresh').trigger('click')
await flushPromises()
expect(getWeightWorkspace).toHaveBeenCalledTimes(workspaceCalls + 1)
})
it('opens the explicit table correction action and atomically confirms an approved weight during measurement review', async () => {
const confirmed = {
...pendingWeight,
recognitionStatus: 'CONFIRMED',
@@ -521,7 +588,10 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(wrapper.text()).not.toContain('删除图片')
expect(wrapper.find('.weight-data-table').exists()).toBe(true)
expect(wrapper.find('.weight-grid').exists()).toBe(false)
await openWeightDialog(wrapper)
expect(wrapper.get('.weight-data-table thead th').text()).toBe('操作')
expect(wrapper.find('.pallet-picker').exists()).toBe(false)
await wrapper.get('button[aria-label="修改重量 1"]').trigger('click')
await flushPromises()
await dialogWeightInput().setValue('125')
const reason = document.body.querySelector<HTMLTextAreaElement>('textarea[placeholder="请填写本次毛重修正原因"]')
expect(reason).not.toBeNull()
@@ -538,6 +608,58 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(correctWeightResult).not.toHaveBeenCalled()
})
it.each([
{ permissions: allWeightPermissions.filter(permission => !permission.endsWith('weight:edit')), status: 'MEASURE_PENDING', correctionMode: true },
{ permissions: allWeightPermissions.filter(permission => !permission.endsWith('weight:confirm')), status: 'MEASURE_PENDING', correctionMode: true },
{ permissions: allWeightPermissions, status: 'WAIT_SPLIT', correctionMode: true },
{ permissions: allWeightPermissions, status: 'MEASURE_REJECTED', correctionMode: true },
{ permissions: allWeightPermissions, status: 'MEASURE_PENDING', correctionMode: false },
])('keeps table correction unavailable for missing permissions or inactive review: %j', async ({ permissions, status, correctionMode }) => {
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...confirmedReviewWorkspace, processStatus: status })
const wrapper = mountPanel(permissions, { reviewMode: true, correctionMode, tableMode: true })
await flushPromises()
expect(wrapper.find('button[aria-label="修改重量 1"]').exists()).toBe(false)
await openWeightDialog(wrapper)
expect(dialogWeightInput().attributes('disabled')).toBeDefined()
expect(wrapper.getComponent(AppDialog).props('showConfirm')).toBe(false)
expect(confirmWeightResult).not.toHaveBeenCalled()
})
it('allows cancelling and reopening correction, and preserves the draft for retry after a save fails', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue(confirmedReviewWorkspace)
vi.mocked(confirmWeightResult)
.mockRejectedValueOnce(new Error('重量写入失败'))
.mockResolvedValueOnce({ ...confirmedReviewWorkspace.weightResults[0], grossWeightKg: 125, netWeightKg: 113, version: 2 })
const wrapper = mountPanel(allWeightPermissions, { reviewMode: true, correctionMode: true, tableMode: true })
await flushPromises()
const correctionButton = wrapper.get('button[aria-label="修改重量 1"]')
await correctionButton.trigger('click')
await flushPromises()
await dialogWeightInput().setValue('125')
await new DOMWrapper(document.body.querySelector<HTMLTextAreaElement>('#weight-correction-reason')!).setValue('复核磅秤读数')
const cancelButton = [...document.body.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')].find(button => button.textContent?.trim() === '取消')!
await new DOMWrapper(cancelButton).trigger('click')
await flushPromises()
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(false)
expect(confirmWeightResult).not.toHaveBeenCalled()
await correctionButton.trigger('click')
await flushPromises()
expect(Number(dialogWeightInput().element.value)).toBe(125)
await dialogConfirmButton('保存重量修正').trigger('click')
await flushPromises()
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true)
expect(Number(dialogWeightInput().element.value)).toBe(125)
expect(dialogConfirmButton('保存重量修正').attributes('disabled')).toBeUndefined()
expect(wrapper.emitted('changed')).toBeUndefined()
await dialogConfirmButton('保存重量修正').trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledTimes(2)
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(false)
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('closes the confirmation dialog immediately after a successful confirmation', async () => {
const wrapper = mountPanel()
await flushPromises()
@@ -109,9 +109,9 @@ const previewConfirmDisabled = computed(() => {
|| Boolean(activeAction.value)
})
const previewCanConfirm = computed(() => Boolean(previewResult.value && canConfirm.value
&& (editable.value || (reviewCorrectionEnabled.value && canEdit.value))))
&& ((!props.reviewMode && editable.value) || (reviewCorrectionEnabled.value && canEdit.value))))
const previewInputEditable = computed(() => Boolean(previewResult.value && canEdit.value && canConfirm.value
&& (editable.value || reviewCorrectionEnabled.value)))
&& ((!props.reviewMode && editable.value) || reviewCorrectionEnabled.value)))
const previewConfirmText = computed(() => previewResult.value?.recognitionStatus === 'CONFIRMED'
? '保存重量修正'
: '确认重量')
@@ -495,6 +495,7 @@ defineExpose({ reload: loadWorkspace })
<template>
<section class="weight-workspace admin-workbench" aria-labelledby="weight-ocr-title">
<slot name="heading">
<header class="weight-heading">
<div>
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片</h3>
@@ -504,9 +505,8 @@ defineExpose({ reload: loadWorkspace })
<div class="weight-summary"><span>实测毛重</span><strong>{{ formatWeight(workspace.actualGrossWeightKg) }}</strong></div>
<div class="weight-summary"><span>货物重量</span><strong>{{ formatWeight(workspace.actualNetWeightKg) }}</strong></div>
</div>
<div class="pallet-picker">
<div v-if="canEdit && !reviewMode" class="pallet-picker">
<el-select-v2
v-if="canEdit && !reviewMode"
v-model="selectedPalletId"
:options="virtualPalletOptions"
aria-label="选择称重托盘"
@@ -536,6 +536,7 @@ defineExpose({ reload: loadWorkspace })
><ImagePlus :size="16" />上传称重图片</el-button>
</div>
</header>
</slot>
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
当前账号缺少入库测量查看权限。
@@ -566,7 +567,7 @@ defineExpose({ reload: loadWorkspace })
<template #details><ApiErrorMeta :details="palletError" /></template>
</LoadErrorState>
<el-alert
v-else-if="!palletLoading && palletOptions.length === 0 && editable && canEdit"
v-else-if="!reviewMode && !palletLoading && palletOptions.length === 0 && editable && canEdit"
type="warning"
:closable="false"
show-icon
@@ -576,15 +577,22 @@ defineExpose({ reload: loadWorkspace })
<el-button size="small" @click="reloadPalletOptions"><RefreshCw :size="14" />重新加载</el-button>
</el-alert>
<div v-loading="loading" class="weight-list" aria-live="polite">
<slot name="results" :rows="rows" :open-preview="openPreview" :can-correct="reviewCorrectionEnabled && canEdit && canConfirm" :busy="loading || Boolean(activeAction)">
<div v-if="!loading && rows.length === 0" class="weight-empty">
<strong>尚未上传称重图片</strong>
</div>
<div v-else-if="tableMode" class="weight-table-wrap">
<div class="workbench-evidence"><table class="weight-data-table">
<thead><tr><th>#</th><th>称重图片</th><th>托盘编码</th><th>托盘类型</th><th>库位</th><th>实测毛重(KG)</th><th>服务端确认毛重(KG)</th><th>托盘皮重(KG)</th><th>货物重量(KG)</th><th>操作</th></tr></thead>
<colgroup v-if="reviewMode"><col /><col class="weight-index-column" /><col span="8" /></colgroup>
<thead><tr><th>操作</th><th>#</th><th>称重图片</th><th>托盘编码</th><th>托盘类型</th><th>库位</th><th>实测毛重(KG)</th><th>服务端确认毛重(KG)</th><th>托盘皮重(KG)</th><th>货物重量(KG)</th></tr></thead>
<tbody>
<tr v-for="(row, index) in rows" :key="row.id">
<td>
<el-button v-if="reviewCorrectionEnabled && row.recognitionStatus === 'CONFIRMED' && canEdit && canConfirm" link type="primary" :aria-label="`修改重量 ${index + 1}`" :disabled="Boolean(activeAction)" @click="openPreview(row)">修改重量</el-button>
<el-tooltip v-else-if="canEdit && editable && !reviewMode" content="删除图片" placement="top"><el-button text type="danger" :aria-label="`删除称重图片 ${index + 1}`" :loading="isActionLoading('delete', row.id)" :disabled="Boolean(activeAction)" @click="removeResult(row)"><Trash2 :size="16" /></el-button></el-tooltip>
<span v-else>—</span>
</td>
<td>{{ index + 1 }}</td>
<td><WeightImageThumbnail :customer-id="customerId" :inbound-id="inboundId" :file-id="row.fileId" :alt="`称重图片 ${index + 1}`" @open="openPreview(row)" /></td>
<td>{{ row.palletCode || '—' }}</td>
@@ -594,7 +602,6 @@ defineExpose({ reload: loadWorkspace })
<td>{{ formatWeight(row.grossWeightKg) }}</td>
<td>{{ formatWeight(row.palletTareWeightKg) }}</td>
<td class="weight-net-value">{{ formatWeight(row.netWeightKg) }}</td>
<td><el-tooltip v-if="canEdit && editable && !reviewMode" content="删除图片" placement="top"><el-button text type="danger" :aria-label="`删除称重图片 ${index + 1}`" :loading="isActionLoading('delete', row.id)" :disabled="Boolean(activeAction)" @click="removeResult(row)"><Trash2 :size="16" /></el-button></el-tooltip><span v-else>—</span></td>
</tr>
</tbody>
</table></div>
@@ -644,6 +651,7 @@ defineExpose({ reload: loadWorkspace })
</div>
</article>
</div>
</slot>
</div>
</template>
</template>
@@ -744,6 +752,7 @@ defineExpose({ reload: loadWorkspace })
.weight-card__correct { width: 100%; margin: var(--admin-space-5, 12px) 0 0; }
.weight-table-wrap { overflow-x: auto; border: 1px solid var(--workbench-border); border-radius: 8px; background: #fff; }
.weight-data-table { width: 100%; min-width: 1180px; border-collapse: separate; border-spacing: 0; table-layout: fixed; font-size: var(--admin-font-caption, 12px); }
.weight-data-table .weight-index-column { width: 44px; }
.weight-data-table th { height: 42px; padding: 0 8px; border-bottom: 1px solid var(--workbench-border); background: var(--cc-list-table-header-bg); color: var(--cc-list-table-header-text); font-weight: 700; white-space: nowrap; }
.weight-data-table td { height: 76px; padding: 6px 8px; border-top: 1px solid var(--workbench-border); color: var(--workbench-text); text-align: center; vertical-align: middle; white-space: nowrap; }
.weight-data-table th + th, .weight-data-table td + td { border-left: 1px solid var(--workbench-border); }
+1 -1
View File
@@ -20,7 +20,7 @@
## 关键边界
- 预装详情进入后先调 `/loading-plans/{planId}/lifecycle`,再按返回状态决定可操作项;不自行推断生命周期阶段。
- 托盘码格式为“真实柜号-FBA 地址内部编号-三位序号”,清单只取服务端按 FBA 仓分组的冻结结果,前端不生成、不改码、不重新编号;一件代发托盘不进入该清单。
- 托盘码格式为“柜次号-FBA 地址内部编号-三位序号”,清单只取服务端按 FBA 仓分组的冻结结果,前端不生成、不改码、不重新编号;一件代发托盘不进入该清单。
- 确认预装和创建 PDA 任务前必须补齐真实集装箱号;历史上已建缺号任务可在预装详情首次补录,保存后恢复 PDA 可进入状态。
- 柜级公共费用只按服务端柜内客户/订单事实分摊,创建、审核、驳回、调整、作废均整组原子,金额必须守恒;待确认时禁止调整或重复作废。
- 独立清单详情与货柜详情页签共用同一套清单 API 与详情组件;改展示或打印时同步检查 `container-loading` 的装柜清单页签。
+2 -2
View File
@@ -13,14 +13,14 @@
| 订舱列表、放舱状态与关联候选 | `pages/OceanBookingsView.vue`;`ocean-bookings` | `api/platformOceanBookings.ts`;`firstleg/oceanbooking/` |
| 新增订舱(登记线下已确认订舱) | `pages/OceanBookingDetailView.vue`;`ocean-bookings/new` | `api/platformOceanBookings.ts` |
| 订舱详情、资料维护与版本 | `pages/OceanBookingDetailView.vue`;`ocean-bookings/:bookingId` | `api/platformOceanBookings.ts` |
| 订舱资料面板与字段边界 | `components/OceanBookingDocumentsPanel.vue` | `firstleg/oceanbooking/`;附件归 `file/` |
| 订舱附件组件(详情页已移除入口) | `components/OceanBookingDocumentsPanel.vue` | `firstleg/oceanbooking/`;附件归 `file/` |
| 订舱资料打印 | `components/OceanBookingPrintDialog.vue` | `print/` 单据模板 |
| 柜型需求与数量换算 | `containerRequirements.ts` | `containerCount` 为柜型需求 `quantity` 总和 |
| 订舱代理候选 | `bookingAgents.ts` | 公共供应商列表,仅 `ACTIVE` 且类别/范围匹配 |
## 关键边界
- 订舱草稿按当前版本维护资料;放舱后运输、关系方、柜型需求、预报货量和附件只读;仅实货量可由订舱编辑权限通过 `/actual-cargo` 按版本、幂等键和原因补录并审计,禁止绕过运输冻结边界。
- 订舱草稿按当前版本维护资料;放舱后除预计到港外的运输、关系方、柜型需求、预报货量和附件只读;预计到港经 `/estimated-arrival` 按编辑权限、版本和幂等键单独维护,不得清空或早于预计开船;实货量可由订舱编辑权限通过 `/actual-cargo` 按版本、幂等键和原因补录并审计,禁止绕过运输冻结边界。
- 打印对草稿与已放舱订舱均开放,按当前**已保存**资料生成,未填写字段显示“—”,不用未保存的表单值。托运单取委值,提单确认件取实值;实值由客服手工录入,不回退为委值,不生成正式提单。
- 关联候选只消费服务端 `/link-candidates` 分页结果,不在浏览器重排或本地拼接候选。
- 订舱是拖车预约的唯一归属:拖车由 `../trucking/` 创建,本模块不直接建拖车单。
+15 -2
View File
@@ -1,15 +1,28 @@
# 订舱页面受影响操作核对
业务依据为用户提供的《订舱信息页面说明.docx》及页面截图;本轮确认:放舱后仅补实货量、客服手工录入、保留现有提单确认件,正式提单从外部附件接收。样式使用 `docs/Admin高密度界面规范.md` 的详情与基本信息、蓝白主题及既有公共密度。用户后续提供现页与产品稿对比,确认现页缺少层次、重点且间距过大;订舱页以该产品稿为局部视觉依据:紧凑页头、蓝色圆点连线流程、编号区块、细线小标题、浅底三方卡片及右下主打印按钮。可编辑输入、下拉和文本域使用白底、清晰边框与文字,悬停/聚焦使用蓝色反馈;灰底和禁止光标仅用于真实禁用状态,校验错误仍保留组件错误色。
业务依据为用户提供的《订舱信息页面说明.docx》及页面截图;本轮确认:放舱后允许单独维护预计到港及补实货量、客服手工录入、保留现有提单确认件,正式提单从外部附件接收。样式使用 `docs/Admin高密度界面规范.md` 的详情与基本信息、蓝白主题及既有公共密度。用户后续提供现页与产品稿对比,确认现页缺少层次、重点且间距过大;订舱页以该产品稿为局部视觉依据:紧凑页头、蓝色圆点连线流程、编号区块、细线小标题、浅底三方卡片及右下主打印按钮。可编辑输入、下拉和文本域使用白底、清晰边框与文字,悬停/聚焦使用蓝色反馈;灰底和禁止光标仅用于真实禁用状态,校验错误仍保留组件错误色。
订舱详情按用户 2026-10-23 截图确认移除“订舱资料”模块及两类文件上传入口,放舱不再要求上传订舱确认文件或提单文件;其他业务资料校验保留。修改后页面待人工截图确认。
流程条根据已保存订舱、当前未保存修改、放舱状态及实货量推进:当前步骤蓝底,已通过阶段勾选,待处理灰色。第一步的“已保存”不代替放舱完整性校验;打印节点仅在本次页面成功生成对应数据的 PDF 预览后显示“已预览”,失败或只点击按钮不产生预览事实,也不声称实际打印完成。预览标记随本次页面保留;刷新后按服务端保存/放舱/实值恢复进度,已放舱时托运单阶段保持通过,提示“可重新预览 / 打印”,避免前面灰色、后面完成的断开。放舱前托运单数据改变须重新预览;提单确认件始终要求已保存实值和对应 PDF 成功预览后才完成。
| 页面/路由 | 操作 | 参考承载与布局 | 当前实现 | 页面核对 |
|---|---|---|---|---|
| `/ocean-bookings/new`、`/ocean-bookings/:bookingId` | 草稿新增、维护 | 独立页面;左主资料、右三方并排,下方放舱资料 | 独立页面;按产品稿重排视觉层级,保留仓库、渠道、预计到港、多柜型和附件;港代码与港名成组、委实成对输入,新增保存留在详情 | 已核对新增保存、初始步骤、控件状态与仓库渠道联动;完整长字段展示待人工确认 |
| `/ocean-bookings/new`、`/ocean-bookings/:bookingId` | 草稿新增、维护 | 独立页面;左主资料、右三方并排,下方放舱资料 | 移除客户选择、港名、箱种、容量、限重、备注及包装单位选择;预计到港恢复在预计开船旁,并在放舱确认摘要展示;仓库和渠道保留在默认收起的“作业配置”,放舱缺配置时自动展开;委实成对输入,新增保存留在详情 | TEST #702 已核对预计到港恢复、录入保存和刷新回显;完整长字段展示待人工确认 |
| `/ocean-bookings/:bookingId` | 放舱后维护预计到港 | 本轮用户确认仅开放预计到港;沿用现有单字段AppDialog | 页头独立入口,560px紧凑时间表单,仅提交预计到港和版本,不触及其他冻结资料 | 待1920×1080、100%人工截图确认 |
| `/ocean-bookings/:bookingId` | 已放舱补实 | 客服补录实际货量 | 运输及预报只读,实值可按编辑权限补录,原因必填并版本审计 | 已核对放舱、原因校验、补实保存和历史;最终 TEST #698 回验实值保存后立即回显,无需刷新 |
| `/ocean-bookings/:bookingId` | 托运单、提单确认件 | 两个作业入口,A4单据预览 | 两张业务卡片底部右侧的蓝色主按钮,分别直达现有PDF预览;读已保存委/实数据,缺失显示“—” | 弹框开关/重开、PDF 生成与数据映射已核对;内嵌 PDF 空白,显示未通过 |
人工按 1920×1080、100% 缩放核对三方长名称/地址、两类货量、保存失败与版本冲突、冻结/无编辑权限,以及两类 PDF 预览的打开、关闭和重开。自动检查结果在交付中单独报告,不能代替人工截图。
新增订舱默认运费条款 `FREIGHT PREPAID`、运输条款 `CY-CY`、委值 `500 / 15000 / 68`,实值留空;包装按 `CARTONS` 保存,新柜需求为整箱 FCL,容量和限重沿平台柜型规格计算。默认值只用于新增,不覆盖已保存资料。预计到港草稿可留空,放舱前必填且不得早于预计开船;放舱后预计到港允许独立窄命令维护,其余运输资料仍冻结。
用户后续确认预计到港供日历和入库操作使用,恢复该字段及前后端放舱门禁。代码核对:关联柜次时 `ContainerRepository.linkReleasedBooking` 传入订舱预计到港;日历在无实际到港时读柜级预计到港;海外 `ContainerWarehouseArrivalService` 在无人工或计划冻结日期时按到港当地日期加 2 天解析默认到仓,入库计划使用该解析结果,不能把订舱预计到港直接当成计划到仓日期。
恢复版本 Admin #702 / Firstleg #513 均 SUCCESS、容器 healthy,Jenkins Admin 原检出配置已恢复。前端 22 项及后端订舱/船期相关 32 项定向测试通过;真实 TEST 草稿 `OB202610220013` 录入预计到港 `2026-11-15T10:00:00` 后保存到版本 1,服务端读回和页面刷新回显一致。证据 `ui-arrival-restored.png`、`arrival-restored-verification.json` 位于同一证据目录;日历至入库日期链路完成代码核对,本轮未推进真实柜次和入库计划,不据此宣称全流程验收。
该轮经 Admin #700、Firstleg #512 发布,运行容器 healthy;前端 28 项、后端订舱 26 项定向测试及前端类型检查通过。真实 TEST 新增页以默认值保存为 `OB202610220013`,服务端读回条款和委值一致,实值及预计到港未填写;本轮没有再次推进放舱,取消预计到港必填由前后端定向测试覆盖。页面证据 `ui-booking-simplified-new.png`、`ui-booking-simplified-saved.png` 和 `simplified-form-verification.json` 位于同一证据目录。
同日流程断开修复经 TEST #699 发布:刷新已放舱测试单后,第 1~3 步保持通过、连接线连续,第 4 步仍为当前待预览;托运单提示“可重新预览 / 打印”。两份定向测试 24 项通过;截图为同一证据目录的 `ui-workflow-continuous.png`。本次未修改后端或测试业务数据,未重新验收内嵌 PDF 显示。
2026-10-22 用户明确授权 AI 操作 TEST 页面并单独授权结构 SQL 后,已接续同一测试订舱 `OB202610220011` 完成保存、确认放舱、手工补实及两类 PDF 请求的页面核对,委实分离、版本与原因历史正确。发现并修复数字控件过期禁用辅助标记和保存后的输入缓存;两份定向测试 23 项通过,最终版本经 TEST #698 发布并回验:修正件数为 481 后无需刷新,输入显示与服务器版本 5 一致,毛重和体积保持,委值仍为 500。截图及脱敏证据在本机 `C:/Users/BY-OY/.codex/booking-ui-acceptance-20261022/`;两类 PDF 可下载且外部渲染正常,但内置浏览器的内嵌显示为空白,不能记为完整打印显示通过。环境事实以部署主契约“订舱 TEST 结构同步与页面验收”为准。
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { createOceanBooking, deleteOceanBookingDraft, downloadOceanBookingDocument, downloadOceanBookingPrint, invalidateOceanBookingDocument, listOceanBookingAgents, listOceanBookingDocuments, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, referenceOceanBookingDocument, releaseOceanBooking, updateOceanBookingActualCargo, updateOceanBookingDraft, uploadOceanBookingDocument } from './platformOceanBookings'
import { http } from '@/shared/api/http'
import { updateOceanBookingEstimatedArrival } from './platformOceanBookings'
const detailResponse = (version = 0, status = 'DRAFT') => ({
oceanBooking: {
@@ -14,6 +15,31 @@ const detailResponse = (version = 0, status = 'DRAFT') => ({
})
describe('ocean bookings api', () => {
it.each([
['宁波', '洛杉矶', 'CNNGB', 'USLAX', '宁波', '洛杉矶'],
[null, null, 'CNNGB', 'USLAX', 'CNNGB', 'USLAX'],
['', ' ', 'CNNGB', 'USLAX', 'CNNGB', 'USLAX'],
['宁波', '洛杉矶', null, null, '宁波', '洛杉矶'],
[null, '', null, ' ', null, null],
])('列表港口名称 %s / %s 为空时展示已保存代码', async (departurePortName, destinationPortName, departurePortCode, destinationPortCode, pol, pod) => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: {
records: [{ ...detailResponse().oceanBooking, departurePortName, destinationPortName, departurePortCode, destinationPortCode }],
page: 1, size: 20, total: 1, pages: 1,
} })
const result = await listOceanBookings({ page: 1, size: 20 })
expect(result.records[0]).toMatchObject({ pol, pod, departurePortName, destinationPortName, departurePortCode, destinationPortCode })
})
it('预计到港使用窄命令并规范化详情回显与列表到港字段', async () => {
const payload = { expectedVersion: 3, estimatedArrivalAt: '2026-10-25T10:00:00' }
const raw = detailResponse(4, 'RELEASED')
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', data: { ...raw, oceanBooking: { ...raw.oceanBooking, estimatedArrivalAt: payload.estimatedArrivalAt } } })
const saved = await updateOceanBookingEstimatedArrival('123', payload)
expect(put).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123/estimated-arrival', payload, expect.objectContaining({ headers: { 'Idempotency-Key': expect.any(String) } }))
expect(saved.estimatedArrivalAt).toBe(payload.estimatedArrivalAt)
expect(saved.eta).toBe(payload.estimatedArrivalAt)
expect(saved.version).toBe(4)
})
it('sends actual cargo only to the versioned supplement endpoint and normalizes decimal responses', async () => {
const payload = { expectedVersion: 3, actualPackageCount: 480, actualGrossWeightKg: 14000.125, actualVolumeCbm: 65.321, reason: '客服补录' }
const response = detailResponse(4, 'RELEASED')
@@ -27,8 +27,8 @@ function booking(raw: OceanBookingResource): OceanBookingResource {
actualPackageCount: raw.actualPackageCount == null ? null : Number(raw.actualPackageCount),
actualGrossWeightKg: raw.actualGrossWeightKg == null ? null : Number(raw.actualGrossWeightKg),
actualVolumeCbm: raw.actualVolumeCbm == null ? null : Number(raw.actualVolumeCbm),
pol: raw.pol ?? raw.departurePortName ?? null,
pod: raw.pod ?? raw.destinationPortName ?? null,
pol: raw.departurePortName?.trim() || raw.departurePortCode?.trim() || null,
pod: raw.destinationPortName?.trim() || raw.destinationPortCode?.trim() || null,
carrierName: raw.carrierName ?? raw.shippingCompanyName ?? null,
bookingAgent: raw.bookingAgent ?? raw.bookingAgentName ?? null,
etd: raw.etd ?? raw.estimatedSailingAt ?? null,
@@ -98,6 +98,10 @@ export async function updateOceanBookingActualCargo(id: string, payload: OceanBo
const response = await http.put<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/actual-cargo`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } })
return detail(unwrapApiResponse(response))
}
export async function updateOceanBookingEstimatedArrival(id: string, payload: { expectedVersion: number; estimatedArrivalAt: string }) {
const response = await http.put<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/estimated-arrival`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } })
return detail(unwrapApiResponse(response))
}
export async function deleteOceanBookingDraft(id: string, expectedVersion: number) { await http.delete(`${basePath}/${encodeURIComponent(id)}`, { data: { expectedVersion }, headers: { 'Idempotency-Key': idempotencyKey() } }) }
export async function releaseOceanBooking(id: string, request: OceanBookingReleaseRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/release`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
const printPaths: Record<OceanBookingPrintType, string> = {
@@ -61,7 +61,7 @@ describe('OceanBookingDocumentsPanel contract', () => {
it('makes released booking files permanently read-only and preserves traced errors', () => {
expect(source).toContain("props.bookingStatus === 'DRAFT' && props.canManage")
expect(source).toContain('已放舱 · 永久只读')
expect(source).not.toContain('已放舱 · 永久只读')
expect(source).toContain('manageReason')
expect(source).toContain('<ApiErrorMeta :details="error" />')
expect(source).toContain('订舱资料上传失败,请稍后重试')
@@ -111,7 +111,7 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
<template>
<section class="booking-documents workbench-panel is-compact admin-density admin-workbench" aria-label="订舱资料">
<div class="booking-documents__heading"><h2>订舱资料<InlineHelp title="订舱资料说明"><p>文件统一归属当前订舱;放舱要求至少一份有效的订舱确认文件或提单文件。</p></InlineHelp></h2><el-tag v-if="bookingStatus === 'RELEASED'" type="info">已放舱 · 永久只读</el-tag></div>
<div class="booking-documents__heading"><h2>订舱资料<InlineHelp title="订舱资料说明"><p>文件统一归属当前订舱;放舱要求至少一份有效的订舱确认文件或提单文件。</p></InlineHelp></h2></div>
<el-alert v-if="bookingStatus === 'DRAFT' && !editable && manageReason" type="warning" :closable="false" show-icon :title="manageReason" />
<el-alert v-if="error" type="error" :closable="true" show-icon @close="error = null"><template #title>{{ error.message }}</template><ApiErrorMeta :details="error" /></el-alert>
<div class="booking-document-types">
@@ -1,8 +1,7 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus, { ElSelect } from 'element-plus'
import ElementPlus, { ElDatePicker } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners } from '@/modules/master-data'
import { listPlatformWarehouses } from '@/modules/warehouses'
import {
@@ -13,6 +12,7 @@ import {
releaseOceanBooking,
updateOceanBookingDraft,
updateOceanBookingActualCargo,
updateOceanBookingEstimatedArrival,
} from '../api/platformOceanBookings'
import type { OceanBookingDetail, OceanBookingStatus } from '../types'
import OceanBookingDetailView from './OceanBookingDetailView.vue'
@@ -28,14 +28,12 @@ vi.mock('vue-router', () => ({
const deniedPermissions = vi.hoisted(() => new Set<string>())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !deniedPermissions.has(permission) }) }))
vi.mock('@/core/workspace-tabs/workspaceTabs', () => ({ useWorkspaceTabsStore: () => ({ removeByIds: vi.fn(), bumpCacheEpoch: vi.fn() }) }))
vi.mock('@/modules/iam', async importOriginal => ({
...await importOriginal<typeof import('@/modules/iam')>(), listPlatformTenants: vi.fn() }))
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
vi.mock('../api/platformOceanBookings', () => ({
createOceanBooking: vi.fn(), deleteOceanBookingDraft: vi.fn(), getOceanBooking: vi.fn(),
listOceanBookingAgents: vi.fn(), listWarehouseLogisticsChannels: vi.fn(),
releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(), updateOceanBookingActualCargo: vi.fn(),
releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(), updateOceanBookingActualCargo: vi.fn(), updateOceanBookingEstimatedArrival: vi.fn(),
}))
function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLadingNo = 'BL-001'): OceanBookingDetail {
@@ -68,6 +66,12 @@ function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLading
}
type ViewModel = {
arrivalValue: string | null
arrivalDialogOpen: boolean
arrivalSaving: boolean
arrivalError: { message: string; code?: string } | null
openArrivalDialog: () => void
saveEstimatedArrival: () => Promise<void>
form: { data: import('../types').OceanBookingDraftData }
saveActualCargo: () => Promise<void>
actualReason: string
@@ -75,8 +79,6 @@ type ViewModel = {
hasUnsavedActualChanges: boolean
printDisabledReason: string
save: () => Promise<void>
selectCustomer: (value: unknown) => void
canManageDocuments: boolean
detail: OceanBookingDetail | null
actionError: { code?: string; requestId?: string; traceId?: string; message: string } | null
actionSaving: boolean
@@ -90,7 +92,7 @@ async function mountView() {
stubs: {
AdminPage: { template: '<main><slot /></main>' },
DetailPageLayout: { template: '<section><slot name="header" /><slot /></section>' },
PageToolbar: { props: ['description'], template: '<header><p class="toolbar-description">{{ description }}</p><slot name="actions" /></header>' },
PageToolbar: { props: ['title', 'description'], template: '<header><h1>{{ title }}</h1><p v-if="description" class="toolbar-description">{{ description }}</p><slot name="actions" /></header>' },
CarrierScheduleQueryPanel: true,
},
},
@@ -100,12 +102,68 @@ async function mountView() {
}
describe('OceanBookingDetailView one-click release', () => {
it('放舱后仅单独保存预计到港并立即回显,保留尚未提交的实货量', async () => {
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
const saved = { ...booking('RELEASED', 4), estimatedArrivalAt: '2026-09-25T08:00:00' }
vi.mocked(updateOceanBookingEstimatedArrival).mockResolvedValue(saved)
const { wrapper, vm } = await mountView()
expect(wrapper.findAll('button').some(button => button.text() === '维护预计到港')).toBe(true)
vm.form.data.actualPackageCount = 499
vm.actualReason = '尚未提交'
vm.openArrivalDialog()
vm.arrivalValue = saved.estimatedArrivalAt
await vm.saveEstimatedArrival()
expect(updateOceanBookingEstimatedArrival).toHaveBeenCalledExactlyOnceWith('123', { expectedVersion: 3, estimatedArrivalAt: saved.estimatedArrivalAt })
expect(vm.detail?.estimatedArrivalAt).toBe(saved.estimatedArrivalAt)
expect(vm.detail?.version).toBe(4)
expect(vm.form.data.actualPackageCount).toBe(499)
expect(vm.actualReason).toBe('尚未提交')
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
const coreForm = wrapper.findAllComponents({ name: 'ElForm' }).find(form => form.classes().includes('booking-groups'))!
expect(coreForm.props('disabled')).toBe(true)
wrapper.unmount()
})
it('无编辑权限不开放维护入口;版本冲突保留输入并恢复操作', async () => {
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
deniedPermissions.add('platform:firstleg:ocean-booking:edit')
let rendered = await mountView()
expect(rendered.wrapper.findAll('button').some(button => button.text() === '维护预计到港')).toBe(false)
rendered.vm.openArrivalDialog()
await rendered.vm.saveEstimatedArrival()
expect(updateOceanBookingEstimatedArrival).not.toHaveBeenCalled()
rendered.wrapper.unmount()
deniedPermissions.clear()
rendered = await mountView()
rendered.vm.openArrivalDialog()
rendered.vm.arrivalValue = '2026-09-25T08:00:00'
vi.mocked(updateOceanBookingEstimatedArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化,请刷新后重试', 'A0409'))
await rendered.vm.saveEstimatedArrival()
expect(rendered.vm.arrivalDialogOpen).toBe(true)
expect(rendered.vm.arrivalValue).toBe('2026-09-25T08:00:00')
expect(rendered.vm.arrivalSaving).toBe(false)
expect(rendered.vm.arrivalError?.code).toBe('A0409')
rendered.wrapper.unmount()
})
it('放舱后预计到港不得清空或早于预计开船', async () => {
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
const { wrapper, vm } = await mountView()
vm.openArrivalDialog()
for (const value of [null, '2026-08-01T08:00:00']) {
vm.arrivalValue = value
await vm.saveEstimatedArrival()
expect(vm.arrivalError?.message).toBeTruthy()
}
expect(updateOceanBookingEstimatedArrival).not.toHaveBeenCalled()
wrapper.unmount()
})
beforeEach(() => {
routeState.name = 'ocean-booking-detail'; routeState.params.bookingId = '123'
vi.clearAllMocks()
deniedPermissions.clear()
vi.mocked(getOceanBooking).mockResolvedValue(booking())
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(listWarehouseLogisticsChannels).mockResolvedValue([{ channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡' }])
@@ -129,25 +187,22 @@ describe('OceanBookingDetailView one-click release', () => {
wrapper.unmount()
})
it('finds a customer outside the initial page and saves its actual ID and name in a new booking', async () => {
it('starts new bookings with the agreed declared defaults and no removed inputs', async () => {
routeState.name = 'ocean-booking-create'; routeState.params.bookingId = 'new'
const customer = { id: '9007199254740999', tenantName: '柏悦物流', status: 'ACTIVE' } as PlatformTenantResource
vi.mocked(listPlatformTenants).mockImplementation(async query => ({
records: query.keyword ? [customer] : [], page: 1, size: 50, total: query.keyword ? 1 : 350, pages: query.keyword ? 1 : 7,
}))
vi.mocked(createOceanBooking).mockResolvedValue({ ...booking(), customerId: String(customer.id), customerName: customer.tenantName })
vi.mocked(createOceanBooking).mockResolvedValue(booking())
const { wrapper, vm } = await mountView()
const selector = wrapper.findAllComponents(ElSelect).find(item => item.props('placeholder') === '单客户可选择,多客户可留空')!
expect(selector.props('remote')).toBe(true)
selector.vm.$emit('end-reached', 'bottom'); await flushPromises()
expect(listPlatformTenants).toHaveBeenLastCalledWith({ page: 2, size: 50, status: 'ACTIVE' })
await selector.props('remoteMethod')?.('柏悦'); await flushPromises()
expect(listPlatformTenants).toHaveBeenLastCalledWith({ page: 1, size: 50, status: 'ACTIVE', keyword: '柏悦' })
selector.vm.$emit('update:modelValue', customer.id); await flushPromises()
expect(vm.form.data.customerId).toBe(customer.id)
expect(vm.form.data.customerName).toBe('柏悦物流')
expect(vm.form.data).toMatchObject({ freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS', declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68, actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null })
expect(wrapper.get('[aria-label="预报件数"]').element).toHaveProperty('value', '500')
expect(wrapper.get('[aria-label="预报毛重"]').element).toHaveProperty('value', '15000.000')
expect(wrapper.get('[aria-label="预报体积"]').element).toHaveProperty('value', '68.000')
const labels = wrapper.findAll('.el-form-item__label').map(item => item.text())
for (const removed of ['客户(选填)', '包装单位', '备注']) expect(labels).not.toContain(removed)
expect(labels).toContain('预计到港')
expect(wrapper.find('[aria-label="起运港名称"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="目的港名称"]').exists()).toBe(false)
expect(wrapper.findAll('.container-table th').map(item => item.text())).toEqual(['操作', '柜型', '数量'])
await vm.save()
expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ customerId: customer.id, customerName: '柏悦物流' }) }))
expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68, actualPackageCount: null }) }))
wrapper.unmount()
})
@@ -170,8 +225,13 @@ describe('OceanBookingDetailView one-click release', () => {
wrapper.unmount()
})
it('shows the matching fixed logistics group instead of the underlying price-book channel', async () => {
const { wrapper } = await mountView()
it('keeps warehouse and channel in a collapsed operation section with their saved bindings', async () => {
const { wrapper, vm } = await mountView()
const header = wrapper.get('.booking-operation-config .el-collapse-item__header')
expect(header.attributes('aria-expanded')).toBe('false')
await header.trigger('click')
expect(header.attributes('aria-expanded')).toBe('true')
expect(vm.form.data).toMatchObject({ warehouseId: '8', channelCode: 'MATSON_CLX' })
expect(wrapper.text()).toContain('美森正班')
expect(wrapper.text()).not.toContain('美森正班海卡')
wrapper.unmount()
@@ -192,19 +252,21 @@ describe('OceanBookingDetailView one-click release', () => {
wrapper.unmount()
})
it('shows the route alone in the heading without a single customer and keeps an actual customer name', async () => {
it('keeps the booking number without a subtitle for empty and populated route data', async () => {
const value = booking()
value.customerId = null; value.customerName = null
value.oceanBooking.customerId = null; value.oceanBooking.customerName = null
value.pol = null; value.pod = null
vi.mocked(getOceanBooking).mockResolvedValue(value)
const { wrapper } = await mountView()
expect(wrapper.get('.toolbar-description').text()).toBe('上海 → 洛杉矶')
expect(wrapper.get('.booking-page-toolbar h1').text()).toBe(value.bookingNo)
expect(wrapper.find('.toolbar-description').exists()).toBe(false)
expect(wrapper.text()).not.toContain('未关联客户')
wrapper.unmount()
vi.mocked(getOceanBooking).mockResolvedValue(booking())
const { wrapper: customerWrapper } = await mountView()
expect(customerWrapper.get('.toolbar-description').text()).toBe('测试客户 · 上海 → 洛杉矶')
expect(customerWrapper.find('.toolbar-description').exists()).toBe(false)
customerWrapper.unmount()
})
@@ -232,26 +294,67 @@ describe('OceanBookingDetailView one-click release', () => {
wrapper.unmount()
})
it('clears an existing customer before saving and releasing with the returned version', async () => {
const saved = booking('DRAFT', 4)
saved.customerId = null; saved.customerName = null; saved.oceanBooking.customerId = null; saved.oceanBooking.customerName = null
vi.mocked(updateOceanBookingDraft).mockResolvedValue(saved)
vi.mocked(releaseOceanBooking).mockResolvedValue({ ...saved, status: 'RELEASED', version: 5 })
it('blocks release without estimated arrival while still allowing a partial draft save', async () => {
const saved = booking()
saved.estimatedArrivalAt = null
saved.oceanBooking.estimatedArrivalAt = null
vi.mocked(getOceanBooking).mockResolvedValue(saved)
vi.mocked(updateOceanBookingDraft).mockResolvedValue({ ...saved, version: 4 })
const { vm, wrapper } = await mountView()
vm.selectCustomer('')
await vm.submitRelease()
expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ customerId: null, customerName: null }) }))
expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 4 })
expect(vm.actionError?.message).toBe('放舱前请填写预计到港时间')
expect(releaseOceanBooking).not.toHaveBeenCalled()
await vm.save()
expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ estimatedArrivalAt: null }) }))
wrapper.unmount()
})
it('allows documents and release for a saved booking with no single customer', async () => {
it('loads and saves estimated arrival from the date input and includes it in release confirmation', async () => {
const arrival = '2026-09-25T10:30:00'
vi.mocked(updateOceanBookingDraft).mockResolvedValue({ ...booking('DRAFT', 4), estimatedArrivalAt: arrival })
const { vm, wrapper } = await mountView()
const picker = wrapper.findAllComponents(ElDatePicker).find(item => item.props('placeholder') === '请选择预计到港时间')!
expect(picker.props('modelValue')).toBe('2026-09-20T08:00:00')
picker.vm.$emit('update:modelValue', arrival)
await flushPromises()
await vm.save()
expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ estimatedArrivalAt: arrival }) }))
await wrapper.findAll('button').find(button => button.text() === '放舱')!.trigger('click')
await flushPromises()
expect(wrapper.get('.release-summary').text()).toContain(arrival)
wrapper.unmount()
})
it('blocks an arrival before the sailing date', async () => {
const { vm, wrapper } = await mountView()
vm.form.data.estimatedArrivalAt = '2026-08-31T08:00:00'
await vm.submitRelease()
expect(vm.actionError?.message).toBe('预计到港时间不能早于预计开船时间')
expect(releaseOceanBooking).not.toHaveBeenCalled()
wrapper.unmount()
})
it('opens operation configuration when release is missing a warehouse', async () => {
const saved = booking()
saved.warehouseId = null
vi.mocked(getOceanBooking).mockResolvedValue(saved)
const { vm, wrapper } = await mountView()
await vm.submitRelease()
await flushPromises()
expect(vm.actionError?.message).toBe('放舱前请填写装柜仓库')
expect(wrapper.get('.booking-operation-config .el-collapse-item__header').attributes('aria-expanded')).toBe('true')
expect(releaseOceanBooking).not.toHaveBeenCalled()
wrapper.unmount()
})
it('allows release without documents for a saved booking with no single customer', async () => {
const empty = booking()
empty.customerId = null; empty.customerName = null; empty.oceanBooking.customerId = null; empty.oceanBooking.customerName = null
empty.documents = []
vi.mocked(getOceanBooking).mockResolvedValue(empty)
vi.mocked(releaseOceanBooking).mockResolvedValue({ ...empty, status: 'RELEASED', version: 4 })
const { vm, wrapper } = await mountView()
expect(vm.canManageDocuments).toBe(true)
expect(wrapper.text()).not.toContain('订舱资料')
await vm.submitRelease()
expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 3 })
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
@@ -70,12 +70,11 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).toContain('if (!saved) return')
expect(source).toContain("{{ form.data.billOfLadingNo || '—' }}")
expect(source).not.toContain("{{ detail?.billOfLadingNo || '—' }}")
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value)')
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value || arrivalSaving.value)')
expect(source).toContain(':disabled="workflowBusy" @click="openReleaseDialog"')
expect(source).not.toContain('releaseReason')
expect(source).toContain('放舱前请上传有效的订舱确认文件或提单文件')
expect(source).toContain('OceanBookingDocumentsPanel')
expect(source).toContain('@updated="updateDocuments"')
expect(source).not.toContain('放舱前请上传有效的订舱确认文件或提单文件')
expect(source).not.toContain('OceanBookingDocumentsPanel')
})
it('freezes the selected carrier standard code into the booking draft', () => {
@@ -17,7 +17,6 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { CONTAINER_SPECIFICATIONS } from '@/shared/domain/containerSpecifications'
import { listPlatformTenants, useActiveCustomerOptions } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
@@ -31,6 +30,7 @@ import {
listWarehouseLogisticsChannels,
updateOceanBookingDraft,
updateOceanBookingActualCargo,
updateOceanBookingEstimatedArrival,
type OceanLogisticsChannel,
} from '../api/platformOceanBookings'
import type {
@@ -38,7 +38,6 @@ import type {
OceanBookingAgentOption,
OceanBookingCarrierCode,
OceanBookingDraftData,
OceanBookingDocument,
OceanBookingParty,
OceanBookingSaveRequest,
OceanBookingStatus,
@@ -49,7 +48,6 @@ import { containerRequirementError, createBlankContainerRequirement, selectConta
import { shouldClearBookingAgent } from '../bookingAgents'
import { oceanBookingPrintSignature, oceanBookingWorkflow, type OceanBookingPreviewSignatures } from '../workflow'
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
import OceanBookingDocumentsPanel from '../components/OceanBookingDocumentsPanel.vue'
const partyTypes = [
{ value: 'SHIPPER', label: '发货人' },
@@ -57,10 +55,6 @@ const partyTypes = [
{ value: 'NOTIFY_PARTY', label: '通知人' },
] as const
const containerTypeOptions = CONTAINER_SPECIFICATIONS
const containerKindOptions = [
{ value: 'FCL', label: '整箱 FCL' },
{ value: 'LCL', label: '拼箱 LCL' },
]
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
@@ -87,9 +81,6 @@ const channelsError = ref('')
const selectedChannelName = ref('')
let channelRequestSerial = 0
let bookingAgentRequestSerial = 0
const selectableCustomers = computed(() => customers.value.filter((customer) => (
customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId)
)))
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.types.includes('CARRIER')))
const channelGroups = computed(() => {
const activeCodes = new Set(channels.value.map((channel) => channel.channelCode))
@@ -103,7 +94,7 @@ function blankParties(): OceanBookingParty[] {
function blankData(): OceanBookingDraftData {
return {
freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS',
cargoDescription: '', marksAndNos: '', declaredPackageCount: null, declaredGrossWeightKg: null, declaredVolumeCbm: null,
cargoDescription: '', marksAndNos: '', declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68,
actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null,
customerId: null, customerName: null, channelCode: '', warehouseId: '', parties: blankParties(),
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
@@ -112,7 +103,6 @@ function blankData(): OceanBookingDraftData {
}
}
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
const { customers, loading: customersLoading, error: customersError, search: loadCustomers, endReached: customerEndReached, retry: retryCustomers } = useActiveCustomerOptions(() => form.data.customerId, listPlatformTenants)
const savedDraftSignature = ref('')
const rules: FormRules = {}
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
@@ -121,13 +111,17 @@ const formWritable = computed(() => canSave.value)
const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete'))
const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release'))
const canPrint = computed(() => !!detail.value && access.can('platform:firstleg:ocean-booking:list'))
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:edit'))
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : '')
const actualSaving = ref(false)
const arrivalDialogOpen = ref(false)
const arrivalSaving = ref(false)
const arrivalValue = ref<string | null>(null)
const arrivalError = ref<ApiErrorDetails | null>(null)
const canMaintainArrival = computed(() => detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:edit'))
const actualReason = ref('')
const cargoWritable = computed(() => formWritable.value || (detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:edit')))
const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value)
const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value || arrivalSaving.value)
const releaseDialogOpen = ref(false)
const operationSections = ref<string[]>([])
const printOpen = ref(false)
const printType = ref<OceanBookingPrintType>('SHIPPING_INSTRUCTION')
const previewSignatures = ref<OceanBookingPreviewSignatures>({})
@@ -136,15 +130,14 @@ function statusLabel(status: OceanBookingStatus | string | null | undefined) {
return oceanBookingStatusLabel(status)
}
function historyActionLabel(action: string) {
const labels: Record<string, string> = { CREATE: '创建草稿', UPDATE_DRAFT: '编辑草稿', RELEASE: '确认放舱', UPDATE_ACTUAL_CARGO: '实货量补录/修正', UPLOAD_DOCUMENT: '上传文件', ATTACH_DOCUMENT: '关联文件', INVALIDATE_DOCUMENT: '作废文件' }
const labels: Record<string, string> = { CREATE: '创建草稿', UPDATE_DRAFT: '编辑草稿', RELEASE: '确认放舱', UPDATE_ESTIMATED_ARRIVAL: '维护预计到港', UPDATE_ACTUAL_CARGO: '实货量补录/修正', UPLOAD_DOCUMENT: '上传文件', ATTACH_DOCUMENT: '关联文件', INVALIDATE_DOCUMENT: '作废文件' }
return labels[action] || '资料维护'
}
async function loadOptions() {
optionsLoading.value = true
optionsError.value = ''
try {
const [, partnerResult, warehouseResult] = await Promise.all([
loadCustomers(),
const [partnerResult, warehouseResult] = await Promise.all([
listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }),
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' }),
])
@@ -161,12 +154,6 @@ async function loadOptions() {
optionsLoading.value = false
}
}
function selectCustomer(value: unknown) {
const customerId = value == null || value === '' ? null : String(value)
form.data.customerId = customerId
const customer = customerId == null ? null : customers.value.find((item) => String(item.id) === customerId)
form.data.customerName = customer?.tenantName || null
}
function draftPayload(): OceanBookingDraftData {
const customerId = form.data.customerId || null
return {
@@ -200,6 +187,35 @@ function openPrint(type: OceanBookingPrintType) {
printType.value = type
printOpen.value = true
}
function openArrivalDialog() {
if (!canMaintainArrival.value || workflowBusy.value) return
arrivalValue.value = detail.value?.estimatedArrivalAt || null
arrivalError.value = null
arrivalDialogOpen.value = true
}
async function saveEstimatedArrival() {
const current = detail.value
if (!current || !canMaintainArrival.value || workflowBusy.value) return
if (!arrivalValue.value || (current.estimatedSailingAt && arrivalValue.value < current.estimatedSailingAt)) {
arrivalError.value = { message: !arrivalValue.value ? '请选择预计到港时间' : '预计到港时间不能早于预计开船时间' }
return
}
arrivalSaving.value = true
arrivalError.value = null
try {
const saved = await updateOceanBookingEstimatedArrival(current.id, { expectedVersion: current.version, estimatedArrivalAt: arrivalValue.value })
// 单独保存预计到港不丢弃客服尚未提交的实际货量。
const pendingCargo = { actualPackageCount: form.data.actualPackageCount, actualGrossWeightKg: form.data.actualGrossWeightKg, actualVolumeCbm: form.data.actualVolumeCbm }
const pendingReason = actualReason.value
applyDetail(saved)
Object.assign(form.data, pendingCargo)
actualReason.value = pendingReason
workspace.bumpCacheEpoch('ocean-bookings')
arrivalDialogOpen.value = false
ElMessage.success('预计到港已保存')
} catch (cause) { arrivalError.value = getApiErrorDetails(cause, '预计到港保存失败') }
finally { arrivalSaving.value = false }
}
async function saveActualCargo() {
if (detail.value?.status !== 'RELEASED' || !cargoWritable.value || workflowBusy.value) return
const validationError = cargoValidationError()
@@ -393,19 +409,14 @@ function releaseValidationError() {
[form.data.shippingCompanyId, '船公司'], [form.data.bookingAgentId, '订舱代理'],
]
const missing = requiredFields.find(([value]) => !String(value || '').trim())
if (missing) return `放舱前请填写${missing[1]}`
if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!) return '预计到港时间不能早于预计开船时间'
if (missing) {
if (missing[1] === '装柜仓库' || missing[1] === '物流渠道') operationSections.value = ['operation']
return `放舱前请填写${missing[1]}`
}
if (form.data.estimatedArrivalAt && form.data.estimatedSailingAt && form.data.estimatedArrivalAt < form.data.estimatedSailingAt) return '预计到港时间不能早于预计开船时间'
if (form.data.parties.some((party) => !party.partyName.trim())) return '放舱前请填写发货人、收货人和通知人名称'
if (!detail.value?.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '放舱前请上传有效的订舱确认文件或提单文件'
return containerRequirementError(form.data.containerRequirements)
}
function updateDocuments(documents: OceanBookingDocument[], bookingVersion: number) {
if (!detail.value) return
detail.value.documents = documents
detail.value.version = bookingVersion
detail.value.oceanBooking.version = bookingVersion
form.expectedVersion = bookingVersion
}
async function submitRelease() {
if (!detail.value || workflowBusy.value) return
actionSaving.value = true
@@ -457,12 +468,13 @@ onMounted(() => { void load(); void loadOptions() })
<div class="detail-heading">
<el-button link class="density-back" @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
<span class="booking-brand" aria-hidden="true"><Ship :size="22" /></span>
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱信息管理'" :description="detail ? `${detail.customerName ? `${detail.customerName} · ` : ''}${detail.pol || '—'} → ${detail.pod || '—'}` : '出口订舱作业'">
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱信息管理'">
<template #actions>
<div class="actions booking-page-actions">
<StatusBadge v-if="detail" :status="statusLabel(detail.status)" />
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
<el-button v-if="canSave" plain :loading="saving" :disabled="workflowBusy" @click="save"><Save :size="16" />保存草稿</el-button>
<el-button v-if="canMaintainArrival" plain :disabled="workflowBusy" @click="openArrivalDialog">维护预计到港</el-button>
</div>
</template>
</PageToolbar>
@@ -492,16 +504,13 @@ onMounted(() => { void load(); void loadOptions() })
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '船公司名称缺失'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable :disabled="!editable || !form.data.carrierCode || bookingAgentsLoading || !!bookingAgentsError" :placeholder="form.data.carrierCode ? '请选择订舱代理' : '请先选择船公司'" :loading="bookingAgentsLoading" no-data-text="当前船公司暂无可用订舱代理" style="width:100%" @update:model-value="selectBookingAgent(String($event || ''))"><el-option v-if="!editable && form.data.bookingAgentId && !bookingAgents.some((supplier) => supplier.supplierId === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="supplier in bookingAgents" :key="supplier.supplierId" :label="supplier.name" :value="supplier.supplierId" /></el-select><div v-if="bookingAgentsError" class="field-error"><ApiErrorMeta :details="bookingAgentsError" /><el-button link type="primary" :loading="bookingAgentsLoading" @click="form.data.carrierCode && loadBookingAgents(form.data.carrierCode)">重新加载</el-button></div></el-form-item>
<el-form-item label="客户(选填)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="单客户可选择,多客户可留空" no-data-text="暂无可选客户" :loading="customersLoading" remote :remote-method="loadCustomers" @end-reached="customerEndReached" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select><LoadErrorState v-if="customersError" title="客户选项加载失败" :message="customersError.message" :loading="customersLoading" compact @retry="retryCustomers"><template #details><ApiErrorMeta :details="customersError" /></template></LoadErrorState></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channelGroups.some((channel) => channel.code === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channelGroups" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
</div>
</section>
<section class="booking-group" aria-label="航线船期">
<h3 class="booking-group__title"><span>港口航线</span></h3>
<div class="booking-group__body booking-group__body--fields">
<el-form-item label="起运港"><div class="booking-port-fields"><el-input v-model="form.data.departurePortCode" placeholder="港代码" aria-label="起运港代码" /><el-input v-model="form.data.departurePortName" placeholder="港口名称" aria-label="起运港名称" /></div></el-form-item>
<el-form-item label="目的港"><div class="booking-port-fields"><el-input v-model="form.data.destinationPortCode" placeholder="港代码" aria-label="目的港代码" /><el-input v-model="form.data.destinationPortName" placeholder="港口名称" aria-label="目的港名称" /></div></el-form-item>
<el-form-item label="起运港"><el-input v-model="form.data.departurePortCode" placeholder="港代码" aria-label="起运港代码" /></el-form-item>
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortCode" placeholder="港代码" aria-label="目的港代码" /></el-form-item>
<el-form-item label="预计开船" prop="estimatedSailingAt"><el-date-picker v-model="form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
<el-form-item label="预计到港" prop="estimatedArrivalAt"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
<el-form-item class="booking-field--wide" label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
@@ -544,7 +553,6 @@ onMounted(() => { void load(); void loadOptions() })
</div>
</div>
<div class="booking-group__body--fields">
<el-form-item label="包装单位"><el-select v-model="form.data.packageUnit" :disabled="!formWritable" placeholder="请选择包装单位"><el-option label="箱 CARTONS" value="CARTONS" /><el-option label="件 PCS" value="PCS" /><el-option label="托 PALLETS" value="PALLETS" /></el-select></el-form-item>
<el-form-item label="唛头和号码"><el-input v-model="form.data.marksAndNos" :disabled="!formWritable" maxlength="1000" /></el-form-item>
<el-form-item class="booking-field--wide" label="品名及托运要求"><el-input v-model="form.data.cargoDescription" :disabled="!formWritable" type="textarea" :rows="2" maxlength="1000" placeholder="填写实际品名及免用箱等托运要求" /></el-form-item>
</div>
@@ -572,10 +580,10 @@ onMounted(() => { void load(); void loadOptions() })
<el-form :model="form.data" label-position="top" :disabled="!formWritable" class="density-form booking-requirements">
<section class="booking-group" aria-label="柜型需求">
<h3 class="booking-group__title"><span>柜型需求与备注</span></h3><div class="booking-group__body">
<h3 class="booking-group__title"><span>柜型需求</span></h3><div class="booking-group__body">
<div class="container-table-wrap" aria-label="柜型需求">
<div class="workbench-evidence"><table class="container-table density-table">
<thead><tr><th scope="col">操作</th><th scope="col">柜型</th><th scope="col">箱种</th><th scope="col">数量</th><th scope="col">容量</th><th scope="col">限重</th><th v-if="detail?.status === 'RELEASED'" scope="col">剩余可关联</th></tr></thead>
<thead><tr><th scope="col">操作</th><th scope="col">柜型</th><th scope="col">数量</th><th v-if="detail?.status === 'RELEASED'" scope="col">剩余可关联</th></tr></thead>
<tbody>
<tr v-for="(requirement, index) in form.data.containerRequirements" :key="requirement.requirementId || index">
<td class="container-table__action">
@@ -585,20 +593,24 @@ onMounted(() => { void load(); void loadOptions() })
<span v-else aria-hidden="true">—</span>
</td>
<td><el-select :model-value="requirement.containerType" :disabled="!formWritable" placeholder="请选择柜型" @update:model-value="selectContainerRequirementType(requirement, String($event || ''))"><el-option v-for="item in containerTypeOptions" :key="item.type" :label="item.type" :value="item.type" /></el-select></td>
<td><el-select v-model="requirement.containerKind" :disabled="!formWritable" placeholder="请选择箱种"><el-option v-for="item in containerKindOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></td>
<td><el-input-number v-model="requirement.quantity" :disabled="!formWritable" :min="1" :max="99" controls-position="right" placeholder="请输入柜量" /></td>
<td class="container-table__specification"><span>{{ requirement.capacityCbm }} m³</span></td>
<td class="container-table__specification"><span>{{ requirement.maxWeightKg }} kg</span></td>
<td v-if="detail?.status === 'RELEASED'"><span>{{ requirement.remainingQuantity ?? requirement.quantity }}</span></td>
</tr>
</tbody>
</table></div>
</div>
<div v-if="formWritable" class="container-actions"><el-button class="add-container density-action" size="small" :disabled="form.data.containerRequirements.length >= 20" @click="addRequirement">新增柜型需求</el-button><span>{{ form.data.containerRequirements.length }} / 20 项</span></div>
<el-form-item label="备注"><el-input v-model="form.data.remark" type="textarea" :rows="3" /></el-form-item>
</div>
</section>
</el-form>
<el-collapse v-model="operationSections" class="booking-operation-config">
<el-collapse-item title="作业配置" name="operation">
<el-form :model="form.data" label-position="top" :disabled="!formWritable" class="density-form booking-group__body--fields">
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channelGroups.some((channel) => channel.code === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channelGroups" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
</el-form>
</el-collapse-item>
</el-collapse>
<footer class="booking-section__footer">
<el-tooltip :content="printDisabledReason || '按已保存预报货量打印托运单'" placement="top"><span><el-button type="primary" :disabled="!!printDisabledReason || workflowBusy" @click="openPrint('SHIPPING_INSTRUCTION')"><Printer :size="16" />打印托运单</el-button></span></el-tooltip>
</footer>
@@ -616,7 +628,6 @@ onMounted(() => { void load(); void loadOptions() })
</footer>
</section>
<OceanBookingDocumentsPanel v-if="detail" :booking-id="detail.id" :booking-no="detail.bookingNo" :booking-version="detail.version" :booking-status="detail.status" :documents="detail.documents" :can-manage="canManageDocuments" :manage-reason="documentManageReason" @updated="updateDocuments" />
<div v-if="detail" class="workbench-panel is-compact"><CarrierScheduleQueryPanel dense :ocean-booking-id="detail.id" /></div>
@@ -626,6 +637,10 @@ onMounted(() => { void load(); void loadOptions() })
</section>
</template>
</DetailPageLayout>
<AppDialog v-if="detail?.status === 'RELEASED'" v-model="arrivalDialogOpen" class="admin-density-dialog admin-workbench is-dialog" :title="bookingDialogTitle('维护预计到港')" width="560px" :confirm-loading="arrivalSaving" :confirm-disabled="!canMaintainArrival" confirm-text="保存" @confirm="saveEstimatedArrival">
<el-form class="density-form" label-position="top"><el-form-item label="预计到港" required><el-date-picker v-model="arrivalValue" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item></el-form>
<LoadErrorState v-if="arrivalError" compact title="预计到港保存失败" :message="arrivalError.message" :show-retry="false"><template #details><ApiErrorMeta :details="arrivalError" /></template></LoadErrorState>
</AppDialog>
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" :initial-type="printType" @previewed="onPrintPreviewed" />
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="releaseDialogOpen" :title="bookingDialogTitle('确认放舱')" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
@@ -667,7 +682,6 @@ onMounted(() => { void load(); void loadOptions() })
}
.booking-page-toolbar { min-width: 0; flex: 1; }
.booking-page-toolbar :deep(h1) { font-size: var(--admin-font-title); }
.booking-page-toolbar :deep(.page-toolbar__description) { margin-top: var(--admin-space-1); font-size: var(--admin-font-caption); color: var(--workbench-muted); }
.booking-page-actions { align-items: center; }
.actions { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); }
.booking-workflow {
@@ -759,7 +773,6 @@ onMounted(() => { void load(); void loadOptions() })
.booking-group__title::after { flex: 1; height: 1px; background: var(--workbench-border); content: ''; }
.booking-group__body--fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--admin-space-5); row-gap: var(--admin-space-3); }
.booking-field--wide { grid-column: 1 / -1; }
.booking-port-fields { display: grid; grid-template-columns: minmax(76px, .65fr) minmax(0, 1fr); gap: var(--admin-space-2); width: 100%; }
.booking-core__main > .booking-group { margin-top: var(--admin-space-6); }
.booking-parties { padding-left: var(--admin-space-7); border-left: 1px solid var(--workbench-border); }
.party-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); align-items: start; }
@@ -799,17 +812,16 @@ onMounted(() => { void load(); void loadOptions() })
.cargo-metric__separator { color: var(--workbench-muted); text-align: center; }
.cargo-metric__unit { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.cargo-metric__inputs :deep(.el-input__inner) { text-align: left; }
.booking-operation-config { margin-top: var(--admin-space-4); }
.booking-requirements { margin-top: var(--admin-space-6); }
.container-table-wrap { max-width: 100%; overflow-x: auto; margin-bottom: var(--admin-space-4); }
.container-table { width: 100%; min-width: 780px; table-layout: fixed; }
.container-table { width: 100%; min-width: 420px; table-layout: fixed; }
.container-table th:first-child, .container-table td:first-child { width: 80px; }
.container-table th:nth-child(2), .container-table td:nth-child(2) { width: 180px; }
.container-table th:nth-child(3), .container-table td:nth-child(3) { width: 90px; }
.container-table th:nth-child(4), .container-table td:nth-child(4) { width: 110px; }
.container-table th:nth-child(5), .container-table td:nth-child(5), .container-table th:nth-child(6), .container-table td:nth-child(6) { width: 100px; }
.container-table td :deep(.el-select), .container-table td :deep(.el-input) { width: 100%; min-width: 0; }
.container-table__action { text-align: center !important; }
.container-table__specification { color: var(--workbench-muted); white-space: nowrap; }
.container-actions { display: flex; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-5); color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.add-container { margin: 0; }
.booking-section__footer { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--admin-space-3); margin-top: var(--admin-space-6); padding-top: var(--admin-space-5); border-top: 1px dashed var(--workbench-border); }
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const client = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }))
vi.mock('@/shared/api/http', async original => ({ ...await original<typeof import('@/shared/api/http')>(), http: client }))
import { actOnOverseasFee, createOverseasFee, getOverseasFee, listBusinessFeeOptions, listBusinessOverseasFees, listOverseasFeeItems, listOverseasFees, listOverseasFeeRetries, type SaveOverseasFee } from './overseasFees'
const base = '/platform/v1/last-leg/finance-fee-sources'
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
const row = { id: '9007199254740993', sourceFeeId: '9007199254740993', customerId: '1', businessId: '9007199254740995', direction: 'RECEIVABLE', allowedActions: ['APPROVE'] }
describe('海外费用真实接口', () => {
beforeEach(() => vi.clearAllMocks())
it('分页不退回旧非分页接口,长整型身份保持字符串', async () => {
client.get.mockResolvedValue(envelope({ records: [row], total: '1', pages: '1', page: 1, size: 20 }))
const result = await listOverseasFees({ page: 1, size: 20, feeItemCode: 'F1' })
expect(client.get).toHaveBeenCalledWith(`${base}/page`, { params: { page: 1, size: 20, feeItemCode: 'F1' } })
expect(result.records[0]?.businessId).toBe('9007199254740995')
expect(result.total).toBe(1)
client.get.mockResolvedValue(envelope(row))
expect((await getOverseasFee(row.sourceFeeId)).sourceFeeId).toBe(row.sourceFeeId)
})
it('创建传正式业务订单及幂等键,审核和锁款重试各用正式命令', async () => {
client.post.mockResolvedValue(envelope(row))
const payload = { businessNo: 'WT1', businessType: 'WAREHOUSE_TRANSFER', amount: 10 } as SaveOverseasFee
await createOverseasFee(payload, 'create-1')
expect(client.post).toHaveBeenCalledWith(base, payload, { headers: { 'Idempotency-Key': 'create-1' } })
await actOnOverseasFee(row.sourceFeeId, 'REJECT', { decision: 'REJECT', reason: '重复费用', idempotencyKey: 'review-1' })
expect(client.post).toHaveBeenLastCalledWith(`${base}/${row.sourceFeeId}/review`, { decision: 'REJECT', reason: '重复费用', idempotencyKey: 'review-1' })
await actOnOverseasFee(row.sourceFeeId, 'RETRY_FREEZE', { idempotencyKey: 'retry-1' })
expect(client.post).toHaveBeenLastCalledWith(`${base}/${row.sourceFeeId}/retry-funding`, { idempotencyKey: 'retry-1' })
await actOnOverseasFee(row.sourceFeeId, 'RETRY_PUBLISH', { expectedVersion: 1, eventId: 'original-event', idempotencyKey: 'retry-2' })
expect(client.post).toHaveBeenLastCalledWith(`${base}/${row.sourceFeeId}/retry-delivery`, { expectedVersion: 1, eventId: 'original-event', idempotencyKey: 'retry-2' })
})
it('目录和业务订单选项读取真实数据,不套用人民币默认价', async () => {
client.get.mockResolvedValueOnce(envelope([{ code: 'F1', name: '操作费', defaultPrice: 10 }]))
.mockResolvedValueOnce(envelope([{ businessId: '9007199254740995', customerId: '1', orderId: null, warehouseId: '2' }]))
expect(await listOverseasFeeItems()).toEqual([{ feeCode: 'F1', feeTypeName: '操作费', defaultPrice: 10 }])
expect(client.get).toHaveBeenLastCalledWith(`${base}/fee-items`, { params: { direction: 'RECEIVABLE' } })
expect((await listBusinessFeeOptions({ businessType: 'WAREHOUSE_TRANSFER', keyword: 'WT' }))[0]?.businessId).toBe('9007199254740995')
expect(client.get).toHaveBeenLastCalledWith(`${base}/business-options`, { params: { businessType: 'WAREHOUSE_TRANSFER', keyword: 'WT' } })
})
it('应付项目明确传递方向', async () => {
client.get.mockResolvedValue(envelope([]))
await listOverseasFeeItems('PAYABLE')
expect(client.get).toHaveBeenCalledWith(`${base}/fee-items`, { params: { direction: 'PAYABLE' } })
})
it('按稳定费用身份查询完整重试记录', async () => {
client.get.mockResolvedValue(envelope([]))
expect(await listOverseasFeeRetries(row.sourceFeeId)).toEqual([])
expect(client.get).toHaveBeenCalledWith(`${base}/${row.sourceFeeId}/retries`)
})
it('单据费用先严格匹配客户、类型及ID,合并去重后分页', async () => {
const business = { businessId: row.businessId, businessType: 'LASTLEG_ORDER', businessNo: 'TB1', customerId: '1', customerName: '测试客户', orderId: null, warehouseId: null }
const related = { ...business, businessId: 'return-1', businessType: 'RETURN_ORDER' }
const first = { ...row, businessType: business.businessType, createdAt: '2026-10-22T10:00:00' }
const second = { ...first, sourceFeeId: '9007199254740994', businessId: related.businessId, businessType: related.businessType }
client.get.mockResolvedValue(envelope([first, second, first,
{ ...first, sourceFeeId: 'wrong-id', businessId: 'another' },
{ ...first, sourceFeeId: 'wrong-type', businessType: 'RETURN_ORDER' },
{ ...first, sourceFeeId: 'wrong-customer', customerId: '2' },
]))
const result = await listBusinessOverseasFees([business, related], { page: 2, size: 1 })
expect(client.get).toHaveBeenCalledWith(base, { params: { customerId: '1', businessType: 'LASTLEG_ORDER', businessNo: 'TB1' } })
expect(result).toMatchObject({ total: 2, pages: 2, page: 2, size: 1 })
expect(result.records.map(fee => fee.sourceFeeId)).toEqual([first.sourceFeeId])
})
it.each(['RECEIVABLE', 'PAYABLE'] as const)('混合返回时先按 %s 分开,再统计分页,不遗漏第二页数据', async direction => {
const business = { businessId: row.businessId, businessType: 'LASTLEG_ORDER', businessNo: 'TB1', customerId: '1', customerName: '客户', orderId: null, warehouseId: null }
const first = { ...row, businessType: 'LASTLEG_ORDER', direction, createdAt: '2026-10-23T10:00:00' }
const second = { ...first, sourceFeeId: 'second', createdAt: '2026-10-23T11:00:00' }
const opposite = { ...first, sourceFeeId: 'opposite', direction: direction === 'RECEIVABLE' ? 'PAYABLE' : 'RECEIVABLE' }
client.get.mockResolvedValue(envelope([first, opposite, second, { ...first, sourceFeeId: 'unknown', direction: null }]))
const result = await listBusinessOverseasFees(business, { page: 2, size: 1 }, direction)
expect(result).toMatchObject({ total: 2, pages: 2 })
expect(result.records.map(fee => fee.sourceFeeId)).toEqual([first.sourceFeeId])
expect(result.records.every(fee => fee.direction === direction)).toBe(true)
})
})
@@ -0,0 +1,99 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface FeePage<T> { records: T[]; page: number; size: number; total: number; pages: number }
export type OverseasFeeDirection = 'RECEIVABLE' | 'PAYABLE'
export interface OverseasFee {
storageRent?: { outboundNo: string; outboundAt: string; calculatedAmount: number; chargedAmount: number | null;
lines: Array<{ lotId: string; inboundAt: string; quantity: number; days: number; calculatedAmount: number;
sourceBusinessType?: string | null; sourceBusinessNo?: string | null; batchNo?: string | null }> } | null
id: string; sourceFeeId: string; sourceVersion: number; customerId: string; customerName: string
sourceType: string; businessType: string; businessId: string | null; businessNo: string
displayBusinessType?: string
direction: OverseasFeeDirection
supplierId?: string | null; supplierName?: string | null; supplierCategory?: string | null
settlementMode?: 'BILL' | 'WALLET' | 'ON_ACCOUNT' | 'DIRECT_PAID' | null
paidAt?: string | null; paymentMethod?: string | null; paymentReferenceNo?: string | null; paymentVoucherFileIds?: string[] | null
orderId: string | null; warehouseId: string | null; feeItemCode: string; feeItemName: string
feeRemark?: string | null
businessDate: string; currency: string; amount: number; originalCurrency: string; originalAmount: number
exchangeRate?: number | null; exchangeRateMonth?: string | null
chargeableQuantity: number | null; quantityUnit: string | null; unitPrice: number | null
periodStart: string | null; periodEnd: string | null; reviewStatus: string; sourceStatus: string
publicationStatus: string; collectionMode: 'BILL' | 'WALLET'; walletStatus: string | null
deliveryStatus?: 'PENDING' | 'PROCESSING' | 'PUBLISHED' | 'DEAD' | null
deliveryKind?: 'FEE' | 'FUNDING' | null; deliveryEventId?: string | null; deliveryErrorMessage?: string | null
walletErrorMessage: string | null; reviewReason: string | null; reviewerId?: string | null; reviewerName?: string | null; reviewedAt: string | null
createdAt: string; updatedAt: string; allowedActions: string[]
}
export interface BusinessFeeOption {
businessId: string; businessType: string; businessNo: string; customerId: string; customerName: string
warehouseId: string | null; orderId: string | null; orderType?: string
}
export interface SaveOverseasFee {
customerId: string; customerName: string; sourceType: string; businessType: string
businessId: string; businessNo: string; orderId: string | null; warehouseId: string | null
feeItemCode: string; feeItemName: string; direction: OverseasFeeDirection; businessDate: string
feeRemark?: string | null
supplierId?: string | null; supplierName?: string | null; supplierCategory?: string | null
settlementMode?: 'BILL' | 'WALLET' | 'ON_ACCOUNT' | 'DIRECT_PAID' | null
paidAt?: string | null; paymentMethod?: string | null; paymentReferenceNo?: string | null; paymentVoucherFileIds?: string[] | null
currency: 'CNY' | 'USD'; amount: number
chargeableQuantity?: number; quantityUnit?: string; unitPrice?: number
}
const base = '/platform/v1/last-leg/finance-fee-sources'
export interface OverseasFeeRetryRecord {
requestId: string; requestedAt: string; operatorName: string; action: string
result: 'SUCCESS' | 'FAILED' | 'SUBMITTED'; errorMessage: string | null
}
export async function listOverseasFeeRetries(id: string): Promise<OverseasFeeRetryRecord[]> {
return unwrapApiResponse(await http.get<ApiEnvelope<OverseasFeeRetryRecord[]>, ApiEnvelope<OverseasFeeRetryRecord[]>>(`${base}/${encodeURIComponent(id)}/retries`))
}
export async function listOverseasFeeItems(direction: 'RECEIVABLE' | 'PAYABLE' = 'RECEIVABLE') {
const response = await http.get<ApiEnvelope<Array<{ code: string; name: string; defaultPrice: number | null }>>, ApiEnvelope<Array<{ code: string; name: string; defaultPrice: number | null }>>>(`${base}/fee-items`, { params: { direction } })
return unwrapApiResponse(response).map(item => ({ feeCode: item.code, feeTypeName: item.name, defaultPrice: item.defaultPrice }))
}
export async function listOverseasFeeCustomers() {
const response = await http.get<ApiEnvelope<Array<{ customerId: string; customerName: string }>>, ApiEnvelope<Array<{ customerId: string; customerName: string }>>>(`${base}/customer-options`)
return unwrapApiResponse(response).map(row => ({ ...row, customerId: String(row.customerId) }))
}
function normalizeFee(value: OverseasFee): OverseasFee {
return { ...value, id: String(value.id), sourceFeeId: String(value.sourceFeeId), customerId: String(value.customerId),
businessId: value.businessId == null ? null : String(value.businessId), orderId: value.orderId == null ? null : String(value.orderId),
warehouseId: value.warehouseId == null ? null : String(value.warehouseId), allowedActions: value.allowedActions || [] }
}
export async function listOverseasFees(query: Record<string, string | number | undefined>): Promise<FeePage<OverseasFee>> {
const response = await http.get<ApiEnvelope<FeePage<OverseasFee>>, ApiEnvelope<FeePage<OverseasFee>>>(`${base}/page`, { params: query })
const data = unwrapApiResponse(response)
return { ...data, records: data.records.map(normalizeFee), total: Number(data.total), pages: Number(data.pages) }
}
export async function listBusinessOverseasFees(business: BusinessFeeOption | BusinessFeeOption[], pagination: { page: number; size: number }, direction: OverseasFeeDirection = 'RECEIVABLE'): Promise<FeePage<OverseasFee>> {
const { page, size } = pagination
const scopes = Array.isArray(business) ? business : [business]
if (!scopes.length || scopes.some(scope => !scope.businessId || !scope.customerId || !scope.businessType)) throw new Error('业务单据信息不完整')
// 非分页接口返回两种方向;先按客户、业务身份及收付方向匹配,再合并分页。
const groups = await Promise.all(scopes.map(async scope => {
const response = await http.get<ApiEnvelope<OverseasFee[]>, ApiEnvelope<OverseasFee[]>>(base, {
params: { customerId: scope.customerId, businessType: scope.businessType, businessNo: scope.businessNo },
})
return unwrapApiResponse(response).map(normalizeFee).filter(row => row.customerId === scope.customerId && row.businessType === scope.businessType && row.businessId === scope.businessId && row.direction === direction)
}))
const records = [...new Map(groups.flat().map(row => [row.sourceFeeId, row])).values()]
.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.sourceFeeId.localeCompare(left.sourceFeeId))
return { records: records.slice((page - 1) * size, page * size), total: records.length, pages: Math.ceil(records.length / size), page, size }
}
export async function getOverseasFee(id: string) {
return normalizeFee(unwrapApiResponse(await http.get<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(`${base}/${encodeURIComponent(id)}`)))
}
export async function listBusinessFeeOptions(params: { businessType: string; keyword?: string; customerId?: string }) {
const response = await http.get<ApiEnvelope<BusinessFeeOption[]>, ApiEnvelope<BusinessFeeOption[]>>(`${base}/business-options`, { params })
return unwrapApiResponse(response).map(row => ({ ...row, businessId: String(row.businessId), customerId: String(row.customerId), orderId: row.orderId == null ? null : String(row.orderId), warehouseId: row.warehouseId == null ? null : String(row.warehouseId) }))
}
export async function createOverseasFee(payload: SaveOverseasFee, key: string) {
return normalizeFee(unwrapApiResponse(await http.post<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(base, payload, { headers: { 'Idempotency-Key': key } })))
}
export async function actOnOverseasFee(id: string, action: string, body: Record<string, unknown>) {
const endpoint = action === 'APPROVE' || action === 'REJECT' ? 'review'
: ['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action) ? 'retry-delivery'
: action.startsWith('RETRY_') ? 'retry-funding' : action.toLowerCase()
return normalizeFee(unwrapApiResponse(await http.post<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(`${base}/${encodeURIComponent(id)}/${endpoint}`, body)))
}
@@ -57,8 +57,8 @@ export async function listOverseasPurchaseCustomerOptions(keyword = '', limit =
const response = await http.get<ApiEnvelope<OverseasPurchaseCustomerOption[]>, ApiEnvelope<OverseasPurchaseCustomerOption[]>>(`${procurementBasePath}/customer-options`, { params: { keyword: keyword.trim() || undefined, limit } })
return unwrapApiResponse(response)
}
export async function listOverseasPurchaseWarehouseOptions() {
const response = await http.get<ApiEnvelope<OverseasPurchaseWarehouseOption[]>, ApiEnvelope<OverseasPurchaseWarehouseOption[]>>(`${procurementBasePath}/warehouse-options`)
export async function listOverseasPurchaseWarehouseOptions(procurementDirection?: import('../procurementDirection').ProcurementDirection) {
const response = await http.get<ApiEnvelope<OverseasPurchaseWarehouseOption[]>, ApiEnvelope<OverseasPurchaseWarehouseOption[]>>(`${procurementBasePath}/warehouse-options`, { params: { procurementDirection } })
return unwrapApiResponse(response).map((item) => ({ ...item, warehouseId: String(item.warehouseId) }))
}
export async function listOverseasPurchases(query: OverseasPurchaseQuery = {}) {
@@ -3,6 +3,7 @@ import type { ProcurementFile, ProcurementFileCategory, ProcurementRemainingKind
export type ProcurementCommand = 'review' | 'purchase' | 'purchase/confirm' | 'budget-authorizations' | 'planned-warehouse' | 'cancel' | 'overseas-receipts' | 'domestic-receipts' | 'overseas-receipts/complete' | 'domestic-receipts/complete' | 'packing' | 'packing/confirm' | 'packing/withdraw' | 'dispositions' | 'receipts/correct' | 'dispositions/revoke' | 'labels' | 'international-estimate' | 'labels/apply' | 'domestic-repack' | 'overseas-shipment' | 'customer-shipment' | 'charges/confirm' | 'fees/retry' | 'fees/terminate' | 'fees/release-estimate' | 'fees/correct-target' | 'fees/refund' | 'fees/replace-failed' | 'fees/additional' | 'control-notes' | 'control-notes/resolve' | 'actual-warehouse/correct' | 'exception-close'
export interface ProcurementCommandBody {
procurementDirection?: import('../procurementDirection').ProcurementDirection
name?: string
targetType?: 'PURCHASE' | 'WAREHOUSE' | 'RECEIPT' | 'SHIPMENT'; targetId?: string; blocking?: boolean; clientSummary?: string; noteId?: string
resolutions?: ProcurementResolution[]; customerVoucherFileId?: string; financialConclusion?: string
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const client = vi.hoisted(() => ({ get: vi.fn() }))
vi.mock('@/shared/api/http', async original => ({ ...await original<typeof import('@/shared/api/http')>(), http: client }))
import { getStorageRentLot, listStorageRentLots, listStorageRentWarehouses } from './storageRent'
const base = '/platform/v1/last-leg/storage-rent'
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
const lot = { id: '9223372036854775001', customerId: '9223372036854775002', warehouseId: '9223372036854775003', businessNo: 'WT1' }
describe('仓租查询接口', () => {
beforeEach(() => vi.clearAllMocks())
it('uses the paginated root endpoint with server filters and preserves long IDs', async () => {
client.get.mockResolvedValue(envelope({ records: [lot], total: '1', page: 1, size: 20, pages: 1 }))
const params = { page: 1, size: 20, businessType: 'WAREHOUSE_TRANSFER', warehouseId: lot.warehouseId, status: 'IN_STOCK' }
const result = await listStorageRentLots(params)
expect(client.get).toHaveBeenCalledWith(base, { params })
expect(result.total).toBe(1)
expect(result.records[0]).toMatchObject(lot)
})
it('reads a lot with its actual charges and warehouse options from dedicated endpoints', async () => {
const charge = { id: '9223372036854775004', outboundNo: 'OUT1', quantity: 2, originalAmount: 23, currency: 'CNY', settlementAmount: 23 }
const currencyLot = { ...lot, generatedAmount: null, generatedCurrency: null, generatedAmounts: [{ currency: 'USD', amount: 12 }, { currency: 'CNY', amount: 23 }] }
client.get.mockResolvedValueOnce(envelope({ lot: currencyLot, charges: [charge] }))
.mockResolvedValueOnce(envelope([{ id: lot.warehouseId, warehouseName: '测试仓' }]))
const detail = await getStorageRentLot(lot.id)
expect(client.get).toHaveBeenNthCalledWith(1, `${base}/${lot.id}`)
expect(detail.lot.id).toBe(lot.id)
expect(detail.lot).toMatchObject(currencyLot)
expect(detail.charges).toEqual([charge])
expect(await listStorageRentWarehouses()).toEqual([{ id: lot.warehouseId, warehouseName: '测试仓' }])
expect(client.get).toHaveBeenNthCalledWith(2, `${base}/warehouse-options`)
})
})
@@ -0,0 +1,39 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { FeePage } from './overseasFees'
import type { OverseasFee } from './overseasFees'
export async function adjustStorageRentFee(id: string, body: { expectedVersion: number; amount: number; reason: string; idempotencyKey: string }) {
return unwrapApiResponse(await http.post<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(`/platform/v1/last-leg/storage-rent/fees/${encodeURIComponent(id)}/adjust`, body))
}
export interface StorageRentLot {
id: string; customerId: string; customerName: string; businessType: string; businessNo: string
warehouseId: string; warehouseName: string; batchNo: string; inboundAt: string; storageDays: number
originalQuantity: number; remainingQuantity: number; quantityUnit: string; billingQuantity: number
billingUnit: string; ruleCode: string; status: string; generatedAmount: number | null
generatedCurrency: string | null; generatedAmounts: Array<{ currency: string; amount: number }>
settledAmount?: number; chargeCount?: number; settledChargeCount?: number
}
export interface StorageRentCharge {
id: string; outboundNo: string; businessNo: string; quantity: number; storageDays: number
originalAmount: number; currency: string; settlementAmount: number; currencyRate: number; status: string
createdAt: string; outboundAt: string
}
export interface StorageRentDetail { lot: StorageRentLot; charges: StorageRentCharge[] }
const base = '/platform/v1/last-leg/storage-rent'
function normalizeLot(row: StorageRentLot): StorageRentLot {
return { ...row, id: String(row.id), customerId: String(row.customerId), warehouseId: String(row.warehouseId) }
}
export async function listStorageRentLots(params: Record<string, string | number | undefined>) {
const response = await http.get<ApiEnvelope<FeePage<StorageRentLot>>, ApiEnvelope<FeePage<StorageRentLot>>>(base, { params })
const data = unwrapApiResponse(response)
return { ...data, total: Number(data.total), records: data.records.map(normalizeLot) }
}
export async function getStorageRentLot(id: string) {
const data = unwrapApiResponse(await http.get<ApiEnvelope<StorageRentDetail>, ApiEnvelope<StorageRentDetail>>(`${base}/${encodeURIComponent(id)}`))
return { lot: normalizeLot(data.lot), charges: data.charges.map(row => ({ ...row, id: String(row.id) })) }
}
export async function listStorageRentWarehouses() {
const data = unwrapApiResponse(await http.get<ApiEnvelope<Array<{ id: string; warehouseName: string }>>, ApiEnvelope<Array<{ id: string; warehouseName: string }>>>(`${base}/warehouse-options`))
return data.map(row => ({ ...row, id: String(row.id) }))
}
@@ -0,0 +1,59 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BusinessDocumentFees from './BusinessDocumentFees.vue'
import { listBusinessFeeOptions } from '../api/overseasFees'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/overseasFees', () => ({ listBusinessFeeOptions: vi.fn() }))
vi.mock('./BusinessOrderFees.vue', () => ({ default: { name: 'BusinessOrderFees', props: ['business', 'relatedBusinesses', 'canCreate', 'readonly', 'direction'], template: '<div><slot name="toolbar-actions" /></div>' } }))
const business = { businessType: 'LASTLEG_ORDER', businessId: '9223372036854775001', businessNo: 'TB-01', customerId: 'C1', customerName: '客户', orderId: '9223372036854775001', warehouseId: null }
function page(props = {}) {
return mount(BusinessDocumentFees, { props: { ...business, ...props }, global: { stubs: {
LoadErrorState: { props: ['message'], template: '<div>{{ message }}</div>' }, ApiErrorMeta: true, ElSkeleton: true, ElAlert: true,
} } })
}
beforeEach(() => { vi.resetAllMocks(); can.mockReturnValue(true); vi.mocked(listBusinessFeeOptions).mockResolvedValue([business]) })
describe('单据费用归属', () => {
it('passes the payable direction to the review panel', async () => {
const wrapper = page({ direction: 'PAYABLE', canCreate: false }); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props()).toMatchObject({ direction: 'PAYABLE', canCreate: false })
})
it('requires exact customer, type, number and long identity', async () => {
vi.mocked(listBusinessFeeOptions).mockResolvedValue([{ ...business, businessNo: 'TB-010' }, { ...business, customerId: 'C2' }, business])
const wrapper = page(); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('business')).toEqual(business)
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('canCreate')).toBe(true)
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('readonly')).toBe(false)
expect(listBusinessFeeOptions).toHaveBeenCalledWith({ businessType: 'LASTLEG_ORDER', customerId: 'C1', keyword: 'TB-01' })
})
it('does not permit a missing or ambiguous match to create charges', async () => {
vi.mocked(listBusinessFeeOptions).mockResolvedValue([{ ...business, businessId: 'another' }])
const wrapper = page(); await flushPromises()
expect(wrapper.findComponent({ name: 'BusinessOrderFees' }).exists()).toBe(false)
expect(wrapper.text()).toContain('未找到唯一关联单据')
})
it('resolves source order charges alongside tail charges', async () => {
const source = { ...business, businessType: 'RETURN_ORDER', businessId: 'return-id', businessNo: 'TH-01', orderId: null }
vi.mocked(listBusinessFeeOptions).mockResolvedValueOnce([business]).mockResolvedValueOnce([source])
const wrapper = page({ relatedDocuments: [source] }); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('relatedBusinesses')).toEqual([source])
})
it('can resolve a unique relabel id without inventing a customer', async () => {
const wrapper = page({ customerId: undefined }); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('business').customerId).toBe('C1')
})
it('keeps the real tail order usable when its source number is only an external reference', async () => {
vi.mocked(listBusinessFeeOptions).mockResolvedValueOnce([business]).mockResolvedValueOnce([])
const wrapper = page({ relatedDocuments: [{ businessType: 'RELABEL_ORDER', businessNo: 'external-reference', customerId: 'C1' }] })
await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('business')).toEqual(business)
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('relatedBusinesses')).toEqual([])
})
it('does not query fees without read permission', async () => {
can.mockReturnValue(false)
const wrapper = page(); await flushPromises()
expect(listBusinessFeeOptions).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'BusinessOrderFees' }).exists()).toBe(false)
})
})
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import BusinessOrderFees from './BusinessOrderFees.vue'
import { listBusinessFeeOptions, listBusinessOverseasFees, type BusinessFeeOption, type OverseasFeeDirection } from '../api/overseasFees'
import { feePermissions } from '../overseasFeePresentation'
interface DocumentIdentity { businessType: string; businessNo: string; customerId?: string; businessId?: string }
class DocumentIdentityError extends Error {}
const props = withDefaults(defineProps<DocumentIdentity & { relatedDocuments?: DocumentIdentity[]; canCreate?: boolean; readonly?: boolean; direction?: OverseasFeeDirection; hideWhenEmpty?: boolean; heading?: string }>(), { canCreate: true, readonly: false, direction: 'RECEIVABLE', hideWhenEmpty: false })
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const canRead = computed(() => access.can(feePermissions.list) || access.can('platform:finance:customer-arap:list'))
const business = ref<BusinessFeeOption | null>(null), related = ref<BusinessFeeOption[]>([])
const loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const panel = ref<InstanceType<typeof BusinessOrderFees>>()
let generation = 0
async function load() {
const request = ++generation
business.value = null; related.value = []; error.value = null; loading.value = false
if (!canRead.value) return
loading.value = true
try {
const identities: DocumentIdentity[] = [props, ...(props.relatedDocuments || [])]
const resolved = await Promise.all(identities.map(async (identity, index) => {
if ((!identity.customerId && !identity.businessId) || !identity.businessNo) throw new DocumentIdentityError('单据信息不完整,无法读取费用')
const options = await listBusinessFeeOptions({ businessType: identity.businessType, customerId: identity.customerId, keyword: identity.businessNo })
// 候选搜索是模糊查询,只有当前客户和完整单号一致时才能承载收费。
const matches = options.filter(option => (!identity.customerId || option.customerId === identity.customerId) && option.businessType === identity.businessType
&& option.businessNo === identity.businessNo && (!identity.businessId || option.businessId === identity.businessId))
// 尾程来源号可能只是外部参考号;没有内部业务实体时不能据此造出费用归属。
if (index > 0 && matches.length === 0) return null
if (matches.length !== 1) throw new DocumentIdentityError(`未找到唯一关联单据:${identity.businessNo}`)
return matches[0]!
}))
if (request === generation) { business.value = resolved[0]!; related.value = resolved.slice(1).filter((value): value is BusinessFeeOption => value !== null) }
} catch (failure) { if (request === generation) error.value = failure instanceof DocumentIdentityError ? { message: failure.message } : getApiErrorDetails(failure, '单据费用加载失败') }
finally { if (request === generation) loading.value = false }
}
watch(() => [props.businessType, props.businessNo, props.customerId, props.businessId, props.relatedDocuments, canRead.value], load, { immediate: true, deep: true })
async function refresh() { if (business.value) await panel.value?.refresh(); else await load() }
/** 新增前读取完整单据范围,重复项目提示不受当前表格分页影响。 */
async function getFees() {
if (!business.value) await load()
if (!business.value || error.value) throw new Error(error.value?.message || '单据费用尚未加载完成')
return (await listBusinessOverseasFees([business.value, ...related.value], { page: 1, size: Number.MAX_SAFE_INTEGER }, props.direction)).records
}
defineExpose({ refresh, getFees })
</script>
<template>
<section aria-label="单据费用">
<el-alert v-if="!canRead" title="暂无费用查看权限" type="info" :closable="false" />
<LoadErrorState v-else-if="error" title="单据费用加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-skeleton v-else-if="loading" :rows="3" animated />
<BusinessOrderFees v-else-if="business" ref="panel" :business="business" :related-businesses="related" :direction="direction" :can-create="canCreate" :readonly="readonly" :hide-when-empty="hideWhenEmpty" :heading="heading" @changed="emit('changed')">
<template v-if="$slots['toolbar-actions']" #toolbar-actions><slot name="toolbar-actions" /></template>
</BusinessOrderFees>
</section>
</template>
@@ -0,0 +1,328 @@
import ElementPlus, { ElDialog } from 'element-plus'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import OverseasFeeEditor from './OverseasFeeEditor.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import * as api from '../api/overseasFees'
import type { OverseasFee } from '../api/overseasFees'
import BusinessOrderFees from './BusinessOrderFees.vue'
import { adjustStorageRentFee } from '../api/storageRent'
vi.mock('../api/storageRent', () => ({ adjustStorageRentFee: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_permission: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/overseasFees', () => ({
actOnOverseasFee: vi.fn(), getOverseasFee: vi.fn(), listOverseasFeeCustomers: vi.fn(), listOverseasFeeItems: vi.fn(),
listOverseasFeeRetries: vi.fn(),
listOverseasFees: vi.fn(), listBusinessOverseasFees: vi.fn(), listBusinessFeeOptions: vi.fn(), createOverseasFee: vi.fn(),
}))
const fee: OverseasFee = {
direction: 'RECEIVABLE',
id: '9223372036854775001', sourceFeeId: '9223372036854775001', sourceVersion: 3,
customerId: '9223372036854775002', customerName: '测试客户', sourceType: 'OVERSEAS_WAREHOUSE_FEE', businessType: 'WAREHOUSE_TRANSFER',
businessId: '9223372036854775003', businessNo: 'WT202610220001', orderId: null, warehouseId: '9223372036854775004',
feeItemCode: 'HANDLING', feeItemName: '操作费', businessDate: '2026-10-22', currency: 'CNY', amount: 70,
originalCurrency: 'USD', originalAmount: 10, chargeableQuantity: null, quantityUnit: null, unitPrice: null,
periodStart: null, periodEnd: null, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', publicationStatus: 'UNPUBLISHED',
collectionMode: 'WALLET', walletStatus: 'FROZEN', walletErrorMessage: null, reviewReason: null, reviewerId: null, reviewedAt: null,
createdAt: '2026-10-22T10:00:00', updatedAt: '', allowedActions: ['APPROVE', 'REJECT', 'CANCEL'],
}
const business = { businessId: fee.businessId!, businessType: fee.businessType, businessNo: fee.businessNo, customerId: fee.customerId, customerName: fee.customerName, warehouseId: fee.warehouseId, orderId: fee.orderId }
function deferred<T>() {
let resolve!: (value: T) => void, reject!: (reason: unknown) => void
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
async function mountPage() {
const wrapper = mount(BusinessOrderFees, { props: { business }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await flushPromises()
return wrapper
}
async function click(wrapper: VueWrapper, label: string, selector = 'button') {
const button = wrapper.findAll(selector).find(node => node.text().trim() === label)
expect(button, label).toBeDefined()
await button!.trigger('click'); await flushPromises()
}
function activeDialog(wrapper: VueWrapper) {
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('modelValue'))
expect(dialog).toBeDefined()
return dialog!.getComponent({ name: 'ElDialogContent' })
}
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.mocked(api.listOverseasFeeRetries).mockResolvedValue([])
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(api.listOverseasFeeCustomers).mockResolvedValue([{ customerId: fee.customerId, customerName: fee.customerName }])
vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'HANDLING', feeTypeName: '操作费', defaultPrice: 20 }])
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [fee], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(fee)
vi.mocked(api.actOnOverseasFee).mockResolvedValue(fee)
vi.mocked(api.createOverseasFee).mockResolvedValue(fee)
vi.mocked(api.listBusinessFeeOptions).mockResolvedValue([{ businessId: fee.businessId!, businessType: fee.businessType, businessNo: fee.businessNo,
customerId: fee.customerId, customerName: fee.customerName, warehouseId: fee.warehouseId, orderId: null }])
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('单据费用操作', () => {
it.each(['RECEIVABLE', 'PAYABLE'] as const)('hides an empty %s block and reveals it when fees are added', async direction => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
const wrapper = mount(BusinessOrderFees, { props: { business, direction, hideWhenEmpty: true, canCreate: false, heading: direction === 'RECEIVABLE' ? '应收明细' : '应付明细' }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await flushPromises()
expect(wrapper.isVisible()).toBe(false)
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, direction }], total: 1, page: 1, size: 20, pages: 1 })
await wrapper.vm.refresh(); await flushPromises()
expect(wrapper.isVisible()).toBe(true)
expect(wrapper.get('h3').text()).toBe(direction === 'RECEIVABLE' ? '应收明细' : '应付明细')
expect(wrapper.text()).toContain('操作费')
expect(wrapper.findAll('button').some(button => button.text().trim() === '新增费用')).toBe(false)
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
await wrapper.vm.refresh(); await flushPromises()
expect(wrapper.isVisible()).toBe(false)
})
it('queries the exact document and creates without a second order selector or catalog default amount', async () => {
const wrapper = await mountPage()
expect(api.listBusinessOverseasFees).toHaveBeenCalledWith([business], { page: 1, size: 20 }, 'RECEIVABLE')
await click(wrapper, '新增费用')
expect(api.listBusinessFeeOptions).not.toHaveBeenCalled()
const dialog = activeDialog(wrapper)
expect(dialog.text()).not.toContain('业务类型')
expect(dialog.text()).not.toContain('业务订单')
expect(dialog.findComponent({ name: 'ElDatePicker' }).exists()).toBe(false)
const selects = dialog.findAllComponents({ name: 'ElSelect' })
selects[1]!.vm.$emit('update:modelValue', 'CNY')
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.34)
selects[0]!.vm.$emit('update:modelValue', 'HANDLING')
selects[0]!.vm.$emit('change', 'HANDLING')
await flushPromises()
expect(dialog.getComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(12.34)
expect(dialog.text()).toContain('USD 10.00 · 待审核')
await click(dialog, '保存')
expect(api.createOverseasFee).not.toHaveBeenCalled()
dialog.findAllComponents({ name: 'ElInput' }).find(input => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '补收本次额外操作费用')
await flushPromises()
await click(dialog, '保存')
expect(api.createOverseasFee).toHaveBeenCalledWith(expect.objectContaining({ ...business, feeRemark: '补收本次额外操作费用', amount: 12.34, currency: 'CNY', businessDate: new Date().toLocaleDateString('sv-SE') }), expect.any(String))
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('checks refreshed eligibility and rejects a fee belonging to another order', async () => {
const wrapper = await mountPage()
vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, allowedActions: [] })
await click(wrapper, '审核', '.el-table__body-wrapper button')
expect(wrapper.findAllComponents(ElDialog).some(dialog => dialog.props('modelValue'))).toBe(false)
vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, businessId: 'different-order' })
await click(wrapper, '审核', '.el-table__body-wrapper button')
expect(wrapper.findAllComponents(ElDialog).some(dialog => dialog.props('modelValue'))).toBe(false)
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
})
it('keeps supplier payables separate and reviews them for settlement without customer billing hints', async () => {
const payable: OverseasFee = { ...fee, businessType: 'LASTLEG_ORDER', direction: 'PAYABLE', supplierId: 'supplier-1', supplierName: '测试供应商', settlementMode: 'ON_ACCOUNT', collectionMode: 'BILL', walletStatus: 'NOT_REQUIRED' }
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [payable], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(payable)
const wrapper = mount(BusinessOrderFees, { props: { business: { ...business, businessType: 'LASTLEG_ORDER' }, direction: 'PAYABLE' }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await flushPromises()
expect(api.listBusinessOverseasFees).toHaveBeenLastCalledWith([expect.objectContaining({ businessType: 'LASTLEG_ORDER' })], { page: 1, size: 20 }, 'PAYABLE')
expect(wrapper.text()).toContain('测试供应商'); expect(wrapper.text()).toContain('推送结算')
expect(wrapper.findAll('button').map(button => button.text())).not.toContain('新增费用')
await click(wrapper, '审核', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).not.toContain('请核对原报价')
await click(dialog, '审核通过')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'APPROVE', expect.objectContaining({ decision: 'APPROVE' }))
})
it('cannot review the opposite direction returned by a refreshed fee', async () => {
const wrapper = await mountPage()
vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, direction: 'PAYABLE' })
await click(wrapper, '审核', '.el-table__body-wrapper button')
expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('modelValue'))).toBe(false)
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
})
it('keeps read-only documents read-only and closes an editor when the order changes', async () => {
const wrapper = await mountPage()
await click(wrapper, '新增费用')
await wrapper.setProps({ business: { ...business, businessId: 'other' } })
await flushPromises()
expect(wrapper.getComponent(OverseasFeeEditor).props('modelValue')).toBe(false)
await wrapper.setProps({ readonly: true })
const visibleButtons = wrapper.findAll('button').filter(button => button.isVisible()).map(button => button.text())
expect(visibleButtons).not.toContain('新增费用')
expect(visibleButtons).not.toContain('审核')
expect(visibleButtons).not.toContain('驳回')
expect(visibleButtons).not.toContain('取消费用')
})
it('does not create against an order or customer changed after the editor was opened', async () => {
const wrapper = mount(OverseasFeeEditor, { props: { modelValue: false, fee: null, business, items: [{ feeCode: 'HANDLING', feeTypeName: '操作费', defaultPrice: 20 }] }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await wrapper.setProps({ modelValue: true }); await flushPromises()
const dialog = activeDialog(wrapper)
dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'HANDLING')
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10)
await wrapper.setProps({ business: { ...business, customerId: 'different-customer' } })
await flushPromises(); await click(dialog, '保存')
expect(api.createOverseasFee).not.toHaveBeenCalled()
})
it('blocks automatic storage rent from manual addition', async () => {
vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'FEE_TYPE_077', feeTypeName: '仓储费', defaultPrice: null }])
const wrapper = await mountPage()
await click(wrapper, '新增费用')
const dialog = activeDialog(wrapper)
const select = dialog.findAllComponents({ name: 'ElSelect' })[0]!
expect(select.findAllComponents({ name: 'ElOption' })).toHaveLength(0)
select.vm.$emit('update:modelValue', 'FEE_TYPE_077')
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10)
await flushPromises(); await click(dialog, '保存')
expect(api.createOverseasFee).not.toHaveBeenCalled()
})
it('includes verified related orders but rejects another customer and keeps adjustment on the original order', async () => {
const related = { ...business, businessId: 'return-1', businessType: 'RETURN_ORDER' }
const relatedFee = { ...fee, ...related, allowedActions: ['ADJUST'] }
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [relatedFee], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(relatedFee)
const wrapper = await mountPage()
await wrapper.setProps({ relatedBusinesses: [related, { ...business, customerId: 'other-customer' }] })
await flushPromises()
expect(api.listBusinessOverseasFees).toHaveBeenLastCalledWith([business, related], { page: 1, size: 20 }, 'RECEIVABLE')
await click(wrapper, '调整', '.el-table__body-wrapper button')
expect(wrapper.getComponent(OverseasFeeEditor).props('business')).toEqual(related)
const dialog = activeDialog(wrapper)
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 15)
await flushPromises(); await click(dialog, '保存')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'ADJUST', expect.objectContaining({
expectedVersion: fee.sourceVersion, fee: expect.objectContaining({ businessId: related.businessId, businessType: 'RETURN_ORDER', amount: 15 }),
}))
})
it('shows a voided freeze-failure fixture as closed instead of pending review', async () => {
const closed = { ...fee, sourceStatus: 'VOIDED', reviewStatus: 'PENDING', walletStatus: 'FREEZE_FAILED', walletErrorMessage: '可用余额和授信额度不足', allowedActions: [] }
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [closed], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(closed)
const wrapper = await mountPage()
expect(wrapper.text()).toContain('已作废')
expect(wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())).toEqual(['详情'])
await click(wrapper, '详情', '.el-table__body-wrapper button')
expect(activeDialog(wrapper).text()).toContain('冻结失败')
expect(activeDialog(wrapper).text()).toContain('此费用已作废,无需继续审核')
expect(activeDialog(wrapper).text()).not.toContain('待审核')
})
it('retries a failed debit only when the refreshed server action is allowed', async () => {
const failed = { ...fee, walletStatus: 'SETTLE_FAILED', allowedActions: ['RETRY_SETTLE'] }
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(failed)
const wrapper = await mountPage()
await click(wrapper, '重试扣费', '.el-table__body-wrapper button')
await click(activeDialog(wrapper), '重试扣费')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'RETRY_SETTLE', { idempotencyKey: expect.any(String) })
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('requires a rejection reason and submits the review contract', async () => {
const wrapper = await mountPage()
vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, sourceVersion: 5 })
await click(wrapper, '审核', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
const reject = dialog.findAll('button').find(button => button.text().trim() === '驳回')
expect(reject?.attributes('disabled')).toBeDefined()
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
await dialog.get('textarea').setValue(' 金额不符 ')
await click(dialog, '驳回')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'REJECT', { decision: 'REJECT', reason: '金额不符', expectedVersion: 5, idempotencyKey: expect.any(String) })
})
it('keeps rejected fees read-only while allowing failed frozen funds to be released', async () => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, reviewStatus: 'REJECTED', allowedActions: ['SUBMIT', 'RETRY_RELEASE', 'CANCEL'] }], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue({ ...fee, reviewStatus: 'REJECTED', allowedActions: ['SUBMIT', 'RETRY_RELEASE', 'CANCEL'] })
const wrapper = await mountPage()
const visibleButtons = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(visibleButtons).toContain('详情')
expect(visibleButtons).not.toContain('审核')
expect(visibleButtons).not.toContain('驳回')
expect(visibleButtons).not.toContain('提交审核')
expect(visibleButtons).toContain('重试释放')
await click(wrapper, '重试释放', '.el-table__body-wrapper button')
await click(activeDialog(wrapper), '重试释放')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'RETRY_RELEASE', expect.objectContaining({ idempotencyKey: expect.any(String) }))
await click(wrapper, '详情', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).toContain('审核状态')
expect(dialog.findAll('button').some(button => button.text().trim() === '审核')).toBe(false)
})
it('voids with the freshly loaded version and a required reason', async () => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, allowedActions: ['VOID'] }], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue({ ...fee, sourceVersion: 5, allowedActions: ['VOID'] })
const wrapper = await mountPage()
await click(wrapper, '作废', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
await click(dialog, '作废')
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
await dialog.get('textarea').setValue(' 重复费用 ')
await click(dialog, '作废')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'VOID', { expectedVersion: 5, reason: '重复费用', idempotencyKey: expect.any(String) })
})
it('locks in-flight approvals and preserves diagnostics and idempotency when retried', async () => {
const pending = deferred<OverseasFee>()
vi.mocked(api.actOnOverseasFee).mockReturnValueOnce(pending.promise)
const wrapper = await mountPage()
await click(wrapper, '审核', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
await click(dialog, '审核通过')
expect(wrapper.findAllComponents(ElDialog).find(component => component.props('modelValue'))!.props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
pending.reject(new ApiResponseError('余额服务暂不可用', 'FUNDING_ERROR', 409, null, 'test-request-1'))
await flushPromises()
expect(wrapper.findAllComponents(AppDialog).some(component => component.props('modelValue'))).toBe(true)
expect(dialog.text()).toContain('余额服务暂不可用')
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('')
const firstKey = vi.mocked(api.actOnOverseasFee).mock.calls[0]![2].idempotencyKey
await click(dialog, '审核通过')
expect(vi.mocked(api.actOnOverseasFee).mock.calls[1]![2].idempotencyKey).toBe(firstKey)
})
})
describe('仓租费用审核与调价', () => {
const rent: OverseasFee = { ...fee, feeItemCode: 'FEE_TYPE_077', feeItemName: '仓储费', allowedActions: ['APPROVE', 'REJECT', 'ADJUST_RENT'],
storageRent: { outboundNo: 'OUT-1', outboundAt: '2026-10-22T10:00:00Z', calculatedAmount: 10, chargedAmount: null,
lines: [
{ lotId: '1', inboundAt: '2026-10-01T10:00:00Z', quantity: 2, days: 21, calculatedAmount: 5, sourceBusinessType: 'LASTLEG_ORDER', sourceBusinessNo: 'WC-SOURCE-1', batchNo: 'B-SOURCE-1' },
{ lotId: '2', inboundAt: '2026-10-01T10:00:00Z', quantity: 2, days: 21, calculatedAmount: 5, sourceBusinessType: 'RETURN_ORDER', sourceBusinessNo: 'TH-SOURCE-2', batchNo: 'B-SOURCE-2' },
] } }
async function setupRent() {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [rent], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(rent)
return mountPage()
}
it('审核显示实际出入库与原计算额,批准以前不请求扣款', async () => {
const wrapper = await setupRent()
await click(wrapper, '审核', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).toContain('入库时间'); expect(dialog.text()).toContain('出库时间')
expect(dialog.text()).toContain('原计算金额'); expect(dialog.text()).toContain('实扣金额')
expect(dialog.text()).toContain('来源单号'); expect(dialog.text()).toContain('头程尾程订单')
expect(dialog.text()).toContain('WC-SOURCE-1'); expect(dialog.text()).toContain('TH-SOURCE-2')
expect(dialog.text()).toContain('退货单'); expect(dialog.text()).toContain('B-SOURCE-2')
expect(dialog.text()).toContain('2026-10-01'); expect(api.actOnOverseasFee).not.toHaveBeenCalled()
await click(dialog, '审核通过')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(rent.sourceFeeId, 'APPROVE', expect.objectContaining({ decision: 'APPROVE' }))
})
it('调价说明必填,失败保留金额说明且同参数重试复用请求编号', async () => {
const wrapper = await setupRent()
vi.mocked(adjustStorageRentFee).mockRejectedValueOnce(new Error('暂时不可用')).mockResolvedValue(rent)
await click(wrapper, '调整仓租', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.getComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(10)
expect(dialog.findAll('button').find(button => button.text() === '保存调整')!.attributes('disabled')).toBeDefined()
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12)
dialog.findAllComponents({ name: 'ElInput' }).find(input => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '人工核对调整')
await flushPromises(); await click(dialog, '保存调整')
expect(dialog.text()).toContain('仓租操作失败')
await click(dialog, '保存调整')
const calls = vi.mocked(adjustStorageRentFee).mock.calls
expect(calls).toHaveLength(2); expect(calls[0]).toEqual(calls[1])
expect(calls[0]).toEqual([rent.sourceFeeId, expect.objectContaining({ amount: 12, reason: '人工核对调整', expectedVersion: 3 })])
})
it('只读单据在仓租弹框已打开时同样禁止修改', async () => {
const wrapper = await setupRent(); await click(wrapper, '调整仓租', '.el-table__body-wrapper button')
await wrapper.setProps({ readonly: true }); await flushPromises()
expect(activeDialog(wrapper).findAll('button').some(button => button.text() === '保存调整')).toBe(false)
expect(adjustStorageRentFee).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,217 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import '../overseasFeeDetails.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import RowActions from '@/shared/components/RowActions.vue'
import OverseasFeeEditor, { type FeeItemOption } from './OverseasFeeEditor.vue'
import OverseasFeeRetryHistory from './OverseasFeeRetryHistory.vue'
import StorageRentFeeDetails from './StorageRentFeeDetails.vue'
import StorageRentFeeActions from './StorageRentFeeActions.vue'
import { useFeeStatusRefresh } from '../useFeeStatusRefresh'
import { actOnOverseasFee, getOverseasFee, listBusinessOverseasFees, listOverseasFeeItems, type BusinessFeeOption, type OverseasFee, type OverseasFeeDirection } from '../api/overseasFees'
import { actionLabels, feeActions, feeMoney, feePermissions, feeProcessingLabel, feeTime, feeReviewLabel, feeFundingErrorLabel } from '../overseasFeePresentation'
const props = withDefaults(defineProps<{ business: BusinessFeeOption; relatedBusinesses?: BusinessFeeOption[]; readonly?: boolean; canCreate?: boolean; direction?: OverseasFeeDirection; hideWhenEmpty?: boolean; heading?: string }>(), { readonly: false, canCreate: true, direction: 'RECEIVABLE', hideWhenEmpty: false })
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const canEdit = computed(() => !props.readonly && access.can(feePermissions.edit))
const loading = ref(false), error = ref<ApiErrorDetails | null>(null), itemsError = ref<ApiErrorDetails | null>(null), itemsLoading = ref(false)
const page = ref(1), pageSize = ref(20), total = ref(0), rows = ref<OverseasFee[]>([]), items = ref<FeeItemOption[]>([])
const detailLoading = ref(''), detail = ref<OverseasFee | null>(null), detailOpen = ref(false)
const editorOpen = ref(false), editFee = ref<OverseasFee | null>(null)
const actionOpen = ref(false), saving = ref(false), actionError = ref<ApiErrorDetails | null>(null)
const actionForm = reactive({ action: '', reason: '', requestKey: '', requestFingerprint: '' })
const actionFee = ref<OverseasFee | null>(null)
const rentFee = ref<OverseasFee | null>(null), rentAction = ref(''), rentOpen = ref(false)
const actionTitle = computed(() => actionForm.action === 'REVIEW' ? '审核' : actionLabels[actionForm.action] || '确认操作')
const actionConfirmText = computed(() => actionLabels[actionForm.action] || '确认操作')
const actionConfirmType = computed(() => ['REJECT', 'VOID'].includes(actionForm.action) ? 'danger' : 'primary')
const businesses = computed(() => [props.business, ...(props.relatedBusinesses || [])].filter(scope => scope.customerId === props.business.customerId))
const businessKey = computed(() => props.direction + '|' + businesses.value.map(scope => [scope.businessType, scope.businessId, scope.customerId].join(':')).join('|'))
const feeBusiness = (fee: OverseasFee) => businesses.value.find(scope => fee.businessId === scope.businessId && fee.businessType === scope.businessType && fee.customerId === scope.customerId)
const belongsToBusiness = (fee: OverseasFee) => fee.direction === props.direction && !!feeBusiness(fee)
const editorBusiness = computed(() => editFee.value ? feeBusiness(editFee.value) || props.business : props.business)
function rowActions(fee: OverseasFee) {
return feeActions(fee, permission => !props.readonly && access.can(permission), !!detailLoading.value || saving.value)
}
const collectionLabel = (fee: OverseasFee) => fee.direction === 'PAYABLE' ? '推送结算' : fee.collectionMode === 'BILL' ? '账单结算' : '余额/授信'
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' },
{ prop: 'feeItemName', label: '费用项目', minWidth: 150 },
...(props.direction === 'PAYABLE' ? [{ prop: 'supplierName', label: '供应商', minWidth: 180 }] : []),
{ key: 'amount', label: '金额', width: 150, align: 'right', formatter: (row: OverseasFee) => feeMoney(row.originalAmount, row.originalCurrency) },
{ key: 'settlementAmount', label: '结算金额(CNY)', width: 145, align: 'right', formatter: (row: OverseasFee) => feeMoney(row.amount) },
{ prop: 'collectionMode', label: '结算方式', width: 110, formatter: collectionLabel },
{ prop: 'reviewStatus', label: '审核状态', width: 100, slotName: 'reviewStatus' },
{ prop: 'reviewerName', label: '审核人', width: 120, formatter: (row: OverseasFee) => row.reviewerName || row.reviewerId || '—' },
{ prop: 'reviewedAt', label: '审核时间', width: 160, formatter: (row: OverseasFee) => feeTime(row.reviewedAt) },
{ key: 'chargeStatus', label: '费用状态', width: 120, formatter: feeProcessingLabel },
{ prop: 'businessDate', label: '费用日期', width: 110 },
])
let loadSequence = 0, detailSequence = 0, itemsSequence = 0
const statusRefresh = useFeeStatusRefresh(rows, () => load(true), computed(() => !error.value))
async function load(background = false) {
if (!background) statusRefresh.reset()
const current = ++loadSequence; if (!background) loading.value = true; error.value = null
try {
const data = await listBusinessOverseasFees(businesses.value, { page: page.value, size: pageSize.value }, props.direction)
if (current !== loadSequence) return
if (page.value > 1 && page.value > data.pages) { page.value = Math.max(1, data.pages); return }
rows.value = data.records.filter(belongsToBusiness); total.value = data.total
const latestDetail = rows.value.find(row => row.sourceFeeId === detail.value?.sourceFeeId)
if (latestDetail) detail.value = latestDetail
} catch (failure) { if (current === loadSequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(failure, '费用列表加载失败') } }
finally { if (current === loadSequence) loading.value = false }
}
async function loadItems() {
const current = ++itemsSequence; itemsLoading.value = true; itemsError.value = null
try {
const result = await listOverseasFeeItems(props.direction)
if (current !== itemsSequence) return false
items.value = result
return true
} catch (failure) { if (current === itemsSequence) itemsError.value = getApiErrorDetails(failure, '费用项目加载失败'); return false }
finally { if (current === itemsSequence) itemsLoading.value = false }
}
async function saved() { emit('changed'); await load() }
async function freshFee(row: OverseasFee) {
const current = ++detailSequence; detailLoading.value = row.sourceFeeId
try { const data = await getOverseasFee(row.sourceFeeId); return current === detailSequence ? data : null }
catch (failure) { ElMessage.error(getApiErrorDetails(failure, '费用详情加载失败').message); return null }
finally { if (current === detailSequence) detailLoading.value = '' }
}
async function create() { if (!canEdit.value || !props.canCreate || props.direction === 'PAYABLE') return; if (!await loadItems() || !canEdit.value || !props.canCreate) return; editFee.value = null; editorOpen.value = true }
async function handleAction(row: OverseasFee, action: string) {
if (detailLoading.value || saving.value) return
const current = await freshFee(row)
if (!current) return
if (!belongsToBusiness(current)) { ElMessage.warning('费用所属单据已变化,请重新查询'); await load(); return }
if (action === 'DETAIL') { detail.value = current; detailOpen.value = true; return }
if (!rowActions(current).some(item => item.key === action)) {
ElMessage.warning('费用状态已更新,请重新查询'); await load(); return
}
detailOpen.value = false
if (current.storageRent && ['REVIEW', 'ADJUST_RENT'].includes(action)) { rentFee.value = current; rentAction.value = action; rentOpen.value = true; return }
if (action === 'ADJUST') { if (!await loadItems() || !canEdit.value || !belongsToBusiness(current)) return; editFee.value = current; editorOpen.value = true; return }
actionFee.value = current; actionForm.action = action
actionForm.reason = ''; actionForm.requestKey = ''; actionForm.requestFingerprint = ''; actionError.value = null; actionOpen.value = true
}
async function confirmAction() {
const fee = actionFee.value, action = actionForm.action
if (!fee || saving.value || !belongsToBusiness(fee)) return
if (action === 'REVIEW' || !rowActions(fee).some(item => item.key === action)) return
if (action === 'VOID' && !actionForm.reason.trim()) { ElMessage.warning('请填写原因'); return }
saving.value = true; actionError.value = null
try {
const body = action === 'VOID' ? { expectedVersion: fee.sourceVersion, reason: actionForm.reason.trim() }
: ['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action) ? { expectedVersion: fee.sourceVersion, eventId: fee.deliveryEventId } : {}
const fingerprint = JSON.stringify(body)
if (actionForm.requestFingerprint !== fingerprint || !actionForm.requestKey) { actionForm.requestFingerprint = fingerprint; actionForm.requestKey = crypto.randomUUID() }
const result = await actOnOverseasFee(fee.sourceFeeId, action, { ...body, idempotencyKey: actionForm.requestKey })
if (detail.value?.sourceFeeId === result.sourceFeeId) detail.value = result
actionOpen.value = false; ElMessage.success('操作已提交'); emit('changed'); await load()
} catch (failure) { actionError.value = getApiErrorDetails(failure, '费用操作失败') }
finally { saving.value = false }
}
async function submitReview(decision: 'APPROVE' | 'REJECT') {
const fee = actionFee.value
if (!fee || saving.value || !belongsToBusiness(fee)) return
if (!rowActions(fee).some(item => item.key === 'REVIEW') || !fee.allowedActions.includes(decision)) return
if (decision === 'REJECT' && !actionForm.reason.trim()) { ElMessage.warning('请填写驳回原因'); return }
const body = { decision, reason: actionForm.reason.trim() || undefined, expectedVersion: fee.sourceVersion }
const fingerprint = JSON.stringify(body)
if (actionForm.requestFingerprint !== fingerprint || !actionForm.requestKey) {
actionForm.requestFingerprint = fingerprint; actionForm.requestKey = crypto.randomUUID()
}
saving.value = true; actionError.value = null
try {
const result = await actOnOverseasFee(fee.sourceFeeId, decision, { ...body, idempotencyKey: actionForm.requestKey })
actionFee.value = result
actionOpen.value = false
ElMessage.success(decision === 'APPROVE' ? '费用审核通过' : '费用已驳回')
emit('changed'); await load()
} catch (failure) { actionError.value = getApiErrorDetails(failure, '费用审核失败') }
finally { saving.value = false }
}
watch([page, pageSize], () => void load())
watch(businessKey, () => {
loadSequence++; detailSequence++; itemsSequence++; detailLoading.value = ''; itemsLoading.value = false
rows.value = []; total.value = 0; detail.value = null; actionFee.value = null; rentOpen.value = false; rentFee.value = null
editorOpen.value = false; detailOpen.value = false; actionOpen.value = false; itemsError.value = null
if (page.value === 1) void load(); else page.value = 1
}, { immediate: true })
watch(canEdit, allowed => { if (!allowed) editorOpen.value = false })
watch(() => actionFee.value && rowActions(actionFee.value).some(item => item.key === actionForm.action), allowed => { if (!allowed && !saving.value) actionOpen.value = false })
defineExpose({ refresh: load })
</script>
<template>
<section v-show="!hideWhenEmpty || !!error || total > 0" class="business-order-fees admin-density admin-workbench" :aria-label="heading">
<h3 v-if="heading" class="business-fee-heading">{{ heading }}</h3>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" appearance="integrated" :preference-key="`business-order-fees-${direction.toLowerCase()}`" row-key="sourceFeeId" empty-text="暂无费用明细">
<template #toolbar-actions>
<slot name="toolbar-actions">
<el-button v-if="canEdit && canCreate && direction === 'RECEIVABLE'" type="primary" :loading="itemsLoading" @click="create"><Plus :size="16" />新增费用</el-button>
</slot>
</template>
<template #notices>
<LoadErrorState v-if="error" title="费用列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="itemsError" title="费用项目加载失败" :message="itemsError.message" :loading="itemsLoading" @retry="loadItems"><template #details><ApiErrorMeta :details="itemsError" /></template></LoadErrorState>
</template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as OverseasFee)" @action="handleAction(row as OverseasFee, $event)" /></template>
<template #reviewStatus="{ row }"><el-tag :type="(row as OverseasFee).reviewStatus === 'APPROVED' ? 'success' : (row as OverseasFee).reviewStatus === 'REJECTED' ? 'danger' : 'info'">{{ feeReviewLabel(row as OverseasFee) }}</el-tag></template>
<template #pagination-left>共 {{ total }} 条费用</template>
</DataTable>
<StorageRentFeeActions v-model="rentOpen" :fee="rentFee" :action="rentAction" :readonly="readonly" @saved="saved" />
<OverseasFeeEditor v-model="editorOpen" :fee="editFee" :items="items" :business="editorBusiness" @saved="saved" />
<AppDialog append-to-body v-model="detailOpen" title="费用详情" width="860px" class="admin-density-dialog admin-workbench is-dialog" :show-confirm="false" cancel-text="关闭" fullscreenable>
<template v-if="detail">
<el-descriptions :column="2" border class="density-descriptions overseas-fee-descriptions">
<el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item>
<el-descriptions-item v-if="detail.feeRemark" label="费用说明" :span="2">{{ detail.feeRemark }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ feeMoney(detail.originalAmount, detail.originalCurrency) }}</el-descriptions-item><el-descriptions-item label="结算方式">{{ collectionLabel(detail) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '—' }}</el-descriptions-item>
<el-descriptions-item v-if="detail.originalCurrency !== detail.currency" label="结算金额">{{ feeMoney(detail.amount, detail.currency) }}</el-descriptions-item><el-descriptions-item v-if="detail.originalCurrency !== detail.currency" label="结算汇率">{{ detail.exchangeRate || '—' }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item>
<el-descriptions-item label="审核状态">{{ feeReviewLabel(detail) }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ feeProcessingLabel(detail) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.reviewerName || detail.reviewerId" label="审核人">{{ detail.reviewerName || detail.reviewerId }}</el-descriptions-item><el-descriptions-item v-if="detail.reviewedAt" label="审核时间">{{ feeTime(detail.reviewedAt) }}</el-descriptions-item>
<el-descriptions-item label="费用日期">{{ detail.businessDate }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ feeTime(detail.createdAt) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.periodStart || detail.periodEnd" label="计费期间" :span="2">{{ detail.periodStart }} — {{ detail.periodEnd }}</el-descriptions-item>
<el-descriptions-item v-if="detail.reviewReason" label="审核意见" :span="2">{{ detail.reviewReason }}</el-descriptions-item>
<el-descriptions-item v-if="detail.walletErrorMessage" :label="feeFundingErrorLabel(detail)" :span="2">{{ detail.walletErrorMessage }}<span v-if="detail.sourceStatus === 'VOIDED'">(此费用已作废,无需继续审核)</span></el-descriptions-item>
</el-descriptions>
<StorageRentFeeDetails :fee="detail" />
<OverseasFeeRetryHistory v-if="detailOpen" :fee-id="detail.sourceFeeId" :refresh-key="[detail.deliveryStatus, detail.deliveryErrorMessage, detail.walletStatus].join('|')" />
</template>
</AppDialog>
<AppDialog append-to-body v-model="actionOpen" :title="actionTitle" width="520px" class="admin-density-dialog admin-workbench is-dialog" :confirm-text="actionConfirmText" :confirm-type="actionConfirmType" :confirm-loading="saving" @confirm="confirmAction">
<el-descriptions v-if="actionFee" :column="1" border class="density-descriptions"><el-descriptions-item label="业务单号">{{ actionFee.businessNo }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ actionFee.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ feeMoney(actionFee.originalAmount, actionFee.originalCurrency) }}</el-descriptions-item><el-descriptions-item v-if="actionFee.feeRemark" label="费用说明">{{ actionFee.feeRemark }}</el-descriptions-item></el-descriptions>
<el-form v-if="actionForm.action === 'REVIEW' || actionForm.action === 'VOID'" label-position="top" class="fee-action-reason density-form" :disabled="saving"><el-form-item :label="actionForm.action === 'REVIEW' ? '驳回原因(驳回时必填)' : '原因'" :required="actionForm.action === 'VOID'"><el-input v-model="actionForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="actionForm.action === 'REVIEW' ? '审核通过可不填,驳回时请填写原因' : ''" /></el-form-item></el-form>
<el-alert v-if="actionForm.action === 'REVIEW' && actionFee?.direction === 'RECEIVABLE' && actionFee.businessType === 'LASTLEG_ORDER' && actionFee.collectionMode === 'BILL'" title="请核对原报价,已含费用请勿重复收取。" type="warning" :closable="false" />
<el-alert v-if="actionError" :title="actionError.message" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert>
<template #footer="{ close, submitting }">
<div v-if="actionForm.action === 'REVIEW'" class="app-dialog__footer">
<el-button v-if="actionFee?.allowedActions.includes('REJECT')" type="danger" :disabled="submitting || !actionForm.reason.trim()" @click="submitReview('REJECT')">驳回</el-button>
<el-button v-if="actionFee?.allowedActions.includes('APPROVE')" type="primary" :loading="submitting" :disabled="submitting" @click="submitReview('APPROVE')">审核通过</el-button>
</div>
<div v-else class="app-dialog__footer">
<el-button :disabled="submitting" @click="close">取消</el-button>
<el-button :type="actionConfirmType" :loading="submitting" @click="confirmAction">{{ actionConfirmText }}</el-button>
</div>
</template>
</AppDialog>
</section>
</template>
<style scoped>
.fee-action-reason { margin-top: 16px; }
.business-fee-heading { margin: var(--admin-space-3) 0; font-size: var(--admin-font-section); }
</style>
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { actOnOverseasFee, createOverseasFee, listBusinessFeeOptions, listBusinessOverseasFees, type BusinessFeeOption, type OverseasFee, type SaveOverseasFee } from '../api/overseasFees'
import { businessLabels, feePermissions, sameProjectFees, sameProjectFeeSummary } from '../overseasFeePresentation'
export interface FeeItemOption { feeCode: string; feeTypeName: string; defaultPrice: number | null }
const props = defineProps<{ fee: OverseasFee | null; items: FeeItemOption[]; business?: BusinessFeeOption }>()
const access = useAccessPreviewStore()
const open = defineModel<boolean>({ required: true })
const emit = defineEmits<{ saved: [] }>()
const saving = ref(false), optionsLoading = ref(false), options = ref<BusinessFeeOption[]>([]), formRef = ref<FormInstance>()
const error = ref<ApiErrorDetails | null>(null)
const today = () => new Date().toLocaleDateString('sv-SE')
const form = reactive({ businessType: 'WAREHOUSE_TRANSFER', businessId: '', feeItemCode: '', feeRemark: '', settlementMode: '' as '' | 'BILL' | 'WALLET', currency: 'USD' as 'CNY' | 'USD', amount: 0, businessDate: today() })
const existingFees = ref<OverseasFee[]>([]), existingLoading = ref(false), existingError = ref('')
const duplicates = computed(() => sameProjectFees(existingFees.value, form.feeItemCode, props.fee?.direction || 'RECEIVABLE', props.fee?.sourceFeeId))
const title = computed(() => props.fee ? '调整费用' : '新增费用')
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'overseas-fee-editor', label: title, isOpen: open, model: form, requiredPermission: computed(() => feePermissions.edit) })
const rules: FormRules<typeof form> = {
businessType: [{ required: true, message: '请选择业务类型', trigger: 'change' }],
businessId: [{ required: true, message: '请选择业务订单', trigger: 'change' }],
feeItemCode: [{ required: true, message: '请选择费用项目', trigger: 'change' }],
amount: [{ validator: (_rule, value: number, callback) => callback(Number.isFinite(value) && value > 0 ? undefined : new Error('金额须大于0')), trigger: 'blur' }],
}
let optionSequence = 0
let savedFingerprint = '', requestKey = ''
let openedBusinessKey = ''
const businessKey = () => props.business ? `${props.business.businessType}:${props.business.businessId}:${props.business.customerId}` : ''
const automaticRentCode = 'FEE_TYPE_077'
const manualItems = computed(() => props.items.filter(item => item.feeCode !== automaticRentCode))
const hasExistingItem = computed(() => !!props.fee && manualItems.value.some(item => item.feeCode === props.fee?.feeItemCode))
async function loadOptions(keyword = '') {
if (props.business) return
const sequence = ++optionSequence
optionsLoading.value = true
try {
const result = await listBusinessFeeOptions({ businessType: form.businessType, keyword })
if (sequence === optionSequence) {
const current = selected.value
options.value = current && !result.some(row => row.businessId === current.businessId) ? [current, ...result] : result
}
} catch (failure) { if (sequence === optionSequence) error.value = getApiErrorDetails(failure, '业务订单加载失败') }
finally { if (sequence === optionSequence) optionsLoading.value = false }
}
function changeBusinessType() { form.businessId = ''; options.value = []; void loadOptions() }
const selected = computed(() => props.business || options.value.find(row => row.businessId === form.businessId))
const selectedItem = computed(() => manualItems.value.find(item => item.feeCode === form.feeItemCode))
let existingSequence = 0
async function loadExistingFees() {
const sequence = ++existingSequence
existingFees.value = []; existingError.value = ''; existingLoading.value = false
if (!open.value || !selected.value) return
existingLoading.value = true
try {
const result = await listBusinessOverseasFees(selected.value, { page: 1, size: Number.MAX_SAFE_INTEGER }, props.fee?.direction || 'RECEIVABLE')
if (sequence === existingSequence) existingFees.value = result.records
} catch (failure) { if (sequence === existingSequence) existingError.value = getApiErrorDetails(failure, '已有费用加载失败').message }
finally { if (sequence === existingSequence) existingLoading.value = false }
}
watch([open, selected], loadExistingFees)
watch(open, async value => {
if (!value) { optionSequence++; optionsLoading.value = false; return }
savedFingerprint = ''; requestKey = ''
openedBusinessKey = businessKey()
error.value = null
const fee = props.fee
Object.assign(form, { businessType: props.business?.businessType || fee?.businessType || 'WAREHOUSE_TRANSFER', businessId: props.business?.businessId || fee?.businessId || '', feeItemCode: fee?.feeItemCode || '', feeRemark: fee?.feeRemark || '', settlementMode: fee?.direction === 'RECEIVABLE' && (fee?.settlementMode === 'BILL' || fee?.settlementMode === 'WALLET') ? fee.settlementMode : '', currency: fee?.originalCurrency || 'USD', amount: Number(fee?.originalAmount || 0), businessDate: fee?.businessDate || today() })
options.value = fee ? [{ businessId: fee.businessId || '', businessType: fee.businessType, businessNo: fee.businessNo, customerId: fee.customerId, customerName: fee.customerName, orderId: fee.orderId, warehouseId: fee.warehouseId }] : []
captureBaseline()
formRef.value?.clearValidate()
if (!fee && !props.business) await loadOptions()
})
const sourceTypes: Record<string, string> = {
LASTLEG_ORDER: 'OVERSEAS_ORDER_FEE', WAREHOUSE_TRANSFER: 'OVERSEAS_WAREHOUSE_FEE', RETURN_ORDER: 'OVERSEAS_WAREHOUSE_FEE',
DISCARD_ORDER: 'OVERSEAS_WAREHOUSE_FEE', RELABEL_ORDER: 'OVERSEAS_RELABEL_FEE', US_TRUCK: 'OVERSEAS_TRUCK_FEE',
LOCAL_LOOSEBOARD: 'OVERSEAS_LOOSEBOARD_FEE',
OVERSEAS_INBOUND: 'OVERSEAS_INBOUND_FEE', DELIVERY_ORDER: 'OVERSEAS_DELIVERY_FEE',
}
const selectableTypes = Object.keys(sourceTypes)
async function save() {
if (!access.can(feePermissions.edit) || saving.value || optionsLoading.value || !await formRef.value?.validate().catch(() => false)) return
if (form.feeItemCode === automaticRentCode) { ElMessage.error('仓租自动计算,请勿手动添加'); return }
const order = selected.value
if (!order) { ElMessage.error('请重新选择业务订单'); return }
if (existingLoading.value || existingError.value) { ElMessage.warning(existingError.value || '正在核对已有费用'); return }
if (duplicates.value.length && !form.feeRemark.trim()) { ElMessage.warning('此单已有相同费用项目,请填写费用说明后继续添加'); return }
const wrongFee = props.business && props.fee && (props.fee.businessId !== props.business.businessId || props.fee.businessType !== props.business.businessType || props.fee.customerId !== props.business.customerId)
if (openedBusinessKey !== businessKey() || wrongFee || (props.business && (form.businessId !== props.business.businessId || form.businessType !== props.business.businessType))) {
ElMessage.error('单据已变化,请关闭后重新添加费用'); return
}
if (!props.fee && !sourceTypes[order.businessType]) { ElMessage.error('请通过本业务的费用流程添加'); return }
const item = selectedItem.value
if (!item && !props.fee) { ElMessage.error('请重新选择费用项目'); return }
saving.value = true; error.value = null
try {
const payload: SaveOverseasFee = {
...order, sourceType: props.fee?.sourceType || sourceTypes[order.businessType]!,
feeItemCode: form.feeItemCode, feeItemName: item?.feeTypeName || props.fee!.feeItemName, direction: props.fee?.direction || 'RECEIVABLE',
businessDate: form.businessDate, currency: form.currency, amount: form.amount, feeRemark: form.feeRemark.trim() || undefined,
...(form.settlementMode ? { settlementMode: form.settlementMode } : {}),
...(props.fee?.direction === 'PAYABLE' ? {
supplierId: props.fee.supplierId, supplierName: props.fee.supplierName, supplierCategory: props.fee.supplierCategory,
settlementMode: props.fee.settlementMode, paidAt: props.fee.paidAt, paymentMethod: props.fee.paymentMethod,
paymentReferenceNo: props.fee.paymentReferenceNo, paymentVoucherFileIds: props.fee.paymentVoucherFileIds,
} : {}),
}
const fingerprint = JSON.stringify(payload)
if (fingerprint !== savedFingerprint || !requestKey) { savedFingerprint = fingerprint; requestKey = crypto.randomUUID() }
const idempotencyKey = requestKey
if (props.fee) await actOnOverseasFee(props.fee.sourceFeeId, 'ADJUST', { expectedVersion: props.fee.sourceVersion, fee: payload, idempotencyKey })
else await createOverseasFee(payload, idempotencyKey)
captureBaseline(); open.value = false; ElMessage.success(props.fee ? '费用已调整' : '费用已创建'); emit('saved')
} catch (failure) { error.value = getApiErrorDetails(failure, '费用保存失败') }
finally { saving.value = false }
}
</script>
<template>
<AppDialog append-to-body v-model="open" :title="title" width="680px" class="admin-density-dialog admin-workbench is-dialog" confirm-text="保存" :confirm-disabled="optionsLoading || !access.can(feePermissions.edit)" :confirm-loading="saving" fullscreenable @confirm="save">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="density-form" :disabled="saving">
<div class="fee-editor-grid">
<el-form-item v-if="!business" label="业务类型" prop="businessType"><el-select v-model="form.businessType" :disabled="!!fee" @change="changeBusinessType"><el-option v-for="type in selectableTypes" :key="type" :value="type" :label="businessLabels[type]" /></el-select></el-form-item>
<el-form-item v-if="!business" label="业务订单" prop="businessId"><el-select v-model="form.businessId" filterable remote :remote-method="loadOptions" :loading="optionsLoading" :disabled="!!fee" placeholder="搜索订单号"><el-option v-for="row in options" :key="row.businessId" :value="row.businessId" :label="`${row.businessNo} · ${row.customerName}`" /></el-select></el-form-item>
<el-form-item label="费用项目" prop="feeItemCode"><el-select v-model="form.feeItemCode" filterable><el-option v-if="fee && fee.feeItemCode !== automaticRentCode && !hasExistingItem" :value="fee.feeItemCode" :label="fee.feeItemName" /><el-option v-for="item in manualItems" :key="item.feeCode" :value="item.feeCode" :label="item.feeTypeName" /></el-select></el-form-item>
<el-form-item label="币种"><el-select v-model="form.currency"><el-option label="美元 USD" value="USD" /><el-option label="人民币 CNY" value="CNY" /></el-select></el-form-item>
<el-form-item label="金额" prop="amount"><el-input-number v-model="form.amount" :min="0" :max="999999999.99" :precision="2" :controls="false" /></el-form-item>
<el-form-item v-if="(props.fee?.direction || 'RECEIVABLE') === 'RECEIVABLE'" label="结算方式"><el-select v-model="form.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item>
<el-form-item label="费用说明" class="fee-remark" :required="duplicates.length > 0"><el-input v-model="form.feeRemark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="补收或重复作业时,请说明原因" /></el-form-item>
</div>
<div v-if="duplicates.length" class="fee-existing" role="status"><span>此单已有相同费用,确认本次为新增作业后可继续添加。</span><p v-for="row in duplicates" :key="row.sourceFeeId">{{ sameProjectFeeSummary(row) }}</p></div>
<div v-if="existingError" class="fee-existing-error">{{ existingError }} <el-button link type="primary" @click="loadExistingFees">重新加载</el-button></div>
</el-form>
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
</AppDialog>
</template>
<style scoped>
.fee-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
.fee-editor-grid :deep(.el-select), .fee-editor-grid :deep(.el-date-editor), .fee-editor-grid :deep(.el-input-number) { width: 100%; }
.fee-remark { grid-column: 1 / -1; }
.fee-existing { color: var(--cc-text-secondary, #606266); font-size: 13px; margin-bottom: 12px; }
.fee-existing p { margin: 4px 0; }
.fee-existing-error { color: var(--cc-danger, #f56c6c); }
</style>
@@ -0,0 +1,65 @@
import ElementPlus from 'element-plus'
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import { listOverseasFeeRetries } from '../api/overseasFees'
import OverseasFeeRetryHistory from './OverseasFeeRetryHistory.vue'
vi.mock('../api/overseasFees', () => ({ listOverseasFeeRetries: vi.fn() }))
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.mocked(listOverseasFeeRetries).mockReset().mockResolvedValue([])
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
})
afterEach(() => vi.unstubAllGlobals())
describe('费用重试记录', () => {
it('shows every retry with its actual operator, timestamp, result and failure reason', async () => {
vi.mocked(listOverseasFeeRetries).mockResolvedValue([
{ requestId: 'retry-2', requestedAt: '2026-10-23T11:22:33', operatorName: '李四 [2]', action: '重试推送', result: 'SUCCESS', errorMessage: null },
{ requestId: 'retry-1', requestedAt: '2026-10-23T10:20:30', operatorName: '张三 [1]', action: '重试扣费', result: 'FAILED', errorMessage: '可用额度不足' },
])
const wrapper = mount(OverseasFeeRetryHistory, { props: { feeId: '9007199254740993' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listOverseasFeeRetries).toHaveBeenCalledWith('9007199254740993')
const text = wrapper.text()
for (const value of ['李四 [2]', '张三 [1]', '2026-10-23 11:22:33', '2026-10-23 10:20:30', '重试成功', '重试后仍失败', '可用额度不足']) expect(text).toContain(value)
})
it('keeps request failures visible and retries without losing the fee identity', async () => {
vi.mocked(listOverseasFeeRetries).mockRejectedValueOnce(new ApiResponseError('查询暂不可用', 'UNAVAILABLE', 503))
const wrapper = mount(OverseasFeeRetryHistory, { props: { feeId: 'fee-1' }, global: { plugins: [ElementPlus] } })
await flushPromises(); expect(wrapper.text()).toContain('查询暂不可用')
await wrapper.get('button').trigger('click'); await flushPromises()
expect(listOverseasFeeRetries).toHaveBeenLastCalledWith('fee-1'); expect(wrapper.text()).toContain('暂无重试记录')
})
it('explains the failed settlement step in Chinese and keeps full technical details collapsed', async () => {
const raw = 'EventDeliveryFailure: Consumer rejected event: code=S0500, message=Internal server error, requestId=original-request, traceId=original-trace'
vi.mocked(listOverseasFeeRetries).mockResolvedValue([
{ requestId: 'r2', requestedAt: '', operatorName: '李四', action: '重试推送', result: 'SUCCESS', errorMessage: null },
{ requestId: 'r1', requestedAt: '', operatorName: '张三', action: '重试推送', result: 'FAILED', errorMessage: raw },
])
const wrapper = mount(OverseasFeeRetryHistory, { props: { feeId: 'fee-1' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('推送结算失败:财务系统处理异常,请联系管理员排查后重试。')
expect(wrapper.findAll('details')).toHaveLength(1)
expect(wrapper.get('details').attributes('open')).toBeUndefined()
expect(wrapper.get('summary').text()).toBe('查看技术详情')
expect(wrapper.get('pre').text()).toBe(raw)
})
it('ignores a late response from another fee', async () => {
let resolve!: (rows: Awaited<ReturnType<typeof listOverseasFeeRetries>>) => void
vi.mocked(listOverseasFeeRetries).mockReturnValueOnce(new Promise(yes => { resolve = yes }))
const wrapper = mount(OverseasFeeRetryHistory, { props: { feeId: 'old-fee' }, global: { plugins: [ElementPlus] } })
await wrapper.setProps({ feeId: 'new-fee' }); await flushPromises()
resolve([{ requestId: 'old', requestedAt: '', operatorName: '旧费用操作人', action: '重试', result: 'SUCCESS', errorMessage: null }]); await flushPromises()
expect(wrapper.text()).not.toContain('旧费用操作人')
})
it('refreshes the open history when the actual delivery result changes', async () => {
const record = { requestId: 'r1', requestedAt: '', operatorName: '张三', action: '重试推送', result: 'SUBMITTED', errorMessage: null } as const
vi.mocked(listOverseasFeeRetries).mockResolvedValueOnce([record]).mockResolvedValueOnce([{ ...record, result: 'SUCCESS' }])
const wrapper = mount(OverseasFeeRetryHistory, { props: { feeId: 'fee-1', refreshKey: 'PENDING' }, global: { plugins: [ElementPlus] } })
await flushPromises(); expect(wrapper.text()).toContain('已提交重试')
await wrapper.setProps({ refreshKey: 'PUBLISHED' }); await flushPromises()
expect(wrapper.text()).toContain('重试成功'); expect(wrapper.text()).not.toContain('已提交重试')
expect(listOverseasFeeRetries).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listOverseasFeeRetries, type OverseasFeeRetryRecord } from '../api/overseasFees'
import { feeTime, retryResultLabels } from '../overseasFeePresentation'
import { retryFailureReason } from '../overseasFeeRetryPresentation'
const props = defineProps<{ feeId: string; refreshKey?: string }>()
const rows = ref<OverseasFeeRetryRecord[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const columns: DataTableColumn[] = [
{ prop: 'requestedAt', label: '重试时间', width: 160, formatter: (row: OverseasFeeRetryRecord) => feeTime(row.requestedAt) },
{ prop: 'operatorName', label: '操作人', width: 170 },
{ prop: 'action', label: '重试操作', width: 100 },
{ prop: 'result', label: '操作结果', width: 110, formatter: (row: OverseasFeeRetryRecord) => retryResultLabels[row.result] || '—' },
{ key: 'failureReason', label: '失败原因', minWidth: 230, slotName: 'failureReason', singleLine: false, showOverflowTooltip: false },
]
let sequence = 0
async function load(background = false) {
const current = ++sequence; error.value = null
if (!background) { rows.value = []; loading.value = true }
try { const result = await listOverseasFeeRetries(props.feeId); if (current === sequence) rows.value = result }
catch (failure) { if (current === sequence) error.value = getApiErrorDetails(failure, '重试记录加载失败') }
finally { if (current === sequence) loading.value = false }
}
watch(() => props.feeId, () => load(), { immediate: true })
// 列表取得新的投递结果时同步刷新记录,不要求关闭再打开详情。
watch(() => props.refreshKey, () => load(true))
onBeforeUnmount(() => { sequence++ })
</script>
<template>
<section class="density-dialog-section" aria-label="重试记录">
<h3 class="density-dialog-section__title">重试记录</h3>
<LoadErrorState v-if="error" title="重试记录加载失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable v-else :data="rows" :columns="columns" :loading="loading" row-key="requestId" column-settings-mode="hide" empty-text="暂无重试记录">
<template #failureReason="{ row }">
<div class="retry-failure-reason">{{ retryFailureReason(row as OverseasFeeRetryRecord) }}</div>
<details v-if="row.result === 'FAILED' && row.errorMessage" class="retry-failure-details">
<summary>查看技术详情</summary>
<pre>{{ row.errorMessage }}</pre>
</details>
</template>
</DataTable>
</section>
</template>
<style scoped>
.retry-failure-reason { white-space: normal; overflow-wrap: anywhere; }
.retry-failure-details { margin-top: 4px; }
.retry-failure-details summary { color: var(--el-color-primary); cursor: pointer; width: fit-content; }
.retry-failure-details pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; color: var(--el-text-color-secondary); }
</style>
@@ -4,6 +4,8 @@ import { computed } from 'vue'
import type { ProcurementCommand } from '../api/procurementCommands'
import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
import { allowedDecisions, decisionLabels, remainingLabels, type ControlForm } from '../procurementControlForm'
import { regionText } from '../procurementDirection'
const t = (text: string) => regionText(text, props.detail.customerView.order.procurementDirection)
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[] }>()
const form = defineModel<ControlForm>({ required: true })
const proofs = computed(() => props.files.filter(file => file.category === 'EXCEPTION'))
@@ -14,7 +16,7 @@ const blockers = computed(() => props.detail.controls.notes.filter(note => note.
<template v-if="command === 'control-notes'">
<el-alert title="追加说明保留原购买、仓库、实收及交寄事实。需要阻断时,正式装箱、预估核对、核费及交寄须先处理异常。" type="info" :closable="false" />
<el-form-item label="关联事实类型" required><el-select v-model="form.targetType" placeholder="选择需要留痕的事实" @change="form.targetId = ''"><el-option value="PURCHASE" label="真实购买" /><el-option value="WAREHOUSE" label="实际收货仓" /><el-option value="RECEIPT" label="实收记录" /><el-option value="SHIPMENT" label="实际交寄记录" /></el-select></el-form-item>
<el-form-item v-if="form.targetType === 'RECEIPT'" label="关联实收" required><el-select v-model="form.targetId" placeholder="选择本单实收记录"><el-option v-for="row in detail.physical.receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}${row.active ? '' : '(已更正)'}`" /></el-select></el-form-item>
<el-form-item v-if="form.targetType === 'RECEIPT'" label="关联实收" required><el-select v-model="form.targetId" placeholder="选择本单实收记录"><el-option v-for="row in detail.physical.receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · ${t(row.leg === 'OVERSEAS' ? '海外' : '国内')}${row.active ? '' : '(已更正)'}`" /></el-select></el-form-item>
<el-form-item v-if="form.targetType === 'SHIPMENT'" label="关联实际交寄" required><el-select v-model="form.targetId" placeholder="选择本单实际交寄记录"><el-option v-for="row in detail.transport.shipments" :key="row.id" :value="row.id" :label="`${row.shipmentNo} · ${row.leg === 'OVERSEAS' ? '国际段' : '寄客户段'}`" /></el-select></el-form-item>
<dl v-if="form.targetType === 'WAREHOUSE'" class="density-fields"><div><dt>当前实际仓</dt><dd>{{ detail.customerView.order.actualWarehouseName || '尚无实际仓' }}</dd></div></dl>
<el-form-item label="是否阻断后续正式作业" required><el-radio-group v-model="form.blocking"><el-radio :value="true">需要阻断</el-radio><el-radio :value="false">仅追加说明</el-radio></el-radio-group></el-form-item>
@@ -7,6 +7,8 @@ import { chargeDraft, feeRetryPermission, refundAvailable, targetPreview, replac
import { money, decimal, feePurposes } from '../procurementPresentation'
import { commandKey } from '../procurementPending'
import ProcurementChargeFields from './ProcurementChargeFields.vue'
import { regionText } from '../procurementDirection'
const t = (text: string) => regionText(text, props.detail.customerView.order.procurementDirection)
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[]; can: (permission: string) => boolean }>()
const form = defineModel<FinanceForm>({ required: true })
const candidates = computed(() => props.detail.fees.filter(fee => {
@@ -34,8 +36,8 @@ function addOther() { form.value.otherDetermination = 'ITEMIZED'; const row = ch
<div class="admin-density">
<template v-if="command === 'charges/confirm'">
<el-alert v-if="detail.operations.some(row => row.action === 'CONFIRM_CHARGES')" title="本单已完成过核费,本次只引用已有费用版本;新增费用或重新核定请使用财务操作。" type="info" :closable="false" />
<el-alert title="一次确认实际国际运费、关税、国内运费和其他费用。正额确认后发起资金处理;确认成功不代表已扣款或可交寄。" type="info" :closable="false" />
<section v-for="(row, index) in form.fixed" :key="row.purpose" class="charge-section"><h3>{{ row.name }}</h3><ProcurementChargeFields v-model="form.fixed[index]" :detail="detail" :files="files" /></section>
<el-alert :title="t('一次确认实际国际运费、关税、国内运费和其他费用。正额确认后发起资金处理;确认成功不代表已扣款或可交寄。')" type="info" :closable="false" />
<section v-for="(row, index) in form.fixed" :key="row.purpose" class="charge-section"><h3>{{ row.purpose === 'DOMESTIC_FREIGHT' ? t('国内运费') : row.name }}</h3><ProcurementChargeFields v-model="form.fixed[index]" :detail="detail" :files="files" /></section>
<section class="charge-section"><h3>其他费用</h3><el-form-item label="其他费用适用情况" required><el-radio-group v-model="form.otherDetermination"><el-radio value="NOT_APPLICABLE" :disabled="detail.fees.some(row => row.active && row.purpose === 'OTHER')">不适用</el-radio><el-radio value="ITEMIZED">逐项核定</el-radio></el-radio-group></el-form-item>
<template v-if="form.otherDetermination === 'ITEMIZED'"><section v-for="(row, index) in form.others" :key="row.code" class="other-charge"><div class="charge-heading"><el-form-item label="费用名称" required><el-input v-model="row.name" :readonly="row.mode === 'EXISTING'" maxlength="128" placeholder="填写独立发生的费用名称" /></el-form-item><el-button v-if="row.mode === 'NEW'" class="density-action" data-tone="danger" @click="form.others.splice(index, 1)">移除新费用</el-button></div><ProcurementChargeFields v-model="form.others[index]" :detail="detail" :files="files" /></section><el-button v-if="!detail.operations.some(row => row.action === 'CONFIRM_CHARGES')" class="density-action" @click="addOther">增加其他费用</el-button></template>
</section>
@@ -55,7 +57,7 @@ function addOther() { form.value.otherDetermination = 'ITEMIZED'; const row = ch
<el-alert v-if="command === 'fees/terminate'" title="仅终止明确失败的费用版本;仍有有效冻结时先释放,释放成功后才可重新核定,不能将未决结果当作失败。" type="warning" :closable="false" />
<el-alert v-if="command === 'fees/release-estimate'" title="释放有效预估冻结不产生退款或实际收费,后续交寄须重新满足预估核对条件。" type="warning" :closable="false" />
<el-alert v-if="command === 'fees/retry'" title="仅重试已明确失败的原资金请求,金额与费用版本不改变;处理中请刷新结果。" type="info" :closable="false" />
<el-form-item label="原费用" required><el-select v-model="form.feeId" placeholder="选择本单符合操作条件的费用"><el-option v-for="fee in candidates" :key="fee.id" :value="fee.id" :label="`${fee.feeNo} · ${feePurposes[fee.purpose] || fee.feeItemName} · ${money(fee.amount, fee.currency)}`" /></el-select></el-form-item>
<el-form-item label="原费用" required><el-select v-model="form.feeId" placeholder="选择本单符合操作条件的费用"><el-option v-for="fee in candidates" :key="fee.id" :value="fee.id" :label="`${fee.feeNo} · ${t(feePurposes[fee.purpose] || fee.feeItemName)} · ${money(fee.amount, fee.currency)}`" /></el-select></el-form-item>
<dl v-if="selected" class="density-fields"><div><dt>原币金额</dt><dd>{{ money(selected.originalAmount, selected.originalCurrency) }}</dd></div><div><dt>原汇率及月份</dt><dd>{{ selected.exchangeRate }} / {{ selected.exchangeRateMonth }}</dd></div></dl>
<el-form-item v-if="['fees/correct-target', 'fees/replace-failed'].includes(command)" :label="`正确原币总额(${selected?.originalCurrency || '先选择费用'})`" required><el-input v-model="form.targetOriginalAmount" placeholder="填写原费用链正确总额,最多两位小数" /></el-form-item>
<el-form-item v-if="command === 'fees/refund'" label="本次退款金额(CNY)" required><el-input v-model="form.refundAmount" placeholder="填写本次正额退款金额" /></el-form-item>
@@ -9,7 +9,6 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import ProcurementNodeActions from './ProcurementNodeActions.vue'
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands'
import { readPendingProcurement } from '../procurementPending'
import type { PlatformProcurement } from '../procurementTypes'
vi.mock('../api/platformOverseasBusiness', () => ({ listOverseasPurchaseWarehouseOptions: vi.fn() }))
vi.mock('../api/procurementCommands', () => ({ submitProcurementCommand: vi.fn(), uploadProcurementFile: vi.fn() }))
@@ -41,6 +40,42 @@ beforeEach(() => {
vi.mocked(uploadProcurementFile).mockReset().mockResolvedValue({ id: '11', fileId: '101', category: 'PURCHASE', fileName: 'purchase.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' })
})
describe('procurement purchase node operations', () => {
it('switches to domestic warehouses, clears old selection and preserves editable direction after failure', async () => {
vi.mocked(listOverseasPurchaseWarehouseOptions).mockImplementation(async direction => direction === 'DOMESTIC_TO_OVERSEAS'
? [{ warehouseId: '30001', warehouseCode: 'SZX', warehouseName: '国内采购仓', countryCode: 'CN' }]
: [{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '海外采购仓', countryCode: 'US' }])
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper)
const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)!
field('采购仓').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001')
field('采购方向').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DOMESTIC_TO_OVERSEAS'); await flushPromises()
expect(listOverseasPurchaseWarehouseOptions).toHaveBeenLastCalledWith('DOMESTIC_TO_OVERSEAS')
expect(field('采购仓').findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('')
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled()
field('采购仓').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '30001')
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('connection lost'))
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ procurementDirection: 'DOMESTIC_TO_OVERSEAS', purchaseWarehouseId: '30001' }))
const original = vi.mocked(submitProcurementCommand).mock.calls[0]![3]
expect(sessionStorage.getItem(storageKey)).toBeNull()
expect(field('采购方向').findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('DOMESTIC_TO_OVERSEAS')
await confirm(wrapper)
const next = vi.mocked(submitProcurementCommand).mock.calls[1]![3]
expect(next.procurementDirection).toBe('DOMESTIC_TO_OVERSEAS')
expect(next.purchaseWarehouseId).toBe('30001')
expect(next.idempotencyKey).not.toBe(original.idempotencyKey)
})
it('ignores late warehouse responses from a previous direction', async () => {
let finishImport!: (rows: Awaited<ReturnType<typeof listOverseasPurchaseWarehouseOptions>>) => void
vi.mocked(listOverseasPurchaseWarehouseOptions).mockImplementation(direction => direction === 'DOMESTIC_TO_OVERSEAS'
? Promise.resolve([{ warehouseId: '30001', warehouseCode: 'SZX', warehouseName: '国内采购仓', countryCode: 'CN' }])
: new Promise(resolve => { finishImport = resolve }))
const wrapper = render(); const dialog = await open(wrapper)
dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '采购方向')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DOMESTIC_TO_OVERSEAS'); await flushPromises()
finishImport([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '过期海外候选', countryCode: 'US' }]); await flushPromises()
const warehouse = dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '采购仓')!.findComponent({ name: 'ElSelect' })
expect(warehouse.findAllComponents({ name: 'ElOption' }).map(item => item.props('value'))).toEqual(['30001'])
})
it('disables terminal physical changes and keeps the management evidence form open', async () => {
const value = fixture('MANAGE'); value.customerView.order.status = 'SHIPPED_TO_CUSTOMER'
value.purchaseFacts = { overseas_shipped_at: '2020-01-02', customer_shipped_at: '2020-01-03', actual_warehouse_id: '20' }
@@ -74,7 +109,7 @@ describe('procurement purchase node operations', () => {
expect(dialog.props('confirmDisabled')).toBe(false)
expect(submitProcurementCommand).not.toHaveBeenCalled()
})
it('saves and restores a separate correction while the immutable original request is pending, scoped to operator/customer/order', async () => {
it('keeps ordinary correction drafts scoped to operator/customer/order while ignoring old requests', async () => {
const value = fixture('FINANCE_ADJUST', false)
value.fees = [{ id: '11', feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', confirmationOrigin: 'BUSINESS', feeItemCode: 'SAMPLE_PAYMENT', feeItemName: '样品货款', feeVersion: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'PENDING', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }]
const original = JSON.stringify({ command: 'fees/correct-target', payload: { version: 3, idempotencyKey: 'original-request', feeId: '11', targetOriginalAmount: '11.00', reason: '原请求', voucherFileId: '501' } })
@@ -90,12 +125,11 @@ describe('procurement purchase node operations', () => {
expect(submitProcurementCommand).not.toHaveBeenCalled()
expect(sessionStorage.getItem(storageKey)).toBe(original)
expect(JSON.parse(sessionStorage.getItem(`${storageKey}:correction-draft`)!)).toMatchObject({ targetOriginalAmount: '12.00', feeId: '11', reason: '准备下一版本' })
expect(wrapper.findAll('button').find(button => button.text() === '核对并继续确认')!.attributes('disabled')).toBeDefined()
expect(wrapper.findAll('button').find(button => button.text() === '核对并继续确认')!.attributes('disabled')).toBeUndefined()
wrapper.unmount(); wrapper = render(value)
await click('恢复费用更正草稿')
expect((wrapper.findComponent(AppDialog).find('input[placeholder="填写原费用链正确总额,最多两位小数"]').element as HTMLInputElement).value).toBe('12.00')
await click('确认上次提交结果')
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'fees/correct-target', JSON.parse(original).payload)
expect(submitProcurementCommand).not.toHaveBeenCalled()
expect(sessionStorage.getItem(`${storageKey}:correction-draft`)).not.toBeNull()
const latest = structuredClone(value)
latest.actions[0].allowed = true; latest.customerView.order.version = 9
@@ -104,7 +138,7 @@ describe('procurement purchase node operations', () => {
await click('恢复费用更正草稿'); await click('核对并继续确认')
wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '本单凭证')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501')
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
expect(submitProcurementCommand).not.toHaveBeenCalled()
expect(wrapper.findComponent(AppDialog).text()).toContain('原实际费用及来源关联尚未完成')
latest.fees[0].walletStatus = 'SETTLED'; latest.fees[0].walletLedgerId = 'LEDGER-1'
latest.customerView.fees = [{ feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', name: '样品货款', version: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeNo: null, walletStatus: 'SETTLED', sourceLinked: true }]
@@ -230,8 +264,8 @@ describe('procurement purchase node operations', () => {
it('requires an authoritative warehouse on approval and sends the current order version', async () => {
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper)
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('请选择有效采购仓')
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001'); await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', { version: 3, idempotencyKey: expect.any(String), decision: 'APPROVE', remark: '已核对客户资料', purchaseWarehouseId: '20001' })
dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '采购仓')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001'); await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', { version: 3, idempotencyKey: expect.any(String), decision: 'APPROVE', procurementDirection: 'OVERSEAS_TO_DOMESTIC', remark: '已核对客户资料', purchaseWarehouseId: '20001' })
expect(wrapper.emitted('changed')).toHaveLength(1); expect(sessionStorage.getItem(storageKey)).toBeNull()
})
it('rejects without a warehouse and never substitutes an invented candidate', async () => {
@@ -251,22 +285,20 @@ describe('procurement purchase node operations', () => {
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'purchase', expect.objectContaining({ productName: '样品商品', specification: '黑色', quantity: 2, amount: '0.00', currency: 'USD', voucherFileId: '101', purchasedAt: '2026-10-08T10:00:00' }))
})
it('recovers an uncertain request after reload without changing its body or key', async () => {
it('unknown request leaves inputs editable and a later click submits current input', async () => {
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('timeout'))
let wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper, '客户取消'); await confirm(wrapper)
const original = vi.mocked(submitProcurementCommand).mock.calls[0][3]; expect(sessionStorage.getItem(storageKey)).not.toBeNull(); wrapper.unmount()
wrapper = render(fixture('CANCEL')); await flushPromises()
const retry = wrapper.findAll('button').find(button => button.text() === '确认上次提交结果')!; await retry.trigger('click'); await flushPromises()
expect(vi.mocked(submitProcurementCommand).mock.calls[1][3]).toEqual(original); expect(sessionStorage.getItem(storageKey)).toBeNull()
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper, '客户取消'); await confirm(wrapper)
const original = vi.mocked(submitProcurementCommand).mock.calls[0]![3]
expect(sessionStorage.getItem(storageKey)).toBeNull(); await explain(wrapper, '修改后的原因'); await confirm(wrapper)
const next = vi.mocked(submitProcurementCommand).mock.calls[1]![3]
expect(next.idempotencyKey).not.toBe(original.idempotencyKey); expect(next.reason).toBe('修改后的原因')
})
it('preserves an earlier uncertain financial or physical command when replay is rejected', async () => {
it('consecutive failures never retain a request or block another click', async () => {
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('timeout')).mockRejectedValueOnce(new ApiResponseError('资源正在处理中', 'A0409'))
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper)
const saved = sessionStorage.getItem(storageKey); expect(saved).not.toBeNull()
await confirm(wrapper)
expect(sessionStorage.getItem(storageKey)).toBe(saved)
expect(vi.mocked(submitProcurementCommand).mock.calls[1][3]).toEqual(vi.mocked(submitProcurementCommand).mock.calls[0][3])
expect(wrapper.emitted('changed')).toBeUndefined()
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper); await confirm(wrapper)
const calls = vi.mocked(submitProcurementCommand).mock.calls
expect(calls).toHaveLength(2); expect(calls[1]![3].idempotencyKey).not.toBe(calls[0]![3].idempotencyKey)
expect(sessionStorage.getItem(storageKey)).toBeNull(); expect(wrapper.emitted('changed')).toBeUndefined()
})
it('keeps a version conflict visible, releases the failed command and does not report success', async () => {
vi.mocked(submitProcurementCommand).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新', 'A0409'))
@@ -300,9 +332,9 @@ describe('procurement purchase node operations', () => {
wrapper.findComponent(AppDialog).vm.$emit('confirm'); wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
expect(submitProcurementCommand).toHaveBeenCalledTimes(1); complete(); await flushPromises()
})
it('rejects malformed recovery records instead of allowing a fresh competing operation', async () => {
it('old malformed request storage does not disable current actions', async () => {
sessionStorage.setItem(storageKey, '{bad')
const wrapper = render(); expect(wrapper.text()).toContain('上次操作记录无法读取'); expect(wrapper.find('.purchase-actions button').attributes('disabled')).toBeDefined()
expect(() => readPendingProcurement(JSON.stringify({ command: 'purchase', payload: { version: 3, idempotencyKey: 'a', amount: '-2' } }))).toThrow()
const wrapper = render(); expect(wrapper.text()).not.toContain('上次操作记录无法读取')
expect(wrapper.find('.purchase-actions button').attributes('disabled')).toBeUndefined()
})
})
@@ -11,7 +11,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
import { submitProcurementCommand, uploadProcurementFile, type ProcurementCommand, type ProcurementCommandBody } from '../api/procurementCommands'
import { commandKey, definitelyRejected, readPendingProcurement, type PendingProcurement } from '../procurementPending'
import { commandKey, type PendingProcurement } from '../procurementPending'
import { readCorrectionDraft, type ProcurementCorrectionDraft } from '../procurementCorrectionDraft'
import type { PlatformProcurement, ProcurementAction, ProcurementFile } from '../procurementTypes'
import type { OverseasPurchaseWarehouseOption } from '../types'
@@ -23,6 +23,7 @@ import ProcurementTransportFields from './ProcurementTransportFields.vue'
import { transportCommands, transportForm, buildTransport } from '../procurementTransportForm'
import ProcurementPhysicalFields from './ProcurementPhysicalFields.vue'
import { physicalCommands, physicalForm, buildPhysical, managementOptions, managementReason, receiptCorrectionReason, dispositionReason } from '../procurementPhysicalForm'
import { directionLabel, procurementDirections, regionText, type ProcurementDirection } from '../procurementDirection'
const props = defineProps<{ detail: PlatformProcurement; customerId: string }>()
const emit = defineEmits<{ changed: [] }>()
const auth = useAuthStore(); const access = useAccessPreviewStore()
@@ -38,7 +39,9 @@ const error = ref<ApiErrorDetails | null>(null); const storageError = ref(''); c
const editingDraft = ref(false); const correctionDraft = ref<ProcurementCorrectionDraft | null>(null); const draftError = ref('')
const warehouses = ref<OverseasPurchaseWarehouseOption[]>([]); const warehouseLoading = ref(false); const warehouseError = ref('')
const extraFiles = ref<ProcurementFile[]>([])
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
const form = reactive({ procurementDirection: (props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC') as ProcurementDirection, decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
const warehouseDirection = computed(() => command.value === 'review' ? form.procurementDirection : props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC')
const t = (text: string) => regionText(text, warehouseDirection.value)
const control = ref(controlForm(props.detail))
const finance = ref(financeForm(props.detail))
const financialCommand = ref<ProcurementCommand>('fees/correct-target')
@@ -50,7 +53,7 @@ const draftKey = computed(() => key.value ? `${key.value}:correction-draft` : ''
const canDraft = computed(() => !!key.value && access.can('platform:overseas-business:purchase:finance-adjust') && props.detail.fees.some(fee => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose)))
const commandPermissions: Record<ProcurementCommand, string> = { review: 'review', purchase: 'buy', 'purchase/confirm': 'buy', 'budget-authorizations': 'review', 'planned-warehouse': 'review', cancel: 'cancel', 'overseas-receipts': 'receive-overseas', 'domestic-receipts': 'receive-domestic', 'overseas-receipts/complete': 'receive-overseas', 'domestic-receipts/complete': 'receive-domestic', packing: 'pack', 'packing/confirm': 'pack', 'packing/withdraw': 'manage', dispositions: 'manage', 'receipts/correct': 'manage', 'dispositions/revoke': 'manage', labels: 'label', 'international-estimate': 'estimate', 'labels/apply': 'apply-label', 'overseas-shipment': 'ship-overseas', 'customer-shipment': 'ship-customer', 'domestic-repack': 'receive-domestic', 'charges/confirm': 'charge', 'fees/retry': 'charge', 'fees/terminate': 'finance-adjust', 'fees/release-estimate': 'finance-adjust', 'fees/correct-target': 'finance-adjust', 'fees/additional': 'finance-adjust', 'fees/replace-failed': 'finance-adjust', 'fees/refund': 'finance-adjust', 'control-notes': 'manage', 'control-notes/resolve': 'manage', 'actual-warehouse/correct': 'manage', 'exception-close': 'manage' }
const command = computed(() => editingDraft.value ? 'fees/correct-target' : pending.value?.command ?? (active.value?.key === 'MANAGE' ? manageCommand.value : active.value?.key === 'FINANCE_ADJUST' ? financialCommand.value : active.value ? routes[active.value.key] : undefined))
const manageOptions = computed(() => managementOptions.map(option => ({ ...option, reason: managementReason(option.value, props.detail) })))
const manageOptions = computed(() => managementOptions.map(option => ({ ...option, label: t(option.label), reason: managementReason(option.value, props.detail) ? t(managementReason(option.value, props.detail)!) : null })))
const managementBlock = computed(() => {
if (active.value?.key !== 'MANAGE' || !command.value || pending.value) return null
const reason = managementReason(command.value, props.detail)
@@ -75,18 +78,19 @@ const { captureBaseline } = useUnsavedWorkRegistration({ key: 'procurement-purch
watch(key, () => {
opened.value = false; pending.value = null; storageError.value = ''; extraFiles.value = []; active.value = null; editingDraft.value = false; correctionDraft.value = null; draftError.value = ''
if (!key.value) return
try { pending.value = readPendingProcurement(sessionStorage.getItem(key.value)) }
catch { storageError.value = '上次操作记录无法读取,请先联系管理员核对该订单的处理结果。' }
try { correctionDraft.value = readCorrectionDraft(sessionStorage.getItem(draftKey.value)) }
catch { draftError.value = '本浏览器的费用更正草稿无法读取,请核对后重新填写。' }
}, { immediate: true })
let warehouseGeneration = 0
async function loadWarehouses() {
if (warehouseLoading.value) return
warehouseLoading.value = true; warehouseError.value = ''
try { warehouses.value = await listOverseasPurchaseWarehouseOptions() }
catch { warehouseError.value = '采购仓候选加载失败,请重试'; warehouses.value = [] }
finally { warehouseLoading.value = false }
const current = ++warehouseGeneration
const direction = warehouseDirection.value
warehouseLoading.value = true; warehouseError.value = ''; warehouses.value = []
try { const result = await listOverseasPurchaseWarehouseOptions(direction); if (current === warehouseGeneration) warehouses.value = result }
catch { if (current === warehouseGeneration) { warehouseError.value = '采购仓候选加载失败,请重试'; warehouses.value = [] } }
finally { if (current === warehouseGeneration) warehouseLoading.value = false }
}
watch(warehouseDirection, () => { form.warehouseId = ''; warehouses.value = []; if (opened.value && needsWarehouse.value) void loadWarehouses() }, { flush: 'sync' })
function open(action: ProcurementAction) {
if (!action.allowed || pending.value || storageError.value || saving.value) return
editingDraft.value = false; active.value = action; error.value = null; manageCommand.value = manageOptions.value.find(option => !option.reason)?.value || 'control-notes'; physical.value = physicalForm(props.detail)
@@ -94,7 +98,7 @@ function open(action: ProcurementAction) {
finance.value = financeForm(props.detail); financialCommand.value = 'fees/correct-target'
transport.value = transportForm(props.detail, ['SHIP_CUSTOMER', 'REPACK'].includes(action.key) ? 'DOMESTIC' : action.key === 'LABELS' ? undefined : 'OVERSEAS')
if (action.key.includes('DOMESTIC')) physical.value.leg = 'DOMESTIC'
Object.assign(form, { decision: 'APPROVE', warehouseId: props.detail.purchaseFacts.actual_warehouse_id || '', reason: '', amount: '', currency: props.detail.customerView.budget.currency === 'USD' ? 'USD' : 'CNY', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
Object.assign(form, { procurementDirection: props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC', decision: 'APPROVE', warehouseId: props.detail.purchaseFacts.actual_warehouse_id || '', reason: '', amount: '', currency: props.detail.customerView.budget.currency === 'USD' ? 'USD' : 'CNY', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
opened.value = true; captureBaseline()
if (needsWarehouse.value) void loadWarehouses()
}
@@ -123,11 +127,6 @@ function useCorrectionDraft() {
const fee = props.detail.fees.find(row => row.id === finance.value.feeId)
active.value = action; financialCommand.value = fee && !fee.active && ['TERMINATED', 'RELEASED'].includes(fee.walletStatus) ? 'fees/replace-failed' : 'fees/correct-target'; editingDraft.value = false
}
async function retryPending() {
if (editingDraft.value) { saveCorrectionDraft(); if (draftError.value) return }
editingDraft.value = false; opened.value = false
await submit()
}
watch(financialCommand, () => { if (editingDraft.value) return; finance.value.feeId = ''; finance.value.targetOriginalAmount = ''; finance.value.refundAmount = ''; form.voucherFileId = ''; error.value = null }, { flush: 'sync' })
watch(needsWarehouse, value => { if (value && opened.value) void loadWarehouses() })
function build(): ProcurementCommandBody {
@@ -142,7 +141,7 @@ function build(): ProcurementCommandBody {
if (needsEvidence.value && !files.value.some(file => file.fileId === form.voucherFileId)) throw new Error('请选择本单异常处置凭证')
return buildPhysical(command.value, physical.value, props.detail, base, form.warehouseId, form.voucherFileId)
}
if (command.value === 'review') return { version: base.version, idempotencyKey: base.idempotencyKey, decision: form.decision, remark: base.reason, ...(form.decision === 'APPROVE' ? { purchaseWarehouseId: form.warehouseId } : {}) }
if (command.value === 'review') return { version: base.version, idempotencyKey: base.idempotencyKey, decision: form.decision, procurementDirection: form.procurementDirection, remark: base.reason, ...(form.decision === 'APPROVE' ? { purchaseWarehouseId: form.warehouseId } : {}) }
if (command.value === 'planned-warehouse') return { ...base, warehouseId: form.warehouseId }
if (needsAmount.value) {
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(form.amount.trim())) throw new Error('请明确填写非负金额,最多两位小数;免费样品请填 0')
@@ -157,33 +156,29 @@ function build(): ProcurementCommandBody {
async function submit() {
if (editingDraft.value) { saveCorrectionDraft(); return }
if (saving.value || uploading.value || storageError.value || !key.value) return
const resuming = !!pending.value
const scope = key.value; const orderNo = props.detail.customerView.order.orderNo; const customer = props.customerId
if (!pending.value) {
if (!active.value?.allowed || !access.can(active.value.permission) || !command.value) return
try {
if (managementBlock.value) throw new Error(managementBlock.value)
const request = { command: command.value, payload: build() }
const serialized = JSON.stringify(request); readPendingProcurement(serialized)
sessionStorage.setItem(scope, serialized); pending.value = request
pending.value = request
} catch (issue) { error.value = { message: issue instanceof Error ? issue.message : '提交信息保存失败,请重试' }; return }
}
const retryFee = pending.value.command === 'fees/retry' ? props.detail.fees.find(fee => fee.id === pending.value?.payload.feeId) : null
const requiredPermission = pending.value.command === 'fees/retry' ? (retryFee ? feeRetryPermission(retryFee) : '') : `platform:overseas-business:purchase:${commandPermissions[pending.value.command]}`
if (!requiredPermission || !access.can(requiredPermission)) { error.value = { message: '当前账号无此节点操作权限' }; return }
if (!requiredPermission || !access.can(requiredPermission)) { pending.value = null; error.value = { message: '当前账号无此节点操作权限' }; return }
saving.value = true; error.value = null
const confirmsCorrection = !resuming && ['fees/correct-target', 'fees/replace-failed'].includes(pending.value.command)
const confirmsCorrection = ['fees/correct-target', 'fees/replace-failed'].includes(pending.value.command)
try {
await submitProcurementCommand(orderNo, customer, pending.value.command, pending.value.payload)
sessionStorage.removeItem(scope)
if (key.value !== scope) return
if (confirmsCorrection) { sessionStorage.removeItem(draftKey.value); correctionDraft.value = null }
pending.value = null; opened.value = false; captureBaseline(); ElMessage.success('操作已保存,请核对最新业务及资金结果'); emit('changed')
} catch (issue) {
if (key.value !== scope) return
if (!resuming && definitelyRejected(issue)) { sessionStorage.removeItem(scope); pending.value = null }
error.value = getApiErrorDetails(issue, '结果尚未确认,请使用原请求重试')
} finally { saving.value = false }
error.value = getApiErrorDetails(issue, '操作失败,请重新提交')
} finally { pending.value = null; saving.value = false }
}
async function upload(event: Event) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
@@ -212,11 +207,10 @@ async function upload(event: Event) {
<dl><template v-for="action in unavailableActions" :key="action.key"><dt>{{ action.label }}</dt><dd>{{ action.reason || '当前不可操作' }}</dd></template></dl>
</details>
<el-alert v-if="draftError" :title="draftError" type="error" :closable="false" /><el-alert v-if="storageError" :title="storageError" type="error" :closable="false" />
<el-alert v-if="pending" title="上次提交结果尚未确认,重试将使用原始内容与原请求编号。" type="warning" :closable="false"><p>原操作:{{ pending.command === 'review' ? (pending.payload.decision === 'APPROVE' ? '审核通过' : '审核拒绝') : actions.find(action => routes[action.key] === pending?.command)?.label || '采购节点操作' }} · {{ pending.payload.remark || pending.payload.reason }}</p><p v-if="pending.payload.amount !== undefined">原提交金额:{{ pending.payload.amount }} {{ pending.payload.currency || (['international-estimate', 'fees/refund'].includes(pending.command) ? 'CNY' : '') }}</p><el-button :loading="saving" @click="retryPending">确认上次提交结果</el-button></el-alert>
<ApiErrorMeta v-if="error && !opened" :details="error" />
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交'" @confirm="submit">
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。原请求未决时只能保存,结果明确后请刷新订单再核对确认。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
<el-alert v-if="managementBlock" :title="managementBlock" type="warning" :closable="false" />
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : '确认提交'" @confirm="submit">
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
<el-alert v-if="managementBlock" :title="t(managementBlock)" type="warning" :closable="false" />
<el-form class="density-form" label-position="top" :disabled="saving || uploading || (!!pending && !editingDraft)">
<el-form-item v-if="active?.key === 'MANAGE'" label="实物更正操作"><el-select v-model="manageCommand" placeholder="选择处置或更正操作"><el-option v-for="option in manageOptions" :key="option.value" :value="option.value" :disabled="!!option.reason" :label="option.reason ? `${option.label}(${option.reason})` : option.label" /></el-select></el-form-item>
<el-form-item v-if="active?.key === 'FINANCE_ADJUST'" label="财务处理方式"><el-select v-model="financialCommand" placeholder="选择财务处理方式"><el-option value="fees/additional" label="登记独立新发生费用" /><el-option value="fees/correct-target" label="更正原费用链目标总额" /><el-option value="fees/refund" label="关联原扣款退款" /><el-option value="fees/terminate" label="终止明确失败的费用版本" /><el-option value="fees/replace-failed" label="重新核定已终止的未扣款费用" /><el-option value="fees/release-estimate" label="释放有效预估冻结" /></el-select></el-form-item>
@@ -225,17 +219,18 @@ async function upload(event: Event) {
<ProcurementFinanceFields v-else-if="isFinance && command" v-model="finance" :command="command" :detail="detail" :files="allFiles" :can="access.can" />
<ProcurementTransportFields v-if="isTransport && command" v-model="transport" :command="command" :detail="detail" :files="allFiles" />
<ProcurementPhysicalFields v-if="isPhysical && command" v-model="physical" :command="command" :detail="detail" />
<el-form-item v-if="command === 'review'" label="采购方向" required><el-select v-model="form.procurementDirection"><el-option v-for="(label, value) in procurementDirections" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item v-if="command === 'review'" label="审核决定"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="needsWarehouse" :label="command === 'actual-warehouse/correct' ? '更正后的实际仓' : isPhysical ? '实际海外收货仓' : '采购仓'" required><el-select v-model="form.warehouseId" :disabled="isPhysical && !!detail.purchaseFacts.actual_warehouse_id" filterable :loading="warehouseLoading" placeholder="选择有效海外收货仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :value="item.warehouseId" :label="`${item.warehouseName} · ${item.warehouseCode}`" /></el-select><span v-if="warehouseError">{{ warehouseError }}<el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
<el-form-item v-if="needsWarehouse" :label="command === 'actual-warehouse/correct' ? '更正后的实际仓' : isPhysical ? t('实际海外收货仓') : '采购仓'" required><el-select v-model="form.warehouseId" :disabled="isPhysical && !!detail.purchaseFacts.actual_warehouse_id" filterable :loading="warehouseLoading" :placeholder="t('选择有效海外收货仓')"><el-option v-for="item in warehouses" :key="item.warehouseId" :value="item.warehouseId" :label="`${item.warehouseName} · ${item.warehouseCode}`" /></el-select><span v-if="warehouseError">{{ warehouseError }}<el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
<div v-if="needsAmount" class="purchase-form-grid"><el-form-item :label="command === 'purchase' ? '供应商实付总额' : '客户授权预算总额'" required><el-input v-model="form.amount" placeholder="明确填写金额,包括零元" /></el-form-item><el-form-item label="原币" required><el-select v-model="form.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item></div>
<div v-if="command === 'purchase'" class="purchase-form-grid"><el-form-item label="真实购买时间" required><el-date-picker v-model="form.purchasedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到海外仓时间" required><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></div>
<div v-if="command === 'purchase'" class="purchase-form-grid"><el-form-item label="真实购买时间" required><el-date-picker v-model="form.purchasedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item :label="t('预计到海外仓时间')" required><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></div>
<el-form-item v-if="command === 'exception-close'" label="上传异常处置或客户确认凭证"><input aria-label="上传异常关闭凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item v-if="['charges/confirm', 'fees/additional'].includes(command || '')" label="上传费用凭证"><input aria-label="上传费用凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item v-if="command === 'labels'" label="上传本单面单"><input aria-label="上传快递面单" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item v-if="needsAmount || needsEvidence" label="本单凭证" required><el-select v-model="form.voucherFileId" placeholder="选择已上传凭证"><el-option v-for="file in files" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select><input aria-label="上传本单凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item label="操作说明" required><el-input v-model="form.reason" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交' }}</el-button></div></template>
</el-form><el-alert v-if="error" :title="t(error.message)" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : '确认提交' }}</el-button></div></template>
</AppDialog>
</section>
</template>
@@ -4,6 +4,8 @@ import { computed } from 'vue'
import type { ProcurementCommand } from '../api/procurementCommands'
import type { PlatformProcurement } from '../procurementTypes'
import { newCarton, shippedCartons, receiptCorrectionReason, dispositionReason, type PhysicalForm } from '../procurementPhysicalForm'
import { regionText } from '../procurementDirection'
const t = (text: string) => regionText(text, props.detail.customerView.order.procurementDirection)
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement }>()
const form = defineModel<PhysicalForm>({ required: true })
const receipts = computed(() => props.detail.physical.receipts.filter(row => row.active && (props.command === 'receipts/correct' || (row.leg === form.value.leg && row.sourceCartonId === (form.value.leg === 'DOMESTIC' ? form.value.sourceCartonId : null)))))
@@ -20,20 +22,20 @@ const differenceNames = { SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货'
<template>
<div class="admin-density">
<el-alert v-if="command.endsWith('/complete')" title="仅在全部到齐或差异已有明确处置后确认收货完成;登记实收不会自动完成收货。" type="info" :closable="false" />
<el-alert v-if="command === 'packing/confirm'" :title="`确认第 ${detail.physical.packingVersion || '—'} 版清单。须完成海外收货、样品货款及费用来源关联。`" type="info" :closable="false" />
<el-alert v-if="command === 'packing/confirm'" :title="`确认第 ${detail.physical.packingVersion || '—'} 版清单。须完成起运仓收货、样品货款及费用来源关联。`" type="info" :closable="false" />
<el-alert v-if="command === 'packing/withdraw'" title="撤回未交寄清单后,需要重新确认装箱并核对后续面单和费用。" type="warning" :closable="false" />
<el-form-item v-if="command === 'dispositions'" label="差异发生段" required><el-radio-group v-model="form.leg" @change="form.sourceCartonId = ''; form.receiptId = ''"><el-radio value="OVERSEAS" :disabled="!!dispositionReason(detail, 'OVERSEAS')">海外收货<span v-if="dispositionReason(detail, 'OVERSEAS')">({{ dispositionReason(detail, 'OVERSEAS') }})</span></el-radio><el-radio value="DOMESTIC" :disabled="!!dispositionReason(detail, 'DOMESTIC')">国内收货<span v-if="dispositionReason(detail, 'DOMESTIC')">({{ dispositionReason(detail, 'DOMESTIC') }})</span></el-radio></el-radio-group></el-form-item>
<el-form-item v-if="command === 'receipts/correct'" label="待更正实收" required><el-select v-model="form.receiptId" placeholder="选择有效原实收记录" @change="selectReceipt"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select><span>保留原记录,更正不能改变运输段或来源箱;已交寄后只能追加更正说明。</span></el-form-item>
<el-form-item v-if="command === 'dispositions'" label="差异发生段" required><el-radio-group v-model="form.leg" @change="form.sourceCartonId = ''; form.receiptId = ''"><el-radio value="OVERSEAS" :disabled="!!dispositionReason(detail, 'OVERSEAS')">{{ t('海外收货') }}<span v-if="dispositionReason(detail, 'OVERSEAS')">({{ dispositionReason(detail, 'OVERSEAS') }})</span></el-radio><el-radio value="DOMESTIC" :disabled="!!dispositionReason(detail, 'DOMESTIC')">{{ t('国内收货') }}<span v-if="dispositionReason(detail, 'DOMESTIC')">({{ dispositionReason(detail, 'DOMESTIC') }})</span></el-radio></el-radio-group></el-form-item>
<el-form-item v-if="command === 'receipts/correct'" label="待更正实收" required><el-select v-model="form.receiptId" placeholder="选择有效原实收记录" @change="selectReceipt"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.receiptNo} · ${t(row.leg === 'OVERSEAS' ? '海外' : '国内')}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select><span>保留原记录,更正不能改变运输段或来源箱;已交寄后只能追加更正说明。</span></el-form-item>
<el-form-item v-if="(showReceipt || command === 'dispositions') && form.leg === 'DOMESTIC'" label="国际交寄原箱" required><el-select v-model="form.sourceCartonId" :disabled="command === 'receipts/correct'" placeholder="选择本单已国际交寄的箱" @change="form.receiptId = ''"><el-option v-for="box in domesticBoxes" :key="box.id" :value="box.id" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
<template v-if="showReceipt"><div class="physical-grid"><el-form-item label="合格件数" required><el-input-number v-model="form.goodQuantity" :min="0" :precision="0" controls-position="right" /></el-form-item><el-form-item label="破损件数" required><el-input-number v-model="form.damagedQuantity" :min="0" :precision="0" controls-position="right" /></el-form-item><el-form-item label="多货件数" required><el-input-number v-model="form.extraQuantity" :min="0" :precision="0" controls-position="right" /></el-form-item></div><el-form-item label="实际收货时间" required><el-date-picker v-model="form.receivedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></template>
<template v-if="command === 'dispositions'"><el-form-item label="差异及处置" required><el-select v-model="form.kind" placeholder="选择差异处理方式" @change="form.receiptId = ''; form.quantity = undefined"><el-option value="SHORTAGE" label="短少 · 客户接受短少" /><el-option value="DAMAGED" label="破损 · 处理破损货物" /><el-option value="EXTRA" label="多货 · 退回多到货物" /></el-select></el-form-item><el-form-item v-if="form.kind !== 'SHORTAGE'" label="对应实收" required><el-select v-model="form.receiptId" placeholder="选择差异来源实收"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · 破损 ${row.damagedQuantity} / 多货 ${row.extraQuantity}`" /></el-select></el-form-item><el-form-item label="本次处置件数" required><el-input-number v-model="form.quantity" :min="1" :precision="0" controls-position="right" placeholder="填写已完成处置的件数" /></el-form-item></template>
<el-form-item v-if="command === 'dispositions/revoke'" label="撤销差异处置" required><el-select v-model="form.dispositionId" placeholder="选择有效处置记录"><el-option v-for="row in detail.physical.dispositions.filter(item => item.active)" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.leg === 'OVERSEAS' ? '海外' : '国内'} · ${differenceNames[row.kind]} ${row.quantity} 件 · ${row.reason}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select></el-form-item>
<el-form-item v-if="command === 'dispositions/revoke'" label="撤销差异处置" required><el-select v-model="form.dispositionId" placeholder="选择有效处置记录"><el-option v-for="row in detail.physical.dispositions.filter(item => item.active)" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${t(row.leg === 'OVERSEAS' ? '海外' : '国内')} · ${differenceNames[row.kind]} ${row.quantity} 件 · ${row.reason}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select></el-form-item>
<template v-if="command === 'packing'">
<el-alert title="每箱填写箱规、毛重及来源实收件数。草稿允许暂未分配完,确认时必须覆盖全部合格实收。" type="info" :closable="false" />
<section v-for="(box, index) in form.cartons" :key="index" class="packing-box">
<div class="packing-heading"><strong>第 {{ index + 1 }} 箱</strong><el-button class="density-action" data-tone="danger" @click="form.cartons.splice(index, 1)">移除本箱</el-button></div>
<div class="packing-grid"><el-form-item label="箱号" required><el-input v-model="box.cartonNo" maxlength="64" placeholder="填写本单唯一箱号" /></el-form-item><el-form-item label="长(cm)" required><el-input v-model="box.lengthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="宽(cm)" required><el-input v-model="box.widthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="高(cm)" required><el-input v-model="box.heightCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="毛重(kg)" required><el-input v-model="box.grossWeightKg" placeholder="最多三位小数" /></el-form-item></div>
<div v-for="(source, sourceIndex) in box.sources" :key="sourceIndex" class="packing-source"><el-form-item label="海外实收来源" required><el-select v-model="source.receiptId" placeholder="选择有效合格实收"><el-option v-for="receipt in sources" :key="receipt.id" :value="receipt.id" :label="`${receipt.receiptNo} · 合格 ${receipt.goodQuantity} 件`" /></el-select></el-form-item><el-form-item label="本箱分配件数" required><el-input-number v-model="source.quantity" :min="1" :precision="0" placeholder="分配到此箱的件数" controls-position="right" /></el-form-item><el-button class="density-action" data-tone="danger" @click="box.sources.splice(sourceIndex, 1)">移除来源</el-button></div>
<div v-for="(source, sourceIndex) in box.sources" :key="sourceIndex" class="packing-source"><el-form-item :label="t('海外实收来源')" required><el-select v-model="source.receiptId" placeholder="选择有效合格实收"><el-option v-for="receipt in sources" :key="receipt.id" :value="receipt.id" :label="`${receipt.receiptNo} · 合格 ${receipt.goodQuantity} 件`" /></el-select></el-form-item><el-form-item label="本箱分配件数" required><el-input-number v-model="source.quantity" :min="1" :precision="0" placeholder="分配到此箱的件数" controls-position="right" /></el-form-item><el-button class="density-action" data-tone="danger" @click="box.sources.splice(sourceIndex, 1)">移除来源</el-button></div>
<el-button class="density-action" @click="box.sources.push({ receiptId: '', quantity: undefined })">增加来源</el-button>
</section><el-button class="density-action" data-tone="primary" @click="form.cartons.push(newCarton())">增加装货箱</el-button>
</template>
@@ -5,6 +5,8 @@ import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
import type { ProcurementCommand } from '../api/procurementCommands'
import { transportForm, transportCargo, type TransportForm } from '../procurementTransportForm'
import { dateTime, measurement, money } from '../procurementPresentation'
import { regionText } from '../procurementDirection'
const t = (text: string) => regionText(text, props.detail.customerView.order.procurementDirection)
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[] }>()
const form = defineModel<TransportForm>({ required: true })
const cargo = computed(() => transportCargo(props.detail, form.value.leg))
@@ -19,8 +21,8 @@ function selectCarton(id: string) { const box = props.detail.transport.domesticC
<div class="admin-density">
<template v-if="command === 'labels'">
<el-form-item label="面单运输段" required><el-radio-group v-model="form.leg" @change="changeLeg"><el-radio value="OVERSEAS" :disabled="!!detail.purchaseFacts.overseas_shipped_at">国际段</el-radio><el-radio value="DOMESTIC" :disabled="!detail.purchaseFacts.domestic_received_at">寄客户段</el-radio></el-radio-group></el-form-item>
<p>寄件资料:{{ sender ? `${sender.name} · ${sender.phone} · ${sender.address}` : '由实际海外收货仓主档提供,保存时核对。' }}</p>
<div class="transport-grid"><el-form-item :label="form.leg === 'OVERSEAS' ? '国内接收人' : '最终收件人'" required><el-input v-model="form.receiver.name" maxlength="128" /></el-form-item><el-form-item label="联系电话" required><el-input v-model="form.receiver.phone" maxlength="64" /></el-form-item></div>
<p>寄件资料:{{ sender ? `${sender.name} · ${sender.phone} · ${sender.address}` : t('由实际海外收货仓主档提供,保存时核对。') }}</p>
<div class="transport-grid"><el-form-item :label="form.leg === 'OVERSEAS' ? t('国内接收人') : '最终收件人'" required><el-input v-model="form.receiver.name" maxlength="128" /></el-form-item><el-form-item label="联系电话" required><el-input v-model="form.receiver.phone" maxlength="64" /></el-form-item></div>
<el-form-item label="完整收件地址" required><el-input v-model="form.receiver.address" maxlength="1000" /></el-form-item>
<el-form-item v-if="changedReceiver" label="客户确认收件信息变更凭证" required><el-select v-model="form.addressVoucherFileId" placeholder="选择本单已上传的客户确认凭证"><el-option v-for="file in files.filter(item => item.category === 'EXCEPTION')" :key="file.fileId" :label="file.fileName" :value="file.fileId" /></el-select></el-form-item>
<section v-for="row in form.labels" :key="row.cartonId" class="label-row">
@@ -39,8 +41,8 @@ function selectCarton(id: string) { const box = props.detail.transport.domesticC
<el-form-item label="实际贴单时间" required><el-date-picker v-model="form.occurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
</template>
<template v-if="command === 'domestic-repack'">
<el-alert title="只允许原箱一对一换包装,不改变货物来源和件数。保存后旧国内面单失效,须重新制单并核对国内费用。" type="warning" :closable="false" />
<el-form-item label="换包装原箱" required><el-select v-model="form.cartonId" placeholder="选择国内可寄原箱" @change="selectCarton"><el-option v-for="box in cargo" :key="box.cartonId" :value="box.cartonId" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
<el-alert :title="t('只允许原箱一对一换包装,不改变货物来源和件数。保存后旧国内面单失效,须重新制单并核对国内费用。')" type="warning" :closable="false" />
<el-form-item label="换包装原箱" required><el-select v-model="form.cartonId" :placeholder="t('选择国内可寄原箱')" @change="selectCarton"><el-option v-for="box in cargo" :key="box.cartonId" :value="box.cartonId" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
<div class="measure-grid"><el-form-item label="新长度(cm)" required><el-input v-model="form.lengthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新宽度(cm)" required><el-input v-model="form.widthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新高度(cm)" required><el-input v-model="form.heightCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新毛重(kg)" required><el-input v-model="form.grossWeightKg" placeholder="最多三位小数" /></el-form-item></div>
</template>
<template v-if="['overseas-shipment', 'customer-shipment'].includes(command)">
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { actOnOverseasFee, type OverseasFee } from '../api/overseasFees'
import { adjustStorageRentFee } from '../api/storageRent'
import { feePermissions, feeMoney } from '../overseasFeePresentation'
import StorageRentFeeDetails from './StorageRentFeeDetails.vue'
const props = defineProps<{ fee: OverseasFee | null; action: string; readonly?: boolean }>()
const open = defineModel<boolean>({ required: true })
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const amount = ref(0), reason = ref(''), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const key = ref(''), fingerprint = ref('')
const adjusting = computed(() => props.action === 'ADJUST_RENT')
const allowed = (action: string) => !props.readonly && props.fee?.allowedActions.includes(action) && access.can(action === 'ADJUST_RENT' ? feePermissions.edit : feePermissions.review)
function reset() { amount.value = props.fee?.originalAmount ?? 0; reason.value = ''; error.value = null; key.value = ''; fingerprint.value = '' }
watch(open, value => { if (value) reset() }, { immediate: true })
async function submit(decision?: 'APPROVE' | 'REJECT') {
const fee = props.fee, action = adjusting.value ? 'ADJUST_RENT' : decision
if (!fee || !action || !allowed(action) || busy.value) return
if ((adjusting.value || decision === 'REJECT') && !reason.value.trim()) { ElMessage.warning('请填写说明'); return }
if (adjusting.value && (!Number.isFinite(amount.value) || amount.value < 0)) { ElMessage.warning('请输入有效金额'); return }
const body = adjusting.value ? { expectedVersion: fee.sourceVersion, amount: amount.value, reason: reason.value.trim() }
: { decision, reason: reason.value.trim() || undefined, expectedVersion: fee.sourceVersion }
const next = JSON.stringify([fee.sourceFeeId, body])
if (fingerprint.value !== next) { fingerprint.value = next; key.value = crypto.randomUUID() }
busy.value = true; error.value = null
try {
if (adjusting.value) await adjustStorageRentFee(fee.sourceFeeId, { expectedVersion: fee.sourceVersion, amount: amount.value, reason: reason.value.trim(), idempotencyKey: key.value })
else await actOnOverseasFee(fee.sourceFeeId, decision!, { ...body, idempotencyKey: key.value })
open.value = false; ElMessage.success(adjusting.value ? '仓租已调整' : decision === 'APPROVE' ? '审核通过,正在处理扣款' : '仓租已驳回'); emit('saved')
} catch (failure) { error.value = getApiErrorDetails(failure, '仓租操作失败') }
finally { busy.value = false }
}
</script>
<template>
<AppDialog v-model="open" append-to-body :title="adjusting ? '调整仓租' : '审核仓租'" width="760px" class="admin-density-dialog admin-workbench is-dialog" :show-confirm="false" :confirm-loading="busy" lock-while-submitting>
<template v-if="fee">
<p>{{ fee.businessNo }} · 仓租 {{ feeMoney(fee.originalAmount, fee.originalCurrency) }}</p>
<StorageRentFeeDetails :fee="fee" />
<p v-if="fee.feeRemark">费用说明:{{ fee.feeRemark }}</p>
<el-form label-position="top" :disabled="busy" class="density-form">
<el-form-item v-if="adjusting" :label="`仓租金额(${fee.originalCurrency})`" required><el-input-number v-model="amount" :precision="2" :min="0" :max="999999999999.99" /></el-form-item>
<el-form-item :label="adjusting ? '调整说明' : '审核说明(驳回必填)'" :required="adjusting"><el-input v-model="reason" type="textarea" :maxlength="500" show-word-limit /></el-form-item>
</el-form>
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
</template>
<template #footer>
<el-button :disabled="busy" @click="open = false">关闭</el-button>
<template v-if="adjusting"><el-button v-if="allowed('ADJUST_RENT')" type="primary" :loading="busy" :disabled="!reason.trim()" @click="submit()">保存调整</el-button></template>
<template v-else>
<el-button v-if="allowed('REJECT')" type="danger" :disabled="busy || !reason.trim()" @click="submit('REJECT')">驳回</el-button>
<el-button v-if="allowed('APPROVE')" type="primary" :loading="busy" @click="submit('APPROVE')">审核通过</el-button>
</template>
</template>
</AppDialog>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import '../overseasFeeDetails.css'
import type { OverseasFee } from '../api/overseasFees'
import { feeMoney } from '../overseasFeePresentation'
import { storageRentSourceType, storageRentTime } from '../storageRentPresentation'
defineProps<{ fee: OverseasFee }>()
</script>
<template>
<section v-if="fee.storageRent" aria-label="仓租计费明细">
<el-descriptions :column="2" border class="density-descriptions overseas-fee-descriptions">
<el-descriptions-item label="出库批次">{{ fee.storageRent.outboundNo }}</el-descriptions-item>
<el-descriptions-item label="出库时间">{{ storageRentTime(fee.storageRent.outboundAt) }}</el-descriptions-item>
<el-descriptions-item label="原计算金额">{{ feeMoney(fee.storageRent.calculatedAmount, fee.originalCurrency) }}</el-descriptions-item>
<el-descriptions-item label="应扣金额">{{ feeMoney(fee.amount, fee.currency) }}</el-descriptions-item>
<el-descriptions-item label="实扣金额">{{ feeMoney(fee.storageRent.chargedAmount, fee.currency) }}</el-descriptions-item>
</el-descriptions>
<el-table :data="fee.storageRent.lines" border empty-text="暂无计租批次">
<el-table-column label="来源类型" min-width="130"><template #default="{ row }">{{ storageRentSourceType(row.sourceBusinessType) }}</template></el-table-column>
<el-table-column label="来源单号" min-width="180"><template #default="{ row }">{{ row.sourceBusinessNo || '—' }}</template></el-table-column>
<el-table-column label="入库批次" min-width="160"><template #default="{ row }">{{ row.batchNo || '—' }}</template></el-table-column>
<el-table-column label="入库时间" min-width="170"><template #default="{ row }">{{ storageRentTime(row.inboundAt) }}</template></el-table-column>
<el-table-column prop="quantity" label="本次出库数量" width="130" />
<el-table-column prop="days" label="计租天数" width="100" />
<el-table-column label="原计算仓租" min-width="130"><template #default="{ row }">{{ feeMoney(row.calculatedAmount, fee.originalCurrency) }}</template></el-table-column>
</el-table>
</section>
</template>
@@ -0,0 +1,103 @@
import { createRequire } from 'node:module'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import * as api from '../api/storageRent'
import type { StorageRentLot } from '../api/storageRent'
import StorageRentPanel from './StorageRentPanel.vue'
const { default: ElementPlus } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
vi.mock('../api/storageRent', () => ({ getStorageRentLot: vi.fn(), listStorageRentLots: vi.fn(), listStorageRentWarehouses: vi.fn() }))
const lot: StorageRentLot = {
id: '9223372036854775001', customerId: '1', customerName: '测试客户', businessType: 'LASTLEG_ORDER', businessNo: 'FL1',
warehouseId: '2', warehouseName: '测试仓', batchNo: 'B1', inboundAt: '2026-10-22T00:00:00Z', storageDays: 1,
originalQuantity: 10, remainingQuantity: 8, quantityUnit: 'PACKAGE', billingQuantity: 1, billingUnit: 'CBM',
ruleCode: 'TRANSIT_V1', status: 'IN_STOCK', generatedAmount: 0, generatedCurrency: null, generatedAmounts: [],
}
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.resetAllMocks()
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(api.listStorageRentLots).mockResolvedValue({ records: [lot], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.listStorageRentWarehouses).mockResolvedValue([{ id: '2', warehouseName: '测试仓' }])
vi.mocked(api.getStorageRentLot).mockResolvedValue({ lot, charges: [{
id: '3', outboundNo: 'OUT1', businessNo: 'FL1', quantity: 2, storageDays: 1, originalAmount: 0, currency: 'USD',
settlementAmount: 0, currencyRate: 7, status: 'SETTLED', createdAt: '2026-10-22T01:00:00Z', outboundAt: '2026-10-22T01:00:00Z',
}] })
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('仓租列表服务端契约', () => {
it('shows storage age and remaining quantity and traces each half pallet without financial actions', async () => {
const returned = { ...lot, businessType: 'RETURN_ORDER', businessNo: 'TH1', quantityUnit: 'PIECE', originalQuantity: 6, remainingQuantity: 2, billingQuantity: 1.5, billingUnit: 'PALLET', status: 'IN_STOCK', generatedAmounts: [{ currency: 'USD', amount: 1.5 }], settledAmount: 10, chargeCount: 2, settledChargeCount: 2 }
vi.mocked(api.listStorageRentLots).mockResolvedValue({ records: [returned], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getStorageRentLot).mockResolvedValue({ lot: returned, charges: ['TH1', 'CD1'].map((businessNo, index) => ({ id: String(index), outboundNo: `OUT${index}`, businessNo, quantity: 2, storageDays: 1, originalAmount: 0.75, currency: 'USD', settlementAmount: 5, currencyRate: 6.67, status: 'SETTLED', createdAt: '', outboundAt: '' })) })
const wrapper = mount(StorageRentPanel, { attachTo: document.body, props: { customers: [] }, global: { plugins: [ElementPlus] } })
await flushPromises()
const body = wrapper.get('.el-table__body-wrapper')
expect(body.text()).toContain('2 件')
expect(wrapper.text()).toContain('库龄(天)')
expect(wrapper.text()).toContain('来源单号')
expect(body.text()).toContain('TH1')
expect(wrapper.text()).not.toContain('仓租 / 扣款')
await body.findAll('button').find(button => button.text().trim() === '详情')!.trigger('click'); await flushPromises()
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.textContent).toContain('0.5 托')
expect(dialog.textContent).toContain('OUT0')
expect(dialog.textContent).toContain('OUT1')
expect(dialog.textContent).not.toContain('USD')
expect(dialog.textContent).not.toContain('审核')
expect(dialog.textContent).toContain('CD1')
expect(dialog.textContent).toContain('来源单号')
expect(dialog.textContent).toContain('出库业务单号')
})
it('displays and filters first-leg lots using LASTLEG_ORDER and retains inbound and outbound evidence', async () => {
const wrapper = mount(StorageRentPanel, { attachTo: document.body, props: { customers: [] }, global: { plugins: [ElementPlus] } })
await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
expect(api.listStorageRentLots).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'IN_STOCK', page: 1 }))
expect(toolbar.props('fields').some(field => field.key === 'status')).toBe(false)
const options = toolbar.props('fields').find(field => field.key === 'businessType')?.options
expect(options).toContainEqual({ value: 'LASTLEG_ORDER', label: '头程入仓' })
expect(options).not.toContainEqual({ value: 'FIRST_LEG', label: '头程入仓' })
expect(wrapper.get('.el-table__body-wrapper').text()).toContain('头程入仓')
toolbar.vm.$emit('search', { businessType: 'LASTLEG_ORDER' })
await flushPromises()
expect(api.listStorageRentLots).toHaveBeenLastCalledWith(expect.objectContaining({ businessType: 'LASTLEG_ORDER', status: 'IN_STOCK', page: 1 }))
toolbar.vm.$emit('search', {})
await flushPromises()
expect(api.listStorageRentLots).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'IN_STOCK', page: 1 }))
const detailButton = wrapper.findAll('.el-table__body-wrapper button').find(button => button.text().trim() === '详情')
await detailButton!.trigger('click'); await flushPromises()
expect(api.getStorageRentLot).toHaveBeenCalledWith(lot.id)
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.textContent).toContain('2026-10-22')
expect(dialog.textContent).toContain('8 箱')
expect(dialog.textContent).toContain('OUT1')
expect(dialog.textContent).not.toContain('实扣金额')
})
it('keeps inventory focused on age and outbound history even when charges use different currencies', async () => {
const mixedLot = { ...lot, generatedAmount: null, generatedCurrency: null, generatedAmounts: [{ currency: 'USD', amount: 12 }, { currency: 'CNY', amount: 23 }] }
vi.mocked(api.listStorageRentLots).mockResolvedValue({ records: [mixedLot], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getStorageRentLot).mockResolvedValue({ lot: mixedLot, charges: [
{ id: '3', outboundNo: 'OUT1', businessNo: 'FL1', quantity: 1, storageDays: 8, originalAmount: 12, currency: 'USD', settlementAmount: 84, currencyRate: 7, status: 'SETTLED', createdAt: '2026-10-22T01:00:00Z', outboundAt: '2026-10-22T01:00:00Z' },
{ id: '4', outboundNo: 'OUT2', businessNo: 'FL1', quantity: 1, storageDays: 9, originalAmount: 23, currency: 'CNY', settlementAmount: 23, currencyRate: 1, status: 'SETTLED', createdAt: '2026-10-22T02:00:00Z', outboundAt: '2026-10-22T02:00:00Z' },
] })
const wrapper = mount(StorageRentPanel, { attachTo: document.body, props: { customers: [] }, global: { plugins: [ElementPlus] } })
await flushPromises()
const body = wrapper.get('.el-table__body-wrapper')
expect(body.text()).toContain('8 箱')
expect(body.text()).not.toContain('USD')
expect(body.text()).not.toContain('CNY')
await body.findAll('button').find(button => button.text().trim() === '详情')!.trigger('click'); await flushPromises()
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.textContent).toContain('OUT1')
expect(dialog.textContent).toContain('OUT2')
expect(dialog.textContent).toContain('计租天数')
const rows = Array.from(dialog.querySelectorAll('.el-table__body-wrapper tbody tr'))
expect(rows[0]?.textContent).toContain('8')
expect(rows[1]?.textContent).toContain('9')
expect(dialog.textContent).not.toContain('USD')
expect(dialog.textContent).not.toContain('CNY')
})
})
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions from '@/shared/components/RowActions.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getStorageRentLot, listStorageRentLots, listStorageRentWarehouses, type StorageRentCharge, type StorageRentDetail, type StorageRentLot } from '../api/storageRent'
import { storageRentBillingShare, storageRentTime } from '../storageRentPresentation'
const props = defineProps<{ customers: Array<{ customerId: string; customerName: string }> }>()
const rows = ref<StorageRentLot[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const warehouses = ref<Array<{ id: string; warehouseName: string }>>([])
const warehouseError = ref<ApiErrorDetails | null>(null), warehouseLoading = ref(false)
const page = ref(1), pageSize = ref(20), total = ref(0), detail = ref<StorageRentDetail | null>(null), detailOpen = ref(false)
const detailLoading = ref('')
const filters = ref<QueryModel>({ customerId: '', businessNo: '', warehouseId: '', businessType: '' }), applied = ref({ ...filters.value })
const typeLabels: Record<string, string> = { LASTLEG_ORDER: '头程入仓', WAREHOUSE_TRANSFER: '仓储转运', RETURN_ORDER: '退货', RELABEL_ORDER: '换标' }
const unitLabels: Record<string, string> = { PACKAGE: '箱', PIECE: '件', CBM: 'CBM', PALLET: '托' }
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '客户', options: props.customers.map(row => ({ label: row.customerName, value: row.customerId })), props: { filterable: true, 'aria-label': '客户' } },
{ key: 'businessNo', type: 'input', label: '来源单号', placeholder: '来源单号', width: 220, props: { 'aria-label': '来源单号' } },
{ key: 'warehouseId', type: 'select', label: '海外仓', placeholder: '海外仓', options: warehouses.value.map(row => ({ value: String(row.id), label: row.warehouseName })), props: { filterable: true, 'aria-label': '海外仓', loading: warehouseLoading.value } },
{ key: 'businessType', type: 'select', label: '业务类型', placeholder: '业务类型', options: Object.entries(typeLabels).map(([value, label]) => ({ value, label })), props: { 'aria-label': '业务类型' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 85, fixed: 'left', slotName: 'actions' },
{ prop: 'customerName', label: '客户', minWidth: 140 }, { prop: 'businessNo', label: '来源单号', minWidth: 180 },
{ prop: 'businessType', label: '业务类型', width: 110, formatter: (row: StorageRentLot) => typeLabels[row.businessType] || row.businessType },
{ prop: 'warehouseName', label: '海外仓', minWidth: 140 }, { prop: 'batchNo', label: '批次号', minWidth: 150 },
{ prop: 'inboundAt', label: '入库时间', minWidth: 170, formatter: (row: StorageRentLot) => storageRentTime(row.inboundAt) },
{ prop: 'storageDays', label: '库龄(天)', width: 100, align: 'right' },
{ key: 'remaining', label: '剩余数量', width: 110, formatter: (row: StorageRentLot) => `${row.remainingQuantity} ${unitLabels[row.quantityUnit] || row.quantityUnit}` },
{ key: 'billing', label: '计租数量', width: 120, formatter: (row: StorageRentLot) => `${row.billingQuantity} ${unitLabels[row.billingUnit] || row.billingUnit}` },
]
const chargeColumns = computed<DataTableColumn[]>(() => [
{ prop: 'outboundNo', label: '出库批次', minWidth: 170 },
{ prop: 'businessNo', label: '出库业务单号', minWidth: 170 },
{ prop: 'quantity', label: '出库数量', width: 105, align: 'right', formatter: (row: { quantity: number }) => `${row.quantity} ${unitLabels[detail.value?.lot.quantityUnit || ''] || ''}`.trim() }, { prop: 'storageDays', label: '计租天数', width: 100, align: 'right' },
{ key: 'billingShare', label: '本次计租份额', width: 120, formatter: (row: StorageRentCharge) => detail.value ? storageRentBillingShare(row.quantity, detail.value.lot) : '—' },
{ prop: 'outboundAt', label: '出库时间', minWidth: 165, formatter: (row: { outboundAt: string }) => storageRentTime(row.outboundAt) },
])
let sequence = 0
async function load() {
const current = ++sequence; loading.value = true; error.value = null
try {
const query = Object.fromEntries(Object.entries(applied.value).map(([key, value]) => [key, value ? String(value) : undefined]))
const data = await listStorageRentLots({ ...query, status: 'IN_STOCK', page: page.value, size: pageSize.value })
if (current === sequence) { rows.value = data.records; total.value = data.total }
} catch (failure) { if (current === sequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(failure, '仓租库存加载失败') } }
finally { if (current === sequence) loading.value = false }
}
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
async function openDetail(row: StorageRentLot) {
if (detailLoading.value) return
detailLoading.value = row.id
try { detail.value = await getStorageRentLot(row.id); detailOpen.value = true }
catch (failure) { ElMessage.error(getApiErrorDetails(failure, '仓租详情加载失败').message) }
finally { detailLoading.value = '' }
}
async function loadWarehouses() {
warehouseLoading.value = true; warehouseError.value = null
try { warehouses.value = await listStorageRentWarehouses() }
catch (failure) { warehouseError.value = getApiErrorDetails(failure, '仓库选项加载失败') }
finally { warehouseLoading.value = false }
}
watch([page, pageSize], () => void load())
onMounted(() => { void load(); void loadWarehouses() })
</script>
<template>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" appearance="integrated" toolbar-placement="filters" preference-key="overseas-storage-rent" row-key="id" empty-text="暂无仓租库存">
<template #filters><QueryToolbar v-model="filters" ariaLabel="仓租库存筛选" :fields="fields" :loading="loading" @search="search" /></template>
<template #notices>
<LoadErrorState v-if="error" title="仓租库存加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" title="仓库选项加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
</template>
<template #actions="{ row }"><RowActions text-actions :actions="[{ key: 'DETAIL', label: '详情', loading: detailLoading === (row as StorageRentLot).id, disabled: !!detailLoading }]" @action="openDetail(row as StorageRentLot)" /></template>
<template #pagination-left>共 {{ total }} 个批次</template>
</DataTable>
<AppDialog v-model="detailOpen" title="仓租详情" width="1080px" class="admin-density-dialog admin-workbench is-dialog" :show-confirm="false" cancel-text="关闭" fullscreenable>
<template v-if="detail">
<el-descriptions :column="3" border class="density-descriptions">
<el-descriptions-item label="客户">{{ detail.lot.customerName }}</el-descriptions-item><el-descriptions-item label="来源单号">{{ detail.lot.businessNo }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ detail.lot.warehouseName }}</el-descriptions-item>
<el-descriptions-item label="批次号">{{ detail.lot.batchNo }}</el-descriptions-item><el-descriptions-item label="入库时间">{{ storageRentTime(detail.lot.inboundAt) }}</el-descriptions-item><el-descriptions-item label="库龄">{{ detail.lot.storageDays }} 天</el-descriptions-item>
<el-descriptions-item label="入库数量">{{ detail.lot.originalQuantity }} {{ unitLabels[detail.lot.quantityUnit] }}</el-descriptions-item><el-descriptions-item label="剩余数量">{{ detail.lot.remainingQuantity }} {{ unitLabels[detail.lot.quantityUnit] }}</el-descriptions-item><el-descriptions-item label="计租规则">{{ detail.lot.ruleCode === 'RETURN_V1' ? '退货仓租' : '中转仓租' }}</el-descriptions-item>
</el-descriptions>
<div class="rent-charges"><DataTable :data="detail.charges" :columns="chargeColumns" :page-sizes="[20]" empty-text="暂无出库记录" max-height="360px" /></div>
</template>
</AppDialog>
</template>
<style scoped>
.rent-charges { margin-top: 16px; }
</style>
@@ -19,7 +19,6 @@ const commands = computed(() => access.can(overseasBusinessPermissions.truckStat
const open = ref(false), saving = ref(false)
const active = ref<TruckCommand | null>(null)
const error = ref<ApiErrorDetails | null>(null), validationError = ref('')
// 响应丢失时保留原命令、版本和幂等键,重试不得变更请求内容。
const pending = ref<TruckOperation | null>(null)
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' })
const selectablePallets = computed(() => props.detail.pallets.filter(pallet => pallet.status === 'RECEIVED'))
@@ -71,7 +70,7 @@ function buildOperation(): TruckOperation | null {
async function submit() {
if (saving.value || !active.value || !commands.value.includes(active.value)) return
validationError.value = ''; error.value = null
const operation = pending.value ?? buildOperation()
const operation = buildOperation()
if (!operation) return
pending.value = operation; saving.value = true
const customerId = props.detail.customerId, orderNo = props.detail.orderNo
@@ -83,20 +82,20 @@ async function submit() {
emit('updated', updated)
} catch (cause) {
if (props.detail.customerId === customerId && props.detail.orderNo === orderNo) error.value = getApiErrorDetails(cause, '操作未确认成功,请重试或刷新订单核对最新记录')
} finally { saving.value = false }
} finally { pending.value = null; saving.value = false }
}
</script>
<template>
<div v-if="commands.length" class="truck-order-actions">
<el-button v-for="command in commands" :key="command" :type="command === 'cancel' ? 'danger' : 'primary'" :plain="command === 'cancel'" :disabled="saving" @click="start(command)">{{ truckCommandLabels[command] }}</el-button>
<p v-if="pending && !open" role="status">上次操作尚未确认结果,请重新打开重试,或刷新订单核对最新记录。</p>
</div>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="active ? truckCommandLabels[active] : '单据操作'" width="680px" :confirm-text="pending ? '重试原操作' : '确认提交'" :confirm-type="active === 'cancel' || (review && form.decision === 'REJECT') ? 'danger' : 'primary'" :confirm-loading="saving" :confirm-disabled="!active || !commands.includes(active)" @confirm="submit">
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="active ? truckCommandLabels[active] : '单据操作'" width="680px" :confirm-text="'确认提交'" :confirm-type="active === 'cancel' || (review && form.decision === 'REJECT') ? 'danger' : 'primary'" :confirm-loading="saving" :confirm-disabled="!active || !commands.includes(active)" @confirm="submit">
<template v-if="active">
<p>{{ detail.orderNo }} · {{ truckCommandHints[active] }}</p>
<el-alert v-if="validationError" :title="validationError" type="warning" :closable="false" role="alert" />
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可重试原操作;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可修改后重新提交;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
<el-form :model="form" label-width="130px" :disabled="saving || !!pending" @submit.prevent="submit">
<el-form-item v-if="review" label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
<template v-if="active === 'receive-pallets'">
+5
View File
@@ -1,3 +1,8 @@
export { overseasBusinessPermissions } from './permissions'
export { overseasBusinessRoutes } from './routes'
export type * from './types'
export { listOverseasFeeItems } from './api/overseasFees'
export { feeStatus, sameProjectFees, sameProjectFeeSummary } from './overseasFeePresentation'
export type { OverseasFee } from './api/overseasFees'
export { default as BusinessDocumentFees } from './components/BusinessDocumentFees.vue'
@@ -0,0 +1,8 @@
.overseas-fee-descriptions .el-descriptions__table {
table-layout: fixed;
}
.overseas-fee-descriptions .el-descriptions__content.el-descriptions__cell {
overflow-wrap: anywhere;
white-space: normal;
}
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { feeActions, feePermissions, feeProcessingLabel, feeProcessingStatus, feeStatus, feeTime, sameProjectFees, sameProjectFeeSummary } from './overseasFeePresentation'
import type { OverseasFee } from './api/overseasFees'
const fee = (overrides: Partial<OverseasFee> = {}): OverseasFee => ({
direction: 'RECEIVABLE',
id: '9007199254740993', sourceFeeId: '9007199254740993', sourceVersion: 1, customerId: '1', customerName: '客户',
sourceType: 'OVERSEAS_WAREHOUSE_FEE', businessType: 'WAREHOUSE_TRANSFER', businessId: '2', businessNo: 'WT1',
orderId: null, warehouseId: '3', feeItemCode: 'FEE1', feeItemName: '操作费', businessDate: '2026-10-22',
currency: 'CNY', amount: 70, originalCurrency: 'USD', originalAmount: 10, chargeableQuantity: null, quantityUnit: null,
unitPrice: null, periodStart: null, periodEnd: null, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', publicationStatus: 'PENDING',
collectionMode: 'WALLET', walletStatus: 'FROZEN', walletErrorMessage: null, reviewReason: null, reviewedAt: null,
createdAt: '', updatedAt: '', allowedActions: [], ...overrides,
})
describe('海外费用动作资格', () => {
it('明确时区的同一时刻显示一致,旧无时区时间不猜测转换', () => {
expect(feeTime('2026-10-23T10:43:25Z')).toBe(feeTime('2026-10-23T18:43:25+08:00'))
const date = new Date('2026-10-23T10:43:25Z')
expect(feeTime('2026-10-23T10:43:25Z')).toContain(String(date.getHours()).padStart(2, '0') + ':43:25')
expect(feeTime('2026-10-23T10:43:25')).toBe('2026-10-23 10:43:25')
expect(feeTime(null)).toBe('—')
})
it('以服务端动作白名单为准,不根据待审核状态自行开放审核', () => {
expect(feeActions(fee(), () => true).map(row => row.key)).toEqual(['DETAIL'])
expect(feeActions(fee({ allowedActions: ['APPROVE', 'REJECT', 'CANCEL'] }), permission => permission === feePermissions.review).map(row => row.key)).toEqual(['DETAIL', 'REVIEW'])
expect(feeActions(fee({ allowedActions: ['APPROVE', 'CANCEL'] }), permission => permission === feePermissions.edit).map(row => row.key)).toEqual(['DETAIL'])
expect(feeActions(fee({ allowedActions: ['REJECT', 'CANCEL'] }), permission => permission === feePermissions.review).map(row => row.key)).toEqual(['DETAIL', 'REVIEW'])
})
it('不为未知动作暴露按钮,请求中全部动作禁用', () => {
const actions = feeActions(fee({ allowedActions: ['DELETE', 'RETRY_FREEZE'] }), () => true, true)
expect(actions.map(row => row.key)).toEqual(['DETAIL', 'RETRY_FREEZE'])
expect(actions.every(row => row.disabled)).toBe(true)
})
it.each([{ reviewStatus: 'REJECTED' }, { sourceStatus: 'VOIDED' }])('关闭费用仅保留服务端允许的资金释放恢复 %o', overrides => {
const closedFee = fee({ ...overrides, walletStatus: 'RELEASE_FAILED', allowedActions: ['APPROVE', 'ADJUST', 'RETRY_SETTLE', 'RETRY_FREEZE', 'RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'] })
expect(feeActions(closedFee, () => true).map(row => row.key)).toEqual(['DETAIL', 'RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'])
expect(feeActions(closedFee, () => false).map(row => row.key)).toEqual(['DETAIL'])
expect(feeProcessingLabel(closedFee)).toBe('处理失败')
})
it('区分账单推送与实际余额扣款', () => {
expect(feeStatus(fee({ walletStatus: 'SETTLE_PENDING' }))).toBe('扣费中')
expect(feeStatus(fee({ walletStatus: 'SETTLED' }))).toBe('已扣费')
expect(feeStatus(fee({ collectionMode: 'BILL', publicationStatus: 'PUBLISHED' }))).toBe('已推送结算')
expect(feeStatus(fee({ sourceStatus: 'CANCELLED' }))).toBe('已取消')
})
it.each([
[{ walletStatus: 'FROZEN' }, 'WAITING'], [{ walletStatus: 'FREEZE_PENDING' }, 'PROCESSING'],
[{ walletStatus: 'SETTLE_FAILED' }, 'FAILED'], [{ walletStatus: 'RELEASE_FAILED' }, 'FAILED'],
[{ walletStatus: 'SETTLED' }, 'COMPLETED'], [{ collectionMode: 'BILL', publicationStatus: 'PUBLISHED', deliveryStatus: 'PENDING' }, 'PROCESSING'],
[{ publicationStatus: 'PUBLISHED', deliveryStatus: 'DEAD' }, 'FAILED'], [{ publicationStatus: 'PUBLISHED', deliveryStatus: 'PUBLISHED' }, 'COMPLETED'],
[{ publicationStatus: 'PUBLISHED', walletStatus: 'SETTLED', deliveryStatus: 'PENDING', deliveryErrorMessage: 'S0500' }, 'COMPLETED'],
[{ publicationStatus: 'PUBLISHED', walletStatus: 'SETTLED', deliveryStatus: 'PROCESSING', deliveryErrorMessage: 'S0500' }, 'COMPLETED'],
[{ publicationStatus: 'PUBLISHED', deliveryStatus: 'PUBLISHED', deliveryErrorMessage: '旧错误' }, 'COMPLETED'],
[{ reviewStatus: 'REJECTED', walletStatus: 'RELEASE_PENDING' }, 'PROCESSING'], [{ sourceStatus: 'VOIDED' }, 'CLOSED'],
] as Array<[Partial<OverseasFee>, string]>)('业务状态归并 %o → %s', (overrides, status) => {
expect(feeProcessingStatus(fee(overrides))).toBe(status)
})
it('业务界面只显示完成或失败,审核前及短暂处理过程不显示额外状态', () => {
for (const walletStatus of ['FREEZE_PENDING', 'FROZEN', 'SETTLE_PENDING', 'RELEASE_PENDING', 'RELEASED']) {
expect(feeProcessingLabel(fee({ walletStatus }))).toBe('—')
}
expect(feeProcessingLabel(fee({ reviewStatus: 'REJECTED' }))).toBe('—')
expect(feeProcessingLabel(fee({ walletStatus: 'SETTLE_FAILED' }))).toBe('处理失败')
expect(feeProcessingLabel(fee({ walletStatus: 'SETTLED' }))).toBe('已完成')
})
it('应付成功只表示推送成功,失败或等待回执时不能显示已付款', () => {
expect(feeProcessingLabel(fee({ direction: 'PAYABLE', collectionMode: 'BILL', walletStatus: 'NOT_REQUIRED', publicationStatus: 'PUBLISHED', deliveryStatus: 'PUBLISHED' }))).toBe('推送成功')
expect(feeProcessingLabel(fee({ direction: 'PAYABLE', collectionMode: 'BILL', publicationStatus: 'PUBLISHED', deliveryStatus: 'DEAD' }))).toBe('处理失败')
expect(feeProcessingLabel(fee({ direction: 'PAYABLE', collectionMode: 'BILL', publicationStatus: 'PUBLISHED', deliveryStatus: 'PENDING' }))).toBe('—')
})
it('重复提醒包含驳回记录且区分收付方向,金额按原币展示', () => {
const original = fee({ sourceFeeId: 'A', reviewStatus: 'REJECTED' })
const payable = fee({ sourceFeeId: 'B', direction: 'PAYABLE' })
const voided = fee({ sourceFeeId: 'C', sourceStatus: 'VOIDED' })
const differentItem = fee({ sourceFeeId: 'D', feeItemCode: 'OTHER' })
expect(sameProjectFees([original, payable, voided, differentItem], 'FEE1', 'RECEIVABLE')).toEqual([original])
expect(sameProjectFees([original], 'FEE1', 'RECEIVABLE', 'A')).toEqual([])
expect(sameProjectFeeSummary(original)).toBe('USD 10.00 · 已驳回')
})
})
@@ -0,0 +1,107 @@
import type { RowActionItem } from '@/shared/components/RowActions.vue'
import type { OverseasFee } from './api/overseasFees'
export const feePermissions = {
list: 'platform:overseas-fee:list', edit: 'platform:finance:fee-source:edit', review: 'platform:finance:fee-source:review',
} as const
export const businessLabels: Record<string, string> = {
LASTLEG_ORDER: '尾程订单', WAREHOUSE_TRANSFER: '仓储转运', RETURN_ORDER: '退货', RELABEL_ORDER: '换标',
US_TRUCK: '本土卡车', LOCAL_LOOSEBOARD: '本土散板', OVERSEAS_PROCUREMENT: '海外采购', DISCARD_ORDER: '废弃',
OVERSEAS_INBOUND: '入库', WAREHOUSE_SERVICE: '库内服务', DELIVERY_ORDER: '派送', OVERSEAS_CONTAINER: '海外柜次',
}
export const reviewLabels: Record<string, string> = { DRAFT: '草稿', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
/** 已终结来源优先显示终态,避免保留的审核快照误导用户继续审核。 */
export function feeReviewLabel(fee: Pick<OverseasFee, 'sourceStatus' | 'reviewStatus'>) {
if (fee.sourceStatus === 'VOIDED') return '已作废'
if (fee.sourceStatus === 'CANCELLED') return '已取消'
if (fee.sourceStatus === 'SUPERSEDED') return '已调整'
return reviewLabels[fee.reviewStatus] || fee.reviewStatus
}
/** 失败发生于冻结、实扣或释放的不同阶段,不能一律称为扣费失败。 */
export function feeFundingErrorLabel(fee: Pick<OverseasFee, 'walletStatus'>) {
if (fee.walletStatus === 'FREEZE_FAILED') return '冻结失败'
if (fee.walletStatus === 'RELEASE_FAILED') return '释放失败'
return '扣费异常'
}
/** 业务分类取服务端真实单据类型,派送费项目不改变换标单归属。 */
export function feeBusinessLabel(fee: Pick<OverseasFee, 'businessType' | 'displayBusinessType'>) {
const type = fee.displayBusinessType || fee.businessType
return businessLabels[type] || type
}
export const walletLabels: Record<string, string> = {
FREEZE_PENDING: '锁定中', FROZEN: '已锁定', FREEZE_FAILED: '锁定失败', SETTLE_PENDING: '扣费中', SETTLED: '已扣费',
SETTLE_FAILED: '扣费失败', RELEASE_PENDING: '释放中', RELEASED: '已释放', RELEASE_FAILED: '释放失败',
}
export const processingLabels: Record<string, string> = { COMPLETED: '已完成', FAILED: '处理失败' }
/** 余额实扣即完成;账单推送按接收回执判断,自动重试期间保留真实失败。 */
export function feeProcessingStatus(fee: Pick<OverseasFee, 'sourceStatus' | 'publicationStatus' | 'reviewStatus' | 'walletStatus' | 'deliveryStatus' | 'deliveryErrorMessage'>) {
// 业务关闭不代表冻结资金已释放;失败必须保留可见,方便恢复处理。
if (fee.walletStatus === 'RELEASE_FAILED' || (['FREEZE_PENDING', 'RELEASE_PENDING'].includes(fee.walletStatus || '')
&& (fee.deliveryStatus === 'DEAD' || !!fee.deliveryErrorMessage?.trim()))) return 'FAILED'
if (['FREEZE_PENDING', 'RELEASE_PENDING'].includes(fee.walletStatus || '')) return 'PROCESSING'
if (['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus) || fee.reviewStatus === 'REJECTED') return 'CLOSED'
if (fee.walletStatus === 'SETTLED') return 'COMPLETED'
if (fee.deliveryStatus === 'DEAD' || (['PENDING', 'PROCESSING'].includes(fee.deliveryStatus || '') && fee.deliveryErrorMessage?.trim()) || fee.walletStatus?.endsWith('_FAILED')) return 'FAILED'
if (fee.deliveryStatus === 'PENDING' || fee.deliveryStatus === 'PROCESSING' || fee.walletStatus?.endsWith('_PENDING')) return 'PROCESSING'
if (fee.publicationStatus === 'PUBLISHED') return 'COMPLETED'
return 'WAITING'
}
/** 应付完成仅代表财务已接收,不能用完成文案暗示供应商已收款。 */
export const feeProcessingLabel = (fee: OverseasFee) => feeProcessingStatus(fee) === 'COMPLETED' && fee.direction === 'PAYABLE'
? '推送成功' : processingLabels[feeProcessingStatus(fee)] || '—'
/** 只提醒当前有效版本的同项目,已驳回记录仍用于解释重新录费原因。 */
export function sameProjectFees(rows: OverseasFee[], code: string, direction: string, excludeId?: string) {
return rows.filter(row => row.feeItemCode === code && row.direction === direction && row.sourceStatus === 'ACTIVE' && row.sourceFeeId !== excludeId)
}
/** 重复收费提示同时交代原币金额和审核结果,不把不同币种直接相加。 */
export const sameProjectFeeSummary = (fee: OverseasFee) => `${feeMoney(fee.originalAmount, fee.originalCurrency)} · ${reviewLabels[fee.reviewStatus] || fee.reviewStatus}`
export const retryResultLabels: Record<string, string> = { SUCCESS: '重试成功', FAILED: '重试后仍失败', SUBMITTED: '已提交重试' }
export const actionLabels: Record<string, string> = {
REVIEW: '审核', APPROVE: '审核通过', REJECT: '驳回', RETRY_FREEZE: '重试锁定', RETRY_SETTLE: '重试扣费',
RETRY_RELEASE: '重试释放', SUBMIT: '提交审核', VOID: '作废', ADJUST: '调整',
RETRY_PUBLISH: '重试推送', RETRY_FUNDING_DELIVERY: '重试资金指令', ADJUST_RENT: '调整仓租',
}
export function feeStatus(fee: Pick<OverseasFee, 'sourceStatus' | 'publicationStatus' | 'deliveryStatus' | 'deliveryKind'> & { collectionMode?: string | null; walletStatus?: string | null }) {
if (fee.sourceStatus === 'VOIDED') return '已作废'
if (fee.sourceStatus === 'SUPERSEDED') return '已调整'
if (fee.sourceStatus === 'CANCELLED') return '已取消'
if (fee.collectionMode === 'WALLET' && fee.walletStatus === 'SETTLED') return '已扣费'
if (fee.deliveryKind === 'FUNDING' && fee.deliveryStatus === 'DEAD') return '资金指令发送失败'
if (fee.deliveryKind === 'FEE' && fee.deliveryStatus) {
if (fee.deliveryStatus === 'DEAD') return '推送失败'
if (fee.deliveryStatus !== 'PUBLISHED') return fee.collectionMode === 'WALLET' ? '已扣费,待推送' : '推送中'
}
if (fee.collectionMode === 'BILL') return fee.publicationStatus === 'PUBLISHED' ? '已推送结算' : '待推送结算'
return fee.walletStatus ? walletLabels[fee.walletStatus] || fee.walletStatus : fee.collectionMode === 'WALLET' ? '待锁定' : '—'
}
export function feeActions(fee: OverseasFee, can: (permission: string) => boolean, busy = false): RowActionItem[] {
const closed = fee.reviewStatus === 'REJECTED' || ['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus)
const canReview = can(feePermissions.review)
const visibleActions = fee.allowedActions.filter(action => action in actionLabels && !['APPROVE', 'REJECT', 'CANCEL'].includes(action))
.filter(action => !closed || ['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'].includes(action))
.filter(action => can(['VOID'].includes(action) ? feePermissions.review : feePermissions.edit))
.map(action => ({ key: action, label: actionLabels[action]!, priority: 20,
tone: ['VOID'].includes(action) ? 'danger' as const : 'primary' as const, disabled: busy }))
// 审核通过与驳回共用一个入口;取消不在业务单据费用行提供。
if (!closed && canReview && fee.allowedActions.some(action => ['APPROVE', 'REJECT'].includes(action))) {
visibleActions.push({ key: 'REVIEW', label: actionLabels.REVIEW!, priority: 90, tone: 'primary', disabled: busy })
}
return [{ key: 'DETAIL', label: '详情', priority: 100, disabled: busy }, ...visibleActions]
}
export function feeMoney(amount: number | string | null | undefined, currency = '') {
if (amount == null) return '—'
return `${currency} ${Number(amount).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`.trim()
}
export function feeTime(value?: string | null) {
if (!value) return '—'
// 只有服务端明确标记时区的时间才转换,兼容旧的无时区数据。
if (/(?:Z|[+-]\d{2}:\d{2})$/i.test(value)) {
const date = new Date(value)
if (!Number.isNaN(date.getTime())) {
const pad = (part: number) => String(part).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
}
return value.replace('T', ' ').slice(0, 19)
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import type { OverseasFeeRetryRecord } from './api/overseasFees'
import { retryFailureReason } from './overseasFeeRetryPresentation'
const failed = (errorMessage: string | null, action = '重试推送'): OverseasFeeRetryRecord => ({
requestId: 'retry-1', requestedAt: '', operatorName: '操作员', action, result: 'FAILED', errorMessage,
})
describe('费用重试失败原因', () => {
it('指出财务接收失败,但不把通用内部错误猜成字段故障', () => {
const text = retryFailureReason(failed('EventDeliveryFailure: Consumer rejected event: code=S0500, message=Internal server error, requestId=abc, traceId=def'))
expect(text).toBe('推送结算失败:财务系统处理异常,请联系管理员排查后重试。')
expect(text).not.toMatch(/字段|EventDeliveryFailure|requestId|traceId/)
})
it('只有明确的字段错误才说明字段长度不足', () => {
expect(retryFailureReason(failed("Data truncation: Data too long for column 'payload_hash' at row 1")))
.toBe('推送结算失败:财务系统的摘要字段长度不足,请联系管理员更新数据库后重试。')
})
it('扣费与余额不足明确指出客户账户及处理方式', () => {
expect(retryFailureReason(failed('可用额度不足', '重试扣费')))
.toBe('扣费失败:客户余额及可用授信不足,请充值或调整授信后重试。')
})
it('超时不能当作未扣款,提示先核查实际费用状态', () => {
expect(retryFailureReason(failed('Read timed out', '重试资金指令')))
.toBe('发送资金指令失败:未及时收到财务处理结果,请先核对费用状态再重试。')
})
it.each([
['Connection refused', '无法连接财务系统'],
["Table 'baiyue_finance.fin_inbox_event' doesn't exist", '缺少必要的表或字段'],
[null, '未返回具体原因'],
['UnknownFailure: unrecognized', '根据技术详情排查'],
['Consumer rejected event: code=FIN-EVT-017, message=费用来源与成功扣款的客户、业务或金额不匹配, requestId=abc', '费用来源与成功扣款的客户、业务或金额不匹配'],
])('显示已知原因,未知异常保留排查入口:%s', (raw, expected) => {
expect(retryFailureReason(failed(raw))).toContain(expected)
})
it('成功及已提交记录不展示过期失败原因', () => {
for (const result of ['SUCCESS', 'SUBMITTED'] as const) {
expect(retryFailureReason({ ...failed('旧失败原因'), result })).toBe('—')
}
})
})
@@ -0,0 +1,34 @@
import type { OverseasFeeRetryRecord } from './api/overseasFees'
/** 将已知失败转换成业务说明,保留原始异常供技术详情查看,不从通用错误猜测具体故障。 */
export function retryFailureReason(row: OverseasFeeRetryRecord): string {
if (row.result !== 'FAILED') return '—'
const step = ({ 重试推送: '推送结算', 重试扣费: '扣费', 重试锁定: '锁定费用', 重试释放: '释放费用', 重试资金指令: '发送资金指令' } as Record<string, string>)[row.action] || '费用处理'
const prefix = `${step}失败:`
const raw = row.errorMessage?.trim() || ''
if (!raw) return `${prefix}系统未返回具体原因,请联系管理员核查。`
if (/余额不足|额度不足|授信不足|insufficient (?:funds|balance|credit)/i.test(raw)) {
return `${prefix}客户余额及可用授信不足,请充值或调整授信后重试。`
}
if (/Data too long for column ['"`](?:payload_hash|source_hash)['"`]/i.test(raw)) {
return `${prefix}财务系统的摘要字段长度不足,请联系管理员更新数据库后重试。`
}
if (/doesn't exist|unknown column|缺少.*(?:表|字段)/i.test(raw)) {
return `${prefix}系统缺少必要的表或字段,请联系管理员更新数据库后重试。`
}
if (/timeout|timed out|超时/i.test(raw)) {
return `${prefix}未及时收到财务处理结果,请先核对费用状态再重试。`
}
if (/connection refused|connectexception|connection reset|连接失败|连接被拒绝|no available (?:server|instance)/i.test(raw)) {
return `${prefix}无法连接财务系统,请联系管理员检查服务后重试。`
}
if (/code=S0500|internal server error/i.test(raw)) {
return `${prefix}财务系统处理异常,请联系管理员排查后重试。`
}
// 保留服务端返回的中文业务原因,隐藏异常类名、请求编号和堆栈。
const message = raw.match(/\bmessage=(.*?)(?:,\s*(?:requestId|traceId)=|$)/i)?.[1]?.trim() || raw
if (/[\u4e00-\u9fff]/.test(message) && !/Exception|\bat\s+[\w.]+\(|\b(?:SELECT|INSERT|UPDATE|DELETE)\b|requestId=|traceId=/i.test(message)) {
return prefix + message
}
return `${prefix}系统处理异常,请联系管理员根据技术详情排查。`
}
@@ -0,0 +1,76 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { ApiResponseError } from '@/shared/api/http'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness'
import type { LocalLooseboardDetail } from '../types'
import LooseboardOrderDetailView from './LooseboardOrderDetailView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('../api/platformOverseasBusiness', () => ({ getLocalLooseboardOrder: vi.fn() }))
function order(overrides: Partial<LocalLooseboardDetail> = {}): LocalLooseboardDetail {
return {
id: '9007199254741001', orderNo: 'LB-TEST', customerId: '9007199254741999', fleetArrangement: null,
palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', productName: '测试货物', estimatedPalletCount: 2,
origin: 'LA', originAddress: '起运仓地址', destination: 'ONT', destinationAddress: '目的仓地址',
originContactName: '发货人', originContactPhone: '10001', destinationContactName: '收货人', destinationContactPhone: '10002',
pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', packageCount: 20,
totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD',
status: 'PENDING_ACCEPTANCE', version: 1, remark: '运输备注', createdBy: '7',
createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00', histories: [], ...overrides,
}
}
function mountDetail() {
return mount(LooseboardOrderDetailView, { global: { plugins: [ElementPlus], stubs: { BusinessDocumentFees: true, FleetArrangementSummary: true, DataTable: true } } })
}
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.clearAllMocks()
routing.route = reactive({ params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } })
vi.mocked(getLocalLooseboardOrder).mockResolvedValue(order())
})
describe('looseboard document fees', () => {
it('loads the customer scoped document and passes its exact identity to fees', async () => {
const wrapper = mountDetail(); await flushPromises()
expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-TEST', '9007199254741999')
expect(wrapper.findComponent(BusinessDocumentFees).props()).toMatchObject({
businessType: 'LOCAL_LOOSEBOARD', businessNo: 'LB-TEST', customerId: '9007199254741999', businessId: '9007199254741001',
})
expect(wrapper.text()).toContain('起运仓地址')
expect(wrapper.findAll('.status-badge')).toHaveLength(1)
})
it('does not expose fees or query a document without a real customer', async () => {
routing.route.query = {}
const wrapper = mountDetail(); await flushPromises()
expect(getLocalLooseboardOrder).not.toHaveBeenCalled()
expect(wrapper.findComponent(BusinessDocumentFees).exists()).toBe(false)
expect(wrapper.text()).toContain('缺少有效客户或订单信息')
})
it('keeps error metadata and retries before exposing fee operations', async () => {
vi.mocked(getLocalLooseboardOrder).mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0409', 409, undefined, 'LB-409')).mockResolvedValueOnce(order())
const wrapper = mountDetail(); await flushPromises()
expect(wrapper.text()).toContain('LB-409')
expect(wrapper.findComponent(BusinessDocumentFees).exists()).toBe(false)
wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
expect(wrapper.findComponent(BusinessDocumentFees).exists()).toBe(true)
})
it('discards an older response when navigating to another order', async () => {
let completeFirst!: (value: LocalLooseboardDetail) => void
vi.mocked(getLocalLooseboardOrder).mockImplementationOnce(() => new Promise(resolve => { completeFirst = resolve }))
.mockResolvedValueOnce(order({ id: '9007199254741002', orderNo: 'LB-NEXT' }))
const wrapper = mountDetail()
routing.route.params.orderId = 'LB-NEXT'; await flushPromises()
completeFirst(order()); await flushPromises()
expect(wrapper.findComponent(BusinessDocumentFees).props('businessId')).toBe('9007199254741002')
expect(wrapper.findComponent(BusinessDocumentFees).props('businessNo')).toBe('LB-NEXT')
})
})
@@ -0,0 +1,87 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import { poolOrderStatusLabels } from '../fleetArrangement'
import type { LocalLooseboardDetail, OverseasBusinessHistory } from '../types'
const route = useRoute(), router = useRouter()
const orderNo = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const detail = ref<LocalLooseboardDetail | null>(null), loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
let requestSequence = 0
const dateTime = (value: string | null | undefined) => value ? value.replace('T', ' ').slice(0, 16) : '—'
const statusLabels: Record<string, string> = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', ...poolOrderStatusLabels }
const statusLabel = (value: string | null) => value ? statusLabels[value] || value : '—'
const fields = computed(() => detail.value ? [
['客户编号', detail.value.customerId], ['商品名称', detail.value.productName], ['预计托数', detail.value.estimatedPalletCount],
['起运地', detail.value.origin], ['起运地详细地址', detail.value.originAddress], ['起运地联系人', detail.value.originContactName], ['起运地联系电话', detail.value.originContactPhone],
['目的地', detail.value.destination], ['目的地详细地址', detail.value.destinationAddress], ['目的地联系人', detail.value.destinationContactName], ['目的地联系电话', detail.value.destinationContactPhone],
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
['件数', detail.value.packageCount], ['总重量(kg)', detail.value.totalWeightKg], ['总体积(m³)', detail.value.totalVolumeCbm],
['预算总价', detail.value.budgetAmount == null ? '—' : `${detail.value.budgetAmount} ${detail.value.currency}`],
['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark],
] : [])
const operationLabels: Record<string, string> = { CREATE: '创建', UPDATE: '编辑', REVIEW: '订单审核', STATUS_CHANGE: '状态变更', CANCEL: '取消订单' }
const historyColumns: DataTableColumn[] = [
{ key: 'createdAt', label: '操作时间', minWidth: 170, formatter: (row: OverseasBusinessHistory) => dateTime(row.createdAt) },
{ key: 'operationType', label: '操作', minWidth: 130, formatter: (row: OverseasBusinessHistory) => operationLabels[row.operationType || ''] || row.operationType || '—' },
{ key: 'fromStatus', label: '原状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.fromStatus) },
{ key: 'toStatus', label: '新状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.toStatus) },
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 140 },
{ key: 'remark', prop: 'remark', label: '操作说明', minWidth: 240 },
]
/** 只用路由中的真实订单和客户查询,切换单据时丢弃旧请求结果。 */
async function load() {
const sequence = ++requestSequence
detail.value = null; error.value = null
if (!orderNo.value || !customerId.value || customerId.value === '0') {
loading.value = false; error.value = { message: '缺少有效客户或订单信息,请返回本土散板列表重新打开' }; return
}
loading.value = true
try { const result = await getLocalLooseboardOrder(orderNo.value, customerId.value); if (sequence === requestSequence) detail.value = result }
catch (cause) { if (sequence === requestSequence) error.value = getApiErrorDetails(cause, '本土散板订单加载失败,请稍后重试') }
finally { if (sequence === requestSequence) loading.value = false }
}
watch([orderNo, customerId], load, { immediate: true })
</script>
<template>
<AdminPage class="admin-density admin-workbench"><DetailPageLayout aria-label="本土散板订单详情" class="density-detail-layout workbench-panel is-flush is-page-surface">
<template #header>
<div class="density-detail-header looseboard-detail-header"><div class="density-detail-heading">
<el-button link class="density-back" @click="router.push({ name: 'overseas-business-looseboards' })"><ArrowLeft :size="16" />返回列表</el-button>
<h1 class="density-detail-title">本土散板订单 {{ detail?.orderNo || orderNo }}</h1>
<StatusBadge v-if="detail" :status="statusLabel(detail.status)" />
</div><el-button v-if="detail" @click="load">刷新订单</el-button></div>
</template>
<LoadErrorState v-if="error" title="本土散板订单暂时无法加载" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-skeleton v-else-if="loading" :rows="8" animated />
<template v-else-if="detail">
<section v-if="detail.fleetArrangement" class="looseboard-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
<section class="looseboard-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
<section class="looseboard-detail-section"><BusinessDocumentFees business-type="LOCAL_LOOSEBOARD" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section>
<section class="looseboard-detail-section"><h2>操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
</template>
</DetailPageLayout></AdminPage>
</template>
<style scoped>
.looseboard-detail-header { flex-wrap: wrap; }
.looseboard-detail-header .density-detail-heading { flex: 1; }
.looseboard-detail-section { margin-bottom: var(--admin-space-6); }
</style>
@@ -0,0 +1,215 @@
import ElementPlus from 'element-plus'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { ApiResponseError } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import * as api from '../api/overseasFees'
import type { OverseasFee } from '../api/overseasFees'
import OverseasFeesView from './OverseasFeesView.vue'
const can = vi.hoisted(() => vi.fn((_permission: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/overseasFees', () => ({
actOnOverseasFee: vi.fn(), getOverseasFee: vi.fn(), listOverseasFeeCustomers: vi.fn(), listOverseasFeeItems: vi.fn(),
listOverseasFeeRetries: vi.fn(),
listOverseasFees: vi.fn(), listBusinessFeeOptions: vi.fn(), createOverseasFee: vi.fn(),
}))
const fee: OverseasFee = {
direction: 'RECEIVABLE',
id: '9223372036854775001', sourceFeeId: '9223372036854775001', sourceVersion: 3,
customerId: '9223372036854775002', customerName: '测试客户', sourceType: 'OVERSEAS_WAREHOUSE_FEE', businessType: 'WAREHOUSE_TRANSFER',
businessId: '9223372036854775003', businessNo: 'WT202610220001', orderId: null, warehouseId: '9223372036854775004',
feeItemCode: 'HANDLING', feeItemName: '操作费', businessDate: '2026-10-22', currency: 'CNY', amount: 70,
originalCurrency: 'USD', originalAmount: 10, chargeableQuantity: null, quantityUnit: null, unitPrice: null,
periodStart: null, periodEnd: null, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', publicationStatus: 'UNPUBLISHED',
collectionMode: 'WALLET', walletStatus: 'FROZEN', walletErrorMessage: null, reviewReason: null, reviewedAt: null,
createdAt: '2026-10-22T10:00:00', updatedAt: '', allowedActions: ['APPROVE', 'REJECT', 'CANCEL'],
}
async function mountPage() {
const wrapper = mount(OverseasFeesView, { attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await flushPromises()
return wrapper
}
async function click(wrapper: VueWrapper, label: string, selector = 'button') {
const button = wrapper.findAll(selector).find(node => node.text().trim() === label)
expect(button, label).toBeDefined()
await button!.trigger('click'); await flushPromises()
}
function activeDialog(wrapper: VueWrapper) {
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('modelValue'))
expect(dialog).toBeDefined()
return dialog!.getComponent({ name: 'ElDialogContent' })
}
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.mocked(api.listOverseasFeeRetries).mockResolvedValue([])
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(api.listOverseasFeeCustomers).mockResolvedValue([{ customerId: fee.customerId, customerName: fee.customerName }])
vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'HANDLING', feeTypeName: '操作费', defaultPrice: 20 }])
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [fee], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(fee)
vi.mocked(api.actOnOverseasFee).mockResolvedValue(fee)
vi.mocked(api.createOverseasFee).mockResolvedValue(fee)
vi.mocked(api.listBusinessFeeOptions).mockResolvedValue([{ businessId: fee.businessId!, businessType: fee.businessType, businessNo: fee.businessNo,
customerId: fee.customerId, customerName: fee.customerName, warehouseId: fee.warehouseId, orderId: null }])
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('海外费用汇总与失败恢复', () => {
it('推送请求提交后继续刷新,真实成功时列表自动显示已完成', async () => {
const pushing = { ...fee, reviewStatus: 'APPROVED', publicationStatus: 'PUBLISHED',
walletStatus: 'NOT_REQUIRED', deliveryStatus: 'PENDING' as const, allowedActions: [] }
const wrapper = await mountPage()
try {
// 挂载后再使用假时钟,避免影响Element Plus自身挂载任务。
vi.useFakeTimers()
vi.mocked(api.listOverseasFees).mockResolvedValueOnce({ records: [pushing], total: 1, page: 1, size: 20, pages: 1 })
.mockResolvedValue({ records: [{ ...pushing, deliveryStatus: 'PUBLISHED' }], total: 1, page: 1, size: 20, pages: 1 })
wrapper.getComponent(QueryToolbar).vm.$emit('search', {})
await flushPromises()
expect(wrapper.get('.el-table__body-wrapper').text()).not.toContain('已完成')
await vi.advanceTimersByTimeAsync(2000); await flushPromises()
expect(wrapper.get('.el-table__body-wrapper').text()).toContain('已完成')
const calls = vi.mocked(api.listOverseasFees).mock.calls.length
await vi.advanceTimersByTimeAsync(4000)
expect(api.listOverseasFees).toHaveBeenCalledTimes(calls)
} finally { wrapper.unmount(); vi.useRealTimers() }
})
it('shows relabel delivery as 换标 in table and detail and filters by that business type', async () => {
const relabel = { ...fee, businessType: 'LASTLEG_ORDER', displayBusinessType: 'RELABEL_ORDER',
businessNo: 'TB202610220001', feeItemName: '派送费', originalCurrency: 'CNY', originalAmount: 66.70, amount: 66.70 }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [relabel], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(relabel)
const wrapper = await mountPage()
const table = wrapper.get('.el-table__body-wrapper')
expect(table.text()).toContain('换标'); expect(table.text()).toContain('派送费')
expect(table.text()).toContain('66.70'); expect(table.text()).not.toContain('尾程订单')
wrapper.getComponent(QueryToolbar).vm.$emit('search', { businessType: 'RELABEL_ORDER' })
await flushPromises()
expect(api.listOverseasFees).toHaveBeenLastCalledWith(expect.objectContaining({ businessType: 'RELABEL_ORDER', page: 1 }))
await click(wrapper, '详情', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).toContain('换标'); expect(dialog.text()).toContain('派送费')
expect(dialog.text()).not.toContain('尾程订单')
})
it('keeps approval and amount changes on the business document', async () => {
const allActions = { ...fee, allowedActions: ['APPROVE', 'REJECT', 'CANCEL', 'ADJUST', 'VOID', 'SUBMIT'] }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [allActions], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(allActions)
const wrapper = await mountPage()
wrapper.getComponent(QueryToolbar).vm.$emit('search', { customerId: fee.customerId, businessNo: ' WT2026 ', reviewStatus: 'PENDING' })
await flushPromises()
expect(api.listOverseasFees).toHaveBeenLastCalledWith(expect.objectContaining({ customerId: fee.customerId, businessNo: 'WT2026', reviewStatus: 'PENDING', page: 1 }))
await click(wrapper, '详情', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).toContain(fee.businessNo)
expect(dialog.text()).toContain('余额/授信')
const buttons = wrapper.findAll('button').map(button => button.text().trim())
for (const label of ['新增费用', '调整', '审核通过', '驳回', '取消费用', '作废', '提交审核', '重试锁定']) expect(buttons).not.toContain(label)
expect(api.createOverseasFee).not.toHaveBeenCalled()
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
})
it.each(['RETRY_FREEZE', 'RETRY_SETTLE', 'RETRY_RELEASE', 'RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'])('retries %s only after refreshing eligibility and preserving the original identity', async action => {
const failed = { ...fee, reviewStatus: 'APPROVED', walletStatus: 'SETTLE_FAILED', deliveryEventId: 'original-event', allowedActions: [action] }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(failed)
const labels: Record<string, string> = { RETRY_FREEZE: '重试锁定', RETRY_SETTLE: '重试扣费', RETRY_RELEASE: '重试释放', RETRY_PUBLISH: '重试推送', RETRY_FUNDING_DELIVERY: '重试资金指令' }
const wrapper = await mountPage()
await click(wrapper, labels[action]!, '.el-table__body-wrapper button')
expect(api.getOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId)
await click(activeDialog(wrapper), labels[action]!)
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, action, {
idempotencyKey: expect.any(String), ...(['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action) ? { expectedVersion: fee.sourceVersion, eventId: 'original-event' } : {}),
})
expect(api.listOverseasFees).toHaveBeenCalledTimes(2)
})
it('does not retry a fee that has already succeeded or belongs to another customer', async () => {
const failed = { ...fee, allowedActions: ['RETRY_SETTLE'] }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 })
const wrapper = await mountPage()
for (const refreshed of [{ ...failed, allowedActions: [] }, { ...failed, customerId: 'other' }]) {
vi.mocked(api.getOverseasFee).mockResolvedValueOnce(refreshed)
await click(wrapper, '重试扣费', '.el-table__body-wrapper button')
expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('modelValue'))).toBe(false)
}
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
})
it('blocks concurrent clicks and reuses the same request key after an uncertain failure', async () => {
const failed = { ...fee, allowedActions: ['RETRY_SETTLE'] }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(failed)
let reject!: (reason: unknown) => void
vi.mocked(api.actOnOverseasFee).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const wrapper = await mountPage()
await click(wrapper, '重试扣费', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
await click(dialog, '重试扣费'); await click(dialog, '重试扣费')
expect(api.actOnOverseasFee).toHaveBeenCalledTimes(1)
reject(new ApiResponseError('连接中断', 'UNAVAILABLE', 503)); await flushPromises()
expect(dialog.text()).toContain('连接中断')
const key = vi.mocked(api.actOnOverseasFee).mock.calls[0]![2].idempotencyKey
await click(dialog, '重试扣费')
expect(vi.mocked(api.actOnOverseasFee).mock.calls[1]![2].idempotencyKey).toBe(key)
})
it('hides retries without edit permission and keeps rejected fees read-only', async () => {
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [{ ...fee, allowedActions: ['RETRY_SETTLE'] }, { ...fee, sourceFeeId: 'rejected', reviewStatus: 'REJECTED', allowedActions: ['RETRY_SETTLE'] }], total: 2, page: 1, size: 20, pages: 1 })
can.mockReturnValue(false)
const wrapper = await mountPage()
expect(wrapper.findAll('.el-table__body-wrapper button').map(button => button.text())).not.toContain('重试扣费')
})
it.each(['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'])('recovers rejected frozen funds through the server-authorized %s action', async action => {
const rejected = { ...fee, reviewStatus: 'REJECTED', walletStatus: 'RELEASE_FAILED', deliveryEventId: 'release-event', allowedActions: [action, 'RETRY_SETTLE', 'RETRY_FREEZE'] }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [rejected], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(rejected)
const wrapper = await mountPage()
const buttons = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(buttons).not.toContain('重试扣费'); expect(buttons).not.toContain('重试锁定')
expect(wrapper.get('.el-table__body-wrapper').text()).toContain('处理失败')
const label = action === 'RETRY_RELEASE' ? '重试释放' : '重试资金指令'
await click(wrapper, label, '.el-table__body-wrapper button')
await click(activeDialog(wrapper), label)
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, action, expect.objectContaining({
idempotencyKey: expect.any(String), ...(action === 'RETRY_FUNDING_DELIVERY' ? { expectedVersion: fee.sourceVersion, eventId: 'release-event' } : {}),
}))
})
it('keeps automatic rent available as a summary filter', async () => {
vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'FEE_TYPE_077', feeTypeName: '仓储费', defaultPrice: null }])
const wrapper = await mountPage()
expect(wrapper.getComponent(QueryToolbar).props('fields').find(field => field.key === 'feeItemCode')?.options).toContainEqual({ value: 'FEE_TYPE_077', label: '仓储费' })
})
it('shows only final results and queries failure filtering on the server for either direction', async () => {
const wrapper = await mountPage()
const fields = wrapper.getComponent(QueryToolbar).props('fields')
expect(fields.find(field => field.key === 'processingStatus')?.options?.map(option => option.label)).toEqual(['已完成', '处理失败'])
expect(fields.some(field => field.key === 'walletStatus')).toBe(false)
wrapper.getComponent(QueryToolbar).vm.$emit('search', { direction: 'PAYABLE', processingStatus: 'FAILED' })
await flushPromises()
expect(api.listOverseasFees).toHaveBeenLastCalledWith(expect.objectContaining({ direction: 'PAYABLE', processingStatus: 'FAILED', page: 1 }))
expect(wrapper.get('.el-table__body-wrapper').text()).toContain('—')
expect(wrapper.get('.el-table__body-wrapper').text()).not.toContain('已锁定')
})
})
it('海外费用管理只查询仓租依据,审核和调价留在对应单据详情', async () => {
const rent: OverseasFee = { ...fee, feeItemCode: 'FEE_TYPE_077', feeItemName: '仓储费', allowedActions: ['APPROVE', 'REJECT', 'ADJUST_RENT'],
storageRent: { outboundNo: 'OUT-1', outboundAt: '2026-10-22T10:00:00Z', calculatedAmount: 10, chargedAmount: null,
lines: [{ lotId: '1', inboundAt: '2026-10-01T10:00:00Z', quantity: 4, days: 21, calculatedAmount: 10 }] } }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [rent], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(rent)
const wrapper = await mountPage()
expect(wrapper.findAll('[role="tab"]').map(item => item.text())).toEqual(['费用明细', '仓租库存'])
const actions = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(actions).not.toContain('调整仓租')
expect(actions).not.toContain('审核仓租')
await click(wrapper, '详情', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).toContain('2026-10-01'); expect(dialog.text()).toContain('2026-10-22')
expect(dialog.text()).toContain('实扣金额')
expect(dialog.text()).toContain('OUT-1')
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
})
@@ -0,0 +1,186 @@
<script setup lang="ts">
import '../overseasFeeDetails.css'
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions from '@/shared/components/RowActions.vue'
import type { FeeItemOption } from '../components/OverseasFeeEditor.vue'
import StorageRentFeeDetails from '../components/StorageRentFeeDetails.vue'
import StorageRentPanel from '../components/StorageRentPanel.vue'
import OverseasFeeRetryHistory from '../components/OverseasFeeRetryHistory.vue'
import { useFeeStatusRefresh } from '../useFeeStatusRefresh'
import { actOnOverseasFee, getOverseasFee, listOverseasFeeCustomers, listOverseasFeeItems, listOverseasFees, type OverseasFee } from '../api/overseasFees'
import { actionLabels, businessLabels, feeBusinessLabel, feeMoney, feePermissions, feeProcessingLabel, feeTime, feeReviewLabel, feeFundingErrorLabel, processingLabels, reviewLabels } from '../overseasFeePresentation'
const tab = ref('fees'), loading = ref(false), error = ref<ApiErrorDetails | null>(null), contextError = ref<ApiErrorDetails | null>(null)
const page = ref(1), pageSize = ref(20), total = ref(0), rows = ref<OverseasFee[]>([])
const customers = ref<Array<{ customerId: string; customerName: string }>>([]), items = ref<FeeItemOption[]>([])
const contextLoading = ref(false), detailLoading = ref(''), detail = ref<OverseasFee | null>(null), detailOpen = ref(false)
const filters = ref<QueryModel>({ customerId: '', businessNo: '', businessType: '', feeItemCode: '', reviewStatus: '', processingStatus: '', direction: 'RECEIVABLE' })
const applied = ref({ ...filters.value })
const access = useAccessPreviewStore()
const retryFee = ref<OverseasFee | null>(null), retryOpen = ref(false), retryAction = ref(''), retryKey = ref(''), retrySaving = ref(false), retryError = ref<ApiErrorDetails | null>(null)
const retryActions = ['RETRY_FREEZE', 'RETRY_SETTLE', 'RETRY_RELEASE', 'RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY']
function rowActions(fee: OverseasFee) {
const disabled = !!detailLoading.value || retrySaving.value
const actions = [{ key: 'DETAIL', label: '详情', disabled }]
const closed = fee.reviewStatus === 'REJECTED' || ['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus)
if (access.can(feePermissions.edit)) for (const action of retryActions) {
if (closed && !['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'].includes(action)) continue
if (fee.allowedActions.includes(action)) actions.push({ key: action, label: actionLabels[action]!, disabled })
}
return actions
}
const fields = computed<QueryField[]>(() => [
{ key: 'direction', type: 'select', label: '收付方向', placeholder: '收付方向', options: [{ value: 'RECEIVABLE', label: '应收客户' }, { value: 'PAYABLE', label: '应付供应商' }], props: { 'aria-label': '收付方向', clearable: false } },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '客户', width: 200, options: customers.value.map(row => ({ value: row.customerId, label: row.customerName })), props: { filterable: true, loading: contextLoading.value, 'aria-label': '客户' } },
{ key: 'businessNo', type: 'input', label: '业务单号', placeholder: '业务单号', width: 220, props: { 'aria-label': '业务单号' } },
{ key: 'businessType', type: 'select', label: '业务类型', placeholder: '业务类型', options: Object.entries(businessLabels).map(([value, label]) => ({ value, label })), props: { 'aria-label': '业务类型' } },
{ key: 'feeItemCode', type: 'select', label: '费用项目', placeholder: '费用项目', options: items.value.map(row => ({ value: row.feeCode, label: row.feeTypeName })), props: { filterable: true, 'aria-label': '费用项目' } },
{ key: 'reviewStatus', type: 'select', label: '审核状态', placeholder: '审核状态', options: Object.entries(reviewLabels).map(([value, label]) => ({ value, label })), props: { 'aria-label': '审核状态' } },
{ key: 'processingStatus', type: 'select', label: '费用状态', placeholder: '费用状态', options: Object.entries(processingLabels).map(([value, label]) => ({ value, label: value === 'COMPLETED' && filters.value.direction === 'PAYABLE' ? '推送成功' : label })), props: { 'aria-label': '费用状态' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'businessNo', label: '业务单号', minWidth: 185 },
{ prop: 'businessType', label: '业务类型', width: 115, formatter: feeBusinessLabel },
{ prop: 'feeItemName', label: '费用项目', minWidth: 145 },
{ prop: 'supplierName', label: '供应商', minWidth: 150, defaultVisible: false },
{ key: 'amount', label: '结算金额(CNY)', width: 145, align: 'right', formatter: (row: OverseasFee) => feeMoney(row.amount) },
{ prop: 'collectionMode', label: '结算方式', width: 115, formatter: (row: OverseasFee) => row.direction === 'PAYABLE' ? '推送结算' : row.collectionMode === 'BILL' ? '账单结算' : '余额/授信' },
{ prop: 'reviewStatus', label: '审核状态', width: 100, slotName: 'reviewStatus' },
{ key: 'chargeStatus', label: '费用状态', minWidth: 115, formatter: feeProcessingLabel },
{ prop: 'businessDate', label: '费用日期', width: 110 },
{ prop: 'createdAt', label: '创建时间', minWidth: 165, defaultVisible: false, formatter: (row: OverseasFee) => feeTime(row.createdAt) },
]
let loadSequence = 0, detailSequence = 0
const statusRefresh = useFeeStatusRefresh(rows, () => load(true), computed(() => tab.value !== 'rent' && !error.value))
async function load(background = false) {
if (!background) statusRefresh.reset()
const current = ++loadSequence; if (!background) loading.value = true; error.value = null
try {
const params = Object.fromEntries(Object.entries(applied.value).map(([key, value]) => [key, value ? String(value).trim() : undefined]))
const data = await listOverseasFees({ ...params, page: page.value, size: pageSize.value, direction: params.direction || 'RECEIVABLE' })
if (current === loadSequence) {
rows.value = data.records; total.value = data.total
const latestDetail = rows.value.find(row => row.sourceFeeId === detail.value?.sourceFeeId)
if (latestDetail) detail.value = latestDetail
}
} catch (failure) { if (current === loadSequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(failure, '费用列表加载失败') } }
finally { if (current === loadSequence) loading.value = false }
}
async function loadContext() {
contextLoading.value = true; contextError.value = null
const results = await Promise.allSettled([listOverseasFeeCustomers(), listOverseasFeeItems()])
if (results[0].status === 'fulfilled') customers.value = results[0].value
else contextError.value = getApiErrorDetails(results[0].reason, '客户选项加载失败')
if (results[1].status === 'fulfilled') items.value = results[1].value
else contextError.value = getApiErrorDetails(results[1].reason, '费用项目加载失败')
contextLoading.value = false
}
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
async function freshFee(row: OverseasFee) {
const current = ++detailSequence; detailLoading.value = row.sourceFeeId
try { const data = await getOverseasFee(row.sourceFeeId); return current === detailSequence ? data : null }
catch (failure) { ElMessage.error(getApiErrorDetails(failure, '费用详情加载失败').message); return null }
finally { if (current === detailSequence) detailLoading.value = '' }
}
async function showDetail(row: OverseasFee) {
if (detailLoading.value) return
const current = await freshFee(row)
if (current) { detail.value = current; detailOpen.value = true }
}
async function onAction(action: string, row: OverseasFee) {
if (detailLoading.value || retrySaving.value) return
if (action === 'DETAIL') { await showDetail(row); return }
if (!retryActions.includes(action) || !rowActions(row).some(item => item.key === action)) return
const current = await freshFee(row)
if (!current) return
if (current.sourceFeeId !== row.sourceFeeId || current.customerId !== row.customerId || !rowActions(current).some(item => item.key === action)) {
ElMessage.warning('费用状态已变化,请刷新后重试'); await load(); return
}
retryFee.value = current; retryAction.value = action; retryKey.value = crypto.randomUUID(); retryError.value = null; retryOpen.value = true
}
async function confirmRetry() {
const fee = retryFee.value, action = retryAction.value
if (!fee || retrySaving.value || !rowActions(fee).some(item => item.key === action) || !access.can(feePermissions.edit)) return
const delivery = ['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action)
if (delivery && !fee.deliveryEventId) return
retrySaving.value = true; retryError.value = null
try {
await actOnOverseasFee(fee.sourceFeeId, action, { idempotencyKey: retryKey.value,
...(delivery ? { expectedVersion: fee.sourceVersion, eventId: fee.deliveryEventId } : {}),
})
retryOpen.value = false; ElMessage.success('重试已提交'); await load()
} catch (failure) { retryError.value = getApiErrorDetails(failure, '重试失败') }
finally { retrySaving.value = false }
}
// 返回费用页签时更新审核与资金结果,保留用户原来的查询条件。
watch(tab, () => { if (tab.value === 'fees') void load() })
watch([page, pageSize], () => { if (tab.value !== 'rent') void load() })
onMounted(() => { void load(); void loadContext() })
</script>
<template>
<AdminPage class="admin-density admin-workbench">
<ListPageLayout aria-label="海外费用管理">
<section class="workbench-table is-page-surface">
<el-tabs v-model="tab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
<el-tab-pane label="费用明细" name="fees">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" appearance="integrated" toolbar-placement="filters" preference-key="overseas-fees" row-key="sourceFeeId" empty-text="暂无费用明细">
<template #filters><QueryToolbar v-model="filters" ariaLabel="海外费用筛选" :fields="fields" :loading="loading" @search="search" /></template>
<template #notices>
<LoadErrorState v-if="error" title="费用列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="contextError" title="筛选选项加载失败" :message="contextError.message" :loading="contextLoading" @retry="loadContext"><template #details><ApiErrorMeta :details="contextError" /></template></LoadErrorState>
</template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as OverseasFee)" @action="onAction($event, row as OverseasFee)" /></template>
<template #reviewStatus="{ row }"><el-tag :type="(row as OverseasFee).reviewStatus === 'APPROVED' ? 'success' : (row as OverseasFee).reviewStatus === 'REJECTED' ? 'danger' : 'info'">{{ feeReviewLabel(row as OverseasFee) }}</el-tag></template>
<template #pagination-left>共 {{ total }} 条费用</template>
</DataTable>
</el-tab-pane>
<el-tab-pane label="仓租库存" name="rent" lazy><StorageRentPanel :customers="customers" /></el-tab-pane>
</el-tabs>
</section>
</ListPageLayout>
<AppDialog v-model="detailOpen" title="费用详情" width="860px" class="admin-density-dialog admin-workbench is-dialog" :show-confirm="false" cancel-text="关闭" fullscreenable>
<template v-if="detail">
<el-descriptions :column="2" border class="density-descriptions overseas-fee-descriptions">
<el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="业务单号">{{ detail.businessNo }}</el-descriptions-item>
<el-descriptions-item label="业务类型">{{ feeBusinessLabel(detail) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item>
<el-descriptions-item v-if="detail.feeRemark" label="费用说明" :span="2">{{ detail.feeRemark }}</el-descriptions-item>
<el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '—' }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ feeMoney(detail.originalAmount, detail.originalCurrency) }}</el-descriptions-item><el-descriptions-item label="结算方式">{{ detail.direction === 'PAYABLE' ? '推送结算' : detail.collectionMode === 'BILL' ? '账单结算' : '余额/授信' }}</el-descriptions-item>
<el-descriptions-item v-if="detail.originalCurrency !== detail.currency" label="结算金额">{{ feeMoney(detail.amount, detail.currency) }}</el-descriptions-item><el-descriptions-item v-if="detail.originalCurrency !== detail.currency" label="结算汇率">{{ detail.exchangeRate || '—' }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item>
<el-descriptions-item label="审核状态">{{ feeReviewLabel(detail) }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ feeProcessingLabel(detail) }}</el-descriptions-item>
<el-descriptions-item label="费用日期">{{ detail.businessDate }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ feeTime(detail.createdAt) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.periodStart || detail.periodEnd" label="计费期间" :span="2">{{ detail.periodStart }} — {{ detail.periodEnd }}</el-descriptions-item>
<el-descriptions-item v-if="detail.reviewReason" label="审核意见" :span="2">{{ detail.reviewReason }}</el-descriptions-item>
<el-descriptions-item v-if="detail.walletErrorMessage" :label="feeFundingErrorLabel(detail)" :span="2">{{ detail.walletErrorMessage }}<span v-if="detail.sourceStatus === 'VOIDED'">(此费用已作废,无需继续审核)</span></el-descriptions-item>
<el-descriptions-item v-if="detail.deliveryErrorMessage" label="推送异常" :span="2">{{ detail.deliveryErrorMessage }}</el-descriptions-item>
</el-descriptions>
<StorageRentFeeDetails :fee="detail" />
<OverseasFeeRetryHistory v-if="detailOpen" :fee-id="detail.sourceFeeId" :refresh-key="[detail.deliveryStatus, detail.deliveryErrorMessage, detail.walletStatus].join('|')" />
</template>
</AppDialog>
<AppDialog v-model="retryOpen" :title="actionLabels[retryAction] || '重试'" width="560px" class="admin-density-dialog admin-workbench is-dialog" :confirm-text="actionLabels[retryAction] || '重试'" :confirm-loading="retrySaving" lock-while-submitting @confirm="confirmRetry">
<el-descriptions v-if="retryFee" :column="1" border class="density-descriptions">
<el-descriptions-item label="业务单号">{{ retryFee.businessNo }}</el-descriptions-item>
<el-descriptions-item label="费用项目">{{ retryFee.feeItemName }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ feeMoney(retryFee.amount, retryFee.currency) }}</el-descriptions-item>
<el-descriptions-item label="当前状态">{{ feeProcessingLabel(retryFee) }}</el-descriptions-item>
</el-descriptions>
<LoadErrorState v-if="retryError" title="重试失败" :message="retryError.message" :loading="retrySaving" @retry="confirmRetry"><template #details><ApiErrorMeta :details="retryError" /></template></LoadErrorState>
</AppDialog>
</AdminPage>
</template>
@@ -23,6 +23,7 @@ import { overseasBusinessPermissions } from '../permissions'
import { statusLabel, money, dateTime, text, measurement, feeStatus, feePurposes } from '../procurementPresentation'
import type { PlatformProcurement, ProcurementFile, ProcurementFee, ProcurementReceipt, ProcurementCarton, ProcurementShipment } from '../procurementTypes'
import { directionLabel, regionText } from '../procurementDirection'
const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const access = useAccessPreviewStore()
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
@@ -30,10 +31,12 @@ const detail = ref<PlatformProcurement | null>(null); const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const operationError = ref<ApiErrorDetails | null>(null)
const downloading = ref<string | null>(null)
const order = computed(() => detail.value?.customerView.order)
const t = (text: string) => regionText(text, order.value?.procurementDirection)
const fields = computed(() => [
['采购方向', directionLabel(order.value?.procurementDirection)],
['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['商品规格', order.value?.productSpecification], ['采购数量', order.value?.purchaseQuantity],
['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['有效预算总额', money(detail.value?.customerView.budget.totalAmount, detail.value?.customerView.budget.currency)],
['预计到海外仓', dateTime(order.value?.estimatedArrivalAt)], ['实际收货仓', order.value?.actualWarehouseName],
[t('预计到海外仓'), dateTime(order.value?.estimatedArrivalAt)], ['实际收货仓', order.value?.actualWarehouseName],
['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['下单收货地址', order.value?.receivingAddress], ['内部备注', detail.value?.internalRemark],
] as const)
const purchaseFields = computed(() => [
@@ -43,14 +46,14 @@ const purchaseFields = computed(() => [
] as const)
const receiptColumns: DataTableColumn[] = [
{ key: 'receiptNo', prop: 'receiptNo', label: '实收编号', minWidth: 180 },
{ key: 'leg', label: '收货段', width: 100, formatter: (row: ProcurementReceipt) => row.leg === 'OVERSEAS' ? '海外' : '国内' },
{ key: 'leg', label: '收货段', width: 100, formatter: (row: ProcurementReceipt) => t(row.leg === 'OVERSEAS' ? '海外' : '国内') },
{ key: 'source', label: '来源箱', minWidth: 160, formatter: (row: ProcurementReceipt) => detail.value?.physical.cartons.find(box => box.id === row.sourceCartonId)?.cartonNo || '—' },
{ key: 'goodQuantity', prop: 'goodQuantity', label: '合格件数', width: 100 }, { key: 'damagedQuantity', prop: 'damagedQuantity', label: '破损件数', width: 100 }, { key: 'extraQuantity', prop: 'extraQuantity', label: '多货件数', width: 100 },
{ key: 'receivedAt', label: '实收时间', minWidth: 160, formatter: (row: ProcurementReceipt) => dateTime(row.receivedAt) },
{ key: 'active', label: '记录状态', width: 100, formatter: (row: ProcurementReceipt) => row.active ? '有效' : '已更正' },
]
const dispositionColumns: DataTableColumn[] = [
{ key: 'leg', label: '发生段', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => row.leg === 'OVERSEAS' ? '海外' : '国内' },
{ key: 'leg', label: '发生段', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => t(row.leg === 'OVERSEAS' ? '海外' : '国内') },
{ key: 'source', label: '实收或原箱来源', minWidth: 180, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => (row.receipt_id ? detail.value?.physical.receipts.find(receipt => receipt.id === row.receipt_id)?.receiptNo : row.source_carton_id ? detail.value?.physical.cartons.find(box => box.id === row.source_carton_id)?.cartonNo : order.value?.orderNo) || '—' },
{ key: 'kind', label: '差异', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => ({ SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货' })[row.kind] },
{ key: 'quantity', prop: 'quantity', label: '处置件数', width: 100 }, { key: 'reason', prop: 'reason', label: '处置依据', minWidth: 220 },
@@ -68,7 +71,7 @@ const shipmentColumns: DataTableColumn[] = [
{ key: 'shippedAt', label: '交寄时间', minWidth: 160, formatter: (row: ProcurementShipment) => dateTime(row.shippedAt) },
]
const feeColumns: DataTableColumn[] = [
{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'purpose', label: '用途', minWidth: 140, formatter: (row: ProcurementFee) => feePurposes[row.purpose] || '待核对' },
{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'purpose', label: '用途', minWidth: 140, formatter: (row: ProcurementFee) => t(feePurposes[row.purpose] || '待核对') },
{ key: 'name', prop: 'name', label: '费用项目', minWidth: 120 }, { key: 'original', label: '原币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.originalAmount, row.originalCurrency) },
{ key: 'amount', label: '人民币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.amount, row.currency) },
{ key: 'rate', label: '汇率 / 月份', minWidth: 150, formatter: (row: ProcurementFee) => `${text(row.exchangeRate)} / ${text(row.exchangeRateMonth)}` },
@@ -152,7 +155,7 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
<template>
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="operationError" />
<DetailPageLayout class="purchase-detail density-detail-layout workbench-panel is-flush is-page-surface" aria-label="采购订单履约详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="order" :status="statusLabel(order.status)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑内部备注</el-button></div></div></template>
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="order" :status="statusLabel(order.status, order.procurementDirection)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑内部备注</el-button></div></div></template>
<template v-if="order" #summary><dl class="density-detail-overview" aria-label="采购订单概览"><div><dt>客户</dt><dd>{{ text(order.customerName) }}</dd></div><div><dt>计划采购仓</dt><dd>{{ text(order.plannedWarehouseName) }}</dd></div><div><dt>采购数量</dt><dd>{{ order.purchaseQuantity }}</dd></div></dl></template>
<LoadErrorState v-if="loadError" title="采购详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="detail-grid">
@@ -164,7 +167,7 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
<section><h3>分次实收记录</h3><div class="workbench-table"><DataTable :data="detail.physical.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无实收记录" /></div></section>
<section><h3>差异处置记录</h3><div class="workbench-table"><DataTable :data="detail.physical.dispositions" :columns="dispositionColumns" row-key="id" empty-text="暂无差异处置" /></div></section>
<section><h3>逐箱装货清单 · {{ detail.physical.packingStatus === 'CONFIRMED' ? '已确认' : detail.physical.packingStatus === 'DRAFT' ? '草稿' : '未建立' }}<span v-if="detail.physical.packingVersion">(第 {{ detail.physical.packingVersion }} 版)</span></h3><div class="workbench-table"><DataTable :data="detail.physical.cartons" :columns="cartonColumns" row-key="id" empty-text="暂无箱明细" /></div></section>
<section v-if="detail.transport.domesticCargo.length"><h3>国内包装与可寄货物核对</h3><div class="workbench-table"><DataTable :data="detail.transport.domesticCargo" :columns="cartonColumns" row-key="cartonId" /></div></section>
<section v-if="detail.transport.domesticCargo.length"><h3>{{ t('国内包装与可寄货物核对') }}</h3><div class="workbench-table"><DataTable :data="detail.transport.domesticCargo" :columns="cartonColumns" row-key="cartonId" /></div></section>
<section v-for="group in labelGroups" :key="group.leg"><h3>{{ group.title }}最近保存面单 · 第 {{ group.value?.version }} 版<InlineHelp title="面单核对说明"><p>包装或实收变化后须重新核对面单;是否可交寄以节点校验结果为准。</p></InlineHelp></h3><dl v-if="group.value" class="density-fields"><div class="wide"><dt>寄件信息</dt><dd>{{ group.value.sender.name }} · {{ group.value.sender.phone }} · {{ group.value.sender.address }}</dd></div><div class="wide"><dt>收件信息</dt><dd>{{ group.value.receiver.name }} · {{ group.value.receiver.phone }} · {{ group.value.receiver.address }}</dd></div></dl><div class="workbench-table"><DataTable :data="group.value?.labels || []" :columns="labelColumns" row-key="cartonId" /></div></section>
<section><h3>两段实际交寄</h3><div class="workbench-table"><DataTable :data="detail.customerView.shipments" :columns="shipmentColumns" empty-text="暂无实际交寄记录" /></div></section>
<section><h3>费用与资金结果<InlineHelp title="费用与资金说明"><p>样品货款与实际费用独立结算;预估国际运费仅冻结。资金结果和费用来源关联分别核对。</p></InlineHelp></h3><div class="workbench-table"><DataTable :data="detail.customerView.fees" :columns="feeColumns" row-key="feeNo" empty-text="暂无核定费用" /></div></section>
@@ -17,6 +17,7 @@ import { listOverseasPurchaseCustomerOptions, listOverseasPurchases } from '../a
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasPurchaseCustomerOption, OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types'
import { directionLabel } from '../procurementDirection'
const router = useRouter(); const access = useAccessPreviewStore(); const auth = useAuthStore()
const rows = ref<OverseasPurchaseSummary[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1); const pageSize = ref(20); const total = ref(0)
@@ -27,7 +28,7 @@ const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 210, options: customerOptions.value.map((item) => ({ label: item.customerName, value: item.customerId })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'purchaseCategory', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
{ key: 'productName', type: 'input', label: '商品名称', placeholder: '请输入商品名称', width: 210 },
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label: ({ WAIT_OVERSEAS_RECEIPT: '待起运仓收货', OVERSEAS_RECEIVING: '起运地收货中', WAIT_OVERSEAS_SHIPMENT: '待国际交寄', DOMESTIC_RECEIVING: '目的地收货中', WAIT_CHARGES: '待目的地核费' } as Record<string, string>)[value] || label })) },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
@@ -41,6 +42,7 @@ const columns: DataTableColumn[] = [
{ key: 'purchaseQuantity', prop: 'purchaseQuantity', label: '采购数量', width: 100, align: 'right' },
{ key: 'status', label: '状态', minWidth: 145, slotName: 'status' },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: OverseasPurchaseSummary) => formatDateTime(row.createdAt) },
{ key: 'procurementDirection', label: '采购方向', minWidth: 170, formatter: (row: OverseasPurchaseSummary) => directionLabel(row.procurementDirection) },
]
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
let generation = 0; let inFlight: Promise<void> | null = null; let requestKey = ''
@@ -74,13 +76,13 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-business-purchases" row-key="id" empty-text="暂无符合筛选条件的海外采购订单">
<template #toolbar-start><h1>海外采购订单</h1></template>
<template #toolbar-start><h1>采购订单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="search" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as OverseasPurchaseSummary)">详情</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(overseasBusinessPermissions.purchaseEdit)" size="small" @click="open(row as OverseasPurchaseSummary, true)">编辑</el-button></div></template>
<template #status="{ row }"><el-tag :type="statusTone(row.status)" size="small" effect="light">{{ statusLabel(row.status) }}</el-tag></template>
<template #status="{ row }"><el-tag :type="statusTone(row.status)" size="small" effect="light">{{ statusLabel(row.status, row.procurementDirection) }}</el-tag></template>
<template #pagination-left>共 {{ total }} 条海外采购订单</template>
</DataTable></div></section>
</ListPageLayout></AdminPage>
@@ -0,0 +1,68 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import RowActions from '@/shared/components/RowActions.vue'
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions as permissions } from '../permissions'
import type { LocalLooseboardOrder } from '../types'
import TransportOrdersView from './TransportOrdersView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { query: {} } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('../api/platformOverseasBusiness', () => ({ listLocalLooseboardOrders: vi.fn(), listUsTruckOrders: vi.fn() }))
vi.mock('@/modules/overseas-master-data', () => ({ inquiryReturnTarget: () => null, TransportRouteDialog: { template: '<div />' }, routeLocation: (name: string) => ({ name }) }))
function order(): LocalLooseboardOrder {
return {
id: '9007199254741001', orderNo: 'LB-TEST', customerId: '9007199254741999', fleetArrangement: null,
palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', productName: '测试货物', estimatedPalletCount: 2,
origin: 'LA', destination: 'ONT', originContactName: '发货人', originContactPhone: '10001', destinationContactName: '收货人', destinationContactPhone: '10002',
pickupAt: null, deliveryAt: null, packageCount: 20, totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD',
status: 'PENDING_ACCEPTANCE', version: 1, createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00',
}
}
function mountList(domain: 'truck' | 'looseboard', edit = false) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '操作员', account: 'tester', roles: [] }
auth.permissions = [permissions.truckList, permissions.looseboardList, ...(edit ? [permissions.looseboardEdit] : [])]
useAccessPreviewStore().initializeAfterLogin()
return mount(TransportOrdersView, { props: { domain }, global: { plugins: [pinia, 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,
} } })
}
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(listLocalLooseboardOrders).mockResolvedValue({ records: [order()], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
})
describe('transport document entry', () => {
it('lets list-only users open looseboard details without edit permission', async () => {
const wrapper = mountList('looseboard'); await flushPromises()
const actions = wrapper.findComponent(RowActions)
expect(actions.text()).toContain('详情')
expect(actions.text()).not.toContain('编辑')
actions.vm.$emit('action', 'detail'); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-looseboard-detail', params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } })
})
it('preserves the permitted looseboard edit entry alongside its detail', async () => {
const wrapper = mountList('looseboard', true); await flushPromises()
const actions = wrapper.findComponent(RowActions)
expect(actions.text()).toContain('编辑')
actions.vm.$emit('action', 'edit'); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-looseboard-edit', params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } })
})
it('keeps truck mode on its own list API', async () => {
mountList('truck'); await flushPromises()
expect(listUsTruckOrders).toHaveBeenCalledOnce()
expect(listLocalLooseboardOrders).not.toHaveBeenCalled()
})
})
@@ -67,7 +67,7 @@ const truckColumns: DataTableColumn[] = [
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.createdAt) },
]
const looseboardColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalLooseboardOrder) => row.transportType ? transportTypeLabels[row.transportType] : '—' }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'originContactName', prop: 'originContactName', label: '起运地联系人', minWidth: 125 }, { key: 'originContactPhone', prop: 'originContactPhone', label: '起运地联系电话', minWidth: 155 },
{ key: 'destinationContactName', prop: 'destinationContactName', label: '目的地联系人', minWidth: 125 }, { key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '目的地联系电话', minWidth: 155 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
@@ -106,6 +106,18 @@ function search(model: QueryModel) { applied.value = { ...model }; if (page.valu
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW') }
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
/** 单据详情保留订单与客户范围,费用在原业务单中处理。 */
function openDetail(row: LocalTransportOrder | LocalLooseboardOrder) {
void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-detail' : 'overseas-business-truck-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } })
}
/** 散板详情不依赖编辑资格,已安排承运的订单也能处理自己的费用。 */
function looseboardActions(row: LocalLooseboardOrder): RowActionItem[] {
return [
{ key: 'detail', label: '详情', priority: 10 },
{ key: 'edit', label: '编辑', visible: !row.fleetArrangement && access.can(editPermission.value), disabled: !canEdit(row) },
{ key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement },
]
}
function truckActions(row: LocalTransportOrder): RowActionItem[] {
return [
{ key: 'detail', label: row.status === 'PENDING_REVIEW' && !row.fleetArrangement && access.can(overseasBusinessPermissions.truckStatus) ? '审核' : '详情', priority: 10 },
@@ -113,10 +125,10 @@ function truckActions(row: LocalTransportOrder): RowActionItem[] {
{ key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement },
]
}
function handleTruckAction(key: string, row: LocalTransportOrder) {
function handleTruckAction(key: string, row: LocalTransportOrder | LocalLooseboardOrder) {
if (key === 'edit') openEdit(row)
else if (key === 'arrangement') showArrangement(row)
else if (key === 'detail') void router.push({ name: 'overseas-business-truck-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } })
else if (key === 'detail') openDetail(row)
}
let activatedOnce = false
onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
@@ -134,7 +146,7 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route
<template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template>
<template #actions="{ row }"><RowActions v-if="!isLooseboard" :actions="truckActions(row as LocalTransportOrder)" text-actions @action="handleTruckAction($event, row as LocalTransportOrder)" /><div v-else class="row-actions density-actions admin-text-actions"><el-button v-if="row.fleetArrangement" class="density-action" @click="showArrangement(row as LocalTransportOrder | LocalLooseboardOrder)">承运安排</el-button><el-tooltip v-if="!row.fleetArrangement && access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
<template #actions="{ row }"><RowActions :actions="isLooseboard ? looseboardActions(row as LocalLooseboardOrder) : truckActions(row as LocalTransportOrder)" text-actions @action="handleTruckAction($event, row as LocalTransportOrder | LocalLooseboardOrder)" /></template>
<template #status="{ row }"><el-tag :type="statusTones[row.status as LocalTransportStatus | LocalLooseboardStatus] || 'info'" size="small" effect="light">{{ statusLabels[row.status] || '—' }}</el-tag></template>
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
<template #routeMap="{ row }"><el-button v-if="row.orderNo" link type="primary" @click="openRoute(row as LocalLooseboardOrder)">查看路线</el-button></template>
@@ -7,6 +7,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import TruckOrderActions from '../components/TruckOrderActions.vue'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import { executeUsTruckCommand, getUsTruckOrder } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions as permissions } from '../permissions'
import type { LocalTransportDetail } from '../types'
@@ -34,7 +35,7 @@ function mountDetail(manage = true) {
const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '操作员', account: 'tester', roles: [] }
auth.permissions = [permissions.truckList, ...(manage ? [permissions.truckStatus] : [])]
useAccessPreviewStore().initializeAfterLogin()
return mount(TruckOrderDetailView, { global: { plugins: [pinia, ElementPlus], stubs: { teleport: true } } })
return mount(TruckOrderDetailView, { global: { plugins: [pinia, ElementPlus], stubs: { teleport: true, BusinessDocumentFees: true } } })
}
async function click(wrapper: VueWrapper, label: string) {
const button = wrapper.findAll('button').find(item => item.text() === label)
@@ -56,6 +57,13 @@ beforeEach(() => {
afterEach(() => vi.unstubAllGlobals())
describe('truck review and fulfillment', () => {
it('scopes fee operations to the loaded truck document', async () => {
const wrapper = mountDetail(); await flushPromises()
expect(wrapper.findComponent(BusinessDocumentFees).props()).toMatchObject({
businessType: 'US_TRUCK', businessNo: 'TR-TEST', customerId: '9007199254741999', businessId: '9007199254741001',
})
})
it('groups the business number, single status and actions in the shared detail header', async () => {
const wrapper = mountDetail(); await flushPromises()
const header = wrapper.find('.detail-page-layout__header')
@@ -14,6 +14,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getUsTruckOrder } from '../api/platformOverseasBusiness'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import TruckOrderActions from '../components/TruckOrderActions.vue'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import { truckStatusLabels } from '../truckWorkflow'
import type { LocalTransportDetail, LocalTransportPallet, OverseasBusinessHistory } from '../types'
@@ -85,6 +86,7 @@ watch([orderNo, customerId], load, { immediate: true })
<template v-else-if="detail">
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
<section class="truck-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
<section class="truck-detail-section"><BusinessDocumentFees business-type="US_TRUCK" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section>
<section class="truck-detail-section"><h2>托盘记录</h2><div class="workbench-table"><DataTable :data="detail.pallets" :columns="palletColumns" empty-text="暂无托盘记录,独立订单首次实收后生成托盘" /></div></section>
<section class="truck-detail-section"><h2>审核与操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
</template>
@@ -0,0 +1,9 @@
export type ProcurementDirection = 'OVERSEAS_TO_DOMESTIC' | 'DOMESTIC_TO_OVERSEAS'
export const procurementDirections: Record<ProcurementDirection, string> = {
OVERSEAS_TO_DOMESTIC: '海外采购,发往国内', DOMESTIC_TO_OVERSEAS: '国内采购,发往海外',
}
export function directionLabel(direction?: string | null) { return direction === 'DOMESTIC_TO_OVERSEAS' ? procurementDirections.DOMESTIC_TO_OVERSEAS : procurementDirections.OVERSEAS_TO_DOMESTIC }
// 两个历史段编码表示起运段和目的地段,展示区域按订单方向转换。
export function regionText(text: string, direction?: string | null) {
return direction === 'DOMESTIC_TO_OVERSEAS' ? text.replaceAll('海外', '__ORIGIN__').replaceAll('国内', '海外').replaceAll('__ORIGIN__', '国内') : text
}
@@ -1,5 +1,3 @@
import { isAxiosError } from 'axios'
import { ApiResponseError } from '@/shared/api/http'
import type { ProcurementCommand, ProcurementCommandBody } from './api/procurementCommands'
export interface PendingProcurement { command: ProcurementCommand; payload: ProcurementCommandBody }
const commands = new Set(['review', 'purchase', 'purchase/confirm', 'budget-authorizations', 'planned-warehouse', 'cancel', 'overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'packing', 'packing/confirm', 'packing/withdraw', 'dispositions', 'receipts/correct', 'dispositions/revoke', 'labels', 'international-estimate', 'labels/apply', 'domestic-repack', 'overseas-shipment', 'customer-shipment', 'charges/confirm', 'fees/retry', 'fees/terminate', 'fees/release-estimate', 'fees/correct-target', 'fees/refund', 'fees/replace-failed', 'fees/additional', 'control-notes', 'control-notes/resolve', 'actual-warehouse/correct', 'exception-close'])
@@ -13,11 +11,12 @@ export function readPendingProcurement(raw: string | null): PendingProcurement |
if (!value || typeof value !== 'object' || !('command' in value) || !('payload' in value) || !commands.has(String(value.command))) throw new Error('invalid pending command')
const payload = value.payload
if (!payload || typeof payload !== 'object' || !('version' in payload) || !Number.isSafeInteger(payload.version) || Number(payload.version) < 0 || !('idempotencyKey' in payload) || typeof payload.idempotencyKey !== 'string' || !payload.idempotencyKey) throw new Error('invalid pending payload')
const fields = new Set(['version', 'idempotencyKey', 'reason', 'decision', 'remark', 'purchaseWarehouseId', 'warehouseId', 'productName', 'specification', 'quantity', 'amount', 'currency', 'purchasedAt', 'estimatedArrivalAt', 'voucherFileId', 'leg', 'sourceCartonId', 'receiptId', 'dispositionId', 'goodQuantity', 'damagedQuantity', 'extraQuantity', 'receivedAt', 'kind', 'packingVersion', 'cartons', 'labelVersion', 'receiver', 'addressVoucherFileId', 'labels', 'cartonIds', 'businessDate', 'appliedAt', 'shippedAt', 'cartonId', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'feeId', 'targetOriginalAmount', 'internationalFreight', 'duty', 'domesticFreight', 'otherDetermination', 'otherFees', 'targetType', 'targetId', 'blocking', 'clientSummary', 'noteId', 'resolutions', 'customerVoucherFileId', 'financialConclusion', 'name'])
const fields = new Set(['version', 'idempotencyKey', 'reason', 'decision', 'remark', 'purchaseWarehouseId', 'procurementDirection', 'warehouseId', 'productName', 'specification', 'quantity', 'amount', 'currency', 'purchasedAt', 'estimatedArrivalAt', 'voucherFileId', 'leg', 'sourceCartonId', 'receiptId', 'dispositionId', 'goodQuantity', 'damagedQuantity', 'extraQuantity', 'receivedAt', 'kind', 'packingVersion', 'cartons', 'labelVersion', 'receiver', 'addressVoucherFileId', 'labels', 'cartonIds', 'businessDate', 'appliedAt', 'shippedAt', 'cartonId', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'feeId', 'targetOriginalAmount', 'internationalFreight', 'duty', 'domesticFreight', 'otherDetermination', 'otherFees', 'targetType', 'targetId', 'blocking', 'clientSummary', 'noteId', 'resolutions', 'customerVoucherFileId', 'financialConclusion', 'name'])
const body = payload as Record<string, unknown>
if (Object.keys(body).some(field => !fields.has(field))) throw new Error('unexpected pending field')
const nonempty = (field: string) => typeof body[field] === 'string' && !!body[field].trim()
const positiveId = (field: string) => nonempty(field) && /^[1-9]\d*$/.test(String(body[field]))
if (body.procurementDirection !== undefined && !['OVERSEAS_TO_DOMESTIC', 'DOMESTIC_TO_OVERSEAS'].includes(String(body.procurementDirection))) throw new Error('invalid procurement direction')
if (value.command === 'review') {
if (!['APPROVE', 'REJECT'].includes(String(body.decision)) || (body.decision === 'APPROVE' && !positiveId('purchaseWarehouseId'))) throw new Error('invalid pending review')
} else if (!nonempty('reason')) throw new Error('missing pending reason')
@@ -125,8 +124,3 @@ export function readPendingProcurement(raw: string | null): PendingProcurement |
if (value.command === 'fees/additional' && (!nonempty('name') || !nonempty('businessDate') || !['CNY', 'USD'].includes(String(body.currency)) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0 || !positiveId('voucherFileId'))) throw new Error('invalid independent financial fee')
return value as PendingProcurement
}
export function definitelyRejected(error: unknown) {
const status = error instanceof ApiResponseError ? error.status : isAxiosError(error) ? error.response?.status : undefined
const code = error instanceof ApiResponseError ? error.code : ''
return [400, 401, 403, 404, 409, 422].includes(status ?? 0) || ['A0400', 'A0401', 'A0403', 'A0404', 'A0409', 'A0422'].includes(code)
}
@@ -1,3 +1,4 @@
import { regionText } from './procurementDirection'
import type { Amount, ProcurementFee } from './procurementTypes'
export const procurementStatuses: Record<string, string> = {
@@ -10,7 +11,7 @@ export const feePurposes: Record<string, string> = {
SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费',
DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用', ADJUSTMENT: '追加费用', REFUND: '退款',
}
export function statusLabel(status: string | null | undefined) { return status ? procurementStatuses[status] || '待核对进度' : '—' }
export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || '待核对进度' : '—', direction) }
export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
if (status === 'SHIPPED_TO_CUSTOMER') return 'success'
if (status === 'REJECTED' || status === 'EXCEPTION_CLOSED') return 'danger'
@@ -1,5 +1,6 @@
export type Amount = number | string
export interface ProcurementSummary {
procurementDirection?: import('./procurementDirection').ProcurementDirection
orderNo: string
purchaseCategory: string
productName: string
+4 -2
View File
@@ -5,6 +5,7 @@ import { overseasBusinessRoutes } from './routes'
describe('overseas business routes', () => {
it('keeps the frozen paths and permissions', () => {
expect(overseasBusinessRoutes.map((route) => [route.path, route.meta?.permission])).toEqual([
['overseas-business/fees', 'platform:overseas-fee:list'],
['overseas-business/purchases', permission.purchaseList],
['overseas-business/purchases/:purchaseId', permission.purchaseList],
['overseas-business/purchases/:purchaseId/edit', permission.purchaseEdit],
@@ -14,15 +15,16 @@ describe('overseas business routes', () => {
['overseas-business/trucks/:orderId/edit', permission.truckEdit],
['overseas-business/looseboards', permission.looseboardList],
['overseas-business/looseboards/new', permission.looseboardCreate],
['overseas-business/looseboards/:orderId', permission.looseboardList],
['overseas-business/looseboards/:orderId/edit', permission.looseboardEdit],
])
})
it('adds the requested truck detail without opening unrelated routes', () => {
it('opens both transport document details without granting edit access', () => {
const paths = overseasBusinessRoutes.map((route) => route.path)
expect(paths).not.toContain('overseas-business/purchases/new')
expect(paths).toContain('overseas-business/trucks/:orderId')
expect(paths).not.toContain('overseas-business/looseboards/:orderId')
expect(paths).toContain('overseas-business/looseboards/:orderId')
expect(JSON.stringify(overseasBusinessRoutes)).not.toContain('permissionAnyOf')
})
})
+12
View File
@@ -2,6 +2,12 @@ import type { RouteRecordRaw } from 'vue-router'
import { overseasBusinessPermissions as permission } from './permissions'
export const overseasBusinessRoutes = [
{
path: 'overseas-business/fees',
name: 'overseas-business-fees',
component: () => import('./pages/OverseasFeesView.vue'),
meta: { title: '海外费用管理', permission: 'platform:overseas-fee:list' },
},
{
path: 'overseas-business/purchases',
name: 'overseas-business-purchases',
@@ -61,6 +67,12 @@ export const overseasBusinessRoutes = [
props: { domain: 'looseboard' },
meta: { title: '新增本土散板订单', description: '创建海外本土散板订单', permission: permission.looseboardCreate, showPageHeading: false },
},
{
path: 'overseas-business/looseboards/:orderId',
name: 'overseas-business-looseboard-detail',
component: () => import('./pages/LooseboardOrderDetailView.vue'),
meta: { title: '本土散板订单详情', permission: permission.looseboardList, showPageHeading: false },
},
{
path: 'overseas-business/looseboards/:orderId/edit',
name: 'overseas-business-looseboard-edit',
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { storageRentBillingShare, storageRentChargeStatus, storageRentGeneratedAmounts, storageRentSummaryStatus, storageRentTime } from './storageRentPresentation'
import type { StorageRentLot } from './api/storageRent'
it('distinguishes missing charges, genuine free rent and incomplete settlement', () => {
const lot = { status: 'CLEARED', originalQuantity: 4, billingQuantity: 1, billingUnit: 'PALLET', chargeCount: 2, settledChargeCount: 2, settledAmount: 10 } as StorageRentLot
expect(storageRentSummaryStatus(lot)).toBe('已扣款')
expect(storageRentSummaryStatus({ ...lot, settledChargeCount: 1 })).toBe('有待完成扣款')
expect(storageRentSummaryStatus({ ...lot, settledAmount: 0 })).toBe('免租 · 已结算')
expect(storageRentSummaryStatus({ ...lot, chargeCount: 0 })).toBe('仓租记录待核对')
expect(storageRentBillingShare(2, lot)).toBe('0.5 托')
})
describe('仓租原币汇总显示', () => {
it('displays server currency groups without adding different currencies', () => {
expect(storageRentGeneratedAmounts({ generatedAmounts: [{ currency: 'USD', amount: 12 }, { currency: 'CNY', amount: 23 }], generatedAmount: null, generatedCurrency: null })).toEqual(['USD 12.00', 'CNY 23.00'])
expect(storageRentGeneratedAmounts({ generatedAmounts: [{ currency: 'EUR', amount: 0 }], generatedAmount: 0, generatedCurrency: 'EUR' })).toEqual(['EUR 0.00'])
})
it('keeps scalar single currency totals and empty totals free of assumed currencies', () => {
expect(storageRentGeneratedAmounts({ generatedAmounts: [], generatedAmount: 8.5, generatedCurrency: 'CNY' })).toEqual(['CNY 8.50'])
expect(storageRentGeneratedAmounts({ generatedAmounts: [], generatedAmount: 0, generatedCurrency: null })).toEqual(['0.00'])
expect(storageRentGeneratedAmounts({ generatedAmounts: [], generatedAmount: null, generatedCurrency: null })).toEqual(['—'])
})
})
describe('仓租扣费状态', () => {
it('labels only settled zero-original-amount charges as rent free', () => {
expect(storageRentChargeStatus({ status: 'SETTLED', originalAmount: 0 })).toBe('免租')
expect(storageRentChargeStatus({ status: 'SETTLED', originalAmount: '0.00' })).toBe('免租')
expect(storageRentChargeStatus({ status: 'SETTLED', originalAmount: 12 })).toBe('已扣费')
expect(storageRentChargeStatus({ status: 'SETTLED', originalAmount: null })).toBe('已扣费')
expect(storageRentChargeStatus({ status: 'CONSUMED', originalAmount: 0 })).toBe('扣费中')
expect(storageRentChargeStatus({ status: 'SETTLE_FAILED', originalAmount: 0 })).toBe('扣费失败')
})
})
describe('仓租带时区时间显示', () => {
it('converts UTC and equivalent offset timestamps before displaying local business dates', () => {
expect(storageRentTime('2026-10-22T18:30:45Z', 'Asia/Shanghai')).toBe('2026-10-23 02:30:45')
expect(storageRentTime('2026-10-23T02:30:45+08:00', 'Asia/Shanghai')).toBe('2026-10-23 02:30:45')
expect(storageRentTime('2026-10-22T02:30:45Z', 'America/Los_Angeles')).toBe('2026-10-21 19:30:45')
})
it('defaults to the browser timezone and handles missing or invalid values', () => {
const value = '2026-10-22T18:30:45Z'
expect(storageRentTime(value)).toBe(storageRentTime(value, Intl.DateTimeFormat().resolvedOptions().timeZone))
expect(storageRentTime(null)).toBe('—')
expect(storageRentTime('invalid')).toBe('—')
})
})
@@ -0,0 +1,41 @@
import { feeMoney } from './overseasFeePresentation'
import type { StorageRentLot } from './api/storageRent'
export function storageRentSourceType(type?: string | null) {
const labels: Record<string, string> = { LASTLEG_ORDER: '头程尾程订单', WAREHOUSE_TRANSFER: '仓储转运单', RETURN_ORDER: '退货单' }
return type ? labels[type] || type : '—'
}
export function storageRentGeneratedAmounts(lot: Pick<StorageRentLot, 'generatedAmounts' | 'generatedAmount' | 'generatedCurrency'>) {
if (lot.generatedAmounts?.length) return lot.generatedAmounts.map(item => feeMoney(item.amount, item.currency))
return [feeMoney(lot.generatedAmount, lot.generatedCurrency || '')]
}
export function storageRentChargeStatus(charge: { status: string; originalAmount: number | string | null }) {
if (charge.status === 'SETTLED' && charge.originalAmount != null && Number(charge.originalAmount) === 0) return '免租'
const labels: Record<string, string> = { PENDING_REVIEW: '待审核', REJECTED: '已驳回', REVIEW_FREEZE_PENDING: '调整锁定中', REVIEW_FREEZE_FAILED: '调整锁定失败', FREEZE_PENDING: '锁定中', READY: '待出库', CONSUMED: '扣费中', SETTLED: '已扣费', SETTLE_FAILED: '扣费失败', FAILED: '锁定失败', EXPIRED: '已过期', CANCELLED: '已取消' }
return labels[charge.status] || charge.status
}
export function storageRentSummaryStatus(lot: StorageRentLot) {
if (lot.chargeCount == null) return '待核对'
if (!Number(lot.chargeCount)) return lot.status === 'CLEARED' ? '仓租记录待核对' : '尚未出库计租'
if (Number(lot.settledChargeCount) < Number(lot.chargeCount)) return '有待完成扣款'
return Number(lot.settledAmount) === 0 ? '免租 · 已结算' : '已扣款'
}
export function storageRentBillingShare(quantity: number, lot: StorageRentLot) {
if (!(Number(lot.originalQuantity) > 0) || lot.billingQuantity == null) return '—'
const units = Number(quantity) * Number(lot.billingQuantity) / Number(lot.originalQuantity)
return `${Number(units.toFixed(6))} ${lot.billingUnit === 'PALLET' ? '托' : lot.billingUnit}`
}
export function storageRentTime(value?: string | null, timeZone?: string) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('sv-SE', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
hourCycle: 'h23', timeZone,
}).format(date)
}
+1
View File
@@ -12,6 +12,7 @@ export interface OverseasPurchaseQuery {
purchaseWarehouseId?: string; createdFrom?: string; createdTo?: string
}
export interface OverseasPurchaseSummary {
procurementDirection?: import('./procurementDirection').ProcurementDirection
id: string; orderNo: string; customerId: string; customerName: string | null
purchaseCategory: string; productName: string; productSpecification: string | null
purchaseQuantity: number; budgetUnitPrice: string | null; currency: string
@@ -0,0 +1,72 @@
import { effectScope, nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OverseasFee } from './api/overseasFees'
import { feeProcessingLabel } from './overseasFeePresentation'
import { useFeeStatusRefresh } from './useFeeStatusRefresh'
const pending = () => ({ sourceFeeId: '1', sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED',
publicationStatus: 'PUBLISHED', deliveryStatus: 'PENDING', walletStatus: 'NOT_REQUIRED' }) as OverseasFee
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
describe('费用最终状态自动回查', () => {
it('余额已扣成功不再等待旧费用推送,显示完成并停止回查', async () => {
const scope = effectScope(), rows = ref([{ ...pending(), walletStatus: 'SETTLED', deliveryErrorMessage: '旧推送失败' }]), enabled = ref(true)
const refresh = vi.fn(async () => {})
scope.run(() => useFeeStatusRefresh(rows, refresh, enabled))
expect(feeProcessingLabel(rows.value[0]!)).toBe('已完成')
await vi.advanceTimersByTimeAsync(20000)
expect(refresh).not.toHaveBeenCalled()
scope.stop()
})
it.each(['PUBLISHED', 'DEAD'] as const)('读取实际 %s 回执后停止,显示最终业务结果', async deliveryStatus => {
const scope = effectScope(), rows = ref([pending()]), enabled = ref(true)
const refresh = vi.fn(async () => { rows.value = [{ ...pending(), deliveryStatus }] })
scope.run(() => useFeeStatusRefresh(rows, refresh, enabled))
rows.value = [pending()]; await nextTick()
await vi.advanceTimersByTimeAsync(2000)
expect(refresh).toHaveBeenCalledTimes(1)
expect(feeProcessingLabel(rows.value[0]!)).toBe(deliveryStatus === 'PUBLISHED' ? '已完成' : '处理失败')
await vi.advanceTimersByTimeAsync(10000)
expect(refresh).toHaveBeenCalledTimes(1)
scope.stop()
})
it('离开当前列表或销毁组件后不再回查,回到列表可恢复', async () => {
const scope = effectScope(), rows = ref<OverseasFee[]>([]), enabled = ref(true)
const refresh = vi.fn(async () => {})
scope.run(() => useFeeStatusRefresh(rows, refresh, enabled))
rows.value = [pending()]; await nextTick()
enabled.value = false; await nextTick()
await vi.advanceTimersByTimeAsync(2000); expect(refresh).not.toHaveBeenCalled()
enabled.value = true; await nextTick()
await vi.advanceTimersByTimeAsync(2000); expect(refresh).toHaveBeenCalledTimes(1)
scope.stop()
await vi.advanceTimersByTimeAsync(10000); expect(refresh).toHaveBeenCalledTimes(1)
})
it('长时间未完成只回查有限次数,人工重新操作后可开启新一轮', async () => {
const scope = effectScope(), rows = ref<OverseasFee[]>([]), enabled = ref(true)
const refresh = vi.fn(async () => { rows.value = [pending()] })
const control = scope.run(() => useFeeStatusRefresh(rows, refresh, enabled))!
rows.value = [pending()]; await nextTick()
await vi.advanceTimersByTimeAsync(700000); expect(refresh).toHaveBeenCalledTimes(90)
control.reset(); rows.value = [pending()]; await nextTick()
await vi.advanceTimersByTimeAsync(2000); expect(refresh).toHaveBeenCalledTimes(91)
scope.stop()
})
it('自动重试中的失败保持可见,超过一分钟仍跟进最终接收结果', async () => {
const scope = effectScope(), rows = ref([{ ...pending(), deliveryErrorMessage: 'S0500' }]), enabled = ref(true)
const refresh = vi.fn(async () => {})
scope.run(() => useFeeStatusRefresh(rows, refresh, enabled))
expect(feeProcessingLabel(rows.value[0]!)).toBe('处理失败')
await vi.advanceTimersByTimeAsync(80000)
expect(refresh).toHaveBeenCalledTimes(32)
rows.value = [{ ...pending(), deliveryStatus: 'PUBLISHED', deliveryErrorMessage: '' }]; await nextTick()
expect(feeProcessingLabel(rows.value[0]!)).toBe('已完成')
await vi.advanceTimersByTimeAsync(20000); expect(refresh).toHaveBeenCalledTimes(32)
scope.stop()
})
})
@@ -0,0 +1,28 @@
import { onScopeDispose, watch, type Ref } from 'vue'
import type { OverseasFee } from './api/overseasFees'
/** 跟踪尚未收到回执的事件;失败后自动重试期间继续回查,离开页面即停止。 */
export function useFeeStatusRefresh(rows: Ref<OverseasFee[]>, refresh: () => Promise<void>, enabled: Ref<boolean>) {
let timer: ReturnType<typeof setTimeout> | undefined
let remaining = 90
let stopped = false
const pending = () => rows.value.some(row => row.walletStatus !== 'SETTLED' && (['PENDING', 'PROCESSING'].includes(row.deliveryStatus || '') || row.walletStatus?.endsWith('_PENDING')))
function clear() { if (timer !== undefined) clearTimeout(timer); timer = undefined }
function schedule() {
clear()
if (stopped || !enabled.value || !pending() || remaining <= 0) return
timer = setTimeout(async () => {
timer = undefined
if (stopped || !enabled.value) return
remaining--
await refresh()
schedule()
}, remaining > 60 ? 2000 : 10000)
}
watch([rows, enabled], () => {
if (!pending()) remaining = 90
schedule()
}, { immediate: true })
onScopeDispose(() => { stopped = true; clear() })
return { reset: () => { remaining = 90; clear() } }
}
+6 -4
View File
@@ -5,7 +5,7 @@
- 运输路线地图:`components/TransportRouteDialog.vue`经`index.ts`公开导出,排车装车后、本土散板列表、本土卡车表单共用;来源字段、缺值规则和原型映射唯一见`PROTOTYPE_MATRIX.md`“运输路线地图”。仓库引用经`routeLocations.ts`复用Warehouse列表/详情解析完整地址(warehouse:list);解析失败不加载地图。仅规划路线,不接GPS、不用固定样例,不在车辆主档顶部重复放入口。
- 当前散板需求:正式列表使用`pages/DispatchCapacityDemandsView.vue`、`api/dispatchCapacityDemands.ts`,只读消费日常派送排车的`/dispatches/capacity-demands`摘要;发布选项在排车新建/维护车辆中默认勾选,规则见拼车主契约当前页面范围。不得以实托数推算标准托位,未知余位明确待确认,不读取客户或司机私人资料。原登记和旧车次入口代码保留隐藏。
- 当前散板需求:正式列表使用`pages/DispatchCapacityDemandsView.vue`、`api/dispatchCapacityDemands.ts`,只读消费日常派送排车的`/dispatches/capacity-demands`摘要;发布选项仅在智能排车建单时选择,默认不勾选;维护车辆不提供发布修改,预约日历建单不自动发布,重发先取消原排车再重新创建,规则见拼车主契约当前页面范围。不得以实托数推算标准托位,未知余位明确待确认,不读取客户或司机私人资料。原登记和旧车次入口代码保留隐藏。
- 保留的内部拼车实现:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。
@@ -15,7 +15,7 @@
- 车队报价`pages/FleetQuotesView.vue`、`api/fleetQuotes.ts`沿`fleet-quotes-v1.yaml`区分采购/销售;原型通用菜单,编辑780px、CSV导入1040px,未知价格与零价分开。出车记录`pages/TripOperationsView.vue`、`api/tripOperations.ts`沿`fleet-operations-v1.yaml`按实际DEPART查询,列表/图表同筛选,缺少成本或财务事实不推为零;分别使用fleet:quote:list与fleet:operations:list,写入/导出独立权限。
- 车次“直接成本”页签:`components/TripCostPanel.vue`、`api/tripCosts.ts`,选择页签才读取一次分页及整趟汇总;登记/更正分别使用trip:cost-create/cost-edit,历史沿trip:list。编辑先取最新历史快照版本;不修改内容不写入,未知新增结果保留原键及内容重试。按原币种展示,不将未登记或未分摊当零,不推定财务付款;费用表操作列在左,分摊表只读。后端合同为`fleet-trip-costs-v1.yaml`。
- 车次“直接成本”页签:`components/TripCostPanel.vue`、`api/tripCosts.ts`,选择页签才读取一次分页及整趟汇总;登记/更正分别使用trip:cost-create/cost-edit,历史沿trip:list。编辑先取最新历史快照版本;不修改内容不写入,请求失败即释放提交态并保留表单,下一次点击按当前输入发起新请求,不保存失败请求用于重放。按原币种展示,不将未登记或未分摊当零,不推定财务付款;费用表操作列在左,分摊表只读。后端合同为`fleet-trip-costs-v1.yaml`。
- 车辆概览中的派送与更换记录由`components/VehicleFulfillmentTable.vue`、`api/vehicleFulfillment.ts`读取,替代原提示文字。派送要求vehicle:list与delivery:list,更换要求vehicle:list与trip:list;无对应权限不请求接口。两个可见分区各一次分页请求,不逐行加载;换车/换页忽略旧响应,错误保留重试及诊断。更换显示原/新车辆与司机、历史车次及排车业务号;现有快照没有操作人姓名时显示“—”,不用内部操作者ID回退。
@@ -24,7 +24,7 @@
- 共享车次接口在 `api/trips.ts`,类型在 `tripTypes.ts`,后端合同为 `fleet-trips-v1.yaml`;覆盖计划/分配、客户排车关联、实际装载、货物结案和分别结束车辆/司机任务。接口保留稳定幂等键、版本与货物hash,长ID按字符串透传;数量和容量展示服务端结果,不在浏览器推算整车可发或可结案。新页面承载按 `PROTOTYPE_MATRIX.md` 确定,不因API存在推导页面能力。
- 当前日常作业统一从排车列表进入,侧栏`fleet-trips`为hidden;“出车记录”保留实际发车后的运营查询。原`pages/TripsView.vue`、`pages/TripDetailView.vue`及附加能力代码保留,不能作为新排车的前置步骤。旧详情仍按页签按需读取,资源候选聚焦或搜索时查询,不逐行读取。新整车审核、PDA真实拣货、双端装车确认及实际发车规则以`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`为准;详情只有已确认四页签,不把旧八项附加功能自动迁入或以派送完成自动释放资源。
- 车辆实际托位/载重kg/容积m³分别由服务端返回,三项同填正数或全空待确认;不按车型名称推算。占用期间主档关键变更由服务端拒绝,下载中心 XLSX 导出使用车牌业务标识并包含容量字段。
- 车辆运维在 `VehicleDetailView.vue` 的页签内,表格与编辑框为 `components/VehicleCareTable.vue`、`VehicleCareEditor.vue`;`api/vehicleCare.ts` 对接 `fleet-vehicle-records-v1.yaml`。保养/加油680px,维修/故障/救援/异常620px居中弹框,行操作按 `PROTOTYPE_MATRIX.md` 的逐类型入口执行。进入页签才分页查询,保养加油图片点击才鉴权读取并在新标签页预览;卸载释放Blob。车辆list包含记录只读,record-create/edit/upload独立;未知保存结果保留原请求键,照片重试不重复建记录。处理状态与付款状态独立,费用未填和零值区分;无财务关联时只显示服务端UNLINKED,不自动修改车辆公里码、状态或财务。
- 车辆运维在 `VehicleDetailView.vue` 的页签内,表格与编辑框为 `components/VehicleCareTable.vue`、`VehicleCareEditor.vue`;`api/vehicleCare.ts` 对接 `fleet-vehicle-records-v1.yaml`。保养/加油680px,维修/故障/救援/异常620px居中弹框,行操作按 `PROTOTYPE_MATRIX.md` 的逐类型入口执行。进入页签才分页查询,保养加油图片点击才鉴权读取并在新标签页预览;卸载释放Blob。车辆list包含记录只读,record-create/edit/upload独立;保存失败即恢复编辑、关闭及重新提交,不保存失败请求;已确认创建成功的记录和附件继续使用返回的业务身份。处理状态与付款状态独立,费用未填和零值区分;无财务关联时只显示服务端UNLINKED,不自动修改车辆公里码、状态或财务。
- 维修/故障/救援的“更新处理”已获用户确认,复用原620px弹框及record-edit权限;更正说明选填,空值由页面填标准动作说明,不移除服务端不可变历史。
- 司机列表 `pages/DriversView.vue`、详情 `pages/DriverDetailView.vue`,编辑和证照预览在 `components/DriverEditorDialog.vue`、`DriverLicensePreview.vue`。详情独立路由;编辑居中1080px三列表单;预览居中620px。
- 列表与统计使用同一次服务端响应;统计范围与列表筛选一致,30天提醒按证照时区计算。页面不能用当前分页行数替代总统计,也不能在浏览器重算派车资格。
@@ -37,10 +37,12 @@
- 车次详情“计划容量”页签按需读写`/capacity-plan`,复用`TripLoadingPanel`的计划模式,预计重量/体积与实际装载分别保存;只在该页签展示逐段计划余位。`planValid=false`时不展示旧余位,货物变化后重新核对;保存计划只更新车次版本,不把理货审核变成通过或写入实载。
- `TripBookingPanel.vue`在车次详情“外接预订”页签按需读取列表;1080px居中弹框登记/编辑或只读详情,客户聚焦搜索才读取`/fleet/trips/customer-options`。询价只要求客户、既有卸货站和箱数,载荷/价格/联系信息可后补;确认承运由服务端校验余位及完整报价。create/edit/confirm/cancel分别使用独立`trip:booking-*`权限,读操作沿trip:list。未知创建及确认结果保留原请求重试,成功用命令结果更新列表和车次版本;计划容量表展示availableSegments(已扣除外接占位),不使用旧自有货余位覆盖。
- `TripBookingPanel.vue`在车次详情“外接预订”页签按需读取列表;1080px居中弹框登记/编辑或只读详情,客户聚焦搜索才读取`/fleet/trips/customer-options`。询价只要求客户、既有卸货站和箱数,载荷/价格/联系信息可后补;确认承运由服务端校验余位及完整报价。create/edit/confirm/cancel分别使用独立`trip:booking-*`权限,读操作沿trip:list。创建及确认失败即释放提交态,下一次点击使用当前输入及新请求键,成功用命令结果更新列表和车次版本;计划容量表展示availableSegments(已扣除外接占位),不使用旧自有货余位覆盖。
- 外接履约弹框`TripBookingFulfillmentDialog.vue`为1080px居中:登记来货、按服务器实托/receiptHash核对实收与约定价格、输入客户排车单号关联;来货详情只读。只有显式打开来货/实收弹框才查询来源,不逐行请求。关联后刷新外接列表及车次详情,更新全部排车成员和理货审核状态;预订取消不删除来货或库存。
- 报价、汇率、车辆、车队报价与出车记录导出统一使用 `../downloads/` 的 `useExportTask`,按当前筛选创建 XLSX 任务;按钮要求原业务导出权限和 `platform:download:list`。出车成本可见性由 File 根据账号权限冻结,不接受页面声明。CSV 仅用于车队报价导入模板。
- 拼车接货简化沿 `FleetCapacityOfferModels.Inquiry.progress` 展示缺项、是否保位、负责人及主要动作,`appointmentCoverage`只消费预约侧真实投影,不以承运成功冒充预约。`CapacityInquiryFulfillment.vue`从原登记按需读取本票履约,不授予销售整车读取权限;来货、核量、排入本车分别保持原写权限。`TripPreparationDialog.vue`和`TripDispatchSelector.vue`通过`index.ts`公开给排车/预约使用;前者提交原子`/fleet/trips/prepare`并衔接容量发布,后者只读取本仓合格候选。新版合同见`fleet-preparation-v1.yaml`及拼车唯一主契约。
- 车辆详情与司机详情通过 `ResourcePhotoPanel.vue` 维护常用照片,接口 `api/resourcePhotos.ts`/`fleet-resource-photos-v1.yaml`;车辆沿vehicle:edit、司机沿driver:upload,读取沿各自list。照片版本独立于档案字段版本,替换保留历史且不改变驾驶证审核;排车缺照片时由服务端复制为当趟证据。只在详情加载元数据、点击预览读取图片,不逐行请求。
@@ -1,5 +1,9 @@
# 海外通用资料原型核对矩阵
## 档案照片复用(2026-10-26)
依据用户确认优化重复上传:车辆概览和司机详情增加常用照片维护,沿既有详情分区与620px居中预览;元数据按详情加载,原图点击才读。车辆编辑权限与司机上传权限分别控制替换。排车选档案后自动带入并保存当趟副本;沿用Admin详情、提示及表单规范。自动行为验证与类型检查单独报告,1920×1080、100%截图待人工确认。
## 散板需求只读展示(2026-10-21)
依据本轮用户确认:仅保留内部余位需求池,人工联系下单;登记与后续拼车直接隐藏,不增加开关,不删除代码。当前范围唯一见[拼车主契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md#当前页面范围2026-10-21),覆盖下文此前开放登记的页面结论。
@@ -367,3 +371,9 @@ S05补充源码核对:实际自有/外配API均经公共解包检查业务码
| OwnedBusinessOperationsEditor、OutsourcedBusinessOperationsEditor | 自有/外配业务详情的居中运营登记弹框 | 表单补齐公共密度控件宽度;目的仓及预约号允许换行,实际到达时间列以受约束宽度占位,防止长业务编号撑宽 | 定向回归通过;待人工1920×1080、100%截图确认 |
依据本轮可用性反馈与本文件原操作矩阵;金额、日期时区、版本及未知结果重试逻辑均未修改。司机、车辆和运维编辑表单已有多列布局与公共底部确认入口,源码复查未据此扩大修改;真实可用性仍需截图确认。
## 装车资料多图(2026-10-26)
依据用户PDA截图标注,五类统一为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字。PDA每类最多5张,可多选追加、逐张更换;Admin排车资料仍沿五行表格展示,每类逐图预览/更换,自有和外配照片弹框展示全部当前图片,油耗保持单张。五类各至少1张与装车确认共同控制自动派送。PDA相关75项、后端47项、Admin照片编辑17项及照片弹框4项通过,定向类型检查通过;扩大到运营布局时另有2项既存关闭行为断言失败,未改动该运营代码。未部署,PDA真机及Admin人工截图待确认。
- 同日双端复核:Admin装车资料补齐多选上传;PDA/Admin保存响应异常时重读服务端当前照片,核对失败保留刷新入口,停止本批后续上传。旧图替换不影响同类其他图,批次已成功部分保留。Admin 6文件32项、PDA 3文件38项及定向类型检查通过;此前2项运营弹框过期断言已按仅busy期间锁定的现行规则修正并通过。后端本轮未改,沿用47项验证结果;未部署、未做真机/人工截图验收。
@@ -57,7 +57,7 @@ export async function createExchangeRate(payload: { month: string; usdToCnyAvera
export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await http.put<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload))) }
export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return normalizeExchangeRate(unwrapApiResponse(await http.patch<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark }))) }
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus }
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus; dispatchNo?: string }
type VehicleEditableFields = Omit<VehicleDetail, 'id' | 'recordVersion' | 'updatedBy' | 'updatedAt' | 'status'>
export type VehicleCreate = VehicleEditableFields & Pick<VehicleDetail, 'status'>
export type VehicleUpdate = VehicleEditableFields & Pick<VehicleDetail, 'recordVersion'>
@@ -0,0 +1,15 @@
import { expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getResourcePhoto, uploadResourcePhoto, downloadResourcePhoto } from './resourcePhotos'
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
it('preserves long identifiers and versions and submits the correct resource scope', async () => {
const id = '9223372036854775701', fileId = '9223372036854775702', version = '9007199254740993'
const wire = { code: '200', data: { recordVersion: version, photo: { fileId, fileName: 'photo.png', contentType: 'image/png', sizeBytes: '3' } } }
vi.mocked(http.get).mockResolvedValue(wire); vi.mocked(http.post).mockResolvedValue(wire)
expect(await getResourcePhoto('VEHICLE', id)).toMatchObject({ recordVersion: version, photo: { fileId, sizeBytes: 3 } })
await uploadResourcePhoto('VEHICLE', id, version, new File(['png'], 'photo.png', { type: 'image/png' }))
expect(http.post).toHaveBeenCalledWith(`/platform/v1/last-leg/fleet/resource-photos/VEHICLE/${id}`, expect.any(FormData))
expect((vi.mocked(http.post).mock.calls[0]![1] as FormData).get('recordVersion')).toBe(version)
vi.mocked(http.get).mockResolvedValue(new Blob(['{"code":"A0403"}'], { type: 'application/json' }))
await expect(downloadResourcePhoto('VEHICLE', id, fileId)).rejects.toThrow('读取失败')
})

Some files were not shown because too many files have changed in this diff Show More