From e1457a8e570150c8b7d50435a9930d0a75994f86 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 9 Oct 2026 22:05:51 +0800
Subject: [PATCH] feat(admin): streamline appointments and fleet intake
---
src/core/navigation/adminNavigation.test.ts | 2 +-
src/core/navigation/adminNavigation.ts | 2 +-
.../AppointmentCalendarPanel.test.ts | 14 ++
.../AppointmentCalendarPanel.vue | 14 +-
.../AppointmentCalendarView.test.ts | 138 +++++++++++++--
.../AppointmentCalendarView.vue | 162 +++++++++++++++---
.../AppointmentCargoPanel.test.ts | 44 +++++
.../AppointmentCargoPanel.vue | 90 ++++++++++
.../AppointmentFollowupDialog.test.ts | 51 ++++++
.../AppointmentFollowupDialog.vue | 101 +++++++++++
.../AppointmentInboundCodes.test.ts | 41 +++++
.../AppointmentInboundCodes.vue | 37 ++++
.../appointment-calendar/PROTOTYPE_MATRIX.md | 9 +-
.../appointment-calendar/planningApi.test.ts | 14 ++
.../appointment-calendar/planningApi.ts | 71 ++++++++
src/modules/overseas-master-data/AGENTS.md | 2 +
.../overseas-master-data/PROTOTYPE_MATRIX.md | 14 ++
.../api/capacityOffers.ts | 13 +-
.../overseas-master-data/api/tripBookings.ts | 20 +--
src/modules/overseas-master-data/api/trips.ts | 10 ++
.../capacityOfferTypes.ts | 11 +-
.../components/CapacityDialogClosing.test.ts | 13 +-
.../components/CapacityInquiryDialog.test.ts | 4 +-
.../components/CapacityInquiryDialog.vue | 48 ++++--
.../components/CapacityInquiryFulfillment.vue | 37 ++++
.../components/CapacityInquiryTable.vue | 14 +-
.../TripBookingFulfillmentDialog.test.ts | 15 +-
.../TripBookingFulfillmentDialog.vue | 41 +++--
.../components/TripDispatchSelector.vue | 21 +++
.../components/TripPreparationDialog.test.ts | 41 +++++
.../components/TripPreparationDialog.vue | 86 ++++++++++
src/modules/overseas-master-data/index.ts | 4 +
.../pages/CapacityOfferDetailView.vue | 28 ++-
.../pages/CapacityOffersBehavior.test.ts | 53 ++++--
.../pages/CapacityOffersView.vue | 64 ++++---
.../pages/TripDetailView.vue | 5 +-
src/modules/overseas-master-data/routes.ts | 2 +-
.../pages/DispatchesView.test.ts | 25 +--
.../pages/DispatchesView.vue | 19 +-
39 files changed, 1215 insertions(+), 165 deletions(-)
create mode 100644 src/modules/appointment-calendar/AppointmentCargoPanel.test.ts
create mode 100644 src/modules/appointment-calendar/AppointmentCargoPanel.vue
create mode 100644 src/modules/appointment-calendar/AppointmentFollowupDialog.test.ts
create mode 100644 src/modules/appointment-calendar/AppointmentFollowupDialog.vue
create mode 100644 src/modules/appointment-calendar/AppointmentInboundCodes.test.ts
create mode 100644 src/modules/appointment-calendar/AppointmentInboundCodes.vue
create mode 100644 src/modules/appointment-calendar/planningApi.test.ts
create mode 100644 src/modules/appointment-calendar/planningApi.ts
create mode 100644 src/modules/overseas-master-data/components/CapacityInquiryFulfillment.vue
create mode 100644 src/modules/overseas-master-data/components/TripDispatchSelector.vue
create mode 100644 src/modules/overseas-master-data/components/TripPreparationDialog.test.ts
create mode 100644 src/modules/overseas-master-data/components/TripPreparationDialog.vue
diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts
index b1dbf3b6..12c2c7e8 100644
--- a/src/core/navigation/adminNavigation.test.ts
+++ b/src/core/navigation/adminNavigation.test.ts
@@ -23,7 +23,7 @@ describe('adminNavigation', () => {
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
{ title: '预约管理', children: ['预约列表'] },
- { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] },
+ { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车接货', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
{ title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts
index 4fd3b351..571b3797 100644
--- a/src/core/navigation/adminNavigation.ts
+++ b/src/core/navigation/adminNavigation.ts
@@ -135,7 +135,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip: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'] },
+ { 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'] },
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
],
},
diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts
index 141ca57d..8cfe2150 100644
--- a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts
+++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts
@@ -50,4 +50,18 @@ describe('appointment calendar interactions', () => {
expect(wrapper.emitted('edit')?.[0]?.[0]).toMatchObject({ key: 'a' })
expect(wrapper.emitted('delete')?.[0]?.[0]).toMatchObject({ key: 'a' })
})
+
+ it('renders many inbound codes outside the two-column fields with only three previews', async () => {
+ const wrapper = render()
+ await wrapper.setProps({ events: [{
+ key: 'many', type: 'delivery', date: '2026-09-24', time: '09:00', title: 'ONT8 预约送仓',
+ fields: [{ label: '货物信息', value: '20 票 / 12 个客户' }],
+ inboundCodes: Array.from({ length: 20 }, (_, index) => `WT-INBOUND-${index}`), canEdit: true,
+ }] })
+ const card = wrapper.get('.detail-event')
+ expect(card.get('dl').text()).not.toContain('WT-INBOUND')
+ expect(card.findAll('.inbound-codes li')).toHaveLength(3)
+ expect(card.get('.inbound-codes').text()).toContain('20 个')
+ expect(card.get('footer').text()).toContain('编辑')
+ })
})
diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue
index ad532175..dd418631 100644
--- a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue
+++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue
@@ -1,6 +1,7 @@
+ 正在核对预约货源…
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
+
+ 输入更完整的仓库编码或名称,缩小搜索范围。
+
+
- 查询可预约货物
已选 {{ selected.length }} 票,可跨客户选择同一目的仓货物
- {{ editing.selectedCount }} 票货物 · 入仓码:{{ editing.items.map(item => item.inboundCode).join('、') || '—' }}
+ 先选择预约仓库,即可勾选同仓货物,支持多个客户一起预约。
+
+
{{ editing.selectedCount }} 票货物
+
+
diff --git a/src/modules/appointment-calendar/AppointmentCargoPanel.test.ts b/src/modules/appointment-calendar/AppointmentCargoPanel.test.ts
new file mode 100644
index 00000000..6becc51e
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentCargoPanel.test.ts
@@ -0,0 +1,44 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
+import { listPlanningCargo } from './planningApi'
+
+vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn() }))
+vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
+const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
+function render() {
+ return mount(AppointmentCargoPanel, { global: { stubs: {
+ DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '
' },
+ QueryToolbar: { name: 'QueryToolbar', emits: ['search'], template: '' },
+ LoadErrorState: true, ApiErrorMeta: true, RowActions: true,
+ } } })
+}
+describe('appointment planning cargo query', () => {
+ beforeEach(() => { vi.resetAllMocks(); vi.mocked(listPlanningCargo).mockResolvedValue(empty) })
+ it('uses one server-page request per entry, page change and reset', async () => {
+ const wrapper = render(); await flushPromises()
+ expect(listPlanningCargo).toHaveBeenCalledTimes(1)
+ await wrapper.get('[data-next]').trigger('click'); await flushPromises()
+ expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 2, size: 20, keyword: undefined }, expect.any(AbortSignal))
+ await wrapper.get('[data-search]').trigger('click'); await flushPromises()
+ expect(listPlanningCargo).toHaveBeenCalledTimes(3)
+ expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 1, size: 20, keyword: undefined }, expect.any(AbortSignal))
+ })
+ it('shows a source failure instead of an authoritative empty list', async () => {
+ vi.mocked(listPlanningCargo).mockRejectedValueOnce(new Error('目录不可用'))
+ const wrapper = render(); await flushPromises()
+ expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('resultsVisible')).toBe(false)
+ })
+ it('discards a late previous page and uses the new server total', async () => {
+ let resolveOld!: (value: typeof empty) => void
+ vi.mocked(listPlanningCargo).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
+ const wrapper = render()
+ vi.mocked(listPlanningCargo).mockResolvedValueOnce({ ...empty, page: 2, total: 21, pages: 2 })
+ await wrapper.get('[data-next]').trigger('click'); await flushPromises()
+ resolveOld({ ...empty, total: 100, pages: 5 }); await flushPromises()
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(21)
+ expect(vi.mocked(listPlanningCargo).mock.calls[0]![1]!.aborted).toBe(true)
+ })
+})
diff --git a/src/modules/appointment-calendar/AppointmentCargoPanel.vue b/src/modules/appointment-calendar/AppointmentCargoPanel.vue
new file mode 100644
index 00000000..d22a4b00
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentCargoPanel.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+ 来源预计时间供筹划核对,确认预约前请核对最新船期与货物备妥情况。
+
+ key === 'followup' ? openFollowup(row as PlanningCargo) : source(row as PlanningCargo)" />
+
+ 暂扣,待处理
+ 含多个目的地,待核对
+ 已有预约
+ {{ row.hasInboundPlan ? '已建入仓计划,待安排' : '尚未建入仓计划' }}
+
+
+
+
+
+
diff --git a/src/modules/appointment-calendar/AppointmentFollowupDialog.test.ts b/src/modules/appointment-calendar/AppointmentFollowupDialog.test.ts
new file mode 100644
index 00000000..dda264b8
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentFollowupDialog.test.ts
@@ -0,0 +1,51 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
+import { getFollowups, saveFollowup, type PlanningSource } from './planningApi'
+
+vi.mock('./planningApi', () => ({ getFollowups: vi.fn(), saveFollowup: vi.fn() }))
+vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
+const history = { history: { records: [], page: 1, size: 10, total: 0, pages: 0 }, versions: { DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 } }
+const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', canFollowup: true, blockReasons: [], orderId: '1', inboundCode: 'IN-1', bookingNo: null, containerNo: null, originWarehouseId: 'US-1', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: null }
+function render(canWrite = true) {
+ return mount(AppointmentFollowupDialog, { props: { source, coverage: { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }, canWrite }, global: { stubs: {
+ AppDialog: { props: ['confirmDisabled'], template: '
' },
+ ElForm: { template: '' }, ElFormItem: { template: '' },
+ ElInput: { props: ['modelValue', 'placeholder'], template: '' },
+ ElSelect: true, ElOption: true, ElDatePicker: true,
+ DataTable: true, LoadErrorState: true, ApiErrorMeta: true,
+ } } })
+}
+describe('appointment followup recovery', () => {
+ beforeEach(() => {
+ vi.resetAllMocks(); vi.mocked(getFollowups).mockResolvedValue(history)
+ vi.mocked(saveFollowup).mockResolvedValue({ stage: 'DRAYAGE_CONTACT', version: 1, contactedParty: null, result: '已联系', nextAction: null, followupDate: null, operatorName: '运营', createdAt: '2026-10-09T12:00:00Z' })
+ })
+ it('saves contact facts without requiring appointment, inbound plan or driver fields', async () => {
+ const wrapper = render(); await flushPromises()
+ await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('已联系,等待回复')
+ await wrapper.get('[data-save]').trigger('click'); await flushPromises()
+ expect(saveFollowup).toHaveBeenCalledWith('ORDER:1', 'A', expect.objectContaining({ stage: 'DRAYAGE_CONTACT', expectedVersion: 0, result: '已联系,等待回复', followupDate: null }))
+ const body = vi.mocked(saveFollowup).mock.calls[0]![2]
+ expect(body).not.toHaveProperty('appointmentNo'); expect(body).not.toHaveProperty('driverId'); expect(body).not.toHaveProperty('operatorId')
+ expect(getFollowups).toHaveBeenCalledTimes(2)
+ })
+ it('retries an unknown outcome with the exact same body and persisted command key', async () => {
+ vi.mocked(saveFollowup).mockRejectedValueOnce(new Error('network'))
+ const wrapper = render(); await flushPromises()
+ await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('等待码头开放')
+ await wrapper.get('[data-save]').trigger('click'); await flushPromises()
+ const first = vi.mocked(saveFollowup).mock.calls[0]![2]
+ await wrapper.get('[data-save]').trigger('click'); await flushPromises()
+ expect(vi.mocked(saveFollowup).mock.calls[1]![2]).toEqual(first)
+ expect(first.idempotencyKey).toBeTruthy()
+ })
+ it('does not allow a write without write permission or a successfully loaded version', async () => {
+ const readonly = render(false); await flushPromises()
+ expect(readonly.get('[data-save]').attributes('disabled')).toBeDefined()
+ vi.mocked(getFollowups).mockRejectedValueOnce(new Error('unavailable'))
+ const failed = render(); await flushPromises()
+ expect(failed.get('[data-save]').attributes('disabled')).toBeDefined()
+ expect(saveFollowup).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/modules/appointment-calendar/AppointmentFollowupDialog.vue b/src/modules/appointment-calendar/AppointmentFollowupDialog.vue
new file mode 100644
index 00000000..2593fe41
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentFollowupDialog.vue
@@ -0,0 +1,101 @@
+
+
+
+
+ {{ source.containerNo || '—' }} · {{ source.originWarehouseName }} → {{ source.destinationWarehouseCode }}
+
+
+ {{ reason }}
+ 来源资料有变化,请核对原预约与运输安排。
+ 已关联预约:{{ coverage.appointments.map(item => item.appointmentNo).join('、') }}
+ 尚未关联收货预约;联系记录保存后仍需登记对方正式确认。
+ - {{ coverageNeeds[item] || '存在待核实事项,请返回来源详情核对。' }}
+
+
+
+
+
+
+
+
+ {{ stages[row.stage as FollowupStage] }}
+
+
+
+
+
diff --git a/src/modules/appointment-calendar/AppointmentInboundCodes.test.ts b/src/modules/appointment-calendar/AppointmentInboundCodes.test.ts
new file mode 100644
index 00000000..8aa7cedb
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentInboundCodes.test.ts
@@ -0,0 +1,41 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
+
+describe('appointment inbound code list', () => {
+ const codes = Array.from({ length: 20 }, (_, index) => `WT-LONG-INBOUND-CODE-${index + 1}`)
+
+ it('previews three codes, exposes the total and expands without losing full codes', async () => {
+ const wrapper = mount(AppointmentInboundCodes, { props: { codes } })
+ expect(wrapper.findAll('li')).toHaveLength(3)
+ expect(wrapper.text()).toContain('20 个')
+ expect(wrapper.get('button').text()).toContain('还有 17 个')
+ expect(wrapper.get('li').attributes('title')).toBe(codes[0])
+ expect(wrapper.get('button').attributes('aria-expanded')).toBe('false')
+ await wrapper.get('button').trigger('click')
+ expect(wrapper.findAll('li').map(row => row.text())).toEqual(codes)
+ expect(wrapper.get('ul').classes()).toContain('expanded')
+ expect(wrapper.get('ul').attributes('tabindex')).toBe('0')
+ expect(wrapper.get('button').attributes('aria-controls')).toBe(wrapper.get('ul').attributes('id'))
+ await wrapper.get('button').trigger('click')
+ expect(wrapper.findAll('li')).toHaveLength(3)
+ })
+
+ it('does not count missing or duplicate codes and shows no redundant toggle', () => {
+ const wrapper = mount(AppointmentInboundCodes, { props: { codes: [' A ', 'A', '', null, undefined, 'B'] } })
+ expect(wrapper.findAll('li').map(row => row.text())).toEqual(['A', 'B'])
+ expect(wrapper.text()).toContain('2 个')
+ expect(wrapper.find('button').exists()).toBe(false)
+ expect(mount(AppointmentInboundCodes, { props: { codes: [] } }).text()).toContain('—')
+ })
+
+ it('preserves expansion on equivalent data but resets when the appointment codes change', async () => {
+ const wrapper = mount(AppointmentInboundCodes, { props: { codes } })
+ await wrapper.get('button').trigger('click')
+ await wrapper.setProps({ codes: [...codes] })
+ expect(wrapper.findAll('li')).toHaveLength(20)
+ await wrapper.setProps({ codes: codes.map(code => `${code}-NEW`) })
+ expect(wrapper.findAll('li')).toHaveLength(3)
+ expect(wrapper.get('button').attributes('aria-expanded')).toBe('false')
+ })
+})
diff --git a/src/modules/appointment-calendar/AppointmentInboundCodes.vue b/src/modules/appointment-calendar/AppointmentInboundCodes.vue
new file mode 100644
index 00000000..a860aca9
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentInboundCodes.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+ 入仓码 ({{ codes.length }} 个)
+
+
+
+ —
+
+
+
+
diff --git a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md
index c5095740..1f1093df 100644
--- a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md
+++ b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md
@@ -7,13 +7,18 @@
| 页面/操作 | 原型承载与布局 | 实现约束 |
|---|---|---|
| 预约列表 | 左侧月/周日历,右侧选中日期详情 | 保留双栏日历;到港蓝色、送仓绿色,区分预计/实际到港 |
-| 新增预约 | 居中双列表单,原型最大宽度760px | 保留居中双列表单;为跨客户货物选择扩至1080px,在同弹框下方分页选择真实入仓货物 |
+| 新增预约 | 居中双列表单,原型最大宽度760px | 按2026-10-09用户简化要求,改用居中1080px三列表单;仓库从启用FBA主档远程搜索选择,选择后自动分页加载货物;主区保留仓库、送仓时间、预约编号、预计装车时间,结束时间/备注/新建默认预约中状态收至补充信息,在同弹框下方分页选择真实入仓货物 |
| 编辑预约 | 复用居中表单 | 与入仓单共用记录;版本校验;真实货物归属不允许手填更改 |
| 到港详情 | 当天安排右侧只读 | 使用货柜/船节点事实,无逐行详情请求,无模拟到港数据 |
| 删除预约 | 二次确认 | 显式提示全部货物解除预约关联;独立删除权限,已关联有效排车拒绝删除 |
| 月/周切换 | 顶部切换与前后按钮 | 周模式按7天移动,修正原型仍按月移动的问题 |
| 多条安排 | 日历标签、右侧明细 | 月/周日格均最多显示3条,超出显示“还有N条”,悬停显示当天总数;日格固定高度,右侧全部展示并独立滚动 |
+| 多入仓码 | 用户2026-10-09补充截图:单预约20票导致两列详情被长字符串撑高 | 右侧卡片与编辑弹框共用独立整行入仓码列表;去空去重后显示总数,默认3条,长码悬停可读全文;超过3条可展开/收起,展开后216px内独立滚动并可键盘聚焦,不拼成长段文字 |
+| 在途货物及外接来源跟进 | 美国预约统一方案的提前联系需求 | 日历与已开船FBA列表切换;跟进在居中1080px弹框中承载,3列表单、联系结果及下一步整行,历史分页;BOOKING深链先由服务端核对,再展示原登记及真实预约缺项 |
+| 来源失败 | 统一方案的部分结果规则 | 日历保留成功来源并提示不完整;深链解析失败不打开假来源;跟进版本加载失败禁止保存,未知提交结果保留原请求供重试 |
-预约结束时间未明确时允许为空;不把预计装车时间当结束时间。预约号、目的仓和货物来源遵守服务端合同。日历范围最多42天,每次范围变化查询预约与到港各一次;选择范围内日期不发送请求。候选只在用户点击查询时分页读取,跨页选择保留,重新查询清空。
+预约结束时间未明确时允许为空;不把预计装车时间当结束时间。预约号、目的仓和货物来源遵守服务端合同。日历范围最多42天,每次范围变化查询预约与到港各一次;选择范围内日期不发送请求。候选在选仓后自动分页读取,跨页选择保留;换仓立即清空旧数据/选择并丢弃旧请求响应,清空仓库后不发送货物查询。后端必填的预计装车时间保留,不自动伪造;预约编号继续填写仓库提供的业务号。仓库请求失败可重试;仅展示前50个匹配仓,超出提示缩小关键词。
+
+本轮视觉验收:待人工1920×1080截图确认。
视觉验收目标为1920×1080、100%缩放。该矩阵描述设计和代码边界,不代替测试环境浏览器验收证据。
diff --git a/src/modules/appointment-calendar/planningApi.test.ts b/src/modules/appointment-calendar/planningApi.test.ts
new file mode 100644
index 00000000..f965ffa9
--- /dev/null
+++ b/src/modules/appointment-calendar/planningApi.test.ts
@@ -0,0 +1,14 @@
+import { expect, it, vi } from 'vitest'
+import { http } from '@/shared/api/http'
+import { listPlanningCargo } from './planningApi'
+vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: { data: T }) => value.data }))
+
+it('uses the gateway planning route and preserves stable source IDs and null predictions', async () => {
+ vi.mocked(http.get).mockResolvedValue({ data: { records: [{ sourceKey: 'ORDER:9007199254740993', orderId: '9007199254740993', estimatedArrivalAt: null }], page: '2', size: '20', total: '21', pages: '2' } })
+ const controller = new AbortController()
+ const result = await listPlanningCargo({ page: 2, size: 20, keyword: 'IN-A' }, controller.signal)
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-planning/cargo', { params: { page: 2, size: 20, keyword: 'IN-A' }, signal: controller.signal })
+ expect(result.records[0]!.orderId).toBe('9007199254740993')
+ expect(result.records[0]!.estimatedArrivalAt).toBeNull()
+ expect(result.total).toBe(21)
+})
diff --git a/src/modules/appointment-calendar/planningApi.ts b/src/modules/appointment-calendar/planningApi.ts
new file mode 100644
index 00000000..9a437ba4
--- /dev/null
+++ b/src/modules/appointment-calendar/planningApi.ts
@@ -0,0 +1,71 @@
+import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+
+export interface PlanningCargo {
+ sourceKey: string
+ orderId: string
+ customerId: string
+ customerName: string | null
+ inboundCode: string | null
+ containerBookingId: string
+ containerNo: string | null
+ originWarehouseId: string
+ originWarehouseCode: string
+ originWarehouseName: string
+ destinationWarehouseCode: string
+ packages: number | null
+ receivedPackages: number | null
+ shippedPackages: number | null
+ sourcePalletCount: number | null
+ volumeCbm: number | null
+ estimatedArrivalAt: string | null
+ estimatedWarehouseAt: string | null
+ orderVersion: number
+ standardNode: string
+ shippingVersion: string | null
+ shippingObservedAt: string | null
+ shippingProvider: string | null
+ hasInboundPlan: boolean
+ hasActiveAppointment: boolean
+ detained: boolean
+ mixedDestinations: boolean
+}
+export interface PlanningCargoPage { records: PlanningCargo[]; page: number; size: number; total: number; pages: number }
+export async function listPlanningCargo(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
+ const response = await http.get, ApiEnvelope>(
+ '/platform/v1/last-leg/appointment-planning/cargo', { params: query, signal },
+ )
+ const page = unwrapApiResponse(response)
+ return { ...page, page: Number(page.page), size: Number(page.size), total: Number(page.total), pages: Number(page.pages) }
+}
+
+export type FollowupStage = 'DRAYAGE_CONTACT' | 'TERMINAL_APPOINTMENT' | 'WAREHOUSE_RECEIVING' | 'FBA_RECEIVING'
+export interface FollowupRecord { stage: FollowupStage; version: number; contactedParty: string | null; result: string; nextAction: string | null; followupDate: string | null; operatorName: string | null; createdAt: string }
+export interface FollowupHistory { history: { records: FollowupRecord[]; page: number; size: number; total: number; pages: number }; versions: Record }
+export interface SaveFollowup { stage: FollowupStage; expectedVersion: number; idempotencyKey: string; contactedParty?: string; result: string; nextAction?: string; followupDate: string | null }
+export async function getFollowups(sourceKey: string, customerId: string, page: number, size: number) {
+ const result = unwrapApiResponse(await http.get, ApiEnvelope>(
+ `/platform/v1/last-leg/appointment-planning/sources/${encodeURIComponent(sourceKey)}/follow-ups`, { params: { customerId, page, size } },
+ ))
+ return { ...result, history: { ...result.history, total: Number(result.history.total) }, versions: Object.fromEntries(Object.entries(result.versions).map(([stage, version]) => [stage, Number(version)])) as Record }
+}
+export async function saveFollowup(sourceKey: string, customerId: string, body: SaveFollowup) {
+ return unwrapApiResponse(await http.post, ApiEnvelope>(
+ `/platform/v1/last-leg/appointment-planning/sources/${encodeURIComponent(sourceKey)}/follow-ups`, body, { params: { customerId } },
+ ))
+}
+
+export interface PlanningSource {
+ sourceKey: string; customerId: string; orderId: string | null; inboundCode: string | null; bookingNo: string | null;
+ containerNo: string | null; originWarehouseId: string; originWarehouseName: string; destinationKey: string;
+ destinationWarehouseCode: string; packages: number | null; sourceVersion: string | null; canFollowup: boolean; blockReasons: string[];
+}
+export interface AppointmentCoverage {
+ status: string; missingFields: string[]; changeReviewRequired: boolean;
+ appointments: Array<{ appointmentNo: string; confirmationNo: string | null; status: string; confirmedAt: string | null; confirmedEndAt: string | null; timezone: string | null }>;
+}
+export interface ResolvedPlanningSource { source: PlanningSource; coverage: AppointmentCoverage }
+export async function resolvePlanningSource(query: { sourceKey: string; customerId: string; destinationKey?: string }) {
+ return unwrapApiResponse(await http.get, ApiEnvelope>(
+ '/platform/v1/last-leg/appointment-planning/sources/resolve', { params: query },
+ ))
+}
diff --git a/src/modules/overseas-master-data/AGENTS.md b/src/modules/overseas-master-data/AGENTS.md
index d9c3756a..007951d4 100644
--- a/src/modules/overseas-master-data/AGENTS.md
+++ b/src/modules/overseas-master-data/AGENTS.md
@@ -37,3 +37,5 @@
- 外接履约弹框`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`及拼车唯一主契约。
diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md
index b1d504c3..d68b2679 100644
--- a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md
+++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md
@@ -130,3 +130,17 @@
| 外接来货与实收复核、关联发运 | 首版外接货自送起运仓、按实际货量确认费用的已确认方案 | 车次外接行操作打开1080px居中弹框;现有PDA转运实收承载现场作业 | 已接真实来源、实测差异与运费复核、版本/hash和客户排车关联;未将本地验证当作环境验收 |
| 出车运营应收/实收读取 | `新版.html:8941–8987` 出车记录列表;车辆规划第4.1节及本轮明确确认的Finance权威口径 | 沿原运营列表应收/实收列,点击读取;金额来源在620px只读浮层展示,不新增菜单或财务写表单 | 单车次按需读取,未分配明确显示待分配,缺权限不请求;真实1920×1080、100%视觉及请求量待总控验收 |
+
+## 拼车接货操作简化开发矩阵(2026-10-09)
+
+依据:用户要求按最新版《拼车数据入口与操作简化评估草案》第七、八节开发,沿既有居中1080px表单和独立发布详情,不引入外部原型。
+
+| 页面 | 操作 | 承载与尺寸 | 调整及验收边界 |
+|---|---|---|---|
+| 拼车接货 | 可拼车次、我的登记、待办 | integrated列表,操作列左置 | 直接登记,销售隐藏容量计算过程,服务端返回处理进度;待1920×1080人工截图 |
+| 客户货物登记 | 新增、补充资料、只读跟进 | 1080px居中三列 | 首屏客户/目的地/已知货量;确认前必填折叠但显示缺项;始终选填独立;单站自动带入 |
+| 客户货物登记 | 确认承运、取消 | 既有居中确认框 | 展示对象及保位/商业订单影响,保存成功不冒充确认成功 |
+| 客户货物登记 | 安排来货、核量、排入本车 | 1080px居中,复用履约表单 | 原登记连续处理,按服务端资格;关联业务按权限跳转 |
+| 车次准备 | 选择合格排车、新建或加入车次、连续发布 | 1080px居中,复用车次与发布表单 | 来源带入、版本和失败续办,未授权的预约事实不由此生成 |
+
+本次无新增文件预览、删除或停用方式;未改变的操作沿既有矩阵。代码测试与真实页面验收分别报告。
diff --git a/src/modules/overseas-master-data/api/capacityOffers.ts b/src/modules/overseas-master-data/api/capacityOffers.ts
index a70a955c..35e4d83f 100644
--- a/src/modules/overseas-master-data/api/capacityOffers.ts
+++ b/src/modules/overseas-master-data/api/capacityOffers.ts
@@ -1,3 +1,4 @@
+import { normalizeBooking, normalizeIncoming, normalizeBookingResult } from './tripBookings'
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { CapacityOffer, CapacityInquiry, CapacityOfferDetail, CapacityLoad, OfferPage, OfferQuery, InquiryQuery, OfferWrite, OfferCommand, InquiryWrite, InquiryCommand, OfferResult, InquiryResult, OfferCustomerOption } from '../capacityOfferTypes'
import { normalizeFleetQuotePage, normalizeFleetQuoteSource, type WireQuotePage } from './fleetQuoteCandidates'
@@ -5,7 +6,7 @@ import type { TripBookingQuoteCriteria } from './tripBookings'
const base = '/platform/v1/last-leg/fleet/capacity-offers'
const path = (value: string) => `${base}/${encodeURIComponent(value)}`
const inquiryPath = (value: string) => `${base}/inquiries/${encodeURIComponent(value)}`
-const number = (value: number) => { const n = Number(value); if (value == null || !Number.isFinite(n)) throw new Error('运力数据数值无效'); return n }
+const number = (value: number | string) => { const n = Number(value); if (value == null || String(value).trim() === '' || !Number.isFinite(n)) throw new Error('运力数据数值无效'); return n }
const nullable = (value: number | null) => value == null ? null : number(value)
const load = (value: CapacityLoad | null) => value == null ? null : { palletSpaces: number(value.palletSpaces), weightKg: number(value.weightKg), volumeCbm: number(value.volumeCbm) }
export function normalizeOffer(value: CapacityOffer): CapacityOffer { return { ...value, recordVersion: number(value.recordVersion), tripRecordVersion: number(value.tripRecordVersion), destinations: value.destinations.map(d => ({ ...d, deliveryIndex: number(d.deliveryIndex), remaining: load(d.remaining) })), segments: value.segments.map(s => ({ ...s, fromIndex: number(s.fromIndex), toIndex: number(s.toIndex), planned: load(s.planned), reserved: load(s.reserved), remaining: load(s.remaining) })) } }
@@ -26,3 +27,13 @@ export async function saveCapacityInquiry(offerNo: string, bookingNo: string | n
export async function commandCapacityInquiry(bookingNo: string, action: 'confirm' | 'cancel', body: InquiryCommand) { return inquiryResult(await post(`${inquiryPath(bookingNo)}/${action}`, body)) }
export const listOfferCustomers = (keyword?: string) => get(`${base}/customer-options`, { keyword })
export async function listOfferQuotes(offerNo: string, query: TripBookingQuoteCriteria) { return normalizeFleetQuotePage(await get(`${path(offerNo)}/quote-candidates`, query)) }
+
+export async function getInquiryFulfillment(bookingNo: string) {
+ const value = await get<{ booking: import('./tripBookings').WireBooking; preview: { tripRecordVersion: number | string; recordVersion: number | string; incoming: import('./tripBookings').WireIncoming | null }; plannedDepartureAt: string }>(`${inquiryPath(bookingNo)}/fulfillment`)
+ return { booking: normalizeBooking(value.booking), plannedDepartureAt: value.plannedDepartureAt, preview: { ...value.preview, tripRecordVersion: number(value.preview.tripRecordVersion), recordVersion: number(value.preview.recordVersion), incoming: value.preview.incoming ? normalizeIncoming(value.preview.incoming) : null } }
+}
+export async function commandInquiryFulfillment(bookingNo: string, action: 'incoming' | 'receipt-confirmation' | 'arrange-dispatch', body: import('../bookingTypes').TripIncomingStart | import('../bookingTypes').TripIncomingAccept | import('../bookingTypes').TripBookingBind | import('../bookingTypes').TripBookingCommand) {
+ return normalizeBookingResult(await post(`${inquiryPath(bookingNo)}/${action}`, body))
+}
+
+export async function saveAndConfirmInquiry(bookingNo: string, body: InquiryWrite) { return inquiryResult(await post(`${inquiryPath(bookingNo)}/save-and-confirm`, body)) }
diff --git a/src/modules/overseas-master-data/api/tripBookings.ts b/src/modules/overseas-master-data/api/tripBookings.ts
index 43384cf3..6e3e8050 100644
--- a/src/modules/overseas-master-data/api/tripBookings.ts
+++ b/src/modules/overseas-master-data/api/tripBookings.ts
@@ -6,16 +6,16 @@ const path = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}/bookings
type N = number | string
type WireCargo = Omit & { packageCount: N | null; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null }
export type WireBooking = Omit & { expectedPalletCount: N | null; recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null }
-type WireIncoming = Omit & { receivedPackages: N; pallets: (Omit & { packages: N; weightKg: N | null; volumeCbm: N | null })[] }
+export type WireIncoming = Omit & { receivedPackages: N; pallets: (Omit & { packages: N; weightKg: N | null; volumeCbm: N | null })[] }
type WireReceipt = Omit & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N }[] }
-type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean }
+export type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean }
const number = (value: N) => { const result = Number(value); if (value == null || String(value).trim() === '' || !Number.isFinite(result) || result < 0) throw new Error('外接预订数值无效'); return result }
const integer = (value: N) => { const result = number(value); if (!Number.isSafeInteger(result)) throw new Error('外接预订版本或数量无效'); return result }
const nullable = (value: N | null) => value == null ? null : number(value)
-const incoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) })
-export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: incoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
+export const normalizeIncoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) })
+export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: normalizeIncoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
packageCount: value.cargo.packageCount == null ? null : integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } })
-const result = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true })
+export const normalizeBookingResult = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true })
export interface TripBookingQuoteCriteria { customerId: string; destinationKey: string; currency: 'CNY' | 'USD'; priceBasis: 'PALLET' | 'ORDER'; page?: number; size?: number }
export async function listTripBookingQuoteCandidates(tripNo: string, criteria: TripBookingQuoteCriteria) {
return normalizeFleetQuotePage(unwrapApiResponse(await http.get, ApiEnvelope>(`${path(tripNo)}/quote-candidates`, { params: criteria })))
@@ -28,13 +28,13 @@ export async function listTripBookings(tripNo: string): Promise
return { tripRecordVersion: integer(value.tripRecordVersion), records: value.records.map(normalizeBooking) }
}
export async function createTripBooking(tripNo: string, body: TripBookingCreate): Promise {
- return result(unwrapApiResponse(await http.post, ApiEnvelope>(path(tripNo), body)))
+ return normalizeBookingResult(unwrapApiResponse(await http.post, ApiEnvelope>(path(tripNo), body)))
}
export async function updateTripBooking(tripNo: string, bookingNo: string, body: TripBookingUpdate): Promise {
- return result(unwrapApiResponse(await http.put, ApiEnvelope>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}`, body)))
+ return normalizeBookingResult(unwrapApiResponse(await http.put, ApiEnvelope>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}`, body)))
}
async function command(tripNo: string, bookingNo: string, action: string, body: TripBookingCommand): Promise {
- return result(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}/${action}`, body)))
+ return normalizeBookingResult(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}/${action}`, body)))
}
export const confirmTripBooking = (tripNo: string, bookingNo: string, body: TripBookingCommand) => command(tripNo, bookingNo, 'confirm', body)
export const cancelTripBooking = (tripNo: string, bookingNo: string, body: TripBookingCommand) => command(tripNo, bookingNo, 'cancel', body)
@@ -42,10 +42,10 @@ export const cancelTripBooking = (tripNo: string, bookingNo: string, body: TripB
const bookingPath = (tripNo: string, bookingNo: string) => `${path(tripNo)}/${encodeURIComponent(bookingNo)}`
export async function getTripIncoming(tripNo: string, bookingNo: string): Promise {
const value = unwrapApiResponse(await http.get, ApiEnvelope<{ tripRecordVersion: N; recordVersion: N; incoming: WireIncoming | null }>>(`${bookingPath(tripNo, bookingNo)}/incoming`))
- return { tripRecordVersion: integer(value.tripRecordVersion), recordVersion: integer(value.recordVersion), incoming: value.incoming == null ? null : incoming(value.incoming) }
+ return { tripRecordVersion: integer(value.tripRecordVersion), recordVersion: integer(value.recordVersion), incoming: value.incoming == null ? null : normalizeIncoming(value.incoming) }
}
async function incomingCommand(tripNo: string, bookingNo: string, action: string, body: TripIncomingStart | TripIncomingAccept | TripBookingBind): Promise {
- return result(unwrapApiResponse(await http.post, ApiEnvelope>(`${bookingPath(tripNo, bookingNo)}/${action}`, body)))
+ return normalizeBookingResult(unwrapApiResponse(await http.post, ApiEnvelope>(`${bookingPath(tripNo, bookingNo)}/${action}`, body)))
}
export const startTripIncoming = (tripNo: string, bookingNo: string, body: TripIncomingStart) => incomingCommand(tripNo, bookingNo, 'incoming', body)
export const acceptTripIncoming = (tripNo: string, bookingNo: string, body: TripIncomingAccept) => incomingCommand(tripNo, bookingNo, 'receipt-confirmation', body)
diff --git a/src/modules/overseas-master-data/api/trips.ts b/src/modules/overseas-master-data/api/trips.ts
index 157f7c45..346dd943 100644
--- a/src/modules/overseas-master-data/api/trips.ts
+++ b/src/modules/overseas-master-data/api/trips.ts
@@ -111,3 +111,13 @@ export async function listTripHistory(tripNo: string, currentPage = 1, size = 20
completion: row.snapshot.completion == null ? null : { ...row.snapshot.completion, outcomes: row.snapshot.completion.outcomes.map(outcome) },
} }))
}
+
+export interface DispatchCandidate { dispatchNo: string; customerId: string; warehouseId: string; packageCount: number; route: string | null }
+export async function listTripDispatchCandidates(tripNo: string, keyword?: string) {
+ const value = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${path(tripNo)}/dispatch-candidates`, { params: { keyword, page: 1, size: 100 } }))
+ return { ...value, total: integer(value.total), records: value.records.map(row => ({ ...row, packageCount: integer(row.packageCount) })) }
+}
+export interface TripPreparation { tripNo: string | null; recordVersion: number | null; plan: TripDraft | null; vehicleId: string | null; driverId: string | null; dispatchNos: string[]; idempotencyKey: string }
+export async function prepareTrip(body: TripPreparation): Promise {
+ return detail(unwrapApiResponse(await http.post, ApiEnvelope>(`${base}/prepare`, body)))
+}
diff --git a/src/modules/overseas-master-data/capacityOfferTypes.ts b/src/modules/overseas-master-data/capacityOfferTypes.ts
index 055c599f..69765dbf 100644
--- a/src/modules/overseas-master-data/capacityOfferTypes.ts
+++ b/src/modules/overseas-master-data/capacityOfferTypes.ts
@@ -12,7 +12,11 @@ export interface CapacityOffer {
destinations: OfferDestination[]; segments: OfferSegment[]; capacityKnown: boolean; canCreateInquiry: boolean; canConfirm: boolean; blockReason: string | null
actions: { canEdit: boolean; canPublish: boolean; canPause: boolean; canResume: boolean; canClose: boolean }
}
+export interface InquiryProgress { stage: string; label: string; reservation: string; owner: string; primaryAction: string; message: string; missingFields: string[] }
+export interface AppointmentCoverage { status: string; appointments: { appointmentNo: string; confirmationNo: string | null; status: string; confirmedAt: string | null; confirmedEndAt: string | null; timezone: string | null }[]; missingFields: string[]; sourceVersion: string | null; changeReviewRequired: boolean }
export interface CapacityInquiry {
+ appointmentCoverage: AppointmentCoverage | null
+ progress: InquiryProgress
bookingNo: string; offerNo: string; tripNo: string; status: 'DRAFT' | 'CONFIRMED' | 'RECEIVED' | 'BOUND' | 'CANCELLED'
recordVersion: number; offerRecordVersion: number; tripRecordVersion: number; salesOwnerUserId: string
customerId: string; customerCode: string; customerName: string; businessType: 'LOCAL_LOOSEBOARD' | 'US_TRUCK'
@@ -23,7 +27,7 @@ export interface CapacityInquiry {
}
export interface CapacityOfferDetail { offer: CapacityOffer; inquiries: OfferPage }
export interface OfferQuery { scope?: 'AVAILABLE' | 'ALL'; page?: number; size?: number; keyword?: string; origin?: string; destination?: string; departureFrom?: string; departureTo?: string; status?: string; canConfirm?: boolean; tripNo?: string }
-export interface InquiryQuery { page?: number; size?: number; keyword?: string; offerNo?: string; status?: string; scope?: 'MINE' | 'ALL' }
+export interface InquiryQuery { page?: number; size?: number; keyword?: string; offerNo?: string; status?: string; scope?: 'MINE' | 'ALL' | 'TODO' }
export interface OfferWrite { tripNo: string; tripRecordVersion: number; offerRecordVersion: number | null; cutoffAt: string; contactName: string | null; contactPhone: string | null; remark: string | null; idempotencyKey: string }
export interface OfferCommand { tripRecordVersion: number; offerRecordVersion: number; idempotencyKey: string; reason?: string }
export interface InquiryWrite {
@@ -36,6 +40,9 @@ export interface OfferResult { offer: CapacityOffer; replayed: boolean }
export interface InquiryResult extends OfferResult { inquiry: CapacityInquiry }
export interface OfferCustomerOption { id: string; code: string; name: string }
export const offerStatusLabels = { DRAFT: '发布草稿', OPEN: '接单中', PAUSED: '已暂停', CLOSED: '已关闭' }
-export const inquiryStatusLabels = { DRAFT: '询价中', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已关联发运', CANCELLED: '已取消' }
+export const inquiryStatusLabels = { DRAFT: '已登记', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已排入本车', CANCELLED: '已取消' }
export const offerTime = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
export const loadText = (value: CapacityLoad | null) => value ? `${value.palletSpaces} 托位 / ${value.weightKg} kg / ${value.volumeCbm} m³` : '待调度核对'
+
+export const appointmentCoverageLabels: Record = { SOURCE_NOT_READY: '待确认承运后筹划', SOURCE_NOT_FOUND: '来源待核对', SOURCE_NOT_MATERIALIZED: '待形成真实货件资料', DESTINATION_MISMATCH: '目的地变化待核对', REVIEW_REQUIRED: '货量待复核', UNASSIGNED: '尚未关联收货预约', PARTIAL: '部分货物未覆盖', PENDING: '等待收货方确认', CONFIRMED: '预约记录已确认' }
+export const appointmentMissingLabels: Record = { CARRIER_CONFIRMATION: '承运确认', REAL_CARGO_MATERIALS: '真实货件资料', DESTINATION: '目的地核对', CARGO_QUANTITY_REVIEW: '货量复核', RECEIVING_APPOINTMENT: '收货预约', UNCOVERED_CARGO: '未覆盖货物的预约', RECEIVING_CONFIRMATION: '收货方确认', APPOINTMENT_TIMEZONE_UNVERIFIED: '预约时间所属地区尚未核实', SOURCE_CHANGED_REVIEW: '货源变化后重新核对预约' }
diff --git a/src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts b/src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts
index be75ddee..0f7ee28b 100644
--- a/src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts
+++ b/src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts
@@ -40,8 +40,8 @@ it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited i
const { wrapper, pinia } = mountDialog(kind)
if (kind === 'settings') await wrapper.setProps({ modelValue: true })
await flushPromises()
- if (kind === 'settings') await wrapper.get('details summary').trigger('click')
- const input = wrapper.find(kind === 'inquiry' ? 'input[placeholder="选填"]' : 'input[placeholder="填写公开给销售的联系人(选填)"]')
+ await wrapper.get('details summary').trigger('click')
+ const input = wrapper.find(kind === 'inquiry' ? 'input[placeholder="填写货物名称(确认承运前必填)"]' : 'input[placeholder="填写公开给销售的联系人(选填)"]')
expect(input.exists()).toBe(true)
await input.setValue('UI-未保存检查'); await flushPromises()
const assertPreserved = () => {
@@ -81,8 +81,9 @@ it('blocks browser back with the real router, cached detail and native dialog, w
await router.push('/pool'); await router.isReady()
const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }); wrappers.push(wrapper)
await router.push('/pool/CO-CLOSE'); await flushPromises()
- await wrapper.findAll('button').find(button => button.text() === '登记意向')!.trigger('click'); await flushPromises()
- const input = wrapper.get('input[placeholder="选填"]')
+ await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
+ await wrapper.get('details summary').trigger('click')
+ const input = wrapper.get('input[placeholder="填写货物名称(确认承运前必填)"]')
await input.setValue('UI-修复后保留输入'); await flushPromises()
input.element.focus(); await input.trigger('keydown', { key: 'Escape', code: 'Escape' }); await flushPromises(); await continueEditing()
confirms.mockClear()
@@ -99,8 +100,8 @@ it('blocks browser back with the real router, cached detail and native dialog, w
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offers')
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
await new Promise(resolve => setTimeout(resolve, 50)); await router.push('/pool/CO-CLOSE'); await flushPromises()
- await wrapper.findAll('button').find(button => button.text() === '登记意向')!.trigger('click'); await flushPromises()
- await wrapper.get('input[placeholder="选填"]').setValue('另一次修改'); await flushPromises()
+ await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
+ await wrapper.get('input[placeholder="填写货物名称(确认承运前必填)"]').setValue('另一次修改'); await flushPromises()
confirms.mockClear(); const navigation = router.push('/outside'); await flushPromises()
expect(confirms).toHaveBeenCalledOnce(); expect(document.body.textContent).toContain('全局离开确认')
await continueEditing(); await navigation
diff --git a/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts b/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts
index a44c4d2f..78444e8d 100644
--- a/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts
+++ b/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts
@@ -31,7 +31,7 @@ it.each(scenarios)('uses explicit offer quote queries, pagination and negotiated
const source: FleetQuoteSourceSnapshot = { quoteNo: 'FQ-PAGE-2', recordVersion: '9007199254740993', priceItem: scenario.priceBasis === 'ORDER' ? 'FULL_DELIVERY' : 'LOOSE_DELIVERY', customerId: '19', customerName: '报价测试客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: 35, currency: 'CNY', priceBasis: scenario.priceBasis, quotedUnitPrice: 25.5, validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-20' }
vi.mocked(listOfferQuotes).mockResolvedValueOnce({ records: Array.from({ length: 20 }, (_, index) => ({ ...source, quoteNo: `FQ-PAGE-1-${index + 1}` })), total: 21, page: 1, size: 20, pages: 2 }).mockResolvedValueOnce({ records: [source], total: 21, page: 2, size: 20, pages: 2 })
vi.mocked(saveCapacityInquiry).mockImplementation(async (_offerNo, _bookingNo, body) => {
- const inquiry: CapacityInquiry = { bookingNo: 'FB-QUOTE-SAVED', offerNo: offer.offerNo, tripNo: offer.tripNo, status: 'DRAFT', recordVersion: 1, offerRecordVersion: offer.recordVersion, tripRecordVersion: offer.tripRecordVersion, salesOwnerUserId: '17', customerId: body.customerId, customerCode: 'C-19', customerName: '报价测试客户', businessType: body.businessType, deliveryMode: body.deliveryMode, estimatedArrivalAt: body.estimatedArrivalAt, expectedPalletCount: body.expectedPalletCount, cargo: { ...body.cargo }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: source, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null } }
+ const inquiry: CapacityInquiry = { bookingNo: 'FB-QUOTE-SAVED', offerNo: offer.offerNo, tripNo: offer.tripNo, status: 'DRAFT', recordVersion: 1, offerRecordVersion: offer.recordVersion, tripRecordVersion: offer.tripRecordVersion, salesOwnerUserId: '17', customerId: body.customerId, customerCode: 'C-19', customerName: '报价测试客户', businessType: body.businessType, deliveryMode: body.deliveryMode, estimatedArrivalAt: body.estimatedArrivalAt, expectedPalletCount: body.expectedPalletCount, cargo: { ...body.cargo }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: source, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null }, appointmentCoverage: null, progress: { stage: 'NEEDS_DETAILS', label: '待补资料', reservation: '尚未保位', owner: '销售', primaryAction: 'EDIT', message: '确认前还需补充资料', missingFields: ['预计重量'] } }
return { offer, inquiry, replayed: false }
})
const wrapper = mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false }, global: { plugins: [createPinia(), ElementPlus] } }); wrappers.push(wrapper)
@@ -54,7 +54,7 @@ it.each(scenarios)('uses explicit offer quote queries, pagination and negotiated
await price.get('input').setValue('21.75'); await flushPromises()
expect(price.props('modelValue')).toBe(21.75)
expect(quotePanel.text()).toContain('来源报价 FQ-PAGE-2 · 25.50 CNY')
- await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '保存意向')!.trigger('click'); await flushPromises()
+ await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '保存登记')!.trigger('click'); await flushPromises()
expect(saveCapacityInquiry).toHaveBeenCalledExactlyOnceWith(offer.offerNo, null, expect.objectContaining({ customerId: '19', offerRecordVersion: 4, tripRecordVersion: 7, recordVersion: null, quoteRef: reference, idempotencyKey: expect.any(String), cargo: expect.objectContaining({ destinationKey: 'FBA:ONT8', currency: 'CNY', priceBasis: scenario.priceBasis, unitPrice: 21.75 }) }))
expect(listOfferQuotes).toHaveBeenCalledTimes(2)
expect(wrapper.emitted('saved')).toHaveLength(1)
diff --git a/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue b/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
index d6b758c3..7a491ee8 100644
--- a/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
+++ b/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
@@ -6,21 +6,22 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
-import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry } from '../api/capacityOffers'
+import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry, saveAndConfirmInquiry } from '../api/capacityOffers'
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
+import { offerTime, appointmentCoverageLabels, appointmentMissingLabels } from '../capacityOfferTypes'
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
import type { TripBookingCargo } from '../bookingTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
const open = defineModel({ default: false })
-const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean }>()
-const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: [] }>()
+const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean; confirmMode?: boolean }>()
+const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: []; action: [key: string, row: CapacityInquiry] }>()
const access = useAccessPreviewStore(), busy = ref(false), error = ref(null)
const blankCargo = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
const form = reactive({ customerId: '', businessType: 'LOCAL_LOOSEBOARD' as CapacityInquiry['businessType'], deliveryMode: 'SELF_DELIVERY' as CapacityInquiry['deliveryMode'], estimatedArrivalAt: null as string | null, expectedPalletCount: null as number | null, cargo: blankCargo(), quoteRef: null as FleetQuoteReference | null })
const quoteSource = ref(null), customers = ref([]), customerLoading = ref(false)
const customerError = ref(null), customerSearched = ref(false)
const quoteRows = ref([]), quoteLoading = ref(false), quoteError = ref(null), quoteSearched = ref(false), quotePage = ref(1), quotePages = ref(0)
-const pending = ref<{ target: string; body: InquiryWrite } | null>(null)
+const pending = ref<{ target: string; confirm: boolean; body: InquiryWrite } | null>(null)
const target = computed(() => props.inquiry?.bookingNo || props.offer?.offerNo || '')
const writable = computed(() => !props.readonly && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${props.inquiry ? 'edit' : 'create'}`)) && (props.inquiry ? props.inquiry.actions.canEdit : props.offer?.canCreateInquiry))
const canQuote = computed(() => access.can('platform:fleet:quote:list'))
@@ -40,6 +41,7 @@ watch(() => [open.value, target.value], async () => {
else {
const row = props.inquiry
Object.assign(form, { customerId: row?.customerId || '', businessType: row?.businessType || 'LOCAL_LOOSEBOARD', deliveryMode: row?.deliveryMode || 'SELF_DELIVERY', estimatedArrivalAt: row?.estimatedArrivalAt || null, expectedPalletCount: row?.expectedPalletCount ?? null, cargo: row ? { ...row.cargo } : blankCargo() })
+ if (!row && props.offer?.destinations.length === 1) form.cargo.destinationKey = props.offer.destinations[0]!.destinationKey
quoteSource.value = row?.quoteSource || null; form.quoteRef = row?.quoteSource ? fleetQuoteReference(row.quoteSource) : null
}
await nextTick(); guard.capture()
@@ -69,30 +71,35 @@ async function save() {
if (form.quoteRef && !canQuote.value) { ElMessage.warning('修改带报价来源的意向需要报价读取权限'); return }
const current = generation; busy.value = true; error.value = null
try {
- pending.value ||= { target: target.value, body: { tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
- const result = await saveCapacityInquiry(props.offer.offerNo, props.inquiry?.bookingNo || null, pending.value.body)
- if (current === generation) { pending.value = null; guard.capture(); emit('saved', result); open.value = false; ElMessage.success('意向已保存,尚未占用运力') }
+ pending.value ||= { target: target.value, confirm: !!props.confirmMode, body: { tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
+ const result = pending.value.confirm && props.inquiry ? await saveAndConfirmInquiry(props.inquiry.bookingNo, pending.value.body) : await saveCapacityInquiry(props.offer.offerNo, props.inquiry?.bookingNo || null, pending.value.body)
+ if (current === generation) { pending.value = null; guard.capture(); emit('saved', result); open.value = false; ElMessage.success(result.inquiry.status === 'CONFIRMED' ? '已保留货位并关联本土订单,尚未安排来货' : '登记已保存,调度可见,尚未保留货位') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '意向保存未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
finally { if (current === generation) busy.value = false }
}
onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clearTimeout(timer) })
-
+
+ 核对本票货量、价格与交货资料后确认承运。成功将保留货位并创建关联本土订单,不自动安排仓库收货。
上次提交结果尚未确定,请保留原内容重试或核对。
{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}
+ {{ inquiry.progress.label }} · {{ inquiry.progress.reservation }}下一步负责人:{{ inquiry.progress.owner }} · {{ inquiry.progress.message }}
+ 保存后调度可见,尚不保位。已知货量可先填,未知可留空,确认承运前再补齐。
+ 计划发车:{{ offerTime(offer.plannedDepartureAt) }} · 最晚送仓:{{ offerTime(offer.cutoffAt) }}
{{ inquiry.actions.blockReason }}
暂无可选客户
-
-
+
+ 确认承运前待补资料{{ inquiry?.progress.missingFields.length ? `(还需:${inquiry.progress.missingFields.join('、')})` : '(货名、价格、到仓及联系信息)' }}
+
@@ -100,14 +107,31 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
-
+
+ 其他说明(选填)
+
- 来源报价 {{ quoteSource.quoteNo }} · {{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }};实际约定价格单独保存。
未引用报价库,可手工询价。约定运费不代表应收或实收。
+ 参考报价(可选)
来源报价 {{ quoteSource.quoteNo }} · {{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }};实际约定价格单独保存。
未引用报价库,可手工询价。约定运费不代表应收或实收。
查询报价库改为手工询价
暂无适用报价,可继续手工询价。
上一页 {{ quotePage }} / {{ quotePages }} 下一页
{{ quoteError.message }}
+
+
+ 补充资料
+ 核对并确认
+ 安排来货
+ 核对实收与运费
+ 确认排入本车
+ 查看收货进度
+ 查看排车与审核
+
+
+ 收货预约覆盖
预约跟进{{ appointmentCoverageLabels[inquiry.appointmentCoverage.status] || '预约结果待核对' }}。承运保位不代表收货方已接受本票货物。
+ 待处理:{{ inquiry.appointmentCoverage.missingFields.map(key => appointmentMissingLabels[key] || '请预约人员核对来源资料').join('、') }}
+ 预约:{{ appointment.appointmentNo }} · 外部确认号:{{ appointment.confirmationNo || '尚未确认' }}
+ 货源或数量发生变化,请负责预约的人员重新核对覆盖。
本土订单:{{ inquiry.businessOrderNo || '尚未成交' }} · 来货:{{ inquiry.incomingNo || '尚未登记' }} · 排车:{{ inquiry.dispatchNo || '尚未关联' }}
diff --git a/src/modules/overseas-master-data/components/CapacityInquiryFulfillment.vue b/src/modules/overseas-master-data/components/CapacityInquiryFulfillment.vue
new file mode 100644
index 00000000..e59ec817
--- /dev/null
+++ b/src/modules/overseas-master-data/components/CapacityInquiryFulfillment.vue
@@ -0,0 +1,37 @@
+
+
+
+ 正在读取本票真实履约资料…
+
+
+
+
diff --git a/src/modules/overseas-master-data/components/CapacityInquiryTable.vue b/src/modules/overseas-master-data/components/CapacityInquiryTable.vue
index 3d230c7d..00d78082 100644
--- a/src/modules/overseas-master-data/components/CapacityInquiryTable.vue
+++ b/src/modules/overseas-master-data/components/CapacityInquiryTable.vue
@@ -10,16 +10,20 @@ const access = useAccessPreviewStore()
function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
const manage = access.can('platform:fleet:capacity-offer:manage')
const write = (kind: string) => manage || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${kind}`)
- return [{ key: 'view', label: '详情', priority: 10 },
- { key: 'edit', label: '编辑', visible: row.actions.canEdit && write('edit'), disabled: busy },
- { key: 'confirm', label: '确认承运', visible: row.actions.canConfirm && manage, disabled: busy },
+ return [{ key: 'view', label: '查看进度', priority: 10 },
+ { key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit'), disabled: busy },
+ { key: 'receipt', label: '查看收货并核量', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm'), disabled: busy },
+ { key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'), disabled: busy },
+ { key: 'fulfillment', label: '收货进度', visible: !!row.incomingNo, disabled: busy },
+ { key: 'edit', label: '补充资料', visible: row.actions.canEdit && write('edit'), disabled: busy },
+ { key: 'confirm', label: '核对并确认', visible: (row.actions.canConfirm || row.actions.canEdit) && manage, disabled: busy },
{ key: 'cancel', label: '取消意向', visible: row.actions.canCancel && write('cancel'), disabled: busy, tone: 'danger' }]
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
{ key: 'bookingNo', label: '意向 / 预订号', minWidth: 210 }, { key: 'customerName', label: '客户', minWidth: 155 },
{ key: 'tripNo', label: '车次号', minWidth: 210 }, { key: 'destinationLabel', label: '卸货站', minWidth: 130 },
- { key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'cargo', label: '预计货量', minWidth: 270, slotName: 'cargo' },
+ { key: 'status', label: '当前进度 / 下一步', minWidth: 270, slotName: 'status', singleLine: false }, { key: 'cargo', label: '预计货量', minWidth: 270, slotName: 'cargo' },
{ key: 'agreedAmount', label: '约定运费(非实收)', minWidth: 165, slotName: 'amount' }, { key: 'businessOrderNo', label: '本土订单号', minWidth: 210 },
{ key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' },
]
@@ -29,7 +33,7 @@ const columns: DataTableColumn[] = [
- {{ inquiryStatusLabels[(row as CapacityInquiry).status] }}{{ row.actions.blockReason }}
+ {{ row.progress.label }} · {{ row.progress.reservation }}下一步:{{ row.progress.owner }}
{{ row.progress.message }}
{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³
{{ row.agreedAmount == null ? '待报价' : `${Number(row.agreedAmount).toFixed(2)} ${row.cargo.currency || ''}` }}
{{ offerTime(row.updatedAt) }}
diff --git a/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts
index a650a27e..141110bc 100644
--- a/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts
+++ b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts
@@ -1,3 +1,4 @@
+import { createPinia } from 'pinia'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
@@ -14,7 +15,7 @@ const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, i
const wrappers: VueWrapper[] = []
function mount(mode: 'incoming' | 'receipt' | 'bind' | 'view') {
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '' }
- const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode }, global: { directives: { loading: () => undefined }, stubs: {
+ const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode, arrivalAt: '2026-10-03T08:00:00Z' }, global: { plugins: [createPinia()], directives: { loading: () => undefined }, stubs: {
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '
' },
DataTable: { props: ['data'], template: '' },
ElForm: { template: '
' }, ElFormItem: { template: '
' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field,
@@ -43,3 +44,15 @@ it('retries the same incoming registration after an uncertain result without cre
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.startTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', first); expect(api.getTripIncoming).not.toHaveBeenCalled()
})
+
+it('keeps an uncertain receipt command retryable after closing and reopening without reloading or changing its hash', async () => {
+ vi.mocked(api.acceptTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: true })
+ const wrapper=mount('receipt'); await flushPromises()
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ const original=vi.mocked(api.acceptTripIncoming).mock.calls[0]![2]
+ await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
+ expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(false)
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', original)
+ expect(api.getTripIncoming).toHaveBeenCalledOnce()
+})
diff --git a/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.vue b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.vue
index dbaf1838..047a061b 100644
--- a/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.vue
+++ b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.vue
@@ -1,5 +1,5 @@
-
+
-
{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}
+
{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}
本票上次提交结果尚未确定,保留原内容与请求键重试;不会改为处理另一票货物。
- 来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ statuses[preview?.incoming?.status || ''] || '等待读取' }}
+ 刷新收货进度来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ statuses[preview?.incoming?.status || ''] || '等待读取' }}
原约定 {{ booking.agreedAmount?.toFixed(2) }} {{ booking.cargo.currency }};本次实收 {{ preview?.incoming?.receivedPackages || 0 }} 箱、{{ totalSpaces }} 托位,确认金额 {{ amount?.toFixed(2) || '—' }} {{ booking.cargo.currency }}。转派送时按本次确认报价登记应收。
-
先将来货库存预排为客户排车单,再填写排车单号。关联后重新核对整车装载并完成审核。
+
将本票全部实收货物排入原车次。系统复用合格排车单,没有时生成并关联;来源冲突或货量不完整时停止。成功后仍需核对容量并完成审核,不代表已装车或发车。
先将来货库存预排为客户排车单,再填写排车单号。关联后重新核对整车装载并完成审核。
diff --git a/src/modules/overseas-master-data/components/TripDispatchSelector.vue b/src/modules/overseas-master-data/components/TripDispatchSelector.vue
new file mode 100644
index 00000000..a722d12b
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripDispatchSelector.vue
@@ -0,0 +1,21 @@
+
+{{ error }} 重试
候选较多,请输入排车单号缩小范围。
diff --git a/src/modules/overseas-master-data/components/TripPreparationDialog.test.ts b/src/modules/overseas-master-data/components/TripPreparationDialog.test.ts
new file mode 100644
index 00000000..071bf519
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripPreparationDialog.test.ts
@@ -0,0 +1,41 @@
+import { createPinia } from 'pinia'
+import { shallowMount, flushPromises } from '@vue/test-utils'
+import { beforeEach, expect, it, vi } from 'vitest'
+import TripPreparationDialog from './TripPreparationDialog.vue'
+import { listTrips, prepareTrip } from '../api/trips'
+import type { TripDetail, FleetTrip } from '../tripTypes'
+vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
+vi.mock('../api/trips', () => ({ listTrips: vi.fn(), prepareTrip: vi.fn() }))
+vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
+const trip = { tripNo: 'TR-READY', recordVersion: 4, warehouseId: '9007199254740993', stops: [{ name: '起运仓' }, { name: 'ACY2' }] } as FleetTrip
+beforeEach(() => { vi.clearAllMocks(); vi.mocked(listTrips).mockResolvedValue({ records: [trip], page: 1, size: 100, total: 1, pages: 1 }) })
+function render() {
+ const field = { props: ['modelValue'], emits: ['update:modelValue', 'focus'], template: '
' }
+ return shallowMount(TripPreparationDialog, { props: { modelValue: true, warehouseId: trip.warehouseId, warehouseName: '本仓', dispatchNos: ['DS-ONE', 'DS-TWO'] }, global: { plugins: [createPinia()], stubs: {
+ AppDialog: { name: 'AppDialog', props: ['showConfirm'], emits: ['confirm'], template: '
' },
+ ElForm: field, ElFormItem: field, ElSelect: { ...field, name: 'ElSelect' }, ElOption: true,
+ ElRadioGroup: { ...field, name: 'ElRadioGroup' }, ElRadio: true, ElDatePicker: field,
+ ElButton: { template: '' }, CapacityOfferSettingsDialog: true,
+ } } })
+}
+it('loads same-warehouse trips on demand and retries one immutable atomic preparation', async () => {
+ const wrapper=render(); await flushPromises()
+ expect(listTrips).not.toHaveBeenCalled()
+ const select=wrapper.findComponent({ name: 'ElSelect' })
+ select.vm.$emit('focus'); await flushPromises()
+ expect(listTrips).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '9007199254740993', status: 'PLANNED' }))
+ select.vm.$emit('update:modelValue', trip.tripNo); await flushPromises()
+ vi.mocked(prepareTrip).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ trip, dispatches: [], driverQualification: null, loadingReview: { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null } } as TripDetail)
+ const dialog=wrapper.findComponent({ name: 'AppDialog' })
+ dialog.vm.$emit('confirm'); await flushPromises()
+ const original=vi.mocked(prepareTrip).mock.calls[0]![0]
+ expect(original).toMatchObject({ tripNo: 'TR-READY', recordVersion: 4, dispatchNos: ['DS-ONE','DS-TWO'], plan: null, vehicleId: null, driverId: null })
+ expect(wrapper.emitted('saved')).toBeUndefined()
+ dialog.vm.$emit('confirm'); await flushPromises()
+ expect(prepareTrip).toHaveBeenNthCalledWith(2, original)
+ expect(wrapper.emitted('saved')).toHaveLength(1)
+ expect(wrapper.text()).toContain('核对容量并发布余位')
+ expect(dialog.props('showConfirm')).toBe(false)
+ wrapper.unmount()
+})
diff --git a/src/modules/overseas-master-data/components/TripPreparationDialog.vue b/src/modules/overseas-master-data/components/TripPreparationDialog.vue
new file mode 100644
index 00000000..a46ea9d2
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripPreparationDialog.vue
@@ -0,0 +1,86 @@
+
+
+
+
+ {{ pending ? submittedSummary?.warehouseName : warehouseName }} · 已选 {{ (pending ? submittedSummary?.dispatchNos : dispatchNos)?.length }} 张排车单:{{ (pending ? submittedSummary?.dispatchNos : dispatchNos)?.join('、') }}
+ 提交结果尚未确定,请保留原请求重试,不另建车次。
+ 已安排到 {{ result.trip.tripNo }} · {{ result.trip.stops.map(s => s.name).join(' → ') }}
多目的地请先在车次中确认行驶顺序,再核对容量发布余位。安排成功不代表已审核或发车。
查看车次核对容量并发布余位
+
+ 加入已有车次新建车次
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/index.ts b/src/modules/overseas-master-data/index.ts
index 5e9055e0..32e76b27 100644
--- a/src/modules/overseas-master-data/index.ts
+++ b/src/modules/overseas-master-data/index.ts
@@ -3,3 +3,7 @@ export { overseasMasterDataPermissions } from './permissions'
export type * from './types'
export { default as TransportRouteDialog } from './components/TransportRouteDialog.vue'
export { routeLocation, type RouteLocation } from './routeLocations'
+
+export { default as TripPreparationDialog } from './components/TripPreparationDialog.vue'
+export { default as TripDispatchSelector } from './components/TripDispatchSelector.vue'
+export { listTripDispatchCandidates, prepareTrip, type TripPreparation, type DispatchCandidate } from './api/trips'
diff --git a/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue b/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue
index db94278c..a872d376 100644
--- a/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue
+++ b/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue
@@ -10,6 +10,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
+import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment.vue'
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
import { getCapacityOffer, getCapacityInquiry, listCapacityInquiries } from '../api/capacityOffers'
import { offerStatusLabels, offerTime, loadText, type CapacityOfferDetail, type CapacityInquiry, type InquiryResult } from '../capacityOfferTypes'
@@ -17,6 +18,16 @@ import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const detail = ref(null), loading = ref(false), error = ref(null), inquiryError = ref(null)
const page = ref(1), size = ref(20), inquiryLoading = ref(false), editorOpen = ref(false), editing = ref(null), readonly = ref(false)
+const confirmMode = ref(false)
+const fulfillmentOpen = ref(false), fulfillmentRow = ref(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
+function openFulfillment(key: string, row: CapacityInquiry) {
+ if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true }
+ if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }); return true }
+ if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false
+ editorOpen.value = false
+ fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true
+ return true
+}
let sequence = 0, inquirySequence = 0, resetting = false
const hasCreatePermission = computed(() => (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create')))
const canCreate = computed(() => detail.value?.offer.canCreateInquiry && hasCreatePermission.value)
@@ -49,8 +60,8 @@ function accept(value: InquiryResult) {
for (const row of rows) { row.offerRecordVersion = value.offer.recordVersion; row.tripRecordVersion = value.offer.tripRecordVersion }
}
const commands = useCapacityInquiryCommands('capacity-offer-command', accept)
-async function action(key: string, row: CapacityInquiry) { if (key === 'confirm' || key === 'cancel') await commands.run(row, key); else { editing.value = row; readonly.value = key !== 'edit'; editorOpen.value = true } }
-function create() { if (!canCreate.value) return; editing.value = null; readonly.value = false; editorOpen.value = true }
+async function action(key: string, row: CapacityInquiry) { if (openFulfillment(key, row)) return; if (key === 'cancel') await commands.run(row, key); else { if (key === 'confirm' && !access.can('platform:fleet:capacity-offer:manage')) return; confirmMode.value = key === 'confirm'; editing.value = row; readonly.value = key !== 'edit' && key !== 'confirm'; editorOpen.value = true } }
+function create() { if (!canCreate.value) return; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
watch(() => [route.name, route.params.offerNo], ([name, offerNo]) => {
if (name !== 'overseas-master-data-capacity-offer-detail' || typeof offerNo !== 'string' || !offerNo) return
detail.value = null; editorOpen.value = false; void load()
@@ -59,17 +70,18 @@ watch([page, size], loadInquiries)
onBeforeUnmount(() => { sequence++; inquirySequence++ })
-
+
- - 车次号
- {{ detail.offer.tripNo }}{{ detail.offer.tripNo }}
- 起运仓
- {{ detail.offer.warehouseName }}
- 计划发车
- {{ offerTime(detail.offer.plannedDepartureAt) }}
- 交货截止
- {{ offerTime(detail.offer.cutoffAt) }}
- 调度联系人
- {{ detail.offer.contactName || '—' }}
- 调度联系方式
- {{ detail.offer.contactPhone || '—' }}
- 无法登记意向:{{ detail.offer.blockReason || '当前发布不接受新意向' }}
- {{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记意向不占位,调度确认后占位。' }}
{{ detail.offer.remark }}
+ - 车次号
- {{ detail.offer.tripNo }}{{ detail.offer.tripNo }}
- 起运仓
- {{ detail.offer.warehouseName }}
- 计划发车
- {{ offerTime(detail.offer.plannedDepartureAt) }}
- 最晚送仓
- {{ offerTime(detail.offer.cutoffAt) }}
- 调度联系人
- {{ detail.offer.contactName || '—' }}
- 调度联系方式
- {{ detail.offer.contactPhone || '—' }}
+ 无法登记客户货物:{{ detail.offer.blockReason || '当前发布不接受新意向' }}
+ {{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记客户货物不占位,调度确认后占位。' }}
{{ detail.offer.remark }}
分段运力
{{ loadText(row.planned) }}{{ loadText(row.reserved) }}{{ detail.offer.capacityKnown ? loadText(row.remaining) : '待调度核对' }}
- {{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的意向' }}
+ {{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的登记' }}
重试原操作
-
+
+
diff --git a/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts b/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts
index 135927ae..9be4ccc4 100644
--- a/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts
+++ b/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts
@@ -14,9 +14,9 @@ const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, rout
vi.mock('vue-router', () => ({ useRoute: () => state.route, useRouter: () => ({ push: state.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => p === 'platform:fleet:capacity-offer:manage' ? state.manage : p === 'platform:fleet:quote:list' ? state.quote : p === 'platform:fleet:capacity-offer:list' || p.includes(':inquiry-') && state.write }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
-vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), listCapacityInquiries: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), saveCapacityInquiry: vi.fn(), commandCapacityInquiry: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() }))
+vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), listCapacityInquiries: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), saveCapacityInquiry: vi.fn(), saveAndConfirmInquiry: vi.fn(), commandCapacityInquiry: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() }))
export const offer: CapacityOffer = { offerNo: 'CO-1', tripNo: 'TR-1', status: 'OPEN', recordVersion: 2, tripRecordVersion: 5, warehouseId: '92', warehouseName: '测试起运仓', plannedDepartureAt: '2026-10-10T12:00:00Z', cutoffAt: '2026-10-10T10:00:00Z', contactName: '调度', contactPhone: '测试联系信息', remark: null, capacityKnown: false, canCreateInquiry: true, canConfirm: false, blockReason: '待调度核对', destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: null }], segments: [], actions: { canEdit: true, canPublish: false, canPause: true, canResume: false, canClose: true } }
-export const inquiry: CapacityInquiry = { bookingNo: 'FB-1', offerNo: 'CO-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 1, offerRecordVersion: 2, tripRecordVersion: 5, salesOwnerUserId: '17', customerId: '19', customerCode: 'C-19', customerName: '测试客户', businessType: 'US_TRUCK', deliveryMode: 'SELF_DELIVERY', estimatedArrivalAt: null, expectedPalletCount: null, cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: null, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null } }
+export const inquiry: CapacityInquiry = { bookingNo: 'FB-1', offerNo: 'CO-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 1, offerRecordVersion: 2, tripRecordVersion: 5, salesOwnerUserId: '17', customerId: '19', customerCode: 'C-19', customerName: '测试客户', businessType: 'US_TRUCK', deliveryMode: 'SELF_DELIVERY', estimatedArrivalAt: null, expectedPalletCount: null, cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: null, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null }, appointmentCoverage: null, progress: { stage: 'NEEDS_DETAILS', label: '待补资料', reservation: '尚未保位', owner: '销售', primaryAction: 'EDIT', message: '确认前还需补充资料', missingFields: ['预计重量'] } }
const pageOf = (records: T[], total = records.length) => ({ records, total, page: 1, size: 20, pages: Math.ceil(total / 20) })
const wrappers: VueWrapper[] = []
const layout = { template: '
' }
@@ -44,7 +44,7 @@ it('loads only the active tab and sends exactly one query after resetting from a
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledTimes(3); expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
- await button(wrapper, '我的意向').trigger('click'); await flushPromises()
+ await button(wrapper, '我的登记').trigger('click'); await flushPromises()
expect(api.listCapacityInquiries).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'MINE' })); expect(api.getCapacityOffer).not.toHaveBeenCalled()
const query = wrapper.findComponent({ name: 'QueryToolbar' }); query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
expect(api.listCapacityInquiries).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'MINE' }))
@@ -52,7 +52,7 @@ it('loads only the active tab and sends exactly one query after resetting from a
it('ignores an older page response after changing tabs and retains an explicit error state', async () => {
let resolve!: (value: ReturnType>) => void
vi.mocked(api.listCapacityOffers).mockReturnValue(new Promise(done => { resolve = done }))
- const { wrapper } = mount(CapacityOffersView); await button(wrapper, '我的意向').trigger('click'); await flushPromises()
+ const { wrapper } = mount(CapacityOffersView); await button(wrapper, '我的登记').trigger('click'); await flushPromises()
resolve(pageOf([offer], 900)); await flushPromises()
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).props('total')).toBe(1)
vi.mocked(api.listCapacityInquiries).mockRejectedValue(new Error('读取失败')); wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
@@ -62,7 +62,7 @@ it('ignores an older page response after changing tabs and retains an explicit e
it('reads detail once, permits drafting while capacity is unknown, and never preloads customers or quotes', async () => {
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
expect(api.getCapacityOffer).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
- await button(wrapper, '登记意向').trigger('click'); await flushPromises()
+ await button(wrapper, '登记客户货物').trigger('click'); await flushPromises()
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(true)
expect(api.listOfferCustomers).not.toHaveBeenCalled(); expect(api.listOfferQuotes).not.toHaveBeenCalled()
})
@@ -100,7 +100,7 @@ it('retains an uncertain request and route protection after closing and reopenin
})
it('keeps sales from confirming even if a stale row advertises the action', async () => {
const row = { ...inquiry, actions: { ...inquiry.actions, canConfirm: true } }
- const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的意向').trigger('click'); await flushPromises()
+ const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的登记').trigger('click'); await flushPromises()
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'confirm', row); await flushPromises()
expect(api.commandCapacityInquiry).not.toHaveBeenCalled(); expect(ElMessageBox.confirm).not.toHaveBeenCalled()
})
@@ -110,27 +110,31 @@ it('loads publishing settings only when explicitly opened, not with the trip pag
expect(api.listCapacityOffers).not.toHaveBeenCalled(); await (wrapper as VueWrapper>).setProps({ modelValue: true }); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith({ scope: 'ALL', tripNo: 'TR-1', page: 1, size: 1 })
})
-it('retries an uncertain dispatch confirmation with the same command and no second confirmation box', async () => {
+it('retries an uncertain combined save and confirmation with the original payload', async () => {
state.manage = true
const row = { ...inquiry, actions: { ...inquiry.actions, canConfirm: true } }
- vi.mocked(api.commandCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry: { ...row, status: 'CONFIRMED' }, replayed: true })
- const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的意向').trigger('click'); await flushPromises()
- wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'confirm', row); await flushPromises()
- const first = vi.mocked(api.commandCapacityInquiry).mock.calls[0]!
- await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
- expect(api.commandCapacityInquiry).toHaveBeenNthCalledWith(2, ...first); expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
- expect(api.listCapacityInquiries).toHaveBeenCalledOnce()
+ vi.mocked(api.saveAndConfirmInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry: { ...row, status: 'CONFIRMED' }, replayed: true })
+ const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: row, readonly: false, confirmMode: true }); await flushPromises()
+ const dialog = wrapper.findComponent({ name: 'AppDialog' })
+ dialog.vm.$emit('confirm'); await flushPromises()
+ const first = vi.mocked(api.saveAndConfirmInquiry).mock.calls[0]!
+ expect(first[0]).toBe(row.bookingNo)
+ expect(wrapper.emitted('saved')).toBeUndefined()
+ dialog.vm.$emit('confirm'); await flushPromises()
+ expect(api.saveAndConfirmInquiry).toHaveBeenNthCalledWith(2, ...first)
+ expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
+ expect(wrapper.emitted('saved')).toHaveLength(1)
})
it('keeps the create button disabled with a reason after cutoff, while hiding it without write permission', async () => {
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: { ...offer, canCreateInquiry: false, blockReason: '交货截止已过或不符合当前发车时间' }, inquiries: pageOf([]) })
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
- expect(button(wrapper, '登记意向').attributes('disabled')).toBeDefined()
- expect(wrapper.text()).toContain('无法登记意向:交货截止已过')
+ expect(button(wrapper, '登记客户货物').attributes('disabled')).toBeDefined()
+ expect(wrapper.text()).toContain('无法登记客户货物:交货截止已过')
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
state.write = false
const readonly = mount(CapacityOfferDetailView); await flushPromises()
- expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记意向')).toBe(false)
+ expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记客户货物')).toBe(false)
})
it('defaults administrators to available offers and requires an explicit history filter', async () => {
state.manage = true
@@ -142,3 +146,18 @@ it('defaults administrators to available offers and requires an explicit history
query.vm.$emit('update:modelValue', {}); await flushPromises(); query.vm.$emit('search'); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'AVAILABLE' }))
})
+
+it('automatically selects a single destination and preserves unknown quantities when only the customer is known', async () => {
+ vi.mocked(api.saveCapacityInquiry).mockResolvedValue({ offer, inquiry, replayed: false })
+ const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: null, readonly: false }); await flushPromises()
+ wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', '19'); await flushPromises()
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(api.saveCapacityInquiry).toHaveBeenCalledWith(offer.offerNo, null, expect.objectContaining({ customerId: '19', cargo: expect.objectContaining({ destinationKey: 'FBA:ONT8', packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null }) }))
+})
+it('requires an explicit choice for multiple destinations', async () => {
+ const many = { ...offer, destinations: [...offer.destinations, { ...offer.destinations[0]!, destinationKey: 'FBA:ACY2', name: 'ACY2', deliveryIndex: 2 }] }
+ const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer: many, inquiry: null, readonly: false }); await flushPromises()
+ wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', '19'); await flushPromises()
+ wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
+})
diff --git a/src/modules/overseas-master-data/pages/CapacityOffersView.vue b/src/modules/overseas-master-data/pages/CapacityOffersView.vue
index bdcbb79a..8dc6346a 100644
--- a/src/modules/overseas-master-data/pages/CapacityOffersView.vue
+++ b/src/modules/overseas-master-data/pages/CapacityOffersView.vue
@@ -11,6 +11,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
+import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment.vue'
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
import { listCapacityOffers, listCapacityInquiries, getCapacityOffer, getCapacityInquiry } from '../api/capacityOffers'
import { offerStatusLabels, inquiryStatusLabels, offerTime, loadText, type CapacityOffer, type CapacityInquiry, type InquiryResult, type OfferQuery, type InquiryQuery } from '../capacityOfferTypes'
@@ -20,6 +21,16 @@ const tab = ref<'offers' | 'inquiries'>('offers'), filters = ref({})
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref(null)
const offers = ref([]), inquiries = ref([])
const editorOpen = ref(false), editing = ref(null), selectedOffer = ref(null), readonly = ref(true)
+const confirmMode = ref(false)
+const fulfillmentOpen = ref(false), fulfillmentRow = ref(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
+function openFulfillment(key: string, row: CapacityInquiry) {
+ if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true }
+ if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }); return true }
+ if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false
+ editorOpen.value = false
+ fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true
+ return true
+}
let sequence = 0, editorSequence = 0
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
const fields = computed(() => tab.value === 'offers' ? [
@@ -30,15 +41,22 @@ const fields = computed(() => tab.value === 'offers' ? [
] : [
{ key: 'keyword', label: '意向关键词', type: 'input', placeholder: '预订号 / 客户 / 车次', width: 260 },
{ key: 'status', label: '意向状态', type: 'select', placeholder: '全部状态', width: 150, options: Object.entries(inquiryStatusLabels).map(([value, label]) => ({ value, label })) },
- ...(manage.value ? [{ key: 'scope', label: '意向范围', type: 'select', width: 150, placeholder: '我的意向', options: [{ value: 'MINE', label: '我的意向' }, { value: 'ALL', label: '全部意向' }] } as QueryField] : []),
+ ...(manage.value ? [{ key: 'scope', label: '意向范围', type: 'select', width: 150, placeholder: '我的登记', options: [{ value: 'MINE', label: '我的登记' }, { value: 'ALL', label: '全部登记' }, { value: 'TODO', label: '待我处理' }] } as QueryField] : []),
])
-const columns: DataTableColumn[] = [
- { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'offerNo', label: '发布号 / 车次号', minWidth: 220, slotName: 'identity' },
+const columns = computed(() => [
+ { key: 'actions', label: '操作', width: 200, fixed: 'left', slotName: 'actions' }, { key: 'offerNo', label: '发布号 / 车次号', minWidth: 220, slotName: 'identity' },
{ key: 'warehouseName', label: '起运仓', minWidth: 155 }, { key: 'destinations', label: '目的站与可接余位', minWidth: 310, slotName: 'destinations', singleLine: false },
- { key: 'plannedDepartureAt', label: '发车 / 交货截止', minWidth: 195, slotName: 'times' },
- { key: 'segments', label: '分段计划占用 / 外接预留', minWidth: 320, slotName: 'loads', singleLine: false },
+ { key: 'plannedDepartureAt', label: '发车 / 最晚送仓', minWidth: 195, slotName: 'times' },
+ ...(manage.value ? [{ key: 'segments', label: '分段计划占用 / 外接预留', minWidth: 320, slotName: 'loads', singleLine: false }] : []),
{ key: 'status', label: '接单状态', minWidth: 170, slotName: 'status', singleLine: false }, { key: 'contactName', label: '调度联系信息', minWidth: 180, slotName: 'contact' },
-]
+])
+const canCreate = computed(() => manage.value || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create'))
+async function create(row: CapacityOffer) {
+ if (!canCreate.value || !row.canCreateInquiry) return
+ const current = ++editorSequence; error.value = null
+ try { const value = await getCapacityOffer(row.offerNo); if (current !== editorSequence) return; selectedOffer.value = value.offer; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
+ catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '登记信息读取失败') }
+}
function switchTab(value: 'offers' | 'inquiries') { if (tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
async function load() {
const current = ++sequence; loading.value = true; error.value = null
@@ -48,10 +66,10 @@ async function load() {
const query: OfferQuery = { scope: manage.value && q.scope === 'ALL' ? 'ALL' : 'AVAILABLE', page: page.value, size: size.value, origin: String(q.origin || '') || undefined, destination: String(q.destination || '') || undefined, departureFrom: String(q.departureFrom || '') || undefined, departureTo: String(q.departureTo || '') || undefined, status: manage.value ? String(q.status || '') || undefined : undefined, canConfirm: q.canConfirm === 'true' ? true : q.canConfirm === 'false' ? false : undefined }
const value = await listCapacityOffers(query); if (current === sequence) { offers.value = value.records; total.value = value.total }
} else {
- const query: InquiryQuery = { page: page.value, size: size.value, keyword: String(q.keyword || '') || undefined, status: String(q.status || '') || undefined, scope: manage.value && q.scope === 'ALL' ? 'ALL' : 'MINE' }
+ const query: InquiryQuery = { page: page.value, size: size.value, keyword: String(q.keyword || '') || undefined, status: String(q.status || '') || undefined, scope: manage.value && q.scope === 'TODO' ? 'TODO' : manage.value && q.scope === 'ALL' ? 'ALL' : 'MINE' }
const value = await listCapacityInquiries(query); if (current === sequence) { inquiries.value = value.records; total.value = value.total }
}
- } catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '拼车需求池读取失败') }
+ } catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '拼车接货读取失败') }
finally { if (current === sequence) loading.value = false }
}
function search() { applied.value = { ...filters.value }; if (page.value !== 1) page.value = 1; else void load() }
@@ -63,13 +81,16 @@ function accept(value: InquiryResult) {
}
const commands = useCapacityInquiryCommands('capacity-pool-command', accept)
async function inquiryAction(key: string, row: CapacityInquiry) {
- if (key === 'confirm' || key === 'cancel') { await commands.run(row, key); return }
+ if (openFulfillment(key, row)) return
+ if (key === 'cancel') { await commands.run(row, key); return }
+ if (key === 'confirm' && !manage.value) return
+ confirmMode.value = key === 'confirm'
const current = ++editorSequence; error.value = null
- if (key === 'edit') {
+ if (key === 'edit' || key === 'confirm') {
try { const detail = await getCapacityOffer(row.offerNo); if (current !== editorSequence) return; selectedOffer.value = detail.offer }
catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '意向编辑信息读取失败'); return }
} else selectedOffer.value = null
- editing.value = row; readonly.value = key !== 'edit'; editorOpen.value = true
+ editing.value = row; readonly.value = key !== 'edit' && key !== 'confirm'; editorOpen.value = true
}
async function recheckEditor() {
if (!editing.value) return
@@ -82,33 +103,34 @@ async function recheckEditor() {
}
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++; editorSequence++ })
-
+
- 拼车需求池
+ 拼车接货
待我处理选择车次发布余位
-
-
+
+
- 详情
+ 登记客户货物详情
{{ row.offerNo }}
{{ row.tripNo }}
{{ stop.name }} · {{ row.capacityKnown ? loadText(stop.remaining) : '待调度核对' }}
- {{ offerTime(row.plannedDepartureAt) }}
截止 {{ offerTime(row.cutoffAt) }}
+ 发车 {{ offerTime(row.plannedDepartureAt) }}
最晚送仓 {{ offerTime(row.cutoffAt) }}
{{ row.capacityKnown ? '—' : '待调度核对' }}{{ segment.fromName }} → {{ segment.toName }}
计划 {{ loadText(segment.planned) }}
预留 {{ loadText(segment.reserved) }}
- {{ offerStatusLabels[(row as CapacityOffer).status] }} · {{ row.canConfirm ? '可确认承运' : '暂不可确认' }}{{ row.blockReason }}
+ {{ offerStatusLabels[(row as CapacityOffer).status] }} · {{ row.canConfirm ? '可登记,确认后保位' : row.canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}{{ row.blockReason }}
{{ row.contactName || '—' }}
{{ row.contactPhone || '—' }}
- {{ applied.scope === 'ALL' ? '全部意向' : '我的意向' }}
+ {{ applied.scope === 'TODO' ? '待我处理' : applied.scope === 'ALL' ? '全部登记' : '我的登记' }}
重试原操作
-
+
+
diff --git a/src/modules/overseas-master-data/pages/TripDetailView.vue b/src/modules/overseas-master-data/pages/TripDetailView.vue
index bb1f8d61..68b133e7 100644
--- a/src/modules/overseas-master-data/pages/TripDetailView.vue
+++ b/src/modules/overseas-master-data/pages/TripDetailView.vue
@@ -1,5 +1,6 @@