diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts index 8fa2c24f..283b1b26 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts @@ -40,7 +40,7 @@ function render() { ElSelect: { name: 'ElSelect', emits: ['update:modelValue', 'change'], props: ['modelValue', 'remoteMethod'], template: '' }, ElOption: { props: ['value', 'label'], template: '' }, DataTable: { name: 'DataTable', props: ['data'], template: '
' }, - LoadErrorState: true, ApiErrorMeta: true, AppointmentFollowupDialog: true, + LoadErrorState: true, ApiErrorMeta: true, AppointmentFollowupDialog: true, AppointmentCargoPanel: { name: 'AppointmentCargoPanel', props: ['externalSource'], template: '
合批工作区
' }, } } }) } @@ -228,6 +228,11 @@ describe('appointment calendar data integration', () => { expect(dialog.props('source').orderId).toBeNull() expect(dialog.props('coverage').status).toBe('SOURCE_NOT_MATERIALIZED') expect(createFbaAppointment).not.toHaveBeenCalled(); expect(saveFollowup).not.toHaveBeenCalled() + expect(dialog.props('canArrange')).toBe(true) + dialog.vm.$emit('arrange');await flushPromises() + expect(wrapper.findComponent({name:'AppointmentFollowupDialog'}).exists()).toBe(false) + expect(wrapper.findComponent({name:'AppointmentCargoPanel'}).props('externalSource')).toMatchObject({sourceKey:'BOOKING:B-1',bookingNo:'B-1',orderId:null}) + }) it('does not trust stale destination query data or open followup after a source error', async () => { diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.vue b/src/modules/appointment-calendar/AppointmentCalendarView.vue index bd30779f..d8440ac3 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarView.vue @@ -2,7 +2,7 @@ import '@/styles/adminDensity.css' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { resolvePlanningSource, listUnscheduledAppointments, type ResolvedPlanningSource } from './planningApi' +import { resolvePlanningSource, listUnscheduledAppointments, type ResolvedPlanningSource, type PlanningSource } from './planningApi' import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue' import { ElMessage, ElMessageBox } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' @@ -31,10 +31,13 @@ const access = useAccessPreviewStore() const route = useRoute() const router = useRouter() const linkedSource = ref(null) +const selectedSource = ref(null) const linkError = ref(null) const linkLoading = ref(false) let linkRequest = 0 const workView = ref<'calendar' | 'cargo' | 'containers'>('calendar') +const cargoVisited = ref(false) +watch(workView, value => { if (value === 'cargo') cargoVisited.value = true }) const appointments = ref([]) const arrivals = ref([]) const pickups = ref([]) @@ -351,6 +354,12 @@ async function resolveLink() { } catch (cause) { if (request === linkRequest) linkError.value = getApiErrorDetails(cause, '预约货源核对失败') } finally { if (request === linkRequest) linkLoading.value = false } } +function arrangeLinkedSource() { + if (!linkedSource.value || !access.can(permission.inboundOrderAppointment) || !linkedSource.value.source.canFollowup || linkedSource.value.coverage.appointments.length) return + selectedSource.value = linkedSource.value.source + linkedSource.value = null + workView.value = 'cargo' +} watch(() => [route.query.sourceKey, route.query.customerId, route.query.destinationKey], resolveLink, { immediate: true }) onMounted(() => { void load(); void loadUnscheduled() }) @@ -361,13 +370,13 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ

正在核对预约货源…

- +
- +
- +
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 - +

{{ 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 })