diff --git a/src/modules/appointment-calendar/AppointmentCargoPanel.vue b/src/modules/appointment-calendar/AppointmentCargoPanel.vue
index 9422a72e..19848d92 100644
--- a/src/modules/appointment-calendar/AppointmentCargoPanel.vue
+++ b/src/modules/appointment-calendar/AppointmentCargoPanel.vue
@@ -10,23 +10,43 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import RowActions from '@/shared/components/RowActions.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
-import { listPlanningCargo, resolvePlanningSource, previewPlanningBatch, createSourceAppointment, type SourceAppointmentPayload, type PlanningBatchPreview, type ResolvedPlanningSource, type PlanningCargo } from './planningApi'
+import { listPlanningCargo, resolvePlanningSource, previewPlanningBatch, createSourceAppointment, type SourceAppointmentPayload, type PlanningBatchPreview, type ResolvedPlanningSource, type PlanningCargo, type PlanningSource } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const router = useRouter()
const access = useAccessPreviewStore()
-const emit = defineEmits<{ created: [] }>()
+const props = defineProps<{ externalSource?: PlanningSource | null }>()
+const emit = defineEmits<{ created: []; sourceAccepted: [] }>()
const arrangement = reactive(createAppointmentForm())
const creating = ref(false), createError = ref
(null)
const pendingCreate = ref(null)
const following = ref(null)
const followupError = ref(null)
-const selected = ref>({})
+type Selection = Pick & { bookingNo?: string | null }
+const selected = ref>({})
const selectedRows = computed(() => Object.values(selected.value))
+const externalRows = computed(() => selectedRows.value.filter(row => row.sourceKey.startsWith('BOOKING:')))
const batchPreview = ref(null)
const batchLoading = ref(false), batchError = ref(null)
let batchRequest = 0
+function addSource(source: PlanningSource) {
+ if (!access.can(overseasWarehousePermissions.inboundOrderAppointment) || batchLoading.value || creating.value) return false
+ if (!source.canFollowup) { ElMessage.warning('当前来源尚不能安排预约'); return false }
+ const anchor = selectedRows.value[0]
+ if (anchor && (anchor.originWarehouseId !== source.originWarehouseId || anchor.destinationWarehouseCode !== source.destinationWarehouseCode)) {
+ ElMessage.warning('请先完成或清空当前合批选择;不同起运仓或目的仓不能混入'); return false
+ }
+ if (!selected.value[source.sourceKey] && selectedRows.value.length >= 100) { ElMessage.warning('每批最多选择100票'); return false }
+ selected.value[source.sourceKey] = source; batchError.value = null
+ return true
+}
+function acceptExternalSource() {
+ if (props.externalSource && addSource(props.externalSource)) emit('sourceAccepted')
+}
+function arrangeFollowing() {
+ if (following.value && !following.value.coverage.appointments.length && addSource(following.value.source)) following.value = null
+}
function selectable(row: PlanningCargo) {
const anchor = selectedRows.value[0]
return !row.detained && !row.mixedDestinations && !row.hasActiveAppointment && (!anchor || (anchor.originWarehouseId === row.originWarehouseId && anchor.destinationWarehouseCode === row.destinationWarehouseCode))
@@ -124,6 +144,14 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
+
+ 待加入来源:{{ externalSource.bookingNo || externalSource.inboundCode || '尚未关联业务编号' }} · {{ externalSource.originWarehouseName }} → {{ externalSource.destinationWarehouseCode }}
+ 加入本次选择
+
+
+ 已选外接货 {{ externalRows.length }} 票(可与同仓头程货合批)
+ {{ row.bookingNo || row.inboundCode || '尚未关联业务编号' }}移除选择
+
@@ -138,7 +166,7 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
{{ row.hasInboundPlan ? '已建入仓计划,待安排' : '尚未建入仓计划' }}
-
+
{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}
{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱
@@ -151,4 +179,7 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
diff --git a/src/modules/appointment-calendar/AppointmentFollowupDialog.vue b/src/modules/appointment-calendar/AppointmentFollowupDialog.vue
index b615d0bd..5e764230 100644
--- a/src/modules/appointment-calendar/AppointmentFollowupDialog.vue
+++ b/src/modules/appointment-calendar/AppointmentFollowupDialog.vue
@@ -8,14 +8,14 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getFollowups, saveFollowup, getContainerFollowups, saveContainerFollowup, type ContainerFollowupContext, type PlanningSource, type AppointmentCoverage, type FollowupRecord, type FollowupStage, type SaveFollowup } from './planningApi'
-const props = defineProps<({ source: PlanningSource; coverage: AppointmentCoverage; container?: never } | { container: ContainerFollowupContext; source?: never; coverage?: never }) & { canWrite: boolean }>()
-const emit = defineEmits<{ close: [] }>()
+const props = defineProps<({ source: PlanningSource; coverage: AppointmentCoverage; container?: never } | { container: ContainerFollowupContext; source?: never; coverage?: never }) & { canWrite: boolean; canArrange?: boolean }>()
+const emit = defineEmits<{ close: []; arrange: [] }>()
const stages: Record = { DRAYAGE_CONTACT: '拖车联系', TERMINAL_APPOINTMENT: '码头约号', WAREHOUSE_RECEIVING: '接仓跟进', FBA_RECEIVING: 'FBA收货跟进' }
const coverageNeeds: Record = {
DESTINATION: '目的仓与原安排不一致,请核对。',
DESTINATION_UNVERIFIED: '来源目的地资料待核实,尚不能判断是否发生变更。',
CARRIER_CONFIRMATION: '尚未确认承运,请先在原登记完成确认。',
- REAL_CARGO_MATERIALS: '尚未关联实际货物资料,可先记录联系结果。',
+ REAL_CARGO_MATERIALS: '尚未关联实际货物资料,可先保存待确认安排;正式确认前需补齐资料。',
CARGO_QUANTITY_REVIEW: '货量或重复关联需要核对。',
RECEIVING_APPOINTMENT: '尚未安排收货预约。',
UNCOVERED_CARGO: '部分货物尚未纳入收货预约。',
@@ -80,6 +80,7 @@ onBeforeUnmount(() => { ++generation })
{{ source.containerNo || '—' }} · {{ source.originWarehouseName }} → {{ source.destinationWarehouseCode }}
+ 加入预约合批
同柜多客户共用{{ container?.warehouseId ? '此接仓点' : '提柜' }}跟进记录;保存联系不自动确认预约或推进收货。
diff --git a/src/modules/appointment-calendar/AppointmentTableRendering.test.ts b/src/modules/appointment-calendar/AppointmentTableRendering.test.ts
index ffb566a0..55d4d1bd 100644
--- a/src/modules/appointment-calendar/AppointmentTableRendering.test.ts
+++ b/src/modules/appointment-calendar/AppointmentTableRendering.test.ts
@@ -4,9 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
import AppointmentContainerPanel from './AppointmentContainerPanel.vue'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
-import { getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, previewPlanningBatch, type PlanningCargo, type PlanningSource } from './planningApi'
+import { getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, previewPlanningBatch, createSourceAppointment, type PlanningCargo, type PlanningSource } from './planningApi'
-vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listPlanningContainers: vi.fn(), resolvePlanningContainer: vi.fn(), previewPlanningBatch: vi.fn() }))
+vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listPlanningContainers: vi.fn(), resolvePlanningContainer: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', orderId: '1', inboundCode: 'IN-A', bookingNo: null, containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'ORDER:1', canFollowup: true, blockReasons: [] }
@@ -15,6 +15,35 @@ const coverage = { status: 'UNASSIGNED', appointments: [], missingFields: [], ch
describe('appointment tables use actual shared table slots', () => {
beforeEach(() => vi.resetAllMocks())
+ it('combines an original external booking with first-leg cargo and retries the identical save command', async () => {
+ const external: PlanningSource={...source,sourceKey:'BOOKING:B-1',customerId:'B',bookingNo:'B-1',orderId:null,inboundCode:null,packages:null}
+ vi.mocked(listPlanningCargo).mockResolvedValue({records:[cargo],page:1,size:20,total:1,pages:1})
+ vi.mocked(previewPlanningBatch).mockResolvedValue({group:{originWarehouseId:'100',destinationKey:'FBA:ONT8',packages:null,sourceCount:2,customerCount:2,readyFrom:null,readyTo:null,readinessIncomplete:true,unknownQuantitySources:1},originWarehouseName:'美国仓',destinationWarehouseCode:'ONT8',sources:[external,source],missingFields:['CARGO_QUANTITY_REVIEW']})
+ vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({id:'1',version:0,appointmentNo:'AP-MIXED',appointmentStatus:'PENDING',originWarehouseId:'100',fbaWarehouseCode:'ONT8',selectedCount:2,items:[]})
+ const wrapper=mount(AppointmentCargoPanel,{props:{externalSource:external},global:{plugins:[ElementPlus],stubs:{AppDialog:{template:''}}}})
+ await flushPromises()
+ await wrapper.findAll('button').find(b=>b.text()==='加入本次选择')!.trigger('click')
+ await wrapper.findAll('button').find(b=>b.text()==='选择本页可合批货物')!.trigger('click')
+ await wrapper.findAll('button').find(b=>b.text().startsWith('合批核对'))!.trigger('click');await flushPromises()
+ expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{sourceKey:'BOOKING:B-1',customerId:'B',destinationKey:'FBA:ONT8'},{sourceKey:'ORDER:1',customerId:'A',destinationKey:'FBA:ONT8'}])
+ await wrapper.get('[data-save]').trigger('click');await flushPromises()
+ const first=vi.mocked(createSourceAppointment).mock.calls[0]![0]
+ expect(first).toMatchObject({originWarehouseId:'100',fbaWarehouseCode:'ONT8',details:{appointmentStatus:'PENDING',confirmationNo:null,appointmentAt:null}})
+ expect(first.sources[0]!.sourceKey).toBe('BOOKING:B-1')
+ await wrapper.get('[data-save]').trigger('click');await flushPromises()
+ expect(createSourceAppointment).toHaveBeenNthCalledWith(2,first);expect(wrapper.emitted('created')).toHaveLength(1)
+ expect(resolvePlanningSource).not.toHaveBeenCalled();wrapper.unmount()
+ })
+ it('retains the current selection when a linked source has a different origin', async () => {
+ vi.mocked(listPlanningCargo).mockResolvedValue({records:[cargo],page:1,size:20,total:1,pages:1})
+ vi.mocked(previewPlanningBatch).mockRejectedValue(new Error('stop after observing request'))
+ const wrapper=mount(AppointmentCargoPanel,{props:{externalSource:{...source,sourceKey:'BOOKING:B-2',bookingNo:'B-2',originWarehouseId:'200'}},global:{plugins:[ElementPlus]}})
+ await flushPromises();await wrapper.findAll('button').find(b=>b.text()==='选择本页可合批货物')!.trigger('click')
+ await wrapper.findAll('button').find(b=>b.text()==='加入本次选择')!.trigger('click')
+ expect(wrapper.emitted('sourceAccepted')).toBeUndefined()
+ await wrapper.findAll('button').find(b=>b.text().startsWith('合批核对'))!.trigger('click');await flushPromises()
+ expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{sourceKey:'ORDER:1',customerId:'A',destinationKey:'FBA:ONT8'}]);wrapper.unmount()
+ })
it('selects multiple customers and makes one authoritative batch check without per-row resolution', async () => {
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo, { ...cargo, sourceKey: 'ORDER:2', orderId: '2', inboundCode: 'IN-B', customerId: 'B', customerName: '客户乙' }], page: 1, size: 20, total: 2, pages: 1 })
vi.mocked(previewPlanningBatch).mockResolvedValue({ group: { originWarehouseId: '100', destinationKey: 'FBA:ONT8', packages: 40, sourceCount: 2, customerCount: 2, readyFrom: null, readyTo: null, readinessIncomplete: true, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', sources: [source], missingFields: ['READY_WINDOW_UNVERIFIED'] })
@@ -63,6 +92,15 @@ describe('appointment tables use actual shared table slots', () => {
expect(wrapper.findComponent(AppointmentFollowupDialog).exists()).toBe(true)
wrapper.unmount()
})
+ it('keeps arrangement permission independent from contact-record writes and avoids duplicate active appointments', async () => {
+ vi.mocked(getFollowups).mockResolvedValue({history:{records:[],total:0,page:1,size:10,pages:0},versions:{DRAYAGE_CONTACT:0,TERMINAL_APPOINTMENT:0,WAREHOUSE_RECEIVING:0,FBA_RECEIVING:0}})
+ const wrapper=mount(AppointmentFollowupDialog,{props:{source,coverage,canWrite:false,canArrange:true},global:{plugins:[ElementPlus],stubs:{AppDialog:{template:''}}}})
+ await flushPromises();await wrapper.findAll('button').find(b=>b.text()==='加入预约合批')!.trigger('click')
+ expect(wrapper.emitted('arrange')).toHaveLength(1)
+ await wrapper.setProps({canArrange:false});expect(wrapper.findAll('button').some(b=>b.text()==='加入预约合批')).toBe(false)
+ await wrapper.setProps({canArrange:true,coverage:{...coverage,appointments:[{appointmentNo:'AP-EXISTING',confirmationNo:null,status:'PENDING',confirmedAt:null,confirmedEndAt:null,timezone:null}]}})
+ expect(wrapper.findAll('button').some(b=>b.text()==='加入预约合批')).toBe(false);wrapper.unmount()
+ })
it('renders followup stage names instead of blank table cells', async () => {
vi.mocked(getFollowups).mockResolvedValue({ history: { records: [{ stage: 'FBA_RECEIVING', version: 1, contactedParty: null, result: '等待回复', nextAction: null, followupDate: null, operatorName: '运营', createdAt: '2026-10-09T12:00:00Z' }], total: 1, page: 1, size: 10, pages: 1 }, versions: { DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 1 } })
const wrapper = mount(AppointmentFollowupDialog, { props: { source, coverage, canWrite: false }, global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '' } } } })
diff --git a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md
index 383e7487..8ce6001f 100644
--- a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md
+++ b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md
@@ -17,6 +17,7 @@
| 在途货物及外接来源跟进 | 美国预约统一方案的提前联系需求 | 日历与已开船FBA列表切换;跟进在居中1080px弹框中承载,3列表单、联系结果及下一步整行,历史分页;BOOKING深链先由服务端核对,再展示原登记及真实预约缺项 |
| 整柜提柜与接仓 | 美国预约统一方案的整柜共享事项 | 独立工作视图按柜汇总,操作列左置;接仓点在行内分组,点击后共用1080px跟进弹框。整柜只选拖车/码头事项,接仓只记录对应仓库,单票只记录FBA联系;数量未知或范围不完整明确显示,不按客户复制整柜联系。首次只请求一页整柜数据,详情按点击读取;待1920×1080页面验收 |
| 在途跨客户合批安排 | 美国预约统一方案的同起运仓、同目的仓合批 | 操作列后选择同起运仓及同目的仓,跨页累计最多100票;批量核对后在1080px弹框填写与入仓入口共用的安排表单。未知箱数保留待核实;暂扣、多目的地和已有预约不可选入。保存必须重新核对,未知结果保留同一命令供重试 |
+| 外接货进入合批 | 同仓跨客户共用预约,不因未入仓而只允许跟进 | 后端核实BOOKING深链后,“加入预约合批”进入现有合批工作区,再显式加入选择;可与同起运仓/目的仓头程货混选,保留原BOOKING键与未知箱数。跨仓不清空已有选择,已有关联预约不再开放新增入口;切换日历/合批工作视图保留本页选择,外接选择默认折叠、展开216px内滚动 |
| 未定时安排 | 总览不得伪造时段 | 日历上方独立折叠分页列表;日期未定不补零点或09点,继续编辑仍操作原系统单号;仅有计划日期时日历显示日期,不生成确认时段 |
| 提柜日历 | 美国业务预约统一总览 | 读取原码头提柜预约,一柜一条,点击查看来源进入清关提柜详情;按美国目的仓确认业务范围,不等于确认港口国家。保留原业务日期,明确港口时区待核实,不套用目的仓时区;未知目的仓及截断显示统计不完整。无提柜权限不请求该来源并提示范围缺失 |
| 用车计划日历 | 统一总览的真实车次只读来源 | 一车一条,计划与实际出发分开,司机预计结束不当送货完成。查询一次带跨时区余量的UTC窗口,按起运仓地点时区筛回42个当地日;缺国家/时区的记录独立分页待核实,不放入每日格。路线最多3站摘要,完整路线进入原车次;不请求逐车详情,不在预约页创建车次或重算余位 |