161 lines
12 KiB
Vue
161 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import { createAppointmentForm, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||
import { listPlanningCargo, listPlanningDestinations, resolvePlanningSource, type PlanningCargo } from './planningApi'
|
||
import { useWorkDraft, type LeaveGuard } from './useWorkDraft'
|
||
import type { SourceReference } from './workApi'
|
||
import WorkCreateAppointmentForm from './WorkCreateAppointmentForm.vue'
|
||
|
||
const props = defineProps<{ canWrite: boolean; canLeave: LeaveGuard }>()
|
||
const emit = defineEmits<{ created: [appointment: FbaAppointmentDto] }>()
|
||
const query = ref<QueryModel>({ keyword: '', destination: '' }), keyword = ref(''), destination = ref(''), page = ref(1), total = ref(0)
|
||
const destinations = ref<string[]>([]), destinationsLoading = ref(false), destinationsLoaded = ref(false), destinationError = ref<ApiErrorDetails | null>(null)
|
||
let destinationsRequest = 0, destinationsController: AbortController | null = null
|
||
const fields = computed<QueryField[]>(() => [
|
||
{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 },
|
||
{ key: 'destination', type: 'select', label: '目的FBA仓', placeholder: '搜索或选择目的FBA仓', width: 220,
|
||
options: destinations.value.map(code => ({ label: code, value: code })),
|
||
props: { filterable: true, loading: destinationsLoading.value, onVisibleChange: (visible: boolean) => {
|
||
if (visible && !destinationsLoaded.value) void loadDestinations()
|
||
} } },
|
||
])
|
||
const rows = ref<PlanningCargo[]>([]), selected = ref<Record<string, PlanningCargo>>({})
|
||
const selectedRows = computed(() => Object.values(selected.value))
|
||
const loading = ref(false), resolving = ref(false), error = ref<ApiErrorDetails | null>(null), selectionError = ref<ApiErrorDetails | null>(null)
|
||
const references = ref<SourceReference[] | null>(null), created = ref<FbaAppointmentDto | null>(null)
|
||
const createdForm = computed(() => createAppointmentForm(created.value || undefined))
|
||
useWorkDraft(computed(() => !references.value && !created.value && selectedRows.value.length > 0), computed(() => false))
|
||
let generation = 0, selectionGeneration = 0, controller: AbortController | null = null
|
||
const columns: DataTableColumn[] = [
|
||
{ key: 'select', label: '选择', width: 60, slotName: 'select' },
|
||
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
|
||
{ prop: 'customerName', label: '客户', minWidth: 140, slotName: 'customer' },
|
||
{ prop: 'containerNo', label: '柜号', minWidth: 145 },
|
||
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
|
||
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
|
||
{ prop: 'packages', label: '箱数', width: 80 },
|
||
{ key: 'eligibility', label: '预约资格', minWidth: 130, slotName: 'eligibility' },
|
||
]
|
||
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
|
||
function reason(row: PlanningCargo) {
|
||
if (row.detained) return '货物暂扣中'
|
||
if (row.mixedDestinations) return '多个目的地,需先核对'
|
||
if (row.hasActiveAppointment) return '已有有效预约'
|
||
const anchor = selectedRows.value[0]
|
||
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
|
||
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
|
||
return ''
|
||
}
|
||
function toggle(row: PlanningCargo, checked: boolean | string | number) {
|
||
if (!props.canWrite || resolving.value) return
|
||
if (!checked) delete selected.value[key(row)]
|
||
else if (!reason(row)) selected.value[key(row)] = row
|
||
selectionError.value = null
|
||
}
|
||
async function load(nextPage = page.value) {
|
||
if (!props.canWrite) return
|
||
page.value = nextPage
|
||
const current = ++generation; controller?.abort(); controller = new AbortController()
|
||
loading.value = true; error.value = null
|
||
try {
|
||
const result = await listPlanningCargo({ page: nextPage, size: 20, keyword: keyword.value || undefined,
|
||
...(destination.value ? { destination: destination.value } : {}) }, controller.signal)
|
||
if (current === generation) { rows.value = result.records; total.value = result.total }
|
||
} catch (cause) {
|
||
if (current === generation) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '可预约货物读取失败') }
|
||
} finally { if (current === generation) loading.value = false }
|
||
}
|
||
function search() {
|
||
keyword.value = String(query.value.keyword || '').trim()
|
||
destination.value = String(query.value.destination || '').trim()
|
||
void load(1)
|
||
}
|
||
async function loadDestinations() {
|
||
if (!props.canWrite || destinationsLoading.value) return
|
||
const current = ++destinationsRequest
|
||
destinationsController?.abort(); destinationsController = new AbortController()
|
||
destinationsLoading.value = true; destinationError.value = null
|
||
try {
|
||
const result = await listPlanningDestinations(destinationsController.signal)
|
||
if (current === destinationsRequest) { destinations.value = result; destinationsLoaded.value = true }
|
||
} catch (cause) {
|
||
if (current === destinationsRequest) destinationError.value = getApiErrorDetails(cause, '目的FBA仓选项读取失败')
|
||
} finally { if (current === destinationsRequest) destinationsLoading.value = false }
|
||
}
|
||
async function prepare() {
|
||
if (!props.canWrite || !selectedRows.value.length || loading.value || resolving.value || error.value) return
|
||
const current = ++selectionGeneration, snapshot = [...selectedRows.value]
|
||
resolving.value = true; selectionError.value = null
|
||
try {
|
||
const sources: SourceReference[] = []
|
||
// 仅对明确选中的货物读取最新来源,不按展示行逐票请求,也不猜目的地键。
|
||
for (const row of snapshot) {
|
||
if (current !== selectionGeneration) return
|
||
const { source } = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId })
|
||
if (current !== selectionGeneration) return
|
||
if (!source.canFollowup) { selectionError.value = { message: source.blockReasons.join(';') || '所选货物当前不能新增预约,请重新核对。' }; return }
|
||
sources.push({ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey })
|
||
}
|
||
if (current === selectionGeneration) references.value = sources
|
||
} catch (cause) { if (current === selectionGeneration) selectionError.value = getApiErrorDetails(cause, '所选货物核对失败') }
|
||
finally { if (current === selectionGeneration) resolving.value = false }
|
||
}
|
||
async function back() { if (await props.canLeave()) references.value = null }
|
||
function saved(appointment: FbaAppointmentDto) {
|
||
created.value = appointment; references.value = null; selected.value = {}
|
||
emit('created', appointment)
|
||
}
|
||
async function again() {
|
||
if (!await props.canLeave()) return
|
||
created.value = null; references.value = null; selected.value = {}; selectionError.value = null
|
||
await load()
|
||
}
|
||
onMounted(() => { void load() })
|
||
onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsRequest; controller?.abort(); destinationsController?.abort() })
|
||
</script>
|
||
<template>
|
||
<section class="density-dialog-sections" aria-label="新增FBA送仓预约">
|
||
<template v-if="created">
|
||
<section class="density-dialog-section" data-tone="summary" aria-label="预约保存回执">
|
||
<h3 class="density-dialog-section__title" role="status">预约已保存:{{ created.appointmentNo || '预约编号未返回' }}</h3>
|
||
<table class="density-table"><tbody>
|
||
<tr><th scope="row">系统单号</th><td>{{ created.appointmentNo || '—' }}</td><th scope="row">目的仓时区</th><td>{{ created.timezone || '待核实' }}</td></tr>
|
||
<tr><th scope="row">计划送仓日期</th><td>{{ createdForm.plannedDate || '—' }}</td><th scope="row">本次登记</th><td>{{ ({ PENDING: '等待对方确认', SUCCESS: '已取得对方正式确认', FAILED: '未约成', CANCELLED: '已取消' })[createdForm.appointmentStatus] }}</td></tr>
|
||
<tr><th scope="row">计划开始时段</th><td>{{ createdForm.plannedAt || '—' }}</td><th scope="row">计划结束时段</th><td>{{ createdForm.plannedEndAt || '—' }}</td></tr>
|
||
<tr><th scope="row">外部约号</th><td colspan="3">{{ createdForm.confirmationNo || '—' }}</td></tr>
|
||
<tr><th scope="row">对方确认开始时间</th><td>{{ createdForm.appointmentAt || '—' }}</td><th scope="row">对方确认结束时间</th><td>{{ createdForm.appointmentEndAt || '—' }}</td></tr>
|
||
<tr><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
|
||
<tr><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr>
|
||
</tbody></table>
|
||
<p class="density-dialog-note">预约保存后仍需在原业务安排车辆,日历中的排车以真实车次为准。</p>
|
||
</section>
|
||
<div v-if="canWrite" class="density-actions density-actions--footer"><el-button @click="again">继续新增预约</el-button></div>
|
||
</template>
|
||
<template v-else-if="references">
|
||
<div class="density-actions"><el-button @click="back">返回选择货物</el-button></div>
|
||
<WorkCreateAppointmentForm :sources="references" :can-write="canWrite" @saved="saved" />
|
||
</template>
|
||
<template v-else>
|
||
<section class="density-dialog-section" data-tone="source" aria-label="选择预约货物">
|
||
<DataTable :data="rows" :columns="columns" :page="page" :page-size="20" :page-sizes="[20]" :total="total" :loading="loading" :results-visible="!error" :row-key="key" max-height="" empty-text="暂无可预约货物" @update:page="load($event)">
|
||
<template #filters><QueryToolbar v-model="query" ariaLabel="新增预约货物查询" :fields="fields" :loading="loading || resolving" @search="search" /></template>
|
||
<template #toolbar-actions><InlineHelp title="选货说明"><p>选择需要送往FBA的已有货物;同一起运仓、同一目的仓可合并多个客户的货物。跨页选择会保留。</p></InlineHelp><el-button :disabled="!selectedRows.length || resolving" @click="selected = {}; selectionError = null">清空选择</el-button><el-button type="primary" :disabled="!canWrite || !selectedRows.length || loading || !!error" :loading="resolving" @click="prepare">下一步:填写预约({{ selectedRows.length }})</el-button></template>
|
||
<template #pagination-left>已选 {{ selectedRows.length }}/100 票</template>
|
||
<template #notices><LoadErrorState v-if="destinationError" title="目的FBA仓选项读取失败" :message="destinationError.message" :loading="destinationsLoading" @retry="loadDestinations"><template #details><ApiErrorMeta :details="destinationError" /></template></LoadErrorState><LoadErrorState v-if="error" title="可预约货物读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><LoadErrorState v-if="selectionError" title="所选货物核对失败" :message="selectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="selectionError" /></template></LoadErrorState></template>
|
||
<template #select="{ row }"><el-checkbox :model-value="!!selected[key(row as PlanningCargo)]" :aria-label="`选择 ${row.inboundCode || row.containerNo || '未提供业务编号的货物'}`" :disabled="!canWrite || resolving || (!selected[key(row as PlanningCargo)] && !!reason(row as PlanningCargo))" @change="toggle(row as PlanningCargo, $event)" /></template>
|
||
<template #eligibility="{ row }">{{ reason(row as PlanningCargo) || '可选择' }}</template>
|
||
<template #customer="{ row }"><LineClampText :content="row.customerName" /></template>
|
||
<template #origin="{ row }"><LineClampText :content="row.originWarehouseName" /></template>
|
||
</DataTable>
|
||
</section>
|
||
</template>
|
||
</section>
|
||
</template>
|