Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2f9023156 | ||
|
|
d2f2ecf57a | ||
|
|
5a46198bf0 | ||
|
|
ada663128a | ||
|
|
17cb1373e1 | ||
|
|
29f887496a |
@@ -36,11 +36,11 @@ describe('adminNavigation', () => {
|
||||
{ title: '客户服务', children: ['异常订单'] },
|
||||
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||
{ title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
|
||||
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] },
|
||||
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] },
|
||||
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
||||
])
|
||||
expect(visible).toHaveLength(13)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(57)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(56)
|
||||
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -196,13 +196,12 @@ describe('adminNavigation', () => {
|
||||
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items'])
|
||||
})
|
||||
|
||||
it('shows the restored supplier entry under general only with its list permission', () => {
|
||||
it('retains the supplier route permission while omitting its entry from the latest sidebar', () => {
|
||||
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list']))
|
||||
const missing = filterAdminNavigation(adminNavigation, new Set())
|
||||
|
||||
expect(getNavigationLeaves(adminNavigation).find(item => item.path === '/suppliers')?.permission).toBe('platform:supplier:list')
|
||||
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers'])
|
||||
expect(visible.find(item => item.title === '通用')?.children?.map(item => item.path)).toEqual(['/suppliers'])
|
||||
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard'])
|
||||
expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard'])
|
||||
})
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'fleet-quotes', title: '车队报价', icon: Truck, path: '/overseas-master-data/fleet-quotes', permission: 'platform:fleet:quote:list' },
|
||||
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
|
||||
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
|
||||
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
|
||||
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list', hidden: true },
|
||||
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list', hidden: true },
|
||||
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list', hidden: true },
|
||||
{ key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list', hidden: true },
|
||||
|
||||
@@ -13,7 +13,9 @@ describe('four-type appointment calendar', () => {
|
||||
expect(wrapper.get('[aria-label="安排类型"]').text()).toBe('到港排车还柜计划到仓')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(4)
|
||||
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('4 项安排')
|
||||
expect(wrapper.findAll('.calendar-day')).toHaveLength(42)
|
||||
expect(wrapper.findAll('.calendar-day')).toHaveLength(35)
|
||||
expect(wrapper.find('[data-date="2026-11-01"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-date="2026-11-02"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toMatch(/新增预约|全局待办|需复核|中转|直送/)
|
||||
})
|
||||
it('filters locally without requesting another range', async () => {
|
||||
@@ -24,6 +26,15 @@ describe('four-type appointment calendar', () => {
|
||||
expect(wrapper.text()).toContain('当天暂无安排')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
})
|
||||
it('offers creation in the header only when authorized', async () => {
|
||||
const wrapper = render()
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '新增预约')).toBe(false)
|
||||
await wrapper.setProps({ canCreate: true })
|
||||
await wrapper.findAll('.calendar-controls button').find(button => button.text() === '新增预约')!.trigger('click')
|
||||
expect(wrapper.emitted('create')).toEqual([[]])
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('changes the selected date locally and requests one range per mode or period change', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.get('[data-date="2026-10-20"]').trigger('click')
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { calendarDates, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
|
||||
import { scheduleLabels, scheduleTypes, type ScheduleEvent, type ScheduleType } from './schedule'
|
||||
|
||||
const props = withDefaults(defineProps<{ events: ScheduleEvent[]; loading?: boolean; incomplete?: boolean; initialDate?: string; initialMode?: CalendarMode }>(),
|
||||
{ loading: false, incomplete: false, initialDate: '', initialMode: 'month' })
|
||||
const emit = defineEmits<{ range: [range: { from: string; to: string }]; select: [date: string, mode: CalendarMode]; open: [event: ScheduleEvent]; edit: [event: ScheduleEvent] }>()
|
||||
const props = withDefaults(defineProps<{ events: ScheduleEvent[]; loading?: boolean; incomplete?: boolean; initialDate?: string; initialMode?: CalendarMode; canCreate?: boolean }>(),
|
||||
{ loading: false, incomplete: false, initialDate: '', initialMode: 'month', canCreate: false })
|
||||
const emit = defineEmits<{ range: [range: { from: string; to: string }]; select: [date: string, mode: CalendarMode]; open: [event: ScheduleEvent]; edit: [event: ScheduleEvent]; create: [] }>()
|
||||
const selectedDate = ref(props.initialDate || dateKey(new Date())), mode = ref<CalendarMode>(props.initialMode)
|
||||
const types = ref<ScheduleType[]>([...scheduleTypes]), keyword = ref(''), undated = ref(false)
|
||||
const dates = computed(() => calendarDates(selectedDate.value, mode.value))
|
||||
@@ -44,6 +45,7 @@ function changeMode(next: CalendarMode) {
|
||||
<strong>预约日历</strong>
|
||||
</div>
|
||||
<div class="calendar-controls">
|
||||
<el-button v-if="canCreate" type="primary" @click="emit('create')"><Plus :size="16" aria-hidden="true" />新增预约</el-button>
|
||||
<div class="view-toggle" role="group" aria-label="预约视图"><button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button></div>
|
||||
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '上个月' : '上一周'" @click="select(moveCalendar(selectedDate, mode, -1))">‹</button>
|
||||
<strong class="calendar-period">{{ heading }}</strong>
|
||||
|
||||
@@ -18,9 +18,10 @@ const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-
|
||||
export function renderSchedule() {
|
||||
return mount(AppointmentCalendarView, { global: { stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode'], template: '<section data-calendar />' },
|
||||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode', 'canCreate'], template: '<section data-calendar />' },
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<section v-if="modelValue" data-dialog><slot /><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button><button data-close @click="beforeClose(() => {})">关闭</button></section>' },
|
||||
WorkDetailPanel: true, SourceAppointmentPanel: true, ApiErrorMeta: true,
|
||||
AppointmentCreatePanel: { name: 'AppointmentCreatePanel', props: ['canWrite', 'canLeave'], emits: ['created'], template: '<section data-create-panel />' },
|
||||
LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button data-retry @click="$emit(\'retry\')">重试</button></section>' },
|
||||
} } })
|
||||
}
|
||||
@@ -32,6 +33,30 @@ beforeEach(() => {
|
||||
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
|
||||
})
|
||||
describe('simplified appointment entry', () => {
|
||||
it('opens creation lazily with the original read and write permissions and refreshes after creation', async () => {
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
|
||||
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(calendar.props('canCreate')).toBe(true)
|
||||
calendar.vm.$emit('create'); await flushPromises()
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCreatePanel' })
|
||||
expect(panel.props('canWrite')).toBe(true)
|
||||
vi.mocked(getCalendarSources).mockClear()
|
||||
panel.vm.$emit('created'); await flushPromises()
|
||||
expect(getCalendarSources).toHaveBeenCalledTimes(1)
|
||||
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each(['platform:overseas-warehouse:inbound-order:fba-appointment', 'platform:overseas-warehouse:last-mile-order:list'])('does not open creation without %s', async permission => {
|
||||
routing.denied.add(permission)
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(calendar.props('canCreate')).toBe(false)
|
||||
calendar.vm.$emit('create'); await flushPromises()
|
||||
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps unsaved input when the operator declines to leave', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
@@ -45,7 +70,7 @@ describe('simplified appointment entry', () => {
|
||||
})
|
||||
it('loads original sources and real trips without loading work summaries or generated work items', async () => {
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-09' }, ['1'], expect.any(AbortSignal))
|
||||
expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-02' }, ['1'], expect.any(AbortSignal))
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(panel.props('events').map((event: ScheduleEvent) => [event.type, event.date])).toEqual([['arrival', '2026-10-19'], ['warehouse', '2026-10-21']])
|
||||
expect(wrapper.text()).not.toMatch(/全局待办|跟进|复核/)
|
||||
|
||||
@@ -23,7 +23,10 @@ import { appointmentExternalTarget } from './workNavigation'
|
||||
// 原业务页面仍能直接进入其预约表单;日历不再提供工作清单或流程编排入口。
|
||||
const WorkDetailPanel = defineAsyncComponent(() => import('./WorkDetailPanel.vue'))
|
||||
const SourceAppointmentPanel = defineAsyncComponent(() => import('./SourceAppointmentPanel.vue'))
|
||||
const AppointmentCreatePanel = defineAsyncComponent(() => import('./AppointmentCreatePanel.vue'))
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const creating = ref(false)
|
||||
const canCreate = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && access.can('platform:overseas-warehouse:last-mile-order:list'))
|
||||
function initialDate() {
|
||||
const value = typeof route.query.date === 'string' ? route.query.date : ''
|
||||
try { calendarRange(value, 'month'); return value } catch { return dateKey(new Date()) }
|
||||
@@ -130,6 +133,8 @@ async function canLeave() {
|
||||
return true
|
||||
}
|
||||
async function closeEditor(done: () => void) { if (await canLeave()) { done(); editor.value = null } }
|
||||
async function openCreate() { if (canCreate.value && await canLeave()) creating.value = true }
|
||||
async function closeCreate(done: () => void) { if (await canLeave()) { done(); creating.value = false } }
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
|
||||
@@ -166,7 +171,10 @@ onBeforeUnmount(() => { ++generation; controller?.abort() })
|
||||
<AdminPage class="admin-density appointment-schedule" spacing="compact">
|
||||
<LoadErrorState v-for="error in errors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
|
||||
<p v-for="notice in notices" :key="notice" role="status" class="data-notice">{{ notice }}</p>
|
||||
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="incomplete" :initial-date="selectedDate" :initial-mode="mode" @range="load" @select="select" @open="openSource" @edit="edit" />
|
||||
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="incomplete" :initial-date="selectedDate" :initial-mode="mode" :can-create="canCreate" @create="openCreate" @range="load" @select="select" @open="openSource" @edit="edit" />
|
||||
<AppDialog v-if="active && creating" :model-value="true" title="新增FBA送仓预约" class="admin-density-dialog admin-density business-dialog" width="1080px" :show-footer="false" :before-close="closeCreate" :close-on-click-modal="true" append-to-body fullscreenable>
|
||||
<AppointmentCreatePanel :can-write="canCreate" :can-leave="canLeave" @created="load()" />
|
||||
</AppDialog>
|
||||
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog" width="520px" :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
|
||||
<template v-if="editor">
|
||||
<p>{{ editor.title }}</p>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import AppointmentCreatePanel from './AppointmentCreatePanel.vue'
|
||||
import { createSourceAppointment, listPlanningCargo, listPlanningDestinations, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi'
|
||||
import type { QueryField } from '@/shared/components/QueryToolbar.vue'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
|
||||
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), listPlanningDestinations: vi.fn(), resolvePlanningSource: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740999', orderId: '9007199254740999', customerId: '9007199254740998', customerName: '测试客户', inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: 0, shippedPackages: 0, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
|
||||
const source = { sourceKey: cargo.sourceKey, customerId: cargo.customerId, orderId: cargo.orderId, inboundCode: cargo.inboundCode, bookingNo: null, containerNo: cargo.containerNo, originWarehouseId: cargo.originWarehouseId, originWarehouseName: cargo.originWarehouseName, destinationKey: 'FBA:LAX9', destinationWarehouseCode: 'LAX9', packages: 20, sourceVersion: 'v1', canFollowup: true, blockReasons: [] }
|
||||
const preview: PlanningBatchPreview = { group: { originWarehouseId: cargo.originWarehouseId, destinationKey: source.destinationKey, packages: 20, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] }
|
||||
const guards = new Set<LeaveGuard>()
|
||||
function render(canWrite = true) {
|
||||
return mount(AppointmentCreatePanel, { props: { canWrite, canLeave: async () => { for (const guard of guards) if (!await guard()) return false; return true } }, global: {
|
||||
provide: { 'appointment-work-leave-guard': (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) } },
|
||||
stubs: {
|
||||
DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="filters" /><slot name="toolbar-actions" /><slot name="notices" /><div v-for="row in data" :key="row.sourceKey"><span>{{ row.inboundCode }}</span><slot name="select" :row="row" /><slot name="eligibility" :row="row" /></div><button data-page @click="$emit(\'update:page\', 2)">下一页</button></div>' },
|
||||
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['search', 'update:modelValue'], template: '<button data-query @click="$emit(\'search\')">查询</button>' },
|
||||
ElCheckbox: { props: ['modelValue', 'disabled'], emits: ['change'], template: '<input type="checkbox" :checked="modelValue" :disabled="disabled" @change="$emit(\'change\', $event.target.checked)">' },
|
||||
ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot /></button>' },
|
||||
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<input data-date type="date" v-model="model.plannedDate" :disabled="disabled">' },
|
||||
LoadErrorState: { props: ['message'], emits: ['retry'], template: '<p role="alert">{{ message }}<button data-retry @click="$emit(\'retry\')">重试</button><slot name="details" /></p>' }, ApiErrorMeta: true,
|
||||
},
|
||||
} })
|
||||
}
|
||||
async function selectAndPrepare(wrapper: ReturnType<typeof render>) {
|
||||
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); guards.clear()
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 21, page: 1, size: 20, pages: 2 })
|
||||
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
|
||||
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
|
||||
vi.mocked(listPlanningDestinations).mockResolvedValue(['ABE4','ONT8'])
|
||||
})
|
||||
it('loads searchable destination options lazily and keeps the submitted filter across pages until reset', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
const toolbar = wrapper.getComponent({name:'QueryToolbar'})
|
||||
const destination = () => (toolbar.props('fields') as QueryField[]).find(field=>field.key==='destination')!
|
||||
expect(destination().props?.filterable).toBe(true)
|
||||
expect(listPlanningDestinations).not.toHaveBeenCalled()
|
||||
const open = destination().props!.onVisibleChange as (visible:boolean)=>void
|
||||
open(true); await flushPromises()
|
||||
expect(destination().options).toEqual([{label:'ABE4',value:'ABE4'},{label:'ONT8',value:'ONT8'}])
|
||||
open(true); await flushPromises(); expect(listPlanningDestinations).toHaveBeenCalledTimes(1)
|
||||
toolbar.vm.$emit('update:modelValue',{keyword:'IN-TEST',destination:'ABE4'})
|
||||
await wrapper.get('[data-query]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal))
|
||||
await wrapper.get('[data-page]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:2,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal))
|
||||
toolbar.vm.$emit('update:modelValue',{keyword:'',destination:undefined})
|
||||
await wrapper.get('[data-query]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:undefined},expect.any(AbortSignal))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reports a destination directory failure and allows retry without hiding loaded cargo', async () => {
|
||||
vi.mocked(listPlanningDestinations).mockRejectedValueOnce(new Error('offline'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
const field = (wrapper.getComponent({name:'QueryToolbar'}).props('fields') as QueryField[]).find(field=>field.key==='destination')!
|
||||
;(field.props!.onVisibleChange as (visible:boolean)=>void)(true); await flushPromises()
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
expect(wrapper.getComponent({name:'DataTable'}).props('resultsVisible')).toBe(true)
|
||||
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningDestinations).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not read or create per-row details until the operator selects cargo and proceeds', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenCalledWith({ page: 1, size: 20, keyword: undefined }, expect.any(AbortSignal))
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled(); expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
|
||||
await selectAndPrepare(wrapper)
|
||||
expect(resolvePlanningSource).toHaveBeenCalledWith({ sourceKey: cargo.sourceKey, customerId: cargo.customerId })
|
||||
expect(previewPlanningBatch).toHaveBeenCalledWith([{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }])
|
||||
expect(wrapper.text()).not.toContain(cargo.orderId)
|
||||
wrapper.unmount(); expect(guards.size).toBe(0)
|
||||
})
|
||||
it('submits the real form, shows the saved appointment, and prevents another submit of the same completed form', async () => {
|
||||
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
|
||||
await wrapper.get('[data-date]').setValue('2026-10-25')
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null }), idempotencyKey: expect.any(String) }))
|
||||
expect(wrapper.emitted('created')).toEqual([[batchContext().appointment]])
|
||||
expect(wrapper.get('[role="status"]').text()).toContain(batchContext().appointment.appointmentNo)
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '保存预约安排')).toBe(false)
|
||||
expect(wrapper.get('[data-date]').attributes('disabled')).toBeDefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('preserves the original request and blocks returning or closing after an unknown creation result', async () => {
|
||||
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '返回选择货物')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).exists()).toBe(false)
|
||||
expect((await Promise.all([...guards].map(guard => guard()))).includes(false)).toBe(true)
|
||||
await wrapper.findAll('button').find(button => button.text() === '重试本次创建')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(createSourceAppointment).mock.calls[1]).toEqual(vi.mocked(createSourceAppointment).mock.calls[0])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('disables blocked cargo and keeps selected cargo across pages', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValueOnce({ records: [cargo, { ...cargo, sourceKey: 'ORDER:blocked', detained: true }], total: 21, page: 1, size: 20, pages: 2 })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findAll('input[type="checkbox"]')[1]!.attributes('disabled')).toBeDefined()
|
||||
await wrapper.findAll('input[type="checkbox"]')[0]!.setValue(true)
|
||||
await wrapper.get('[data-page]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 2, size: 20, keyword: undefined }, expect.any(AbortSignal))
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '下一步:填写预约(1)')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reports source failures and does not offer a write path without permission', async () => {
|
||||
const readonly = render(false); await flushPromises()
|
||||
expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled(); readonly.unmount()
|
||||
vi.mocked(listPlanningCargo).mockRejectedValueOnce(new Error('unavailable'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('resultsVisible')).toBe(false)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores a source resolution that arrives after the dialog is closed', async () => {
|
||||
let complete!: (value: Awaited<ReturnType<typeof resolvePlanningSource>>) => void
|
||||
vi.mocked(resolvePlanningSource).mockImplementationOnce(() => new Promise(resolve => { complete = resolve }))
|
||||
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
|
||||
wrapper.unmount(); complete({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } }); await flushPromises()
|
||||
expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, 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 { FbaAppointmentFields, createAppointmentForm, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import { listPlanningCargo, listPlanningDestinations, resolvePlanningSource, type PlanningCargo } from './planningApi'
|
||||
import { useWorkDraft, type LeaveGuard } from './useWorkDraft'
|
||||
import type { SourceReference } from './workApi'
|
||||
import WorkCreateAppointmentForm from './WorkCreateAppointmentForm.vue'
|
||||
|
||||
const props = defineProps<{ canWrite: boolean; canLeave: LeaveGuard }>()
|
||||
const emit = defineEmits<{ created: [appointment: FbaAppointmentDto] }>()
|
||||
const query = ref<QueryModel>({ keyword: '', destination: '' }), keyword = ref(''), destination = ref(''), page = ref(1), total = ref(0)
|
||||
const destinations = ref<string[]>([]), destinationsLoading = ref(false), destinationsLoaded = ref(false), destinationError = ref<ApiErrorDetails | null>(null)
|
||||
let destinationsRequest = 0, destinationsController: AbortController | null = null
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 },
|
||||
{ key: 'destination', type: 'select', label: '目的FBA仓', placeholder: '搜索或选择目的FBA仓', width: 220,
|
||||
options: destinations.value.map(code => ({ label: code, value: code })),
|
||||
props: { filterable: true, loading: destinationsLoading.value, onVisibleChange: (visible: boolean) => {
|
||||
if (visible && !destinationsLoaded.value) void loadDestinations()
|
||||
} } },
|
||||
])
|
||||
const rows = ref<PlanningCargo[]>([]), selected = ref<Record<string, PlanningCargo>>({})
|
||||
const selectedRows = computed(() => Object.values(selected.value))
|
||||
const loading = ref(false), resolving = ref(false), error = ref<ApiErrorDetails | null>(null), selectionError = ref<ApiErrorDetails | null>(null)
|
||||
const references = ref<SourceReference[] | null>(null), created = ref<FbaAppointmentDto | null>(null)
|
||||
const createdForm = computed(() => createAppointmentForm(created.value || undefined))
|
||||
useWorkDraft(computed(() => !references.value && !created.value && selectedRows.value.length > 0), computed(() => false))
|
||||
let generation = 0, selectionGeneration = 0, controller: AbortController | null = null
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'select', label: '选择', width: 60, slotName: 'select' },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 140 },
|
||||
{ prop: 'containerNo', label: '柜号', minWidth: 145 },
|
||||
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140 },
|
||||
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
|
||||
{ prop: 'packages', label: '箱数', width: 80 },
|
||||
{ key: 'eligibility', label: '预约资格', minWidth: 130, slotName: 'eligibility' },
|
||||
]
|
||||
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
|
||||
function reason(row: PlanningCargo) {
|
||||
if (row.detained) return '货物暂扣中'
|
||||
if (row.mixedDestinations) return '多个目的地,需先核对'
|
||||
if (row.hasActiveAppointment) return '已有有效预约'
|
||||
const anchor = selectedRows.value[0]
|
||||
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
|
||||
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
|
||||
return ''
|
||||
}
|
||||
function toggle(row: PlanningCargo, checked: boolean | string | number) {
|
||||
if (!props.canWrite || resolving.value) return
|
||||
if (!checked) delete selected.value[key(row)]
|
||||
else if (!reason(row)) selected.value[key(row)] = row
|
||||
selectionError.value = null
|
||||
}
|
||||
async function load(nextPage = page.value) {
|
||||
if (!props.canWrite) return
|
||||
page.value = nextPage
|
||||
const current = ++generation; controller?.abort(); controller = new AbortController()
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const result = await listPlanningCargo({ page: nextPage, size: 20, keyword: keyword.value || undefined,
|
||||
...(destination.value ? { destination: destination.value } : {}) }, controller.signal)
|
||||
if (current === generation) { rows.value = result.records; total.value = result.total }
|
||||
} catch (cause) {
|
||||
if (current === generation) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '可预约货物读取失败') }
|
||||
} finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function search() {
|
||||
keyword.value = String(query.value.keyword || '').trim()
|
||||
destination.value = String(query.value.destination || '').trim()
|
||||
void load(1)
|
||||
}
|
||||
async function loadDestinations() {
|
||||
if (!props.canWrite || destinationsLoading.value) return
|
||||
const current = ++destinationsRequest
|
||||
destinationsController?.abort(); destinationsController = new AbortController()
|
||||
destinationsLoading.value = true; destinationError.value = null
|
||||
try {
|
||||
const result = await listPlanningDestinations(destinationsController.signal)
|
||||
if (current === destinationsRequest) { destinations.value = result; destinationsLoaded.value = true }
|
||||
} catch (cause) {
|
||||
if (current === destinationsRequest) destinationError.value = getApiErrorDetails(cause, '目的FBA仓选项读取失败')
|
||||
} finally { if (current === destinationsRequest) destinationsLoading.value = false }
|
||||
}
|
||||
async function prepare() {
|
||||
if (!props.canWrite || !selectedRows.value.length || loading.value || resolving.value || error.value) return
|
||||
const current = ++selectionGeneration, snapshot = [...selectedRows.value]
|
||||
resolving.value = true; selectionError.value = null
|
||||
try {
|
||||
const sources: SourceReference[] = []
|
||||
// 仅对明确选中的货物读取最新来源,不按展示行逐票请求,也不猜目的地键。
|
||||
for (const row of snapshot) {
|
||||
if (current !== selectionGeneration) return
|
||||
const { source } = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId })
|
||||
if (current !== selectionGeneration) return
|
||||
if (!source.canFollowup) { selectionError.value = { message: source.blockReasons.join(';') || '所选货物当前不能新增预约,请重新核对。' }; return }
|
||||
sources.push({ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey })
|
||||
}
|
||||
if (current === selectionGeneration) references.value = sources
|
||||
} catch (cause) { if (current === selectionGeneration) selectionError.value = getApiErrorDetails(cause, '所选货物核对失败') }
|
||||
finally { if (current === selectionGeneration) resolving.value = false }
|
||||
}
|
||||
async function back() { if (await props.canLeave()) references.value = null }
|
||||
function saved(appointment: FbaAppointmentDto) {
|
||||
created.value = appointment; references.value = null; selected.value = {}
|
||||
emit('created', appointment)
|
||||
}
|
||||
async function again() {
|
||||
if (!await props.canLeave()) return
|
||||
created.value = null; references.value = null; selected.value = {}; selectionError.value = null
|
||||
await load()
|
||||
}
|
||||
onMounted(() => { void load() })
|
||||
onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsRequest; controller?.abort(); destinationsController?.abort() })
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="新增FBA送仓预约">
|
||||
<template v-if="created">
|
||||
<p role="status">预约已保存:<strong>{{ created.appointmentNo || '预约编号未返回' }}</strong></p>
|
||||
<p>预约保存后仍需在原业务安排车辆,日历中的排车以真实车次为准。</p>
|
||||
<el-button v-if="canWrite" @click="again">继续新增预约</el-button>
|
||||
<FbaAppointmentFields :model="createdForm" :appointment-no="created.appointmentNo" :timezone="created.timezone" :disabled="true" />
|
||||
</template>
|
||||
<template v-else-if="references">
|
||||
<el-button @click="back">返回选择货物</el-button>
|
||||
<WorkCreateAppointmentForm :sources="references" :can-write="canWrite" @saved="saved" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>选择需要送往FBA的已有货物;同一起运仓、同一目的仓可合并多个客户的货物。跨页选择会保留。</p>
|
||||
<DataTable :data="rows" :columns="columns" :page="page" :page-size="20" :page-sizes="[20]" :total="total" :loading="loading" :results-visible="!error" :row-key="key" empty-text="暂无可预约货物" @update:page="load($event)">
|
||||
<template #filters><QueryToolbar v-model="query" ariaLabel="新增预约货物查询" :fields="fields" :loading="loading || resolving" @search="search" /></template>
|
||||
<template #toolbar-actions><el-button :disabled="!selectedRows.length || resolving" @click="selected = {}; selectionError = null">清空选择</el-button><el-button type="primary" :disabled="!canWrite || !selectedRows.length || loading || !!error" :loading="resolving" @click="prepare">下一步:填写预约({{ selectedRows.length }})</el-button></template>
|
||||
<template #pagination-left>已选 {{ selectedRows.length }}/100 票</template>
|
||||
<template #notices><LoadErrorState v-if="destinationError" title="目的FBA仓选项读取失败" :message="destinationError.message" :loading="destinationsLoading" @retry="loadDestinations"><template #details><ApiErrorMeta :details="destinationError" /></template></LoadErrorState><LoadErrorState v-if="error" title="可预约货物读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><LoadErrorState v-if="selectionError" title="所选货物核对失败" :message="selectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="selectionError" /></template></LoadErrorState></template>
|
||||
<template #select="{ row }"><el-checkbox :model-value="!!selected[key(row as PlanningCargo)]" :aria-label="`选择 ${row.inboundCode || row.containerNo || '未提供业务编号的货物'}`" :disabled="!canWrite || resolving || (!selected[key(row as PlanningCargo)] && !!reason(row as PlanningCargo))" @change="toggle(row as PlanningCargo, $event)" /></template>
|
||||
<template #eligibility="{ row }">{{ reason(row as PlanningCargo) || '可选择' }}</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -7,12 +7,23 @@
|
||||
| 页面 | 操作 | 当前确认承载与布局 | 实现结论 |
|
||||
|---|---|---|---|
|
||||
| 预约列表 | 月/周、四类选择、当前范围搜索 | 左日历右当天安排;日期格简洁标签,最多三条 | 恢复 `AppointmentCalendarPanel`,不再打开全局工作明细弹框;待人工截图确认 |
|
||||
| 预约列表 | 月视图周数 | 用户截图 `codex-clipboard-372a5dc5-0c82-4298-93db-089b6c97c8ca.png`;周一至周日,只覆盖当月所需完整周 | 4/5/6周自适应;2026年10月五周,保留末周11月1日,不追加11月2—8日整周;待人工截图确认 |
|
||||
| 预约列表 | 查看当天、未定日期 | 固定右侧区域,完整业务字段与滚动明细 | 类型仅到港、排车、还柜、计划到仓;待人工截图确认 |
|
||||
| 预约列表 | 新增、审核、删除 | 本轮明确取消通用入口 | 来源业务生成记录;不在日历另建预约或改状态 |
|
||||
| 预约列表 | 新增预约 | 用户本轮要求恢复顶部按钮,随后确认先支持FBA;1080px居中弹框,先选择已有货物,再登记预约时间 | 复用真实来源预览及预约保存流程,原权限/幂等/未保存保护;保存成功显示预约编号和只读内容;普通客户仓不在本次范围;待人工截图确认 |
|
||||
| 新增FBA送仓预约 | 按目的仓查询 | 用户截图 `codex-clipboard-44b338fe-af49-45e2-abf2-3bc0bd27224a.png`;在原查询栏增加可搜索、可清空的目的FBA仓下拉框 | 选项使用同货物查询权限的权威美国FBA目录;提交查询后从第一页按目的仓过滤,翻页沿用条件;待人工截图确认 |
|
||||
| 预约列表 | 审核、删除 | 不恢复通用入口 | 在原业务处理,不在日历改业务状态 |
|
||||
| 计划到仓 | 调整 | 原居中弹框保留,小型时间表单 | 整柜原命令、原权限/版本/幂等与计划冻结门禁;待人工截图确认 |
|
||||
| 到港、排车、还柜 | 查看与办理 | 跳原货柜/车次正式详情页 | 保留日期/月周返回上下文,无独立工作流程导航 |
|
||||
| 原业务深链 | 精确来源/事项办理 | 仍使用既有居中原业务表单 | 只承接其他模块实际使用的精确入口,不恢复全局工作待办;待人工截图确认 |
|
||||
|
||||
2026-10-19 目的FBA仓筛选补修:原查询栏增加可搜索、可清空的下拉框,首次展开读取完整权威美国FBA目录;复用原货物读取权限,不新增仓库管理权限。查询回第一页,翻页沿用目的仓条件,重置清除条件;选项失败独立提示并可重试,不隐藏已加载货物或伪装为空结果。预约模块37文件/276项及生产构建(含类型检查)通过,Gateway路由合同5项通过。后端生产源码编译通过,本次两个测试文件单独javac编译并经Surefire执行13项通过;常规Maven流程仍被派送模块三份测试中的5项编译错误阻挡,不能据定向结果宣称后端完整门禁通过。Admin全量405文件/3388项通过、1项输入提示合同失败,原因是其他在途运输单表单的7个输入框缺少placeholder;单独复查相同失败,未修改其他模块。日志为`.logs/appointment-destination-{test,build,full-test,placeholder-check}.log`,后端为同名前缀的`route`、`backend`、`javac`及`targeted-backend`日志。本补修未提交或部署,人工1920×1080/100%视觉待确认。
|
||||
|
||||
2026-10-19 新增FBA预约入口补修:顶部按钮要求原FBA预约写权限及尾程订单列表权限,点击后才读取候选;支持同起运仓、同目的仓的跨客户货物选择,核对最新真实来源后复用原创建表单。未知结果保留原请求并阻止关闭/返回,成功后移除创建表单、显示真实预约编号和只读内容,刷新原日历;不生成虚拟车次或第五类日历。本次已明确普通客户仓暂不加入。
|
||||
|
||||
验证:新增入口直接5文件/59项通过;`pnpm build`含类型检查通过,仅既有AnalyticsChart大分块提示。工作区首次全量为405文件/3385项通过、1项排车详情布局合同失败,后续该项单独6项复查通过;再次全量出现9文件/11项失败(以5秒超时为主),在不修改超时阈值和其他业务代码的情况下,以2个工作进程复查整个预约模块、密度及上述全部失败文件,47文件/385项全过。不将这些分次结果记录为单次全量绿灯。证据为`.logs/appointment-create-{focused-final,build,full-test,full-test-final,layout-recheck,bounded-recheck}.log`。额外临时候选核对未作为通过证据;工作区其他业务在途修改保留。本次未提交、发布或新增真实业务记录,人工1920×1080/100%视觉待确认。
|
||||
|
||||
2026-10-19 月视图周数补修:按月实际周数生成日期格,并同步查询开区间;覆盖2026年10月五周、跨年五周、完整二月四周与确需六周的八月。Admin全量405文件/3375项、类型检查及生产构建通过,`git diff --check`通过;仅既有AnalyticsChart大分块提示。本补修尚未提交或发布,人工视觉待确认;证据为`.logs/appointment-calendar-month-weeks-{test,build}.log`。
|
||||
|
||||
2026-10-19 发布候选检查:Admin全量405文件/3372项通过,定向输入提示、弹框密度及取货组件4文件/35项通过;`pnpm build`(含类型检查)通过,仅既有AnalyticsChart大分块提示。新增日历后端直接测试及受影响SQL、API、权限合同通过,Gateway路由/部署拓扑/回滚合同与Mapper注册合同通过。
|
||||
|
||||
发布检查中发现的既有问题已在本候选最小修正:调拨新增箱数和取货输入提示、取货弹框密度、尾程贴标文件页签结构与对应断言;取货测试Bean及API数量断言;国内空库SQL中误放的五张海外取货表归回海外空库基线,并同步现行车次用途字段和基线表数。这些仅修改源码基线,不对运行环境执行SQL。
|
||||
|
||||
@@ -57,7 +57,7 @@ it('shows the actual cross-month interval and hides empty-day zeros while keepin
|
||||
})
|
||||
it('renders server type counts instead of individual event cards and opens exact date/type details', async () => {
|
||||
const wrapper = render()
|
||||
expect(wrapper.findAll('.work-day')).toHaveLength(42)
|
||||
expect(wrapper.findAll('.work-day')).toHaveLength(35)
|
||||
const day = wrapper.get('[data-date="2026-10-15"]')
|
||||
expect(day.get('.day-heading').text()).toContain('2 项')
|
||||
await day.get('[data-type="DELIVERY"]').trigger('click')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
|
||||
import { FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, overseasWarehousePermissions, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -11,7 +11,7 @@ import type { SourceReference } from './workApi'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
import { workReason } from './workPresentation'
|
||||
const props = defineProps<{ sources: SourceReference[]; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto] }>()
|
||||
const access = useAccessPreviewStore(), preview = ref<PlanningBatchPreview | null>(null)
|
||||
const form = reactive(createAppointmentForm()), baseline = ref(JSON.stringify(form))
|
||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -35,7 +35,7 @@ async function save() {
|
||||
try {
|
||||
const result = await createSourceAppointment(pending.value)
|
||||
pending.value = null; baseline.value = JSON.stringify(form)
|
||||
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('saved')
|
||||
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('saved', result)
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '创建结果待确认,请重试本次请求')
|
||||
if (rejectedCommand(cause)) { pending.value = null; await load() }
|
||||
|
||||
@@ -2,12 +2,17 @@ import { describe, expect, it } from 'vitest'
|
||||
import { addDays, calendarDates, calendarRange, moveCalendar } from './calendar'
|
||||
|
||||
describe('appointment calendar', () => {
|
||||
it('shows six complete Monday-first weeks across year boundaries', () => {
|
||||
const dates = calendarDates('2027-01-15', 'month')
|
||||
expect(dates).toHaveLength(42)
|
||||
expect(dates[0]).toBe('2026-12-28')
|
||||
expect(dates.at(-1)).toBe('2027-02-07')
|
||||
expect(calendarRange('2027-01-15', 'month').to).toBe('2027-02-08')
|
||||
it.each([
|
||||
['2026-10-19', '2026-09-28', '2026-11-02', 35],
|
||||
['2027-01-15', '2026-12-28', '2027-02-01', 35],
|
||||
['2027-02-15', '2027-02-01', '2027-03-01', 28],
|
||||
['2026-08-15', '2026-07-27', '2026-09-07', 42],
|
||||
] as const)('shows only the complete weeks needed for %s', (anchor, from, to, count) => {
|
||||
const dates = calendarDates(anchor, 'month')
|
||||
expect(dates).toHaveLength(count)
|
||||
expect(dates[0]).toBe(from)
|
||||
expect(dates.at(-1)).toBe(addDays(to, -1))
|
||||
expect(calendarRange(anchor, 'month')).toEqual({ from, to })
|
||||
})
|
||||
|
||||
it('moves by a week in week mode instead of following the prototype month-navigation bug', () => {
|
||||
|
||||
@@ -29,16 +29,19 @@ export function addDays(value: string, days: number): string {
|
||||
|
||||
export function calendarRange(anchor: string, mode: CalendarMode): { from: string; to: string } {
|
||||
const date = parseDate(anchor)
|
||||
const nextMonth = new Date(date.getFullYear(), date.getMonth() + 1, 1, 12)
|
||||
if (mode === 'month') date.setDate(1)
|
||||
date.setDate(date.getDate() - (date.getDay() + 6) % 7)
|
||||
const from = dateKey(date)
|
||||
// A half-open interval also works directly with database timestamps.
|
||||
return { from, to: addDays(from, mode === 'month' ? 42 : 7) }
|
||||
// 月视图只补齐月末所在周,不追加整周的下月日期;结束日期仍为开区间。
|
||||
return { from, to: mode === 'month' ? addDays(dateKey(nextMonth), (8 - nextMonth.getDay()) % 7) : addDays(from, 7) }
|
||||
}
|
||||
|
||||
export function calendarDates(anchor: string, mode: CalendarMode): string[] {
|
||||
const { from } = calendarRange(anchor, mode)
|
||||
return Array.from({ length: mode === 'month' ? 42 : 7 }, (_, index) => addDays(from, index))
|
||||
const { from, to } = calendarRange(anchor, mode)
|
||||
const dates: string[] = []
|
||||
for (let date = from; date < to; date = addDays(date, 1)) dates.push(date)
|
||||
return dates
|
||||
}
|
||||
|
||||
export function moveCalendar(anchor: string, mode: CalendarMode, offset: number): string {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listPlanningCargo, listPlanningContainers, resolvePlanningOrder } from './planningApi'
|
||||
import { listPlanningCargo, listPlanningDestinations, listPlanningContainers, resolvePlanningOrder } from './planningApi'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data }))
|
||||
|
||||
it('uses the gateway planning route and preserves stable source IDs and null predictions', async () => {
|
||||
@@ -20,6 +20,15 @@ it('keeps basic container listing on its own endpoint rather than requiring impa
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/containers', { params: { page: 1, size: 20 }, signal: undefined })
|
||||
expect(result.total).toBe(0)
|
||||
})
|
||||
it('uses authoritative destination options and sends the selected destination to server pagination', async () => {
|
||||
vi.mocked(http.get).mockClear()
|
||||
vi.mocked(http.get).mockResolvedValueOnce({data:['ABE4','ONT8']})
|
||||
expect(await listPlanningDestinations()).toEqual(['ABE4','ONT8'])
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/cargo/destinations',{signal:undefined})
|
||||
vi.mocked(http.get).mockResolvedValueOnce({data:{records:[],page:2,size:20,total:0,pages:0}})
|
||||
await listPlanningCargo({page:2,size:20,keyword:'IN-A',destination:'ONT8'})
|
||||
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-planning/cargo',{params:{page:2,size:20,keyword:'IN-A',destination:'ONT8'},signal:undefined})
|
||||
})
|
||||
it('resolves an order through the real canonical-source endpoint without fabricating an ORDER alias', async () => {
|
||||
vi.mocked(http.get).mockClear()
|
||||
vi.mocked(http.get).mockResolvedValue({data:{source:{sourceKey:'BOOKING:B-1'},coverage:{appointments:[]}}})
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface PlanningCargo {
|
||||
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) {
|
||||
export async function listPlanningCargo(query: { page: number; size: number; keyword?: string; destination?: string }, signal?: AbortSignal) {
|
||||
const response = await http.get<ApiEnvelope<PlanningCargoPage>, ApiEnvelope<PlanningCargoPage>>(
|
||||
'/platform/v1/last-leg/appointment-planning/cargo', { params: query, signal },
|
||||
)
|
||||
@@ -39,6 +39,12 @@ export async function listPlanningCargo(query: { page: number; size: number; key
|
||||
return { ...page, page: Number(page.page), size: Number(page.size), total: Number(page.total), pages: Number(page.pages) }
|
||||
}
|
||||
|
||||
export async function listPlanningDestinations(signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<string[]>, ApiEnvelope<string[]>>(
|
||||
'/platform/v1/last-leg/appointment-planning/cargo/destinations', { signal },
|
||||
))
|
||||
}
|
||||
|
||||
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<FollowupStage, number> }
|
||||
|
||||
@@ -32,7 +32,7 @@ type FilterModel = QueryModel & {
|
||||
expectedArrivalRange: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{ mode: InboundTaskMode }>()
|
||||
const props = defineProps<{ mode: InboundTaskMode; title?: string }>()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const page = ref(1)
|
||||
@@ -356,8 +356,9 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
||||
:aria-label="`${taskLabel}待办列表`"
|
||||
@selection-change="handleReviewSelectionChange"
|
||||
>
|
||||
<template #toolbar-start><h1>{{ isReview ? '测量数据审核' : '订单测量' }}</h1></template>
|
||||
<template #toolbar-start><h1>{{ title || (isReview ? '测量数据审核' : '订单测量') }}</h1></template>
|
||||
<template #filters>
|
||||
<slot name="status-tabs" />
|
||||
<QueryToolbar
|
||||
v-model="filters"
|
||||
:ariaLabel="`${taskLabel}待办筛选`"
|
||||
|
||||
@@ -3,6 +3,7 @@ export { createInboundHoldIdempotencyKey, downloadInboundCartonLabelsBatch, down
|
||||
export { default as DropshipPalletPrintPanel } from './core/DropshipPalletPrintPanel.vue'
|
||||
export { default as InboundMeasurementPanel } from './measurement/InboundMeasurementPanel.vue'
|
||||
export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue'
|
||||
export { default as InboundTaskList } from './components/InboundTaskList.vue'
|
||||
export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue'
|
||||
export { default as InboundFeePanel } from './fee/InboundFeePanel.vue'
|
||||
export {
|
||||
|
||||
@@ -89,19 +89,6 @@
|
||||
|
||||
以上仅为代码接入依据,真实界面验收仍待执行。
|
||||
|
||||
## 2026-10-19 出车记录统计与列表优化
|
||||
|
||||
依据:当前对话图1(现状)、图2(原型);用户确认优先在前端优化。沿当前实际发车口径与接口,不恢复原型手填发车事实。
|
||||
|
||||
| 页面 | 操作 | 原型承载/布局 | 当前实现 | 本轮结论 |
|
||||
|---|---|---|---|---|
|
||||
| 出车记录 | 统计与查询 | 图2上方独立四栏饼图,下方记录与筛选 | 图1统计位于列表筛选和表格之间,条形明细内部滚动 | 独立四栏饼图及彩色图例;用户追加确认只改饼图、保留现有日期/目的地统计分组;列表一套日期/关键词/货源/状态查询,同一响应更新统计与记录;币种切换独立展示,顶部均价明确为最近统计日值,不相加或简单平均每日均价;长图例与完整统计按需展开 |
|
||||
| 出车记录 | 列表、导出 | 图2列表独立卡片,操作第一列,导出在右上 | integrated列表、字段设置、XLSX下载任务 | 保留公共连续列表,整理默认列;完整字段仍可通过字段设置显示;导出沿原筛选及权限 |
|
||||
| 出车记录 | 详情、实测编辑、凭证预览 | 图2仅能确认列表编辑入口;当前操作以既有确认矩阵为准 | 详情独立路由;实测820px居中及按需凭证预览 | 不改变承载、读写权限或请求方式 |
|
||||
| 出车记录 | 新增、审核、删除 | 图2有新增记录,未定义审核/删除 | 当前列表无新增/审核/删除 | 不新增业务动作,实际发车事实仍由既有作业链产生 |
|
||||
|
||||
本轮只调整前端;自动检查与人工视觉验收分别记录,修改后1920×1080、100%截图待用户确认。
|
||||
|
||||
## 新版报价与出车记录接入
|
||||
|
||||
| 入口 | 原型依据 | 当前承载 | 核对边界 |
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import TripOperationsCharts from './TripOperationsCharts.vue'
|
||||
import type { TripOperationsCharts as Charts, TripOperationsSummary } from '../api/tripOperations'
|
||||
|
||||
const wrappers: VueWrapper[] = []
|
||||
const emptyCharts = (): Charts => ({ physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] })
|
||||
const summary = (): TripOperationsSummary => ({ trips: 20, inTransit: 5, completed: 15, physicalPallets: 90, palletSpaces: 100, ownPackages: 50, externalPackages: 20, externalAgreedAmounts: [{ currency: 'CNY', amount: 390 }, { currency: 'USD', amount: 100 }], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } })
|
||||
function render(charts: Charts, totals = summary()) {
|
||||
const wrapper = mount(TripOperationsCharts, { props: { charts, summary: totals }, global: { stubs: { ElButton: { template: '<button><slot/></button>' } } } })
|
||||
wrappers.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
|
||||
it('renders all four statistics as pies with matching legends and server totals', () => {
|
||||
const charts = emptyCharts()
|
||||
charts.physicalPallets = [{ label: '每日实运托盘', unit: '托', points: [{ label: '2026-10-02', value: 10 }] }]
|
||||
charts.destinationTrips = [{ label: '目的地涉及车次', unit: '次', points: [{ label: 'ONT8', value: 3 }, { label: 'ABE2', value: 8 }] }]
|
||||
charts.externalAgreedAmounts = [{ label: 'CNY', unit: 'CNY', points: [{ label: '2026-10-02', value: 390 }] }]
|
||||
charts.externalPalletRates = [{ label: 'CNY', unit: 'CNY/标准托位', points: [{ label: '2026-10-07', value: 30 }, { label: '2026-10-02', value: 90 }] }]
|
||||
const original = structuredClone(charts)
|
||||
const wrapper = render(charts)
|
||||
expect(wrapper.findAll('.pie-chart')).toHaveLength(4)
|
||||
expect(wrapper.find('polyline').exists()).toBe(false)
|
||||
expect(wrapper.find('.bar-track').exists()).toBe(false)
|
||||
expect(wrapper.find('.chart--pallets .chart-metric').text()).toBe('90 托')
|
||||
expect(wrapper.find('.chart--rates .chart-metric').text()).toContain('CNY/标准托位 30.00')
|
||||
expect(wrapper.find('.chart--rates .series-caption').text()).toContain('2026-10-07')
|
||||
expect(wrapper.find('.chart--rates').text()).toContain('顶部为最近统计日均价')
|
||||
expect(wrapper.findAll('.chart--rates .pie-slice').map(slice => slice.attributes('aria-label'))).toEqual(['2026-10-02:90.00 CNY/标准托位', '2026-10-07:30.00 CNY/标准托位'])
|
||||
expect(wrapper.find('.chart--destinations .chart-legend .legend-dot').attributes('style')).toContain('rgb(59, 130, 246)')
|
||||
expect(wrapper.find('.chart--destinations .pie-slice').attributes('fill')).toBe('#3b82f6')
|
||||
expect(charts).toEqual(original)
|
||||
})
|
||||
|
||||
it('switches currency without combining monetary amounts or adding daily rates', async () => {
|
||||
const charts = emptyCharts()
|
||||
charts.externalAgreedAmounts = [{ label: 'CNY', unit: 'CNY', points: [{ label: '2026-10-02', value: 390 }] }, { label: 'USD', unit: 'USD', points: [{ label: '2026-10-03', value: 100 }] }]
|
||||
const wrapper = render(charts)
|
||||
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('CNY 390.00')
|
||||
expect(wrapper.find('.chart--amounts').findAll('svg')).toHaveLength(1)
|
||||
const usd = wrapper.findAll('.chart--amounts .series-selector button').find(button => button.text() === 'USD')!
|
||||
await usd.trigger('click')
|
||||
expect(usd.attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('USD 100.00')
|
||||
expect(wrapper.find('.chart--amounts .pie-slice').attributes('aria-label')).toBe('2026-10-03:100.00 USD')
|
||||
await wrapper.setProps({ charts: { ...charts, externalAgreedAmounts: [charts.externalAgreedAmounts[0]!] } })
|
||||
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('CNY 390.00')
|
||||
})
|
||||
|
||||
it('includes every destination in its pie while collapsing long legends and preserving complete details', () => {
|
||||
const charts = emptyCharts()
|
||||
charts.destinationTrips = [{ label: '目的地涉及车次', unit: '次', points: Array.from({ length: 7 }, (_, index) => ({ label: `FBA-${index}`, value: index + 1 })) }]
|
||||
const wrapper = render(charts)
|
||||
const destination = wrapper.find('.chart--destinations')
|
||||
expect(destination.find('.chart-metric').text()).toBe('7 个目的地')
|
||||
expect(destination.findAll('.pie-slice')).toHaveLength(7)
|
||||
expect(destination.find('.legend-details').attributes('open')).toBeUndefined()
|
||||
expect(destination.find('.legend-details summary').text()).toBe('展开其余 2 项图例')
|
||||
expect(wrapper.find('[aria-label="派送目的地明细"]').findAll('tbody tr')).toHaveLength(7)
|
||||
expect(wrapper.find('[aria-label="派送目的地明细"]').text()).toContain('FBA-0')
|
||||
})
|
||||
|
||||
it('draws a single positive value as a full pie and retains known zero values without invalid geometry', () => {
|
||||
const charts = emptyCharts()
|
||||
charts.physicalPallets = [{ label: '托盘', unit: '托', points: [{ label: '2026-10-01', value: 0 }, { label: '2026-10-02', value: 2 }] }]
|
||||
charts.externalPalletRates = [{ label: 'USD', unit: 'USD/标准托位', points: [{ label: '2026-10-01', value: 0 }] }]
|
||||
const wrapper = render(charts)
|
||||
expect(wrapper.find('.chart--pallets .pie-slice').attributes('d')).toBe('M 80 8 A 72 72 0 1 1 80 152 A 72 72 0 1 1 80 8 Z')
|
||||
expect(wrapper.find('.chart--rates .pie-empty').exists()).toBe(true)
|
||||
expect(wrapper.find('.chart--rates .chart-metric').text()).toContain('0.00')
|
||||
expect(wrapper.find('.chart--pallets .chart-legend').text()).toContain('2026-10-01')
|
||||
expect(wrapper.html()).not.toMatch(/NaN|Infinity/)
|
||||
})
|
||||
|
||||
it('shows missing amounts and rates as unknown and omits empty detail controls', () => {
|
||||
const wrapper = render(emptyCharts(), { ...summary(), physicalPallets: 0, externalAgreedAmounts: [] })
|
||||
expect(wrapper.findAll('.chart-empty')).toHaveLength(4)
|
||||
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('—')
|
||||
expect(wrapper.find('.chart--rates .chart-metric').text()).toBe('—')
|
||||
expect(wrapper.find('details').exists()).toBe(false)
|
||||
})
|
||||
@@ -1,137 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { TripOperationsCharts, TripOperationsSeries, TripOperationsSummary } from '../api/tripOperations'
|
||||
|
||||
const props = defineProps<{ charts: TripOperationsCharts; summary: TripOperationsSummary }>()
|
||||
const selectedSeries = ref<Record<string, string>>({})
|
||||
const palette = ['#3b82f6', '#14b8a6', '#f59e0b', '#8b5cf6', '#f46565', '#06b6d4', '#ec4899', '#84cc16', '#6366f1', '#f97316']
|
||||
import { computed } from 'vue'
|
||||
import type { TripOperationsCharts, TripOperationsSeries } from '../api/tripOperations'
|
||||
const props = defineProps<{ charts: TripOperationsCharts }>()
|
||||
const sections = computed(() => [
|
||||
{ key: 'pallets', title: '实运托盘', note: '按实际发车日(UTC)分布,物理托盘去重', series: props.charts.physicalPallets, money: false },
|
||||
{ key: 'destinations', title: '派送目的地', note: '按目的地涉及车次分布,同车多站可分别计入', series: props.charts.destinationTrips, money: false },
|
||||
{ key: 'amounts', title: '外接约定金额', note: '按日期分布,各币种独立展示,不代表应收或实收', series: props.charts.externalAgreedAmounts, money: true },
|
||||
{ key: 'rates', title: '外接按托报价均价', note: '按日均价数值对比,顶部为最近统计日均价', series: props.charts.externalPalletRates, money: true },
|
||||
].map(section => {
|
||||
const series = section.series.filter(series => series.points.length).map(series => ({
|
||||
...series,
|
||||
points: [...series.points].sort((a, b) => section.key === 'destinations' ? b.value - a.value || a.label.localeCompare(b.label) : a.label.localeCompare(b.label)),
|
||||
}))
|
||||
return { ...section, series, active: series.find(series => series.label === selectedSeries.value[section.key]) ?? series[0] ?? null }
|
||||
}))
|
||||
const destinationCount = computed(() => new Set(props.charts.destinationTrips.flatMap(series => series.points.map(point => point.label))).size)
|
||||
const hasPoints = computed(() => sections.value.some(section => section.series.length))
|
||||
const number = (value: number, money = false) => value.toLocaleString('zh-CN', { minimumFractionDigits: money ? 2 : 0, maximumFractionDigits: 2 })
|
||||
const color = (index: number) => palette[index % palette.length]!
|
||||
const seriesTitle = (series: TripOperationsSeries) => series.label === series.unit ? series.label : `${series.label}(${series.unit})`
|
||||
const amount = (currency: string) => props.summary.externalAgreedAmounts.find(amount => amount.currency === currency)
|
||||
|
||||
function slices(series: TripOperationsSeries) {
|
||||
const total = series.points.reduce((sum, point) => sum + Math.max(0, point.value), 0)
|
||||
if (!total) return []
|
||||
let angle = -Math.PI / 2
|
||||
return series.points.flatMap((point, index) => {
|
||||
if (point.value <= 0) return []
|
||||
const end = angle + point.value / total * Math.PI * 2
|
||||
const startX = 80 + 72 * Math.cos(angle), startY = 80 + 72 * Math.sin(angle)
|
||||
const endX = 80 + 72 * Math.cos(end), endY = 80 + 72 * Math.sin(end)
|
||||
const path = point.value === total
|
||||
? 'M 80 8 A 72 72 0 1 1 80 152 A 72 72 0 1 1 80 8 Z'
|
||||
: `M 80 80 L ${startX} ${startY} A 72 72 0 ${end - angle > Math.PI ? 1 : 0} 1 ${endX} ${endY} Z`
|
||||
angle = end
|
||||
return [{ ...point, color: color(index), path }]
|
||||
})
|
||||
}
|
||||
{ title: '实运托盘', note: '按实际发车日(UTC)汇总,物理托盘去重', series: props.charts.physicalPallets },
|
||||
{ title: '派送目的地', note: '各目的地涉及的车次,同车多站可分别出现', series: props.charts.destinationTrips },
|
||||
{ title: '外接约定金额', note: '按币种分别展示,不代表财务应收或实收', series: props.charts.externalAgreedAmounts },
|
||||
{ title: '外接按托报价均价', note: '按确认计价托位加权,排除整单报价', series: props.charts.externalPalletRates },
|
||||
])
|
||||
function width(value: number, series: TripOperationsSeries) { const max = Math.max(...series.points.map(p => p.value), 0); return max ? value / max * 100 : 0 }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="operations-visualizations">
|
||||
<section class="operations-charts" aria-label="出车统计饼图">
|
||||
<article v-for="section in sections" :key="section.key" class="chart" :class="`chart--${section.key}`" :aria-label="section.title">
|
||||
<h3>{{ section.title }}</h3>
|
||||
<div class="chart-metric">
|
||||
<strong v-if="section.key === 'pallets'">{{ number(summary.physicalPallets) }} <small>托</small></strong>
|
||||
<strong v-else-if="section.key === 'destinations'">{{ number(destinationCount) }} <small>个目的地</small></strong>
|
||||
<template v-else-if="section.key === 'amounts'">
|
||||
<strong v-if="section.active && amount(section.active.label)"><small>{{ section.active.label }}</small> {{ number(amount(section.active.label)!.amount, true) }}</strong>
|
||||
<strong v-else>—</strong>
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong v-if="section.active"><small>{{ section.active.unit }}</small> {{ number(section.active.points[section.active.points.length - 1]!.value, true) }}</strong>
|
||||
<strong v-else>—</strong>
|
||||
</template>
|
||||
</div>
|
||||
<p class="chart-note">{{ section.note }}</p>
|
||||
<div v-if="section.series.length > 1" class="series-selector" role="group" :aria-label="`${section.title}币种或系列`">
|
||||
<el-button v-for="series in section.series" :key="series.label" size="small" :type="section.active?.label === series.label ? 'primary' : 'default'" :aria-pressed="section.active?.label === series.label" @click="selectedSeries[section.key] = series.label">{{ series.label }}</el-button>
|
||||
</div>
|
||||
<template v-if="section.active">
|
||||
<p class="series-caption">{{ seriesTitle(section.active) }}<template v-if="section.key === 'rates'"> · {{ section.active.points[section.active.points.length - 1]!.label }}</template></p>
|
||||
<svg class="pie-chart" viewBox="0 0 160 160" role="img" :aria-label="`${section.title} · ${seriesTitle(section.active)}饼图,完整数值见图例及统计明细`">
|
||||
<circle v-if="!slices(section.active).length" cx="80" cy="80" r="72" class="pie-empty" />
|
||||
<path v-for="slice in slices(section.active)" :key="slice.label" :d="slice.path" :fill="slice.color" class="pie-slice" tabindex="0" :aria-label="`${slice.label}:${number(slice.value, section.money)} ${section.active.unit}`"><title>{{ slice.label }}:{{ number(slice.value, section.money) }} {{ section.active.unit }}</title></path>
|
||||
<text v-if="!slices(section.active).length" x="80" y="84" text-anchor="middle" class="pie-empty-label">暂无正值数据</text>
|
||||
</svg>
|
||||
<ul class="chart-legend" :aria-label="`${section.title}图例`">
|
||||
<li v-for="(point, pointIndex) in section.active.points.slice(0, 5)" :key="point.label" :title="`${point.label}:${number(point.value, section.money)} ${section.active.unit}`"><span class="legend-dot" :style="{ background: color(pointIndex) }" aria-hidden="true" /><span>{{ point.label }}</span><strong>{{ number(point.value, section.money) }}</strong></li>
|
||||
</ul>
|
||||
<details v-if="section.active.points.length > 5" class="legend-details">
|
||||
<summary>展开其余 {{ section.active.points.length - 5 }} 项图例</summary>
|
||||
<ul class="chart-legend">
|
||||
<li v-for="(point, pointIndex) in section.active.points.slice(5)" :key="point.label"><span class="legend-dot" :style="{ background: color(pointIndex + 5) }" aria-hidden="true" /><span>{{ point.label }}</span><strong>{{ number(point.value, section.money) }}</strong></li>
|
||||
</ul>
|
||||
</details>
|
||||
</template>
|
||||
<p v-else class="chart-empty">暂无对应统计</p>
|
||||
</article>
|
||||
</section>
|
||||
<details v-if="hasPoints" class="chart-details">
|
||||
<summary>查看完整统计明细</summary>
|
||||
<div class="chart-details-grid">
|
||||
<section v-for="section in sections" :key="section.key" :aria-label="`${section.title}明细`">
|
||||
<h3>{{ section.title }}</h3>
|
||||
<p v-if="section.key === 'rates'" class="chart-note">各日均价按确认计价托位加权,排除整单报价;不将每日均价相加或简单平均为期间均价。</p>
|
||||
<p v-if="!section.series.length" class="chart-note">暂无对应统计</p>
|
||||
<table v-for="(series, seriesIndex) in section.series" :key="`${series.label}-${seriesIndex}`" class="density-table">
|
||||
<caption>{{ seriesTitle(series) }}</caption>
|
||||
<thead><tr><th scope="col">{{ section.key === 'destinations' ? '目的地' : '日期(UTC)' }}</th><th scope="col">数值</th></tr></thead>
|
||||
<tbody><tr v-for="point in series.points" :key="point.label"><td>{{ point.label }}</td><td>{{ number(point.value, section.money) }}</td></tr></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template><section class="operations-charts" aria-label="出车数据统计"><article v-for="section in sections" :key="section.title" class="chart">
|
||||
<strong>{{ section.title }}</strong><p>{{ section.note }}</p>
|
||||
<p v-if="!section.series.some(s => s.points.length)">暂无对应统计</p>
|
||||
<div class="chart-values"><template v-for="(series, si) in section.series" :key="`${series.label}-${si}`"><div v-if="series.points.length"><b>{{ series.label }}({{ series.unit }})</b>
|
||||
<div v-for="(point, pi) in series.points" :key="pi" class="point"><span>{{ point.label }}</span><div class="bar-track" aria-hidden="true"><div class="bar" :style="{ width: width(point.value, series) + '%' }" /></div><span>{{ point.value }}</span></div>
|
||||
</div></template></div>
|
||||
</article></section></template>
|
||||
<style scoped>
|
||||
.operations-visualizations { display: grid; gap: var(--admin-space-3); min-width: 0; }
|
||||
.operations-charts, .chart-details-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-6); }
|
||||
.chart { min-width: 0; padding-right: var(--admin-space-6); border-right: 1px solid var(--cc-border); color: var(--cc-text); text-align: center; }
|
||||
.chart:last-child { padding-right: 0; border-right: 0; }
|
||||
.chart h3, .chart-details h3 { margin: 0 0 var(--admin-space-2); font-size: var(--admin-font-section); }
|
||||
.chart-metric { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: center; gap: var(--admin-space-1) var(--admin-space-5); margin-bottom: var(--admin-space-1); font-variant-numeric: tabular-nums; }
|
||||
.chart-metric > strong { font-size: calc(var(--admin-font-title) * 1.25); overflow-wrap: anywhere; }
|
||||
.chart-metric small { font-size: var(--admin-font-caption); font-weight: normal; color: var(--cc-muted); }
|
||||
.chart-note, .chart-empty, .series-caption { margin: 0; font-size: var(--admin-font-caption); color: var(--cc-muted); line-height: 1.6; }
|
||||
.chart-empty { display: grid; min-height: 136px; place-content: center; }
|
||||
.series-caption { margin-top: var(--admin-space-2); }
|
||||
.series-selector { display: flex; justify-content: center; flex-wrap: wrap; gap: var(--admin-space-2); margin-top: var(--admin-space-2); }
|
||||
.series-selector :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.pie-chart { display: block; width: 136px; height: 136px; max-width: 100%; margin: var(--admin-space-2) auto; overflow: visible; }
|
||||
.pie-slice { stroke: var(--admin-surface); stroke-width: 2; stroke-linejoin: round; }
|
||||
.pie-slice:hover, .pie-slice:focus-visible { stroke: var(--cc-text); stroke-width: 3; outline: none; }
|
||||
.pie-empty { fill: var(--el-fill-color-light); }
|
||||
.pie-empty-label { fill: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.chart-legend { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--admin-space-1) var(--admin-space-3); padding: 0; margin: 0; list-style: none; font-size: var(--admin-font-caption); }
|
||||
.chart-legend li { display: inline-flex; align-items: baseline; gap: var(--admin-space-1); min-width: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.legend-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; flex: 0 0 9px; }
|
||||
.chart-legend strong { font-weight: normal; color: var(--cc-muted); }
|
||||
.chart-details summary, .legend-details summary { width: fit-content; cursor: pointer; color: var(--el-color-primary); font-size: var(--admin-font-caption); }
|
||||
.legend-details summary { margin: var(--admin-space-2) auto; }
|
||||
.chart-details summary:focus-visible, .legend-details summary:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
.chart-details-grid { margin-top: var(--admin-space-5); }
|
||||
.chart-details-grid > section { min-width: 0; }
|
||||
.chart-details table { width: 100%; table-layout: fixed; }
|
||||
.chart-details caption { text-align: left; padding: var(--admin-space-2) 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.chart-details td { overflow-wrap: anywhere; }
|
||||
@media (max-width: 1100px) { .operations-charts, .chart-details-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .chart:nth-child(2n) { padding-right: 0; border-right: 0; } }
|
||||
@media (max-width: 720px) { .operations-charts, .chart-details-grid { grid-template-columns: minmax(0, 1fr); } .chart { padding-right: 0; padding-bottom: var(--admin-space-5); border-right: 0; border-bottom: 1px solid var(--cc-border); } .chart:last-child { padding-bottom: 0; border-bottom: 0; } }
|
||||
.operations-charts { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:var(--admin-space-3); }
|
||||
.chart { display:grid; align-content:start; gap:var(--admin-space-2); min-width:0; padding:var(--admin-space-3); border:1px solid var(--cc-border); border-radius:var(--admin-radius); background:var(--admin-surface); }
|
||||
.chart > strong { font-size:var(--admin-font-section); }
|
||||
.chart p { margin:0; line-height:1.6; color:var(--cc-muted); font-size:var(--admin-font-caption); }
|
||||
.chart-values { max-height:220px; overflow:auto; }
|
||||
.point { display:grid; grid-template-columns:minmax(95px,1fr) minmax(45px,1fr) minmax(40px,auto); gap:var(--admin-space-2); align-items:center; margin:var(--admin-space-2) 0; }
|
||||
.bar-track { height:10px; background:var(--el-fill-color-light); }.bar { height:100%; background:var(--el-color-primary); }
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,6 @@ import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import FleetQuotesView from './FleetQuotesView.vue'
|
||||
import TripOperationsView from './TripOperationsView.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import TripOperationsCharts from '../components/TripOperationsCharts.vue'
|
||||
import * as quotes from '../api/fleetQuotes'
|
||||
import * as reports from '../api/tripOperations'
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => p.endsWith(':list') }) }))
|
||||
@@ -11,10 +9,10 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('../api/fleetQuotes', () => ({ listFleetQuotes: vi.fn(), setFleetQuoteStatus: vi.fn(), deleteFleetQuote: vi.fn(), exportFleetQuotes: vi.fn(), saveFleetCsv: vi.fn() }))
|
||||
vi.mock('../api/tripOperations', () => ({ listTripOperations: vi.fn(), exportTripOperations: vi.fn() }))
|
||||
const wrappers: VueWrapper[] = []
|
||||
const layout = { template: '<div><slot name="metrics"/><slot name="state"/><slot/></div>' }
|
||||
const layout = { template: '<div><slot/></div>' }
|
||||
function mount(component: object) { const wrapper = shallowMount(component, { global: { stubs: { AdminPage: layout, ListPageLayout: layout, QueryToolbar: true,
|
||||
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><slot name="toolbar-start"/><slot name="toolbar-actions"/><slot name="filters"/><slot name="notices"/></div>' }, ElButton: { template: '<button><slot/></button>' },
|
||||
ElTooltip: true, LoadErrorState: true, FleetQuoteEditor: true, FleetQuoteImport: true, ApiErrorMeta: true } } }); wrappers.push(wrapper); return wrapper }
|
||||
LoadErrorState: true, FleetQuoteEditor: true, FleetQuoteImport: true, ApiErrorMeta: true } } }); wrappers.push(wrapper); return wrapper }
|
||||
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.clearAllMocks() })
|
||||
it('quotes read-only view fetches a page once and hides all write/export tools', async () => {
|
||||
vi.mocked(quotes.listFleetQuotes).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
|
||||
@@ -26,30 +24,4 @@ it('operations uses server totals rather than displayed page size', async () =>
|
||||
vi.mocked(reports.listTripOperations).mockResolvedValue({ page: { records: [], total: 21, page: 1, size: 20, pages: 2 }, summary: { trips: 21, inTransit: 12, completed: 9, physicalPallets: 40, palletSpaces: 40, ownPackages: 70, externalPackages: 10, externalAgreedAmounts: [], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } })
|
||||
const wrapper = mount(TripOperationsView); await flushPromises()
|
||||
expect(reports.listTripOperations).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('实际出车 21 次'); expect(wrapper.text()).not.toContain('导出CSV')
|
||||
expect(wrapper.findComponent(TripOperationsCharts).props('summary').physicalPallets).toBe(40)
|
||||
expect(wrapper.find('.operations-overview').element.compareDocumentPosition(wrapper.findComponent({ name: 'DataTable' }).element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('columns').find((column: { key: string }) => column.key === 'finance')).toMatchObject({ defaultVisible: false })
|
||||
})
|
||||
it('operations queries charts and records together and resets with only one request', async () => {
|
||||
vi.mocked(reports.listTripOperations).mockResolvedValue({ page: { records: [], total: 0, page: 1, size: 20, pages: 0 }, summary: { trips: 0, inTransit: 0, completed: 0, physicalPallets: 0, palletSpaces: 0, ownPackages: 0, externalPackages: 0, externalAgreedAmounts: [], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } })
|
||||
const wrapper = mount(TripOperationsView); await flushPromises()
|
||||
const initial = vi.mocked(reports.listTripOperations).mock.calls[0]![0]
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('update:modelValue', { from: '2026-10-01', to: '2026-10-07', keyword: 'TRIP', sourceType: 'EXTERNAL', status: 'IN_TRANSIT' })
|
||||
toolbar.vm.$emit('search'); await flushPromises()
|
||||
expect(reports.listTripOperations).toHaveBeenLastCalledWith(expect.objectContaining({ from: '2026-10-01T00:00:00Z', to: '2026-10-08T00:00:00.000Z', keyword: 'TRIP', sourceType: 'EXTERNAL', status: 'IN_TRANSIT' }))
|
||||
expect(wrapper.text()).toContain('2026-10-01 至 2026-10-07(UTC)')
|
||||
toolbar.vm.$emit('update:modelValue', { from: '2026-09-01', to: '2026-09-07' }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('2026-10-01 至 2026-10-07(UTC)')
|
||||
toolbar.vm.$emit('reset'); toolbar.vm.$emit('search'); await flushPromises()
|
||||
expect(reports.listTripOperations).toHaveBeenCalledTimes(3)
|
||||
expect(reports.listTripOperations).toHaveBeenLastCalledWith(initial)
|
||||
expect(toolbar.props('modelValue')).toMatchObject({ keyword: '', sourceType: '', status: '' })
|
||||
})
|
||||
it('operations shows query failures without leaving stale statistics', async () => {
|
||||
vi.mocked(reports.listTripOperations).mockRejectedValue(new Error('统计暂不可用'))
|
||||
const wrapper = mount(TripOperationsView); await flushPromises()
|
||||
expect(wrapper.findComponent(TripOperationsCharts).exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).attributes('message')).toBe('运营记录加载失败')
|
||||
expect(wrapper.text()).not.toContain('实际出车 0 次')
|
||||
})
|
||||
|
||||
@@ -20,79 +20,48 @@ const router = useRouter(), access = useAccessPreviewStore()
|
||||
const result = ref<TripOperationsResult | null>(null), page = ref(1), size = ref(20), loading = ref(false), exporting = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const recordOpen = ref(false), selected = ref<TripOperationsRow | null>(null)
|
||||
const today = new Date().toISOString().slice(0, 10), monthAgo = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10)
|
||||
const defaultFilters = () => ({ from: monthAgo, to: today, keyword: '', sourceType: '', status: '' })
|
||||
const filters = ref<QueryModel>(defaultFilters())
|
||||
const displayedRange = ref('')
|
||||
const filters = ref<QueryModel>({ from: monthAgo, to: today, keyword: '', sourceType: '', status: '' })
|
||||
let applied: TripOperationsQuery = range(), sequence = 0
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'from', label: '实际发车起日(UTC)', placeholder: '发车起日(UTC)', type: 'date', width: 150, props: { 'aria-label': '实际发车起日(UTC)' } },
|
||||
{ key: 'to', label: '实际发车止日(UTC)', placeholder: '发车止日(UTC)', type: 'date', width: 150, props: { 'aria-label': '实际发车止日(UTC)' } },
|
||||
{ key: 'keyword', label: '关键词', type: 'input', width: 270, placeholder: '车次 / 车牌 / 司机 / 目的仓', props: { 'aria-label': '车次、车牌、司机或目的仓' } },
|
||||
{ key: 'sourceType', label: '货源', placeholder: '全部货源', type: 'select', width: 140, props: { 'aria-label': '货源' }, options: [{ value: 'OWN', label: '含自有业务' }, { value: 'EXTERNAL', label: '含外接业务' }] },
|
||||
{ key: 'status', label: '状态', placeholder: '全部状态', type: 'select', width: 140, props: { 'aria-label': '状态' }, options: [{ value: 'IN_TRANSIT', label: '在途' }, { value: 'COMPLETED', label: '货物已结案' }] },
|
||||
{ key: 'from', label: '实际发车起日(UTC)', type: 'date', width: 185 }, { key: 'to', label: '实际发车止日(UTC)', type: 'date', width: 185 },
|
||||
{ key: 'keyword', label: '关键词', type: 'input', width: 235, placeholder: '车次 / 发车车牌 / 司机 / 目的仓' },
|
||||
{ key: 'sourceType', label: '货源', type: 'select', width: 145, options: [{ value: 'OWN', label: '含自有业务' }, { value: 'EXTERNAL', label: '含外接业务' }] },
|
||||
{ key: 'status', label: '状态', type: 'select', width: 130, options: [{ value: 'IN_TRANSIT', label: '在途' }, { value: 'COMPLETED', label: '货物已结案' }] },
|
||||
]
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 220 },
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 185, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 220 },
|
||||
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'vehiclePlate', label: '发车车牌', minWidth: 125 }, { key: 'driverName', label: '发车司机', minWidth: 100 },
|
||||
{ key: 'warehouseName', label: '起运仓', minWidth: 150 }, { key: 'destinations', label: '目的地', minWidth: 180, slotName: 'destinations' },
|
||||
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'physicalPallets', label: '实物托盘', width: 100 },
|
||||
{ key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' },
|
||||
{ key: 'completedAt', label: '货物结案(UTC)', minWidth: 190, defaultVisible: false },
|
||||
{ key: 'palletSpaces', label: '标准托位', width: 100, defaultVisible: false }, { key: 'ownPackages', label: '自有箱数', width: 100, defaultVisible: false }, { key: 'externalPackages', label: '外接箱数', width: 100, defaultVisible: false },
|
||||
{ key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance', defaultVisible: false }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel', defaultVisible: false },
|
||||
{ key: 'finance', label: '应收 / 实收', minWidth: 260, slotName: 'finance', defaultVisible: false }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost', defaultVisible: false }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated', defaultVisible: false }]
|
||||
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'completedAt', label: '货物结案(UTC)', minWidth: 190 },
|
||||
{ key: 'physicalPallets', label: '实物托盘', width: 100 }, { key: 'palletSpaces', label: '标准托位', width: 100 }, { key: 'ownPackages', label: '自有箱数', width: 100 }, { key: 'externalPackages', label: '外接箱数', width: 100 },
|
||||
{ key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' }, { key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance' }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel' }, { key: 'finance', label: '应收 / 实收', minWidth: 260, slotName: 'finance' }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost' }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated' }]
|
||||
function range(): TripOperationsQuery {
|
||||
const from = String(filters.value.from || monthAgo), to = String(filters.value.to || today)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to) || Number.isNaN(Date.parse(from)) || Number.isNaN(Date.parse(to))) throw new Error('请输入有效日期 YYYY-MM-DD')
|
||||
return { from: `${from}T00:00:00Z`, to: new Date(Date.parse(`${to}T00:00:00Z`) + 86400000).toISOString(), keyword: String(filters.value.keyword || '') || undefined, sourceType: (filters.value.sourceType || undefined) as TripOperationsQuery['sourceType'], status: String(filters.value.status || '') || undefined }
|
||||
}
|
||||
async function load() {
|
||||
const current = ++sequence, query = { ...applied, page: page.value, size: size.value }
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const data = await listTripOperations(query)
|
||||
if (current === sequence) {
|
||||
result.value = data
|
||||
displayedRange.value = `${query.from.slice(0, 10)} 至 ${new Date(Date.parse(query.to) - 86400000).toISOString().slice(0, 10)}(UTC)`
|
||||
}
|
||||
} catch (e) {
|
||||
if (current === sequence) { result.value = null; error.value = getApiErrorDetails(e, '运营记录加载失败') }
|
||||
} finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
async function load() { const current = ++sequence; loading.value = true; error.value = null; try { const data = await listTripOperations({ ...applied, page: page.value, size: size.value }); if (current === sequence) result.value = data } catch (e) { if (current === sequence) { result.value = null; error.value = getApiErrorDetails(e, '运营记录加载失败') } } finally { if (current === sequence) loading.value = false } }
|
||||
function search() { try { applied = range(); if (page.value !== 1) page.value = 1; else void load() } catch (e) { error.value = getApiErrorDetails(e, '日期范围无效') } }
|
||||
function resetFilters() { filters.value = defaultFilters() }
|
||||
function openRecord(tripNo: unknown) { const row = result.value?.page.records.find(item => item.tripNo === tripNo); if (row) { selected.value = row; recordOpen.value = true } }
|
||||
async function download() { exporting.value = true; try { await submitExport('TRIP_OPERATIONS_LIST', '出车记录', applied) } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { exporting.value = false } }
|
||||
const money = (rows: TripOperationsMoney[]) => rows.length ? rows.map(r => `${r.currency} ${r.amount.toFixed(2)}`).join(' / ') : '—'
|
||||
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="出车记录">
|
||||
<template #metrics>
|
||||
<section class="operations-overview" aria-labelledby="operations-statistics-title" :aria-busy="loading">
|
||||
<header class="operations-heading">
|
||||
<h2 id="operations-statistics-title">出车数据统计</h2>
|
||||
<span class="operations-note">{{ loading ? '正在更新统计与记录…' : displayedRange }} · 与下方列表筛选一致</span>
|
||||
</header>
|
||||
<template v-if="result">
|
||||
<TripOperationsCharts :charts="result.charts" :summary="result.summary" />
|
||||
<footer class="operations-footer">
|
||||
<div class="operations-summary" aria-live="polite">
|
||||
<span>实际出车 <strong>{{ result.summary.trips }}</strong> 次</span><span>在途 <strong>{{ result.summary.inTransit }}</strong> 次</span><span>货物结案 <strong>{{ result.summary.completed }}</strong> 次</span>
|
||||
</div>
|
||||
<el-tooltip placement="top" :show-after="200" content="按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。物理托盘去重;同车多站可分别计入目的地。外接约定金额不代表应收或实收,结案不代表车辆与司机已释放。">
|
||||
<el-button link aria-label="查看统计口径">统计口径</el-button>
|
||||
</el-tooltip>
|
||||
</footer>
|
||||
<p v-if="result.summary.costs.visible" class="operations-note operations-costs">已登记直接成本:{{ result.summary.costs.totals.length ? result.summary.costs.totals.map(c => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') : '—' }};未登记 {{ result.summary.costs.unrecordedTrips ?? '—' }} 趟,已登记但未能分摊 {{ result.summary.costs.unallocatedTrips ?? '—' }} 趟。</p>
|
||||
</template>
|
||||
<p v-else class="operations-placeholder operations-note" role="status">{{ loading ? '正在加载出车统计…' : '暂无统计数据' }}</p>
|
||||
</section>
|
||||
</template>
|
||||
<template #state>
|
||||
<LoadErrorState v-if="error" title="查询或导出失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
</template>
|
||||
<DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-operations-table" v-model:page="page" v-model:page-size="size" :data="result?.page.records || []" :columns="columns" :total="result?.page.total || 0" :loading="loading" row-key="tripNo" empty-text="该范围暂无实际出车记录">
|
||||
<template #toolbar-start><h1>出车记录</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="运营记录查询" preference-key="fleet-operations-query" :loading="loading" @search="search" @reset="resetFilters" /></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="运营记录查询" preference-key="fleet-operations-query" :loading="loading" @search="search" @reset="search" /></template>
|
||||
<template #notices>
|
||||
<div class="operations-overview">
|
||||
<LoadErrorState v-if="error" title="查询或导出失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<TripOperationsCharts v-if="result" :charts="result.charts" />
|
||||
<div v-if="result" class="operations-summary" aria-live="polite">
|
||||
<span>实际出车 <strong>{{ result.summary.trips }}</strong> 次</span><span>在途 <strong>{{ result.summary.inTransit }}</strong> 次</span><span>货物结案 <strong>{{ result.summary.completed }}</strong> 次</span>
|
||||
<span>实物托盘 <strong>{{ result.summary.physicalPallets }}</strong> 托</span><span>外接约定 {{ money(result.summary.externalAgreedAmounts) }}</span>
|
||||
</div>
|
||||
<p v-if="result?.summary.costs.visible" class="operations-note">已登记直接成本:{{ result.summary.costs.totals.length ? result.summary.costs.totals.map(c => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') : '—' }};未登记 {{ result.summary.costs.unrecordedTrips }} 趟,已登记但未能分摊 {{ result.summary.costs.unallocatedTrips }} 趟。</p>
|
||||
<p class="operations-note">按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。约定金额不代表应收或实收,结案不代表车辆与司机已释放。</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:operations:export') && access.can('platform:download:list')" :loading="exporting" @click="download">导出</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button v-if="access.can('platform:fleet:trip:list')" class="density-action" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } })">详情</el-button><el-button class="density-action" @click="openRecord(row.tripNo)">实测记录</el-button></div></template>
|
||||
<template #status="{ row }">{{ row.status === 'COMPLETED' ? '货物已结案' : '在途' }}</template><template #destinations="{ row }">{{ row.destinations.map((d: TripOperationsRow['destinations'][number]) => d.label).join(' / ') }}</template>
|
||||
@@ -104,12 +73,7 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
|
||||
<TripOperationsRecordDialog v-model="recordOpen" :row="selected" :readonly="!access.can('platform:fleet:operations:edit')" @saved="load" />
|
||||
</ListPageLayout></AdminPage></template>
|
||||
<style scoped>
|
||||
.operations-overview { display: grid; gap: var(--admin-space-3); min-width: 0; padding: var(--admin-space-5); background: var(--admin-surface); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }
|
||||
.operations-heading, .operations-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--admin-space-3) var(--admin-space-6); }
|
||||
.operations-heading h2 { margin: 0; font-size: var(--admin-font-title); }
|
||||
.operations-footer { border-top: 1px solid var(--cc-border); padding-top: var(--admin-space-3); }
|
||||
.operations-overview { display: grid; gap: var(--admin-space-3); min-width: 0; }
|
||||
.operations-summary { display: flex; flex-wrap: wrap; gap: var(--admin-space-2) var(--admin-space-6); font-variant-numeric: tabular-nums; }
|
||||
.operations-note { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.6; }
|
||||
.operations-placeholder { min-height: var(--admin-empty-height); display: grid; place-content: center; }
|
||||
.operations-costs { padding-top: var(--admin-space-1); }
|
||||
</style>
|
||||
|
||||
@@ -4,11 +4,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getOutbound } from '../api/outbounds'
|
||||
import OutboundDetailView from './OutboundDetailView.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getPlatformWarehouse } from '@/modules/warehouses'
|
||||
|
||||
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { outboundNo: 'OUT/1' } }), useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('../api/outbounds', () => ({ getOutbound: vi.fn(), cancelOutbound: vi.fn() }))
|
||||
vi.mock('@/modules/warehouses', () => ({ getPlatformWarehouse: vi.fn() }))
|
||||
|
||||
describe('OutboundDetailView', () => {
|
||||
it.each([
|
||||
@@ -25,6 +27,7 @@ describe('OutboundDetailView', () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getPlatformWarehouse).mockReset().mockRejectedValue(new Error('Warehouse unavailable'))
|
||||
canPermission.mockReset().mockReturnValue(false)
|
||||
vi.mocked(getOutbound).mockReset().mockResolvedValue({
|
||||
outbound: { id: '1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'PICKED', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
|
||||
@@ -87,6 +90,24 @@ describe('OutboundDetailView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('resolves the warehouse for a discard outbound without deliveries', async () => {
|
||||
vi.mocked(getOutbound).mockResolvedValueOnce({
|
||||
outbound: { id: '1', outboundNo: 'OB-WH-20261019-151681', outboundType: 'DISCARD_OUTBOUND', overseasWarehouseId: 'warehouse-internal-id', packageCount: 1 },
|
||||
deliveries: [],
|
||||
})
|
||||
vi.mocked(getPlatformWarehouse).mockResolvedValueOnce({
|
||||
warehouse: { id: 'warehouse-internal-id', tenantId: '0', warehouseCode: 'LAX', internalCode: null, warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
|
||||
addresses: [], capabilities: [],
|
||||
})
|
||||
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(getPlatformWarehouse).toHaveBeenCalledWith('warehouse-internal-id')
|
||||
const label = wrapper.findAll('.el-descriptions__label').find(item => item.text() === '海外仓')!
|
||||
expect(label.element.nextElementSibling?.textContent?.trim()).toBe('洛杉矶仓')
|
||||
expect(wrapper.text()).not.toContain('warehouse-internal-id')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['warehouse-internal-id', '洛杉矶仓', '洛杉矶仓'],
|
||||
['warehouse-internal-id', null, '—'],
|
||||
|
||||
@@ -13,6 +13,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getPlatformWarehouse } from '@/modules/warehouses'
|
||||
import { cancelOutbound, getOutbound, type OutboundDetail } from '../api/outbounds'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
@@ -21,6 +22,7 @@ const route = useRoute(); const router = useRouter(); const access = useAccessPr
|
||||
const outboundNo = computed(() => String(route.params.outboundNo || '').trim())
|
||||
const detail = ref<OutboundDetail | null>(null); const loading = ref(false); const cancelling = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const warehouseName = ref('')
|
||||
const statusLabels: Record<string, string> = { WAIT_PICK: '待拣货', WAIT_OUTBOUND: '待出库', PICKING: '拣货中', PICKED: '已拣货', OUTBOUNDED: '已出库', CANCELLED: '已取消' }
|
||||
const palletColumns: DataTableColumn[] = [
|
||||
{ key: 'palletNo', label: '托盘码', width: 220 },
|
||||
@@ -40,8 +42,23 @@ function show(value: unknown) { const text = String(value ?? '').trim(); return
|
||||
// A warehouse ID is only a join key; never render it as a warehouse name.
|
||||
const warehouseLabel = computed(() => {
|
||||
const outboundWarehouseId = detail.value?.outbound.overseasWarehouseId
|
||||
if (!outboundWarehouseId) return '—'
|
||||
const delivery = detail.value?.deliveries?.find(item => item.overseasWarehouseId === outboundWarehouseId)
|
||||
return outboundWarehouseId && delivery?.overseasWarehouseId === outboundWarehouseId ? show(delivery.overseasWarehouseName) : '—'
|
||||
return show(delivery?.overseasWarehouseName?.trim() || warehouseName.value)
|
||||
})
|
||||
// 废弃等出库没有关联派送单,直接从仓库主档补充名称。
|
||||
watch(() => detail.value, async (value, _previous, onCleanup) => {
|
||||
warehouseName.value = ''
|
||||
let current = true
|
||||
onCleanup(() => { current = false })
|
||||
const warehouseId = value?.outbound.overseasWarehouseId
|
||||
if (!warehouseId || value?.deliveries?.some(item => item.overseasWarehouseId === warehouseId && item.overseasWarehouseName?.trim())) return
|
||||
try {
|
||||
const result = await getPlatformWarehouse(warehouseId)
|
||||
if (current && String(result.warehouse.id) === String(warehouseId)) warehouseName.value = result.warehouse.warehouseName
|
||||
} catch {
|
||||
// 主档不可用时保留占位,出库详情仍可查看,不展示内部 ID。
|
||||
}
|
||||
})
|
||||
function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) }
|
||||
function outboundTypeLabel(value?: string | null) { return value === 'RELABEL_OUTBOUND' ? '贴标出库' : value === 'DISCARD_OUTBOUND' ? '废弃出库' : value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : show(value) }
|
||||
|
||||
@@ -15,7 +15,7 @@ export type UnifiedOrderMainStatus =
|
||||
export const unifiedOrderStatusOptions: Array<{ value: UnifiedOrderMainStatus; label: string }> = [
|
||||
{ value: 'PENDING_REVIEW', label: '待审核' },
|
||||
{ value: 'WAIT_MEASURE', label: '待测量数据' },
|
||||
{ value: 'WAIT_PALLETIZE', label: '待一件代发组托' },
|
||||
{ value: 'WAIT_PALLETIZE', label: '一件代发打托' },
|
||||
{ value: 'WAIT_SPLIT', label: '待分柜' },
|
||||
{ value: 'LOADING', label: '装柜中' },
|
||||
{ value: 'LOADED', label: '装柜完成' },
|
||||
|
||||
@@ -14,7 +14,10 @@ vi.mock('vue-router', () => ({
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) }))
|
||||
vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn() }))
|
||||
vi.mock('@/modules/inbounds', () => ({
|
||||
downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn(),
|
||||
InboundTaskList: { name: 'InboundTaskList', props: ['mode', 'title'], template: '<section><slot name="status-tabs" /></section>' },
|
||||
}))
|
||||
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
||||
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
|
||||
@@ -51,6 +54,7 @@ function mountView() {
|
||||
directives: { loading: () => undefined },
|
||||
stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
InboundTaskList: false,
|
||||
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
|
||||
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue'], emits: ['update:modelValue', 'search', 'reset'], template: '<div data-testid="query-toolbar" />' },
|
||||
DataTable: { name: 'DataTable', props: ['page', 'pageSize', 'data'], emits: ['update:page', 'update:pageSize', 'selection-change'], template: '<div data-testid="data-table"><slot name="toolbar-start" /><slot name="toolbar-actions" /><slot name="filters" /><slot name="notices" /><div v-for="row in data" :key="row.orderId" class="test-row"><slot name="estimatedVolume" :row="row" /><slot name="expectedArrival" :row="row" /></div></div>' },
|
||||
@@ -94,6 +98,25 @@ describe('UnifiedOrdersView list request coordination', () => {
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('opens the existing measurement review list beside measurement and returns to order queries', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
const labels = wrapper.findAll('[role="tab"]').map((tab) => tab.text())
|
||||
expect(labels.indexOf('测量数据审核')).toBe(labels.indexOf('待测量数据') + 1)
|
||||
|
||||
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '测量数据审核')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'InboundTaskList' }).props()).toMatchObject({ mode: 'review', title: '订单列表' })
|
||||
expect(wrapper.get('[aria-selected="true"]').text()).toBe('测量数据审核')
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1)
|
||||
|
||||
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '待测量数据')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'InboundTaskList' }).exists()).toBe(false)
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ mainStatus: 'WAIT_MEASURE' }))
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not apply draft filters to pagination until query is submitted', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
|
||||
it('exposes the product main statuses including dropship palletizing', () => {
|
||||
expect(unifiedOrderStatusOptions.map((item) => item.label)).toEqual([
|
||||
'待审核', '待测量数据', '待一件代发组托', '待分柜', '装柜中', '装柜完成', '派送完成', '已完成',
|
||||
'待审核', '待测量数据', '一件代发打托', '待分柜', '装柜中', '装柜完成', '派送完成', '已完成',
|
||||
])
|
||||
for (const legacyLabel of ['待客户确认', '差异处理中', '派送中', '进入分柜']) {
|
||||
expect(source).not.toContain(legacyLabel)
|
||||
@@ -72,7 +72,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
it('renders accessible current-status tabs with keyboard navigation', () => {
|
||||
expect(source).toContain('role="tablist"')
|
||||
expect(source).toContain('role="tab"')
|
||||
expect(source).toContain(':aria-selected="selectedStatus === tab.value"')
|
||||
expect(source).toContain(':aria-selected="selectedTab === tab.value"')
|
||||
expect(source).toContain("event.key === 'ArrowRight'")
|
||||
expect(source).toContain("event.key === 'ArrowLeft'")
|
||||
expect(source).toContain("event.key === 'Home'")
|
||||
@@ -237,7 +237,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
})
|
||||
|
||||
it('keeps the title, search, errors and results in the integrated list', () => {
|
||||
expect(source).toContain('<ListPageLayout ariaLabel="统一订单列表">')
|
||||
expect(source).toContain('<ListPageLayout v-else ariaLabel="统一订单列表">')
|
||||
expect(source).toContain('<template #filters>')
|
||||
expect(source).toContain('<template #notices>')
|
||||
expect(source).toContain('<template #default>')
|
||||
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
updateMeasurementOuterDimensions,
|
||||
InboundFeePanel,
|
||||
InboundMeasurementDialog,
|
||||
InboundTaskList,
|
||||
MeasurementEvidenceThumbnail,
|
||||
DropshipPalletPrintPanel,
|
||||
sendInboundFee,
|
||||
@@ -322,6 +323,9 @@ const showAdvancedFilters = ref(false)
|
||||
const filters = ref<FilterModel>(emptyFilters())
|
||||
const appliedFilters = ref<FilterModel>(emptyFilters())
|
||||
const selectedStatus = ref<UnifiedOrderMainStatus | ''>('')
|
||||
const measurementReviewSelected = ref(false)
|
||||
const selectedTab = computed(() => measurementReviewSelected.value ? 'MEASUREMENT_REVIEW' : selectedStatus.value)
|
||||
const statusTabList = ref<HTMLElement | null>(null)
|
||||
const businessInboundCode = computed(() => route.name === 'unified-order-detail-by-inbound-code' && typeof route.params.inboundCode === 'string'
|
||||
? route.params.inboundCode.trim()
|
||||
: '')
|
||||
@@ -584,7 +588,9 @@ async function waitForSavedMeasurementVersion(tenantId: string, inboundId: strin
|
||||
|
||||
const statusTabs = computed(() => [
|
||||
{ value: '' as const, label: '全部' },
|
||||
...unifiedOrderStatusOptions,
|
||||
...unifiedOrderStatusOptions.flatMap((tab) => tab.value === 'WAIT_MEASURE' && can('firstleg:inbound:measurement-review')
|
||||
? [tab, { value: 'MEASUREMENT_REVIEW' as const, label: '测量数据审核' }]
|
||||
: [tab]),
|
||||
])
|
||||
|
||||
const warehouseOptions = computed(() => warehouses.value.map((warehouse) => ({
|
||||
@@ -706,8 +712,11 @@ function optionalBoolean(value: string | null | undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function selectStatus(status: UnifiedOrderMainStatus | '') {
|
||||
if (selectedStatus.value === status) return
|
||||
function selectStatus(status: UnifiedOrderMainStatus | '' | 'MEASUREMENT_REVIEW') {
|
||||
if (selectedTab.value === status) return
|
||||
measurementReviewSelected.value = status === 'MEASUREMENT_REVIEW'
|
||||
if (measurementReviewSelected.value) selectedOrders.value = []
|
||||
if (status === 'MEASUREMENT_REVIEW') return
|
||||
selectedStatus.value = status
|
||||
if (page.value === 1) void loadOrders()
|
||||
else page.value = 1
|
||||
@@ -724,8 +733,9 @@ function handleTabKeydown(event: KeyboardEvent, index: number) {
|
||||
const tab = statusTabs.value[nextIndex]
|
||||
if (!tab) return
|
||||
selectStatus(tab.value)
|
||||
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
buttons?.[nextIndex]?.focus()
|
||||
void nextTick(() => {
|
||||
statusTabList.value?.querySelectorAll<HTMLElement>('[role="tab"]')[nextIndex]?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function can(permission: string) {
|
||||
@@ -1574,6 +1584,7 @@ async function loadWarehouses() {
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
if (measurementReviewSelected.value) return
|
||||
selectedApprovalOrders.value = []
|
||||
selectedOrders.value = []
|
||||
loading.value = true
|
||||
@@ -2691,7 +2702,27 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<ListPageLayout ariaLabel="统一订单列表">
|
||||
<div v-if="measurementReviewSelected" id="unified-orders-results" role="tabpanel">
|
||||
<InboundTaskList mode="review" title="订单列表">
|
||||
<template #status-tabs>
|
||||
<div ref="statusTabList" class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态">
|
||||
<button
|
||||
v-for="(tab, index) in statusTabs"
|
||||
:key="tab.value || 'ALL'"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="selectedTab === tab.value"
|
||||
aria-controls="unified-orders-results"
|
||||
:tabindex="selectedTab === tab.value ? 0 : -1"
|
||||
:class="['status-tab', { 'is-active': selectedTab === tab.value }]"
|
||||
@click="selectStatus(tab.value)"
|
||||
@keydown="handleTabKeydown($event, index)"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</InboundTaskList>
|
||||
</div>
|
||||
<ListPageLayout v-else ariaLabel="统一订单列表">
|
||||
|
||||
|
||||
|
||||
@@ -2701,16 +2732,16 @@ onBeforeUnmount(() => {
|
||||
<template #toolbar-start><h1>订单列表</h1></template>
|
||||
<template #filters>
|
||||
<div class="orders-query-panel integrated-query-group">
|
||||
<div class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态">
|
||||
<div ref="statusTabList" class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态">
|
||||
<button
|
||||
v-for="(tab, index) in statusTabs"
|
||||
:key="tab.value || 'ALL'"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="selectedStatus === tab.value"
|
||||
:aria-selected="selectedTab === tab.value"
|
||||
aria-controls="unified-orders-results"
|
||||
:tabindex="selectedStatus === tab.value ? 0 : -1"
|
||||
:class="['status-tab', { 'is-active': selectedStatus === tab.value }]"
|
||||
:tabindex="selectedTab === tab.value ? 0 : -1"
|
||||
:class="['status-tab', { 'is-active': selectedTab === tab.value }]"
|
||||
@click="selectStatus(tab.value)"
|
||||
@keydown="handleTabKeydown($event, index)"
|
||||
>{{ tab.label }}</button>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
describe('unified order status labels', () => {
|
||||
it('maps order, review and loading statuses to domain-specific Chinese', () => {
|
||||
expect(unifiedOrderStatusLabel('WAIT_PALLETIZE')).toBe('待一件代发组托')
|
||||
expect(unifiedOrderStatusLabel('WAIT_PALLETIZE')).toBe('一件代发打托')
|
||||
expect(unifiedOrderStatusLabel('WAIT_SPLIT')).toBe('待分柜')
|
||||
expect(unifiedOrderReviewStatusLabel('APPROVED')).toBe('审核通过')
|
||||
expect(unifiedOrderLoadingStatusLabel('LOADED')).toBe('已配载')
|
||||
|
||||
@@ -9,7 +9,7 @@ const orderStatusLabels: Record<string, string> = {
|
||||
CANCELLED: '已取消',
|
||||
PENDING_REVIEW: '待审核',
|
||||
WAIT_MEASURE: '待测量数据',
|
||||
WAIT_PALLETIZE: '待一件代发组托',
|
||||
WAIT_PALLETIZE: '一件代发打托',
|
||||
WAIT_SPLIT: '待分柜',
|
||||
LOADING: '装柜中',
|
||||
LOADED: '装柜完成',
|
||||
|
||||
@@ -71,7 +71,6 @@ const generalCatalogConsumers = [
|
||||
'overseas-master-data/components/CapacityInquiryTable.vue',
|
||||
'overseas-master-data/components/CapacityOfferSettingsDialog.vue',
|
||||
'overseas-master-data/pages/TripOperationsView.vue',
|
||||
'overseas-master-data/components/TripOperationsCharts.vue',
|
||||
'overseas-master-data/pages/TripDetailView.vue',
|
||||
'overseas-master-data/components/TripPlanDialog.vue',
|
||||
'overseas-master-data/pages/DriverDetailView.vue',
|
||||
@@ -200,7 +199,8 @@ describe('approved Admin density ownership', () => {
|
||||
expect(declarations('.detail-events')['overflow-y']).toBe('auto')
|
||||
expect(declarations('.detail-event dd')['overflow-wrap']).toBe('anywhere')
|
||||
expect(panel.template!.content).toContain('.slice(0, 3)')
|
||||
expect(panel.template!.content).not.toContain('新增预约')
|
||||
expect(panel.template!.content).toContain('v-if="canCreate"')
|
||||
expect(panel.template!.content).toContain('新增预约')
|
||||
})
|
||||
it('shares list status and category tab styles across registered list consumers', () => {
|
||||
for (const file of [
|
||||
|
||||
Reference in New Issue
Block a user