feat(appointment): add shared container followup workspace
This commit is contained in:
@@ -21,6 +21,7 @@ import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@
|
||||
import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.vue'
|
||||
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
|
||||
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
|
||||
import AppointmentContainerPanel from './AppointmentContainerPanel.vue'
|
||||
import { calendarRange, dateKey } from './calendar'
|
||||
import { deleteAppointment, listArrivalCalendar, type ArrivalCalendarEntry } from './api'
|
||||
|
||||
@@ -30,7 +31,7 @@ const linkedSource = ref<ResolvedPlanningSource | null>(null)
|
||||
const linkError = ref<ApiErrorDetails | null>(null)
|
||||
const linkLoading = ref(false)
|
||||
let linkRequest = 0
|
||||
const workView = ref<'calendar' | 'cargo'>('calendar')
|
||||
const workView = ref<'calendar' | 'cargo' | 'containers'>('calendar')
|
||||
const appointments = ref<FbaAppointmentDto[]>([])
|
||||
const arrivals = ref<ArrivalCalendarEntry[]>([])
|
||||
const range = ref(calendarRange(dateKey(new Date()), 'month'))
|
||||
@@ -301,9 +302,11 @@ onBeforeUnmount(() => { ++calendarRequest; ++candidateRequest; ++detailRequest;
|
||||
<AppointmentFollowupDialog v-if="linkedSource" :source="linkedSource.source" :coverage="linkedSource.coverage" :can-write="access.can('platform:appointment:followup')" @close="linkedSource = null" />
|
||||
<div class="density-status-tabs" role="group" aria-label="预约工作视图">
|
||||
<button type="button" :class="{ 'is-active': workView === 'calendar' }" :aria-pressed="workView === 'calendar'" @click="workView = 'calendar'">预约日历</button>
|
||||
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'containers' }" :aria-pressed="workView === 'containers'" @click="workView = 'containers'">整柜提柜与接仓</button>
|
||||
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="workView = 'cargo'">已开船 FBA 货物</button>
|
||||
</div>
|
||||
<AppointmentCargoPanel v-if="workView === 'cargo'" />
|
||||
<AppointmentContainerPanel v-if="workView === 'containers'" />
|
||||
<div v-show="workView === 'calendar'">
|
||||
<LoadErrorState v-if="loadError" :title="errorTitle" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-for="error in sourceErrors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlanningContainers, resolvePlanningContainer, type PlanningContainer, type ContainerFollowupContext } from './planningApi'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const query = ref<QueryModel>({ keyword: '' })
|
||||
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '整柜', placeholder: '柜号或入仓码', width: 300 }]
|
||||
const rows = ref<PlanningContainer[]>([]), page = ref(1), size = ref(20), total = ref(0)
|
||||
const loading = ref(false), opening = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), contextError = ref<ApiErrorDetails | null>(null)
|
||||
const context = ref<ContainerFollowupContext | null>(null)
|
||||
let request = 0, contextRequest = 0, controller: AbortController | undefined
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 130, fixed: 'left' },
|
||||
{ prop: 'containerNo', label: '集装箱号', minWidth: 170 },
|
||||
{ prop: 'orderCount', label: '美国范围票数', width: 120 },
|
||||
{ prop: 'customerCount', label: '客户数', width: 90 },
|
||||
{ key: 'packages', label: '预报箱数', slotName: 'packages', width: 130 },
|
||||
{ key: 'arrival', label: '来源预计到港', slotName: 'arrival', minWidth: 200 },
|
||||
{ key: 'receiving', label: '接仓点及跟进', slotName: 'receiving', minWidth: 280, singleLine: false },
|
||||
{ key: 'scope', label: '范围核对', slotName: 'scope', minWidth: 200 },
|
||||
]
|
||||
async function load(reset = false) {
|
||||
if (reset) page.value = 1
|
||||
const current = ++request
|
||||
controller?.abort(); controller = new AbortController()
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const result = await listPlanningContainers({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }, controller.signal)
|
||||
if (current !== request) return
|
||||
rows.value = result.records; total.value = result.total
|
||||
} catch (cause) { if (current === request) { error.value = getApiErrorDetails(cause, '整柜货源加载失败'); rows.value = []; total.value = 0 } }
|
||||
finally { if (current === request) loading.value = false }
|
||||
}
|
||||
async function follow(row: PlanningContainer, warehouseId?: string) {
|
||||
const current = ++contextRequest
|
||||
contextError.value = null; opening.value = true
|
||||
try {
|
||||
const result = await resolvePlanningContainer(row.containerBookingId, warehouseId)
|
||||
if (current === contextRequest) context.value = result
|
||||
} catch (cause) { if (current === contextRequest) contextError.value = getApiErrorDetails(cause, '整柜范围核对失败') }
|
||||
finally { if (current === contextRequest) opening.value = false }
|
||||
}
|
||||
const displayTime = (value: string | null) => value?.replace('T', ' ') || '—'
|
||||
onMounted(() => load())
|
||||
onBeforeUnmount(() => { ++request; ++contextRequest; controller?.abort() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="rows" :total="total" :loading="loading" :results-visible="!error" row-key="containerBookingId" @update:page="load()" @update:page-size="load(true)">
|
||||
<template #filters><QueryToolbar v-model="query" ariaLabel="整柜提柜与接仓查询" :fields="fields" :loading="loading" @search="load(true)" /></template>
|
||||
<template #notices>
|
||||
<LoadErrorState v-if="error" title="整柜货源加载失败" :message="error.message" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="contextError" title="整柜范围核对失败" :message="contextError.message" :show-retry="false"><template #details><ApiErrorMeta :details="contextError" /></template></LoadErrorState>
|
||||
<p>同柜多客户只记录一份提柜跟进;接仓按实际仓库分别跟进。预计到港为来源快照,不是对方确认时间。</p>
|
||||
</template>
|
||||
<template #actions="{ row }"><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer)">提柜跟进</el-button></template>
|
||||
<template #packages="{ row }">{{ row.packages ?? '待核实' }}</template>
|
||||
<template #arrival="{ row }">{{ displayTime(row.earliestArrivalAt) }}<span v-if="row.latestArrivalAt && row.latestArrivalAt !== row.earliestArrivalAt"> 至 {{ displayTime(row.latestArrivalAt) }}</span></template>
|
||||
<template #receiving="{ row }"><div v-for="warehouse in row.warehouses" :key="warehouse.warehouseId" class="receiving-row"><span>{{ warehouse.warehouseName }} · {{ warehouse.orderCount }} 票</span><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer, warehouse.warehouseId)">接仓跟进</el-button></div></template>
|
||||
<template #scope="{ row }">{{ row.incompleteScope ? '有未明确或非美国接仓点,待核对' : '接仓点已核实' }}</template>
|
||||
</DataTable>
|
||||
<AppointmentFollowupDialog v-if="context" :container="context" :can-write="access.can('platform:appointment:followup')" @close="context = null" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.receiving-row { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }
|
||||
</style>
|
||||
@@ -1,14 +1,14 @@
|
||||
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'
|
||||
import { getFollowups, saveFollowup, getContainerFollowups, saveContainerFollowup, type PlanningSource, type ContainerFollowupContext } from './planningApi'
|
||||
|
||||
vi.mock('./planningApi', () => ({ getFollowups: vi.fn(), saveFollowup: vi.fn() }))
|
||||
vi.mock('./planningApi', () => ({ getFollowups: vi.fn(), saveFollowup: vi.fn(), getContainerFollowups: vi.fn(), saveContainerFollowup: 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: {
|
||||
function render(canWrite = true, container?: ContainerFollowupContext) {
|
||||
return mount(AppointmentFollowupDialog, { props: container ? { container, canWrite } : { source, coverage: { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }, canWrite }, global: { stubs: {
|
||||
AppDialog: { props: ['confirmDisabled'], template: '<div><slot/><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></div>' },
|
||||
ElForm: { template: '<form><slot/></form>' }, ElFormItem: { template: '<label><slot/></label>' },
|
||||
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :value="modelValue" :placeholder="placeholder" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
@@ -19,13 +19,25 @@ function render(canWrite = 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' })
|
||||
vi.mocked(getContainerFollowups).mockResolvedValue(history)
|
||||
vi.mocked(saveFollowup).mockResolvedValue({ stage: 'FBA_RECEIVING', version: 1, contactedParty: null, result: '已联系', nextAction: null, followupDate: null, operatorName: '运营', createdAt: '2026-10-09T12:00:00Z' })
|
||||
})
|
||||
it.each([null, '100'])('saves the correct container stage for warehouse %s without customer-scoped calls', async warehouseId => {
|
||||
const container = { containerBookingId: 'C1', containerNo: 'CONTAINER-A', warehouseId, warehouseName: warehouseId ? '洛杉矶仓' : null, canFollowup: true, blockReasons: [] }
|
||||
const wrapper = render(true, container); await flushPromises()
|
||||
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('同柜统一联系')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(saveContainerFollowup).toHaveBeenCalledWith(container, expect.objectContaining({ stage: warehouseId ? 'WAREHOUSE_RECEIVING' : 'DRAYAGE_CONTACT', expectedVersion: 0, result: '同柜统一联系' }))
|
||||
expect(getContainerFollowups).toHaveBeenCalledTimes(2)
|
||||
expect(getFollowups).not.toHaveBeenCalled()
|
||||
expect(saveFollowup).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
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 }))
|
||||
expect(saveFollowup).toHaveBeenCalledWith('ORDER:1', 'A', expect.objectContaining({ stage: 'FBA_RECEIVING', 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)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getFollowups, saveFollowup, type PlanningSource, type AppointmentCoverage, type FollowupRecord, type FollowupStage, type SaveFollowup } from './planningApi'
|
||||
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; canWrite: boolean }>()
|
||||
const props = defineProps<({ source: PlanningSource; coverage: AppointmentCoverage; container?: never } | { container: ContainerFollowupContext; source?: never; coverage?: never }) & { canWrite: boolean }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const stages: Record<FollowupStage, string> = { DRAYAGE_CONTACT: '拖车联系', TERMINAL_APPOINTMENT: '码头约号', WAREHOUSE_RECEIVING: '接仓跟进', FBA_RECEIVING: 'FBA收货跟进' }
|
||||
const coverageNeeds: Record<string, string> = {
|
||||
@@ -23,7 +23,11 @@ const coverageNeeds: Record<string, string> = {
|
||||
FBA_SHIPMENT_REFERENCES: 'FBA货件资料尚未齐全,请补充货件号或追踪号。',
|
||||
SOURCE_CHANGED_REVIEW: '货源资料更新晚于预约,请核对是否需要改约。',
|
||||
}
|
||||
const form = reactive({ stage: (props.source.bookingNo ? 'FBA_RECEIVING' : 'DRAYAGE_CONTACT') as FollowupStage, contactedParty: '', result: '', nextAction: '', followupDate: '' })
|
||||
const allowedStages = computed(() => props.container ? props.container.warehouseId ? { WAREHOUSE_RECEIVING: stages.WAREHOUSE_RECEIVING } : { DRAYAGE_CONTACT: stages.DRAYAGE_CONTACT, TERMINAL_APPOINTMENT: stages.TERMINAL_APPOINTMENT } : { FBA_RECEIVING: stages.FBA_RECEIVING })
|
||||
const canFollowup = computed(() => props.container?.canFollowup ?? props.source?.canFollowup ?? false)
|
||||
const blockReasons = computed(() => props.container?.blockReasons ?? props.source?.blockReasons ?? [])
|
||||
const title = computed(() => props.container ? `${props.container.containerNo || '未回传柜号'} · ${props.container.warehouseName || '提柜'}` : props.source?.inboundCode || props.source?.bookingNo || '未关联入仓码')
|
||||
const form = reactive({ stage: (props.container ? props.container.warehouseId ? 'WAREHOUSE_RECEIVING' : 'DRAYAGE_CONTACT' : 'FBA_RECEIVING') as FollowupStage, contactedParty: '', result: '', nextAction: '', followupDate: '' })
|
||||
const rows = ref<FollowupRecord[]>([])
|
||||
const versions = ref<Record<FollowupStage, number>>({ DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 })
|
||||
const page = ref(1), size = ref(10), total = ref(0)
|
||||
@@ -44,14 +48,14 @@ async function load() {
|
||||
const request = ++generation
|
||||
loading.value = true; error.value = null; ready.value = false
|
||||
try {
|
||||
const result = await getFollowups(props.source.sourceKey, props.source.customerId, page.value, size.value)
|
||||
const result = props.container ? await getContainerFollowups(props.container, page.value, size.value) : await getFollowups(props.source!.sourceKey, props.source!.customerId, page.value, size.value)
|
||||
if (request !== generation) return
|
||||
rows.value = result.history.records; total.value = result.history.total; versions.value = result.versions; ready.value = true
|
||||
} catch (cause) { if (request === generation) error.value = getApiErrorDetails(cause, '跟进记录加载失败') }
|
||||
finally { if (request === generation) loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!props.canWrite || !props.source.canFollowup || saving.value || !ready.value) return
|
||||
if (!props.canWrite || !canFollowup.value || saving.value || !ready.value) return
|
||||
if (!pending.value && (!form.result.trim() || (form.followupDate && !form.nextAction.trim()))) {
|
||||
ElMessage.warning('请填写联系结果;设置下次跟进日时需填写下一步安排'); return
|
||||
}
|
||||
@@ -59,7 +63,8 @@ async function save() {
|
||||
contactedParty: form.contactedParty.trim() || undefined, result: form.result.trim(), nextAction: form.nextAction.trim() || undefined, followupDate: form.followupDate || null }
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
await saveFollowup(props.source.sourceKey, props.source.customerId, pending.value)
|
||||
if (props.container) await saveContainerFollowup(props.container, pending.value)
|
||||
else await saveFollowup(props.source!.sourceKey, props.source!.customerId, pending.value)
|
||||
pending.value = null; form.result = ''; form.nextAction = ''; form.followupDate = ''; page.value = 1
|
||||
ElMessage.success('跟进记录已保存'); await load()
|
||||
} catch (cause) {
|
||||
@@ -72,17 +77,18 @@ onBeforeUnmount(() => { ++generation })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog :model-value="true" class="admin-density-dialog" :title="`预约跟进 · ${source.inboundCode || source.bookingNo || '未关联入仓码'}`" width="min(1080px, calc(100vw - 48px))" :confirm-text="pending ? '重试本次保存' : '保存跟进'" :confirm-loading="saving" :confirm-disabled="!canWrite || !source.canFollowup || !ready || loading" @confirm="save" @update:model-value="emit('close')">
|
||||
<p>{{ source.containerNo || '—' }} · {{ source.originWarehouseName }} → {{ source.destinationWarehouseCode }}</p>
|
||||
<AppDialog :model-value="true" class="admin-density-dialog" :title="`预约跟进 · ${title}`" width="min(1080px, calc(100vw - 48px))" :confirm-text="pending ? '重试本次保存' : '保存跟进'" :confirm-loading="saving" :confirm-disabled="!canWrite || !canFollowup || !ready || loading" @confirm="save" @update:model-value="emit('close')">
|
||||
<p v-if="source">{{ source.containerNo || '—' }} · {{ source.originWarehouseName }} → {{ source.destinationWarehouseCode }}</p>
|
||||
<p v-else>同柜多客户共用{{ container?.warehouseId ? '此接仓点' : '提柜' }}跟进记录;保存联系不自动确认预约或推进收货。</p>
|
||||
<LoadErrorState v-if="error" title="跟进记录加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="saveError" title="跟进保存未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-for="reason in source.blockReasons" :key="reason">{{ reason }}</p>
|
||||
<p v-if="coverage.changeReviewRequired">来源资料有变化,请核对原预约与运输安排。</p>
|
||||
<p v-if="coverage.appointments.length">已关联预约:{{ coverage.appointments.map(item => item.appointmentNo).join('、') }}</p>
|
||||
<p v-else>尚未关联收货预约;联系记录保存后仍需登记对方正式确认。</p>
|
||||
<ul v-if="coverage.missingFields.length" aria-label="预约待核实事项"><li v-for="item in coverage.missingFields" :key="item">{{ coverageNeeds[item] || '存在待核实事项,请返回来源详情核对。' }}</li></ul>
|
||||
<el-form v-if="canWrite && source.canFollowup" label-position="top" class="followup-form density-form" :disabled="saving || !!pending">
|
||||
<el-form-item label="跟进事项"><el-select v-model="form.stage"><el-option v-for="(label,value) in stages" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
||||
<p v-for="reason in blockReasons" :key="reason">{{ reason }}</p>
|
||||
<p v-if="coverage?.changeReviewRequired">来源资料有变化,请核对原预约与运输安排。</p>
|
||||
<p v-if="coverage?.appointments.length">已关联预约:{{ coverage.appointments.map(item => item.appointmentNo).join('、') }}</p>
|
||||
<p v-else-if="coverage">尚未关联收货预约;联系记录保存后仍需登记对方正式确认。</p>
|
||||
<ul v-if="coverage?.missingFields.length" aria-label="预约待核实事项"><li v-for="item in coverage.missingFields" :key="item">{{ coverageNeeds[item] || '存在待核实事项,请返回来源详情核对。' }}</li></ul>
|
||||
<el-form v-if="canWrite && canFollowup" label-position="top" class="followup-form density-form" :disabled="saving || !!pending">
|
||||
<el-form-item label="跟进事项"><el-select v-model="form.stage"><el-option v-for="(label,value) in allowedStages" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
||||
<el-form-item label="联系对象"><el-input v-model="form.contactedParty" maxlength="200" placeholder="本次联系的车队、码头或仓库" /></el-form-item>
|
||||
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" type="date" value-format="YYYY-MM-DD" placeholder="业务地点当地日期" /></el-form-item>
|
||||
<el-form-item label="联系结果" required class="wide"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录对方反馈,不会自动确认预约" /></el-form-item>
|
||||
|
||||
@@ -2,10 +2,11 @@ import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
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, type PlanningCargo, type PlanningSource } from './planningApi'
|
||||
import { getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, type PlanningCargo, type PlanningSource } from './planningApi'
|
||||
|
||||
vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn() }))
|
||||
vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listPlanningContainers: vi.fn(), resolvePlanningContainer: 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: [] }
|
||||
@@ -14,6 +15,23 @@ const coverage = { status: 'UNASSIGNED', appointments: [], missingFields: [], ch
|
||||
|
||||
describe('appointment tables use actual shared table slots', () => {
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
it('renders one shared container and resolves each receiving warehouse without per-row requests', async () => {
|
||||
vi.mocked(listPlanningContainers).mockResolvedValue({ total: 1, records: [{ containerBookingId: 'C1', containerNo: 'CONTAINER-A', orderCount: 20, customerCount: 12, packages: null, unknownPackageOrders: 1, earliestArrivalAt: null, latestArrivalAt: null, standardNode: 'DEPARTED', sourceVersion: null, incompleteScope: false, warehouses: [{ warehouseId: '100', warehouseName: '洛杉矶仓', orderCount: 20, customerCount: 12 }] }] })
|
||||
vi.mocked(resolvePlanningContainer).mockResolvedValue({ containerBookingId: 'C1', containerNo: 'CONTAINER-A', warehouseId: '100', warehouseName: '洛杉矶仓', canFollowup: true, blockReasons: [] })
|
||||
const wrapper = mount(AppointmentContainerPanel, { global: { plugins: [ElementPlus], stubs: { AppointmentFollowupDialog: true } } })
|
||||
await flushPromises()
|
||||
expect(listPlanningContainers).toHaveBeenCalledTimes(1)
|
||||
expect(resolvePlanningContainer).not.toHaveBeenCalled()
|
||||
const rows = wrapper.findAll('.el-table__body-wrapper .el-table__row')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.text()).toContain('待核实')
|
||||
expect(rows[0]!.text()).toContain('洛杉矶仓')
|
||||
await rows[0]!.findAll('button').find(button => button.text() === '接仓跟进')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(resolvePlanningContainer).toHaveBeenCalledExactlyOnceWith('C1', '100')
|
||||
expect(wrapper.findComponent(AppointmentFollowupDialog).props('container')?.warehouseId).toBe('100')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('renders working source actions and readable progress through DataTable and RowActions', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage })
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
| 多条安排 | 日历标签、右侧明细 | 月/周日格均最多显示3条,超出显示“还有N条”,悬停显示当天总数;日格固定高度,右侧全部展示并独立滚动 |
|
||||
| 多入仓码 | 用户2026-10-09补充截图:单预约20票导致两列详情被长字符串撑高 | 右侧卡片与编辑弹框共用独立整行入仓码列表;去空去重后显示总数,默认3条,长码悬停可读全文;超过3条可展开/收起,展开后216px内独立滚动并可键盘聚焦,不拼成长段文字 |
|
||||
| 在途货物及外接来源跟进 | 美国预约统一方案的提前联系需求 | 日历与已开船FBA列表切换;跟进在居中1080px弹框中承载,3列表单、联系结果及下一步整行,历史分页;BOOKING深链先由服务端核对,再展示原登记及真实预约缺项 |
|
||||
| 整柜提柜与接仓 | 美国预约统一方案的整柜共享事项 | 独立工作视图按柜汇总,操作列左置;接仓点在行内分组,点击后共用1080px跟进弹框。整柜只选拖车/码头事项,接仓只记录对应仓库,单票只记录FBA联系;数量未知或范围不完整明确显示,不按客户复制整柜联系。首次只请求一页整柜数据,详情按点击读取;待1920×1080页面验收 |
|
||||
| 来源失败 | 统一方案的部分结果规则 | 日历保留成功来源并提示不完整;深链解析失败不打开假来源;跟进版本加载失败禁止保存,未知提交结果保留原请求供重试 |
|
||||
|
||||
预约结束时间未明确时允许为空;不把预计装车时间当结束时间。预约号、目的仓和货物来源遵守服务端合同。日历范围最多42天,每次范围变化查询预约与到港各一次;选择范围内日期不发送请求。候选在选仓后自动分页读取,跨页选择保留;换仓立即清空旧数据/选择并丢弃旧请求响应,清空仓库后不发送货物查询。后端必填的预计装车时间保留,不自动伪造;预约编号继续填写仓库提供的业务号。仓库请求失败可重试;仅展示前50个匹配仓,超出提示缩小关键词。
|
||||
|
||||
@@ -69,3 +69,33 @@ export async function resolvePlanningSource(query: { sourceKey: string; customer
|
||||
'/platform/v1/last-leg/appointment-planning/sources/resolve', { params: query },
|
||||
))
|
||||
}
|
||||
|
||||
export interface PlanningContainer {
|
||||
containerBookingId: string; containerNo: string | null; orderCount: number; customerCount: number;
|
||||
packages: number | null; unknownPackageOrders: number; earliestArrivalAt: string | null; latestArrivalAt: string | null;
|
||||
standardNode: string; sourceVersion: string | null; incompleteScope: boolean;
|
||||
warehouses: Array<{ warehouseId: string; warehouseName: string; orderCount: number; customerCount: number }>;
|
||||
}
|
||||
export interface ContainerFollowupContext {
|
||||
containerBookingId: string; containerNo: string | null; warehouseId: string | null; warehouseName: string | null;
|
||||
canFollowup: boolean; blockReasons: string[];
|
||||
}
|
||||
const containerBase = '/platform/v1/last-leg/appointment-planning/containers'
|
||||
export async function listPlanningContainers(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ records: PlanningContainer[]; total: number }>, ApiEnvelope<{ records: PlanningContainer[]; total: number }>>(containerBase, { params: query, signal }))
|
||||
return { ...result, total: Number(result.total), records: result.records.map(row => ({
|
||||
...row, orderCount: Number(row.orderCount), customerCount: Number(row.customerCount),
|
||||
packages: row.packages == null ? null : Number(row.packages), unknownPackageOrders: Number(row.unknownPackageOrders),
|
||||
warehouses: row.warehouses.map(warehouse => ({ ...warehouse, orderCount: Number(warehouse.orderCount), customerCount: Number(warehouse.customerCount) })),
|
||||
})) }
|
||||
}
|
||||
export async function resolvePlanningContainer(containerId: string, warehouseId?: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<ContainerFollowupContext>, ApiEnvelope<ContainerFollowupContext>>(`${containerBase}/${encodeURIComponent(containerId)}`, { params: { warehouseId } }))
|
||||
}
|
||||
export async function getContainerFollowups(context: ContainerFollowupContext, page: number, size: number) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<FollowupHistory>, ApiEnvelope<FollowupHistory>>(`${containerBase}/${encodeURIComponent(context.containerBookingId)}/follow-ups`, { params: { warehouseId: context.warehouseId || undefined, 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<FollowupStage, number> }
|
||||
}
|
||||
export async function saveContainerFollowup(context: ContainerFollowupContext, body: SaveFollowup) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<FollowupRecord>, ApiEnvelope<FollowupRecord>>(`${containerBase}/${encodeURIComponent(context.containerBookingId)}/follow-ups`, body, { params: { warehouseId: context.warehouseId || undefined } }))
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
| 页面 | 操作 | 承载与尺寸 | 调整及验收边界 |
|
||||
|---|---|---|---|
|
||||
| 拼车接货 | 可拼车次、我的登记、待办 | integrated列表,操作列左置 | 直接登记,销售隐藏容量计算过程,服务端返回处理进度;待1920×1080人工截图 |
|
||||
| 我的登记 | 多动作与客户名称显示 | 沿上述列表及RowActions | 2026-10-10 TEST截图`minimal-inquiry-saved-1920.jpg`显示230px操作列裁掉第三动作及更多;改320px,客户列210px允许换行,保留原动作和权限;发布后原FB记录复验 |
|
||||
| 客户货物登记 | 新增、补充资料、只读跟进 | 1080px居中三列 | 首屏客户/目的地/已知货量;确认前必填折叠但显示缺项;始终选填独立;单站自动带入 |
|
||||
| 客户货物登记 | 确认承运、取消 | 既有居中确认框 | 展示对象及保位/商业订单影响,保存成功不冒充确认成功 |
|
||||
| 客户货物登记 | 安排来货、核量、排入本车 | 1080px居中,复用履约表单 | 原登记连续处理,按服务端资格;关联业务按权限跳转 |
|
||||
|
||||
@@ -20,8 +20,8 @@ function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
|
||||
{ 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: 'actions', label: '操作', width: 320, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'bookingNo', label: '意向 / 预订号', minWidth: 210, singleLine: false }, { key: 'customerName', label: '客户', minWidth: 210, singleLine: false },
|
||||
{ key: 'tripNo', label: '车次号', minWidth: 210 }, { key: 'destinationLabel', label: '卸货站', minWidth: 130 },
|
||||
{ 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 },
|
||||
|
||||
Reference in New Issue
Block a user