Files
admin-web/src/modules/inbounds/components/InboundTaskList.vue
T

515 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import '../measurement/measurementDensity.css'
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CircleCheckBig } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformWarehouses } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { firstLegBusinessTypeLabel } from '@/shared/utils/firstLegBusinessLabels'
import { listPlatformInbounds } from '../core/api'
import type { InboundOrderSummary, InboundPageResult } from '../core/types'
import InboundMeasurementDialog from '../measurement/InboundMeasurementDialog.vue'
import MeasurementReviewPanel from '../review/MeasurementReviewPanel.vue'
import { approveMeasurement, getMeasurementReviewDetail } from '../review/api'
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
import { buildInboundTaskQuery, loadAllActiveCustomers, type InboundTaskMode } from '../task-list'
type FilterModel = QueryModel & {
customerId: string
inboundCode: string
warehouseId: string
expectedArrivalRange: string[]
}
const props = defineProps<{
mode: InboundTaskMode
title?: string
taskLoader?: (pagination: { page: number; size: number }) => Promise<InboundPageResult>
}>()
const emit = defineEmits<{ changed: [] }>()
const router = useRouter()
const access = useAccessPreviewStore()
const reviewPanelRef = ref<InstanceType<typeof MeasurementReviewPanel> | null>(null)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', warehouseId: '', expectedArrivalRange: [] })
const appliedFilters = ref<FilterModel>({ ...filters.value, expectedArrivalRange: [] })
const customers = ref<PlatformTenantResource[]>([])
const customerLoadError = ref<ApiErrorDetails | null>(null)
const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
const selectedInbound = ref<InboundOrderSummary | null>(null)
const measurementDialogOpen = ref(false)
const reviewDialogOpen = ref(false)
const reviewNavigationLoading = ref(false)
const reviewApproved = ref(false)
const reviewNavigationError = ref<ApiErrorDetails | null>(null)
const batchReviewLoading = ref(false)
const selectedReviewRows = ref<InboundOrderSummary[]>([])
let requestSerial = 0
let optionsRequestSerial = 0
let disposed = false
let activatedOnce = false
const isReview = computed(() => props.mode === 'review')
const actionPermission = computed(() => isReview.value
? 'firstleg:inbound:measurement-review'
: 'platform:firstleg:inbound:measure:view')
const taskLabel = computed(() => isReview.value ? '审核' : '测量')
const emptyText = computed(() => isReview.value ? '暂无待审核的测量数据' : '暂无当前可测量的订单')
const preferenceKey = computed(() => isReview.value ? 'inbound-measurement-reviews' : 'inbound-measurements')
const customerNameById = computed(() => new Map(customers.value.map((customer) => [
String(customer.id), customer.legalName?.trim() || customer.tenantName,
])))
const queryFields = computed<QueryField[]>(() => [
{
key: 'customerId',
type: 'select',
label: '客户',
width: 260,
placeholder: customerLoadError.value ? '客户列表加载失败' : '请选择客户',
options: customers.value.map((customer) => ({
value: String(customer.id),
label: customer.legalName?.trim() || customer.tenantName,
})),
props: { filterable: true, disabled: Boolean(customerLoadError.value) },
},
{ key: 'warehouseId', type: 'select', label: '定位仓库', width: 220, placeholder: '请选择仓库', options: warehouseOptions.value, props: { filterable: true } },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 220, placeholder: '请输入入仓码' },
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到达时间', width: 320, placeholder: '请选择日期范围' },
])
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 160, fixed: 'left', slotName: 'actions' },
...(isReview.value && access.can(actionPermission.value) ? [{
key: 'selection', type: 'selection', width: 48, fixed: 'left',
selectable: (row: InboundOrderSummary) => row.measureReviewStatus === 'PENDING' && !batchReviewLoading.value,
} satisfies DataTableColumn] : []),
{ key: 'inboundCode', label: '入仓码', minWidth: 140, slotName: 'inboundCode' },
{ key: 'processStatus', label: '流程状态', minWidth: 132, slotName: 'processStatus' },
{ key: 'measureReviewStatus', label: '测量审核状态', minWidth: 120, slotName: 'measureReviewStatus' },
{ key: 'expectedArrivalAt', label: '预计到达时间', minWidth: 140, slotName: 'expectedArrivalAt' },
{ key: 'customerName', label: '客户', minWidth: 140, slotName: 'customerName' },
{ key: 'warehouseName', label: '仓库', minWidth: 120, slotName: 'warehouseName' },
{ key: 'deliveryWarehouseName', label: '交货仓', minWidth: 120, slotName: 'deliveryWarehouseName' },
{ key: 'businessType', label: '业务类型', minWidth: 100, slotName: 'businessType' },
{ key: 'channelName', label: '渠道', minWidth: 100, slotName: 'channelName' },
{ key: 'totalPackageCount', label: '总箱数', width: 90, slotName: 'totalPackageCount' },
{ key: 'palletCount', label: '托盘数', width: 90, slotName: 'palletCount' },
{ key: 'actualGrossWeightKg', label: '实测毛重(kg)', minWidth: 100, slotName: 'actualGrossWeightKg' },
{ key: 'actualNetWeightKg', label: '货物重量(kg)', minWidth: 100, slotName: 'actualNetWeightKg' },
{ key: 'actualVolumeCbm', label: '实测体积(m³)', minWidth: 100, slotName: 'actualVolumeCbm' },
{ key: 'measureCompletedAt', label: '提交时间', minWidth: 140, slotName: 'measureCompletedAt' },
])
function text(value: unknown) {
return value === null || value === undefined || value === '' ? '—' : String(value)
}
function customerDisplayName(inbound: InboundOrderSummary) {
const snapshot = inbound.customerName?.trim() || inbound.companyName?.trim()
return snapshot || customerNameById.value.get(String(inbound.customerId || inbound.tenantId)) || ''
}
function formatNumber(value: number | null | undefined) {
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })
}
function formatDate(value: string | null | undefined) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function formatDateOnly(value: string | null | undefined) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString('zh-CN')
}
function dateParam(value: string | undefined, end = false) {
return value ? `${value}T${end ? '23:59:59' : '00:00:00'}` : undefined
}
async function loadOptions() {
if (props.taskLoader) return
const serial = ++optionsRequestSerial
customerLoadError.value = null
try {
const [customerRecords, warehousePage] = await Promise.all([
loadAllActiveCustomers((query) => listPlatformTenants(query)),
listPlatformWarehouses(1, 200),
])
if (disposed || serial !== optionsRequestSerial) return
customers.value = customerRecords
warehouseOptions.value = warehousePage.records.map((warehouse) => ({
label: `${warehouse.warehouseName} · ${warehouse.warehouseCode}`,
value: String(warehouse.id),
}))
} catch (error) {
if (disposed) return
customerLoadError.value = getApiErrorDetails(error, '客户或仓库筛选项加载失败,不影响查看全部待办')
}
}
async function loadTasks() {
selectedReviewRows.value = []
const serial = ++requestSerial
loading.value = true
loadError.value = null
const applied = appliedFilters.value
try {
const response = props.taskLoader ? await props.taskLoader({ page: page.value, size: pageSize.value }) : await listPlatformInbounds(applied.customerId || undefined, buildInboundTaskQuery(props.mode, {
page: page.value,
size: pageSize.value,
warehouseId: applied.warehouseId || undefined,
inboundCode: applied.inboundCode.trim() || undefined,
expectedArrivalFrom: dateParam(applied.expectedArrivalRange[0]),
expectedArrivalTo: dateParam(applied.expectedArrivalRange[1], true),
}))
if (disposed || serial !== requestSerial) return
result.value = { records: response.records, total: response.total }
} catch (error) {
if (disposed || serial !== requestSerial) return
loadError.value = getApiErrorDetails(error, `${taskLabel.value}待办加载失败`)
} finally {
if (!disposed && serial === requestSerial) loading.value = false
}
}
function applyFilters() {
appliedFilters.value = {
...filters.value,
expectedArrivalRange: Array.isArray(filters.value.expectedArrivalRange) ? [...filters.value.expectedArrivalRange] : [],
}
if (page.value === 1) void loadTasks()
else page.value = 1
}
function resetFilters() {
filters.value = { customerId: '', inboundCode: '', warehouseId: '', expectedArrivalRange: [] }
appliedFilters.value = { ...filters.value, expectedArrivalRange: [] }
if (page.value === 1) void loadTasks()
else page.value = 1
}
function openTask(row: InboundOrderSummary) {
selectedInbound.value = row
if (isReview.value) {
reviewApproved.value = false
reviewNavigationError.value = null
reviewDialogOpen.value = true
}
else measurementDialogOpen.value = true
}
function openDetail(row: InboundOrderSummary) {
if (!row.orderId) return
void router.push({ name: 'unified-order-detail', params: { orderId: row.orderId }, query: { tenantId: row.tenantId } })
}
async function handleTaskChanged() {
emit('changed')
const target = selectedInbound.value
if (!isReview.value) {
reviewDialogOpen.value = false
selectedInbound.value = null
void loadTasks()
return
}
if (!target || reviewNavigationLoading.value) return
reviewApproved.value = true
reviewNavigationError.value = null
const inboundCode = target.inboundCode?.trim()
const orderId = target.orderId?.trim()
if (!inboundCode && !orderId) {
reviewNavigationError.value = { message: '审核已通过,但未找到对应订单,请关闭弹框后刷新列表重试' }
return
}
reviewNavigationLoading.value = true
try {
if (inboundCode) {
await router.push({
name: 'unified-order-detail-by-inbound-code',
params: { inboundCode },
query: { tab: 'measurement', from: 'measurement-review' },
})
} else {
await router.push({
name: 'unified-order-detail',
params: { orderId },
query: { tenantId: target.tenantId, tab: 'measurement', from: 'measurement-review' },
})
}
reviewDialogOpen.value = false
selectedInbound.value = null
} catch (error) {
reviewNavigationError.value = getApiErrorDetails(error, '订单详情打开失败,请重试')
} finally {
reviewNavigationLoading.value = false
}
}
function handleReviewSelectionChange(rows: InboundOrderSummary[]) {
selectedReviewRows.value = rows.filter((row) => row.measureReviewStatus === 'PENDING')
}
async function batchApproveMeasurements() {
const selected = [...selectedReviewRows.value]
if (!selected.length || batchReviewLoading.value) return
batchReviewLoading.value = true
try {
await ElMessageBox.confirm(
`确认批量审核通过已勾选的 ${selected.length} 条测量数据吗?一件代发通过后进入待组托,其他订单进入待分柜。`,
'批量审核测量数据',
{ customClass: 'admin-density-confirm', confirmButtonText: '确认批量审核', cancelButtonText: '取消', type: 'warning' },
)
let successCount = 0
let failureCount = 0
const failures: string[] = []
const pricingWarnings: string[] = []
for (const row of selected) {
try {
const detail = await getMeasurementReviewDetail(String(row.tenantId), String(row.id))
const approval = await approveMeasurement(String(row.tenantId), String(row.id), {
measurementId: detail.measurementId,
expectedVersion: detail.inboundVersion,
})
successCount += 1
if (!approval.initialPricingSucceeded) {
pricingWarnings.push(
`${row.inboundCode || '入仓码待补充'}:${approval.initialPricingMessage || '费用初算未生成'}`,
)
}
} catch (error) {
failureCount += 1
failures.push(getApiErrorDetails(error, `${row.inboundCode || '入仓码待补充'} 审核失败`).message)
}
}
await loadTasks()
if (successCount) emit('changed')
const reason = [...new Set(failures.filter(Boolean))].slice(0, 3).join(';')
const pricingReason = [...new Set(pricingWarnings.filter(Boolean))].slice(0, 3).join(';')
if (!failureCount && !pricingWarnings.length) ElMessage.success(`已批量审核通过 ${successCount} 条测量数据,费用初算已生成`)
else if (!failureCount) ElMessage.warning(`已批量审核通过 ${successCount} 条测量数据;其中 ${pricingWarnings.length} 条费用初算未生成,可补价后重新计算${pricingReason ? `。${pricingReason}` : ''}`)
else if (successCount) ElMessage.warning(`批量审核完成:成功 ${successCount} 条,失败 ${failureCount} 条${reason ? `。${reason}` : ''}`)
else ElMessage.error(`批量审核失败:${failureCount} 条测量数据未通过${reason ? `。${reason}` : ''}`)
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '批量审核失败').message)
} finally {
batchReviewLoading.value = false
}
}
function handleMeasurementUpdated() {
void loadTasks()
}
function handleMeasurementSubmitted() {
void loadTasks()
}
function clearSelectedInbound() {
selectedInbound.value = null
reviewApproved.value = false
reviewNavigationError.value = null
reviewNavigationLoading.value = false
}
function reload() {
if (page.value === 1) return loadTasks()
page.value = 1
}
defineExpose({ reload })
watch([page, pageSize], () => { void loadTasks() })
onMounted(() => { void loadOptions(); void loadTasks() })
onActivated(() => {
if (!activatedOnce) {
activatedOnce = true
return
}
void loadTasks()
})
onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSerial += 1 })
</script>
<template>
<AdminPage as="div" class="inbound-task-page admin-density admin-workbench">
<ListPageLayout :aria-label="`${taskLabel}待办`">
<section :aria-busy="loading"><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page"
v-model:page-size="pageSize"
:data="result.records"
:columns="columns"
:preference-key="`${preferenceKey}-table`"
:loading="loading"
:total="result.total"
:page-sizes="[20, 50, 100]"
row-key="id"
:empty-text="emptyText"
:aria-label="`${taskLabel}待办列表`"
@selection-change="handleReviewSelectionChange"
>
<template #toolbar-start><slot name="status-tabs"><h1>{{ title || (isReview ? '测量数据审核' : '订单测量') }}</h1></slot></template>
<template #filters>
<slot name="filters" :loading="loading">
<QueryToolbar
v-model="filters"
:ariaLabel="`${taskLabel}待办筛选`"
:fields="queryFields"
:preference-key="`${preferenceKey}-query`"
:search-on-reset="false"
@search="applyFilters"
@reset="resetFilters"
/>
</slot>
</template>
<template #notices><template v-if="customerLoadError || loadError">
<div class="state-stack">
<el-alert v-if="customerLoadError" type="warning" :closable="false" show-icon>
<template #title><span>{{ customerLoadError.message }}</span></template>
</el-alert>
<LoadErrorState v-if="loadError" :title="`${taskLabel}待办暂时无法加载`" :message="loadError.message" @retry="loadTasks">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</div>
</template></template>
<template v-if="isReview && access.can(actionPermission)" #toolbar-actions>
<el-button type="success" :loading="batchReviewLoading" :disabled="loading || !selectedReviewRows.length" @click="batchApproveMeasurements"><CircleCheckBig :size="16" />批量审核通过<span v-if="selectedReviewRows.length">({{ selectedReviewRows.length }})</span></el-button>
</template>
<template #pagination-left>当前显示 {{ result.records.length }} 条 · 共 {{ result.total }} 条</template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions">
<el-button v-if="access.can(actionPermission)" class="density-action" :data-tone="isReview ? 'warning' : 'purple'" size="small" @click="openTask(row as InboundOrderSummary)">
{{ taskLabel }}
</el-button>
<el-button v-if="access.can('platform:firstleg:inbound:view') && (row as InboundOrderSummary).orderId" class="density-action" size="small" @click="openDetail(row as InboundOrderSummary)">
查看
</el-button>
<span v-if="!access.can(actionPermission) && (!access.can('platform:firstleg:inbound:view') || !(row as InboundOrderSummary).orderId)" class="muted-action">无操作权限</span>
</div></template>
<template #inboundCode="{ row }"><div class="inbound-code-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong></div></template>
<template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template>
<template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template>
<template #expectedArrivalAt="{ row }">{{ formatDateOnly((row as InboundOrderSummary).expectedArrivalAt) }}</template>
<template #customerName="{ row }"><LineClampText :content="(row as InboundOrderSummary).customerName" /></template>
<template #warehouseName="{ row }">{{ text((row as InboundOrderSummary).warehouseName) }}</template>
<template #deliveryWarehouseName="{ row }">{{ text((row as InboundOrderSummary).deliveryWarehouseName) }}</template>
<template #businessType="{ row }">{{ firstLegBusinessTypeLabel((row as InboundOrderSummary).businessType) }}</template>
<template #channelName="{ row }"><LineClampText :content="(row as InboundOrderSummary).channelName || (row as InboundOrderSummary).channelCode" /></template>
<template #totalPackageCount="{ row }">{{ text((row as InboundOrderSummary).totalPackageCount) }}</template>
<template #palletCount="{ row }">{{ text((row as InboundOrderSummary).palletCount) }}</template>
<template #actualGrossWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).actualGrossWeightKg) }}</template>
<template #actualNetWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).actualNetWeightKg) }}</template>
<template #actualVolumeCbm="{ row }">{{ formatNumber((row as InboundOrderSummary).actualVolumeCbm) }}</template>
<template #measureCompletedAt="{ row }">{{ formatDate((row as InboundOrderSummary).measureCompletedAt) }}</template>
<template #empty><el-empty :description="emptyText" /></template>
</DataTable></div></section>
</ListPageLayout>
<AppDialog
v-if="isReview"
v-model="reviewDialogOpen"
:title="selectedInbound ? `${taskLabel} · ${selectedInbound.inboundCode || '入仓码待补充'}` : taskLabel"
width="min(1560px, calc(100vw - 48px))"
top="4vh"
fullscreenable
:show-confirm="false"
destroy-on-close
append-to-body
:close-on-click-modal="!reviewNavigationLoading"
:close-on-press-escape="!reviewNavigationLoading"
class="inbound-task-dialog admin-density-dialog admin-density-workspace inbound-density admin-workbench is-dialog"
@closed="clearSelectedInbound"
>
<el-alert v-if="reviewNavigationError" type="error" :closable="false" show-icon class="review-navigation-error">
<template #title>{{ reviewNavigationError.message }}</template>
<ApiErrorMeta :details="reviewNavigationError" />
<el-button v-if="selectedInbound?.inboundCode || selectedInbound?.orderId" link type="danger" :loading="reviewNavigationLoading" @click="handleTaskChanged">重新打开订单详情</el-button>
</el-alert>
<MeasurementReviewPanel
v-if="selectedInbound"
ref="reviewPanelRef"
:show-actions="false"
:tenant-id="selectedInbound.tenantId"
:inbound-id="selectedInbound.id"
:fulfillment-type="selectedInbound.fulfillmentType"
:customer-name="selectedInbound.customerName || selectedInbound.companyName"
:channel-name="selectedInbound.channelName"
:channel-code="selectedInbound.channelCode"
:post-approve-loading="reviewNavigationLoading"
:approval-completed="reviewApproved"
@changed="handleTaskChanged"
/>
<template #footer="{ close }">
<div class="review-dialog-footer">
<el-button :disabled="reviewNavigationLoading" @click="close">关闭</el-button>
<template v-if="reviewPanelRef?.isPending && reviewPanelRef?.canReview">
<el-button type="primary" :loading="reviewPanelRef?.actionLoading === 'approve' || reviewNavigationLoading" :disabled="reviewApproved" @click="reviewPanelRef?.confirmApprove()">{{ reviewApproved ? '审核已通过' : reviewPanelRef?.approvalButtonLabel }}</el-button>
</template>
</div>
</template>
</AppDialog>
<InboundMeasurementDialog
v-if="!isReview && selectedInbound"
v-model="measurementDialogOpen"
:customer-id="selectedInbound.tenantId"
:customer-name="customerDisplayName(selectedInbound)"
:inbound-id="selectedInbound.id"
:inbound-code="selectedInbound.inboundCode"
@updated="handleMeasurementUpdated"
@submitted="handleMeasurementSubmitted"
@closed="clearSelectedInbound"
/>
</AdminPage>
</template>
<style scoped>
.review-dialog-footer { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--admin-space-4, 10px); }
.review-dialog-footer :deep(.el-button) { margin-left: 0; }
.inbound-task-page { min-width: 0; }
.state-stack { display: grid; gap: var(--admin-space-5, 12px); }
.review-navigation-error { margin-bottom: 14px; }
.row-actions { white-space: nowrap; }
.primary-cell { display: grid; gap: 3px; min-width: 0; }
.primary-cell strong, .primary-cell span { overflow-wrap: anywhere; }
.inbound-code-cell { display: grid; gap: 3px; min-width: 0; }
.primary-cell span, .muted-action { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
@media (max-width: 520px) {
.row-actions { align-items: stretch; flex-direction: column; }
.row-actions :deep(.el-button) { width: 100%; }
}
</style>
<style>
.inbound-task-dialog.admin-density-workspace.el-dialog:not(.is-fullscreen) { height: auto; max-height: 92vh; overflow: hidden; }
@media (max-width: 520px) {
.inbound-task-dialog .el-dialog__body { padding: 14px; }
}
</style>