feat: add appointment calendar and cross-customer booking
This commit is contained in:
@@ -33,6 +33,7 @@ import { overseasBusinessRoutes } from '@/modules/overseas-business/routes'
|
||||
import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes'
|
||||
import { pdaReleaseRoutes } from '@/modules/pda-releases/routes'
|
||||
import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes'
|
||||
import { appointmentCalendarRoutes } from '@/modules/appointment-calendar/routes'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -79,6 +80,7 @@ const router = createRouter({
|
||||
...iamRoutes,
|
||||
...pdaReleaseRoutes,
|
||||
...sailingCalendarRoutes,
|
||||
...appointmentCalendarRoutes,
|
||||
{
|
||||
path: '403',
|
||||
name: 'forbidden',
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('adminNavigation', () => {
|
||||
{ title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] },
|
||||
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
|
||||
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
|
||||
{ title: '预约管理', children: ['预约列表'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] },
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
|
||||
{ title: '客户服务', children: ['异常订单'] },
|
||||
@@ -29,8 +30,8 @@ describe('adminNavigation', () => {
|
||||
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] },
|
||||
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
||||
])
|
||||
expect(visible).toHaveLength(11)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(51)
|
||||
expect(visible).toHaveLength(12)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(52)
|
||||
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -118,6 +118,14 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'appointment-management',
|
||||
title: '预约管理',
|
||||
icon: CalendarDays,
|
||||
children: [
|
||||
{ key: 'appointment-calendar', title: '预约列表', icon: CalendarDays, path: '/appointment-calendar', permission: 'platform:appointment:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'vehicle-management',
|
||||
title: '车辆管理',
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
|
||||
describe('appointment calendar interactions', () => {
|
||||
const render = () => mount(AppointmentCalendarPanel, {
|
||||
props: { initialDate: '2026-09-24', canCreate: true, events: [
|
||||
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00-11:00', title: 'ONT8 预约送货', fields: [], canEdit: true, canDelete: true },
|
||||
] },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
|
||||
})
|
||||
|
||||
it('selects an already loaded date without requesting data again', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.get('[aria-label="2026-09-25,0 项安排"]').trigger('click')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无到港或送仓预约')
|
||||
})
|
||||
|
||||
it('requests exactly one range for a view switch and navigates seven days', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
|
||||
expect(wrapper.emitted('range')).toEqual([[{ from: '2026-09-21', to: '2026-09-28' }]])
|
||||
await wrapper.get('[aria-label="下一周"]').trigger('click')
|
||||
expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-09-28', to: '2026-10-05' }])
|
||||
})
|
||||
|
||||
it('uses the selected day for creation and emits edit/delete only from detail actions', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.findAll('button').find(button => button.text() === '+ 新增预约')!.trigger('click')
|
||||
expect(wrapper.emitted('create')).toEqual([['2026-09-24']])
|
||||
await wrapper.findAll('button').find(button => button.text() === '编辑')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '删除')!.trigger('click')
|
||||
expect(wrapper.emitted('edit')?.[0]?.[0]).toMatchObject({ key: 'a' })
|
||||
expect(wrapper.emitted('delete')?.[0]?.[0]).toMatchObject({ key: 'a' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref } from 'vue'
|
||||
import { addDays, calendarDates, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
|
||||
|
||||
export interface CalendarEvent {
|
||||
key: string
|
||||
date: string
|
||||
time: string
|
||||
type: 'arrival' | 'delivery'
|
||||
title: string
|
||||
subtitle?: string
|
||||
fields: Array<{ label: string; value?: string | null }>
|
||||
canEdit?: boolean
|
||||
canDelete?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
events: CalendarEvent[]
|
||||
loading?: boolean
|
||||
canCreate?: boolean
|
||||
initialDate?: string
|
||||
}>(), { loading: false, canCreate: false, initialDate: '' })
|
||||
const emit = defineEmits<{
|
||||
range: [range: { from: string; to: string }]
|
||||
create: [date: string]
|
||||
edit: [event: CalendarEvent]
|
||||
delete: [event: CalendarEvent]
|
||||
}>()
|
||||
const selectedDate = ref(props.initialDate || dateKey(new Date()))
|
||||
const mode = ref<CalendarMode>('month')
|
||||
const dates = computed(() => calendarDates(selectedDate.value, mode.value))
|
||||
const grouped = computed(() => {
|
||||
const result = new Map<string, CalendarEvent[]>()
|
||||
for (const event of props.events) {
|
||||
const group = result.get(event.date) || []
|
||||
group.push(event)
|
||||
result.set(event.date, group)
|
||||
}
|
||||
for (const group of result.values()) group.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
|
||||
return result
|
||||
})
|
||||
const selectedEvents = computed(() => grouped.value.get(selectedDate.value) || [])
|
||||
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
const heading = computed(() => mode.value === 'month'
|
||||
? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月`
|
||||
: `${dates.value[0]} — ${addDays(calendarRange(selectedDate.value, mode.value).to, -1)}`)
|
||||
function select(date: string) {
|
||||
const before = calendarRange(selectedDate.value, mode.value)
|
||||
selectedDate.value = date
|
||||
const after = calendarRange(date, mode.value)
|
||||
if (before.from !== after.from || before.to !== after.to) emit('range', after)
|
||||
}
|
||||
function changeMode(next: CalendarMode) {
|
||||
if (mode.value === next) return
|
||||
mode.value = next
|
||||
emit('range', calendarRange(selectedDate.value, next))
|
||||
}
|
||||
defineExpose({ selectedDate, mode })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="appointment-calendar admin-density" :aria-busy="loading">
|
||||
<div class="calendar-main">
|
||||
<header class="calendar-toolbar">
|
||||
<div class="calendar-label">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M8 3v4M16 3v4M3 10h18M8 15h3M14 15h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
|
||||
<strong>预约日历</strong>
|
||||
<span class="legend arrival">船舶到港</span><span class="legend delivery">预约送仓</span>
|
||||
</div>
|
||||
<div class="calendar-controls">
|
||||
<el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</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>
|
||||
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '下个月' : '下一周'" @click="select(moveCalendar(selectedDate, mode, 1))">›</button>
|
||||
<el-button @click="select(dateKey(new Date()))">今天</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="weekdays"><span v-for="day in weekdays" :key="day">{{ day }}</span></div>
|
||||
<div v-loading="loading" class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
|
||||
<button v-for="date in dates" :key="date" type="button" class="calendar-day"
|
||||
:class="{ selected: selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
|
||||
:aria-label="`${date},${(grouped.get(date) || []).length} 项安排`" :aria-pressed="selectedDate === date" @click="select(date)">
|
||||
<span class="day-number">{{ mode === 'week' ? `${Number(date.slice(5, 7))}月` : '' }}{{ Number(date.slice(8)) }}</span>
|
||||
<span class="day-events">
|
||||
<span v-for="event in (grouped.get(date) || []).slice(0, mode === 'month' ? 3 : 12)" :key="event.key" class="event-chip" :class="event.type" :title="`${event.time} ${event.title}`">
|
||||
{{ event.title }}<small v-if="mode === 'week'">{{ event.time }}</small>
|
||||
</span>
|
||||
<span v-if="(grouped.get(date) || []).length > (mode === 'month' ? 3 : 12)" class="event-more">另有 {{ (grouped.get(date) || []).length - (mode === 'month' ? 3 : 12) }} 项</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="calendar-detail" aria-label="当天安排">
|
||||
<header><strong>{{ selectedDate }}</strong><p>当天共有 <b>{{ selectedEvents.length }}</b> 项安排</p></header>
|
||||
<div class="detail-events" aria-live="polite">
|
||||
<p v-if="!selectedEvents.length && !loading" class="empty-day">当天暂无到港或送仓预约</p>
|
||||
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
|
||||
<div class="event-heading"><span class="event-type" :class="event.type">{{ event.type === 'arrival' ? '船舶到港' : '预约送仓' }}</span><span>{{ event.time || '—' }}</span></div>
|
||||
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
|
||||
<dl><div v-for="field in event.fields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
|
||||
<footer v-if="event.canEdit || event.canDelete" class="density-actions">
|
||||
<el-button v-if="event.canEdit" class="density-action" :disabled="loading" @click="emit('edit', event)">编辑</el-button>
|
||||
<el-button v-if="event.canDelete" class="density-action" data-tone="danger" :disabled="loading" @click="emit('delete', event)">删除</el-button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.appointment-calendar { display: grid; grid-template-columns: minmax(0, 2.5fr) minmax(320px, 1fr); gap: 16px; color: #344054; }
|
||||
.calendar-main, .calendar-detail { background: #fff; border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
|
||||
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; padding: 16px; border-bottom: 1px solid #edf0f6; }
|
||||
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: 10px; }
|
||||
.calendar-label > svg { color: #617ce9; }
|
||||
.calendar-label > strong { font-size: 16px; }
|
||||
.legend { font-size: 12px; white-space: nowrap; }
|
||||
.legend::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; background: #4b78e8; }
|
||||
.legend.delivery::before { background: #27a87a; }
|
||||
.view-toggle { display: flex; border: 1px solid #dbe3ee; border-radius: 6px; padding: 2px; background: #f7f9fc; }
|
||||
.view-toggle button, .nav-button { border: 0; border-radius: 5px; background: transparent; cursor: pointer; color: #64748b; min-width: 30px; height: 30px; }
|
||||
.view-toggle button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 700; }
|
||||
.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
|
||||
.calendar-period { font-size: 14px; white-space: nowrap; }
|
||||
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: 12px 14px; font-size: 13px; }
|
||||
.calendar-grid { gap: 7px; padding: 12px; background: #fbfcff; }
|
||||
.calendar-day { min-height: 100px; padding: 8px; border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; overflow: hidden; }
|
||||
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; }
|
||||
.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
|
||||
.calendar-day:focus-visible, .nav-button:focus-visible, .view-toggle button:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
|
||||
.day-number { display: block; font-size: 12px; font-weight: 600; }
|
||||
.day-events { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
|
||||
.event-chip { display: block; padding: 4px 6px; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }
|
||||
.delivery { --event-bg: #e8f8f1; --event-fg: #18845f; }
|
||||
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }
|
||||
.event-chip small { display: block; margin-top: 4px; }
|
||||
.event-more, .empty-day { color: #667085; font-size: 12px; }
|
||||
.week-grid .calendar-day { min-height: 350px; align-content: start; }
|
||||
.calendar-detail { align-self: start; }
|
||||
.calendar-detail > header { padding: 18px; border-bottom: 1px solid #edf0f6; }
|
||||
.calendar-detail > header > strong { font-size: 17px; }
|
||||
.calendar-detail header p { margin: 10px 0 0; font-size: 13px; color: #8491a5; }
|
||||
.calendar-detail header b { color: #617ce9; font-size: 17px; }
|
||||
.detail-events { padding: 14px; max-height: 650px; overflow-y: auto; }
|
||||
.detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: 16px; padding-top: 16px; }
|
||||
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 12px; }
|
||||
.event-type { padding: 5px 8px; border-radius: 4px; font-weight: 600; }
|
||||
.detail-event h3 { font-size: 15px; margin: 14px 0 8px; overflow-wrap: anywhere; }
|
||||
.event-subtitle { color: #667085; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 16px 0; }
|
||||
.detail-event dt { font-size: 12px; color: #8491a5; }
|
||||
.detail-event dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.detail-event footer { display: flex; justify-content: flex-end; border-top: 1px solid #edf0f6; padding-top: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { createFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse'
|
||||
import { listArrivalCalendar } from './api'
|
||||
|
||||
vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), deleteAppointment: vi.fn() }))
|
||||
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', () => ({
|
||||
createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(),
|
||||
listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(),
|
||||
overseasWarehousePermissions: { inboundOrderAppointment: 'write' },
|
||||
}))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
|
||||
const candidates = [
|
||||
{ lineId: '9007199254740993', customerId: 'A', fbaWarehouseCode: 'ONT8', planId: '1', planNo: 'IN-A', orderId: '10' },
|
||||
{ lineId: '9007199254740994', customerId: 'B', fbaWarehouseCode: 'ONT8', planId: '2', planNo: 'IN-B', orderId: '20' },
|
||||
]
|
||||
function render() {
|
||||
return mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
AppointmentCalendarPanel: { template: '<button data-create @click="$emit(\'create\', \'2026-10-09\')">新增预约</button>' },
|
||||
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /><button data-save @click="$emit(\'confirm\')">保存</button></div>' },
|
||||
ElForm: { template: '<form><slot /></form>' },
|
||||
ElFormItem: { template: '<label><slot /></label>' },
|
||||
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
ElDatePicker: { props: ['modelValue'], template: '<input data-date :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
ElButton: { template: '<button><slot /></button>' },
|
||||
ElSelect: true, ElOption: true,
|
||||
DataTable: { props: ['data'], template: '<div><slot name="toolbar-actions" /><button data-select @click="$emit(\'selection-change\', data)">选择货物</button></div>' },
|
||||
LoadErrorState: true, ApiErrorMeta: true,
|
||||
} } })
|
||||
}
|
||||
|
||||
describe('appointment calendar data integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([])
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValue([])
|
||||
vi.mocked(listInboundOrders).mockResolvedValue({ records: candidates, total: 2, page: 1, size: 10, pages: 1 })
|
||||
vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', appointmentAt: '2026-10-09T09:00:00', expectedLoadingAt: '2026-10-09T07:00:00', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] })
|
||||
})
|
||||
|
||||
it('creates one shared appointment for two customers without sending a fabricated header customer', async () => {
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
|
||||
expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
|
||||
expect(listInboundOrders).not.toHaveBeenCalled()
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
await wrapper.get('input[placeholder="例如 ONT8"]').setValue('ONT8')
|
||||
await wrapper.findAll('button').find(button => button.text() === '查询可预约货物')!.trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-select]').trigger('click')
|
||||
await wrapper.get('input[placeholder="请输入预约编号"]').setValue('APT-AB')
|
||||
await wrapper.findAll('[data-date]')[2]!.setValue('2026-10-09T07:00:00')
|
||||
await wrapper.get('[data-save]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(createFbaAppointment).toHaveBeenCalledTimes(1)
|
||||
expect(createFbaAppointment).toHaveBeenCalledWith(expect.objectContaining({
|
||||
appointmentNo: 'APT-AB', lineIds: ['9007199254740993', '9007199254740994'],
|
||||
}))
|
||||
expect(vi.mocked(createFbaAppointment).mock.calls[0]![0]).not.toHaveProperty('customerId')
|
||||
expect(listInboundOrders).toHaveBeenCalledTimes(1)
|
||||
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import {
|
||||
createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar, listInboundOrders, updateFbaAppointment,
|
||||
overseasWarehousePermissions as permission,
|
||||
type FbaAppointmentDto, type BatchFbaAppointmentPayload, type InboundOrderSummaryDto,
|
||||
} from '@/modules/overseas-warehouse'
|
||||
import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.vue'
|
||||
import { calendarRange, dateKey } from './calendar'
|
||||
import { deleteAppointment, listArrivalCalendar, type ArrivalCalendarEntry } from './api'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const appointments = ref<FbaAppointmentDto[]>([])
|
||||
const arrivals = ref<ArrivalCalendarEntry[]>([])
|
||||
const range = ref(calendarRange(dateKey(new Date()), 'month'))
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const dialogError = ref<ApiErrorDetails | null>(null)
|
||||
const dialogOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailReady = ref(false)
|
||||
const editing = ref<FbaAppointmentDto | null>(null)
|
||||
const warehouse = ref('')
|
||||
const candidates = ref<InboundOrderSummaryDto[]>([])
|
||||
const selected = ref<InboundOrderSummaryDto[]>([])
|
||||
const candidateLoading = ref(false)
|
||||
const candidateError = ref<ApiErrorDetails | null>(null)
|
||||
const candidatePage = ref(1)
|
||||
const candidateSize = ref(10)
|
||||
const candidateTotal = ref(0)
|
||||
const searchedWarehouse = ref('')
|
||||
const selectionEpoch = ref(0)
|
||||
let calendarController: AbortController | undefined
|
||||
let calendarRequest = 0
|
||||
let candidateRequest = 0
|
||||
let detailRequest = 0
|
||||
const form = reactive({ appointmentEndAt: '', appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'PENDING' as BatchFbaAppointmentPayload['appointmentStatus'], appointmentRemark: '' })
|
||||
const statusLabels: Record<string, string> = { PENDING: '预约中', SUCCESS: '预约成功', FAILED: '预约失败', CANCELLED: '已取消' }
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'selection', type: 'selection', reserveSelection: true, width: 46, selectable: (row: InboundOrderSummaryDto) => row.fbaWarehouseCode === searchedWarehouse.value && (!row.appointmentStatus || ['FAILED', 'CANCELLED'].includes(row.appointmentStatus)) },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 150 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 150 },
|
||||
{ prop: 'fbaWarehouseCode', label: '目的仓', width: 110 },
|
||||
{ prop: 'overseasWarehouseName', label: '海外仓', minWidth: 150 },
|
||||
{ prop: 'packageCount', label: '箱数', width: 80 },
|
||||
{ prop: 'palletCount', label: '托数', width: 80 },
|
||||
{ prop: 'appointmentNo', label: '已有预约号', minWidth: 160 },
|
||||
]
|
||||
const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival => ({
|
||||
key: `arrival-${arrival.containerBookingId}`, type: 'arrival' as const,
|
||||
date: arrival.arrivalAt.slice(0, 10), time: arrival.arrivalAt.slice(11, 16),
|
||||
title: `${arrival.voyageNo || arrival.bookingNo} ${arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港'}`,
|
||||
fields: [
|
||||
{ label: '船名 / 航次', value: [arrival.vesselName, arrival.voyageNo].filter(Boolean).join(' / ') },
|
||||
{ label: '目的港', value: arrival.destinationPortName },
|
||||
{ label: '已装货柜号', value: arrival.containerNo },
|
||||
{ label: '柜次号', value: arrival.bookingNo },
|
||||
{ label: '货物去向', value: arrival.destinationWarehouseCode },
|
||||
],
|
||||
})), ...appointments.value.map(appointment => ({
|
||||
key: appointment.appointmentNo, type: 'delivery' as const, date: appointment.appointmentAt.slice(0, 10),
|
||||
time: appointment.appointmentAt.slice(11, 16) + (appointment.appointmentEndAt ? `–${appointment.appointmentEndAt.slice(11, 16)}` : ''), title: `${appointment.fbaWarehouseCode} 预约送仓`,
|
||||
fields: [
|
||||
{ label: '预约仓库', value: appointment.fbaWarehouseCode },
|
||||
{ label: '预约编号', value: appointment.appointmentNo },
|
||||
{ label: '预约状态', value: statusLabels[appointment.appointmentStatus] || '—' },
|
||||
{ label: '货物信息', value: `${appointment.selectedCount} 票 / ${new Set(appointment.items.map(item => item.customerId)).size} 个客户` },
|
||||
{ label: '入仓码', value: appointment.items.map(item => item.inboundCode).filter(Boolean).join('、') },
|
||||
{ label: '预约备注', value: appointment.appointmentRemark },
|
||||
],
|
||||
canEdit: access.can(permission.inboundOrderAppointment),
|
||||
canDelete: access.can('platform:appointment:delete'),
|
||||
}))])
|
||||
|
||||
async function load(nextRange = range.value) {
|
||||
range.value = nextRange
|
||||
const request = ++calendarRequest
|
||||
calendarController?.abort()
|
||||
calendarController = new AbortController()
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
appointments.value = []
|
||||
arrivals.value = []
|
||||
try {
|
||||
const [result, arrivalResult] = await Promise.all([
|
||||
listFbaAppointmentCalendar(nextRange, calendarController.signal),
|
||||
listArrivalCalendar(nextRange, calendarController.signal),
|
||||
])
|
||||
if (request === calendarRequest) { appointments.value = result; arrivals.value = arrivalResult }
|
||||
} catch (error) {
|
||||
if (request === calendarRequest) loadError.value = getApiErrorDetails(error, '预约日历加载失败')
|
||||
} finally { if (request === calendarRequest) loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate(date: string) {
|
||||
editing.value = null
|
||||
warehouse.value = ''
|
||||
searchedWarehouse.value = ''
|
||||
candidates.value = []
|
||||
selected.value = []
|
||||
selectionEpoch.value++
|
||||
candidateTotal.value = 0
|
||||
candidateError.value = null
|
||||
dialogError.value = null
|
||||
Object.assign(form, { appointmentEndAt: '', appointmentNo: '', appointmentAt: `${date}T09:00:00`, expectedLoadingAt: '', appointmentStatus: 'PENDING', appointmentRemark: '' })
|
||||
dialogOpen.value = true
|
||||
detailReady.value = true
|
||||
}
|
||||
|
||||
async function openEdit(event: CalendarEvent) {
|
||||
const request = ++detailRequest
|
||||
dialogError.value = null
|
||||
editing.value = appointments.value.find(item => item.appointmentNo === event.key) || null
|
||||
if (!editing.value) return
|
||||
dialogOpen.value = true
|
||||
detailLoading.value = true
|
||||
detailReady.value = false
|
||||
try {
|
||||
const detail = await getFbaAppointment(event.key)
|
||||
if (request !== detailRequest || !dialogOpen.value) return
|
||||
editing.value = detail
|
||||
detailReady.value = true
|
||||
warehouse.value = detail.fbaWarehouseCode
|
||||
Object.assign(form, { appointmentEndAt: detail.appointmentEndAt || '', appointmentNo: detail.appointmentNo, appointmentAt: detail.appointmentAt,
|
||||
expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: detail.appointmentStatus,
|
||||
appointmentRemark: detail.appointmentRemark || '' })
|
||||
} catch (error) {
|
||||
if (request === detailRequest) dialogError.value = getApiErrorDetails(error, '预约详情加载失败')
|
||||
} finally { if (request === detailRequest) detailLoading.value = false }
|
||||
}
|
||||
|
||||
async function loadCandidates(search = false) {
|
||||
if (search) {
|
||||
searchedWarehouse.value = warehouse.value.trim().toUpperCase()
|
||||
candidatePage.value = 1
|
||||
selected.value = []
|
||||
selectionEpoch.value++
|
||||
}
|
||||
if (!searchedWarehouse.value) { ElMessage.warning('请先输入目的仓编码'); return }
|
||||
candidateLoading.value = true
|
||||
candidateError.value = null
|
||||
const request = ++candidateRequest
|
||||
try {
|
||||
const result = await listInboundOrders({ page: candidatePage.value, size: candidateSize.value, fbaWarehouseCode: searchedWarehouse.value })
|
||||
if (request !== candidateRequest || !dialogOpen.value) return
|
||||
candidates.value = result.records
|
||||
candidateTotal.value = result.total
|
||||
} catch (error) {
|
||||
if (request === candidateRequest) {
|
||||
candidates.value = []
|
||||
candidateError.value = getApiErrorDetails(error, '可预约货物加载失败')
|
||||
}
|
||||
} finally { if (request === candidateRequest) candidateLoading.value = false }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving.value || detailLoading.value || !detailReady.value) return
|
||||
if (!form.appointmentNo.trim() || !form.appointmentAt || !form.expectedLoadingAt) {
|
||||
ElMessage.warning('请填写预约号、预约送仓时间和预计装车时间'); return
|
||||
}
|
||||
if (!editing.value) {
|
||||
if (!selected.value.length) { ElMessage.warning('请选择需要预约的货物'); return }
|
||||
if (new Set(selected.value.map(item => item.fbaWarehouseCode)).size !== 1) {
|
||||
ElMessage.warning('同一预约的货物必须送往同一目的仓'); return
|
||||
}
|
||||
}
|
||||
saving.value = true
|
||||
dialogError.value = null
|
||||
try {
|
||||
const common = { appointmentEndAt: form.appointmentEndAt || null, appointmentAt: form.appointmentAt, expectedLoadingAt: form.expectedLoadingAt,
|
||||
appointmentStatus: form.appointmentStatus, appointmentRemark: form.appointmentRemark.trim() || undefined }
|
||||
if (editing.value) await updateFbaAppointment(editing.value.appointmentNo, { ...common, version: editing.value.version })
|
||||
else await createFbaAppointment({ ...common, appointmentNo: form.appointmentNo.trim(), lineIds: selected.value.map(item => item.lineId) })
|
||||
dialogOpen.value = false
|
||||
ElMessage.success('预约已保存')
|
||||
await load()
|
||||
} catch (error) { dialogError.value = getApiErrorDetails(error, '预约保存失败,请核对后重试') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
function selectCandidates(rows: object[]) {
|
||||
selected.value = rows as InboundOrderSummaryDto[]
|
||||
}
|
||||
|
||||
async function remove(event: CalendarEvent) {
|
||||
const appointment = appointments.value.find(item => item.appointmentNo === event.key)
|
||||
if (!appointment || loading.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除预约 ${appointment.appointmentNo}?该操作会解除全部 ${appointment.selectedCount} 票货物的预约关联。`, '删除预约',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', customClass: 'admin-density-confirm' })
|
||||
} catch { return }
|
||||
loading.value = true
|
||||
try {
|
||||
await deleteAppointment(appointment.appointmentNo, appointment.version)
|
||||
ElMessage.success('预约已删除')
|
||||
await load()
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorDetails(error, '预约删除失败')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => load())
|
||||
onBeforeUnmount(() => { ++calendarRequest; ++candidateRequest; ++detailRequest; calendarController?.abort() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density">
|
||||
<LoadErrorState v-if="loadError" title="预约日历加载失败" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<AppointmentCalendarPanel :events="events" :loading="loading" :can-create="access.can(permission.inboundOrderAppointment)" @range="load" @create="openCreate" @edit="openEdit" @delete="remove" />
|
||||
<AppDialog v-model="dialogOpen" class="admin-density-dialog" :title="editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="saving" :confirm-disabled="detailLoading" @confirm="save">
|
||||
<LoadErrorState v-if="dialogError" title="预约操作失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
|
||||
<div v-loading="detailLoading">
|
||||
<el-form label-position="top" class="calendar-form density-form">
|
||||
<el-form-item label="预约送仓时间" required><el-date-picker v-model="form.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||
<el-form-item label="预约结束时间"><el-date-picker v-model="form.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||
<el-form-item label="预计装车时间" required><el-date-picker v-model="form.expectedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||
<el-form-item label="预约仓库" required><el-input v-model="warehouse" :readonly="!!editing" placeholder="例如 ONT8" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="预约编号" required><el-input v-model="form.appointmentNo" :readonly="!!editing" maxlength="64" placeholder="请输入预约编号" /></el-form-item>
|
||||
<el-form-item label="预约状态" required><el-select v-model="form.appointmentStatus"><el-option v-for="(label, value) in statusLabels" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
||||
<el-form-item label="预约备注"><el-input v-model="form.appointmentRemark" maxlength="1000" placeholder="请输入预约备注(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<template v-if="!editing">
|
||||
<DataTable :key="selectionEpoch" v-model:page="candidatePage" v-model:page-size="candidateSize" :data="candidates" :columns="columns" :total="candidateTotal" :loading="candidateLoading" row-key="lineId" :results-visible="!candidateError" @selection-change="selectCandidates" @update:page="loadCandidates()" @update:page-size="candidatePage = 1; loadCandidates()">
|
||||
<template #toolbar-actions><el-button type="primary" :loading="candidateLoading" @click="loadCandidates(true)">查询可预约货物</el-button></template>
|
||||
<template #notices><LoadErrorState v-if="candidateError" title="货物加载失败" :message="candidateError.message" @retry="loadCandidates()" /></template>
|
||||
<template #pagination-left>已选 {{ selected.length }} 票,可跨客户选择同一目的仓货物</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
<div v-else class="appointment-cargo">{{ editing.selectedCount }} 票货物 · 入仓码:{{ editing.items.map(item => item.inboundCode).join('、') || '—' }}</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
|
||||
.calendar-form :deep(.el-date-editor) { width: 100%; }
|
||||
.appointment-cargo { padding: 12px 0; line-height: 1.7; overflow-wrap: anywhere; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
# 预约管理交互矩阵
|
||||
|
||||
原型:用户提供 `C:/Users/BY-OY/Desktop/新版.html` 的 `page-appointment-calendar`、`modal-appointment-edit`、`renderAppointmentCalendar`、`renderAppointmentDay`、`openAppointmentModal`、`deleteAppointment`,以及本对话三张截图。
|
||||
|
||||
用户确认:同一目的仓允许多个客户货物统一预约、统一送仓,不能按客户拆预约。客户隔离保留在货物明细。
|
||||
|
||||
| 页面/操作 | 原型承载与布局 | 实现约束 |
|
||||
|---|---|---|
|
||||
| 预约列表 | 左侧月/周日历,右侧选中日期详情 | 保留双栏日历;到港蓝色、送仓绿色,区分预计/实际到港 |
|
||||
| 新增预约 | 居中双列表单,原型最大宽度760px | 保留居中双列表单;为跨客户货物选择扩至1080px,在同弹框下方分页选择真实入仓货物 |
|
||||
| 编辑预约 | 复用居中表单 | 与入仓单共用记录;版本校验;真实货物归属不允许手填更改 |
|
||||
| 到港详情 | 当天安排右侧只读 | 使用货柜/船节点事实,无逐行详情请求,无模拟到港数据 |
|
||||
| 删除预约 | 二次确认 | 显式提示全部货物解除预约关联;独立删除权限,已关联有效排车拒绝删除 |
|
||||
| 月/周切换 | 顶部切换与前后按钮 | 周模式按7天移动,修正原型仍按月移动的问题 |
|
||||
| 多条安排 | 日历标签、右侧明细 | 日格限制显示数量并提示剩余条目,右侧全部展示 |
|
||||
|
||||
预约结束时间未明确时允许为空;不把预计装车时间当结束时间。预约号、目的仓和货物来源遵守服务端合同。日历范围最多42天,每次范围变化查询预约与到港各一次;选择范围内日期不发送请求。候选只在用户点击查询时分页读取,跨页选择保留,重新查询清空。
|
||||
|
||||
视觉验收目标为1920×1080、100%缩放。该矩阵描述设计和代码边界,不代替测试环境浏览器验收证据。
|
||||
@@ -0,0 +1,28 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface ArrivalCalendarEntry {
|
||||
containerBookingId: string
|
||||
bookingNo: string
|
||||
containerNo?: string | null
|
||||
vesselName?: string | null
|
||||
voyageNo?: string | null
|
||||
destinationPortName?: string | null
|
||||
destinationWarehouseCode?: string | null
|
||||
arrivalAt: string
|
||||
arrivalType: 'ACTUAL' | 'ESTIMATED'
|
||||
}
|
||||
|
||||
export async function listArrivalCalendar(query: { from: string; to: string }, signal?: AbortSignal) {
|
||||
const response = await http.get<ApiEnvelope<ArrivalCalendarEntry[]>, ApiEnvelope<ArrivalCalendarEntry[]>>(
|
||||
'/platform/v1/port-schedules/arrival-calendar', { params: query, signal },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function deleteAppointment(appointmentNo: string, version: number) {
|
||||
const response = await http.delete<ApiEnvelope<void>, ApiEnvelope<void>>(
|
||||
`/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`,
|
||||
{ params: { version } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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('moves by a week in week mode instead of following the prototype month-navigation bug', () => {
|
||||
expect(moveCalendar('2026-09-24', 'week', 1)).toBe('2026-10-01')
|
||||
expect(calendarDates('2026-09-27', 'week')).toEqual([
|
||||
'2026-09-21', '2026-09-22', '2026-09-23', '2026-09-24',
|
||||
'2026-09-25', '2026-09-26', '2026-09-27',
|
||||
])
|
||||
})
|
||||
|
||||
it('clamps month-end selection and handles leap days', () => {
|
||||
expect(moveCalendar('2028-01-31', 'month', 1)).toBe('2028-02-29')
|
||||
expect(moveCalendar('2027-03-31', 'month', -1)).toBe('2027-02-28')
|
||||
expect(addDays('2028-02-28', 1)).toBe('2028-02-29')
|
||||
expect(() => addDays('2027-02-29', 1)).toThrow('无效的日历日期')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
export type CalendarMode = 'month' | 'week'
|
||||
|
||||
/** Calendar dates are warehouse-local dates, never UTC conversions. */
|
||||
export function dateKey(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function parseDate(value: string): Date {
|
||||
const date = new Date(`${value}T12:00:00`)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(date.getTime()) || dateKey(date) !== value) {
|
||||
throw new Error('无效的日历日期')
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
export function addDays(value: string, days: number): string {
|
||||
const date = parseDate(value)
|
||||
date.setDate(date.getDate() + days)
|
||||
return dateKey(date)
|
||||
}
|
||||
|
||||
export function calendarRange(anchor: string, mode: CalendarMode): { from: string; to: string } {
|
||||
const date = parseDate(anchor)
|
||||
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) }
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
export function moveCalendar(anchor: string, mode: CalendarMode, offset: number): string {
|
||||
if (mode === 'week') return addDays(anchor, offset * 7)
|
||||
const date = parseDate(anchor)
|
||||
const day = date.getDate()
|
||||
date.setDate(1)
|
||||
date.setMonth(date.getMonth() + offset)
|
||||
const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
|
||||
date.setDate(Math.min(day, lastDay))
|
||||
return dateKey(date)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const appointmentCalendarRoutes = [{
|
||||
path: 'appointment-calendar',
|
||||
name: 'appointment-calendar',
|
||||
component: () => import('./AppointmentCalendarView.vue'),
|
||||
meta: { title: '预约列表', showPageHeading: false, permission: 'platform:appointment:list' },
|
||||
}] satisfies RouteRecordRaw[]
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment } from './inboundOrders'
|
||||
import { createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar, listInboundOrders, updateFbaAppointment } from './inboundOrders'
|
||||
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||
|
||||
@@ -11,16 +11,32 @@ describe('inbound order overseas API', () => {
|
||||
vi.mocked(http.put).mockReset()
|
||||
})
|
||||
|
||||
it('loads a calendar range in one request and supports cancelling obsolete requests', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
|
||||
const controller = new AbortController()
|
||||
const query = { from: '2026-08-31', to: '2026-10-12' }
|
||||
expect(await listFbaAppointmentCalendar(query, controller.signal)).toEqual([])
|
||||
expect(http.get).toHaveBeenCalledTimes(1)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/calendar', {
|
||||
params: query, signal: controller.signal,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not turn calendar business errors into an empty successful calendar', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '500', message: '日历查询失败', data: null })
|
||||
await expect(listFbaAppointmentCalendar({ from: '2026-09-01', to: '2026-10-01' })).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('loads appointment details and updates the existing appointment without replacing its lines', async () => {
|
||||
const detail = { id: '1', customerId: '190000000000000001', appointmentNo: 'APT/1', appointmentAt: '2026-08-20T10:30:00', expectedLoadingAt: '2026-08-20T09:00:00', appointmentStatus: 'SUCCESS', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }
|
||||
const detail = { version: 0, id: '1', customerId: '190000000000000001', appointmentNo: 'APT/1', appointmentAt: '2026-08-20T10:30:00', expectedLoadingAt: '2026-08-20T09:00:00', appointmentStatus: 'SUCCESS', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
|
||||
await getFbaAppointment('190000000000000001', 'APT/1')
|
||||
await updateFbaAppointment('APT/1', { customerId: '190000000000000001', appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: 'SUCCESS' })
|
||||
await getFbaAppointment('APT/1')
|
||||
await updateFbaAppointment('APT/1', { version: detail.version, appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: 'SUCCESS' })
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', { params: { customerId: '190000000000000001' } })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', expect.objectContaining({ customerId: '190000000000000001' }))
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1')
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', expect.not.objectContaining({ customerId: expect.anything() }))
|
||||
})
|
||||
|
||||
it('loads the paginated list across all customers without a customer scope', async () => {
|
||||
@@ -34,7 +50,6 @@ describe('inbound order overseas API', () => {
|
||||
it('preserves selected line ids as strings when creating an appointment', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', appointmentNo: 'APT-1', appointmentStatus: 'SUCCESS', selectedCount: 1, fbaWarehouseCode: 'ONT8' } })
|
||||
const payload = {
|
||||
customerId: '190000000000000001',
|
||||
lineIds: ['190000000000000099'],
|
||||
appointmentNo: 'APT-1',
|
||||
appointmentAt: '2026-08-20T10:30:00',
|
||||
|
||||
@@ -54,29 +54,37 @@ export interface InboundOrderSummaryDto {
|
||||
}
|
||||
|
||||
export interface BatchFbaAppointmentPayload {
|
||||
customerId: string
|
||||
lineIds: string[]
|
||||
appointmentNo: string
|
||||
appointmentAt: string
|
||||
appointmentEndAt?: string | null
|
||||
expectedLoadingAt: string
|
||||
appointmentStatus: 'PENDING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'
|
||||
appointmentRemark?: string
|
||||
}
|
||||
|
||||
export interface FbaAppointmentDto {
|
||||
version: number
|
||||
id: string
|
||||
customerId: string
|
||||
appointmentNo: string
|
||||
appointmentAt: string
|
||||
appointmentEndAt?: string | null
|
||||
expectedLoadingAt: string
|
||||
appointmentStatus: string
|
||||
appointmentRemark?: string | null
|
||||
selectedCount: number
|
||||
fbaWarehouseCode: string
|
||||
items: Array<{ id: string; planId: string; lineId: string; orderId: string; inboundCode: string; fbaWarehouseCode: string }>
|
||||
items: Array<{ customerId: string; id: string; planId: string; lineId: string; orderId: string; inboundCode: string; fbaWarehouseCode: string }>
|
||||
}
|
||||
|
||||
export type UpdateFbaAppointmentPayload = Omit<BatchFbaAppointmentPayload, 'lineIds' | 'appointmentNo'>
|
||||
export type UpdateFbaAppointmentPayload = Omit<BatchFbaAppointmentPayload, 'lineIds' | 'appointmentNo'> & { version: number }
|
||||
|
||||
export async function listFbaAppointmentCalendar(query: { from: string; to: string }, signal?: AbortSignal) {
|
||||
const response = await http.get<ApiEnvelope<FbaAppointmentDto[]>, ApiEnvelope<FbaAppointmentDto[]>>(
|
||||
'/platform/v1/last-leg/inbound-plans/fba-appointments/calendar', { params: query, signal },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function listInboundOrders(query: InboundOrderQuery) {
|
||||
const response = await http.get<ApiEnvelope<OverseasPagePayload<InboundOrderSummaryDto>>, ApiEnvelope<OverseasPagePayload<InboundOrderSummaryDto>>>(
|
||||
@@ -92,10 +100,9 @@ export async function createFbaAppointment(payload: BatchFbaAppointmentPayload)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getFbaAppointment(customerId: string, appointmentNo: string) {
|
||||
export async function getFbaAppointment(appointmentNo: string) {
|
||||
const response = await http.get<ApiEnvelope<FbaAppointmentDto>, ApiEnvelope<FbaAppointmentDto>>(
|
||||
`/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`,
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar,
|
||||
listInboundOrders, updateFbaAppointment,
|
||||
} from './api/inboundOrders'
|
||||
export type { FbaAppointmentDto, BatchFbaAppointmentPayload, InboundOrderSummaryDto } from './api/inboundOrders'
|
||||
export { overseasWarehousePermissions } from './permissions'
|
||||
@@ -50,10 +50,10 @@ const smartDispatchOpen = ref(false)
|
||||
const appointmentLoading = ref(false)
|
||||
const appointmentMode = ref<'create' | 'edit'>('create')
|
||||
const editingAppointmentNo = ref('')
|
||||
const editingCustomerId = ref('')
|
||||
const appointmentSelectedCount = ref(0)
|
||||
const appointmentFbaWarehouse = ref('—')
|
||||
const appointmentForm = reactive({ appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS' as 'PENDING' | 'SUCCESS' | 'FAILED' | 'CANCELLED', appointmentRemark: '' })
|
||||
const appointmentVersion = ref(0)
|
||||
const appointmentForm = reactive({ appointmentEndAt: '', appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS' as 'PENDING' | 'SUCCESS' | 'FAILED' | 'CANCELLED', appointmentRemark: '' })
|
||||
const appointmentTitle = computed(() => appointmentMode.value === 'edit' ? '编辑预约' : '预约')
|
||||
const planEditOpen = ref(false); const planEditLoading = ref(false); const planEditSaving = ref(false); const planEditTarget = ref<InboundOrderListItem | null>(null); const planEditForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', version: 0 }); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
|
||||
|
||||
@@ -61,7 +61,7 @@ function selectableRow(source: object) {
|
||||
const row = source as InboundOrderListItem
|
||||
if (!row.fbaWarehouse || row.fbaWarehouse === '—') return false
|
||||
const anchor = selectedRows.value[0]
|
||||
return !anchor || (anchor.customerId === row.customerId && anchor.fbaWarehouse === row.fbaWarehouse)
|
||||
return !anchor || anchor.fbaWarehouse === row.fbaWarehouse
|
||||
}
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
@@ -117,7 +117,6 @@ function onSelectionChange(selection: object[]) { selectedRows.value = selection
|
||||
function selectionError() {
|
||||
if (!selectedRows.value.length) return '请至少选择 1 条入库单'
|
||||
if (selectedRows.value.some((row) => !row.fbaWarehouse || row.fbaWarehouse === '—')) return '所选入库单缺少 FBA 仓库信息'
|
||||
if (new Set(selectedRows.value.map((row) => row.customerId)).size !== 1) return '所选入库单必须属于同一客户'
|
||||
if (new Set(selectedRows.value.map((row) => row.fbaWarehouse)).size !== 1) return '所选入库单必须属于同一 FBA 仓库'
|
||||
return ''
|
||||
}
|
||||
@@ -125,9 +124,10 @@ function selectionError() {
|
||||
function openAppointment() {
|
||||
const error = selectionError()
|
||||
if (error) { ElMessage.warning(error); return }
|
||||
appointmentMode.value = 'create'; editingAppointmentNo.value = ''; editingCustomerId.value = ''
|
||||
appointmentMode.value = 'create'; editingAppointmentNo.value = ''
|
||||
appointmentSelectedCount.value = selectedRows.value.length; appointmentFbaWarehouse.value = selectedRows.value[0]?.fbaWarehouse || '—'
|
||||
Object.assign(appointmentForm, { appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS', appointmentRemark: '' })
|
||||
appointmentVersion.value = 0
|
||||
Object.assign(appointmentForm, { appointmentEndAt: '', appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS', appointmentRemark: '' })
|
||||
mutationError.value = null
|
||||
appointmentOpen.value = true
|
||||
}
|
||||
@@ -139,13 +139,14 @@ function openSmartDispatch() {
|
||||
async function openAppointmentEdit(source: object) {
|
||||
const row = source as InboundOrderListItem
|
||||
if (!row.appointmentNo || row.appointmentNo === '—') return
|
||||
appointmentMode.value = 'edit'; editingAppointmentNo.value = row.appointmentNo; editingCustomerId.value = row.customerId
|
||||
appointmentMode.value = 'edit'; editingAppointmentNo.value = row.appointmentNo
|
||||
appointmentLoading.value = true; mutationError.value = null; appointmentOpen.value = true
|
||||
try {
|
||||
const detail = await getFbaAppointment(row.customerId, row.appointmentNo)
|
||||
const detail = await getFbaAppointment(row.appointmentNo)
|
||||
appointmentSelectedCount.value = detail.selectedCount
|
||||
appointmentFbaWarehouse.value = detail.fbaWarehouseCode || row.fbaWarehouse
|
||||
Object.assign(appointmentForm, { appointmentNo: detail.appointmentNo, appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: detail.appointmentStatus, appointmentRemark: detail.appointmentRemark || '' })
|
||||
appointmentVersion.value = detail.version
|
||||
Object.assign(appointmentForm, { appointmentEndAt: detail.appointmentEndAt || '', appointmentNo: detail.appointmentNo, appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: detail.appointmentStatus, appointmentRemark: detail.appointmentRemark || '' })
|
||||
} catch (caught) {
|
||||
appointmentOpen.value = false
|
||||
mutationError.value = getApiErrorDetails(caught, 'FBA 预约详情加载失败,请重试')
|
||||
@@ -157,9 +158,9 @@ async function submitAppointment() {
|
||||
if (!appointmentForm.appointmentNo.trim() || !appointmentForm.appointmentAt || !appointmentForm.expectedLoadingAt) { ElMessage.warning('请填写预约号、预约送仓时间和预计装车时间'); return }
|
||||
saving.value = true; mutationError.value = null
|
||||
try {
|
||||
const common = { appointmentAt: appointmentForm.appointmentAt, expectedLoadingAt: appointmentForm.expectedLoadingAt, appointmentStatus: appointmentForm.appointmentStatus, appointmentRemark: appointmentForm.appointmentRemark.trim() || undefined }
|
||||
if (appointmentMode.value === 'edit') await updateFbaAppointment(editingAppointmentNo.value, { customerId: editingCustomerId.value, ...common })
|
||||
else await createFbaAppointment({ customerId: selectedRows.value[0].customerId, lineIds: selectedRows.value.map((row) => row.lineId), appointmentNo: appointmentForm.appointmentNo.trim(), ...common })
|
||||
const common = { appointmentEndAt: appointmentForm.appointmentEndAt || null, appointmentAt: appointmentForm.appointmentAt, expectedLoadingAt: appointmentForm.expectedLoadingAt, appointmentStatus: appointmentForm.appointmentStatus, appointmentRemark: appointmentForm.appointmentRemark.trim() || undefined }
|
||||
if (appointmentMode.value === 'edit') await updateFbaAppointment(editingAppointmentNo.value, { ...common, version: appointmentVersion.value })
|
||||
else await createFbaAppointment({ lineIds: selectedRows.value.map((row) => row.lineId), appointmentNo: appointmentForm.appointmentNo.trim(), ...common })
|
||||
appointmentOpen.value = false; selectedRows.value = []; ElMessage.success('FBA 预约保存成功'); await load()
|
||||
} catch (caught) { mutationError.value = getApiErrorDetails(caught, 'FBA 预约保存失败,请重试') }
|
||||
finally { saving.value = false }
|
||||
@@ -210,6 +211,7 @@ onMounted(() => { void load() })
|
||||
<el-form label-position="top" class="appointment-form density-form">
|
||||
<el-form-item label="预约号" required><el-input v-model="appointmentForm.appointmentNo" maxlength="64" :readonly="appointmentMode === 'edit'" placeholder="请输入预约号" /></el-form-item>
|
||||
<el-form-item label="预约送仓时间" required><el-date-picker v-model="appointmentForm.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预约送仓时间" /></el-form-item>
|
||||
<el-form-item label="预约结束时间"><el-date-picker v-model="appointmentForm.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" /></el-form-item>
|
||||
<el-form-item label="预计装车时间" required><el-date-picker v-model="appointmentForm.expectedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计装车时间" /></el-form-item>
|
||||
<el-form-item label="预约状态" required><el-select v-model="appointmentForm.appointmentStatus" placeholder="请选择预约状态"><el-option label="预约中" value="PENDING" /><el-option label="预约成功" value="SUCCESS" /><el-option label="预约失败" value="FAILED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item>
|
||||
<el-form-item label="预约备注"><el-input v-model="appointmentForm.appointmentRemark" maxlength="1000" placeholder="请输入预约备注" /></el-form-item>
|
||||
|
||||
@@ -166,7 +166,7 @@ async function reviewPlan(plan: Record<string, unknown>) {
|
||||
}
|
||||
async function cancelPlan(plan: Record<string, unknown>) {
|
||||
try {
|
||||
await ElMessageBox.confirm('取消后释放本计划锁定的来源库存,是否继续?', '取消合箱计划', { type: 'warning' });
|
||||
await ElMessageBox.confirm('取消后释放本计划锁定的来源库存,是否继续?', '取消合箱计划', { type: 'warning', customClass: 'admin-density-confirm' });
|
||||
await cancelReturnPackingPlan(String(plan.planNo)); ElMessage.success('计划已取消,库存已释放'); await load();
|
||||
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '取消失败'); }
|
||||
}
|
||||
@@ -567,7 +567,7 @@ const tableColumns = computed(() =>
|
||||
>
|
||||
<template #filters
|
||||
><el-tabs
|
||||
class="density-element-tabs"
|
||||
class="density-element-tabs density-status-tabs"
|
||||
v-if="mode === 'inventory'"
|
||||
v-model="inventoryTab"
|
||||
@tab-change="changeInventoryTab"
|
||||
@@ -731,7 +731,7 @@ const tableColumns = computed(() =>
|
||||
}}</template>
|
||||
</DataTable>
|
||||
<AppDialog
|
||||
class="packing-dialog"
|
||||
class="packing-dialog admin-density-dialog"
|
||||
v-model="packingDialogOpen"
|
||||
title="创建退货合箱计划"
|
||||
width="min(1380px, calc(100vw - 32px))"
|
||||
@@ -1000,11 +1000,11 @@ const tableColumns = computed(() =>
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid var(--cc-border);
|
||||
}
|
||||
.packing-sku-list-head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface-subtle, #f8fafc); color: var(--cc-muted); font-size: 12px; }
|
||||
.packing-sku-list-head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface-subtle, #f8fafc); color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.packing-sku-row:last-of-type { border-bottom: 0; }
|
||||
.packing-sku-row > span {
|
||||
color: var(--cc-muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--admin-font-caption);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.packing-sku-row :deep(.el-input-number) {
|
||||
@@ -1014,7 +1014,7 @@ const tableColumns = computed(() =>
|
||||
padding: 0 7px;
|
||||
}
|
||||
.packing-sku-row :deep(.el-input__inner) {
|
||||
font-size: 12px;
|
||||
font-size: var(--admin-font-caption);
|
||||
text-align: center;
|
||||
}
|
||||
.packing-box-list {
|
||||
@@ -1041,7 +1041,7 @@ const tableColumns = computed(() =>
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
color: var(--cc-muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--admin-font-caption);
|
||||
background: var(--cc-surface-subtle, #f8fafc);
|
||||
border-bottom: 1px solid var(--cc-border);
|
||||
}
|
||||
@@ -1055,20 +1055,20 @@ const tableColumns = computed(() =>
|
||||
.packing-box-list-row > span,
|
||||
.packing-box-items {
|
||||
color: var(--cc-muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--admin-font-caption);
|
||||
}
|
||||
.packing-box-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.return-nos-count { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
||||
.return-nos-count { color: var(--cc-muted); font-size: var(--admin-font-caption); white-space: nowrap; }
|
||||
.packing-form :deep(.el-alert) {
|
||||
margin-top: 2px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.packing-form :deep(.el-alert__title) {
|
||||
font-size: 12px;
|
||||
font-size: var(--admin-font-caption);
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.packing-box-list { overflow-x: auto; }
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
const expected = new Map([
|
||||
['LastMileOrdersView.vue', 'overseas-last-mile-orders-query'],
|
||||
['InboundOrdersView.vue', 'overseas-inbound-orders-query'],
|
||||
['CargoDiscardsView.vue', 'overseas-cargo-discards-query'],
|
||||
['DispatchesView.vue', 'overseas-dispatches-query'],
|
||||
['DeliveryOrdersView.vue', 'overseas-delivery-orders-query'],
|
||||
['CustomerVisitsView.vue', 'overseas-customer-visits-query'],
|
||||
@@ -211,7 +212,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(value).toContain('createFbaAppointment')
|
||||
expect(value).toContain('预计装车时间')
|
||||
expect(value).toContain('所选入库单必须属于同一 FBA 仓库')
|
||||
expect(value).toContain('anchor.customerId === row.customerId')
|
||||
expect(value).not.toContain('anchor.customerId === row.customerId')
|
||||
expect(value).toContain('anchor.fbaWarehouse === row.fbaWarehouse')
|
||||
expect(value).toContain('<CalendarCheck2 :size="16" />预约')
|
||||
expect(value).toContain('<Truck :size="16" />智能排车')
|
||||
expect(value).toContain('type="primary" @click="openSmartDispatch"')
|
||||
|
||||
@@ -8,6 +8,7 @@ describe('overseas warehouse routes', () => {
|
||||
['overseas-warehouse/last-mile-orders', permission.lastMileOrderList],
|
||||
['overseas-warehouse/last-mile-orders/:orderId', permission.lastMileOrderList],
|
||||
['overseas-warehouse/inbound-orders', permission.inboundOrderList],
|
||||
['overseas-warehouse/cargo-discards', permission.discardList],
|
||||
['overseas-warehouse/inbound-orders/:orderId', permission.inboundOrderList],
|
||||
['overseas-warehouse/dispatches', permission.dispatchList],
|
||||
['overseas-warehouse/dispatches/:dispatchNo', permission.dispatchList],
|
||||
|
||||
Reference in New Issue
Block a user