This commit is contained in:
2026-08-04 12:57:50 +08:00
parent 3dcc8b30db
commit 6354f5ca66
4 changed files with 139 additions and 38 deletions
@@ -24,18 +24,30 @@ describe('platform first-leg order API', () => {
vi.mocked(http.put).mockReset()
})
it('queries a tenant-scoped order page with server pagination and filters', async () => {
it('queries the cross-tenant review queue with one server-paginated request', async () => {
const page = { records: [], page: 2, size: 20, total: 0, pages: 0 }
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: page })
await expect(listPlatformFirstLegOrders('10001', {
await expect(listPlatformFirstLegOrders({
keyword: 'FL2026', status: 'SUBMITTED', createdFrom: '2026-07-01T00:00:00', createdTo: '2026-07-31T23:59:59', page: 2, size: 20,
})).resolves.toEqual(page)
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg', {
params: {
tenantId: '10001', keyword: 'FL2026', status: 'SUBMITTED', createdFrom: '2026-07-01T00:00:00', createdTo: '2026-07-31T23:59:59', page: 2, size: 20,
keyword: 'FL2026', status: 'SUBMITTED', createdFrom: '2026-07-01T00:00:00', createdTo: '2026-07-31T23:59:59', page: 2, size: 20,
},
})
expect(http.get).toHaveBeenCalledTimes(1)
})
it('adds the optional enterprise filter without changing server pagination', async () => {
const page = { records: [], page: 1, size: 20, total: 0, pages: 0 }
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: page })
await listPlatformFirstLegOrders({ tenantId: '2084455952401629185', status: 'SUBMITTED', page: 1, size: 20 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg', {
params: { tenantId: '2084455952401629185', status: 'SUBMITTED', page: 1, size: 20 },
})
})
it('uses the platform lifecycle routes and target tenant query parameter', async () => {
@@ -106,6 +106,7 @@ export interface PlatformFirstLegOrderPageResource {
}
export interface PlatformFirstLegOrderQuery {
tenantId?: string
keyword?: string
status?: PlatformFirstLegOrderStatus
customerId?: string
@@ -126,8 +127,8 @@ async function postData<T>(path: string, body?: unknown, params?: Record<string,
return unwrapApiResponse(response)
}
export function listPlatformFirstLegOrders(tenantId: string, query: PlatformFirstLegOrderQuery) {
return getData<PlatformFirstLegOrderPageResource>('/platform/v1/orders/first-leg', { tenantId, ...query })
export function listPlatformFirstLegOrders(query: PlatformFirstLegOrderQuery) {
return getData<PlatformFirstLegOrderPageResource>('/platform/v1/orders/first-leg', { ...query })
}
export function getPlatformFirstLegOrder(tenantId: string, orderId: string) {
@@ -0,0 +1,34 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.vue'), 'utf8')
describe('PlatformFirstLegOrdersPanel review queue', () => {
it('loads one cross-tenant page by default and keeps enterprise as an optional filter', () => {
expect(source).toContain("status: 'SUBMITTED'")
expect(source).toContain("tenantId: filters.value.tenantId || undefined")
expect(source).toContain('listPlatformTenants')
expect(source).toContain("key: 'tenantId'")
expect(source).toContain('tenantName((row as PlatformFirstLegOrderResource).tenantId)')
expect(source).toContain('result.pages')
})
it('does not retain the historical fixed tenant', () => {
expect(source).not.toContain('PlatformTenantSelector')
expect(source).not.toContain('selectedTenantId')
})
it('resets to the pending review queue instead of all statuses', () => {
expect(source).toContain(':search-on-reset="false"')
expect(source).toContain("filters.value = { ...filters.value, status: 'SUBMITTED' }")
expect(source).toContain('await nextTick()')
})
it('passes the order tenant through detail and lifecycle actions', () => {
expect(source).toContain('tenantId: String(order.tenantId)')
expect(source).toContain('getPlatformFirstLegOrder(tenantId, orderId)')
expect(source).toContain('approvePlatformFirstLegOrder(tenantId, String(order.id))')
expect(source).not.toContain('loadedTenantId.value')
})
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { ArrowLeft, Eye, FilePenLine, PackageCheck } from '@lucide/vue'
@@ -24,13 +24,14 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
const PHASE_ONE_TENANT_ID = '10001'
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const loadedTenantId = ref(PHASE_ONE_TENANT_ID)
const orders = ref<PlatformFirstLegOrderResource[]>([])
const tenants = ref<PlatformTenantResource[]>([])
const tenantLoadError = ref<ApiErrorDetails | null>(null)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
@@ -49,17 +50,26 @@ const expressFormRef = ref<FormInstance>()
const noteFormRef = ref<FormInstance>()
const expressForm = reactive({ expressCompany: '', expressTrackingNo: '' })
const filters = ref<QueryModel & {
tenantId: string
keyword: string
status: PlatformFirstLegOrderStatus | ''
dateRange: string[]
}>({
tenantId: '',
keyword: '',
status: '',
status: 'SUBMITTED',
dateRange: [],
})
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '订单号或客户名称', props: { 'aria-label': '订单关键词' } },
const queryFields = computed<QueryField[]>(() => [
{
key: 'tenantId',
type: 'select',
placeholder: tenantLoadError.value ? '企业列表加载失败' : '全部企业',
props: { 'aria-label': '企业客户', filterable: true, disabled: Boolean(tenantLoadError.value) },
options: tenants.value.map((tenant) => ({ value: String(tenant.id), label: `${tenant.tenantName}(${tenant.tenantCode || tenant.id})` })),
},
{ key: 'keyword', type: 'input', placeholder: '订单号或发货客户名称', props: { 'aria-label': '订单关键词' } },
{
key: 'status',
type: 'select',
@@ -77,7 +87,7 @@ const queryFields: QueryField[] = [
],
},
{ key: 'dateRange', type: 'daterange', props: { 'aria-label': '订单创建日期' } },
]
])
const orderColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', minWidth: 200, fixed: 'left', slotName: 'actions' },
@@ -102,11 +112,14 @@ const noteRules: FormRules = {
content: [{ required: true, message: '请填写内部备注', trigger: 'blur' }],
}
const tenantReady = computed(() => loadedTenantId.value === PHASE_ONE_TENANT_ID)
const detailOrderId = computed(() => {
const orderId = route.query.orderId
return typeof orderId === 'string' && orderId ? orderId : ''
})
const detailTenantId = computed(() => {
const tenantId = route.query.tenantId
return typeof tenantId === 'string' && tenantId ? tenantId : ''
})
const isDetailPage = computed(() => Boolean(detailOrderId.value))
const statusOptions: Array<{ value: PlatformFirstLegOrderStatus; label: string }> = [
@@ -135,6 +148,29 @@ function formattedDate(value: string | null | undefined) {
return value ? value.replace('T', ' ') : '-'
}
function tenantName(tenantId: number | string) {
return tenants.value.find((tenant) => String(tenant.id) === String(tenantId))?.tenantName || `企业 ${tenantId}`
}
async function loadTenants() {
tenantLoadError.value = null
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200 })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
tenants.value = records
} catch (error) {
tenants.value = []
tenantLoadError.value = getApiErrorDetails(error, '企业列表加载失败,暂时无法按企业筛选;订单仍可正常审核')
}
}
function can(action: string) {
return access.can(action)
}
@@ -152,11 +188,11 @@ function actionLabel(order: PlatformFirstLegOrderResource) {
}
async function loadOrders() {
const targetTenantId = PHASE_ONE_TENANT_ID
loadError.value = null
loading.value = true
try {
const result = await listPlatformFirstLegOrders(targetTenantId, {
const result = await listPlatformFirstLegOrders({
tenantId: filters.value.tenantId || undefined,
keyword: filters.value.keyword.trim() || undefined,
status: filters.value.status || undefined,
createdFrom: filters.value.dateRange.length === 2 ? `${filters.value.dateRange[0]}T00:00:00` : undefined,
@@ -166,7 +202,6 @@ async function loadOrders() {
})
orders.value = result.records
total.value = result.total
loadedTenantId.value = targetTenantId
} catch (error) {
orders.value = []
total.value = 0
@@ -184,22 +219,34 @@ function searchOrders() {
void loadOrders()
}
async function resetOrders() {
filters.value = { ...filters.value, status: 'SUBMITTED' }
await nextTick()
searchOrders()
}
watch([page, pageSize], () => {
void loadOrders()
})
async function openDetail(order: PlatformFirstLegOrderResource) {
if (!tenantReady.value) return
selectedOrder.value = order
await router.push({ path: route.path, query: { ...route.query, orderId: String(order.id) } })
await router.push({
path: route.path,
query: { ...route.query, orderId: String(order.id), tenantId: String(order.tenantId) },
})
}
async function loadDetail(orderId: string) {
if (!tenantReady.value) return
async function loadDetail(orderId: string, tenantId = detailTenantId.value) {
if (!tenantId) {
detail.value = null
ElMessage.error('订单详情缺少企业租户标识,请从订单列表重新打开')
return
}
detail.value = null
detailLoading.value = true
try {
detail.value = await getPlatformFirstLegOrder(loadedTenantId.value, orderId)
detail.value = await getPlatformFirstLegOrder(tenantId, orderId)
selectedOrder.value = detail.value.order
} catch (error) {
const details = getApiErrorDetails(error, '订单详情加载失败')
@@ -217,7 +264,7 @@ async function refreshAfterAction(orderId: string) {
}
function closeDetailPage() {
const { orderId: _orderId, ...query } = route.query
const { orderId: _orderId, tenantId: _tenantId, ...query } = route.query
void router.replace({ path: route.path, query })
}
@@ -233,7 +280,6 @@ function getFieldValidationMessage(error: unknown, field: string, fallback: stri
}
async function runLifecycleAction(order: PlatformFirstLegOrderResource) {
if (!tenantReady.value) return
const label = actionLabel(order)
if (!label) return
try {
@@ -243,10 +289,11 @@ async function runLifecycleAction(order: PlatformFirstLegOrderResource) {
type: 'warning',
})
actionLoadingId.value = String(order.id)
if (order.status === 'SUBMITTED') await approvePlatformFirstLegOrder(loadedTenantId.value, String(order.id))
if (order.status === 'APPROVED') await acceptPlatformFirstLegOrder(loadedTenantId.value, String(order.id))
if (order.status === 'ACCEPTED') await startPlatformFirstLegOrder(loadedTenantId.value, String(order.id))
if (order.status === 'PROCESSING') await completePlatformFirstLegOrder(loadedTenantId.value, String(order.id))
const tenantId = String(order.tenantId)
if (order.status === 'SUBMITTED') await approvePlatformFirstLegOrder(tenantId, String(order.id))
if (order.status === 'APPROVED') await acceptPlatformFirstLegOrder(tenantId, String(order.id))
if (order.status === 'ACCEPTED') await startPlatformFirstLegOrder(tenantId, String(order.id))
if (order.status === 'PROCESSING') await completePlatformFirstLegOrder(tenantId, String(order.id))
await refreshAfterAction(String(order.id))
ElMessage.success(`${label}已完成`)
} catch (error) {
@@ -258,7 +305,6 @@ async function runLifecycleAction(order: PlatformFirstLegOrderResource) {
}
async function cancelOrder(order: PlatformFirstLegOrderResource) {
if (!tenantReady.value) return
try {
const result = await ElMessageBox.prompt('请填写取消原因。后端要求该字段必填,并将写入订单状态历史。', '取消订单', {
confirmButtonText: '确认取消',
@@ -268,7 +314,7 @@ async function cancelOrder(order: PlatformFirstLegOrderResource) {
inputPlaceholder: '例如:客户取消本次运输安排',
})
actionLoadingId.value = String(order.id)
await cancelPlatformFirstLegOrder(loadedTenantId.value, String(order.id), result.value.trim())
await cancelPlatformFirstLegOrder(String(order.tenantId), String(order.id), result.value.trim())
await refreshAfterAction(String(order.id))
ElMessage.success('订单已取消')
} catch (error) {
@@ -288,12 +334,12 @@ function openExpress(order: PlatformFirstLegOrderResource) {
}
async function saveExpress() {
if (!selectedOrder.value || !tenantReady.value) return
if (!selectedOrder.value) return
const valid = await expressFormRef.value?.validate().catch(() => false)
if (!valid) return
expressSaving.value = true
try {
await updatePlatformFirstLegOrderExpress(loadedTenantId.value, String(selectedOrder.value.id), {
await updatePlatformFirstLegOrderExpress(String(selectedOrder.value.tenantId), String(selectedOrder.value.id), {
expressCompany: expressForm.expressCompany.trim(),
expressTrackingNo: expressForm.expressTrackingNo.trim(),
})
@@ -315,12 +361,12 @@ function openNote(order: PlatformFirstLegOrderResource) {
}
async function saveNote() {
if (!selectedOrder.value || !tenantReady.value) return
if (!selectedOrder.value) return
const valid = await noteFormRef.value?.validate().catch(() => false)
if (!valid) return
noteSaving.value = true
try {
await addPlatformFirstLegOrderNote(loadedTenantId.value, String(selectedOrder.value.id), noteForm.content.trim())
await addPlatformFirstLegOrderNote(String(selectedOrder.value.tenantId), String(selectedOrder.value.id), noteForm.content.trim())
noteDialogOpen.value = false
await refreshAfterAction(String(selectedOrder.value.id))
ElMessage.success('内部备注已保存')
@@ -331,15 +377,17 @@ async function saveNote() {
}
}
watch(detailOrderId, (orderId) => {
watch([detailOrderId, detailTenantId], ([orderId, tenantId]) => {
if (orderId) {
void loadDetail(orderId)
void loadDetail(orderId, tenantId)
return
}
detail.value = null
selectedOrder.value = null
void loadOrders()
}, { immediate: true })
onMounted(() => void loadTenants())
</script>
<template>
@@ -359,7 +407,7 @@ watch(detailOrderId, (orderId) => {
<div>
<span>{{ transportLabel(detail.order) }}</span>
<h2>{{ detail.order.orderNo || detail.order.id }}</h2>
<p>{{ detail.order.customerName }} · {{ detail.order.referenceId || '无客户参考号' }}</p>
<p>{{ tenantName(detail.order.tenantId) }} · {{ detail.order.customerName }} · {{ detail.order.referenceId || '无客户参考号' }}</p>
</div>
<StatusBadge :status="statusLabel(detail.order.status)" />
</div>
@@ -430,10 +478,16 @@ watch(detailOrderId, (orderId) => {
ariaLabel="头程订单筛选"
:fields="queryFields"
search-text="查询"
:search-on-reset="true"
:search-on-reset="false"
@search="searchOrders"
@reset="resetOrders"
/>
<el-alert v-if="tenantLoadError" type="warning" :closable="false" show-icon role="alert">
<template #title><div class="api-error-alert-title"><span>{{ tenantLoadError.message }}</span><ApiErrorMeta :details="tenantLoadError" /></div></template>
<el-button link type="warning" @click="loadTenants">重新加载企业列表</el-button>
</el-alert>
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
<el-button link type="danger" @click="loadOrders">重新加载</el-button>
@@ -453,7 +507,7 @@ watch(detailOrderId, (orderId) => {
>
<template #actions="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button><el-button v-if="actionLabel(row as PlatformFirstLegOrderResource)" link type="success" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="runLifecycleAction(row as PlatformFirstLegOrderResource)">{{ actionLabel(row as PlatformFirstLegOrderResource) }}</el-button><el-button v-if="canCancel(row as PlatformFirstLegOrderResource)" link type="danger" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="cancelOrder(row as PlatformFirstLegOrderResource)">取消</el-button><el-button v-if="can('platform:order:fulfillment') && !['DRAFT', 'REJECTED', 'CANCELLED'].includes((row as PlatformFirstLegOrderResource).status)" link type="primary" @click="openExpress(row as PlatformFirstLegOrderResource)"><PackageCheck :size="15" />快递</el-button><el-button v-if="can('platform:order:note')" link type="primary" @click="openNote(row as PlatformFirstLegOrderResource)"><FilePenLine :size="15" />备注</el-button></div></template>
<template #orderInfo="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).orderNo || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).referenceId || '无客户参考号' }} · {{ formattedDate((row as PlatformFirstLegOrderResource).createdAt) }}</span></div></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).customerName }}</strong><span>租户 {{ (row as PlatformFirstLegOrderResource).tenantId }}</span></div></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ tenantName((row as PlatformFirstLegOrderResource).tenantId) }}</strong><span>{{ (row as PlatformFirstLegOrderResource).customerName }} · {{ (row as PlatformFirstLegOrderResource).tenantId }}</span></div></template>
<template #transport="{ row }"><div class="primary-cell"><strong>{{ transportLabel(row as PlatformFirstLegOrderResource) }}</strong><span>{{ (row as PlatformFirstLegOrderResource).channelName || '未填写渠道' }}</span></div></template>
<template #destination="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '-' }}</span></div></template>
<template #receivingWarehouse="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseAddress || '-' }}</span></div></template>