Files
admin-web/src/modules/overseas-warehouse/pages/InboundOrdersView.vue
T

251 lines
22 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 { computed, onMounted, reactive, ref } from 'vue'
import { CalendarCheck2, Truck } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
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 OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import FbaAppointmentFields from '../components/FbaAppointmentFields.vue'
import { createAppointmentForm, appointmentPayload, appointmentFormError } from '../appointmentForm'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { overseasWarehousePermissions as permission } from '../permissions'
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
import type { InboundOrderListItem } from '../types'
const access = useAccessPreviewStore()
const router = useRouter()
const rows = ref<InboundOrderListItem[]>([])
const selectedRows = ref<InboundOrderListItem[]>([])
const loading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const filters = ref<QueryModel>({ orderNo: '', inboundCode: '', lxCode: '', status: '', warehouse: '', fbaWarehouse: '', appointmentStatus: '', dispatchStatus: '', inboundDate: [] })
const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '入库单号', width: 165, placeholder: '入库单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 145, placeholder: '入仓码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 160, placeholder: '领星入库单代码' },
{ key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待入库', value: 'PENDING_ASSIGNMENT' }, { label: '已签收', value: 'SIGNED' }, { label: '拣货中', value: 'PICKING' }, { label: '全部入库', value: 'COMPLETED' }, { label: '部分入库', value: 'PARTIAL_INBOUND' }] },
{ key: 'warehouse', type: 'input', label: '海外仓', width: 155, placeholder: '全部海外仓' },
{ key: 'fbaWarehouse', type: 'input', label: 'FBA仓库', width: 145, placeholder: '全部FBA仓库' },
{ key: 'appointmentStatus', type: 'select', label: '预约状态', width: 145, placeholder: '全部预约状态', options: [{ label: '未预约', value: 'UNAPPOINTED' }, { label: '预约中', value: 'PENDING' }, { label: '预约成功', value: 'SUCCESS' }, { label: '预约失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }] },
{ key: 'dispatchStatus', type: 'select', label: '排车状态', width: 140, placeholder: '全部排车状态', options: [{ label: '未排车', value: 'UNSCHEDULED' }, { label: '已排车', value: 'SCHEDULED' }] },
{ key: 'inboundDate', type: 'daterange', label: '入库时间', width: 260 },
])
const appointmentOpen = ref(false)
const smartDispatchOpen = ref(false)
const appointmentLoading = ref(false)
const appointmentMode = ref<'create' | 'edit'>('create')
const editingAppointmentNo = ref('')
const appointmentSelectedCount = ref(0)
const appointmentFbaWarehouse = ref('—')
const appointmentVersion = ref(0)
const appointmentForm = reactive(createAppointmentForm())
const appointmentTimezone = ref<string | null>(null)
const pendingAppointment = ref<{appointmentNo: string | null; version: number; originWarehouseId: string; fbaWarehouseCode: string; lineIds: string[]; details: ReturnType<typeof appointmentPayload>; idempotencyKey: string} | null>(null)
const appointmentViewOnly = ref(false)
const appointmentReadOnly = computed(() => appointmentViewOnly.value || !access.can(permission.inboundOrderAppointment))
const appointmentTitle = computed(() => appointmentReadOnly.value ? '查看预约' : 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[]>([])
function selectableRow(source: object) {
const row = source as InboundOrderListItem
if (!row.fbaWarehouse || row.fbaWarehouse === '—') return false
const anchor = selectedRows.value[0]
return !anchor || (anchor.fbaWarehouse === row.fbaWarehouse && anchor.originWarehouseId === row.originWarehouseId)
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: selectableRow },
{ key: 'orderNo', prop: 'orderNo', label: '入库单号', minWidth: 170 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
{ key: 'lxCode', prop: 'lxCode', label: 'LX代码', minWidth: 150 },
{ key: 'type', prop: 'type', label: '类型', width: 100 },
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: 'FBA仓库', minWidth: 130 },
{ key: 'owner', prop: 'owner', label: '货主', minWidth: 150 },
{ key: 'cartons', prop: 'cartons', label: '应拣货数', width: 100, align: 'right' },
{ key: 'pickedCartons', prop: 'pickedCartons', label: '实际拣货数', width: 110, align: 'right' },
{ key: 'grossWeight', prop: 'grossWeight', label: '货物毛重(kg)', width: 120, align: 'right' },
{ key: 'netWeight', prop: 'netWeight', label: '货物净重(kg)', width: 120, align: 'right' },
{ key: 'volume', prop: 'volume', label: '计费体积(m³)', width: 125, align: 'right' },
{ key: 'pallets', prop: 'pallets', label: '总托盘数', width: 100, align: 'right' },
{ key: 'assigned', prop: 'assigned', label: '分配', width: 82, align: 'center' },
{ key: 'mergeAllowed', prop: 'mergeAllowed', label: '合托', width: 96, align: 'center' },
{ key: 'urgent', prop: 'urgent', label: '加急', width: 82, align: 'center' },
{ key: 'status', prop: 'status', label: '状态', width: 100 },
{ key: 'appointmentStatus', prop: 'appointmentStatus', label: 'FBA预约', width: 110 },
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110 },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
]
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
function receivingWarehouse(code: unknown, name: unknown) {
const normalizedCode = String(code ?? '').trim()
const normalizedName = String(name ?? '').trim()
return normalizedCode && normalizedName ? `${normalizedCode} · ${normalizedName}` : '—'
}
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 }
function typeLabel(value: unknown) { return lastMileOrderTypeLabel(value) }
function appointmentStatusLabel(value: unknown) { return !value ? '未预约' : ({ PENDING: '预约中', SUCCESS: '预约成功', FAILED: '预约失败', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value) }
function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
return {
lineId: String(dto.lineId), planId: String(dto.planId), customerId: String(dto.customerId), orderId: String(dto.orderId),
originWarehouseId: String(dto.overseasWarehouseId || ''), warehouseId: text(dto.warehouseId) === '—' ? '' : String(dto.warehouseId), destinationWarehouseCode: text(dto.warehouseCode), destinationWarehouseName: text(dto.warehouseName),
orderNo: text(dto.planNo), inboundCode: text(dto.inboundCode), lxCode: text(dto.lxCode), type: typeLabel(dto.orderType), warehouse: receivingWarehouse(dto.overseasWarehouseCode, dto.overseasWarehouseName),
fbaWarehouse: text(dto.fbaWarehouseCode), owner: text(dto.customerName), cartons: number(dto.packageCount), pickedCartons: number(dto.pickedPackageCount),
grossWeight: number(dto.grossWeightKg), netWeight: number(dto.netWeightKg), volume: number(dto.volumeCbm), pallets: number(dto.palletCount),
assigned: dto.assigned ? '已分配' : '未分配', mergeAllowed: dto.mergeAllowed ? '可合托' : '不合托', urgent: dto.urgent ? '加急' : '普通',
status: inboundPlanStatusLabel(dto.status), appointmentNo: text(dto.appointmentNo), appointmentStatus: appointmentStatusLabel(dto.appointmentStatus),
appointmentAt: formatOverseasDateTime(dto.appointmentAt), expectedLoadingAt: formatOverseasDateTime(dto.expectedLoadingAt), dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车',
createdAt: formatOverseasDateTime(dto.createdAt), version: number(dto.version),
}
}
function onSelectionChange(selection: object[]) { selectedRows.value = selection as InboundOrderListItem[] }
function selectionError() {
if (!selectedRows.value.length) return '请至少选择 1 条入库单'
if (selectedRows.value.some((row) => !row.fbaWarehouse || row.fbaWarehouse === '—')) return '所选入库单缺少 FBA 仓库信息'
if (selectedRows.value.some(row => !row.originWarehouseId) || new Set(selectedRows.value.map(row => row.originWarehouseId)).size !== 1) return '所选入库单必须属于同一接仓点'
if (new Set(selectedRows.value.map((row) => row.fbaWarehouse)).size !== 1) return '所选入库单必须属于同一 FBA 仓库'
return ''
}
function openAppointment() {
if (!access.can(permission.inboundOrderAppointment) || !canLeaveAppointment()) return
appointmentViewOnly.value = false
const error = selectionError()
if (error) { ElMessage.warning(error); return }
appointmentMode.value = 'create'; editingAppointmentNo.value = ''
appointmentSelectedCount.value = selectedRows.value.length; appointmentFbaWarehouse.value = selectedRows.value[0]?.fbaWarehouse || '—'
appointmentVersion.value = 0
Object.assign(appointmentForm, createAppointmentForm()); appointmentTimezone.value = null; pendingAppointment.value = null
mutationError.value = null
appointmentOpen.value = true
}
function openSmartDispatch() {
smartDispatchOpen.value = true
}
async function openAppointmentEdit(source: object, readOnly = false) {
if (!canLeaveAppointment()) return
const row = source as InboundOrderListItem
if (!row.appointmentNo || row.appointmentNo === '—') return
appointmentViewOnly.value = readOnly
appointmentMode.value = 'edit'; editingAppointmentNo.value = row.appointmentNo
appointmentLoading.value = true; mutationError.value = null; appointmentOpen.value = true
try {
const detail = await getFbaAppointment(row.appointmentNo)
appointmentSelectedCount.value = detail.selectedCount
appointmentFbaWarehouse.value = detail.fbaWarehouseCode || row.fbaWarehouse
appointmentVersion.value = detail.version
Object.assign(appointmentForm, createAppointmentForm(detail)); appointmentTimezone.value = detail.timezone || null; pendingAppointment.value = null
} catch (caught) {
appointmentOpen.value = false
mutationError.value = getApiErrorDetails(caught, 'FBA 预约详情加载失败,请重试')
} finally { appointmentLoading.value = false }
}
function canLeaveAppointment() {
if (saving.value || pendingAppointment.value) { ElMessage.warning('请先核实本次预约保存结果,再关闭或切换'); return false }
return true
}
function closeAppointment() { if (canLeaveAppointment()) appointmentOpen.value = false }
function beforeCloseAppointment(done: () => void) { if (canLeaveAppointment()) done() }
onBeforeRouteLeave(canLeaveAppointment)
onBeforeRouteUpdate(canLeaveAppointment)
async function submitAppointment() {
if (appointmentReadOnly.value || saving.value || appointmentLoading.value) return
if (appointmentMode.value === 'create') { const error = selectionError(); if (error) { ElMessage.warning(error); return } }
const validation = appointmentFormError(appointmentForm)
if (!pendingAppointment.value && validation) { ElMessage.warning(validation); return }
saving.value = true; mutationError.value = null
try {
pendingAppointment.value ||= {appointmentNo: appointmentMode.value === 'edit' ? editingAppointmentNo.value : null, version: appointmentVersion.value, originWarehouseId: selectedRows.value[0]?.originWarehouseId || '', fbaWarehouseCode: selectedRows.value[0]?.fbaWarehouse || '', lineIds: selectedRows.value.map(row => row.lineId), details: appointmentPayload(appointmentForm), idempotencyKey: crypto.randomUUID()}
const payload = pendingAppointment.value
if (payload.appointmentNo) await updateFbaAppointment(payload.appointmentNo, { details: payload.details, version: payload.version, idempotencyKey: payload.idempotencyKey })
else await createFbaAppointment({ originWarehouseId: payload.originWarehouseId, fbaWarehouseCode: payload.fbaWarehouseCode, lineIds: payload.lineIds, details: payload.details, idempotencyKey: payload.idempotencyKey })
pendingAppointment.value = null
appointmentOpen.value = false; selectedRows.value = []; ElMessage.success('FBA 预约保存成功'); await load()
} catch (caught) { mutationError.value = getApiErrorDetails(caught, 'FBA 预约保存失败,请重试'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(mutationError.value.code || '')) pendingAppointment.value = null }
finally { saving.value = false }
}
function dateRange() {
const range = Array.isArray(filters.value.inboundDate) ? filters.value.inboundDate : []
return { createdFrom: range[0] ? `${String(range[0]).slice(0, 10)}T00:00:00` : undefined, createdTo: range[1] ? `${String(range[1]).slice(0, 10)}T23:59:59` : undefined }
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
selectedRows.value = []
loading.value = true; loadError.value = null
try {
const result = await listInboundOrders({ page: page.value, size: pageSize.value, planNo: String(filters.value.orderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, lxCode: String(filters.value.lxCode || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined, warehouse: String(filters.value.warehouse || '').trim() || undefined, fbaWarehouseCode: String(filters.value.fbaWarehouse || '').trim() || undefined, appointmentStatus: String(filters.value.appointmentStatus || '').trim() || undefined, dispatchStatus: String(filters.value.dispatchStatus || '').trim() || undefined, ...dateRange() })
rows.value = result.records.map(toListItem); total.value = result.total
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '入库单加载失败,请重试') }
finally { loading.value = false }
}
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as InboundOrderListItem; void router.push({ name: 'overseas-inbound-order-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
async function openPlanEdit(source: object) { const row = source as InboundOrderListItem; planEditTarget.value = row; planEditOpen.value = true; planEditLoading.value = true; mutationError.value = null; try { Object.assign(planEditForm, { warehouseId: row.warehouseId, warehouseCode: row.destinationWarehouseCode === '—' ? '' : row.destinationWarehouseCode, warehouseName: row.destinationWarehouseName === '—' ? '' : row.destinationWarehouseName, version: row.version }); if (!warehouses.value.length) warehouses.value = (await listReceivingOverseasWarehouses()).records } catch (error) { planEditOpen.value = false; mutationError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') } finally { planEditLoading.value = false } }
async function submitPlanEdit() { const target = planEditTarget.value; if (!target || !planEditForm.warehouseId) { ElMessage.warning('请选择目的仓'); return } planEditSaving.value = true; mutationError.value = null; try { await updateInboundPlan(target.orderNo, { customerId: target.customerId, overseasWarehouseId: planEditForm.warehouseId, warehouseCode: planEditForm.warehouseCode, warehouseName: planEditForm.warehouseName, version: planEditForm.version }); planEditOpen.value = false; ElMessage.success('入库单已更新'); await load() } catch (error) { mutationError.value = getApiErrorDetails(error, '入库单更新失败,请重试') } finally { planEditSaving.value = false } }
onMounted(() => { void load() })
</script>
<template>
<AdminPage class="admin-density"><OperationErrorToast :details="mutationError" /><ListPageLayout aria-label="海外仓入库单列表">
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inbound-orders" row-key="lineId" empty-text="暂无入库单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>入库单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="入库单筛选" preference-key="overseas-inbound-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="入库单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="access.can(permission.inboundOrderSmartDispatch)" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" @click="openPlanEdit(row)">编辑</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderList)" size="small" @click="openAppointmentEdit(row, true)">查看预约</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button></div></template>
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog" :model-value="appointmentOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :title="appointmentTitle" :show-confirm="!appointmentReadOnly" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
<div v-loading="appointmentLoading">
<div class="appointment-summary">已选择 {{ appointmentSelectedCount }} 条,FBA仓库:{{ appointmentFbaWarehouse }}</div>
<FbaAppointmentFields :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="appointmentReadOnly || appointmentLoading || saving || !!pendingAppointment" />
</div>
</AppDialog>
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
<AppDialog class="admin-density-dialog" v-model="planEditOpen" title="编辑入库单" confirm-text="保存" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div v-loading="planEditLoading"><el-form class="density-form" label-position="top"><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable style="width:100%" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item><el-form-item label="版本"><el-input-number v-model="planEditForm.version" :min="0" disabled /></el-form-item></el-form></div></AppDialog>
</AdminPage>
</template>
<style scoped>
.appointment-summary { margin-bottom: var(--admin-space-5); padding: var(--admin-space-4) var(--admin-space-5); border: 1px solid #cdddfd; border-radius: var(--admin-radius); background: #f5f9ff; color: #52627a; font-size: var(--admin-font-body); }
.appointment-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.appointment-form :deep(.el-date-editor), .appointment-form :deep(.el-select) { width: 100%; }
.row-actions { min-width: max-content; flex-wrap: nowrap; white-space: nowrap; }
.row-actions :deep(.el-button) { flex: 0 0 auto; }
@media (max-width: 720px) { .appointment-form { grid-template-columns: 1fr; } }
</style>