feat(appointments): simplify planning and preserve source warehouse intent
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue'
|
||||
@@ -7,14 +10,58 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import RowActions from '@/shared/components/RowActions.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listPlanningCargo, resolvePlanningSource, type ResolvedPlanningSource, type PlanningCargo } from './planningApi'
|
||||
import { listPlanningCargo, resolvePlanningSource, previewPlanningBatch, createSourceAppointment, type SourceAppointmentPayload, type PlanningBatchPreview, type ResolvedPlanningSource, type PlanningCargo } from './planningApi'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const emit = defineEmits<{ created: [] }>()
|
||||
const arrangement = reactive(createAppointmentForm())
|
||||
const creating = ref(false), createError = ref<ApiErrorDetails | null>(null)
|
||||
const pendingCreate = ref<SourceAppointmentPayload | null>(null)
|
||||
const following = ref<ResolvedPlanningSource | null>(null)
|
||||
const followupError = ref<ApiErrorDetails | null>(null)
|
||||
const selected = ref<Record<string, PlanningCargo>>({})
|
||||
const selectedRows = computed(() => Object.values(selected.value))
|
||||
const batchPreview = ref<PlanningBatchPreview | null>(null)
|
||||
const batchLoading = ref(false), batchError = ref<ApiErrorDetails | null>(null)
|
||||
let batchRequest = 0
|
||||
function selectable(row: PlanningCargo) {
|
||||
const anchor = selectedRows.value[0]
|
||||
return !row.detained && !row.mixedDestinations && !row.hasActiveAppointment && (!anchor || (anchor.originWarehouseId === row.originWarehouseId && anchor.destinationWarehouseCode === row.destinationWarehouseCode))
|
||||
}
|
||||
function toggle(row: PlanningCargo, checked: unknown) {
|
||||
batchError.value = null
|
||||
if (checked && selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row
|
||||
else delete selected.value[row.sourceKey]
|
||||
}
|
||||
function selectPage() { for (const row of rows.value) if (selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row }
|
||||
async function previewBatch() {
|
||||
if (!selectedRows.value.length || batchLoading.value) return
|
||||
const current = ++batchRequest
|
||||
batchLoading.value = true; batchError.value = null
|
||||
try {
|
||||
const result = await previewPlanningBatch(selectedRows.value.map(row => ({ sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: `FBA:${row.destinationWarehouseCode}` })))
|
||||
if (current === batchRequest) { batchPreview.value = result; Object.assign(arrangement, createAppointmentForm()); pendingCreate.value = null; createError.value = null }
|
||||
} catch (cause) { if (current === batchRequest) batchError.value = getApiErrorDetails(cause, '所选货物暂不能合批') }
|
||||
finally { if (current === batchRequest) batchLoading.value = false }
|
||||
}
|
||||
async function saveArrangement() {
|
||||
if (!batchPreview.value || creating.value || !access.can(overseasWarehousePermissions.inboundOrderAppointment)) return
|
||||
const error = appointmentFormError(arrangement)
|
||||
if (!pendingCreate.value && error) { ElMessage.warning(error); return }
|
||||
pendingCreate.value ||= { originWarehouseId: batchPreview.value.group.originWarehouseId, fbaWarehouseCode: batchPreview.value.destinationWarehouseCode, sources: batchPreview.value.sources.map(row => ({sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: row.destinationKey})), details: appointmentPayload(arrangement), idempotencyKey: crypto.randomUUID() }
|
||||
creating.value = true; createError.value = null
|
||||
try {
|
||||
const result = await createSourceAppointment(pendingCreate.value)
|
||||
pendingCreate.value = null; batchPreview.value = null; selected.value = {}
|
||||
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('created'); await load()
|
||||
} catch (cause) {
|
||||
createError.value = getApiErrorDetails(cause, '保存结果待核实,请重试原请求')
|
||||
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(createError.value.code || '')) pendingCreate.value = null
|
||||
} finally { creating.value = false }
|
||||
}
|
||||
let followupRequest = 0
|
||||
const query = ref<QueryModel>({ keyword: '' })
|
||||
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 }]
|
||||
@@ -26,6 +73,7 @@ let request = 0
|
||||
let controller: AbortController | undefined
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 150, fixed: 'left' },
|
||||
{ key: 'select', label: '选择', slotName: 'select', width: 65 },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 150 },
|
||||
{ prop: 'containerNo', label: '柜号', minWidth: 150 },
|
||||
@@ -63,18 +111,26 @@ async function openFollowup(row: PlanningCargo) {
|
||||
} catch (cause) { if (request === followupRequest) followupError.value = getApiErrorDetails(cause, '来源核对失败') }
|
||||
}
|
||||
onMounted(() => load())
|
||||
onBeforeUnmount(() => { ++request; ++followupRequest; controller?.abort() })
|
||||
onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller?.abort() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="rows" :total="total" :loading="loading" :results-visible="!error" row-key="sourceKey" @update:page="load()" @update:page-size="load(true)">
|
||||
<template #toolbar-start>已选 {{ selectedRows.length }} 票,可跨客户选择同一起运仓、同一目的仓货物</template>
|
||||
<template #toolbar-actions>
|
||||
<el-button :disabled="loading || batchLoading" @click="selectPage">选择本页可合批货物</el-button>
|
||||
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
|
||||
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
|
||||
</template>
|
||||
<template #filters><QueryToolbar v-model="query" ariaLabel="在途货物查询" :fields="fields" :loading="loading" @search="load(true)" /></template>
|
||||
<template #notices>
|
||||
<LoadErrorState v-if="batchError" title="所选货物暂不能合批" :message="batchError.message" :show-retry="false"><template #details><ApiErrorMeta :details="batchError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="followupError" title="来源核对失败" :message="followupError.message" :show-retry="false" />
|
||||
<LoadErrorState v-if="error" title="已开船货物加载失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-else class="cargo-note">来源预计时间供筹划核对,确认预约前请核对最新船期与货物备妥情况。</p>
|
||||
</template>
|
||||
<template #actions="{ row }"><RowActions :actions="[{ key: 'followup', label: '跟进' }, { key: 'source', label: '查看来源' }]" text-actions @action="key => key === 'followup' ? openFollowup(row as PlanningCargo) : source(row as PlanningCargo)" /></template>
|
||||
<template #select="{ row }"><el-checkbox :model-value="!!selected[row.sourceKey]" :aria-label="`选择 ${row.inboundCode || '未关联入仓码'}`" :disabled="batchLoading || (!selected[row.sourceKey] && (!selectable(row as PlanningCargo) || selectedRows.length >= 100))" @change="value => toggle(row as PlanningCargo, value)" /></template>
|
||||
<template #progress="{ row }">
|
||||
<span v-if="row.detained">暂扣,待处理</span>
|
||||
<span v-else-if="row.mixedDestinations">含多个目的地,待核对</span>
|
||||
@@ -83,6 +139,14 @@ onBeforeUnmount(() => { ++request; ++followupRequest; controller?.abort() })
|
||||
</template>
|
||||
</DataTable>
|
||||
<AppointmentFollowupDialog v-if="following" :source="following.source" :coverage="following.coverage" :can-write="access.can('platform:appointment:followup')" @close="following = null" />
|
||||
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="pendingCreate ? '重试本次保存' : '保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="batchPreview = null">
|
||||
<p>{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}</p>
|
||||
<p>{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱</p>
|
||||
<p v-if="batchPreview.group.readinessIncomplete">货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。</p>
|
||||
<p>保存前尚未占用货物;先保存安排不代表已取得对方确认。</p>
|
||||
<LoadErrorState v-if="createError" title="预约安排保存未完成" :message="createError.message" :show-retry="false"><template #details><ApiErrorMeta :details="createError" /></template></LoadErrorState>
|
||||
<FbaAppointmentFields :model="arrangement" :disabled="creating || !!pendingCreate" />
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user