feat(appointment): integrate work calendar and original business actions
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import AppointmentBatchForm from './AppointmentBatchForm.vue'
|
||||
import { getAppointmentBatch, repartitionAppointments } from './batchApi'
|
||||
import { getWorkItems } from './workApi'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import { workItem } from './work.test-support'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(true) } }))
|
||||
vi.mock('./batchApi', async original => ({ ...await original<typeof import('./batchApi')>(), getAppointmentBatch: vi.fn(), repartitionAppointments: vi.fn() }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkItems: vi.fn() }))
|
||||
let guard: LeaveGuard
|
||||
function render() { return mount(AppointmentBatchForm, { props: { batchKey: 'FBA_APPOINTMENT:1' }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
|
||||
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType || \'button\'" :disabled="disabled"><slot /></button>' },
|
||||
LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' }, ApiErrorMeta: true, CargoVerificationForm: true,
|
||||
} } }) }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); vi.mocked(getAppointmentBatch).mockResolvedValue(batchContext())
|
||||
vi.mocked(repartitionAppointments).mockResolvedValue({ replacedBatchKeys: ['FBA_APPOINTMENT:1'], replacedAppointmentNos: ['AP-001'], appointments: [batchContext('AP-NEW-1', 'FBA_APPOINTMENT:11').appointment, batchContext('AP-NEW-2', 'FBA_APPOINTMENT:12').appointment] })
|
||||
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem({ batchKey: 'FBA_APPOINTMENT:2' })], page: 1, size: 8, total: 1, pages: 1 })
|
||||
})
|
||||
it('submits two conservative allocations without fake confirmation times and renders replacement business numbers', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20)
|
||||
await wrapper.findAll('textarea')[0]!.setValue('分两次送仓'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const request = vi.mocked(repartitionAppointments).mock.calls[0]![0]
|
||||
expect(request.originals).toEqual([{ appointmentNo: 'AP-001', version: 3 }])
|
||||
expect(request.batches.map(row => row.plannedDate)).toEqual([null, null])
|
||||
expect(request.batches[0]!.allocations.map(row => row.packageCount)).toEqual([50, 30])
|
||||
expect(request.batches[1]!.allocations.map(row => row.packageCount)).toEqual([20])
|
||||
expect(request).not.toHaveProperty('appointmentAt')
|
||||
expect(wrapper.text()).toContain('AP-NEW-1'); expect(wrapper.text()).not.toContain('FBA_APPOINTMENT:')
|
||||
expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('locks an unknown outcome and retries the same immutable repartition command', async () => {
|
||||
vi.mocked(repartitionAppointments).mockRejectedValueOnce(new Error('unknown outcome'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20); await wrapper.findAll('textarea')[0]!.setValue('分批')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const first = vi.mocked(repartitionAppointments).mock.calls[0]![0]
|
||||
expect(await guard()).toBe(false); expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(repartitionAppointments).toHaveBeenNthCalledWith(2, first); wrapper.unmount()
|
||||
})
|
||||
it('keeps edited allocations after a version conflict and requires a fresh explicit submit', async () => {
|
||||
vi.mocked(repartitionAppointments).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20); await wrapper.findAll('textarea')[0]!.setValue('保留分批原因')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(getAppointmentBatch).toHaveBeenCalledTimes(2); expect(repartitionAppointments).toHaveBeenCalledTimes(1)
|
||||
expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('20')
|
||||
expect((wrapper.findAll('textarea')[0]!.element as HTMLTextAreaElement).value).toBe('保留分批原因'); wrapper.unmount()
|
||||
})
|
||||
it('shows full appointment cargo when confirmed or replaced batches have no repartition sources', async () => {
|
||||
const context = batchContext(); context.sources = []; context.allowedActions = []; context.blockReasons = ['原批次已有正式确认']; context.appointment.appointmentStatus = 'SUCCESS'
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.get('[aria-label="原批次货物快照"]').text()).toContain('IN-A')
|
||||
expect(wrapper.text()).toContain('FBA-B'); expect(wrapper.text()).toContain('当前批次无分批资格')
|
||||
await wrapper.get('form').trigger('submit'); expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('loads a candidate context before merging and preserves both original versions', async () => {
|
||||
const other = batchContext('AP-002', 'FBA_APPOINTMENT:2'); other.appointment.version = 6
|
||||
vi.mocked(getAppointmentBatch).mockImplementation(async key => key === 'FBA_APPOINTMENT:2' ? other : batchContext())
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '同仓合并安排')!.trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '核对并选择')!.trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('textarea')[0]!.setValue('同仓合并'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(repartitionAppointments).toHaveBeenCalledWith(expect.objectContaining({ originals: [{ appointmentNo: 'AP-001', version: 3 }, { appointmentNo: 'AP-002', version: 6 }], batches: [expect.objectContaining({ allocations: [expect.objectContaining({ customerId: 'customer-a', packageCount: 140 }), expect.objectContaining({ customerId: 'customer-b', packageCount: 60 })] })] }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getAppointmentBatch, repartitionAppointments, allocation, allocationKey, mergeBatchSources, splitBatch, type FbaAppointmentBatchContext, type RepartitionFbaAppointmentsRequest, type RepartitionFbaAppointmentsResponse } from './batchApi'
|
||||
import { getWorkItems, type WorkPage } from './workApi'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
import CargoVerificationForm from './CargoVerificationForm.vue'
|
||||
import type { CargoVerificationSource } from './cargoVerificationApi'
|
||||
const props = defineProps<{ batchKey: string }>()
|
||||
const emit = defineEmits<{ updated: []; appointment: [appointmentNo: string] }>()
|
||||
const access = useAccessPreviewStore(), activeKey = ref(props.batchKey), context = ref<FbaAppointmentBatchContext | null>(null), other = ref<FbaAppointmentBatchContext | null>(null)
|
||||
const mode = ref<'split' | 'merge'>('split'), loading = ref(false), saving = ref(false), ready = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null), pending = ref<RepartitionFbaAppointmentsRequest | null>(null), result = ref<RepartitionFbaAppointmentsResponse | null>(null)
|
||||
const form = reactive({ firstDate: '', nextDate: '', reason: '', remark: '', moved: {} as Record<string, number | string> })
|
||||
const baseline = ref(JSON.stringify(form)), verification = ref<CargoVerificationSource | null>(null)
|
||||
const canLeave = useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
|
||||
const candidates = ref<WorkPage | null>(null), candidatePage = ref(1), keyword = ref(''), candidateLoading = ref(false), candidateError = ref<ApiErrorDetails | null>(null)
|
||||
let candidateRequest = 0
|
||||
const canWrite = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment'))
|
||||
const canSubmit = computed(() => canWrite.value && ready.value && !loading.value && !error.value && !!context.value?.allowedActions.includes(mode.value === 'split' ? 'SPLIT' : 'MERGE') && (mode.value !== 'merge' || !!other.value?.allowedActions.includes('MERGE')))
|
||||
async function load(preserve = false) {
|
||||
loading.value = true; ready.value = false; error.value = null
|
||||
try {
|
||||
context.value = await getAppointmentBatch(activeKey.value)
|
||||
if (other.value?.appointment.batchKey) other.value = await getAppointmentBatch(other.value.appointment.batchKey)
|
||||
if (!preserve) {
|
||||
Object.assign(form, { firstDate: context.value.appointment.plannedDate || '', nextDate: '', reason: '', remark: '', moved: Object.fromEntries(context.value.sources.map(source => [allocationKey(source), 0])) })
|
||||
baseline.value = JSON.stringify(form)
|
||||
}
|
||||
ready.value = true
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '批次资料读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function searchCandidates(page = 1) {
|
||||
const request = ++candidateRequest; candidatePage.value = page; candidates.value = null; candidateError.value = null; candidateLoading.value = true
|
||||
try { const rows = await getWorkItems({ view: 'ALL', types: ['DELIVERY'], keyword: keyword.value.trim() || undefined, page, size: 8 }); if (request === candidateRequest) candidates.value = rows }
|
||||
catch (cause) { if (request === candidateRequest) candidateError.value = getApiErrorDetails(cause, '可合并工作事项读取失败') }
|
||||
finally { if (request === candidateRequest) candidateLoading.value = false }
|
||||
}
|
||||
async function chooseOther(key: string) {
|
||||
if (!context.value || pending.value || saving.value) return
|
||||
candidateLoading.value = true; candidateError.value = null
|
||||
try {
|
||||
const selected = await getAppointmentBatch(key)
|
||||
if (!selected.allowedActions.includes('MERGE')) throw new Error(selected.blockReasons.join(';') || '该批次当前不允许合并')
|
||||
mergeBatchSources([context.value, selected]); other.value = selected
|
||||
} catch (cause) { candidateError.value = getApiErrorDetails(cause, '批次不能合并') }
|
||||
finally { candidateLoading.value = false }
|
||||
}
|
||||
async function changeMode(next: 'split' | 'merge') {
|
||||
if (!await canLeave()) return
|
||||
mode.value = next; other.value = null; result.value = null; await load()
|
||||
if (next === 'merge') await searchCandidates()
|
||||
}
|
||||
function sourceLabel(source: CargoVerificationSource) {
|
||||
const item = context.value?.appointment.items.find(item => item.customerId === source.customerId && item.sourceKey === source.sourceKey)
|
||||
return item?.inboundCode || '原票业务号未提供'
|
||||
}
|
||||
async function verify(source: CargoVerificationSource) { if (await canLeave()) { baseline.value = JSON.stringify(form); verification.value = source } }
|
||||
async function verified() { verification.value = null; await load(); emit('updated') }
|
||||
async function openBatch(key: string) { if (!await canLeave()) return; activeKey.value = key; result.value = null; other.value = null; mode.value = 'split'; await load() }
|
||||
async function save() {
|
||||
if (!context.value || !canSubmit.value || saving.value) return
|
||||
if (!pending.value) {
|
||||
try {
|
||||
if (!form.reason.trim()) { ElMessage.warning('请填写分批或合并原因'); return }
|
||||
const contexts = mode.value === 'merge' && other.value ? [context.value, other.value] : [context.value]
|
||||
const allocations = mode.value === 'split' ? splitBatch(context.value, form.moved) : [mergeBatchSources(contexts).map(source => allocation(source, Number(source.packageCount)))]
|
||||
pending.value = { originals: contexts.map(row => ({ appointmentNo: row.appointment.appointmentNo, version: row.appointment.version })), batches: allocations.map((rows, index) => ({ plannedDate: (index === 0 ? form.firstDate : form.nextDate) || null, remark: form.remark.trim() || null, allocations: rows })), reason: form.reason.trim(), idempotencyKey: crypto.randomUUID() }
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '请核对逐票分配数量'); return }
|
||||
}
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
result.value = await repartitionAppointments(pending.value); pending.value = null; baseline.value = JSON.stringify(form)
|
||||
ElMessage.success(mode.value === 'split' ? '已分为两个待安排批次' : '已合并为一个待安排批次'); emit('updated')
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '调整结果待确认,请重试本次提交'); if (rejectedCommand(cause)) { pending.value = null; await load(true) } }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="预约分批合并">
|
||||
<CargoVerificationForm v-if="verification" :key="`${verification.customerId}:${verification.sourceKey}`" :source="verification" @saved="verified" />
|
||||
<template v-else>
|
||||
<h3>{{ context?.appointment.appointmentNo || '预约批次' }} · 分批与合并</h3>
|
||||
<LoadErrorState v-if="error" title="批次资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在核对批次、来源和核实版本…</p>
|
||||
<template v-if="result"><p>原批次 {{ result.replacedAppointmentNos.join('、') }} 已保留历史并被替代。</p><div v-for="appointment in result.appointments" :key="appointment.appointmentNo" class="batch-result"><strong>{{ appointment.appointmentNo }}</strong><span>{{ appointment.plannedDate || '日期未定' }} · 待安排</span><el-button @click="emit('appointment', appointment.appointmentNo)">登记安排</el-button><el-button v-if="appointment.batchKey" @click="openBatch(appointment.batchKey)">继续分批</el-button></div></template>
|
||||
<template v-else-if="context">
|
||||
<p>{{ context.appointment.fbaWarehouseCode }} · {{ context.appointment.items.length }} 条货物来源;客户与原来源逐票保留。</p>
|
||||
<template v-if="context.appointment.replacementAppointmentNos?.length"><p>该批次已被替代,当前只读。{{ context.appointment.repartitionReason }}</p><div v-for="(number, index) in context.appointment.replacementAppointmentNos" :key="number"><el-button @click="emit('appointment', number)">{{ number }}</el-button><el-button v-if="context.appointment.replacementBatchKeys?.[index]" @click="openBatch(context.appointment.replacementBatchKeys[index]!)">查看当前批次</el-button></div></template>
|
||||
<p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
|
||||
<div v-if="!context.sources.length" aria-label="原批次货物快照"><p>当前批次无分批资格,以下为原预约保留的货物资料。</p><div v-for="(item, index) in context.appointment.items" :key="item.id" class="allocation-row"><b>来源 {{ index + 1 }} · {{ item.inboundCode || '原票业务号未提供' }}</b><p>{{ item.fbaWarehouseCode }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null"> 箱</span></p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div></div>
|
||||
<div class="batch-modes"><el-button :disabled="saving || !!pending || !context.allowedActions.includes('SPLIT')" @click="changeMode('split')">分成两次送仓</el-button><el-button :disabled="saving || !!pending || !context.allowedActions.includes('MERGE')" @click="changeMode('merge')">同仓合并安排</el-button></div>
|
||||
<form @submit.prevent="save"><fieldset :disabled="saving || !!pending || loading || !canWrite">
|
||||
<div v-for="(source, index) in context.sources" :key="allocationKey(source)" class="allocation-row"><b>来源 {{ index + 1 }} · {{ sourceLabel(source) }}</b><p>本批 {{ source.packageCount ?? '待核实' }} 箱 · 原箱数 {{ source.originalPackages ?? '未知' }}</p><p>货件:{{ source.shipmentReferences.join('、') || '待核实' }}</p><label v-if="mode === 'split'">分到第二批的箱数<input v-model="form.moved[allocationKey(source)]" type="number" min="0" :max="source.packageCount ?? undefined" step="1" :aria-label="`来源${index + 1}分出箱数`"></label><el-button v-if="context.allowedActions.includes('VERIFY_CARGO')" @click="verify(source)">核实本票资料</el-button></div>
|
||||
<template v-if="mode === 'merge'"><label>查找另一批工作事项<input v-model="keyword" placeholder="预约号、批次号、柜号" maxlength="80" @keydown.enter.prevent="searchCandidates()"></label><el-button :loading="candidateLoading" @click="searchCandidates()">查询批次</el-button><LoadErrorState v-if="candidateError" title="合并候选不可用" :message="candidateError.message" @retry="searchCandidates(candidatePage)" /><div v-for="candidate in candidates?.records.filter(item => item.batchKey && item.batchKey !== activeKey)" :key="candidate.workItemKey" class="batch-candidate"><span>{{ candidate.title }} · {{ candidate.destinationName || '目的仓待核实' }}</span><el-button :disabled="candidateLoading" @click="chooseOther(candidate.batchKey!)">核对并选择</el-button></div><p v-if="candidates && !candidates.records.length">本页未查询到工作事项。</p><div v-if="candidates && candidates.total > 8" class="batch-modes"><el-button :disabled="candidatePage === 1 || candidateLoading" @click="searchCandidates(candidatePage - 1)">上一页</el-button><span>第 {{ candidatePage }} 页</span><el-button :disabled="candidatePage * 8 >= candidates.total || candidateLoading" @click="searchCandidates(candidatePage + 1)">下一页</el-button></div><p v-if="other">已核对:{{ other.appointment.appointmentNo }},{{ other.sources.length }} 条来源;提交时再次逐来源校验。</p></template>
|
||||
<label>{{ mode === 'split' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}<input v-model="form.firstDate" type="date"></label><label v-if="mode === 'split'">第二批目标日期(可不填)<input v-model="form.nextDate" type="date"></label><label>调整原因<textarea v-model="form.reason" required maxlength="1000" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" /></label>
|
||||
</fieldset><LoadErrorState v-if="saveError" title="批次调整未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canSubmit || !!result">{{ pending ? '重试本次调整' : mode === 'split' ? '拆分为两批预约安排' : '合并为一批继续预约' }}</el-button></form>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.allocation-row, .batch-result, .batch-candidate { padding: var(--admin-space-3) 0; border-bottom: 1px solid var(--cc-border); overflow-wrap: anywhere; }.batch-modes, .batch-result, .batch-candidate { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }.batch-candidate > span { flex: 1; }</style>
|
||||
@@ -1,408 +1,126 @@
|
||||
import { inject, onBeforeUnmount } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { createFbaAppointment, getFbaAppointment, updateFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse'
|
||||
import { listArrivalCalendar, listPickupCalendar } from './api'
|
||||
import { listTripCalendar } from './tripCalendar'
|
||||
vi.mock('./tripCalendar', async (original) => ({...(await original<typeof import('./tripCalendar')>()),listTripCalendar:vi.fn()}))
|
||||
import { resolvePlanningSource, saveFollowup, listUnscheduledAppointments } from './planningApi'
|
||||
const routing = vi.hoisted(() => ({ query: {} as Record<string,string>, canSourceRead: true, canPickup: true, canTrips: true, canWrite: true, containerPending: false }))
|
||||
vi.mock('./planningApi', () => ({ resolvePlanningSource: vi.fn(), listPlanningCargo: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listUnscheduledAppointments: vi.fn() }))
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
|
||||
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRoute: () => routing, useRouter: () => ({ push: vi.fn() }) }))
|
||||
|
||||
vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), listPickupCalendar: vi.fn(), deleteAppointment: vi.fn() }))
|
||||
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => (permission !== 'write' || routing.canWrite) && (permission !== 'source-read' || routing.canSourceRead) && (permission !== 'platform:firstleg:terminal-drayage:list' || routing.canPickup) && (permission !== 'platform:fleet:trip:list' || routing.canTrips) }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/modules/overseas-warehouse')>()),
|
||||
createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(),
|
||||
listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(),
|
||||
overseasWarehousePermissions: { inboundOrderAppointment: 'write', lastMileOrderList: 'source-read' },
|
||||
}))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
|
||||
const candidates = [
|
||||
{ lineId: '9007199254740993', customerId: 'A', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '1', planNo: 'IN-A', orderId: '10' },
|
||||
{ lineId: '9007199254740994', customerId: 'B', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '2', planNo: 'IN-B', orderId: '20' },
|
||||
]
|
||||
function render(openCalendar = true) {
|
||||
const wrapper = mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: {
|
||||
AppointmentCancellationDialog: {name:'AppointmentCancellationDialog',template:'<div>取消弹窗</div>'},
|
||||
AppointmentCancellationHistory: {name:'AppointmentCancellationHistory',template:'<div>取消历史详情</div>'},
|
||||
AppointmentCancellationRecords: {name:'AppointmentCancellationRecords',template:'<button data-history-view @click="$emit(\'view\',\'AP-CANCEL\')">查看取消记录</button>'},
|
||||
import { getWorkCalendar, getWorkItems, getWorkSummary } from './workApi'
|
||||
import { listArrivalCalendar } from './api'
|
||||
import { workCalendar, workItem, workSummary, followAction } from './work.test-support'
|
||||
const routing = vi.hoisted(() => ({ query: {} as Record<string, string>, push: vi.fn(), replace: vi.fn(), canReadSource: true }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'platform:overseas-warehouse:last-mile-order:list' || routing.canReadSource }) }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkCalendar: vi.fn(), getWorkItems: vi.fn(), getWorkSummary: vi.fn() }))
|
||||
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), listArrivalCalendar: vi.fn() }))
|
||||
function render() {
|
||||
return mount(AppointmentCalendarView, { global: { stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
AppointmentContainerPanel: { name: 'AppointmentContainerPanel', setup() {
|
||||
const register = inject<(guard: () => boolean) => () => void>('appointment-register-leave-guard')!
|
||||
const unregister = register(() => !routing.containerPending)
|
||||
onBeforeUnmount(unregister)
|
||||
}, template: '<div>整柜跟进工作区</div>' },
|
||||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', mounted() { this.$emit('layers', { arrivals: true, trips: true }) }, props: ['events', 'incomplete', 'notices'], template: '<div><button data-create @click="$emit(\'create\', \'2026-10-09\')">新增预约</button><slot name="notice-actions" /><slot name="toolbar-actions" /></div>' },
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm', 'beforeClose'], template: '<div v-if="modelValue"><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button v-if="showConfirm !== false" data-save @click="$emit(\'confirm\')">保存</button></div>' },
|
||||
ElForm: { template: '<form><slot /></form>' },
|
||||
ElFormItem: { template: '<label><slot /></label>' },
|
||||
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
ElDatePicker: { props: ['modelValue'], template: '<input data-date :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
ElButton: { template: '<button><slot /></button>' },
|
||||
ElSelect: { name: 'ElSelect', emits: ['update:modelValue', 'change'], props: ['modelValue', 'remoteMethod'], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value); $emit(\'change\', $event.target.value)"><option value="">请选择</option><slot /></select>' },
|
||||
ElOption: { props: ['value', 'label'], template: '<option :value="value">{{ label }}</option>' },
|
||||
DataTable: { name: 'DataTable', props: ['data'], template: '<div><slot name="toolbar-actions" /><slot v-for="row in data" name="actions" :row="row"/><button data-select @click="$emit(\'selection-change\', data)">选择货物</button></div>' },
|
||||
LoadErrorState: true, ApiErrorMeta: true, AppointmentFollowupDialog: true, AppointmentCargoPanel: { name: 'AppointmentCargoPanel', props: ['externalSource'], template: '<div>合批工作区</div>' },
|
||||
WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference'], template: '<div data-calendar />' },
|
||||
WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], template: '<div data-detail>{{ itemKey }}</div>' },
|
||||
SourceAppointmentPanel: { name: 'SourceAppointmentPanel', props: ['source'], template: '<div data-cargo />' },
|
||||
LoadErrorState: { props: ['title', 'message'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button @click="$emit(\'retry\')">重试</button></section>' },
|
||||
ApiErrorMeta: true,
|
||||
} } })
|
||||
if (openCalendar) void wrapper.findAll('button').find(button => button.text() === '预约日历')!.trigger('click')
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('appointment calendar data integration', () => {
|
||||
describe('appointment work calendar', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
routing.query = {}; routing.canSourceRead = true; routing.canPickup = true; routing.canTrips = true; routing.canWrite = true; routing.containerPending = false
|
||||
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: ['ONT8', 'LAX9'].map(code => ({ id: code, tenantId: 0, warehouseCode: code, warehouseName: code, warehouseType: 'FBA' as const, status: 'ACTIVE' as const, internalCode: null, countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, remark: null })), page: 1, size: 50, pages: 1, total: 2 })
|
||||
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([])
|
||||
vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [], total: 0})
|
||||
vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = true
|
||||
routing.replace.mockResolvedValue(undefined); routing.push.mockResolvedValue(undefined)
|
||||
vi.mocked(getWorkCalendar).mockResolvedValue(workCalendar)
|
||||
vi.mocked(getWorkSummary).mockResolvedValue(workSummary)
|
||||
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 1, pages: 1 })
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValue([])
|
||||
vi.mocked(listTripCalendar).mockResolvedValue({records:[],unresolvedRecords:[]})
|
||||
vi.mocked(listPickupCalendar).mockResolvedValue({records: [], truncated: false, unclassifiedCount: 0, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'})
|
||||
vi.mocked(listInboundOrders).mockResolvedValue({ records: candidates, total: 2, page: 1, size: 10, pages: 1 })
|
||||
vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] })
|
||||
})
|
||||
|
||||
it('displays the existing D cabinet number in both arrival and pickup events', async () => {
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', cabinetNo: 'D0007', arrivalAt: '2026-10-09T08:00:00', arrivalType: 'ESTIMATED' }])
|
||||
vi.mocked(listPickupCalendar).mockResolvedValueOnce({ records: [{ containerBookingId: '1', cabinetNo: 'D0007', containerNo: null, destinationPortName: null, destinationWarehouseCode: null, fleetSupplierName: null, pickupAppointmentAt: '2026-10-09T09:00:00', actualPickupAt: null }], truncated: false, unclassifiedCount: 0, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED' })
|
||||
it('opens the real work calendar by default and converts its half-open helper range to the inclusive API contract', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
const events = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')
|
||||
expect(events).toHaveLength(2)
|
||||
for (const event of events) {
|
||||
expect(event.fields).toContainEqual({ label: '柜次号', value: 'D0007' })
|
||||
expect(event.title).toContain('D0007')
|
||||
}
|
||||
expect(wrapper.text()).toContain('预约工作日历')
|
||||
expect(getWorkCalendar).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-08', flow: undefined, types: ['PICKUP', 'WAREHOUSE_RECEIPT', 'DELIVERY', 'TRANSPORT', 'EMPTY_RETURN'] }, expect.any(AbortSignal))
|
||||
expect(getWorkSummary).toHaveBeenCalledTimes(1); expect(getWorkItems).not.toHaveBeenCalled(); expect(listArrivalCalendar).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('opens a dated calendar appointment read-only for list-only users', async () => {
|
||||
routing.canWrite=false
|
||||
const record={version:0,id:'1',appointmentNo:'AP-DATED',plannedDate:'2026-10-15',originWarehouseId:'100',appointmentStatus:'PENDING',selectedCount:1,fbaWarehouseCode:'ABE3',items:[]}
|
||||
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([record])
|
||||
vi.mocked(getFbaAppointment).mockResolvedValue(record)
|
||||
const wrapper=render();await flushPromises()
|
||||
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
|
||||
expect(panel.props('events')).toEqual(expect.arrayContaining([expect.objectContaining({key:'AP-DATED',canView:true,canEdit:false})]))
|
||||
panel.vm.$emit('view',{key:'AP-DATED'});await flushPromises()
|
||||
expect(getFbaAppointment).toHaveBeenCalledWith('AP-DATED')
|
||||
expect(wrapper.findComponent({name:'FbaAppointmentFields'}).props('disabled')).toBe(true)
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||
wrapper.findComponent({name:'AppDialog'}).vm.$emit('confirm');await flushPromises()
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
it('uses the dedicated cancellation dialog for an undated appointment instead of editing its status', async () => {
|
||||
const record = { version: 3, id: '1', appointmentNo: 'AP-UNDATED', originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] }
|
||||
vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [record], total: 1})
|
||||
vi.mocked(getFbaAppointment).mockResolvedValue(record)
|
||||
const wrapper=render(); await flushPromises()
|
||||
await wrapper.get('[data-unscheduled]').trigger('click')
|
||||
await wrapper.findAll('button').find(button=>button.text()==='查看 / 继续')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('option[value="CANCELLED"]').exists()).toBe(false)
|
||||
await wrapper.get('[data-cancel-editing]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({name:'AppointmentCancellationDialog'}).attributes('appointment-no')).toBe('AP-UNDATED')
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('loads cancellation records only after opening the dialog and opens cancelled appointments read-only', async () => {
|
||||
const record = { version: 1, id: '1', appointmentNo: 'AP-CANCEL', originWarehouseId: '100', appointmentStatus: 'CANCELLED', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] }
|
||||
vi.mocked(getFbaAppointment).mockResolvedValue(record)
|
||||
const wrapper=render();await flushPromises()
|
||||
expect(wrapper.findComponent({name:'AppointmentCancellationRecords'}).exists()).toBe(false)
|
||||
const records=wrapper.get('[data-cancellations]')
|
||||
await records.trigger('click')
|
||||
await wrapper.get('[data-history-view]').trigger('click');await flushPromises()
|
||||
expect(getFbaAppointment).toHaveBeenCalledWith('AP-CANCEL')
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||
expect(wrapper.findComponent({name:'FbaAppointmentFields'}).props('disabled')).toBe(true)
|
||||
expect(wrapper.findComponent({name:'AppointmentCancellationHistory'}).exists()).toBe(false)
|
||||
const history=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消历史')!
|
||||
;(history.element as HTMLDetailsElement).open=true;await history.trigger('toggle')
|
||||
expect(wrapper.findComponent({name:'AppointmentCancellationHistory'}).exists()).toBe(true)
|
||||
wrapper.findAllComponents({name:'AppDialog'}).at(-1)!.vm.$emit('confirm');await flushPromises()
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
|
||||
it('refreshes an expanded cancellation list after a successful cancellation', async () => {
|
||||
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([{version:0,id:'1',appointmentNo:'AP-CANCEL',originWarehouseId:'100',appointmentStatus:'PENDING',selectedCount:1,fbaWarehouseCode:'ABE3',items:[],plannedDate:'2026-10-15'}])
|
||||
const wrapper=render();await flushPromises()
|
||||
const records=wrapper.get('[data-cancellations]')
|
||||
await records.trigger('click')
|
||||
const previous=wrapper.getComponent({name:'AppointmentCancellationRecords'}).vm
|
||||
wrapper.getComponent({name:'AppointmentCalendarPanel'}).vm.$emit('delete',{key:'AP-CANCEL'});await flushPromises()
|
||||
wrapper.getComponent({name:'AppointmentCancellationDialog'}).vm.$emit('cancelled');await flushPromises()
|
||||
expect(wrapper.getComponent({name:'AppointmentCancellationRecords'}).vm).not.toBe(previous)
|
||||
expect(wrapper.findComponent({name:'AppointmentCancellationDialog'}).exists()).toBe(false)
|
||||
it('sends an explicit safe return context with external business navigation', async () => {
|
||||
routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3', workOrigins: '["work:0"]' }
|
||||
const wrapper = render(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('external', '/overseas-master-data/trips/TR-001?tab=loading'); await flushPromises()
|
||||
expect(routing.replace).toHaveBeenCalledWith({ query: expect.objectContaining({ workItem: 'work:1', workOrigins: '["work:0"]', workPage: '3' }) })
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-master-data/trips/TR-001', query: { tab: 'loading', appointmentReturn: expect.stringContaining('/appointment-calendar?') } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the container view mounted while a child has an unresolved command', async () => {
|
||||
it('retains all global counts and unfiltered global detail through month, flow and all-off layer changes', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
const tabs = wrapper.get('[aria-label="预约工作视图"]').findAll('button')
|
||||
await tabs[1]!.trigger('click'); await flushPromises()
|
||||
routing.containerPending = true
|
||||
await tabs[0]!.trigger('click')
|
||||
expect(tabs[1]!.attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.findComponent({ name: 'AppointmentContainerPanel' }).exists()).toBe(true)
|
||||
routing.containerPending = false
|
||||
await tabs[0]!.trigger('click')
|
||||
expect(tabs[0]!.attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.findComponent({ name: 'AppointmentContainerPanel' }).exists()).toBe(false)
|
||||
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
|
||||
const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' })
|
||||
calendar.vm.$emit('anchor', 1); await flushPromises()
|
||||
calendar.vm.$emit('flow', 'DIRECT'); await flushPromises()
|
||||
const calls = vi.mocked(getWorkCalendar).mock.calls.length
|
||||
calendar.vm.$emit('types', []); await flushPromises()
|
||||
expect(getWorkCalendar).toHaveBeenCalledTimes(calls)
|
||||
expect(getWorkSummary).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.get('[data-view="UNDATED"]').text()).toContain('7')
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
|
||||
expect(wrapper.text()).toContain('作业日期未定'); expect(wrapper.text()).toContain('跨月份、流程与类型')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('opens an unscheduled appointment read-only without allowing a save', async () => {
|
||||
routing.canWrite = false
|
||||
const record = { version: 0, id: '1', appointmentNo: 'AP-READ', originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ONT8', items: [] }
|
||||
vi.mocked(listUnscheduledAppointments).mockResolvedValue({ records: [record], total: 1 })
|
||||
vi.mocked(getFbaAppointment).mockResolvedValue(record)
|
||||
it('uses explicit day and type filters only for date details, and includes completed work through the server bucket', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-unscheduled]').trigger('click')
|
||||
await wrapper.findAll('button').find(b => b.text() === '查看 / 继续')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||
const dialog = wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'FbaAppointmentFields' }).props('disabled')).toBe(true)
|
||||
wrapper.getComponent({ name: 'WorkCalendarPanel' }).vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises()
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, date: '2026-10-15', types: ['PICKUP'], flow: undefined, bucket: undefined }, expect.any(AbortSignal))
|
||||
await wrapper.findAll('.bucket-buttons button').find(button => button.text() === '已完成')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(getWorkItems).mock.calls.at(-1)![0].bucket).toBe('COMPLETED')
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8 }, expect.any(AbortSignal))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('puts calendar first and opens it by default without reloading when switching back', async () => {
|
||||
const wrapper = render(false); await flushPromises()
|
||||
const tabs = wrapper.get('[aria-label="预约工作视图"]').findAll('button')
|
||||
expect(tabs.map(button => button.text())).toEqual(['预约日历', '提柜与到仓', '送仓与排车'])
|
||||
expect(tabs[0]!.attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.text()).not.toContain('美国预约总览')
|
||||
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
|
||||
expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
|
||||
await tabs[1]!.trigger('click'); await flushPromises()
|
||||
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
|
||||
await tabs[0]!.trigger('click'); await flushPromises()
|
||||
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('distinguishes missing country data from truncation instead of suggesting a narrower date range', async () => {
|
||||
vi.mocked(listPickupCalendar).mockResolvedValueOnce({ records: [], truncated: false, unclassifiedCount: 74, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED' })
|
||||
it('shows a failed source as an error, not zero work or all complete', async () => {
|
||||
vi.mocked(getWorkSummary).mockRejectedValue(new Error('source unavailable'))
|
||||
vi.mocked(getWorkCalendar).mockRejectedValue(new Error('source unavailable'))
|
||||
vi.mocked(getWorkItems).mockRejectedValue(new Error('source unavailable'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('74 条提柜记录')
|
||||
expect(wrapper.text()).not.toContain('缩小日期范围')
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('[data-view="ALL"]').text()).toContain('加载失败')
|
||||
expect(wrapper.getComponent({ name: 'WorkCalendarPanel' }).props('failed')).toBe(true)
|
||||
expect(wrapper.text()).toContain('工作明细读取失败'); expect(wrapper.text()).not.toContain('当前范围没有工作事项')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows the original pickup time once without claiming a port time zone and retains partial-source warnings', async () => {
|
||||
vi.mocked(listPickupCalendar).mockResolvedValueOnce({records: [{containerBookingId: '123',cabinetNo: 'D0001',containerNo: 'TEST-CONT',destinationPortName: 'Port',destinationWarehouseCode: 'US-W',fleetSupplierName: 'Fleet',pickupAppointmentAt: '2026-10-09T11:00:00',actualPickupAt: null}],truncated: false,unclassifiedCount: 1,scopeBasis: 'US_DESTINATION_WAREHOUSE',timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'})
|
||||
const wrapper=render(); await flushPromises()
|
||||
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
|
||||
expect(panel.props('events')).toEqual([expect.objectContaining({type:'pickup',date:'2026-10-09',time:'11:00',sourcePath:'/container-loading/overseas/123'})])
|
||||
expect(panel.props('incomplete')).toBe(true)
|
||||
expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('目的仓国家待核实')
|
||||
expect(listPickupCalendar).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('does not request pickup records without pickup list permission and discloses the omitted source', async () => {
|
||||
routing.canPickup=false
|
||||
const wrapper=render(); await flushPromises()
|
||||
expect(listPickupCalendar).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('无提柜查看权限')
|
||||
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('incomplete')).toBe(true)
|
||||
})
|
||||
it('keeps successful arrival data visible when the pickup source fails', async () => {
|
||||
vi.mocked(listPickupCalendar).mockRejectedValueOnce(new Error('pickup unavailable'))
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{containerBookingId:'1',cabinetNo:'D0001',arrivalAt:'2026-10-09T09:00:00',arrivalType:'ESTIMATED'}])
|
||||
const wrapper=render(); await flushPromises()
|
||||
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
|
||||
expect(panel.props('events')).toEqual([expect.objectContaining({type:'arrival',key:'arrival-1'})])
|
||||
expect(panel.props('incomplete')).toBe(true)
|
||||
})
|
||||
it('omits trip requests without trip list permission and keeps other sources usable', async () => {
|
||||
routing.canTrips=false
|
||||
const wrapper=render();await flushPromises()
|
||||
expect(listTripCalendar).not.toHaveBeenCalled();expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('无车次查看权限')
|
||||
})
|
||||
it('keeps unresolved trip origins outside daily slots and clearly labels their UTC source time', async () => {
|
||||
vi.mocked(listTripCalendar).mockResolvedValueOnce({records:[],unresolvedRecords:[{sourceType:'TRIP_PLAN',tripNo:'TR-UNKNOWN',status:'PLANNED',originWarehouseId:null,originWarehouseCode:null,originWarehouseName:null,originCountryCode:null,originTimezone:null,originWarehouseStatus:null,plannedDepartureAt:'2026-10-10T12:00:00Z',expectedDriverEndAt:null,departedAt:null,completedAt:null,stops:[],incompleteReasons:['ORIGIN_WAREHOUSE_UNKNOWN']}]})
|
||||
const wrapper=render();await flushPromises()
|
||||
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([])
|
||||
expect(wrapper.findAll('.appointment-extra summary').map(node => node.text())).not.toContain('待核实地点的车次(1)')
|
||||
expect(wrapper.text()).not.toContain('原计划时间保留 UTC')
|
||||
await wrapper.findAll('button').find(button => button.text() === '查看待核实车次(1)')!.trigger('click')
|
||||
expect(wrapper.text()).toContain('原计划时间保留 UTC')
|
||||
expect(listTripCalendar).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('creates one shared appointment for two customers without sending a fabricated header customer', async () => {
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
|
||||
expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
|
||||
expect(listInboundOrders).not.toHaveBeenCalled()
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-select]').trigger('click')
|
||||
expect(wrapper.find('input[placeholder="填写仓库提供的预约编号"]').exists()).toBe(false)
|
||||
await wrapper.get('[data-save]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(createFbaAppointment).toHaveBeenCalledTimes(1)
|
||||
expect(createFbaAppointment).toHaveBeenCalledWith(expect.objectContaining({
|
||||
originWarehouseId: '100', fbaWarehouseCode: 'ONT8', lineIds: ['9007199254740993', '9007199254740994'], idempotencyKey: expect.any(String), details: expect.objectContaining({plannedDate: '2026-10-09', appointmentStatus: 'PENDING', appointmentAt: null, confirmationNo: null}),
|
||||
}))
|
||||
expect(vi.mocked(createFbaAppointment).mock.calls[0]![0]).not.toHaveProperty('customerId')
|
||||
expect(listInboundOrders).toHaveBeenCalledTimes(1)
|
||||
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('only searches active FBA warehouses and loads cargo automatically after selection', async () => {
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(expect.objectContaining({ warehouseType: 'FBA', status: 'ACTIVE', size: 50 }))
|
||||
expect(wrapper.find('input[placeholder="例如 ONT8"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('查询可预约货物')
|
||||
expect(wrapper.findComponent({name:'AppointmentCancellationRecords'}).exists()).toBe(false)
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
|
||||
await flushPromises()
|
||||
expect(listInboundOrders).toHaveBeenLastCalledWith({ page: 1, size: 10, fbaWarehouseCode: 'ONT8' })
|
||||
})
|
||||
it('keeps a pending unknown save retry on the same immutable command', async () => {
|
||||
vi.mocked(createFbaAppointment).mockRejectedValueOnce(new Error('response lost'))
|
||||
it('ignores a late response after the global scope changes', async () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof getWorkItems>>) => void
|
||||
vi.mocked(getWorkItems).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-create]').trigger('click'); await flushPromises()
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8'); await flushPromises()
|
||||
await wrapper.get('[data-select]').trigger('click')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(createFbaAppointment).mock.calls[0]![0]
|
||||
await wrapper.get('[data-close]').trigger('click')
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(true)
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
expect((wrapper.get('select[aria-label="预约仓库"]').element as HTMLSelectElement).value).toBe('ONT8')
|
||||
const done = vi.fn()
|
||||
wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done)
|
||||
expect(done).not.toHaveBeenCalled()
|
||||
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) {
|
||||
const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean
|
||||
expect(guard()).toBe(false)
|
||||
}
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(createFbaAppointment).mock.calls[1]![0]).toEqual(first)
|
||||
expect(first.details.appointmentAt).toBeNull()
|
||||
expect(first.details.confirmationNo).toBeNull()
|
||||
expect(first.idempotencyKey).toBeTruthy()
|
||||
await wrapper.get('[data-view="OVERDUE"]').trigger('click')
|
||||
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
|
||||
resolveOld({ records: [workItem({ title: '过期响应不得覆盖' })], page: 1, size: 8, total: 1, pages: 1 }); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('过期响应不得覆盖'); expect(wrapper.text()).toContain('批次 SD-001')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps undated arrangements outside calendar time slots', async () => {
|
||||
vi.mocked(listUnscheduledAppointments).mockResolvedValueOnce({ records: [{ id:'1', version:0, appointmentNo:'AP-UNSCHEDULED', originWarehouseId:'100', appointmentStatus:'PENDING', selectedCount:2, fbaWarehouseCode:'ONT8', items:[] }], total:1 })
|
||||
const wrapper=render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('未定时安排(1)')
|
||||
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([])
|
||||
expect(wrapper.findComponent({name:'DataTable'}).exists()).toBe(false)
|
||||
await wrapper.get('[data-unscheduled]').trigger('click')
|
||||
expect(wrapper.findComponent({name:'DataTable'}).props('data')[0]).not.toHaveProperty('appointmentAt')
|
||||
})
|
||||
|
||||
it('changing or clearing the warehouse clears prior cargo selection and blocks saving it', async () => {
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-select]').trigger('click')
|
||||
expect(wrapper.find('input[placeholder="填写仓库提供的预约编号"]').exists()).toBe(false)
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('')
|
||||
await wrapper.get('[data-save]').trigger('click')
|
||||
expect(createFbaAppointment).not.toHaveBeenCalled()
|
||||
expect(listInboundOrders).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.find('[data-select]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('discards cargo responses from the previously selected warehouse', async () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof listInboundOrders>>) => void
|
||||
vi.mocked(listInboundOrders).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
|
||||
vi.mocked(listInboundOrders).mockResolvedValueOnce({ records: [], total: 0, page: 1, size: 10, pages: 0 })
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('LAX9')
|
||||
await flushPromises()
|
||||
resolveOld({ records: candidates, total: 2, page: 1, size: 10, pages: 1 })
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([])
|
||||
})
|
||||
|
||||
it('searches by keyword and exposes warehouse failures without accepting free text', async () => {
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
await flushPromises()
|
||||
const select = wrapper.findAllComponents({ name: 'ElSelect' }).find(item => item.props('remoteMethod'))!
|
||||
vi.mocked(listPlatformWarehouses).mockRejectedValueOnce(new Error('仓库暂不可用'))
|
||||
await select.props('remoteMethod')(' LAX ')
|
||||
await flushPromises()
|
||||
expect(listPlatformWarehouses).toHaveBeenLastCalledWith(expect.objectContaining({ keyword: 'LAX', warehouseType: 'FBA', status: 'ACTIVE' }))
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('仓库加载失败')
|
||||
expect(wrapper.findAll('select[aria-label="预约仓库"] option')).toHaveLength(1)
|
||||
expect(listInboundOrders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps available arrivals and marks the result incomplete when appointments fail', async () => {
|
||||
vi.mocked(listFbaAppointmentCalendar).mockRejectedValueOnce(new Error('预约暂不可用'))
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', cabinetNo: 'D0001', arrivalAt: '2026-10-09T09:00:00', arrivalType: 'ESTIMATED' }])
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(calendar.props('incomplete')).toBe(true)
|
||||
expect(calendar.props('events')).toHaveLength(1)
|
||||
expect(calendar.props('events')[0].type).toBe('arrival')
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('送仓预约加载失败')
|
||||
})
|
||||
|
||||
it('keeps delivery appointments when the arrival source fails', async () => {
|
||||
vi.mocked(listArrivalCalendar).mockRejectedValueOnce(new Error('到港暂不可用'))
|
||||
vi.mocked(listFbaAppointmentCalendar).mockResolvedValueOnce([{ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }])
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(calendar.props('incomplete')).toBe(true)
|
||||
expect(calendar.props('events')[0].key).toBe('APT-AB')
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('船舶到港加载失败')
|
||||
})
|
||||
|
||||
it('resolves an external booking deep link through the backend before opening followup', async () => {
|
||||
routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:ONT8' }
|
||||
vi.mocked(resolvePlanningSource).mockResolvedValueOnce({ source: { sourceKey: 'BOOKING:B-1', customerId: 'A', orderId: null, inboundCode: null, bookingNo: 'B-1', containerNo: null, originWarehouseId: '100', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: 'BOOKING:1', canFollowup: true, blockReasons: [] }, coverage: { status: 'SOURCE_NOT_MATERIALIZED', appointments: [], missingFields: ['REAL_CARGO_MATERIALS'], changeReviewRequired: false } })
|
||||
it('returns from a prerequisite to the original item and preserves the original undated list context', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(resolvePlanningSource).toHaveBeenCalledWith(routing.query)
|
||||
const dialog = wrapper.findComponent({ name: 'AppointmentFollowupDialog' })
|
||||
expect(dialog.props('source').bookingNo).toBe('B-1')
|
||||
expect(dialog.props('source').orderId).toBeNull()
|
||||
expect(dialog.props('coverage').status).toBe('SOURCE_NOT_MATERIALIZED')
|
||||
expect(createFbaAppointment).not.toHaveBeenCalled(); expect(saveFollowup).not.toHaveBeenCalled()
|
||||
expect(dialog.props('canArrange')).toBe(true)
|
||||
dialog.vm.$emit('arrange');await flushPromises()
|
||||
expect(wrapper.findComponent({name:'AppointmentFollowupDialog'}).exists()).toBe(false)
|
||||
expect(wrapper.findComponent({name:'AppointmentCargoPanel'}).props('externalSource')).toMatchObject({sourceKey:'BOOKING:B-1',bookingNo:'B-1',orderId:null})
|
||||
|
||||
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
|
||||
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(followAction)
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'work-pickup-1', followAction); await flushPromises()
|
||||
expect(wrapper.text()).toContain('返回原事项')
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('work-delivery-1')
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not trust stale destination query data or open followup after a source error', async () => {
|
||||
routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:OLD' }
|
||||
vi.mocked(resolvePlanningSource).mockRejectedValueOnce(new Error('目的地已变化'))
|
||||
it('goes directly to the current container unfinished list after route confirmation', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'AppointmentFollowupDialog' }).exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('预约货源核对失败')
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: ['pickup', 'receipt'] }, 'CONFIRM_ROUTE'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'WorkDetailPanel' }).exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('TEST-CN-001 · 本柜待办')
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, containerBookingId: '9007199254740993' }, expect.any(AbortSignal))
|
||||
expect(getWorkSummary).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not request deep-link cargo without the underlying source permission', async () => {
|
||||
routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:ONT8' }; routing.canSourceRead = false
|
||||
it('opens explicit source verification without forging an appointment or guessing a customer', async () => {
|
||||
routing.query = { ...routing.query, sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' }
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'AppointmentFollowupDialog' }).exists()).toBe(false)
|
||||
expect(wrapper.getComponent({ name: 'SourceAppointmentPanel' }).props('source')).toEqual({ sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' })
|
||||
wrapper.unmount()
|
||||
routing.canReadSource = false
|
||||
const denied = render(); await flushPromises()
|
||||
expect(denied.findComponent({ name: 'SourceAppointmentPanel' }).exists()).toBe(false)
|
||||
expect(denied.text()).toContain('没有货源读取权限'); denied.unmount()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,485 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, provide, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import { resolvePlanningSource, listUnscheduledAppointments, type ResolvedPlanningSource, type PlanningSource } from './planningApi'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import {
|
||||
createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar, listInboundOrders, updateFbaAppointment,
|
||||
overseasWarehousePermissions as permission,
|
||||
FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError,
|
||||
type FbaAppointmentDto, type FbaAppointmentDetailsPayload, type InboundOrderSummaryDto,
|
||||
} from '@/modules/overseas-warehouse'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.vue'
|
||||
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
|
||||
import AppointmentCancellationDialog from './AppointmentCancellationDialog.vue'
|
||||
import AppointmentCancellationHistory from './AppointmentCancellationHistory.vue'
|
||||
import AppointmentCancellationRecords from './AppointmentCancellationRecords.vue'
|
||||
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
|
||||
import AppointmentContainerPanel from './AppointmentContainerPanel.vue'
|
||||
import { calendarRange, dateKey } from './calendar'
|
||||
import { listArrivalCalendar, listPickupCalendar, type ArrivalCalendarEntry, type PickupCalendarEntry } from './api'
|
||||
import { listTripCalendar, projectTripCalendar, tripIssues, type TripCalendarEntry } from './tripCalendar'
|
||||
|
||||
const cancellationTarget = ref<FbaAppointmentDto | null>(null)
|
||||
const unscheduledOpen = ref(false)
|
||||
const cancellationsOpen = ref(false), cancellationsRevision = ref(0), cancellationHistoryOpen = ref(false), forceReadOnly = ref(false)
|
||||
const appointmentReadOnly = computed(() => forceReadOnly.value || editing.value?.appointmentStatus === 'CANCELLED' || !access.can(permission.inboundOrderAppointment))
|
||||
const access = useAccessPreviewStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const linkedSource = ref<ResolvedPlanningSource | null>(null)
|
||||
const selectedSource = ref<PlanningSource | null>(null)
|
||||
const linkError = ref<ApiErrorDetails | null>(null)
|
||||
const linkLoading = ref(false)
|
||||
let linkRequest = 0
|
||||
const childLeaveGuards = new Set<() => boolean>()
|
||||
provide('appointment-register-leave-guard', (guard: () => boolean) => {
|
||||
childLeaveGuards.add(guard)
|
||||
return () => { childLeaveGuards.delete(guard) }
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { addDays, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
|
||||
import { listArrivalCalendar, type ArrivalCalendarEntry } from './api'
|
||||
import { getWorkCalendar, getWorkItems, getWorkSummary, workTypes, type WorkAction, type WorkCalendar, type WorkCommand, type WorkCommandResult, type WorkBucket, type WorkFlow, type WorkItemsQuery, type WorkPage, type WorkSummary, type WorkType, type WorkView } from './workApi'
|
||||
import { flowLabels, typeLabels, viewLabels, workDate, workStatus } from './workPresentation'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
import WorkCalendarPanel from './WorkCalendarPanel.vue'
|
||||
import WorkDetailPanel from './WorkDetailPanel.vue'
|
||||
import SourceAppointmentPanel from './SourceAppointmentPanel.vue'
|
||||
import type { PlanningSourceLookup } from './planningApi'
|
||||
import { appointmentExternalTarget } from './workNavigation'
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const stringQuery = (key: string) => typeof route.query[key] === 'string' ? route.query[key] as string : ''
|
||||
const savedAnchor = stringQuery('workAnchor')
|
||||
function initialAnchor() { try { calendarRange(savedAnchor, 'month'); return savedAnchor } catch { return dateKey(new Date()) } }
|
||||
const anchor = ref(initialAnchor())
|
||||
const mode = ref<CalendarMode>(stringQuery('workMode') === 'week' ? 'week' : 'month')
|
||||
const flow = ref<WorkFlow | ''>(['TRANSFER', 'DIRECT', 'UNKNOWN'].includes(stringQuery('workFlow')) ? stringQuery('workFlow') as WorkFlow : '')
|
||||
const types = ref<WorkType[]>(route.query.workTypes !== undefined ? workTypes.filter(type => stringQuery('workTypes').split(',').includes(type)) : [...workTypes])
|
||||
const reference = ref(false), arrivals = ref<ArrivalCalendarEntry[]>([]), referenceReady = ref(false)
|
||||
const calendar = ref<WorkCalendar | null>(null), summary = ref<WorkSummary | null>(null), items = ref<WorkPage | null>(null)
|
||||
const calendarLoading = ref(false), summaryLoading = ref(false), itemsLoading = ref(false), referenceLoading = ref(false)
|
||||
const calendarError = ref<ApiErrorDetails | null>(null), summaryError = ref<ApiErrorDetails | null>(null), itemsError = ref<ApiErrorDetails | null>(null), referenceError = ref<ApiErrorDetails | null>(null)
|
||||
type Scope = { kind: 'global'; view: WorkView } | { kind: 'day'; date: string; types: WorkType[]; flow: WorkFlow | ''; bucket?: WorkBucket } | { kind: 'container'; containerBookingId: string; label: string } | { kind: 'search'; keyword: string } | { kind: 'arrivals'; date: string }
|
||||
const scope = ref<Scope | null>(null), page = ref(1), detailKey = ref<string | null>(null), initialAction = ref<WorkAction | null>(null)
|
||||
const origins = ref<string[]>([]), search = ref(''), sourceLink = ref<PlanningSourceLookup | null>(null), linkError = ref('')
|
||||
const sourceLinkKey = computed(() => sourceLink.value ? `${sourceLink.value.customerId}:${'sourceKey' in sourceLink.value ? sourceLink.value.sourceKey : sourceLink.value.orderId}:${sourceLink.value.destinationKey || ''}` : '')
|
||||
const navigationError = ref('')
|
||||
const summaryButtons: Array<{ view: WorkView; field: keyof WorkSummary }> = [{ view: 'ALL', field: 'all' }, { view: 'FOLLOW_TODAY', field: 'followToday' }, { view: 'OVERDUE', field: 'overdue' }, { view: 'UNDATED', field: 'undated' }, { view: 'REVIEW', field: 'review' }]
|
||||
const buckets: Array<{ key?: WorkBucket; label: string }> = [{ label: '全部' }, { key: 'PENDING', label: '待安排' }, { key: 'CONFIRMED', label: '已落实' }, { key: 'NO_BOOKING_REQUIRED', label: '无需预约' }, { key: 'REVIEW', label: '需复核' }, { key: 'COMPLETED', label: '已完成' }]
|
||||
const range = computed(() => calendarRange(anchor.value, mode.value))
|
||||
const heading = computed(() => {
|
||||
const value = scope.value
|
||||
if (!value) return ''
|
||||
if (value.kind === 'global') return viewLabels[value.view]
|
||||
if (value.kind === 'day') return `${value.date} · ${value.types.length === 1 ? typeLabels[value.types[0]!] : '当天工作'}`
|
||||
if (value.kind === 'container') return `${value.label} · 本柜待办`
|
||||
if (value.kind === 'arrivals') return `${value.date} · 到港参考`
|
||||
return `查找:${value.keyword}`
|
||||
})
|
||||
function switchWorkView(next: 'calendar' | 'cargo' | 'containers') {
|
||||
if (canLeave()) workView.value = next
|
||||
}
|
||||
const workView = ref<'calendar' | 'cargo' | 'containers'>('calendar')
|
||||
const calendarVisited = ref(true)
|
||||
const showTripLayer = ref(false)
|
||||
const cargoVisited = ref(false)
|
||||
watch(workView, value => {
|
||||
if (value === 'cargo') cargoVisited.value = true
|
||||
if (value === 'calendar' && !calendarVisited.value) { calendarVisited.value = true; void load(); void loadUnscheduled() }
|
||||
})
|
||||
const appointments = ref<FbaAppointmentDto[]>([])
|
||||
const arrivals = ref<ArrivalCalendarEntry[]>([])
|
||||
const pickups = ref<PickupCalendarEntry[]>([])
|
||||
const pickupNotice = ref('')
|
||||
const tripEvents = ref<CalendarEvent[]>([])
|
||||
const unresolvedTrips = ref<TripCalendarEntry[]>([])
|
||||
const tripNotice = ref('')
|
||||
const unresolvedDialogOpen = ref(false)
|
||||
const unresolvedPage = ref(1), unresolvedSize = ref(10)
|
||||
const unresolvedRows = computed(() => unresolvedTrips.value.slice((unresolvedPage.value - 1) * unresolvedSize.value, unresolvedPage.value * unresolvedSize.value))
|
||||
const unresolvedColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 110, fixed: 'left' },
|
||||
{ prop: 'tripNo', label: '车次号', minWidth: 210 },
|
||||
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 170 },
|
||||
{ prop: 'plannedDepartureAt', label: '原计划出发(UTC)', minWidth: 225 },
|
||||
{ key: 'issues', label: '待核实资料', slotName: 'issues', minWidth: 230 },
|
||||
]
|
||||
const range = ref(calendarRange(dateKey(new Date()), 'month'))
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const sourceErrors = ref<Array<{ title: string; details: ApiErrorDetails }>>([])
|
||||
const errorTitle = ref('预约日历加载失败')
|
||||
const dialogError = ref<ApiErrorDetails | null>(null)
|
||||
const dialogOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailReady = ref(false)
|
||||
const editing = ref<FbaAppointmentDto | null>(null)
|
||||
const warehouse = ref('')
|
||||
const warehouseOptions = ref<PlatformWarehouseSummaryResource[]>([])
|
||||
const warehouseLoading = ref(false)
|
||||
const warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const warehouseKeyword = ref('')
|
||||
const warehouseTotal = ref(0)
|
||||
let warehouseRequest = 0
|
||||
const candidates = ref<InboundOrderSummaryDto[]>([])
|
||||
const selected = ref<InboundOrderSummaryDto[]>([])
|
||||
const candidateLoading = ref(false)
|
||||
const candidateError = ref<ApiErrorDetails | null>(null)
|
||||
const candidatePage = ref(1)
|
||||
const candidateSize = ref(10)
|
||||
const candidateTotal = ref(0)
|
||||
const searchedWarehouse = ref('')
|
||||
const selectionEpoch = ref(0)
|
||||
let calendarController: AbortController | undefined
|
||||
let calendarRequest = 0
|
||||
let candidateRequest = 0
|
||||
let detailRequest = 0
|
||||
const form = reactive(createAppointmentForm())
|
||||
const pendingSave = ref<{ appointmentNo: string | null; version: number; originWarehouseId: string; fbaWarehouseCode: string; lineIds: string[]; details: FbaAppointmentDetailsPayload; idempotencyKey: string } | null>(null)
|
||||
const unscheduled = ref<FbaAppointmentDto[]>([]), unscheduledTotal = ref(0), unscheduledPage = ref(1), unscheduledSize = ref(10)
|
||||
const unscheduledError = ref<ApiErrorDetails | null>(null)
|
||||
const unscheduledColumns: DataTableColumn[] = [{key:'actions',label:'操作',slotName:'actions',width:100,fixed:'left'},{prop:'appointmentNo',label:'系统预约单号',minWidth:200},{prop:'fbaWarehouseCode',label:'目的仓',width:100},{prop:'selectedCount',label:'票数',width:80}]
|
||||
let unscheduledRequest = 0
|
||||
async function loadUnscheduled() {
|
||||
const current = ++unscheduledRequest; unscheduledError.value = null
|
||||
try { const result = await listUnscheduledAppointments(unscheduledPage.value, unscheduledSize.value); if(current === unscheduledRequest) {unscheduled.value = result.records; unscheduledTotal.value = result.total} }
|
||||
catch(cause) {if(current === unscheduledRequest) unscheduledError.value = getApiErrorDetails(cause, '未定时安排加载失败')}
|
||||
}
|
||||
const statusLabels: Record<string, string> = { PENDING: '安排待确认', SUCCESS: '对方已确认', FAILED: '未约成', CANCELLED: '已取消' }
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'selection', type: 'selection', reserveSelection: true, width: 46, selectable: (row: InboundOrderSummaryDto) => row.fbaWarehouseCode === searchedWarehouse.value && (!row.appointmentStatus || ['FAILED', 'CANCELLED'].includes(row.appointmentStatus)) },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 220, singleLine: false, showOverflowTooltip: false },
|
||||
{ prop: 'customerName', label: '客户', slotName: 'customer', minWidth: 180, singleLine: false, showOverflowTooltip: false },
|
||||
{ prop: 'fbaWarehouseCode', label: '目的仓', width: 110 },
|
||||
{ prop: 'overseasWarehouseName', label: '海外仓', minWidth: 150, singleLine: false, showOverflowTooltip: false },
|
||||
{ prop: 'packageCount', label: '箱数', width: 80 },
|
||||
{ prop: 'palletCount', label: '托数', width: 80 },
|
||||
{ prop: 'appointmentNo', label: '已有预约号', minWidth: 160, singleLine: false, showOverflowTooltip: false },
|
||||
]
|
||||
const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival => ({
|
||||
key: `arrival-${arrival.containerBookingId}`, type: 'arrival' as const,
|
||||
date: arrival.arrivalAt.slice(0, 10), time: arrival.arrivalAt.slice(11, 16),
|
||||
title: `${arrival.voyageNo || arrival.cabinetNo} ${arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港'}`,
|
||||
fields: [
|
||||
{ label: '船名 / 航次', value: [arrival.vesselName, arrival.voyageNo].filter(Boolean).join(' / ') },
|
||||
{ label: '目的港', value: arrival.destinationPortName },
|
||||
{ label: '已装货柜号', value: arrival.containerNo },
|
||||
{ label: '柜次号', value: arrival.cabinetNo },
|
||||
{ label: '货物去向', value: arrival.destinationWarehouseCode },
|
||||
],
|
||||
})), ...pickups.value.map(pickup => ({
|
||||
key: `pickup-${pickup.containerBookingId}`, type: 'pickup' as const,
|
||||
date: pickup.pickupAppointmentAt.slice(0, 10), time: pickup.pickupAppointmentAt.slice(11, 16),
|
||||
title: `${pickup.containerNo || pickup.cabinetNo || '未关联柜号'} 预约提柜`,
|
||||
sourcePath: `/container-loading/overseas/${encodeURIComponent(pickup.containerBookingId)}`,
|
||||
subtitle: '美国目的仓相关提柜安排',
|
||||
fields: [
|
||||
{ label: '柜次号', value: pickup.cabinetNo }, { label: '目的港', value: pickup.destinationPortName },
|
||||
{ label: '目的仓', value: pickup.destinationWarehouseCode }, { label: '车队', value: pickup.fleetSupplierName },
|
||||
{ label: '实际提柜', value: pickup.actualPickupAt || '尚未记录' },
|
||||
{ label: '时间口径', value: '原提柜业务时间,港口时区待核实' },
|
||||
],
|
||||
})), ...appointments.value.map(appointment => ({
|
||||
key: appointment.appointmentNo, type: 'delivery' as const, date: (appointment.appointmentAt || appointment.plannedAt || appointment.plannedDate || '').slice(0, 10),
|
||||
endAt: (appointment.appointmentAt ? appointment.appointmentEndAt : appointment.plannedEndAt) || undefined,
|
||||
time: (appointment.appointmentAt || appointment.plannedAt || '').slice(11, 16) + ((appointment.appointmentAt ? appointment.appointmentEndAt : appointment.plannedEndAt) ? `–${(appointment.appointmentAt ? appointment.appointmentEndAt : appointment.plannedEndAt)!.slice(11, 16)}` : ''), title: `${appointment.fbaWarehouseCode} ${appointment.appointmentStatus === 'SUCCESS' ? '已确认送仓' : '计划送仓'}`,
|
||||
fields: [
|
||||
{ label: '预约仓库', value: appointment.fbaWarehouseCode },
|
||||
{ label: '系统单号', value: appointment.appointmentNo },
|
||||
{ label: '外部约号', value: appointment.confirmationNo },
|
||||
{ label: '地点时区', value: appointment.timezone || '待核实' },
|
||||
{ label: '完整时段', value: `${appointment.appointmentAt || appointment.plannedAt || appointment.plannedDate || '待定'}${(appointment.appointmentAt ? appointment.appointmentEndAt : appointment.plannedEndAt) ? ` — ${appointment.appointmentAt ? appointment.appointmentEndAt : appointment.plannedEndAt}` : ''}` },
|
||||
{ label: '预约状态', value: statusLabels[appointment.appointmentStatus] || '—' },
|
||||
{ label: '货物信息', value: `${appointment.selectedCount} 票 / ${new Set(appointment.items.map(item => item.customerId)).size} 个客户` },
|
||||
{ label: '预约备注', value: appointment.appointmentRemark },
|
||||
].filter(field => field.value != null && field.value !== ''),
|
||||
inboundCodes: appointment.items.map(item => item.inboundCode),
|
||||
canEdit: access.can(permission.inboundOrderAppointment),
|
||||
canView: access.can('platform:appointment:list'),
|
||||
canDelete: access.can('platform:appointment:delete'),
|
||||
})), ...tripEvents.value])
|
||||
|
||||
async function load(nextRange = range.value) {
|
||||
unresolvedDialogOpen.value = false
|
||||
range.value = nextRange
|
||||
const request = ++calendarRequest
|
||||
calendarController?.abort()
|
||||
calendarController = new AbortController()
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
sourceErrors.value = []
|
||||
errorTitle.value = '预约日历加载失败'
|
||||
appointments.value = []
|
||||
arrivals.value = []
|
||||
pickups.value = []
|
||||
tripEvents.value = []; unresolvedTrips.value = []; unresolvedPage.value = 1
|
||||
const canReadPickup = access.can('platform:firstleg:terminal-drayage:list')
|
||||
const canReadTrips = access.can('platform:fleet:trip:list')
|
||||
pickupNotice.value = canReadPickup ? '' : '无提柜查看权限,当前日历不包含提柜预约。'
|
||||
tripNotice.value = canReadTrips ? '' : '无车次查看权限,当前日历不包含用车计划。'
|
||||
try {
|
||||
const [result, arrivalResult, pickupResult, tripResult] = await Promise.allSettled([
|
||||
listFbaAppointmentCalendar(nextRange, calendarController.signal),
|
||||
listArrivalCalendar(nextRange, calendarController.signal),
|
||||
canReadPickup ? listPickupCalendar(nextRange, calendarController.signal) : Promise.resolve(null),
|
||||
canReadTrips ? listTripCalendar(nextRange, calendarController.signal) : Promise.resolve(null),
|
||||
])
|
||||
if (request !== calendarRequest) return
|
||||
if (result.status === 'fulfilled') appointments.value = result.value
|
||||
else sourceErrors.value.push({ title: '送仓预约加载失败', details: getApiErrorDetails(result.reason, '送仓预约加载失败') })
|
||||
if (arrivalResult.status === 'fulfilled') arrivals.value = arrivalResult.value
|
||||
else sourceErrors.value.push({ title: '船舶到港加载失败', details: getApiErrorDetails(arrivalResult.reason, '船舶到港加载失败') })
|
||||
if (pickupResult.status === 'fulfilled' && pickupResult.value) {
|
||||
pickups.value = pickupResult.value.records
|
||||
pickupNotice.value = [pickupResult.value.truncated ? '提柜安排超出读取上限,请缩小日期范围。' : '', Number(pickupResult.value.unclassifiedCount) > 0 ? `有 ${Number(pickupResult.value.unclassifiedCount)} 条提柜记录的目的仓国家待核实,请完善仓库资料后重新核对;这些记录未计入当前范围。` : ''].filter(Boolean).join(' ')
|
||||
} else if (pickupResult.status === 'rejected') sourceErrors.value.push({ title: '提柜预约加载失败', details: getApiErrorDetails(pickupResult.reason, '提柜预约加载失败') })
|
||||
if (tripResult.status === 'fulfilled' && tripResult.value) {
|
||||
const projection = projectTripCalendar(tripResult.value, nextRange)
|
||||
tripEvents.value = projection.events; unresolvedTrips.value = projection.unresolved
|
||||
if (projection.unresolved.length) tripNotice.value = '部分车次无法确定起运地点或当地日期,已单列待核实,未计入每日安排。'
|
||||
} else if (tripResult.status === 'rejected') sourceErrors.value.push({ title: '用车计划加载失败', details: getApiErrorDetails(tripResult.reason, '用车计划加载失败') })
|
||||
} catch (error) {
|
||||
if (request === calendarRequest) loadError.value = getApiErrorDetails(error, '预约日历加载失败')
|
||||
} finally { if (request === calendarRequest) loading.value = false }
|
||||
}
|
||||
|
||||
function openSource(event: CalendarEvent) {
|
||||
if (event.sourcePath) void router.push(event.sourcePath)
|
||||
}
|
||||
|
||||
function canLeave() {
|
||||
if (saving.value || pendingSave.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
|
||||
return [...childLeaveGuards].every(guard => guard())
|
||||
}
|
||||
function beforeCloseAppointment(done: () => void) { if (canLeave()) done() }
|
||||
function closeAppointment() { if (canLeave()) dialogOpen.value = false }
|
||||
const scopeDescription = computed(() => scope.value?.kind === 'day' ? '按所选日期、流程与类型查询,包含已完成事项' : scope.value?.kind === 'arrivals' ? '独立到港来源,不计工作量;来源未提供流程筛选,展示该日全部参考' : '跨月份、流程与类型,包含未预约和未定日期事项')
|
||||
const panelOpen = computed(() => !!scope.value || !!detailKey.value || !!sourceLink.value)
|
||||
const guards = new Set<LeaveGuard>()
|
||||
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => { guards.delete(guard) } })
|
||||
const canLeave: LeaveGuard = async () => { for (const guard of guards) if (!await guard()) return false; return true }
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
function openCreate(date: string) {
|
||||
if (!access.can(permission.inboundOrderAppointment) || !canLeave()) return
|
||||
forceReadOnly.value = false; cancellationHistoryOpen.value = false
|
||||
++candidateRequest
|
||||
++detailRequest
|
||||
detailLoading.value = false
|
||||
candidateLoading.value = false
|
||||
candidatePage.value = 1
|
||||
editing.value = null
|
||||
warehouse.value = ''
|
||||
searchedWarehouse.value = ''
|
||||
candidates.value = []
|
||||
selected.value = []
|
||||
selectionEpoch.value++
|
||||
candidateTotal.value = 0
|
||||
candidateError.value = null
|
||||
dialogError.value = null
|
||||
Object.assign(form, createAppointmentForm(), { plannedDate: date }); pendingSave.value = null
|
||||
dialogOpen.value = true
|
||||
detailReady.value = true
|
||||
void searchWarehouses('')
|
||||
}
|
||||
|
||||
async function openEdit(event: Pick<CalendarEvent, 'key'>, readOnly = false) {
|
||||
if (!canLeave()) return
|
||||
forceReadOnly.value = readOnly; cancellationHistoryOpen.value = false
|
||||
const request = ++detailRequest
|
||||
dialogError.value = null
|
||||
editing.value = [...appointments.value, ...unscheduled.value].find(item => item.appointmentNo === event.key) || null
|
||||
pendingSave.value = null
|
||||
dialogOpen.value = true
|
||||
detailLoading.value = true
|
||||
detailReady.value = false
|
||||
onBeforeRouteUpdate((to, from) => to.query.sourceKey !== from.query.sourceKey || to.query.orderId !== from.query.orderId || to.query.customerId !== from.query.customerId || to.query.destinationKey !== from.query.destinationKey || to.query.workItem !== from.query.workItem ? canLeave() : true)
|
||||
let calendarRequest = 0, summaryRequest = 0, itemsRequest = 0, referenceRequest = 0
|
||||
let calendarController: AbortController | null = null, summaryController: AbortController | null = null, itemsController: AbortController | null = null
|
||||
async function loadCalendar() {
|
||||
const request = ++calendarRequest; calendarController?.abort(); calendarController = new AbortController(); calendarError.value = null; calendar.value = null
|
||||
if (!types.value.length) { calendarLoading.value = false; return }
|
||||
calendarLoading.value = true
|
||||
try {
|
||||
const detail = await getFbaAppointment(event.key)
|
||||
if (request !== detailRequest || !dialogOpen.value) return
|
||||
editing.value = detail
|
||||
detailReady.value = true
|
||||
warehouse.value = detail.fbaWarehouseCode
|
||||
Object.assign(form, createAppointmentForm(detail))
|
||||
} catch (error) {
|
||||
if (request === detailRequest) dialogError.value = getApiErrorDetails(error, '预约详情加载失败')
|
||||
} finally { if (request === detailRequest) detailLoading.value = false }
|
||||
const result = await getWorkCalendar({ from: range.value.from, to: addDays(range.value.to, -1), flow: flow.value || undefined, types: [...types.value] }, calendarController.signal)
|
||||
if (request === calendarRequest) calendar.value = result
|
||||
} catch (cause) { if (request === calendarRequest) calendarError.value = getApiErrorDetails(cause, '工作日历读取失败') }
|
||||
finally { if (request === calendarRequest) calendarLoading.value = false }
|
||||
}
|
||||
|
||||
async function searchWarehouses(keyword: string) {
|
||||
warehouseKeyword.value = keyword
|
||||
const request = ++warehouseRequest
|
||||
warehouseLoading.value = true
|
||||
warehouseError.value = null
|
||||
warehouseOptions.value = []
|
||||
warehouseTotal.value = 0
|
||||
try {
|
||||
const result = await listPlatformWarehouses({ page: 1, size: 50, keyword: keyword.trim() || undefined, warehouseType: 'FBA', status: 'ACTIVE' })
|
||||
if (request !== warehouseRequest || !dialogOpen.value) return
|
||||
warehouseOptions.value = result.records.filter(item => item.warehouseType === 'FBA' && item.status === 'ACTIVE')
|
||||
warehouseTotal.value = result.total
|
||||
} catch (error) {
|
||||
if (request === warehouseRequest) warehouseError.value = getApiErrorDetails(error, '仓库搜索失败,请重试')
|
||||
} finally { if (request === warehouseRequest) warehouseLoading.value = false }
|
||||
async function loadSummary() {
|
||||
const request = ++summaryRequest; summaryController?.abort(); summaryController = new AbortController(); summaryLoading.value = true; summaryError.value = null
|
||||
try { const result = await getWorkSummary(summaryController.signal); if (request === summaryRequest) summary.value = result }
|
||||
catch (cause) { if (request === summaryRequest) { summaryError.value = getApiErrorDetails(cause, '全局待办读取失败'); summary.value = null } }
|
||||
finally { if (request === summaryRequest) summaryLoading.value = false }
|
||||
}
|
||||
|
||||
function changeWarehouse(value: string) {
|
||||
++candidateRequest
|
||||
candidateLoading.value = false
|
||||
candidates.value = []
|
||||
selected.value = []
|
||||
candidateTotal.value = 0
|
||||
candidateError.value = null
|
||||
candidatePage.value = 1
|
||||
selectionEpoch.value++
|
||||
searchedWarehouse.value = value
|
||||
if (value) void loadCandidates()
|
||||
function itemQuery(): WorkItemsQuery | null {
|
||||
const value = scope.value
|
||||
if (!value || value.kind === 'arrivals') return null
|
||||
const query: WorkItemsQuery = { view: value.kind === 'global' ? value.view : 'ALL', page: page.value, size: 8 }
|
||||
if (value.kind === 'day') Object.assign(query, { date: value.date, types: value.types, flow: value.flow || undefined, bucket: value.bucket })
|
||||
if (value.kind === 'container') query.containerBookingId = value.containerBookingId
|
||||
if (value.kind === 'search') query.keyword = value.keyword
|
||||
return query
|
||||
}
|
||||
|
||||
async function loadCandidates() {
|
||||
if (!searchedWarehouse.value) return
|
||||
candidateLoading.value = true
|
||||
candidateError.value = null
|
||||
const request = ++candidateRequest
|
||||
try {
|
||||
const result = await listInboundOrders({ page: candidatePage.value, size: candidateSize.value, fbaWarehouseCode: searchedWarehouse.value })
|
||||
if (request !== candidateRequest || !dialogOpen.value) return
|
||||
candidates.value = result.records
|
||||
candidateTotal.value = result.total
|
||||
} catch (error) {
|
||||
if (request === candidateRequest) {
|
||||
candidates.value = []
|
||||
candidateError.value = getApiErrorDetails(error, '可预约货物加载失败')
|
||||
}
|
||||
} finally { if (request === candidateRequest) candidateLoading.value = false }
|
||||
async function loadItems() {
|
||||
const request = ++itemsRequest; itemsController?.abort(); itemsController = new AbortController(); itemsError.value = null; items.value = null
|
||||
const query = itemQuery(); if (!query) { itemsLoading.value = false; return }
|
||||
itemsLoading.value = true
|
||||
try { const result = await getWorkItems(query, itemsController.signal); if (request === itemsRequest) items.value = result }
|
||||
catch (cause) { if (request === itemsRequest) itemsError.value = getApiErrorDetails(cause, '工作明细读取失败') }
|
||||
finally { if (request === itemsRequest) itemsLoading.value = false }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (appointmentReadOnly.value || saving.value || detailLoading.value || !detailReady.value) return
|
||||
const validation = appointmentFormError(form)
|
||||
if (!pendingSave.value && validation) { ElMessage.warning(validation); return }
|
||||
if (!editing.value) {
|
||||
if (!warehouse.value || warehouse.value !== searchedWarehouse.value || candidateLoading.value || candidateError.value) { ElMessage.warning('请先选择仓库并等待货物加载完成'); return }
|
||||
if (selected.value.some(item => item.fbaWarehouseCode !== warehouse.value)) { ElMessage.warning('请重新选择当前仓库的货物'); return }
|
||||
if (!selected.value.length) { ElMessage.warning('请选择需要预约的货物'); return }
|
||||
if (selected.value.some(item => !item.overseasWarehouseId) || new Set(selected.value.map(item => item.overseasWarehouseId)).size !== 1) { ElMessage.warning('请选择同一接仓点的货物'); return }
|
||||
if (new Set(selected.value.map(item => item.fbaWarehouseCode)).size !== 1) {
|
||||
ElMessage.warning('同一预约的货物必须送往同一目的仓'); return
|
||||
}
|
||||
}
|
||||
saving.value = true
|
||||
dialogError.value = null
|
||||
try {
|
||||
pendingSave.value ||= {appointmentNo: editing.value?.appointmentNo || null, version: editing.value?.version || 0, originWarehouseId: String(selected.value[0]?.overseasWarehouseId || ''), fbaWarehouseCode: searchedWarehouse.value, lineIds: selected.value.map(item => item.lineId), details: appointmentPayload(form), idempotencyKey: crypto.randomUUID()}
|
||||
const payload = pendingSave.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, details: payload.details, idempotencyKey: payload.idempotencyKey, lineIds: payload.lineIds })
|
||||
pendingSave.value = null
|
||||
dialogOpen.value = false
|
||||
ElMessage.success('预约已保存')
|
||||
await Promise.all([load(), loadUnscheduled()])
|
||||
} catch (error) { dialogError.value = getApiErrorDetails(error, '预约保存失败,请核对后重试'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(dialogError.value.code || '')) pendingSave.value = null }
|
||||
finally { saving.value = false }
|
||||
async function loadArrivals() {
|
||||
const request = ++referenceRequest; referenceError.value = null; referenceReady.value = false; arrivals.value = []
|
||||
if (!reference.value) { referenceLoading.value = false; return }
|
||||
if (!access.can('platform:firstleg:port-schedule:list')) { referenceError.value = { message: '当前账号没有到港参考读取权限,工作事项仍可正常查看。' }; return }
|
||||
referenceLoading.value = true
|
||||
try { const result = await listArrivalCalendar({ ...range.value }); if (request === referenceRequest) { arrivals.value = result; referenceReady.value = true } }
|
||||
catch (cause) { if (request === referenceRequest) referenceError.value = getApiErrorDetails(cause, '到港参考读取失败') }
|
||||
finally { if (request === referenceRequest) referenceLoading.value = false }
|
||||
}
|
||||
|
||||
function selectCandidates(rows: object[]) {
|
||||
selected.value = rows as InboundOrderSummaryDto[]
|
||||
async function setScope(value: Scope) {
|
||||
if (!await canLeave()) return
|
||||
scope.value = value; detailKey.value = null; initialAction.value = null; origins.value = []; sourceLink.value = null; page.value = 1; await loadItems()
|
||||
}
|
||||
|
||||
function remove(event: CalendarEvent) {
|
||||
if (!access.can('platform:appointment:delete') || loading.value || !canLeave()) return
|
||||
cancellationTarget.value = appointments.value.find(item => item.appointmentNo === event.key) || null
|
||||
async function openItem(key: string, action?: WorkAction) {
|
||||
if (!await canLeave()) return
|
||||
navigationError.value = ''
|
||||
if (action && origins.value.includes(key)) { navigationError.value = '前置事项关系需要核对,请返回原事项并刷新后重试。'; return }
|
||||
if (detailKey.value && detailKey.value !== key) origins.value.push(detailKey.value)
|
||||
detailKey.value = key; initialAction.value = action || null; sourceLink.value = null
|
||||
}
|
||||
function cancelEditing() {
|
||||
if (!editing.value || editing.value.appointmentStatus === 'CANCELLED' || !access.can('platform:appointment:delete') || !canLeave()) return
|
||||
cancellationTarget.value = editing.value
|
||||
dialogOpen.value = false
|
||||
async function back() {
|
||||
if (!await canLeave()) return
|
||||
const origin = origins.value.pop()
|
||||
detailKey.value = origin || null; initialAction.value = null; sourceLink.value = null
|
||||
if (!origin && !scope.value) scope.value = { kind: 'global', view: 'ALL' }
|
||||
if (!origin) await loadItems()
|
||||
}
|
||||
async function cancelled() {
|
||||
cancellationTarget.value = null; ++cancellationsRevision.value
|
||||
ElMessage.success('预约已取消,可在取消记录中查看原因与原记录')
|
||||
await Promise.all([load(),loadUnscheduled()])
|
||||
async function closePanel() {
|
||||
if (!await canLeave()) return
|
||||
scope.value = null; detailKey.value = null; initialAction.value = null; sourceLink.value = null; origins.value = []; ++itemsRequest; itemsController?.abort()
|
||||
}
|
||||
|
||||
|
||||
watch(dialogOpen, open => {
|
||||
if (!open) { ++candidateRequest; ++detailRequest; ++warehouseRequest; candidateLoading.value = false; warehouseLoading.value = false; detailLoading.value = false; detailReady.value = false }
|
||||
async function filterCalendar(change: () => void) {
|
||||
if (!await canLeave()) return
|
||||
change()
|
||||
if (scope.value?.kind === 'day' || scope.value?.kind === 'arrivals') { scope.value = null; detailKey.value = null; initialAction.value = null; origins.value = [] }
|
||||
await Promise.all([loadCalendar(), loadArrivals()])
|
||||
}
|
||||
async function toggleReference(value: boolean) { reference.value = value; await loadArrivals() }
|
||||
async function searchItems() { if (search.value.trim()) await setScope({ kind: 'search', keyword: search.value.trim() }) }
|
||||
async function changePage(next: number) { page.value = next; await loadItems() }
|
||||
async function setBucket(bucket?: WorkBucket) { if (scope.value?.kind === 'day') { scope.value = { ...scope.value, bucket }; page.value = 1; await loadItems() } }
|
||||
async function saved(result?: WorkCommandResult, command?: WorkCommand['command']) {
|
||||
if (command === 'CONFIRM_ROUTE' && result?.item.containers[0]) {
|
||||
const container = result.item.containers[0]
|
||||
scope.value = { kind: 'container', containerBookingId: container.containerBookingId, label: container.containerNo || container.bookingNo || '本柜' }; page.value = 1; detailKey.value = null; origins.value = []; initialAction.value = null
|
||||
} else { detailKey.value = origins.value.pop() || null; initialAction.value = null; sourceLink.value = null }
|
||||
await Promise.all([loadCalendar(), loadSummary(), loadItems()])
|
||||
}
|
||||
async function refreshWork() { await Promise.all([loadCalendar(), loadSummary(), loadItems()]) }
|
||||
async function external(path: string) {
|
||||
if (!await canLeave()) return
|
||||
const value = scope.value
|
||||
const context = { workAnchor: anchor.value, workMode: mode.value, workFlow: flow.value || undefined, workTypes: types.value.join(','), workItem: detailKey.value || undefined, workOrigins: origins.value.length ? JSON.stringify(origins.value) : undefined, workScope: value?.kind, workView: value?.kind === 'global' ? value.view : undefined, workDate: value?.kind === 'day' ? value.date : undefined, workDayTypes: value?.kind === 'day' ? value.types.join(',') : undefined, workBucket: value?.kind === 'day' ? value.bucket : undefined, workContainer: value?.kind === 'container' ? value.containerBookingId : undefined, workContainerLabel: value?.kind === 'container' ? value.label : undefined, workKeyword: value?.kind === 'search' ? value.keyword : undefined, workPage: String(page.value) }
|
||||
await router.replace({ query: context })
|
||||
await router.push(appointmentExternalTarget(path, context))
|
||||
}
|
||||
onMounted(() => {
|
||||
const scopeKind = stringQuery('workScope'), restoredView = stringQuery('workView')
|
||||
if (scopeKind === 'container' && stringQuery('workContainer')) scope.value = { kind: 'container', containerBookingId: stringQuery('workContainer'), label: stringQuery('workContainerLabel') || '本柜' }
|
||||
else if (scopeKind === 'day' && /^\d{4}-\d{2}-\d{2}$/.test(stringQuery('workDate'))) scope.value = { kind: 'day', date: stringQuery('workDate'), flow: flow.value, types: workTypes.filter(type => stringQuery('workDayTypes').split(',').includes(type)), bucket: buckets.find(bucket => bucket.key === stringQuery('workBucket'))?.key }
|
||||
else if (scopeKind === 'search') scope.value = { kind: 'search', keyword: stringQuery('workKeyword') }
|
||||
else if (scopeKind === 'global') scope.value = { kind: 'global', view: summaryButtons.some(button => button.view === restoredView) ? restoredView as WorkView : 'ALL' }
|
||||
const restoredPage = Number(stringQuery('workPage')); page.value = Number.isInteger(restoredPage) && restoredPage > 0 ? restoredPage : 1
|
||||
detailKey.value = stringQuery('workItem') || null
|
||||
try { const savedOrigins: unknown = JSON.parse(stringQuery('workOrigins') || '[]'); if (Array.isArray(savedOrigins) && savedOrigins.every(value => typeof value === 'string' && value.length < 256)) origins.value = savedOrigins.slice(-10) } catch { origins.value = [] }
|
||||
void loadCalendar(); void loadSummary(); if (scope.value) void loadItems()
|
||||
})
|
||||
|
||||
async function resolveLink() {
|
||||
const request = ++linkRequest
|
||||
linkedSource.value = null; linkError.value = null; linkLoading.value = false
|
||||
const { sourceKey, customerId, destinationKey } = route.query
|
||||
if (!sourceKey && !customerId && !destinationKey) return
|
||||
if (typeof sourceKey !== 'string' || typeof customerId !== 'string' || typeof destinationKey !== 'string') {
|
||||
linkError.value = { message: '货物链接不完整,请返回原登记重新打开' }; return
|
||||
watch(() => [route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
|
||||
sourceLink.value = null; linkError.value = ''
|
||||
const sourceKey = stringQuery('sourceKey'), orderId = stringQuery('orderId'), customerId = stringQuery('customerId'), destinationKey = stringQuery('destinationKey')
|
||||
if (sourceKey || orderId || customerId || destinationKey) {
|
||||
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) linkError.value = '当前账号没有货源读取权限,请联系预约负责人处理。'
|
||||
else if (sourceKey && orderId) linkError.value = '货物链接同时包含两种来源,请从原业务重新打开。'
|
||||
else if (sourceKey && customerId) sourceLink.value = { sourceKey, customerId, destinationKey: destinationKey || undefined }
|
||||
else if (orderId && customerId) sourceLink.value = { orderId, customerId, destinationKey: destinationKey || undefined }
|
||||
else linkError.value = '货物链接缺少客户或原来源,请从原业务重新打开。'
|
||||
}
|
||||
if (!access.can(permission.lastMileOrderList)) { linkError.value = { message: '当前账号没有货源读取权限,请联系预约负责人处理' }; return }
|
||||
linkLoading.value = true
|
||||
try {
|
||||
const result = await resolvePlanningSource({ sourceKey, customerId, destinationKey })
|
||||
if (request === linkRequest) linkedSource.value = result
|
||||
} catch (cause) { if (request === linkRequest) linkError.value = getApiErrorDetails(cause, '预约货源核对失败') }
|
||||
finally { if (request === linkRequest) linkLoading.value = false }
|
||||
}
|
||||
function arrangeLinkedSource() {
|
||||
if (!linkedSource.value || !access.can(permission.inboundOrderAppointment) || !linkedSource.value.source.canFollowup || linkedSource.value.coverage.appointments.length) return
|
||||
selectedSource.value = linkedSource.value.source
|
||||
linkedSource.value = null
|
||||
workView.value = 'cargo'
|
||||
}
|
||||
watch(() => [route.query.sourceKey, route.query.customerId, route.query.destinationKey], resolveLink, { immediate: true })
|
||||
|
||||
onMounted(() => { void load(); void loadUnscheduled() })
|
||||
onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequest; ++detailRequest; ++warehouseRequest; ++linkRequest; calendarController?.abort() })
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++referenceRequest; calendarController?.abort(); summaryController?.abort(); itemsController?.abort() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density" spacing="compact">
|
||||
<p v-if="linkLoading">正在核对预约货源…</p>
|
||||
<LoadErrorState v-if="linkError" title="预约货源核对失败" :message="linkError.message" :loading="linkLoading" @retry="resolveLink" />
|
||||
<AppointmentFollowupDialog v-if="linkedSource" :source="linkedSource.source" :coverage="linkedSource.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(permission.inboundOrderAppointment)" @arrange="arrangeLinkedSource" @close="linkedSource = null" />
|
||||
<div class="appointment-heading">
|
||||
<div class="density-status-tabs" role="group" aria-label="预约工作视图">
|
||||
<button type="button" :class="{ 'is-active': workView === 'calendar' }" :aria-pressed="workView === 'calendar'" @click="switchWorkView('calendar')">预约日历</button>
|
||||
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'containers' }" :aria-pressed="workView === 'containers'" @click="switchWorkView('containers')">提柜与到仓</button>
|
||||
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="switchWorkView('cargo')">送仓与排车</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="cargoVisited" v-show="workView === 'cargo'"><AppointmentCargoPanel :external-source="selectedSource" @source-accepted="selectedSource = null" @created="load(); loadUnscheduled()" /></div>
|
||||
<AppointmentContainerPanel v-if="workView === 'containers'" />
|
||||
<div v-if="calendarVisited" v-show="workView === 'calendar'">
|
||||
<LoadErrorState v-if="loadError" :title="errorTitle" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-for="error in sourceErrors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
|
||||
<AppointmentCalendarPanel :notices="[pickupNotice, showTripLayer ? tripNotice : ''].filter(Boolean)" :events="events" :loading="loading" :incomplete="sourceErrors.length > 0 || !!pickupNotice || (showTripLayer && !!tripNotice)" :can-create="access.can(permission.inboundOrderAppointment)" @layers="showTripLayer = $event.trips" @range="load" @create="openCreate" @view="openEdit($event, true)" @edit="openEdit" @delete="remove" @open="openSource">
|
||||
<template #toolbar-actions>
|
||||
<el-button aria-haspopup="dialog" data-unscheduled @click="unscheduledOpen = true">未定时安排({{ unscheduledError ? '加载失败' : unscheduledTotal }})</el-button>
|
||||
<el-button aria-haspopup="dialog" data-cancellations @click="cancellationsOpen = true">取消记录</el-button>
|
||||
</template>
|
||||
<template #notice-actions><el-button v-if="showTripLayer && unresolvedTrips.length" link type="primary" @click="unresolvedDialogOpen = true">查看待核实车次({{ unresolvedTrips.length }})</el-button></template>
|
||||
</AppointmentCalendarPanel>
|
||||
</div>
|
||||
<AppDialog v-if="unscheduledOpen" v-model="unscheduledOpen" title="未定时安排" width="min(1000px, calc(100vw - 48px))" :show-confirm="false" cancel-text="关闭" class="admin-density-dialog">
|
||||
<LoadErrorState v-if="unscheduledError" title="未定时安排加载失败" :message="unscheduledError.message" @retry="loadUnscheduled" />
|
||||
<DataTable v-model:page="unscheduledPage" v-model:page-size="unscheduledSize" :data="unscheduled" :columns="unscheduledColumns" :total="unscheduledTotal" :results-visible="!unscheduledError" row-key="appointmentNo" empty-text="暂无未定时安排" @update:page="loadUnscheduled" @update:page-size="unscheduledPage = 1; loadUnscheduled()">
|
||||
<template #actions="{ row }"><el-button @click="openEdit({key: row.appointmentNo})">查看 / 继续</el-button></template>
|
||||
</DataTable>
|
||||
</AppDialog>
|
||||
<AppDialog v-if="cancellationsOpen" v-model="cancellationsOpen" title="取消记录" width="min(1160px, calc(100vw - 48px))" :show-confirm="false" cancel-text="关闭" class="admin-density-dialog">
|
||||
<AppointmentCancellationRecords :key="cancellationsRevision" @view="openEdit({key: $event}, true)" />
|
||||
</AppDialog>
|
||||
<AppDialog v-if="unresolvedDialogOpen" v-model="unresolvedDialogOpen" :title="`待核实车次(${unresolvedTrips.length})`" width="min(1000px, calc(100vw - 48px))" :show-confirm="false" class="admin-density-dialog">
|
||||
<p>这些车次缺少地点或时区,暂未计入日历。原计划时间保留 UTC。</p>
|
||||
<DataTable v-model:page="unresolvedPage" v-model:page-size="unresolvedSize" :data="unresolvedRows" :columns="unresolvedColumns" :total="unresolvedTrips.length" row-key="tripNo" @update:page-size="unresolvedPage = 1">
|
||||
<template #actions="{ row }"><el-button class="density-action" @click="router.push(`/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`)">查看来源</el-button></template>
|
||||
<template #issues="{ row }">{{ tripIssues(row as TripCalendarEntry) }}</template>
|
||||
</DataTable>
|
||||
</AppDialog>
|
||||
<AppointmentCancellationDialog v-if="cancellationTarget" :appointment-no="cancellationTarget.appointmentNo" :version="cancellationTarget.version" :selected-count="cancellationTarget.selectedCount" @close="cancellationTarget = null" @cancelled="cancelled" />
|
||||
<AppDialog :model-value="dialogOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :show-confirm="!appointmentReadOnly" class="admin-density-dialog" :title="appointmentReadOnly ? '查看预约' : editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingSave ? '重试本次保存' : '保存'" :confirm-loading="saving" :confirm-disabled="detailLoading || !detailReady || (!editing && (!warehouse || !selected.length || candidateLoading || !!candidateError))" @confirm="save">
|
||||
<LoadErrorState v-if="dialogError" title="预约操作失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
|
||||
<div v-loading="detailLoading">
|
||||
<el-form label-position="top" class="calendar-form density-form">
|
||||
<el-form-item label="预约仓库" required>
|
||||
<el-input v-if="editing" :model-value="warehouse" readonly />
|
||||
<el-select v-else v-model="warehouse" aria-label="预约仓库" filterable remote clearable :remote-method="searchWarehouses" :loading="warehouseLoading" placeholder="搜索仓库编码或名称" @change="changeWarehouse">
|
||||
<el-option v-for="item in warehouseOptions" :key="String(item.id)" :value="item.warehouseCode" :label="item.warehouseName === item.warehouseCode ? item.warehouseCode : `${item.warehouseCode} · ${item.warehouseName}`" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="!editing && warehouseError" title="仓库加载失败" :message="warehouseError.message" @retry="searchWarehouses(warehouseKeyword)" />
|
||||
<p v-if="!editing && warehouseTotal > warehouseOptions.length && !warehouseError" class="appointment-hint">输入更完整的仓库编码或名称,缩小搜索范围。</p>
|
||||
<FbaAppointmentFields :model="form" :appointment-no="editing?.appointmentNo" :timezone="editing?.timezone || warehouseOptions.find(item => item.warehouseCode === warehouse)?.timezone" :disabled="appointmentReadOnly || saving || !!pendingSave" />
|
||||
<el-button v-if="editing && editing.appointmentStatus !== 'CANCELLED' && access.can('platform:appointment:delete')" data-cancel-editing type="danger" plain :disabled="saving || !!pendingSave || !detailReady" @click="cancelEditing">取消预约</el-button>
|
||||
<template v-if="!editing && warehouse">
|
||||
<DataTable :key="selectionEpoch" v-model:page="candidatePage" v-model:page-size="candidateSize" :data="candidates" :columns="columns" :total="candidateTotal" :loading="candidateLoading" row-key="lineId" :results-visible="!candidateError" @selection-change="selectCandidates" @update:page="loadCandidates()" @update:page-size="candidatePage = 1; loadCandidates()">
|
||||
<template #customer="{ row }">{{ row.customerName?.trim() || '客户名称缺失' }}</template>
|
||||
<template #notices><LoadErrorState v-if="candidateError" title="货物加载失败" :message="candidateError.message" @retry="loadCandidates()" /></template>
|
||||
<template #pagination-left>已选 {{ selected.length }} 票,可跨客户选择同一目的仓货物</template>
|
||||
</DataTable>
|
||||
<AdminPage class="admin-density appointment-work" spacing="compact">
|
||||
<header class="work-heading"><h2 class="density-detail-title">预约工作日历</h2><span>已开船事项 · 中转与直送统一查看</span></header>
|
||||
<nav class="work-inbox" aria-label="全局待办">
|
||||
<button v-for="button in summaryButtons" :key="button.view" type="button" :data-view="button.view" :aria-pressed="scope?.kind === 'global' && scope.view === button.view" @click="setScope({ kind: 'global', view: button.view })">{{ viewLabels[button.view] }} <strong>{{ summaryLoading ? '…' : summaryError ? '加载失败' : summary?.[button.field] ?? '—' }}</strong></button><span>不受日历筛选影响 · 数量可重叠</span>
|
||||
</nav>
|
||||
<LoadErrorState v-if="summaryError" title="全局待办读取失败" :message="summaryError.message" :loading="summaryLoading" @retry="loadSummary"><template #details><ApiErrorMeta :details="summaryError" /></template></LoadErrorState>
|
||||
<form class="work-search" role="search" @submit.prevent="searchItems"><label class="sr-only" for="appointment-work-search">查找工作事项</label><input id="appointment-work-search" v-model="search" type="search" maxlength="80" placeholder="柜号、送仓批次、车次、目的仓"><button type="submit">查找事项</button><span>跨日期查找未完成事项 · 含未定日期</span></form>
|
||||
<p v-if="linkError" role="alert">{{ linkError }}</p>
|
||||
<LoadErrorState v-if="calendarError" title="工作日历读取失败" :message="calendarError.message" :loading="calendarLoading" @retry="loadCalendar"><template #details><ApiErrorMeta :details="calendarError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="referenceError" title="到港参考不可用" :message="referenceError.message" :loading="referenceLoading" @retry="loadArrivals"><template #details><ApiErrorMeta :details="referenceError" /></template></LoadErrorState>
|
||||
<div class="work-layout" :class="{ 'has-panel': panelOpen }">
|
||||
<WorkCalendarPanel :anchor="anchor" :mode="mode" :flow="flow" :types="types" :calendar="calendar" :loading="calendarLoading" :failed="!!calendarError" :reference="reference" :arrivals="arrivals" :reference-ready="referenceReady" @anchor="offset => filterCalendar(() => anchor = moveCalendar(anchor, mode, offset))" @today="filterCalendar(() => anchor = dateKey(new Date()))" @mode="value => filterCalendar(() => mode = value)" @flow="value => filterCalendar(() => flow = value)" @types="value => filterCalendar(() => types = value)" @reference="toggleReference" @day="(date, type) => setScope({ kind: 'day', date, types: type ? [type] : [...types], flow })" @arrivals="date => setScope({ kind: 'arrivals', date })" />
|
||||
<aside v-if="panelOpen" class="work-panel" aria-label="工作明细与事项操作">
|
||||
<div class="panel-nav"><button v-if="detailKey || sourceLink" type="button" @click="back">‹ {{ origins.length ? '返回原事项' : '返回工作明细' }}</button><button type="button" @click="closePanel">关闭 ×</button></div>
|
||||
<p v-if="origins.length" class="origin-context">正在处理关联前置事项,完成后可继续原事项。</p>
|
||||
<p v-if="navigationError" role="alert">{{ navigationError }}</p>
|
||||
<SourceAppointmentPanel v-if="sourceLink" :key="sourceLinkKey" :source="sourceLink" :can-leave="canLeave" @updated="refreshWork" />
|
||||
<WorkDetailPanel v-else-if="detailKey" :key="detailKey" :item-key="detailKey" :initial-action="initialAction" :can-leave="canLeave" @open="openItem" @external="external" @saved="saved" @updated="refreshWork" @container="(id, label) => setScope({ kind: 'container', containerBookingId: id, label })" />
|
||||
<template v-else>
|
||||
<h3>{{ heading }}</h3><p class="scope-description">{{ scopeDescription }}</p>
|
||||
<template v-if="scope?.kind === 'arrivals'"><p v-for="arrival in arrivals.filter(row => row.arrivalAt.slice(0, 10) === (scope?.kind === 'arrivals' ? scope.date : ''))" :key="arrival.containerBookingId">{{ arrival.containerNo || arrival.cabinetNo || '—' }} · {{ arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' }} {{ arrival.arrivalAt.replace('T', ' ') }}</p><p>到港时间沿用原船节点事实,不用于生成工作安排或实际执行记录。</p></template>
|
||||
<template v-else>
|
||||
<div v-if="scope?.kind === 'day'" class="bucket-buttons"><button v-for="bucket in buckets" :key="bucket.label" type="button" :aria-pressed="scope.bucket === bucket.key" @click="setBucket(bucket.key)">{{ bucket.label }}</button></div>
|
||||
<LoadErrorState v-if="itemsError" title="工作明细读取失败" :message="itemsError.message" :loading="itemsLoading" @retry="loadItems"><template #details><ApiErrorMeta :details="itemsError" /></template></LoadErrorState>
|
||||
<p v-if="itemsLoading">正在读取明细…</p>
|
||||
<template v-else-if="items && !itemsError"><p>共 {{ items.total }} 项</p><article v-for="item in items.records" :key="item.workItemKey" class="work-row"><div class="work-row-action"><el-button v-if="item.nextAction" type="primary" @click="openItem(item.workItemKey, item.nextAction)">{{ item.nextAction.label }}</el-button><el-button v-else @click="openItem(item.workItemKey)">查看详情</el-button></div><div class="work-row-content"><button type="button" class="item-title" @click="openItem(item.workItemKey)">{{ item.title }}</button><p>{{ typeLabels[item.type] }} · {{ workStatus(item) }} · {{ workDate(item) }} · {{ item.timezone || '地点时区待核实' }}</p><p>{{ item.destinationName || item.originName || '地点待核实' }} · {{ flowLabels[item.flow] }} · {{ item.owner?.name || '待分派' }}</p><p v-if="item.followupDate">跟进 {{ item.followupDate }}</p><p v-for="reason in [...item.overdueReasons, ...item.blockReasons]" :key="reason" class="work-warning">{{ reason }}</p></div></article><p v-if="!items.records.length">当前范围没有工作事项。</p><div v-if="items.total > 8" class="work-pagination"><button type="button" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button><span>{{ page }} / {{ Math.max(1, Math.ceil(items.total / 8)) }}</span><button type="button" :disabled="page * 8 >= items.total" @click="changePage(page + 1)">下一页</button></div></template>
|
||||
</template>
|
||||
</template>
|
||||
<p v-else-if="!editing" class="appointment-hint">先选择预约仓库,即可勾选同仓货物,支持多个客户一起预约。</p>
|
||||
<div v-else class="appointment-cargo">
|
||||
<span>{{ editing.selectedCount }} 票货物</span>
|
||||
<AppointmentInboundCodes :key="editing.appointmentNo" :codes="editing.items.map(item => item.inboundCode)" />
|
||||
<details :key="`cancellations-${editing.appointmentNo}`" @toggle="cancellationHistoryOpen = ($event.target as HTMLDetailsElement).open"><summary>取消历史</summary><AppointmentCancellationHistory v-if="cancellationHistoryOpen" :key="editing.appointmentNo" :appointment-no="editing.appointmentNo" /></details>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</aside>
|
||||
</div>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.appointment-heading { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
|
||||
.appointment-heading .density-status-tabs { flex: 1; margin: 0; }
|
||||
.calendar-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-6); }
|
||||
.calendar-form :deep(.el-select), .calendar-form :deep(.el-date-editor) { width: 100%; }
|
||||
.appointment-cargo { padding: 12px 0; line-height: 1.7; overflow-wrap: anywhere; font-size: 13px; }
|
||||
.appointment-hint { color: var(--el-text-color-secondary); font-size: var(--admin-font-body); margin: var(--admin-space-5) 0; }
|
||||
.work-heading, .work-inbox, .work-search { display: flex; align-items: center; gap: var(--admin-space-5); flex-wrap: wrap; }.work-heading h2 { margin: 0; }.work-heading span, .work-inbox > span, .work-search > span, .scope-description { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.work-inbox { padding: var(--admin-space-3) var(--admin-space-5); border: 1px solid var(--cc-border); background: var(--admin-surface); }.work-inbox button { display: flex; gap: var(--admin-space-3); align-items: center; }.work-search input { width: 320px; }
|
||||
.appointment-work button:not(.el-button), .appointment-work input { font: inherit; }.appointment-work button:not(.el-button) { cursor: pointer; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); padding: 0 var(--admin-space-3); }.appointment-work button:not(.el-button)[aria-pressed=true] { color: var(--el-color-primary); border-color: var(--el-color-primary); }.appointment-work input { min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); }.appointment-work button:not(.el-button):focus-visible, .appointment-work input:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
|
||||
.work-layout { display: grid; grid-template-columns: minmax(0, 1fr); align-items: start; gap: var(--admin-space-5); }.work-layout.has-panel { grid-template-columns: minmax(0, 1fr) minmax(420px, 490px); }.work-panel { border: 1px solid var(--cc-border); padding: var(--admin-space-5); background: var(--admin-surface); min-width: 0; overflow-wrap: anywhere; max-height: 900px; overflow-y: auto; }.panel-nav { display: flex; justify-content: space-between; gap: var(--admin-space-3); position: sticky; top: 0; background: var(--admin-surface); z-index: 1; }.panel-nav > button:last-child { margin-left: auto; }.origin-context { background: var(--el-fill-color-light); padding: var(--admin-space-3); }
|
||||
.work-row { display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: var(--admin-space-3); padding: var(--admin-space-5) 0; border-bottom: 1px solid var(--cc-border); }.work-row-action .el-button { white-space: normal; width: 100%; min-height: var(--admin-control-height); height: auto; padding: var(--admin-space-3); }.work-row-content p { margin: var(--admin-space-2) 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }.work-row-content .item-title { border: 0; padding: 0; text-align: left; min-height: 24px; color: var(--el-text-color-primary); }.work-warning { color: var(--el-color-warning-dark-2); }.work-pagination, .bucket-buttons { display: flex; align-items: center; justify-content: center; gap: var(--admin-space-3); flex-wrap: wrap; margin-top: var(--admin-space-3); }.bucket-buttons { justify-content: flex-start; }.bucket-buttons button { font-size: var(--admin-font-caption); }
|
||||
.work-panel :deep(.work-form fieldset) { border: 0; margin: 0; padding: 0; min-width: 0; }.work-panel :deep(.work-form label:not(.el-form-item__label)) { display: flex; flex-direction: column; gap: var(--admin-space-2); margin-bottom: var(--admin-space-5); font-size: var(--admin-font-caption); }.work-panel :deep(.work-form input:not(.el-input__inner)), .work-panel :deep(.work-form textarea:not(.el-textarea__inner)), .work-panel :deep(.work-form select) { box-sizing: border-box; width: 100%; font: inherit; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: var(--admin-space-2) var(--admin-space-3); }.work-panel :deep(.work-form input[type=checkbox]) { width: auto; align-self: flex-start; }.work-panel :deep(.work-form textarea:not(.el-textarea__inner)) { min-height: 64px; resize: vertical; }.work-panel :deep(.appointment-fields), .work-panel :deep(.appointment-fields__extra-grid) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
@media(max-width: 1100px) { .work-layout.has-panel { grid-template-columns: minmax(0, 1fr); } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import CargoVerificationForm from './CargoVerificationForm.vue'
|
||||
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
const permissions = vi.hoisted(() => ({ canWrite: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !permission.endsWith('fba-appointment') || permissions.canWrite }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
|
||||
const source = { sourceKey: 'ORDER:9007199254740993', customerId: 'customer-1', destinationKey: 'FBA:ONT8' }
|
||||
const context: CargoVerificationContext = {
|
||||
source: { ...source, orderId: '9007199254740993', inboundCode: 'IN-TEST-001', bookingNo: null, containerNo: 'TEST-CN-001', originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: null, canFollowup: true, blockReasons: [] },
|
||||
verification: null, allocatedPackages: 0, unknownAllocationCount: 0, effectivePackages: null, availablePackages: null, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [],
|
||||
}
|
||||
function render() { return mount(CargoVerificationForm, { props: { source }, global: { stubs: { ElButton: { props: ['nativeType'], template: '<button :type="nativeType"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}</div>' }, ApiErrorMeta: true } } }) }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); permissions.canWrite = true; vi.mocked(getCargoVerification).mockResolvedValue(context)
|
||||
vi.mocked(saveCargoVerification).mockResolvedValue({ version: 1, sourceVersion: null, destinationKey: source.destinationKey, sourcePackages: null, packageCount: 20, shipmentReferences: ['FBA-TEST'], remark: null, operatorName: '测试调度', verifiedAt: '2026-10-15T03:00:00Z' })
|
||||
})
|
||||
it('saves independent cargo verification before any appointment exists, preserving unknown source values', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('待核实'); expect(wrapper.text()).not.toContain('9007199254740993')
|
||||
await wrapper.get('input[type=number]').setValue('20'); await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST\nFBA-TEST')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ expectedSourceVersion: null, expectedVersion: 0, packageCount: 20, shipmentReferences: ['FBA-TEST'] }))
|
||||
expect(vi.mocked(saveCargoVerification).mock.calls[0]![1]).not.toHaveProperty('appointmentStatus')
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('uses the same key and payload after an unknown save outcome', async () => {
|
||||
vi.mocked(saveCargoVerification).mockRejectedValueOnce(new Error('network lost'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('input[type=number]').setValue('20'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const first = vi.mocked(saveCargoVerification).mock.calls[0]![1]
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(saveCargoVerification).toHaveBeenNthCalledWith(2, source, first); wrapper.unmount()
|
||||
})
|
||||
it('refreshes source conflicts without deleting the entered quantity or shipment numbers', async () => {
|
||||
vi.mocked(saveCargoVerification).mockRejectedValueOnce(new ApiResponseError('原来源已变化', 'A0409', 409))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('input[type=number]').setValue('20'); await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-KEEP')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(getCargoVerification).toHaveBeenCalledTimes(2)
|
||||
expect((wrapper.get('input[type=number]').element as HTMLInputElement).value).toBe('20')
|
||||
expect((wrapper.get('textarea[aria-label="本票货件号"]').element as HTMLTextAreaElement).value).toBe('FBA-KEEP'); wrapper.unmount()
|
||||
})
|
||||
it('blocks invalid allocation bounds and denies writes for list-only users', async () => {
|
||||
vi.mocked(getCargoVerification).mockResolvedValue({ ...context, source: { ...context.source, packages: 50 }, allocatedPackages: 30, availablePackages: 20 })
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('input[type=number]').setValue('20'); await wrapper.get('form').trigger('submit')
|
||||
expect(saveCargoVerification).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
permissions.canWrite = false; const denied = render(); await flushPromises()
|
||||
await denied.get('form').trigger('submit'); expect(saveCargoVerification).not.toHaveBeenCalled(); denied.unmount()
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext, type CargoVerificationSource, type SaveCargoVerification } from './cargoVerificationApi'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ source: CargoVerificationSource }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const context = ref<CargoVerificationContext | null>(null), loading = ref(false), saving = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null), pending = ref<SaveCargoVerification | null>(null)
|
||||
const form = reactive({ packages: '', references: '', remark: '' }), baseline = ref(JSON.stringify(form))
|
||||
const dirty = computed(() => JSON.stringify(form) !== baseline.value)
|
||||
useWorkDraft(dirty, computed(() => saving.value || !!pending.value))
|
||||
const canWrite = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && context.value?.allowedActions.includes('VERIFY_CARGO'))
|
||||
async function load(preserve = false) {
|
||||
loading.value = true; error.value = null
|
||||
if (!access.can('platform:appointment:list') || !access.can('platform:overseas-warehouse:last-mile-order:list')) { error.value = { message: '当前账号没有该货源的读取权限,请联系预约负责人处理。' }; loading.value = false; return }
|
||||
try {
|
||||
context.value = await getCargoVerification(props.source)
|
||||
if (!preserve) {
|
||||
const record = context.value.verification
|
||||
Object.assign(form, { packages: record?.packageCount == null ? '' : String(record.packageCount), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' })
|
||||
baseline.value = JSON.stringify(form)
|
||||
}
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '核实资料读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!context.value || !canWrite.value || loading.value || error.value || saving.value) return
|
||||
if (!pending.value) {
|
||||
const count = String(form.packages).trim() ? Number(form.packages) : null
|
||||
if (count !== null && (!Number.isSafeInteger(count) || count < 1)) { ElMessage.warning('核实箱数须为正整数,暂未核实可留空'); return }
|
||||
if (count !== null && (count < context.value.allocatedPackages || (context.value.source.packages != null && count > context.value.source.packages))) { ElMessage.warning('核实箱数不能低于已分配量,也不能超过已知原箱数'); return }
|
||||
const references = [...new Set(form.references.split(/\r?\n/).map(value => value.trim()).filter(Boolean))]
|
||||
if (references.length > 100 || references.some(value => value.length > 128)) { ElMessage.warning('最多填写100条货件号,每条不超过128字'); return }
|
||||
pending.value = { destinationKey: context.value.source.destinationKey, expectedSourceVersion: context.value.source.sourceVersion, expectedVersion: Number(context.value.verification?.version || 0), packageCount: count, shipmentReferences: references, remark: form.remark.trim() || null, idempotencyKey: crypto.randomUUID() }
|
||||
}
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
await saveCargoVerification(props.source, pending.value)
|
||||
pending.value = null; baseline.value = JSON.stringify(form)
|
||||
ElMessage.success('核实资料已保存,原订单与预约状态保持不变'); emit('saved'); await load()
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '保存结果待确认,请重试本次保存')
|
||||
if (rejectedCommand(cause)) { pending.value = null; await load(true) }
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="货物核实资料">
|
||||
<h3>核实货物资料</h3><p>未约成也可保存;只用于预约核实,保留原订单资料。</p>
|
||||
<LoadErrorState v-if="error" title="核实资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在读取货物资料…</p>
|
||||
<template v-if="context">
|
||||
<dl class="cargo-facts"><dt>原票</dt><dd>{{ context.source.inboundCode || context.source.bookingNo || '—' }}</dd><dt>目的仓</dt><dd>{{ context.source.destinationWarehouseCode || '—' }}</dd><dt>原箱数</dt><dd>{{ context.source.packages ?? '待核实' }}</dd><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></dl>
|
||||
<p v-if="context.sourceChanged" role="alert">原货源已变化,请核对当前原票和已分配量后重新保存。</p><p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
|
||||
<form class="density-form work-form" @submit.prevent="save"><fieldset :disabled="!canWrite || loading || !!error || saving || !!pending">
|
||||
<label>核实箱数(可暂不填)<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数"></label>
|
||||
<label>本票货件号 / 追踪号(每行一个)<textarea v-model="form.references" rows="4" aria-label="本票货件号" /></label>
|
||||
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" /></label>
|
||||
</fieldset><p v-if="!canWrite">当前仅可查看核实资料;需核实权限及有效货源资格才能保存。</p>
|
||||
<LoadErrorState v-if="saveError" title="核实资料未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<el-button type="primary" native-type="submit" :loading="saving" :disabled="!canWrite || loading || !!error">{{ pending ? '重试本次保存' : '保存核实资料' }}</el-button></form>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.cargo-facts { display: grid; grid-template-columns: 80px 1fr; gap: var(--admin-space-3); }.cargo-facts dd { margin: 0; overflow-wrap: anywhere; }.cargo-facts dt { color: var(--el-text-color-secondary); }
|
||||
</style>
|
||||
@@ -1,45 +1,23 @@
|
||||
# 预约管理交互矩阵
|
||||
# 预约工作日历逐操作矩阵
|
||||
|
||||
原型:用户提供 `C:/Users/BY-OY/Desktop/新版.html` 的 `page-appointment-calendar`、`modal-appointment-edit`、`renderAppointmentCalendar`、`renderAppointmentDay`、`openAppointmentModal`、`deleteAppointment`,以及本对话三张截图。
|
||||
本轮依据:`C:/Users/BY-OY/.codex/visualizations/2026/10/14/01a13a8d-e968-7962-906b-1a514792d921/appointment-calendar-guided.html`(v10)与预约工作日历开发协作契约 v1。下表取代旧三视图、整行选货和日历辅助方案;不改变原业务实际执行入口。
|
||||
|
||||
用户确认:同一目的仓允许多个客户货物统一预约、统一送仓,不能按客户拆预约。客户隔离保留在货物明细。
|
||||
| 页面/操作 | 原型证据 | 承载与布局 | 实现入口及核对范围 |
|
||||
|---|---|---|---|
|
||||
| 日历首屏、五类计数 | `.calendar`、`renderCalendar()` | 月/周日历,固定高度日格,五类勾选与类型计数;到港参考单列且不计工作 | `AppointmentCalendarView.vue`、`WorkCalendarPanel.vue`;服务端聚合,实际日期优先由服务端给出 |
|
||||
| 全部待办、今日、超期、未定日期、复核 | `.inboxbar`、`backlog()`、`renderList()` | 日历上方全局入口;侧边分页明细,操作在左,范围明确 | `workApi.ts`;summary 不带月历条件,全关图层不影响全局查询 |
|
||||
| 日期、类型与本柜连续工作 | `openDay()`、`renderList()` 的 container 模式 | 同一侧边面板;确认路线后回本柜未完成事项 | `WorkDetailPanel.vue`;保存刷新摘要、日历和当前范围 |
|
||||
| 下一步、前置事项与返回 | `nextAction()`、`renderTask()`、`.origin-context` | 主按钮直接处理当前下一步;前置项保留原事项返回入口 | 动作 code/target 白名单,重新读取目标资格;原业务执行仍回原入口 |
|
||||
| 回复、路线、收货要求、复核、还空责任 | `planForm()`、`followForm()` | 侧边表单;保留原因、反馈、地点时区与历史;无需预约不造确认号 | `WorkCommandForm.vue`;显式跨日结束日期/时间成对填写,完整当地结束时间严格晚于开始;日期未含时刻时不补造;版本、权限、未知结果同键重试、冲突保留输入 |
|
||||
| 未预约补核实资料 | `cargoFields()`、`cargoSourceHtml()` | 侧边独立表单;逐来源客户、原票、数量与货件 | `CargoVerificationForm.vue`;原箱数与可分配量未知不显示0,不覆盖原订单 |
|
||||
| 编辑/确认/取消原预约 | `planForm()` 及 cancel 分支 | 侧边原预约表单,取消原因和明确确认按钮;不增加假预约 | `WorkAppointmentForm.vue`;复用既有时区与夏令时字段、版本及原业务命令 |
|
||||
| 新建原预约 | `planForm()` | 侧边先读取实际货物预览,再登记预约安排 | `WorkCreateAppointmentForm.vue`;复用原来源预览/创建 API,明确客户与 ORDER/BOOKING 来源,未知结果同键重试 |
|
||||
| 分批、合并 | `splitForm()`、`mergeForm()` | 侧边逐来源数量分配,数量守恒 | `AppointmentBatchForm.vue` 按真实 batches/repartition 合同提交逐来源数量、来源/核实版本、原批次版本和幂等键;被替代快照只读,显示新业务号;无分批资格时仍显示 appointment.items 货物快照 |
|
||||
| 提柜安排、实际提柜、还空 | `executeForm()` | 原货柜页独立表单;返回预约时恢复原事项与筛选 | `ContainerDetailView.vue`;真实车队检索、当前版本和原权限,固定 PICKED_UP 原船节点命令;实际时间由操作者填写,未知结果保留原请求与幂等键 |
|
||||
| 整柜直送逐票卸货 | `executeForm()` | 原货柜页逐来源清单及签收表单,已登记只读 | 原 direct-deliveries API;真实装柜来源、当地时间、IANA 时区、签收人与凭据号,服务端核验路线和还空前置 |
|
||||
| FBA 批次排车与用车 | `executeForm()`、原车次安排入口 | 侧边按真实托盘选择;创建原排车单后进入原车次安排弹窗 | `WorkDispatchForm.vue`;逐来源 batchAllocations 原样提交原草稿分组,每单同客户,未知结果重试原草稿;已有车次按业务号进入原详情 |
|
||||
| 客户仓批次用车 | 原排车与车次安排入口 | 按 DISPATCH 引用读取原排车单,再进入原车次安排 | `WorkExistingDispatchForm.vue`;核对不可变批次键与真实业务号,保留原实物;不调用 FBA 候选,不新建排车单;原派送进度按明确业务引用进入 |
|
||||
| 本柜接仓计划与记录 | `executeForm()`、原入库单详情 | 单计划进入对应客户原单,多计划列出本柜实际关联清单;缺计划时先预览整柜分组 | `WorkReceiptPlans.vue`;复用 container-summaries、原整柜预览/生成 API 和原入库单路由,快照与原资源重试键保持一致;实际收货仍由原仓库作业流程登记 |
|
||||
| 失败、无权限、未保存 | 真实接口与应用验收规则 | 明确失败和重试;错误不改为0项,保留输入和原上下文 | 各组件直接回归;未知命令不执行,未确认保存结果禁止离开 |
|
||||
|
||||
| 页面/操作 | 原型承载与布局 | 实现约束 |
|
||||
|---|---|---|
|
||||
| 补充计划时段 | 用户2026-10-12新增预约截图:开始、结束时段输入框各占整行过长 | 共用 FbaAppointmentFields 的补充区域沿用三列网格,计划开始、结束时段同排各占一列,与主字段对齐;夏令时说明整行、两个时差输入同排。日历新增/编辑、合批安排、入仓单预约同步生效;字段语义及保存不变,待人工截图确认 |
|
||||
| 预约补充记录入口 | 用户2026-10-12截图1:原顶部未定时安排、取消记录两行默认折叠文字要求优化 | 用户2026-10-12后续确认覆盖页内展开方案:入口并入日历工具栏,与数据提示同组;未定时安排1000px、取消记录1160px居中弹框,保留独立分页及错误重试,取消记录仅打开时加载。新增预约紧邻日历标题,月/周及日期操作连续排列 |
|
||||
| 送仓与排车货源页签 | 用户2026-10-12截图2、3:参考尾程订单下划线页签 | 头程货物、外接货物通过同一局部页签组件复用 density-status-tabs,两种模式均放入表格 filters 的 integrated-query-group 内并紧邻搜索;点击及方向键/Home/End切换,保留跨货源选择;新增、编辑、取消预约沿用原承载方式。待人工1920×1080、100%截图确认 |
|
||||
| 预约列表 | 左侧月/周日历,右侧选中日期详情 | 保留双栏日历;到港蓝色、送仓绿色,区分预计/实际到港 |
|
||||
| 新增预约 | 居中表单,按用户简化要求使用1080px三列 | 仓库远程搜索并自动加载货物;主区保留计划日期、登记阶段及可选备注。系统单号自动生成,不要求外部约号和预计装车时间;计划时段收至补充信息。选择“已取得对方正式确认”才展开外部号和确认时段,并显示地点时区 |
|
||||
| 编辑预约 | 复用居中表单 | 与入仓单共用记录;版本校验;真实货物归属不允许手填更改 |
|
||||
| 到港详情 | 当天安排右侧只读 | 使用货柜/船节点事实,无逐行详情请求,无模拟到港数据 |
|
||||
| 取消预约 | 二次确认 | 显式提示全部货物释放占用、历史保留;沿用独立权限,已关联有效排车时先处理运输安排,不能自动回退库存 |
|
||||
| 月/周切换 | 顶部切换与前后按钮 | 周模式按7天移动,修正原型仍按月移动的问题 |
|
||||
| 多条安排 | 日历标签、右侧明细 | 月/周日格均最多显示3条,超出显示“还有N条”,悬停显示当天总数;日格固定高度,右侧全部展示并独立滚动 |
|
||||
| 多入仓码 | 用户2026-10-09补充截图:单预约20票导致两列详情被长字符串撑高 | 右侧卡片与编辑弹框共用独立整行入仓码列表;去空去重后显示总数,默认3条,长码悬停可读全文;超过3条可展开/收起,展开后216px内独立滚动并可键盘聚焦,不拼成长段文字 |
|
||||
| 在途货物及外接来源跟进 | 美国预约统一方案的提前联系需求 | 日历与已开船FBA列表切换;跟进在居中1080px弹框中承载,3列表单、联系结果及下一步整行,历史分页;BOOKING深链先由服务端核对,再展示原登记及真实预约缺项 |
|
||||
| 整柜提柜与接仓 | 美国预约统一方案的整柜共享事项 | 独立工作视图按柜汇总,操作列左置;接仓点在行内分组,点击后共用1080px跟进弹框。整柜只选拖车/码头事项,接仓只记录对应仓库,单票只记录FBA联系;数量未知或范围不完整明确显示,不按客户复制整柜联系。首次只请求一页整柜数据,详情按点击读取;待1920×1080页面验收 |
|
||||
| 在途跨客户合批安排 | 美国预约统一方案的同起运仓、同目的仓合批 | 操作列后选择同起运仓及同目的仓,跨页累计最多100票;批量核对后在1080px弹框填写与入仓入口共用的安排表单。未知箱数保留待核实;暂扣、多目的地和已有预约不可选入。保存必须重新核对,未知结果保留同一命令供重试 |
|
||||
| 外接货进入合批 | 同仓跨客户共用预约,不因未入仓而只允许跟进 | 后端核实BOOKING深链后,“加入预约合批”进入现有合批工作区,再显式加入选择;可与同起运仓/目的仓头程货混选,保留原BOOKING键与未知箱数。跨仓不清空已有选择,已有关联预约不再开放新增入口;切换日历/合批工作视图保留本页选择,外接选择默认折叠、展开216px内滚动 |
|
||||
| 完整外接候选 | 统一方案第九节两类货源均可筹划、合批及核对覆盖 | 在同一合批工作区切换头程/外接来源列表,外接按服务端分页、关键词筛选,展示原登记、客户、车次、接仓点、目的仓、可空箱数及预约覆盖。加入选择复用原100票合批选择与保存弹框;查看跟进沿用原弹框,不新增预约数据副本。切换来源保留选择,未打开外接列表不请求;待1920×1080验收 |
|
||||
| 未定时安排 | 总览不得伪造时段 | 日历上方独立折叠分页列表;日期未定不补零点或09点,继续编辑仍操作原系统单号;仅有计划日期时日历显示日期,不生成确认时段 |
|
||||
| 提柜日历 | 美国业务预约统一总览 | 读取原码头提柜预约,一柜一条,点击查看来源进入清关提柜详情;按美国目的仓确认业务范围,不等于确认港口国家。保留原业务日期,明确港口时区待核实,不套用目的仓时区;未知目的仓及截断显示统计不完整。无提柜权限不请求该来源并提示范围缺失 |
|
||||
| 用车计划日历 | 统一总览的真实车次只读来源 | 一车一条,计划与实际出发分开,司机预计结束不当送货完成。查询一次带跨时区余量的UTC窗口,按起运仓地点时区筛回42个当地日;缺国家/时区的记录独立分页待核实,不放入每日格。路线最多3站摘要,完整路线进入原车次;不请求逐车详情,不在预约页创建车次或重算余位 |
|
||||
| 来源失败 | 统一方案的部分结果规则 | 日历保留成功来源并提示不完整;深链解析失败不打开假来源;跟进版本加载失败禁止保存,未知提交结果保留原请求供重试 |
|
||||
| 船期变化影响与处理 | 统一方案第十节场景16,旧/新预测、下游影响、负责人及结果闭环 | 并入提柜与到仓的货柜列表,不设第四工作视图;行内处理船期变化,本页待办计数明确标识范围;显式本页批量核对,不逐柜轮询。1200px居中详情展示直接货物/预约、关联车次与同车待复核承诺;各明细220px内滚动,历史服务端分页。认领及结果表单复用现有控件;未知提交保留原命令并禁止关闭,已确认预约不因核对动作改变。待1920×1080真实验收 |
|
||||
|
||||
结束时间未明确时允许为空。系统单号、外部确认号、计划时段及确认时段保持不同语义。日历范围最多42天,每次范围变化查询预约、到港及有权限的提柜、车次来源各一次;未定时列表独立分页,选中日期不发送请求。候选选仓后自动分页读取,跨页选择保留;换仓清空旧选择并丢弃旧请求,清空仓库不查货。时区由目的仓主档核实,夏令时重叠明确时差,不凭浏览器时区猜测。仓库请求失败可重试;只展示前50个匹配仓,超出提示缩小关键词。
|
||||
|
||||
本轮视觉验收:待人工1920×1080截图确认。
|
||||
|
||||
视觉验收目标为1920×1080、100%缩放。该矩阵描述设计和代码边界,不代替测试环境浏览器验收证据。
|
||||
|
||||
按用户确认收敛:主视图仅提柜与到仓、送仓与排车、预约日历,默认有权限的列表视图;日历首次进入才加载。到港参考、用车计划默认隐藏,可独立开关,不计入预约数量;缺国家资料与查询超限分开提示。送仓与排车目前仍以选货合批为主,目的仓货量汇总及7/14/30天窗口尚未完成,不因标题调整宣称完成。
|
||||
|
||||
用户截图密度修正:业务标题复用density-detail-title并与三视图同行,不独占大标题区;船期操作并入查询栏,不单独占空白工具栏。常驻业务解释改为默认折叠“说明”;错误保留重试,诊断信息默认折叠。基础货柜与船期状态独立读取,后者失败不阻断货柜列表、不展示假的零待办。
|
||||
|
||||
用户最新截图确认:移除“美国预约总览”文字,仅保留三视图导航;日历图例和图层开关归入有内边距的辅助栏,长范围提示默认折叠,月格112px仍容纳3条摘要及剩余数;右侧固定360px,系统单号/完整时段/备注跨整行。该项以当前用户要求覆盖原标题建议,待1920×1080实际页面确认。
|
||||
|
||||
用户最新确认覆盖默认列表建议:预约日历排第一并默认打开,之后为提柜与到仓、送仓与排车;范围资料提示移入日历工具栏“数据提示”,点击展开浮层,不在日历上方占行。真实接口错误仍保留错误与重试入口。
|
||||
|
||||
用户确认待核实车次不占日历顶部:入口并入“数据提示”,显式点击后打开1000px居中分页明细;保留原UTC、缺项及查看来源操作,未打开不渲染明细,不增加查询;日期范围变化关闭旧明细。
|
||||
视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import SourceAppointmentPanel from './SourceAppointmentPanel.vue'
|
||||
import { resolvePlanningSource, resolvePlanningOrder } from './planningApi'
|
||||
vi.mock('./planningApi', () => ({ resolvePlanningSource: vi.fn(), resolvePlanningOrder: vi.fn() }))
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('shows every associated appointment for a multi-batch source and opens the chosen business number', async () => {
|
||||
const source = { customerId: 'customer-a', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8' }
|
||||
vi.mocked(resolvePlanningSource).mockResolvedValue({source:{...source,orderId:'1',inboundCode:'IN-TEST',bookingNo:null,containerNo:'CN-TEST',originWarehouseId:'warehouse-1',originWarehouseName:'测试仓',destinationWarehouseCode:'ONT8',packages:100,sourceVersion:'v1',canFollowup:true,blockReasons:[]},coverage:{status:'MULTIPLE',missingFields:[],changeReviewRequired:false,appointments:['AP-A','AP-B'].map(appointmentNo=>({appointmentNo,confirmationNo:null,status:'PENDING',confirmedAt:null,confirmedEndAt:null,timezone:null}))}})
|
||||
const wrapper = mount(SourceAppointmentPanel,{props:{source,canLeave:async()=>true},global:{stubs:{ElButton:{template:'<button><slot/></button>'},CargoVerificationForm:true,WorkAppointmentForm:{name:'WorkAppointmentForm',props:['appointmentNo'],template:'<div>{{ appointmentNo }}</div>'},AppointmentBatchForm:true,LoadErrorState:true,ApiErrorMeta:true}}})
|
||||
await flushPromises(); expect(wrapper.text()).toContain('共 2 个关联预约')
|
||||
await wrapper.findAll('button').find(button=>button.text()==='AP-B')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({name:'WorkAppointmentForm'}).props('appointmentNo')).toBe('AP-B')
|
||||
expect(resolvePlanningSource).toHaveBeenCalledExactlyOnceWith(source); wrapper.unmount()
|
||||
})
|
||||
it('resolves an arrived external order to canonical BOOKING and passes only that source to cargo verification', async () => {
|
||||
const input = { orderId: '9007199254740993', customerId: 'customer-a', destinationKey: 'FBA:ONT8' }
|
||||
vi.mocked(resolvePlanningOrder).mockResolvedValue({source:{sourceKey:'BOOKING:external-booking',customerId:'customer-a',destinationKey:'FBA:ONT8',orderId:input.orderId,inboundCode:'IN-EXTERNAL',bookingNo:'BK-EXTERNAL',containerNo:null,originWarehouseId:'warehouse-1',originWarehouseName:'测试仓',destinationWarehouseCode:'ONT8',packages:100,sourceVersion:'booking-v1',canFollowup:true,blockReasons:[]},coverage:{status:'MULTIPLE',missingFields:[],changeReviewRequired:false,appointments:[]}})
|
||||
const wrapper = mount(SourceAppointmentPanel,{props:{source:input,canLeave:async()=>true},global:{stubs:{ElButton:true,CargoVerificationForm:{name:'CargoVerificationForm',props:['source'],template:'<div />'},WorkAppointmentForm:true,AppointmentBatchForm:true,LoadErrorState:true,ApiErrorMeta:true}}})
|
||||
await flushPromises()
|
||||
expect(resolvePlanningOrder).toHaveBeenCalledExactlyOnceWith(input)
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||||
expect(wrapper.getComponent({name:'CargoVerificationForm'}).props('source')).toEqual({sourceKey:'BOOKING:external-booking',customerId:'customer-a',destinationKey:'FBA:ONT8'})
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { resolvePlanningSource, resolvePlanningOrder, type ResolvedPlanningSource, type PlanningSourceLookup } from './planningApi'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
import CargoVerificationForm from './CargoVerificationForm.vue'
|
||||
import WorkAppointmentForm from './WorkAppointmentForm.vue'
|
||||
import AppointmentBatchForm from './AppointmentBatchForm.vue'
|
||||
const props = defineProps<{ source: PlanningSourceLookup; canLeave: LeaveGuard }>()
|
||||
const emit = defineEmits<{ updated: [] }>()
|
||||
const context = ref<ResolvedPlanningSource | null>(null), error = ref<ApiErrorDetails | null>(null), loading = ref(false)
|
||||
const appointmentNo = ref<string | null>(null), batchKey = ref<string | null>(null)
|
||||
const canonicalSource = computed(() => context.value ? { sourceKey: context.value.source.sourceKey, customerId: context.value.source.customerId, destinationKey: context.value.source.destinationKey } : null)
|
||||
async function load() {
|
||||
loading.value = true; error.value = null
|
||||
try { context.value = 'sourceKey' in props.source ? await resolvePlanningSource(props.source) : await resolvePlanningOrder(props.source) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '货源预约关联读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function openAppointment(number: string) { if (await props.canLeave()) { appointmentNo.value = number; batchKey.value = null } }
|
||||
async function openBatch(key: string) { if (await props.canLeave()) { batchKey.value = key; appointmentNo.value = null } }
|
||||
async function back() { if (await props.canLeave()) { appointmentNo.value = null; batchKey.value = null; await load() } }
|
||||
async function updated() { emit('updated'); await load() }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="货源与全部预约批次">
|
||||
<el-button v-if="appointmentNo || batchKey" @click="back">返回本票全部预约</el-button>
|
||||
<AppointmentBatchForm v-if="batchKey" :key="batchKey" :batch-key="batchKey" @updated="updated" @appointment="openAppointment" />
|
||||
<WorkAppointmentForm v-else-if="appointmentNo" :key="appointmentNo" :appointment-no="appointmentNo" :cancel="false" :can-write="true" @saved="updated" @batch="openBatch" />
|
||||
<template v-else>
|
||||
<h3>本票预约与货物核实</h3>
|
||||
<LoadErrorState v-if="error" title="货源预约关联读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在读取全部关联预约…</p>
|
||||
<template v-else-if="context && !error"><p>{{ context.source.inboundCode || context.source.bookingNo || '原票业务号未提供' }} · {{ context.source.destinationWarehouseCode || '目的仓待核实' }}</p><p v-for="reason in context.source.blockReasons" :key="reason">{{ reason }}</p><p>共 {{ context.coverage.appointments.length }} 个关联预约,分别查看和处理。</p><div v-for="appointment in context.coverage.appointments" :key="appointment.appointmentNo" class="source-appointment"><el-button @click="openAppointment(appointment.appointmentNo)">{{ appointment.appointmentNo }}</el-button><span>{{ appointment.confirmedAt || '尚无确认时段' }} · {{ appointment.timezone || '地点时区待核实' }}</span></div><CargoVerificationForm v-if="canonicalSource" :source="canonicalSource" @saved="updated" /></template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.source-appointment { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-3); padding-block: var(--admin-space-3); border-bottom: 1px solid var(--cc-border); }</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkAppointmentForm from './WorkAppointmentForm.vue'
|
||||
import { getFbaAppointment, updateFbaAppointment, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import { deleteAppointment } from './api'
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn() }))
|
||||
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), deleteAppointment: vi.fn() }))
|
||||
const detail: FbaAppointmentDto = { id: '1', appointmentNo: 'AP-001', batchKey: 'FBA_APPOINTMENT:1', version: 3, originWarehouseId: 'warehouse-1', fbaWarehouseCode: 'ONT8', selectedCount: 1, items: [], appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-001', appointmentAt: '2026-10-15T23:30:00', appointmentEndAt: '2026-10-16T01:00:00', appointmentStartUtc: '2026-10-16T06:30:00Z', appointmentEndUtc: '2026-10-16T08:00:00Z', timezone: 'America/Los_Angeles' }
|
||||
function render(cancel = false) { return mount(WorkAppointmentForm, { props: { appointmentNo: 'AP-001', cancel, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<div />' }, LoadErrorState: true, ApiErrorMeta: true } } }) }
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getFbaAppointment).mockResolvedValue(detail); vi.mocked(updateFbaAppointment).mockResolvedValue(detail) })
|
||||
it('preserves both dates of an existing cross-day appointment and its explicit offsets in the original business command', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
expect(updateFbaAppointment).toHaveBeenCalledWith('AP-001', expect.objectContaining({ version: 3, details: expect.objectContaining({ appointmentAt: '2026-10-15T23:30:00', appointmentEndAt: '2026-10-16T01:00:00', startOffset: '-07:00', endOffset: '-07:00' }) }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not edit or cancel replaced snapshots and offers the stable batch context for replacement history', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValue({ ...detail, replacementAppointmentNos: ['AP-NEW'], replacementBatchKeys: ['FBA_APPOINTMENT:2'], repartitionReason: '分批送仓' })
|
||||
const wrapper = render(true); await flushPromises()
|
||||
expect(wrapper.text()).toContain('原预约已被替代'); expect(wrapper.text()).toContain('AP-NEW')
|
||||
await wrapper.findAll('button').find(button => button.text() === '确认取消并继续跟进')!.trigger('click')
|
||||
expect(deleteAppointment).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find(button => button.text() === '查看替代批次')!.trigger('click')
|
||||
expect(wrapper.emitted('batch')).toEqual([['FBA_APPOINTMENT:1']]); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getFbaAppointment, updateFbaAppointment, FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { deleteAppointment } from './api'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ appointmentNo: string; cancel: boolean; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ saved: []; batch: [key: string] }>()
|
||||
const access = useAccessPreviewStore(), detail = ref<FbaAppointmentDto | null>(null)
|
||||
const form = reactive(createAppointmentForm()), reason = ref(''), baseline = ref(JSON.stringify(form))
|
||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
type SaveRequest = { kind: 'update'; payload: { details: ReturnType<typeof appointmentPayload>; version: number; idempotencyKey: string } } | { kind: 'cancel'; version: number; reason: string }
|
||||
const pending = ref<SaveRequest | null>(null)
|
||||
useWorkDraft(computed(() => JSON.stringify(form) !== baseline.value || !!reason.value), computed(() => saving.value || !!pending.value))
|
||||
const replaced = computed(() => !!detail.value?.replacementAppointmentNos?.length || !!detail.value?.replacementBatchKeys?.length)
|
||||
const writable = computed(() => props.canWrite && !replaced.value && detail.value?.appointmentStatus !== 'CANCELLED' && access.can(props.cancel ? 'platform:appointment:delete' : 'platform:overseas-warehouse:inbound-order:fba-appointment'))
|
||||
async function load(preserve = false) {
|
||||
loading.value = true; error.value = null
|
||||
try { detail.value = await getFbaAppointment(props.appointmentNo); if (!preserve) { Object.assign(form, createAppointmentForm(detail.value)); baseline.value = JSON.stringify(form) } }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '预约详情读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!detail.value || !writable.value || loading.value || error.value || saving.value) return
|
||||
if (!pending.value) {
|
||||
if (props.cancel) {
|
||||
if (!reason.value.trim()) { ElMessage.warning('请填写取消原因'); return }
|
||||
pending.value = { kind: 'cancel', version: detail.value.version, reason: reason.value.trim() }
|
||||
} else {
|
||||
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
|
||||
pending.value = { kind: 'update', payload: { details: appointmentPayload(form), version: detail.value.version, idempotencyKey: crypto.randomUUID() } }
|
||||
}
|
||||
}
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
if (pending.value.kind === 'cancel') await deleteAppointment(props.appointmentNo, pending.value.version, pending.value.reason)
|
||||
else await updateFbaAppointment(props.appointmentNo, pending.value.payload)
|
||||
pending.value = null; reason.value = ''; baseline.value = JSON.stringify(form)
|
||||
ElMessage.success(props.cancel ? '原预约已取消,工作需求继续跟进' : '预约安排已保存'); emit('saved'); await load()
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '保存结果待确认,请重试本次保存'); if (rejectedCommand(cause)) { pending.value = null; await load(true) } }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="预约安排">
|
||||
<h3>{{ cancel ? '取消本次预约' : '登记预约安排' }}</h3>
|
||||
<LoadErrorState v-if="error" title="预约详情读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在读取原预约…</p>
|
||||
<template v-if="detail">
|
||||
<p>{{ detail.fbaWarehouseCode }} · {{ detail.appointmentNo }}</p>
|
||||
<p v-if="replaced">原预约已被替代,保留历史只读。当前批次:{{ detail.replacementAppointmentNos?.join('、') || '—' }}。{{ detail.repartitionReason }}</p>
|
||||
<el-button v-if="detail.batchKey && !saving && !pending" @click="emit('batch', detail.batchKey)">{{ replaced ? '查看替代批次' : '分批 / 合并与货物来源' }}</el-button>
|
||||
<template v-if="cancel"><p>取消原预约并保留历史;工作需求与后续跟进仍保留。已有运输安排须满足原业务取消条件。</p><label>取消原因<textarea v-model="reason" :disabled="!writable || saving || !!pending" maxlength="1000" /></label></template>
|
||||
<FbaAppointmentFields v-else :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
|
||||
<p v-if="!writable">当前预约仅可查看。</p>
|
||||
<LoadErrorState v-if="saveError" title="预约未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<el-button :type="cancel ? 'danger' : 'primary'" :disabled="!writable || loading || !!error" :loading="saving" @click="save">{{ pending ? '重试本次保存' : cancel ? '确认取消并继续跟进' : '保存预约安排' }}</el-button>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { expect, it } from 'vitest'
|
||||
import WorkCalendarPanel from './WorkCalendarPanel.vue'
|
||||
import { workCalendar } from './work.test-support'
|
||||
import { workTypes } from './workApi'
|
||||
function render() { return mount(WorkCalendarPanel, { props: { anchor: '2026-10-15', mode: 'month', flow: '', types: [...workTypes], calendar: workCalendar, loading: false, failed: false, reference: false, arrivals: [], referenceReady: false } }) }
|
||||
it('renders server type counts instead of individual event cards and opens exact date/type details', async () => {
|
||||
const wrapper = render()
|
||||
expect(wrapper.findAll('.work-day')).toHaveLength(42)
|
||||
const day = wrapper.get('[data-date="2026-10-15"]')
|
||||
expect(day.get('.day-heading').text()).toContain('2 项')
|
||||
await day.get('[data-type="DELIVERY"]').trigger('click')
|
||||
expect(wrapper.emitted('day')).toEqual([['2026-10-15', 'DELIVERY']]); wrapper.unmount()
|
||||
})
|
||||
it('does not conflate arrivals, no-booking requirements or completed work with confirmed work', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ reference: true, referenceReady: true, arrivals: [{ containerBookingId: '1', cabinetNo: 'D-1', arrivalAt: '2026-10-15T10:00:00', arrivalType: 'ESTIMATED' }] })
|
||||
expect(wrapper.get('[data-date="2026-10-15"] .day-heading').text()).toContain('2 项')
|
||||
expect(wrapper.get('.calendar-counts').text()).toContain('已完成 1')
|
||||
expect(wrapper.get('.calendar-counts').text()).toContain('已落实 0')
|
||||
await wrapper.get('.arrival-reference').trigger('click'); expect(wrapper.emitted('arrivals')).toEqual([['2026-10-15']]); wrapper.unmount()
|
||||
})
|
||||
it('shows explicit all-off and failure states without manufacturing zero aggregates', async () => {
|
||||
const wrapper = render(); await wrapper.setProps({ types: [], calendar: null })
|
||||
expect(wrapper.text()).toContain('全局待办仍可查看'); expect(wrapper.findAll('.type-counts button')).toHaveLength(0)
|
||||
await wrapper.setProps({ types: [...workTypes], failed: true })
|
||||
expect(wrapper.get('.calendar-counts').text()).toBe('工作统计加载失败')
|
||||
expect(wrapper.get('[data-date="2026-10-15"] .day-heading').text()).not.toContain('0 项'); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { calendarDates, type CalendarMode } from './calendar'
|
||||
import { workTypes, type WorkCalendar, type WorkFlow, type WorkType } from './workApi'
|
||||
import { typeLabels } from './workPresentation'
|
||||
import type { ArrivalCalendarEntry } from './api'
|
||||
const props = defineProps<{ anchor: string; mode: CalendarMode; flow: WorkFlow | ''; types: WorkType[]; calendar: WorkCalendar | null; loading: boolean; failed: boolean; reference: boolean; arrivals: ArrivalCalendarEntry[]; referenceReady: boolean }>()
|
||||
const emit = defineEmits<{ anchor: [offset: number]; today: []; mode: [value: CalendarMode]; flow: [value: WorkFlow | '']; types: [value: WorkType[]]; reference: [value: boolean]; day: [date: string, type?: WorkType]; arrivals: [date: string] }>()
|
||||
const days = computed(() => calendarDates(props.anchor, props.mode))
|
||||
const aggregates = computed(() => new Map(props.calendar?.days.map(day => [day.date, day]) || []))
|
||||
const heading = computed(() => props.mode === 'month' ? `${props.anchor.slice(0, 4)}年${Number(props.anchor.slice(5, 7))}月` : `${days.value[0]} — ${days.value.at(-1)}`)
|
||||
function toggle(type: WorkType, checked: boolean) { emit('types', checked ? [...props.types, type] : props.types.filter(value => value !== type)) }
|
||||
function arrivalCount(date: string) { return props.arrivals.filter(row => row.arrivalAt.slice(0, 10) === date).length }
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-calendar" aria-label="预约工作日历" :aria-busy="loading">
|
||||
<header class="calendar-toolbar">
|
||||
<label>流程 <select aria-label="日历流程" :value="flow" @change="emit('flow', ($event.target as HTMLSelectElement).value as WorkFlow | '')"><option value="">全部流程</option><option value="TRANSFER">海外仓中转</option><option value="DIRECT">整柜直送</option><option value="UNKNOWN">路线待确认</option></select></label>
|
||||
<div class="period-controls"><button type="button" :aria-pressed="mode === 'month'" @click="emit('mode', 'month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="emit('mode', 'week')">周</button><button type="button" aria-label="上一时段" @click="emit('anchor', -1)">‹</button><strong>{{ heading }}</strong><button type="button" aria-label="下一时段" @click="emit('anchor', 1)">›</button><button type="button" @click="emit('today')">今天</button></div>
|
||||
<span class="muted">作业地点当地时间</span>
|
||||
</header>
|
||||
<div class="work-layers"><span class="muted">显示类型</span><label v-for="type in workTypes" :key="type" :data-type="type"><input type="checkbox" :checked="types.includes(type)" @change="toggle(type, ($event.target as HTMLInputElement).checked)">{{ typeLabels[type] }}</label><label><input type="checkbox" :checked="reference" @change="emit('reference', ($event.target as HTMLInputElement).checked)">到港参考</label></div>
|
||||
<div class="calendar-counts" aria-live="polite">
|
||||
<span v-if="loading">正在读取工作统计…</span><span v-else-if="failed">工作统计加载失败</span><span v-else-if="!types.length">未勾选工作类型,全局待办仍可查看</span>
|
||||
<template v-else-if="calendar"><strong>当前显示区间 · {{ calendar.total }} 项工作</strong><span>待安排 {{ calendar.counts.pending }}</span><span>已落实 {{ calendar.counts.confirmed }}</span><span>无需预约 {{ calendar.counts.noBookingRequired }}</span><span>需复核 {{ calendar.counts.review }}</span><span>已完成 {{ calendar.counts.completed }}</span></template>
|
||||
</div>
|
||||
<div class="weekdays"><span v-for="day in ['一', '二', '三', '四', '五', '六', '日']" :key="day">周{{ day }}</span></div>
|
||||
<div class="calendar-days">
|
||||
<div v-for="date in days" :key="date" class="work-day" :class="{ outside: mode === 'month' && !date.startsWith(anchor.slice(0, 7)) }" :data-date="date">
|
||||
<button class="day-heading" type="button" :disabled="loading || failed || !types.length" :aria-label="`${date} 工作明细`" @click="emit('day', date)"><span>{{ Number(date.slice(8)) }}</span><span>{{ !loading && !failed && types.length ? `${aggregates.get(date)?.total ?? 0} 项` : '—' }}</span></button>
|
||||
<div class="type-counts"><template v-if="!loading && !failed && types.length"><button v-for="type in workTypes.filter(type => types.includes(type) && (aggregates.get(date)?.typeCounts[type] ?? 0) > 0)" :key="type" type="button" :data-type="type" :aria-label="`${date} ${typeLabels[type]} ${aggregates.get(date)?.typeCounts[type]} 项`" @click="emit('day', date, type)"><span>{{ typeLabels[type] }}</span><b>{{ aggregates.get(date)?.typeCounts[type] }}</b></button></template></div>
|
||||
<button v-if="reference && referenceReady && arrivalCount(date)" type="button" class="arrival-reference" @click="emit('arrivals', date)">到港参考 {{ arrivalCount(date) }} 柜</button>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="muted">点击日期或类型查看明细;到港参考不计工作量。已执行按实际日期展示。</footer>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.work-calendar { min-width: 0; border: 1px solid var(--cc-border); background: var(--admin-surface); }
|
||||
.calendar-toolbar, .period-controls, .work-layers, .calendar-counts { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.calendar-toolbar { justify-content: space-between; padding: var(--admin-space-5); }
|
||||
.calendar-toolbar label { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||
.period-controls strong { min-width: 100px; text-align: center; }
|
||||
.work-layers, .calendar-counts { padding: var(--admin-space-3) var(--admin-space-5); border-top: 1px solid var(--cc-border); }
|
||||
.work-layers label { display: inline-flex; align-items: center; gap: var(--admin-space-2); cursor: pointer; }
|
||||
.calendar-counts { min-height: 32px; font-size: var(--admin-font-caption); }
|
||||
.weekdays, .calendar-days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
.weekdays { text-align: center; padding-block: var(--admin-space-3); background: var(--el-fill-color-light); }
|
||||
.work-day { height: 140px; min-height: 140px; max-height: 140px; box-sizing: border-box; overflow: hidden; border-top: 1px solid var(--cc-border); border-right: 1px solid var(--cc-border); padding: var(--admin-space-2); }
|
||||
.work-day:nth-child(7n) { border-right: 0; }
|
||||
.outside { background: var(--el-fill-color-lighter); }
|
||||
.day-heading { display: flex; justify-content: space-between; width: 100%; border: 0; padding: 0 var(--admin-space-2); min-height: 24px; background: transparent; }
|
||||
.type-counts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 24px; gap: 2px; min-height: 76px; }
|
||||
.type-counts button { display: flex; align-items: center; justify-content: space-between; gap: 2px; border: 1px solid currentColor; padding: 0 3px; font-size: var(--admin-font-caption); background: transparent; min-width: 0; min-height: 24px; }
|
||||
[data-type=PICKUP] { color: var(--el-color-primary); } [data-type=WAREHOUSE_RECEIPT] { color: #8356ad; } [data-type=DELIVERY] { color: #27815c; } [data-type=TRANSPORT] { color: #b26a12; } [data-type=EMPTY_RETURN] { color: #a64f69; }
|
||||
.arrival-reference { border: 0; background: transparent; padding: 0; min-height: 18px; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
footer { padding: var(--admin-space-3) var(--admin-space-5); }
|
||||
button, select { font: inherit; cursor: pointer; border-radius: var(--admin-radius); min-height: var(--admin-control-height); color: inherit; }
|
||||
button:disabled { cursor: default; opacity: .6; } button:focus-visible, select:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
|
||||
.muted { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkCommandForm from './WorkCommandForm.vue'
|
||||
import { submitWorkCommand, type WorkAction } from './workApi'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { followAction, workDetail, workItem } from './work.test-support'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const session = vi.hoisted(() => ({ canWrite: true }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'user-1', name: '测试调度' } }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => session.canWrite }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), submitWorkCommand: vi.fn() }))
|
||||
let guard: LeaveGuard
|
||||
function render(action: WorkAction = followAction, detail = workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) })) {
|
||||
return mount(WorkCommandForm, { props: { detail, action, refreshing: false }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
|
||||
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType" :disabled="disabled"><slot /></button>' },
|
||||
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}</div>' }, ApiErrorMeta: true,
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); session.canWrite = true; vi.mocked(submitWorkCommand).mockResolvedValue({ item: workItem(), affectedWorkItemKeys: [] }) })
|
||||
it('sends only follow-up ownership and feedback, retaining the undated work and the exact retry command', async () => {
|
||||
vi.mocked(submitWorkCommand).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = render()
|
||||
await wrapper.get('textarea').setValue('已联系,等待回复'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const first = vi.mocked(submitWorkCommand).mock.calls[0]![1]
|
||||
expect(first).toMatchObject({ command: 'SAVE_FOLLOWUP', expectedVersion: 3, payload: { ownerId: 'user-1', note: '已联系,等待回复', followupDate: '2026-10-15' } })
|
||||
expect(first.payload).not.toHaveProperty('target'); expect(await guard()).toBe(false)
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(submitWorkCommand).toHaveBeenNthCalledWith(2, 'work-delivery-1', first)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('preserves entered feedback after a conflict and uses the newly read version on an explicit resubmission', async () => {
|
||||
vi.mocked(submitWorkCommand).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
|
||||
const wrapper = render(); await wrapper.get('textarea').setValue('保留此反馈')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const first = vi.mocked(submitWorkCommand).mock.calls[0]![1]
|
||||
expect(wrapper.emitted('refresh')).toHaveLength(1)
|
||||
await wrapper.setProps({ detail: workDetail({ item: workItem({ version: 4 }) }) })
|
||||
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('保留此反馈')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const second = vi.mocked(submitWorkCommand).mock.calls[1]![1]
|
||||
expect(second.expectedVersion).toBe(4); expect(second.idempotencyKey).not.toBe(first.idempotencyKey)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('uses the distinct review version and never substitutes the item version or marks work complete', async () => {
|
||||
const action: WorkAction = { code: 'RESOLVE_REVIEW', label: '复核安排', targetWorkItemKey: null, target: { kind: 'WORK_COMMAND', command: 'RESOLVE_REVIEW' } }
|
||||
const wrapper = render(action, workDetail({ item: workItem({ allowedActions: [action], reviewRequired: true }), review: { version: 12, reasons: ['到港日期调整'] } }))
|
||||
await wrapper.get('textarea').setValue('已核对原安排'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ expectedVersion: 3, command: 'RESOLVE_REVIEW', payload: { reviewVersion: 12, note: '已核对原安排' } }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('requires receiving instructions for a customer warehouse that needs no appointment and does not invent a time', async () => {
|
||||
const action: WorkAction = { code: 'SAVE_REQUIREMENT', label: '登记收货要求', targetWorkItemKey: null, target: { kind: 'WORK_COMMAND', command: 'SAVE_REQUIREMENT' } }
|
||||
const wrapper = render(action)
|
||||
await wrapper.get('input[type=checkbox]').setValue(false)
|
||||
await wrapper.findAll('textarea')[1]!.setValue('客户仓确认无需预约')
|
||||
await wrapper.get('form').trigger('submit'); expect(submitWorkCommand).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('textarea')[0]!.setValue('工作日9至16点到仓联系收货人')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ command: 'SAVE_REQUIREMENT', payload: { target: null, bookingRequired: false, receivingRequirements: '工作日9至16点到仓联系收货人', reason: '客户仓确认无需预约' } }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps a return handoff separate from original carrier plans and records external acceptance evidence', async () => {
|
||||
const action: WorkAction = { code: 'ACCEPT_RETURN', label: '记录接手反馈', targetWorkItemKey: null, target: { kind: 'WORK_COMMAND', command: 'ACCEPT_RETURN' } }
|
||||
const wrapper = render(action, workDetail({ item: workItem({ type: 'EMPTY_RETURN', allowedActions: [action] }), returnHandoff: { version: 7, from: { kind: 'USER', id: 'user-1', name: '测试调度' }, to: { kind: 'EXTERNAL', id: null, name: '测试还空车队' }, status: 'PENDING_ACCEPTANCE', reason: '原车次调整' } }))
|
||||
await wrapper.get('form').trigger('submit'); expect(submitWorkCommand).not.toHaveBeenCalled()
|
||||
await wrapper.get('textarea').setValue('接手车队已回复确认'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ command: 'ACCEPT_RETURN', payload: { handoffVersion: 7, note: '接手车队已回复确认' } }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('denies command submission without write permission even when a submit event is forced', async () => {
|
||||
session.canWrite = false; const wrapper = render()
|
||||
await wrapper.get('form').trigger('submit'); expect(submitWorkCommand).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('当前仅可查看'); wrapper.unmount()
|
||||
})
|
||||
|
||||
const requirementAction: WorkAction = { code: 'SAVE_REQUIREMENT', label: '登记收货要求', targetWorkItemKey: null, target: { kind: 'WORK_COMMAND', command: 'SAVE_REQUIREMENT' } }
|
||||
it('preserves an explicit cross-day local interval in the requirement request', async () => {
|
||||
const wrapper = render(requirementAction)
|
||||
await wrapper.findAll('textarea')[1]!.setValue('跨日收货窗口')
|
||||
const dates = wrapper.findAll('input[type=date]'), times = wrapper.findAll('input[type=time]')
|
||||
await dates[0]!.setValue('2026-10-15'); await times[0]!.setValue('23:00')
|
||||
await dates[1]!.setValue('2026-10-16'); await times[1]!.setValue('01:00')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ payload: expect.objectContaining({ target: { date: '2026-10-15', startTime: '23:00', endDate: '2026-10-16', endTime: '01:00', timezone: 'America/Los_Angeles' } }) }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([
|
||||
['2026-10-16', '', '23:00'], ['', '01:00', '23:00'],
|
||||
['2026-10-15', '22:00', '23:00'], ['2026-10-15', '23:00', '23:00'],
|
||||
['2026-10-16', '01:00', ''],
|
||||
])('rejects incomplete or non-increasing interval %s %s %s without inferring a next day', async (endDate, endTime, startTime) => {
|
||||
const wrapper = render(requirementAction)
|
||||
await wrapper.findAll('textarea')[1]!.setValue('核对时间')
|
||||
const dates = wrapper.findAll('input[type=date]'), times = wrapper.findAll('input[type=time]')
|
||||
await dates[0]!.setValue('2026-10-15'); await times[0]!.setValue(startTime)
|
||||
await dates[1]!.setValue(endDate); await times[1]!.setValue(endTime)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(submitWorkCommand).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('saves a date-only requirement with all time fields null', async () => {
|
||||
const wrapper = render(requirementAction)
|
||||
await wrapper.findAll('textarea')[1]!.setValue('具体时段待回复')
|
||||
await wrapper.findAll('input[type=date]')[0]!.setValue('2026-10-15')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ payload: expect.objectContaining({ target: { date: '2026-10-15', startTime: null, endDate: null, endTime: null, timezone: 'America/Los_Angeles' } }) }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { submitWorkCommand, type WorkAction, type WorkDetail, type WorkCommand, type WorkCommandPayload, type WorkCommandResult } from './workApi'
|
||||
import { actionAllowed, commandName } from './workPresentation'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ detail: WorkDetail; action: WorkAction; refreshing: boolean }>()
|
||||
const emit = defineEmits<{ saved: [result: WorkCommandResult, command: WorkCommand['command']]; refresh: [] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||||
const command = computed(() => commandName(props.action))
|
||||
const item = computed(() => props.detail.item)
|
||||
const form = reactive({ flow: item.value.flow === 'DIRECT' ? 'DIRECT' : 'TRANSFER', ownerId: item.value.owner?.id || auth.user?.id || '', followupDate: item.value.followupDate || '', note: '', reason: '', date: item.value.target?.date || '', startTime: item.value.target?.startTime || '', endDate: item.value.target?.endDate || '', endTime: item.value.target?.endTime || '', bookingRequired: props.detail.requirement?.bookingRequired ?? true, receivingRequirements: props.detail.requirement?.receivingRequirements || '', recipientKind: 'EXTERNAL', recipientId: '', recipientName: '' })
|
||||
const baseline = ref(JSON.stringify(form)), pending = ref<WorkCommand | null>(null), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const supplierKeyword = ref(''), suppliers = ref<Supplier[]>([]), supplierTotal = ref(0), supplierLoading = ref(false), supplierError = ref<ApiErrorDetails | null>(null)
|
||||
let supplierRequest = 0
|
||||
async function searchSuppliers() {
|
||||
const request = ++supplierRequest; supplierError.value = null; suppliers.value = []; form.recipientId = ''
|
||||
if (!access.can('platform:supplier:list')) { supplierError.value = { message: '当前账号没有供应商读取权限,请联系有权限的负责人选择车队。' }; return }
|
||||
supplierLoading.value = true
|
||||
try { const result = await listSuppliers({ page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: supplierKeyword.value }); if (request === supplierRequest) { suppliers.value = result.records; supplierTotal.value = result.total } }
|
||||
catch (cause) { if (request === supplierRequest) supplierError.value = getApiErrorDetails(cause, '还空车队读取失败') }
|
||||
finally { if (request === supplierRequest) supplierLoading.value = false }
|
||||
}
|
||||
function changeRecipient() { form.recipientId = ''; form.recipientName = ''; if (form.recipientKind === 'SUPPLIER') void searchSuppliers() }
|
||||
useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
|
||||
const owners = computed(() => {
|
||||
const people = [item.value.owner, auth.user ? { id: auth.user.id, name: auth.user.name } : null, ...props.detail.relatedItems.map(row => row.owner)]
|
||||
return [...new Map(people.filter((person): person is { id: string; name: string } => !!person?.id).map(person => [person.id, person])).values()]
|
||||
})
|
||||
const permission = computed(() => command.value === 'SAVE_FOLLOWUP' || command.value === 'RESOLVE_REVIEW' ? 'platform:appointment:followup' : 'platform:appointment:manage-work')
|
||||
const allowed = computed(() => !!command.value && actionAllowed(item.value, props.action) && access.can(permission.value))
|
||||
const missingContext = computed(() => {
|
||||
if (command.value === 'RESOLVE_REVIEW' && props.detail.review == null) return '当前没有可复核的影响版本,请刷新事项。'
|
||||
if (command.value === 'ACCEPT_RETURN' && props.detail.returnHandoff == null) return '当前没有有效的还空交接,请刷新事项。'
|
||||
if (command.value === 'SAVE_REQUIREMENT' && form.date && !item.value.timezone) return '作业地点时区未核实,暂不能保存带日期的要求。'
|
||||
return ''
|
||||
})
|
||||
function payload(): WorkCommandPayload | null {
|
||||
const note = form.note.trim(), reason = form.reason.trim()
|
||||
switch (command.value) {
|
||||
case 'CONFIRM_ROUTE': return { command: 'CONFIRM_ROUTE', payload: { flow: form.flow === 'DIRECT' ? 'DIRECT' : 'TRANSFER', reason: reason || null } }
|
||||
case 'SAVE_FOLLOWUP':
|
||||
if (!form.ownerId || !note) { ElMessage.warning('请选择跟进负责人并填写本次回复'); return null }
|
||||
return { command: 'SAVE_FOLLOWUP', payload: { ownerId: form.ownerId, followupDate: form.followupDate || null, note } }
|
||||
case 'RESOLVE_REVIEW':
|
||||
if (!props.detail.review || !note) { ElMessage.warning('请核对当前影响并填写处理结果'); return null }
|
||||
return { command: 'RESOLVE_REVIEW', payload: { reviewVersion: props.detail.review.version, note } }
|
||||
case 'SAVE_REQUIREMENT':
|
||||
if (!reason || (!form.bookingRequired && !form.receivingRequirements.trim())) { ElMessage.warning('请填写处理说明;无需预约时必须填写真实收货要求'); return null }
|
||||
if ((form.startTime || form.endDate || form.endTime) && !form.date) { ElMessage.warning('填写时段前需明确目标日期'); return null }
|
||||
if (!!form.endDate !== !!form.endTime) { ElMessage.warning('结束日期和结束时间须同时填写或同时留空'); return null }
|
||||
if (form.endDate && (!form.startTime || form.endDate + 'T' + form.endTime <= form.date + 'T' + form.startTime)) { ElMessage.warning('完整的当地结束时间须晚于开始时间'); return null }
|
||||
return { command: 'SAVE_REQUIREMENT', payload: { target: form.date && item.value.timezone ? { date: form.date, startTime: form.startTime || null, endDate: form.endDate || null, endTime: form.endTime || null, timezone: item.value.timezone } : null, bookingRequired: form.bookingRequired, receivingRequirements: form.receivingRequirements.trim(), reason } }
|
||||
case 'HANDOFF_RETURN': {
|
||||
const person = owners.value.find(owner => owner.id === form.recipientId)
|
||||
const supplier = suppliers.value.find(candidate => candidate.id === form.recipientId)
|
||||
const recipient = form.recipientKind === 'USER' && person ? { kind: 'USER' as const, id: person.id, name: person.name } : form.recipientKind === 'SUPPLIER' && supplier && !supplierLoading.value && !supplierError.value ? { kind: 'SUPPLIER' as const, id: supplier.id, name: supplier.name } : form.recipientKind === 'EXTERNAL' ? { kind: 'EXTERNAL' as const, id: null, name: form.recipientName.trim() } : null
|
||||
if (!recipient?.name || !reason) { ElMessage.warning('请明确接手方并填写转交原因'); return null }
|
||||
return { command: 'HANDOFF_RETURN', payload: { recipient, reason } }
|
||||
}
|
||||
case 'ACCEPT_RETURN':
|
||||
if (!props.detail.returnHandoff) return null
|
||||
if (props.detail.returnHandoff.to.kind !== 'USER' && !note) { ElMessage.warning('请记录外部接手方已确认的反馈依据'); return null }
|
||||
return { command: 'ACCEPT_RETURN', payload: { handoffVersion: props.detail.returnHandoff.version, note: note || null } }
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (!allowed.value || props.refreshing || saving.value || missingContext.value) return
|
||||
if (!pending.value) { const request = payload(); if (!request) return; pending.value = { ...request, expectedVersion: item.value.version, idempotencyKey: crypto.randomUUID() } }
|
||||
saving.value = true; error.value = null
|
||||
try {
|
||||
const request = pending.value, result = await submitWorkCommand(item.value.workItemKey, request)
|
||||
pending.value = null; baseline.value = JSON.stringify(form); ElMessage.success('已保存,工作待办已更新'); emit('saved', result, request.command)
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '本次保存结果待确认,请重试同一次保存')
|
||||
if (rejectedCommand(cause)) { pending.value = null; emit('refresh') }
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<form class="density-form work-form" @submit.prevent="save">
|
||||
<h3>{{ action.label }}</h3>
|
||||
<p v-if="!allowed">当前仅可查看。此操作需要有效的服务端资格和对应操作权限。</p>
|
||||
<p v-if="missingContext" role="alert">{{ missingContext }}</p>
|
||||
<fieldset :disabled="!allowed || refreshing || saving || !!pending">
|
||||
<template v-if="command === 'CONFIRM_ROUTE'"><label>运输方式<select v-model="form.flow"><option value="TRANSFER">先入海外仓,再分批送仓</option><option value="DIRECT">整柜直接送目的仓</option></select></label><label>路线说明<textarea v-model="form.reason" maxlength="1000" /></label></template>
|
||||
<template v-else-if="command === 'SAVE_FOLLOWUP'"><label>跟进负责人<select v-model="form.ownerId" required><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>本次回复<textarea v-model="form.note" required maxlength="1000" /></label><label>下次跟进日期(可留空)<input v-model="form.followupDate" type="date"></label><p>跟进日期不代替作业日期,未完成事项仍保留在待办中。</p></template>
|
||||
<template v-else-if="command === 'RESOLVE_REVIEW'"><p v-for="reason in detail.review?.reasons" :key="reason">{{ reason }}</p><label>本次核对结果<textarea v-model="form.note" required maxlength="1000" /></label><p>若原安排需要改期,请先处理原安排,再记录核对结果。</p></template>
|
||||
<template v-else-if="command === 'SAVE_REQUIREMENT'"><label>目标日期(可暂不填)<input v-model="form.date" type="date"></label><div class="time-fields"><label>开始时段(可留空)<input v-model="form.startTime" type="time"></label><label>结束日期(可留空)<input v-model="form.endDate" type="date"></label><label>结束时段(可留空)<input v-model="form.endTime" type="time"></label></div><p>{{ item.timezone || '地点时区待核实' }}</p><label v-if="item.type === 'DELIVERY'"><input v-model="form.bookingRequired" type="checkbox">需要收货方预约</label><label>收货要求<textarea v-model="form.receivingRequirements" :required="!form.bookingRequired" maxlength="1000" /></label><label>处理说明<textarea v-model="form.reason" required maxlength="1000" /></label></template>
|
||||
<template v-else-if="command === 'HANDOFF_RETURN'"><p>转交保留原预约和责任记录;接手方确认前持续跟进。</p><label>接手方类型<select v-model="form.recipientKind" @change="changeRecipient"><option value="EXTERNAL">外部车队 / 联系人</option><option value="SUPPLIER">已登记海外车队</option><option value="USER">当前关联负责人</option></select></label><label v-if="form.recipientKind === 'EXTERNAL'">接手方名称<input v-model="form.recipientName" required maxlength="200"></label><template v-else-if="form.recipientKind === 'SUPPLIER'"><label>查找海外车队<input v-model="supplierKeyword" maxlength="80" @keydown.enter.prevent="searchSuppliers"></label><el-button :loading="supplierLoading" @click="searchSuppliers">查询车队</el-button><LoadErrorState v-if="supplierError" title="还空车队不可用" :message="supplierError.message" @retry="searchSuppliers" /><label>接手车队<select v-model="form.recipientId" required :disabled="supplierLoading || !!supplierError"><option value="" disabled>请选择已登记海外车队</option><option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">{{ supplier.name }}</option></select></label><p v-if="supplierTotal > suppliers.length">请输入更完整名称缩小查询范围。</p></template><label v-else>接手负责人<select v-model="form.recipientId" required><option value="" disabled>请选择</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>转交原因<textarea v-model="form.reason" required maxlength="1000" /></label></template>
|
||||
<template v-else-if="command === 'ACCEPT_RETURN'"><p>原责任方:{{ detail.returnHandoff?.from.name || '—' }} → 接手方:{{ detail.returnHandoff?.to.name || '—' }}</p><label>接手反馈依据<textarea v-model="form.note" :required="detail.returnHandoff?.to.kind !== 'USER'" maxlength="1000" /></label><p>确认接手只记录责任,实际还空仍按原业务操作。</p></template>
|
||||
</fieldset>
|
||||
<LoadErrorState v-if="error" title="工作操作未保存" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-button type="primary" native-type="submit" :disabled="!allowed || refreshing || !!missingContext" :loading="saving">{{ pending ? '重试本次保存' : command === 'CONFIRM_ROUTE' ? '确认路线,继续本柜工作' : '保存并更新待办' }}</el-button>
|
||||
</form>
|
||||
</template>
|
||||
<style scoped>.time-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); }</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkCreateAppointmentForm from './WorkCreateAppointmentForm.vue'
|
||||
import { previewPlanningBatch, createSourceAppointment, type PlanningBatchPreview } from './planningApi'
|
||||
import type { SourceReference } from './workApi'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
const access = vi.hoisted(() => ({ write: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.write }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
|
||||
const sources: SourceReference[] = [{ sourceKey: 'BOOKING:external-1', customerId: '9007199254740999', destinationKey: 'FBA:ONT8' }]
|
||||
const preview: PlanningBatchPreview = { group: { originWarehouseId: 'origin-1', destinationKey: 'FBA:ONT8', packages: 30, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: '测试起运仓', destinationWarehouseCode: 'ONT8', destinationTimezone: 'America/Los_Angeles', missingFields: [], sources: [{ ...sources[0]!, destinationKey: 'FBA:ONT8', orderId: null, inboundCode: null, bookingNo: 'BK-TEST', containerNo: null, originWarehouseId: 'origin-1', originWarehouseName: '测试起运仓', destinationWarehouseCode: 'ONT8', packages: 30, sourceVersion: null, canFollowup: true, blockReasons: [] }] }
|
||||
let guard: LeaveGuard
|
||||
function render(input = sources) { return mount(WorkCreateAppointmentForm, { props: { sources: input, canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<input v-model="model.plannedDate" type="date">' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true } } }) }
|
||||
beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(previewPlanningBatch).mockResolvedValue(preview); vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment) })
|
||||
it('previews and creates the exact canonical external source and customer without inventing times', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(previewPlanningBatch).toHaveBeenCalledWith(sources)
|
||||
await wrapper.get('input').setValue('2026-10-16'); await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources, originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', details: expect.objectContaining({ plannedDate: '2026-10-16', plannedAt: null, plannedEndAt: null, appointmentStatus: 'PENDING' }) }))
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); expect(wrapper.text()).not.toContain('external-1'); wrapper.unmount()
|
||||
})
|
||||
it('retains the same immutable request after an unknown outcome and blocks leaving', async () => {
|
||||
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
const request = vi.mocked(createSourceAppointment).mock.calls[0]![0]
|
||||
expect(await guard()).toBe(false)
|
||||
await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(createSourceAppointment).toHaveBeenNthCalledWith(2, request); wrapper.unmount()
|
||||
})
|
||||
it('refreshes qualification after conflict while keeping user-entered dates', async () => {
|
||||
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new ApiResponseError('来源变化', 'A0409', 409))
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input').setValue('2026-10-16')
|
||||
await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(previewPlanningBatch).toHaveBeenCalledTimes(2)
|
||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-16')
|
||||
expect(wrapper.emitted('saved')).toBeUndefined(); wrapper.unmount()
|
||||
})
|
||||
it('rejects missing destination scope and lacks a write path without permission', async () => {
|
||||
const missing = render([{ ...sources[0]!, destinationKey: null }]); await flushPromises()
|
||||
expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(missing.text()).toContain('缺少完整货源'); missing.unmount()
|
||||
access.write = false; const readonly = render(); await flushPromises(); await readonly.get('button').trigger('click')
|
||||
expect(createSourceAppointment).not.toHaveBeenCalled(); readonly.unmount()
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { previewPlanningBatch, createSourceAppointment, type PlanningBatchPreview, type SourceAppointmentPayload } from './planningApi'
|
||||
import type { SourceReference } from './workApi'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ sources: SourceReference[]; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore(), preview = ref<PlanningBatchPreview | null>(null)
|
||||
const form = reactive(createAppointmentForm()), baseline = ref(JSON.stringify(form))
|
||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const pending = ref<SourceAppointmentPayload | null>(null)
|
||||
const writable = computed(() => props.canWrite && access.can(overseasWarehousePermissions.inboundOrderAppointment))
|
||||
useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
|
||||
async function load() {
|
||||
if (!props.sources.length || props.sources.some(source => !source.customerId || !source.sourceKey || !source.destinationKey)) { error.value = { message: '缺少完整货源、客户或目的仓范围,无法新建预约。' }; return }
|
||||
loading.value = true; error.value = null
|
||||
try { preview.value = await previewPlanningBatch(props.sources.map(source => ({ ...source, destinationKey: source.destinationKey! }))) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '预约货物预览失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!writable.value || loading.value || saving.value || error.value || !preview.value) return
|
||||
if (!pending.value) {
|
||||
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
|
||||
pending.value = { originWarehouseId: preview.value.group.originWarehouseId, fbaWarehouseCode: preview.value.destinationWarehouseCode, sources: preview.value.sources.map(source => ({ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey })), details: appointmentPayload(form), idempotencyKey: crypto.randomUUID() }
|
||||
}
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
const result = await createSourceAppointment(pending.value)
|
||||
pending.value = null; baseline.value = JSON.stringify(form)
|
||||
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('saved')
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '创建结果待确认,请重试本次请求')
|
||||
if (rejectedCommand(cause)) { pending.value = null; await load() }
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="新建预约安排">
|
||||
<h3>新建预约安排</h3>
|
||||
<LoadErrorState v-if="error" title="预约货物预览失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在核对本次货物范围…</p>
|
||||
<template v-if="preview && !error">
|
||||
<p>{{ preview.originWarehouseName }} → {{ preview.destinationWarehouseCode }} · {{ preview.group.sourceCount }} 票 · {{ preview.group.packages ?? '箱数待核实' }}</p>
|
||||
<p v-for="source in preview.sources" :key="`${source.customerId}:${source.sourceKey}`">{{ source.inboundCode || source.bookingNo || '原票业务号未提供' }}</p>
|
||||
<p v-for="field in preview.missingFields" :key="field">{{ field }}</p>
|
||||
<p v-if="preview.group.readinessIncomplete">货物备妥窗口待核实。</p>
|
||||
<FbaAppointmentFields :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
|
||||
<LoadErrorState v-if="saveError" title="预约尚未创建" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkDetailPanel from './WorkDetailPanel.vue'
|
||||
import { getWorkDetail, type WorkAction, type WorkDetail } from './workApi'
|
||||
import { followAction, workDetail, workItem } from './work.test-support'
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn() }))
|
||||
function render(detail: WorkDetail, initialAction?: WorkAction) {
|
||||
vi.mocked(getWorkDetail).mockResolvedValue(detail)
|
||||
return mount(WorkDetailPanel, { props: { itemKey: detail.item.workItemKey, initialAction, canLeave: async () => true }, global: { stubs: {
|
||||
ElButton: { template: '<button><slot /></button>' },
|
||||
CargoVerificationForm: { name: 'CargoVerificationForm', props: ['source'], template: '<div data-cargo />' },
|
||||
WorkCommandForm: { name: 'WorkCommandForm', props: ['detail', 'action'], template: '<div data-command />' },
|
||||
WorkAppointmentForm: { name: 'WorkAppointmentForm', props: ['appointmentNo', 'canWrite'], template: '<div data-appointment />' },
|
||||
WorkCreateAppointmentForm: { name: 'WorkCreateAppointmentForm', props: ['sources', 'canWrite'], template: '<div data-create />' },
|
||||
WorkDispatchForm: { name: 'WorkDispatchForm', props: ['batchKeys', 'canWrite'], template: '<div data-dispatch />' },
|
||||
WorkExistingDispatchForm: { name: 'WorkExistingDispatchForm', props: ['dispatches', 'canWrite'], template: '<div data-existing-dispatch />' },
|
||||
WorkReceiptPlans: { name: 'WorkReceiptPlans', props: ['containerBookingId'], template: '<div data-receiving />' },
|
||||
LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true,
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('never sends customer-warehouse dispatch batches to the FBA candidate endpoint', async () => {
|
||||
const action: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '安排运输', targetWorkItemKey: null, target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['DISPATCH:9007199254740999'] } }
|
||||
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'WorkDispatchForm' }).exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('resolves a customer-warehouse batch using the frozen work item batch number', async () => {
|
||||
const action: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '安排运输', targetWorkItemKey: null, target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['DISPATCH:9007199254740999'] } }
|
||||
const wrapper = render(workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS-ORIGINAL', allowedActions: [action], nextAction: action }) }), action); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkExistingDispatchForm' }).props('dispatches')).toEqual([{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS-ORIGINAL' }])
|
||||
expect(wrapper.findComponent({ name: 'WorkDispatchForm' }).exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('opens real batch dispatch when the server has not bound a trip yet', async () => {
|
||||
const action: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '安排运输', targetWorkItemKey: null, target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:1'] } }
|
||||
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDispatchForm' }).props('batchKeys')).toEqual(['FBA_APPOINTMENT:1']); wrapper.unmount()
|
||||
})
|
||||
it('loads the exact container plan list for a multiple-plan receiving target', async () => {
|
||||
const action: WorkAction = { code: 'RECORD_EXECUTION', label: '查看接仓', targetWorkItemKey: null, target: { kind: 'WAREHOUSE_RECEIPT', inboundPlanId: null, inboundPlanNo: null, customerId: null, containerBookingId: '9007199254740999' } }
|
||||
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkReceiptPlans' }).props('containerBookingId')).toBe('9007199254740999'); wrapper.unmount()
|
||||
})
|
||||
it('opens a real create form for unappointed sources with their exact customer scope', async () => {
|
||||
const action: WorkAction = { code: 'SAVE_PLAN', label: '安排预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: null, batchKey: null, sources: [{ sourceKey: 'BOOKING:1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' }] } }
|
||||
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkCreateAppointmentForm' }).props('sources')).toEqual(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : [])
|
||||
expect(wrapper.text()).not.toContain('尚未关联可操作的预约记录'); wrapper.unmount()
|
||||
})
|
||||
it('opens the server-selected prerequisite instead of issuing the current item command', async () => {
|
||||
const action = { ...followAction, targetWorkItemKey: 'work-pickup-1' }
|
||||
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
|
||||
expect(wrapper.emitted('open')).toEqual([['work-pickup-1', action]])
|
||||
expect(wrapper.findComponent({ name: 'WorkCommandForm' }).exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('uses explicit cargo context and preserves large customer IDs without inspecting a business key', async () => {
|
||||
const action: WorkAction = { code: 'VERIFY_CARGO', label: '核实货物资料', targetWorkItemKey: null, target: { kind: 'CARGO_VERIFICATION', source: { customerId: '9007199254740999', sourceKey: 'BOOKING:B-1', destinationKey: 'FBA:ONT8' } } }
|
||||
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'CargoVerificationForm' }).props('source')).toEqual(action.target.kind === 'CARGO_VERIFICATION' ? action.target.source : null)
|
||||
expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount()
|
||||
})
|
||||
it('rechecks current qualification before opening a previously offered write action', async () => {
|
||||
const wrapper = render(workDetail({ item: workItem({ allowedActions: [], nextAction: null, blockReasons: ['预约已由其他人修改'] }) }), followAction); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'WorkCommandForm' }).exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('预约已由其他人修改'); wrapper.unmount()
|
||||
})
|
||||
it('continues the real unbatched cargo action into original appointment creation with exact source scope', async () => {
|
||||
const source = { customerId: 'customer-a', sourceKey: 'BOOKING:9007199254740999', destinationKey: 'FBA:ONT8' }
|
||||
const action: WorkAction = { code: 'VERIFY_CARGO', label: '核实货物并建立预约', targetWorkItemKey: null, target: { kind: 'CARGO_VERIFICATION', source } }
|
||||
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
|
||||
wrapper.getComponent({ name: 'CargoVerificationForm' }).vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
await wrapper.findAll('button').find(button => button.text() === '核对货物并新建原预约')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkCreateAppointmentForm' }).props('sources')).toEqual([source]); wrapper.unmount()
|
||||
})
|
||||
it('uses the matched appointment business number and never passes the internal appointment ID to the old API', async () => {
|
||||
const action: WorkAction = { code: 'CHANGE_BOOKING', label: '调整预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: 'internal-id', batchKey: 'batch-1', sources: [] } }
|
||||
const detail = workDetail({ item: workItem({ allowedActions: [action], nextAction: action }), references: [{ kind: 'FBA_APPOINTMENT', key: 'batch-1', businessNo: 'AP-TEST', label: '预约' }] })
|
||||
const wrapper = render(detail, action); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkAppointmentForm' }).props('appointmentNo')).toBe('AP-TEST'); wrapper.unmount()
|
||||
const missing = render({ ...detail, references: [] }, action); await flushPromises()
|
||||
expect(missing.findComponent({ name: 'WorkAppointmentForm' }).exists()).toBe(false)
|
||||
expect(missing.text()).toContain('尚未关联可操作的预约记录'); missing.unmount()
|
||||
})
|
||||
it('displays all associated containers of a shared trip without creating duplicate transport work', async () => {
|
||||
const wrapper = render(workDetail({ item: workItem({ type: 'TRANSPORT', containers: [{ containerBookingId: '1', bookingNo: 'D-1', containerNo: 'TEST-CN-A' }, { containerBookingId: '2', bookingNo: 'D-2', containerNo: 'TEST-CN-B' }] }) })); await flushPromises()
|
||||
expect(wrapper.findAll('.container-links button')).toHaveLength(2)
|
||||
await wrapper.findAll('.container-links button')[1]!.trigger('click'); expect(wrapper.emitted('container')).toEqual([['2', 'TEST-CN-B']]); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getWorkDetail, type WorkAction, type WorkDetail, type WorkCommand, type WorkCommandResult } from './workApi'
|
||||
import { actionAllowed, actionRoute, referenceRoute, commandName, flowLabels, typeLabels, workDate, workStatus, workReason } from './workPresentation'
|
||||
import CargoVerificationForm from './CargoVerificationForm.vue'
|
||||
import WorkCommandForm from './WorkCommandForm.vue'
|
||||
import WorkAppointmentForm from './WorkAppointmentForm.vue'
|
||||
import WorkCreateAppointmentForm from './WorkCreateAppointmentForm.vue'
|
||||
import WorkDispatchForm from './WorkDispatchForm.vue'
|
||||
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
|
||||
import WorkReceiptPlans from './WorkReceiptPlans.vue'
|
||||
import AppointmentBatchForm from './AppointmentBatchForm.vue'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const props = defineProps<{ itemKey: string; initialAction?: WorkAction | null; canLeave: LeaveGuard }>()
|
||||
const emit = defineEmits<{ open: [key: string, action?: WorkAction]; external: [path: string]; saved: [result?: WorkCommandResult, command?: WorkCommand['command']]; updated: []; container: [id: string, label: string] }>()
|
||||
const detail = ref<WorkDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), action = ref<WorkAction | null>(null)
|
||||
const actionNotice = ref('')
|
||||
const creatingFromCargo = ref(false)
|
||||
const batchKey = ref<string | null>(null), openedAppointmentNo = ref<string | null>(null)
|
||||
let controller: AbortController | null = null, request = 0
|
||||
async function load() {
|
||||
const generation = ++request; controller?.abort(); controller = new AbortController(); loading.value = true; error.value = null
|
||||
try { const result = await getWorkDetail(props.itemKey, controller.signal); if (generation === request) detail.value = result }
|
||||
catch (cause) { if (generation === request) error.value = getApiErrorDetails(cause, '工作事项读取失败') }
|
||||
finally { if (generation === request) loading.value = false }
|
||||
}
|
||||
const fbaNumber = computed(() => {
|
||||
const target = action.value?.target
|
||||
if (target?.kind !== 'FBA_APPOINTMENT' || !target.appointmentId) return null
|
||||
return detail.value?.references.find(ref => ref.kind === 'FBA_APPOINTMENT' && ref.key === target.batchKey)?.businessNo || null
|
||||
})
|
||||
const cargoSource = computed(() => {
|
||||
const target = action.value?.target
|
||||
return target?.kind === 'CARGO_VERIFICATION' ? { ...target.source, destinationKey: target.source.destinationKey || undefined } : null
|
||||
})
|
||||
const createSources = computed(() => creatingFromCargo.value && action.value?.target.kind === 'CARGO_VERIFICATION' ? [action.value.target.source] : action.value?.target.kind === 'FBA_APPOINTMENT' && !action.value.target.appointmentId && ['SAVE_PLAN', 'REQUEST_BOOKING', 'RECORD_CONFIRMATION'].includes(action.value.code) ? action.value.target.sources : null)
|
||||
const dispatchBatchKeys = computed(() => {
|
||||
const target = action.value?.target
|
||||
if (target?.kind !== 'FLEET_TRIP' || target.tripId || action.value?.code !== 'ARRANGE_TRANSPORT') return null
|
||||
return target.batchKeys.length && target.batchKeys.every(key => /^FBA_APPOINTMENT:\d+$/.test(key)) ? target.batchKeys : null
|
||||
})
|
||||
const existingDispatches = computed(() => {
|
||||
const target = action.value?.target
|
||||
if (target?.kind !== 'FLEET_TRIP' || target.tripId || action.value?.code !== 'ARRANGE_TRANSPORT' || target.batchKeys.length !== 1) return []
|
||||
const batchKey = target.batchKeys[0]!, item = detail.value?.item
|
||||
return /^DISPATCH:\d+$/.test(batchKey) && item?.batchKey === batchKey && item.batchNo ? [{ batchKey, dispatchNo: item.batchNo }] : []
|
||||
})
|
||||
const canAct = computed(() => !!detail.value && !!action.value && actionAllowed(detail.value.item, action.value) && !error.value && !loading.value)
|
||||
async function choose(next: WorkAction) {
|
||||
if (!detail.value || loading.value || error.value || !await props.canLeave()) return
|
||||
actionNotice.value = ''
|
||||
if (!actionAllowed(detail.value.item, next)) { actionNotice.value = '操作资格已变化,请刷新事项后查看当前下一步。'; return }
|
||||
if (next.targetWorkItemKey && next.targetWorkItemKey !== props.itemKey) { emit('open', next.targetWorkItemKey, next); return }
|
||||
const path = actionRoute(next, detail.value.references, detail.value.item)
|
||||
if (path) { emit('external', path); return }
|
||||
action.value = next; batchKey.value = null; openedAppointmentNo.value = null; creatingFromCargo.value = false
|
||||
if (next.code === 'EDIT_BATCH' && next.target.kind === 'FBA_APPOINTMENT' && next.target.batchKey) { batchKey.value = next.target.batchKey; return }
|
||||
if (next.target.kind === 'WORK_COMMAND' && !commandName(next)) actionNotice.value = '当前操作尚未提供可用入口,请联系负责人核对。'
|
||||
if (next.target.kind === 'FBA_APPOINTMENT' && !fbaNumber.value && !createSources.value) actionNotice.value = '尚未关联可操作的预约记录,请先核实批次与预约关系。'
|
||||
if (next.target.kind === 'FLEET_TRIP' && !dispatchBatchKeys.value?.length && !existingDispatches.value.length) actionNotice.value = '尚未关联可进入的车次或可排批次,请核对运输来源。'
|
||||
}
|
||||
function saved(result?: WorkCommandResult, command?: WorkCommand['command']) { emit('saved', result, command) }
|
||||
async function cargoSaved() { emit('updated'); await load() }
|
||||
async function createFromCargo() { if (canAct.value && await props.canLeave()) creatingFromCargo.value = true }
|
||||
async function openBatch(key: string) { if (await props.canLeave()) { batchKey.value = key; openedAppointmentNo.value = null } }
|
||||
async function openAppointment(number: string) { if (await props.canLeave()) { openedAppointmentNo.value = number; batchKey.value = null } }
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
// A predecessor's action is only a hint: use the newly fetched item's own qualification.
|
||||
if (props.initialAction && detail.value) {
|
||||
const own = detail.value.item.allowedActions.find(candidate => candidate.code === props.initialAction?.code && candidate.target.kind === props.initialAction?.target.kind)
|
||||
if (own) await choose(own)
|
||||
else if (detail.value.item.nextAction) await choose(detail.value.item.nextAction)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => { ++request; controller?.abort() })
|
||||
</script>
|
||||
<template>
|
||||
<div :aria-busy="loading">
|
||||
<LoadErrorState v-if="error" title="工作事项读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在读取当前事项与操作资格…</p>
|
||||
<template v-if="detail">
|
||||
<h3>{{ detail.item.title }}</h3><p>{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }} · {{ workStatus(detail.item) }}</p>
|
||||
<p>{{ workDate(detail.item) }} · {{ detail.item.timezone || '地点时区待核实' }}</p><p>{{ detail.item.destinationName || detail.item.originName || '地点待核实' }} · {{ detail.item.owner?.name || '待分派' }}</p>
|
||||
<div class="container-links"><button v-for="container in detail.item.containers" :key="container.containerBookingId" type="button" @click="emit('container', container.containerBookingId, container.containerNo || container.bookingNo || '本柜')">{{ container.containerNo || container.bookingNo || '—' }} · 本柜待办</button></div>
|
||||
<p v-for="reason in [...detail.item.blockReasons, ...detail.item.missingFields, ...detail.item.overdueReasons]" :key="reason" class="work-warning">{{ workReason(reason) }}</p>
|
||||
<p v-if="detail.returnHandoff">还空责任:{{ detail.returnHandoff.from.name }} → {{ detail.returnHandoff.to.name }} · {{ detail.returnHandoff.status === 'PENDING_ACCEPTANCE' ? '接手方待确认,持续跟进' : '已确认接手' }}</p>
|
||||
<template v-if="!action">
|
||||
<el-button v-if="detail.item.nextAction" type="primary" :disabled="loading || !!error" @click="choose(detail.item.nextAction)">{{ detail.item.nextAction.label }}</el-button>
|
||||
<p v-else-if="detail.item.executionStatus !== 'COMPLETED'">当前没有可执行的下一步,请根据缺项或前置说明联系负责人。</p>
|
||||
</template>
|
||||
<p v-if="actionNotice" role="status" class="work-warning">{{ actionNotice }}</p>
|
||||
<AppointmentBatchForm v-if="batchKey" :key="batchKey" :batch-key="batchKey" @updated="emit('updated')" @appointment="openAppointment" />
|
||||
<WorkAppointmentForm v-else-if="openedAppointmentNo" :key="openedAppointmentNo" :appointment-no="openedAppointmentNo" :cancel="false" :can-write="true" @saved="saved()" @batch="openBatch" />
|
||||
<WorkCommandForm v-else-if="action && commandName(action)" :key="`${itemKey}:${action.code}`" :detail="detail" :action="action" :refreshing="loading || !!error" @refresh="load" @saved="saved" />
|
||||
<template v-else-if="cargoSource && !creatingFromCargo"><CargoVerificationForm :key="`${cargoSource.customerId}:${cargoSource.sourceKey}:${cargoSource.destinationKey}`" :source="cargoSource" @saved="cargoSaved" /><el-button :disabled="!canAct" @click="createFromCargo">核对货物并新建原预约</el-button></template>
|
||||
<WorkAppointmentForm v-else-if="fbaNumber && action" :key="`${fbaNumber}:${action.code}`" :appointment-no="fbaNumber" :cancel="action.code === 'CANCEL_BOOKING'" :can-write="canAct" @saved="saved()" @batch="openBatch" />
|
||||
<WorkCreateAppointmentForm v-else-if="createSources" :key="JSON.stringify(createSources)" :sources="createSources" :can-write="canAct" @saved="saved()" />
|
||||
<WorkDispatchForm v-else-if="dispatchBatchKeys?.length" :key="JSON.stringify(dispatchBatchKeys)" :batch-keys="dispatchBatchKeys" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<WorkExistingDispatchForm v-else-if="existingDispatches.length" :key="JSON.stringify(existingDispatches)" :dispatches="existingDispatches" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<WorkReceiptPlans v-else-if="action?.target.kind === 'WAREHOUSE_RECEIPT'" :key="action.target.containerBookingId" :container-booking-id="action.target.containerBookingId" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<section v-else-if="action?.target.kind === 'READ_ONLY'" aria-label="原业务进度"><h4>原业务进度</h4><p v-for="reference in action.target.references" :key="`${reference.kind}:${reference.key}`"><el-button v-if="referenceRoute(reference)" :disabled="!canAct" @click="emit('external', referenceRoute(reference)!)">{{ reference.label }} · {{ reference.businessNo }}</el-button><span v-else>{{ reference.label }} · {{ reference.businessNo || '—' }}</span></p></section>
|
||||
<details v-if="detail.item.allowedActions.length"><summary>更多操作</summary><div class="more-actions"><el-button v-for="candidate in detail.item.allowedActions.filter(candidate => candidate.code !== action?.code)" :key="`${candidate.code}:${candidate.targetWorkItemKey}`" :disabled="loading || !!error" @click="choose(candidate)">{{ candidate.label }}</el-button></div></details>
|
||||
<details><summary>关联工作 · {{ detail.relatedItems.length }} 项</summary><div v-for="related in detail.relatedItems" :key="related.workItemKey" class="related-row"><div>{{ related.title }}<p>{{ workStatus(related) }} · {{ workDate(related) }}</p></div><el-button @click="emit('open', related.workItemKey)">查看</el-button></div></details>
|
||||
<details><summary>原业务记录 · {{ detail.references.length }} 项</summary><p v-for="reference in detail.references" :key="`${reference.kind}:${reference.key}`">{{ reference.label }} · {{ reference.businessNo || '—' }}</p></details>
|
||||
<details :open="action?.code === 'VIEW_HISTORY'"><summary>操作记录</summary><div v-for="(entry, index) in detail.history" :key="index" class="history-entry"><b>{{ entry.action }}</b><p>{{ entry.summary }}</p><small>{{ entry.operatorName || '—' }} · {{ entry.occurredAt }}</small></div><p v-if="!detail.history.length">暂无记录</p></details>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.container-links, .more-actions { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); }.container-links button { background: transparent; border: 0; color: var(--el-color-primary); cursor: pointer; font: inherit; }
|
||||
.related-row { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }.related-row, .history-entry { border-bottom: 1px solid var(--cc-border); padding-block: var(--admin-space-3); }details { margin-top: var(--admin-space-5); }summary { cursor: pointer; padding-block: var(--admin-space-3); }.work-warning { color: var(--el-color-warning-dark-2); }
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkDispatchForm from './WorkDispatchForm.vue'
|
||||
import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, type BatchDispatchCandidate } from '@/modules/overseas-warehouse'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getBatchDispatchCandidates: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), preArrangeDraftDispatch: vi.fn(), releaseDispatchDraft: vi.fn() }))
|
||||
const candidate: BatchDispatchCandidate = { planPalletId: '9007199254740993', palletNo: 'PL-TEST', planId: 'plan-1', planNo: 'IP-001', customerId: 'customer-a', sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', sourceKey: 'BOOKING:9', destinationKey: 'FBA:ONT8', packageCount: 12 }], blockReasons: [] }
|
||||
let guard: LeaveGuard
|
||||
function render() { return mount(WorkDispatchForm, { props: { batchKeys: ['FBA_APPOINTMENT:1'], canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
|
||||
function button(wrapper: ReturnType<typeof render>, text: string) { return wrapper.findAll('button').find(button => button.text() === text)! }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); access.allowed = true
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [structuredClone(candidate)], blockReasons: [] })
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValue([])
|
||||
vi.mocked(createDispatchDraftContainer).mockResolvedValue({ id: 'container-1', capacity: 30, palletIds: [] })
|
||||
vi.mocked(lockDispatchDraftPallet).mockResolvedValue(null)
|
||||
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-dispatch', dispatchNo: 'DS-001', overseasWarehouseId: 'origin-1', overseasWarehouseName: '测试起运仓' } }])
|
||||
})
|
||||
it('copies exact server allocations into a real draft group and continues into original trip preparation', async () => {
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
expect(preArrangeDraftDispatch).toHaveBeenCalledWith({ draftToken: expect.any(String), groups: [{ draftContainerId: 'container-1', planPalletIds: [candidate.planPalletId], returnReceiptIds: [], batchAllocations: candidate.allocations }] })
|
||||
expect(wrapper.text()).toContain('DS-001'); expect(wrapper.text()).not.toContain('internal-dispatch')
|
||||
const prepare = wrapper.getComponent({ name: 'TripPreparationDialog' })
|
||||
expect(prepare.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-001'] })
|
||||
prepare.vm.$emit('saved', { trip: { tripNo: 'TR-001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-001']])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('replays a submitted draft without re-locking already dispatched pallets after an unknown response', async () => {
|
||||
vi.mocked(preArrangeDraftDispatch).mockRejectedValueOnce(new Error('lost response'))
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0]
|
||||
expect(await guard()).toBe(false)
|
||||
await button(wrapper, '重试本次预排').trigger('click'); await flushPromises()
|
||||
expect(preArrangeDraftDispatch).toHaveBeenNthCalledWith(2, first); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('recovers a draft container from the same token when its creation response was lost', async () => {
|
||||
vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('lost create response'))
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
const token = vi.mocked(createDispatchDraftContainer).mock.calls[0]![0]
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: token, containers: [{ id: 'container-1', capacity: 30, palletIds: [] }] }])
|
||||
await button(wrapper, '重试本次预排').trigger('click'); await flushPromises()
|
||||
expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
})
|
||||
it('shows server blockers and prevents mixing customers or selecting blocked physical cargo', async () => {
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: '2', customerId: 'customer-b' }, { ...candidate, planPalletId: '3', blockReasons: ['本托货量超过本批剩余可排量'] }] })
|
||||
const wrapper = render(); await flushPromises(); const inputs = wrapper.findAll('input[type=checkbox]')
|
||||
expect(inputs[2]!.attributes('disabled')).toBeDefined(); expect(wrapper.text()).toContain('本托货量超过本批剩余可排量')
|
||||
await inputs[0]!.setValue(true); await inputs[1]!.setValue(true)
|
||||
expect((inputs[1]!.element as HTMLInputElement).checked).toBe(false)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.planPalletIds).toEqual([candidate.planPalletId]); wrapper.unmount()
|
||||
})
|
||||
it('does not read or write scoped cargo without all original permissions', async () => {
|
||||
access.allowed = false; const wrapper = render(); await flushPromises()
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, dispatchVehicleSpecs, type BatchDispatchCandidate, type BatchDispatchCandidates, type DispatchSummaryDto } from '@/modules/overseas-warehouse'
|
||||
import { TripPreparationDialog } from '@/modules/overseas-master-data'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; external: [path: string] }>()
|
||||
const access = useAccessPreviewStore(), contexts = ref<BatchDispatchCandidates[]>([]), selected = ref<string[]>([])
|
||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const capacity = ref(30), result = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false), rejected = ref(false)
|
||||
const pending = ref<{ token: string; capacity: number; rows: BatchDispatchCandidate[]; containerId: string | null; lockedIds: string[]; submitting: boolean } | null>(null)
|
||||
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list'].every(permission => access.can(permission)))
|
||||
const writable = computed(() => props.canWrite && readable.value && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'))
|
||||
const rows = computed(() => contexts.value.flatMap(context => context.candidates.map(row => ({ ...row, blockReasons: [...context.blockReasons, ...row.blockReasons] }))))
|
||||
function reasons(row: BatchDispatchCandidate) {
|
||||
const duplicates = rows.value.filter(other => other.planPalletId === row.planPalletId).length > 1
|
||||
return [...row.blockReasons, ...(!row.allocations.length ? ['缺少逐来源批次分配'] : []), ...(duplicates ? ['同一实物出现在多个批次,请核对分批范围'] : [])]
|
||||
}
|
||||
const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.planPalletId)))
|
||||
useWorkDraft(computed(() => selected.value.length > 0 && !result.value), computed(() => saving.value || !!pending.value))
|
||||
async function load() {
|
||||
if (!readable.value || pending.value) return
|
||||
loading.value = true; error.value = null
|
||||
try { contexts.value = await Promise.all([...new Set(props.batchKeys)].map(getBatchDispatchCandidates)) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '批次可排实物读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function select(row: BatchDispatchCandidate, checked: boolean) {
|
||||
if (!writable.value || pending.value || saving.value || loading.value || error.value || reasons(row).length) return
|
||||
if (!checked) { selected.value = selected.value.filter(id => id !== row.planPalletId); return }
|
||||
if (chosen.value.some(current => current.customerId !== row.customerId)) { ElMessage.warning('一张排车单只选择同一客户实物;其余客户可继续分别安排'); return }
|
||||
if (selected.value.length >= capacity.value) { ElMessage.warning('已达到所选车型托位上限'); return }
|
||||
selected.value = [...selected.value, row.planPalletId]
|
||||
}
|
||||
function changeSelection(row: BatchDispatchCandidate, event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
select(row, input.checked); input.checked = selected.value.includes(row.planPalletId)
|
||||
}
|
||||
async function save() {
|
||||
if (!writable.value || saving.value || loading.value || error.value || result.value) return
|
||||
if (!pending.value) {
|
||||
if (!chosen.value.length || chosen.value.length > capacity.value || chosen.value.some(row => reasons(row).length) || new Set(chosen.value.map(row => row.customerId)).size !== 1) { ElMessage.warning('请核对所选客户、实物资格和托位数量'); return }
|
||||
pending.value = { token: crypto.randomUUID(), capacity: capacity.value, rows: structuredClone(chosen.value.map(row => ({ ...row, allocations: row.allocations.map(allocation => ({ ...allocation })), blockReasons: [...row.blockReasons] }))), containerId: null, lockedIds: [], submitting: false }
|
||||
}
|
||||
saving.value = true; saveError.value = null; rejected.value = false
|
||||
try {
|
||||
const request = pending.value
|
||||
if (!request.containerId) {
|
||||
const drafts = await listDispatchDrafts(), existing = drafts.find(draft => draft.draftToken === request.token)?.containers || []
|
||||
if (existing.length > 1) throw new Error('本次草稿出现多组装车容器,请核对保存结果')
|
||||
request.containerId = existing[0]?.id || (await createDispatchDraftContainer(request.token, request.capacity)).id
|
||||
}
|
||||
if (!request.submitting) for (const row of request.rows) if (!request.lockedIds.includes(row.planPalletId)) { await lockDispatchDraftPallet(row.planPalletId, request.token, request.containerId); request.lockedIds.push(row.planPalletId) }
|
||||
request.submitting = true
|
||||
const response = await preArrangeDraftDispatch({ draftToken: request.token, groups: [{ draftContainerId: request.containerId, planPalletIds: request.rows.map(row => row.planPalletId), returnReceiptIds: [], batchAllocations: request.rows.flatMap(row => row.allocations) }] })
|
||||
if (response.length !== 1 || response[0]?.draftContainerId !== request.containerId || !response[0].dispatch.dispatchNo) throw new Error('排车结果与本次分组不一致,请重试核对原请求')
|
||||
result.value = response[0].dispatch; pending.value = null; selected.value = []
|
||||
ElMessage.success(`排车单 ${result.value.dispatchNo} 已创建`); emit('updated')
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '本次排车结果待确认,请重试原请求'); rejected.value = rejectedCommand(cause) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function refreshRejected() {
|
||||
if (!rejected.value || !pending.value || saving.value) return
|
||||
saving.value = true
|
||||
try { await releaseDispatchDraft(pending.value.token); pending.value = null; selected.value = []; rejected.value = false; await load() }
|
||||
catch (cause) { saveError.value = getApiErrorDetails(cause, '本次草稿尚未释放,请重试') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
function tripSaved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
|
||||
async function continueArranging() { if (!pending.value && !saving.value) { result.value = null; await load() } }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="按预约批次排车">
|
||||
<h3>按批次选择真实托盘</h3><p>每次选择同一客户的实物创建排车单,再安排车辆和司机;逐来源数量由服务端候选确定。</p>
|
||||
<p v-if="!readable">读取批次实物需要预约、尾程订单和入仓单列表权限。</p>
|
||||
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在核对批次、客户与实物来源…</p>
|
||||
<template v-if="result"><p>已创建排车单:{{ result.dispatchNo }}</p><el-button v-if="result.overseasWarehouseId && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" type="primary" @click="prepareOpen = true">安排车次、车辆与司机</el-button><p v-if="!result.overseasWarehouseId">排车结果未提供起运仓,需在原排车单核对。</p><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(result.dispatchNo)}`)">查看原排车单</el-button><el-button @click="continueArranging">继续安排其他实物</el-button></template>
|
||||
<template v-else-if="readable"><p v-for="reason in contexts.flatMap(context => context.blockReasons)" :key="reason">{{ reason }}</p><label>预排车型<select v-model.number="capacity" :disabled="saving || !!pending || !writable"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
||||
<table class="density-table"><thead><tr><th>选择</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.planPalletId}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.planPalletId)" :disabled="!writable || loading || saving || !!pending || !!error || !!reasons(row).length" @change="changeSelection(row, $event)"></td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : row.sourceStage === 'PLAN' ? '计划托盘' : '原业务来源' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table>
|
||||
<p v-if="!loading && !error && !rows.length">当前批次尚无可排实物。</p><el-button type="primary" :disabled="!writable || loading || !!error" :loading="saving" @click="save">{{ pending ? '重试本次预排' : '创建本客户排车单' }}</el-button>
|
||||
</template>
|
||||
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button v-if="rejected && pending" :loading="saving" @click="refreshRejected">释放本次草稿并重新核对</el-button>
|
||||
<TripPreparationDialog v-if="result?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="result.overseasWarehouseId" :warehouse-name="result.overseasWarehouseName || '起运仓待核实'" :dispatch-nos="[result.dispatchNo]" @saved="tripSaved" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
|
||||
import { getDispatch } from '@/modules/overseas-warehouse'
|
||||
const state = vi.hoisted(() => ({ allowed: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.allowed }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
|
||||
function render() { return mount(WorkExistingDispatchForm, { props: { dispatches: [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS-ORIGINAL' }], canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
|
||||
beforeEach(() => { vi.clearAllMocks(); state.allowed = true; vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', overseasWarehouseId: 'origin-1', overseasWarehouseName: '原起运仓', status: 'PENDING_DEPARTURE', packageCount: 12 }) })
|
||||
it('reads the original dispatch and passes its existing cargo and origin to trip preparation', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledExactlyOnceWith('DS-ORIGINAL')
|
||||
await wrapper.findAll('button').find(button => button.text() === '安排原排车单车次')!.trigger('click')
|
||||
const dialog = wrapper.getComponent({ name: 'TripPreparationDialog' }); expect(dialog.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-ORIGINAL'] })
|
||||
dialog.vm.$emit('saved', { trip: { tripNo: 'TR/001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR%2F001']])
|
||||
expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount()
|
||||
})
|
||||
it('rejects mismatched dispatch identity instead of arranging unrelated cargo', async () => {
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740998', dispatchNo: 'DS-ORIGINAL', status: 'PENDING_DEPARTURE' })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('排车单与工作批次关联不一致'); expect(wrapper.find('table').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('opens an already bound real trip instead of preparing another one', async () => {
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', workflow: { tripNo: 'TR-EXISTING', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } } })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.text()).not.toContain('安排原排车单车次')
|
||||
await wrapper.findAll('button').find(button => button.text() === 'TR-EXISTING')!.trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-EXISTING']]); wrapper.unmount()
|
||||
})
|
||||
it('does not read the dispatch without its original list permission', async () => {
|
||||
state.allowed = false; const wrapper = render(); await flushPromises(); expect(getDispatch).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getDispatch, type DispatchSummaryDto } from '@/modules/overseas-warehouse'
|
||||
import { TripPreparationDialog } from '@/modules/overseas-master-data'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
const props = defineProps<{ dispatches: Array<{ batchKey: string; dispatchNo: string }>; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; external: [path: string] }>()
|
||||
const access = useAccessPreviewStore(), rows = ref<DispatchSummaryDto[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const selected = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false)
|
||||
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list'))
|
||||
const writable = computed(() => props.canWrite && readable.value && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit'))
|
||||
async function load() {
|
||||
if (!readable.value) return
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
rows.value = await Promise.all(props.dispatches.map(async reference => {
|
||||
const row = await getDispatch(reference.dispatchNo)
|
||||
if (`DISPATCH:${row.id}` !== reference.batchKey || row.dispatchNo !== reference.dispatchNo) throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
|
||||
return row
|
||||
}))
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '原排车单读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function prepare(row: DispatchSummaryDto) { if (writable.value && !loading.value && !error.value && row.status === 'PENDING_DEPARTURE' && !row.workflow && row.overseasWarehouseId) { selected.value = row; prepareOpen.value = true } }
|
||||
function saved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="原排车单安排用车"><h3>原排车单安排用车</h3><p>客户仓批次沿用已选实物和原排车单,确认起运仓后安排车次、车辆与司机。</p>
|
||||
<p v-if="!readable">查看原排车单需要排车列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
|
||||
<LoadErrorState v-if="error" title="原排车单读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>起运仓</th><th>箱数</th><th>车次</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="!row.workflow" :disabled="!writable || row.status !== 'PENDING_DEPARTURE' || !row.overseasWarehouseId" @click="prepare(row)">安排原排车单车次</el-button><el-button @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(row.dispatchNo)}`)">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.overseasWarehouseName || '起运仓待核实' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td><el-button v-if="row.workflow?.tripNo && access.can('platform:fleet:trip:list')" @click="emit('external', `/overseas-master-data/trips/${encodeURIComponent(row.workflow.tripNo)}`)">{{ row.workflow.tripNo }}</el-button><span v-else>尚未关联车次</span></td></tr></tbody></table>
|
||||
<TripPreparationDialog v-if="selected?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="selected.overseasWarehouseId" :warehouse-name="selected.overseasWarehouseName || '起运仓待核实'" :dispatch-nos="[selected.dispatchNo]" @saved="saved" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkReceiptPlans from './WorkReceiptPlans.vue'
|
||||
import { getContainerPlanProgress, getContainerInboundPlanPreview, convertLastMileContainerToInboundPlans, type ContainerInboundPlanPreviewDto } from '@/modules/overseas-warehouse'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const state = vi.hoisted(() => ({ canRead: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.canRead }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getContainerPlanProgress: vi.fn(), getContainerInboundPlanPreview: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn() }))
|
||||
let guard: LeaveGuard
|
||||
function render() { return mount(WorkReceiptPlans, { props: { containerBookingId: '9007199254740993', canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: true, ApiErrorMeta: true } } }) }
|
||||
function button(wrapper: ReturnType<typeof render>, text: string) { return wrapper.findAll('button').find(button => button.text() === text)! }
|
||||
function preview(overrides: Partial<ContainerInboundPlanPreviewDto> = {}) { return { sourceContainerBookingId: '9007199254740993', containerNo: 'TEST-CN', overseasWarehouseName: '接仓仓库', snapshotVersion: 4, snapshotHash: 'snapshot-4', pendingOrderCount: 1, blockedOrderCount: 0, sourcePalletCount: 2, supplementPalletCount: 1, totalPlannedPalletCount: 0, ready: true, groups: [], ...overrides } as ContainerInboundPlanPreviewDto }
|
||||
beforeEach(() => { vi.clearAllMocks(); state.canRead = true; vi.mocked(getContainerPlanProgress).mockResolvedValue({ summary: { containerNo: 'TEST-CN', overseasWarehouseName: '接仓仓库' }, orders: [{ orderId: 'internal-1', customerId: '9007199254740999', planId: 'internal-plan', planNo: 'IP-001', planValid: true, inboundCode: 'IN-001' }, { orderId: 'internal-2', customerId: 'customer-b', planId: null, planNo: null, planValid: false, inboundCode: 'IN-002' }] } as never) })
|
||||
it('opens a real plan in the exact customer scope while leaving missing plans unavailable', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(getContainerPlanProgress).toHaveBeenCalledExactlyOnceWith('9007199254740993')
|
||||
await wrapper.findAll('button')[0]!.trigger('click')
|
||||
expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/inbound-orders/IP-001?customerId=9007199254740999']])
|
||||
expect(wrapper.findAll('button')[1]!.attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).not.toContain('internal-plan'); expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount()
|
||||
})
|
||||
it('does not read the container source without its original permission', async () => {
|
||||
state.canRead = false; const wrapper = render(); await flushPromises(); expect(getContainerPlanProgress).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('submits the actual preview snapshot and replays the same request after an unknown result', async () => {
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(preview())
|
||||
vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new Error('lost response')).mockResolvedValueOnce([])
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '核对本柜入库计划预览').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '确认生成本柜计划').trigger('click'); await flushPromises()
|
||||
const original = vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[0]!
|
||||
expect(original).toEqual([{ sourceContainerBookingId: '9007199254740993', snapshotVersion: 4, snapshotHash: 'snapshot-4' }, expect.any(String)])
|
||||
expect(await guard()).toBe(false)
|
||||
await button(wrapper, '重试本次计划生成').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]).toEqual(original)
|
||||
expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('uses the original resource recovery snapshot and key instead of creating new plans', async () => {
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(preview({ pendingOrderCount: 0, resourceRetry: { idempotencyKey: 'original-key', snapshotVersion: 2, snapshotHash: 'original-snapshot' } }))
|
||||
vi.mocked(convertLastMileContainerToInboundPlans).mockResolvedValue([])
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '核对本柜入库计划预览').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '重试原资源确认').trigger('click'); await flushPromises()
|
||||
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: '9007199254740993', snapshotVersion: 2, snapshotHash: 'original-snapshot' }, 'original-key'); wrapper.unmount()
|
||||
})
|
||||
it('does not submit a blocked preview', async () => {
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(preview({ ready: false, blockedOrderCount: 1 }))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '核对本柜入库计划预览').trigger('click'); await flushPromises()
|
||||
expect(button(wrapper, '确认生成本柜计划').attributes('disabled')).toBeDefined(); expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getContainerPlanProgress, getContainerInboundPlanPreview, convertLastMileContainerToInboundPlans, type ContainerPlanProgressDetailDto, type ContainerInboundPlanPreviewDto, type ConvertContainerInboundPlanPayload } from '@/modules/overseas-warehouse'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ containerBookingId: string; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ external: [path: string]; updated: [] }>()
|
||||
const access = useAccessPreviewStore(), detail = ref<ContainerPlanProgressDetailDto | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const readable = computed(() => access.can('platform:overseas-warehouse:last-mile-order:list'))
|
||||
const writable = computed(() => props.canWrite && readable.value && access.can('platform:overseas-warehouse:inbound-order:create'))
|
||||
const preview = ref<ContainerInboundPlanPreviewDto | null>(null), previewLoading = ref(false), saving = ref(false), previewError = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const pending = ref<{ body: ConvertContainerInboundPlanPayload; key: string } | null>(null)
|
||||
useWorkDraft(computed(() => false), computed(() => saving.value || !!pending.value))
|
||||
async function loadPreview() {
|
||||
if (!writable.value || saving.value || pending.value) return
|
||||
previewLoading.value = true; previewError.value = null
|
||||
try { preview.value = await getContainerInboundPlanPreview(props.containerBookingId) }
|
||||
catch (cause) { previewError.value = getApiErrorDetails(cause, '本柜入库计划预览失败') }
|
||||
finally { previewLoading.value = false }
|
||||
}
|
||||
async function createPlans() {
|
||||
if (!writable.value || saving.value || previewLoading.value || previewError.value || !preview.value) return
|
||||
if (!pending.value) {
|
||||
if (!preview.value.ready || (!preview.value.resourceRetry && preview.value.pendingOrderCount <= 0)) return
|
||||
const snapshot = preview.value.resourceRetry || preview.value
|
||||
pending.value = { body: { sourceContainerBookingId: props.containerBookingId, snapshotVersion: snapshot.snapshotVersion, snapshotHash: snapshot.snapshotHash }, key: preview.value.resourceRetry?.idempotencyKey || crypto.randomUUID() }
|
||||
}
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
await convertLastMileContainerToInboundPlans(pending.value.body, pending.value.key)
|
||||
pending.value = null; preview.value = null; ElMessage.success('本柜入库计划已确认,请查看原接仓记录'); emit('updated'); await load()
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '入库计划生成结果待确认,请重试原请求')
|
||||
if (rejectedCommand(cause)) { pending.value = null; preview.value = null }
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
async function load() { if (!readable.value) return; loading.value = true; error.value = null; try { detail.value = await getContainerPlanProgress(props.containerBookingId) } catch (cause) { error.value = getApiErrorDetails(cause, '本柜入仓计划读取失败') } finally { loading.value = false } }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="本柜接仓计划"><h3>选择本柜入仓计划</h3><p>实际收货与卸柜由仓库原作业流程登记;此处进入对应客户的原入库单查看。</p><p v-if="!readable">读取本柜计划需要尾程订单列表权限。</p>
|
||||
<LoadErrorState v-if="error" title="本柜入仓计划读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-if="detail && !error"><p>{{ detail.summary.containerNo || '柜号待核实' }} · {{ detail.summary.overseasWarehouseName || '接仓仓库待核实' }}</p><p v-if="detail.summary.blockingReason">{{ detail.summary.blockingReason }}</p><table class="density-table"><thead><tr><th>操作</th><th>入库单号</th><th>入仓码 / 订单号</th></tr></thead><tbody><tr v-for="row in detail.orders" :key="`${row.customerId}:${row.orderId}`"><td><el-button :disabled="saving || !!pending || !row.planNo || !row.customerId || !row.planValid || !access.can('platform:overseas-warehouse:inbound-order:list')" @click="emit('external', `/overseas-warehouse/inbound-orders/${encodeURIComponent(row.planNo!)}?customerId=${encodeURIComponent(row.customerId)}`)">查看原接仓记录</el-button></td><td>{{ row.planNo || '尚未生成有效计划' }}</td><td>{{ row.inboundCode || row.orderNo || '—' }}</td></tr></tbody></table><p v-if="!detail.orders.length">本柜尚无可读取的入仓来源。</p></template>
|
||||
<el-button v-if="writable && !preview && !pending" :loading="previewLoading" @click="loadPreview">核对本柜入库计划预览</el-button>
|
||||
<LoadErrorState v-if="previewError" title="入库计划预览失败" :message="previewError.message" :loading="previewLoading" @retry="loadPreview"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState>
|
||||
<div v-if="preview && !previewError"><h4>核对本柜计划与托盘</h4><p>{{ preview.containerNo || '柜号待核实' }} · {{ preview.overseasWarehouseName || '接仓仓库待核实' }} · {{ preview.pendingOrderCount }} 票待生成 · {{ preview.blockedOrderCount }} 票待处理</p><p>头程已有 {{ preview.sourcePalletCount }} 托;海外补充 {{ preview.supplementPalletCount }} 托;已保存计划 {{ preview.totalPlannedPalletCount }} 托。</p><table class="density-table"><thead><tr><th>客户</th><th>原票</th><th>目的地</th><th>箱数</th><th>目标托数</th><th>计划状态</th></tr></thead><tbody><tr v-for="row in preview.groups.flatMap(group => group.orders)" :key="`${row.customerId}:${row.orderId}`"><td>{{ row.customerName || '客户名称待核实' }}</td><td>{{ row.inboundCode || row.businessOrderNo || '—' }}</td><td>{{ row.destinationWarehouseName || row.fbaWarehouseCode || (row.destinationType === 'PRIVATE_ADDRESS' ? '客户地址' : '目的地待核实') }}</td><td>{{ row.packageCount }}</td><td>{{ row.targetPalletCount }}</td><td>{{ row.existingPlanNo || ({ READY: '可生成', RESOURCE_PENDING: '资源待确认', PLANNED: '已有计划', BLOCKED: '来源条件未就绪' }[row.readiness]) }}</td></tr></tbody></table><p v-if="!preview.ready">本柜尚不具备生成条件,请在原尾程订单核对阻塞来源。</p><el-button type="primary" :loading="saving" :disabled="!writable || (!pending && (!preview.ready || (!preview.resourceRetry && preview.pendingOrderCount <= 0)))" @click="createPlans">{{ pending ? '重试本次计划生成' : preview.resourceRetry ? '重试原资源确认' : '确认生成本柜计划' }}</el-button></div>
|
||||
<LoadErrorState v-if="saveError" title="入库计划尚未确认" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
</section>
|
||||
</template>
|
||||
@@ -19,7 +19,7 @@ export interface PickupCalendarEntry {
|
||||
destinationPortName: string | null
|
||||
destinationWarehouseCode: string | null
|
||||
fleetSupplierName: string | null
|
||||
pickupAppointmentAt: string
|
||||
pickupAppointmentAt: string | null
|
||||
actualPickupAt: string | null
|
||||
}
|
||||
export interface PickupCalendar {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { FbaAppointmentBatchContext } from './batchApi'
|
||||
export function batchContext(number = 'AP-001', key = 'FBA_APPOINTMENT:1'): FbaAppointmentBatchContext {
|
||||
return { appointment: { id: '1', appointmentNo: number, batchKey: key, version: 3, appointmentStatus: 'PENDING', originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', selectedCount: 2, replacementAppointmentNos: [], replacementBatchKeys: [], items: [
|
||||
{ id: 'item-1', customerId: 'customer-a', sourceKey: 'ORDER:1', inboundCode: 'IN-A', fbaWarehouseCode: 'ONT8', packageCount: 70, shipmentReferences: ['FBA-A'] },
|
||||
{ id: 'item-2', customerId: 'customer-b', sourceKey: 'BOOKING:2', inboundCode: 'IN-B', fbaWarehouseCode: 'ONT8', packageCount: 30, shipmentReferences: ['FBA-B'] },
|
||||
] }, sources: [
|
||||
{ customerId: 'customer-a', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 70, originalPackages: 100, sourceVersion: 'source-a-v2', cargoVerificationVersion: 4, shipmentReferences: ['FBA-A'] },
|
||||
{ customerId: 'customer-b', sourceKey: 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 30, originalPackages: null, sourceVersion: null, cargoVerificationVersion: 2, shipmentReferences: ['FBA-B'] },
|
||||
], allowedActions: ['VERIFY_CARGO', 'SPLIT', 'MERGE'], blockReasons: [] }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { allocationKey, getAppointmentBatch, mergeBatchSources, repartitionAppointments, splitBatch, type RepartitionFbaAppointmentsRequest } from './batchApi'
|
||||
import { batchContext } from './batch.test-support'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data }))
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('reads the immutable batch key and submits the actual repartition contract including nullable versions', async () => {
|
||||
const context = batchContext(); vi.mocked(http.get).mockResolvedValue({ data: context })
|
||||
expect(await getAppointmentBatch(context.appointment.batchKey!)).toEqual(context)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-planning/batches/FBA_APPOINTMENT%3A1')
|
||||
const split = splitBatch(context, { [allocationKey(context.sources[0]!)]: 20 })
|
||||
const request: RepartitionFbaAppointmentsRequest = { originals: [{ appointmentNo: 'AP-001', version: 3 }], batches: split.map(allocations => ({ allocations, plannedDate: null, remark: null })), reason: '分两次送仓', idempotencyKey: 'same-request' }
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { replacedBatchKeys: ['FBA_APPOINTMENT:1'], replacedAppointmentNos: ['AP-001'], appointments: [] } })
|
||||
expect((await repartitionAppointments(request)).replacedBatchKeys).toEqual(['FBA_APPOINTMENT:1'])
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-planning/appointments/repartition', request)
|
||||
expect(request.batches[0]!.allocations.find(row => row.customerId === 'customer-b')).toMatchObject({ expectedSourceVersion: null, expectedVerificationVersion: 2, packageCount: 30 })
|
||||
})
|
||||
it('conserves each source separately, omits zero allocations and preserves all customers and snapshot versions', () => {
|
||||
const context = batchContext()
|
||||
const [remaining, moved] = splitBatch(context, { [allocationKey(context.sources[0]!)]: 70 })
|
||||
expect(remaining).toEqual([{ customerId: 'customer-b', sourceKey: 'BOOKING:2', packageCount: 30, expectedSourceVersion: null, expectedVerificationVersion: 2 }])
|
||||
expect(moved).toEqual([{ customerId: 'customer-a', sourceKey: 'ORDER:1', packageCount: 70, expectedSourceVersion: 'source-a-v2', expectedVerificationVersion: 4 }])
|
||||
expect(() => splitBatch(context, { [allocationKey(context.sources[0]!)]: 71 })).toThrow('每票')
|
||||
expect(() => splitBatch(context, {})).toThrow('两批')
|
||||
expect(() => splitBatch(context, { [allocationKey(context.sources[0]!)]: 70, [allocationKey(context.sources[1]!)]: 30 })).toThrow('两批')
|
||||
})
|
||||
it('merges only the same origin and destination, preserving per-source amounts and detecting stale verification', () => {
|
||||
const first = batchContext(), second = batchContext('AP-002', 'FBA_APPOINTMENT:2')
|
||||
second.sources = [{ ...first.sources[0]!, packageCount: 10 }]
|
||||
const merged = mergeBatchSources([first, second]); expect(merged.map(row => row.packageCount)).toEqual([80, 30])
|
||||
second.sources[0]!.cargoVerificationVersion = 9
|
||||
expect(() => mergeBatchSources([first, second])).toThrow('核实版本')
|
||||
second.appointment.originWarehouseId = 'another-origin'
|
||||
expect(() => mergeBatchSources([first, second])).toThrow('同一起运仓')
|
||||
})
|
||||
it('does not turn unknown quantities or superseded snapshots into allocatable cargo', () => {
|
||||
const context = batchContext(); context.sources[0]!.packageCount = null
|
||||
expect(() => splitBatch(context, {})).toThrow('核实每个来源')
|
||||
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:3']
|
||||
expect(() => mergeBatchSources([context])).toThrow('已被替代')
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
export interface BatchSourceAllocation {
|
||||
customerId: string; sourceKey: string; destinationKey: string; packageCount: number | null
|
||||
originalPackages: number | null; sourceVersion: string | null; cargoVerificationVersion: number | null; shipmentReferences: string[]
|
||||
}
|
||||
export interface FbaAppointmentBatchContext { appointment: FbaAppointmentDto; sources: BatchSourceAllocation[]; allowedActions: Array<'VERIFY_CARGO' | 'SPLIT' | 'MERGE'>; blockReasons: string[] }
|
||||
export interface BatchAllocation { customerId: string; sourceKey: string; packageCount: number; expectedSourceVersion: string | null; expectedVerificationVersion: number | null }
|
||||
export interface RepartitionFbaAppointmentsRequest {
|
||||
originals: Array<{ appointmentNo: string; version: number }>
|
||||
batches: Array<{ plannedDate: string | null; remark: string | null; allocations: BatchAllocation[] }>
|
||||
reason: string; idempotencyKey: string
|
||||
}
|
||||
export interface RepartitionFbaAppointmentsResponse { replacedBatchKeys: string[]; replacedAppointmentNos: string[]; appointments: FbaAppointmentDto[] }
|
||||
const base = '/platform/v1/last-leg/appointment-planning'
|
||||
export async function getAppointmentBatch(batchKey: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<FbaAppointmentBatchContext>, ApiEnvelope<FbaAppointmentBatchContext>>(`${base}/batches/${encodeURIComponent(batchKey)}`))
|
||||
}
|
||||
export async function repartitionAppointments(request: RepartitionFbaAppointmentsRequest) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<RepartitionFbaAppointmentsResponse>, ApiEnvelope<RepartitionFbaAppointmentsResponse>>(`${base}/appointments/repartition`, request))
|
||||
}
|
||||
export const allocationKey = (source: Pick<BatchSourceAllocation, 'customerId' | 'sourceKey' | 'destinationKey'>) => JSON.stringify([source.customerId, source.sourceKey, source.destinationKey])
|
||||
export function mergeBatchSources(contexts: FbaAppointmentBatchContext[]): BatchSourceAllocation[] {
|
||||
const first = contexts[0]
|
||||
if (!first) throw new Error('请先选择批次')
|
||||
const rows = new Map<string, BatchSourceAllocation>()
|
||||
const seen = new Set<string>()
|
||||
for (const context of contexts) {
|
||||
const appointment = context.appointment
|
||||
if (seen.has(appointment.appointmentNo)) throw new Error('同一批次不能重复选择')
|
||||
seen.add(appointment.appointmentNo)
|
||||
if (appointment.originWarehouseId !== first.appointment.originWarehouseId || appointment.fbaWarehouseCode !== first.appointment.fbaWarehouseCode) throw new Error('只允许同一起运仓、同一目的仓的批次合并')
|
||||
if (appointment.replacementBatchKeys?.length || appointment.replacementAppointmentNos?.length) throw new Error('原批次已被替代,请选择当前有效批次')
|
||||
if (!context.sources.length) throw new Error('批次缺少真实货物来源,请核对后再操作')
|
||||
for (const source of context.sources) {
|
||||
if (source.packageCount == null || !Number.isSafeInteger(Number(source.packageCount)) || Number(source.packageCount) < 1) throw new Error('请先核实每个来源的本批箱数')
|
||||
const key = allocationKey(source), previous = rows.get(key)
|
||||
if (previous && (previous.sourceVersion !== source.sourceVersion || previous.cargoVerificationVersion !== source.cargoVerificationVersion || JSON.stringify(previous.shipmentReferences) !== JSON.stringify(source.shipmentReferences))) throw new Error('同一来源的核实版本或货件资料不一致,请重新读取批次')
|
||||
const count = Number(source.packageCount) + (previous?.packageCount || 0)
|
||||
if (!Number.isSafeInteger(count)) throw new Error('来源箱数超出可处理范围')
|
||||
rows.set(key, { ...source, packageCount: count })
|
||||
}
|
||||
}
|
||||
return [...rows.values()]
|
||||
}
|
||||
export function allocation(source: BatchSourceAllocation, packages: number): BatchAllocation {
|
||||
return { customerId: source.customerId, sourceKey: source.sourceKey, packageCount: packages, expectedSourceVersion: source.sourceVersion, expectedVerificationVersion: source.cargoVerificationVersion }
|
||||
}
|
||||
export function splitBatch(context: FbaAppointmentBatchContext, moved: Record<string, number | string>): [BatchAllocation[], BatchAllocation[]] {
|
||||
const remaining: BatchAllocation[] = [], next: BatchAllocation[] = []
|
||||
for (const source of mergeBatchSources([context])) {
|
||||
const quantity = Number(moved[allocationKey(source)] ?? 0), total = Number(source.packageCount)
|
||||
if (!Number.isSafeInteger(quantity) || quantity < 0 || quantity > total) throw new Error('每票分出箱数须为0至本批箱数之间的整数')
|
||||
if (quantity > 0) next.push(allocation(source, quantity))
|
||||
if (quantity < total) remaining.push(allocation(source, total - quantity))
|
||||
}
|
||||
if (!remaining.length || !next.length) throw new Error('两批都须保留至少一箱货物')
|
||||
return [remaining, next]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { PlanningSource } from './planningApi'
|
||||
export interface CargoVerification { version: number; sourceVersion: string | null; destinationKey: string; sourcePackages: number | null; packageCount: number | null; shipmentReferences: string[]; remark: string | null; operatorName: string | null; verifiedAt: string }
|
||||
export interface CargoVerificationContext { source: PlanningSource; verification: CargoVerification | null; allocatedPackages: number; unknownAllocationCount: number; effectivePackages: number | null; availablePackages: number | null; sourceChanged: boolean; allowedActions: string[]; blockReasons: string[] }
|
||||
export interface CargoVerificationSource { sourceKey: string; customerId: string; destinationKey?: string }
|
||||
export interface SaveCargoVerification { destinationKey: string; expectedSourceVersion: string | null; expectedVersion: number; packageCount: number | null; shipmentReferences: string[]; remark: string | null; idempotencyKey: string }
|
||||
const path = (key: string) => `/platform/v1/last-leg/appointment-planning/sources/${encodeURIComponent(key)}/cargo-verification`
|
||||
export async function getCargoVerification(source: CargoVerificationSource) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<CargoVerificationContext>, ApiEnvelope<CargoVerificationContext>>(path(source.sourceKey), { params: { customerId: source.customerId, destinationKey: source.destinationKey } }))
|
||||
}
|
||||
export async function saveCargoVerification(source: CargoVerificationSource, body: SaveCargoVerification) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<CargoVerification>, ApiEnvelope<CargoVerification>>(path(source.sourceKey), body, { params: { customerId: source.customerId } }))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { appointmentReturnTarget, appointmentReturnQuery } from './workNavigation'
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listPlanningCargo, listPlanningContainers } from './planningApi'
|
||||
import { listPlanningCargo, listPlanningContainers, resolvePlanningOrder } from './planningApi'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data }))
|
||||
|
||||
it('uses the gateway planning route and preserves stable source IDs and null predictions', async () => {
|
||||
@@ -20,3 +20,10 @@ it('keeps basic container listing on its own endpoint rather than requiring impa
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/containers', { params: { page: 1, size: 20 }, signal: undefined })
|
||||
expect(result.total).toBe(0)
|
||||
})
|
||||
it('resolves an order through the real canonical-source endpoint without fabricating an ORDER alias', async () => {
|
||||
vi.mocked(http.get).mockClear()
|
||||
vi.mocked(http.get).mockResolvedValue({data:{source:{sourceKey:'BOOKING:B-1'},coverage:{appointments:[]}}})
|
||||
const query = {orderId:'9007199254740993',customerId:'customer-a',destinationKey:'FBA:ONT8'}
|
||||
expect((await resolvePlanningOrder(query)).source.sourceKey).toBe('BOOKING:B-1')
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/sources/resolve-order',{params:query})
|
||||
})
|
||||
|
||||
@@ -65,6 +65,12 @@ export interface AppointmentCoverage {
|
||||
appointments: Array<{ appointmentNo: string; confirmationNo: string | null; status: string; confirmedAt: string | null; confirmedEndAt: string | null; timezone: string | null }>;
|
||||
}
|
||||
export interface ResolvedPlanningSource { source: PlanningSource; coverage: AppointmentCoverage }
|
||||
export type PlanningSourceLookup = { sourceKey: string; customerId: string; destinationKey?: string } | { orderId: string; customerId: string; destinationKey?: string }
|
||||
export async function resolvePlanningOrder(query: { orderId: string; customerId: string; destinationKey?: string }) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<ResolvedPlanningSource>, ApiEnvelope<ResolvedPlanningSource>>(
|
||||
'/platform/v1/last-leg/appointment-planning/sources/resolve-order', { params: query },
|
||||
))
|
||||
}
|
||||
export interface ExternalAppointmentCandidate extends ResolvedPlanningSource { customerName: string | null; tripNo: string }
|
||||
export async function listExternalAppointmentCandidates(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ records: ExternalAppointmentCandidate[]; total: number }>, ApiEnvelope<{ records: ExternalAppointmentCandidate[]; total: number }>>(
|
||||
|
||||
@@ -25,12 +25,27 @@ describe('location-based trip calendar', () => {
|
||||
const result=projectTripCalendar({records:[trip('offset','2026-10-10T12:00:00Z','UTC-5'),trip('bad','2026-10-10T12:00:00Z','Mars/Base'),trip('naive','2026-10-10T12:00:00')],unresolvedRecords:[unknown]},range)
|
||||
expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.tripNo)).toEqual(['unknown','offset','bad','naive'])
|
||||
})
|
||||
it('retains original instants through DST overlap and does not replace the plan date with actual departure', () => {
|
||||
it('uses actual local departure through DST changes while retaining the original plan for comparison', () => {
|
||||
const row={...trip('TR-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))}
|
||||
const events=projectTripCalendar({records:[row,trip('TR-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events
|
||||
expect(events.map(event=>event.time)).toEqual(['01:30','01:30'])
|
||||
expect(events[0]).toMatchObject({date:'2026-11-01',subtitle:'运输计划,不代表收货方预约已确认'})
|
||||
expect(events.map(event=>event.time)).toEqual(['00:00','01:30'])
|
||||
expect(events[0]).toMatchObject({date:'2026-11-02',title:'TR-DST 实际发车'})
|
||||
expect(events[0]!.fields).toContainEqual({label:'计划出发(UTC)',value:'2026-11-01T05:30:00Z'})
|
||||
expect(events[0]!.fields).toContainEqual({label:'路线',value:'Stop 0 → Stop 1 → Stop 2 等20站'})
|
||||
})
|
||||
it('keeps executed trips in the actual month after replanning and excludes their old plan month', () => {
|
||||
const executed = {...trip('TR-ACTUAL', '2026-10-31T12:00:00Z'), departedAt:'2026-11-01T12:00:00Z'}
|
||||
expect(projectTripCalendar({records:[executed],unresolvedRecords:[]}, range).events).toEqual([])
|
||||
const november = {from:'2026-11-01',to:'2026-12-01'}
|
||||
const event = projectTripCalendar({records:[executed],unresolvedRecords:[]}, november).events[0]
|
||||
expect(event).toMatchObject({date:'2026-11-01',time:'04:00'})
|
||||
const changed = {...executed, plannedDepartureAt:'2026-12-10T12:00:00Z'}
|
||||
expect(projectTripCalendar({records:[changed],unresolvedRecords:[]},november).events[0]).toMatchObject({date:event!.date,time:event!.time})
|
||||
})
|
||||
it('does not fall back to the plan when an actual departure cannot be resolved', () => {
|
||||
const invalid = {...trip('TR-INVALID', '2026-10-15T12:00:00Z'), departedAt:'2026-10-16T12:00:00'}
|
||||
const result = projectTripCalendar({records:[invalid],unresolvedRecords:[]},range)
|
||||
expect(result.events).toEqual([])
|
||||
expect(result.unresolved.map(row=>row.tripNo)).toEqual(['TR-INVALID'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,15 +57,16 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
|
||||
const events: CalendarEvent[] = [], unresolved = [...result.unresolvedRecords]
|
||||
const labels: Record<string, string> = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
|
||||
for (const row of result.records) {
|
||||
const planned = localStamp(row.plannedDepartureAt, row.originTimezone)
|
||||
if (row.originCountryCode !== 'US' || !planned) {
|
||||
// Executed work stays on its actual local date even if the plan changes later.
|
||||
const occurrence = localStamp(row.departedAt ?? row.plannedDepartureAt, row.originTimezone)
|
||||
if (row.originCountryCode !== 'US' || !occurrence) {
|
||||
unresolved.push({ ...row, incompleteReasons: [...row.incompleteReasons, 'LOCAL_DATE_UNRESOLVED'] }); continue
|
||||
}
|
||||
if (planned.date < range.from || planned.date >= range.to) continue
|
||||
if (occurrence.date < range.from || occurrence.date >= range.to) continue
|
||||
const actual = localStamp(row.departedAt, row.originTimezone)
|
||||
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
|
||||
events.push({ key: `trip-${row.tripNo}`, type: 'trip', date: planned.date, time: planned.time,
|
||||
title: `${row.tripNo} 用车计划`, subtitle: '运输计划,不代表收货方预约已确认',
|
||||
events.push({ key: `trip-${row.tripNo}`, type: 'trip', date: occurrence.date, time: occurrence.time,
|
||||
title: `${row.tripNo} ${row.departedAt ? '实际发车' : '用车计划'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '运输计划,不代表收货方预约已确认',
|
||||
sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`,
|
||||
fields: [
|
||||
{ label: '车次状态', value: labels[row.status] || '待核实' },
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { inject, onBeforeUnmount, type Ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
|
||||
export type LeaveGuard = () => Promise<boolean>
|
||||
export function useWorkDraft(dirty: Ref<boolean>, pending: Ref<boolean>) {
|
||||
const register = inject<((guard: LeaveGuard) => () => void) | undefined>('appointment-work-leave-guard', undefined)
|
||||
const canLeave: LeaveGuard = async () => {
|
||||
if (pending.value) { ElMessage.warning('本次保存结果待确认,请重试后再离开'); return false }
|
||||
if (!dirty.value) return true
|
||||
try { await ElMessageBox.confirm('未保存的内容将丢失,确认离开?', '保留编辑', { confirmButtonText: '离开', cancelButtonText: '继续编辑', customClass: 'admin-density-confirm' }); return true } catch { return false }
|
||||
}
|
||||
const unregister = register?.(canLeave)
|
||||
onBeforeUnmount(() => unregister?.())
|
||||
return canLeave
|
||||
}
|
||||
export function rejectedCommand(cause: unknown) {
|
||||
const status = cause instanceof ApiResponseError ? cause.status : axios.isAxiosError(cause) ? cause.response?.status : undefined
|
||||
const code = getApiErrorDetails(cause).code || ''
|
||||
return [400, 403, 404, 409, 422].includes(status || 0) || /^(HTTP_|A0?)?(400|403|404|409|422)$/.test(code)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { WorkAction, WorkDetail, WorkItem, WorkCalendar, WorkSummary } from './workApi'
|
||||
export const followAction: WorkAction = { code: 'SAVE_FOLLOWUP', label: '记录回复', targetWorkItemKey: null, target: { kind: 'WORK_COMMAND', command: 'SAVE_FOLLOWUP' } }
|
||||
export function workItem(overrides: Partial<WorkItem> = {}): WorkItem {
|
||||
return { workItemKey: 'work-delivery-1', version: 3, type: 'DELIVERY', flow: 'TRANSFER', title: '批次 SD-001 · ONT8 收货', businessNo: 'SD-001', containers: [{ containerBookingId: '9007199254740993', bookingNo: 'D-001', containerNo: 'TEST-CN-001' }], requirementKey: 'requirement-1', batchKey: 'batch-1', batchNo: 'SD-001', originName: '洛杉矶测试仓', destinationName: 'ONT8', planStatus: 'UNPLANNED', executionStatus: 'PENDING', reviewRequired: false, target: null, confirmed: null, actual: null, calendarDate: null, dateBasis: 'NONE', timezone: 'America/Los_Angeles', owner: { id: 'user-1', name: '测试调度' }, followupDate: '2026-10-15', overdueReasons: [], allowedActions: [followAction], nextAction: followAction, blockReasons: [], missingFields: [], ...overrides }
|
||||
}
|
||||
export function workDetail(overrides: Partial<WorkDetail> = {}): WorkDetail { return { item: workItem(), relatedItems: [], references: [], history: [], review: null, requirement: null, returnHandoff: null, ...overrides } }
|
||||
export const workSummary: WorkSummary = { all: 20, followToday: 9, overdue: 3, undated: 7, review: 2 }
|
||||
export const workCalendar: WorkCalendar = { from: '2026-09-28', to: '2026-11-08', total: 2, counts: { pending: 1, confirmed: 0, noBookingRequired: 0, review: 0, completed: 1 }, days: [{ date: '2026-10-15', total: 2, typeCounts: { DELIVERY: 1, PICKUP: 1 }, counts: { pending: 1, confirmed: 0, noBookingRequired: 0, review: 0, completed: 1 } }] }
|
||||
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getWorkCalendar, getWorkSummary, getWorkItems, getWorkDetail, submitWorkCommand } from './workApi'
|
||||
import { getCargoVerification, saveCargoVerification } from './cargoVerificationApi'
|
||||
import { workCalendar, workDetail, workItem, workSummary } from './work.test-support'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data }))
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('uses inclusive calendar dates and repeated type parameters with no duplicate API prefix', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: workCalendar })
|
||||
const signal = new AbortController().signal
|
||||
await getWorkCalendar({ from: '2026-10-01', to: '2026-10-31', flow: 'DIRECT', types: ['PICKUP', 'EMPTY_RETURN'] }, signal)
|
||||
const [path, config] = vi.mocked(http.get).mock.calls[0]!
|
||||
expect(path).toBe('/platform/v1/last-leg/appointment-work/calendar')
|
||||
expect(config?.params.getAll('types')).toEqual(['PICKUP', 'EMPTY_RETURN'])
|
||||
expect(config?.params.get('to')).toBe('2026-10-31'); expect(config?.signal).toBe(signal)
|
||||
})
|
||||
it('keeps global summary free of month, flow, types and page parameters', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: workSummary })
|
||||
expect(await getWorkSummary()).toEqual(workSummary)
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-work/summary', { signal: undefined })
|
||||
})
|
||||
it('preserves server totals, null work dates and long string identities', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [workItem()], page: '2', size: '8', total: '35', pages: '5' } })
|
||||
const result = await getWorkItems({ view: 'UNDATED', page: 2, size: 8 })
|
||||
expect(result.total).toBe(35); expect(result.records[0]!.calendarDate).toBeNull()
|
||||
expect(result.records[0]!.containers[0]!.containerBookingId).toBe('9007199254740993')
|
||||
const params = vi.mocked(http.get).mock.calls[0]![1]?.params as URLSearchParams
|
||||
expect([...params.keys()]).toEqual(['view', 'page', 'size'])
|
||||
})
|
||||
it('encodes stable keys and forwards the immutable versioned command', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: workDetail() }); vi.mocked(http.post).mockResolvedValue({ data: { item: workItem(), affectedWorkItemKeys: [] } })
|
||||
await getWorkDetail('work/ORDER:1')
|
||||
const command = { command: 'CONFIRM_ROUTE' as const, expectedVersion: 3, idempotencyKey: 'same-command', payload: { flow: 'DIRECT' as const, reason: null } }
|
||||
await submitWorkCommand('work/ORDER:1', command)
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-work/items/work%2FORDER%3A1/commands', command)
|
||||
})
|
||||
it('does not turn upstream failures into an empty calendar or zero summary', async () => {
|
||||
vi.mocked(http.get).mockRejectedValue(new Error('unavailable'))
|
||||
await expect(getWorkSummary()).rejects.toThrow('unavailable')
|
||||
await expect(getWorkCalendar({ from: '2026-10-01', to: '2026-10-31' })).rejects.toThrow('unavailable')
|
||||
})
|
||||
it('scopes cargo verification to its explicit customer and never coerces unknown allocation to zero', async () => {
|
||||
const source = { sourceKey: 'BOOKING:B/1', customerId: '9007199254740995', destinationKey: 'FBA:ONT8' }
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { availablePackages: null, sourceChanged: true } })
|
||||
const context = await getCargoVerification(source); expect(context.availablePackages).toBeNull()
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-planning/sources/BOOKING%3AB%2F1/cargo-verification', { params: { customerId: source.customerId, destinationKey: source.destinationKey } })
|
||||
const request = { destinationKey: source.destinationKey, expectedSourceVersion: null, expectedVersion: 0, packageCount: null, shipmentReferences: ['FBA-TEST'], remark: null, idempotencyKey: 'verify-1' }
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { version: 1 } }); await saveCargoVerification(source, request)
|
||||
expect(http.post).toHaveBeenCalledWith(expect.stringContaining('/cargo-verification'), request, { params: { customerId: source.customerId } })
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export const workTypes = ['PICKUP', 'WAREHOUSE_RECEIPT', 'DELIVERY', 'TRANSPORT', 'EMPTY_RETURN'] as const
|
||||
export type WorkType = typeof workTypes[number] | 'ROUTE'
|
||||
export type WorkFlow = 'TRANSFER' | 'DIRECT' | 'UNKNOWN'
|
||||
export type WorkView = 'ALL' | 'FOLLOW_TODAY' | 'OVERDUE' | 'UNDATED' | 'REVIEW'
|
||||
export type WorkBucket = 'PENDING' | 'CONFIRMED' | 'NO_BOOKING_REQUIRED' | 'REVIEW' | 'COMPLETED'
|
||||
export interface WorkSlot { date: string; startTime: string | null; endDate: string | null; endTime: string | null; timezone: string | null }
|
||||
export const workCommands = ['CONFIRM_ROUTE', 'SAVE_FOLLOWUP', 'RESOLVE_REVIEW', 'SAVE_REQUIREMENT', 'HANDOFF_RETURN', 'ACCEPT_RETURN'] as const
|
||||
export type WorkCommandName = typeof workCommands[number]
|
||||
export const actionCodes = ['VIEW_DETAIL', 'SAVE_PLAN', 'REQUEST_BOOKING', 'RECORD_CONFIRMATION', 'CHANGE_BOOKING', 'CANCEL_BOOKING', 'VERIFY_CARGO', 'EDIT_BATCH', 'ARRANGE_TRANSPORT', 'RECORD_EXECUTION', 'CONFIRM_ROUTE', 'SAVE_FOLLOWUP', 'RESOLVE_REVIEW', 'SAVE_REQUIREMENT', 'HANDOFF_RETURN', 'ACCEPT_RETURN', 'VIEW_HISTORY'] as const
|
||||
export interface SourceReference { customerId: string; sourceKey: string; destinationKey: string | null }
|
||||
export interface BusinessReference { kind: string; key: string; businessNo: string | null; label: string }
|
||||
export type ActionTarget =
|
||||
| { kind: 'WORK_COMMAND'; command: WorkCommandName }
|
||||
| { kind: 'CARGO_VERIFICATION'; source: SourceReference }
|
||||
| { kind: 'FBA_APPOINTMENT'; appointmentId: string | null; batchKey: string | null; sources: SourceReference[] }
|
||||
| { kind: 'FIRSTLEG_CONTAINER'; containerBookingId: string }
|
||||
| { kind: 'FLEET_TRIP'; tripId: string | null; batchKeys: string[] }
|
||||
| { kind: 'WAREHOUSE_RECEIPT'; inboundPlanId: string | null; inboundPlanNo: string | null; customerId: string | null; containerBookingId: string }
|
||||
| { kind: 'READ_ONLY'; references: BusinessReference[] }
|
||||
export interface WorkAction { code: typeof actionCodes[number]; label: string; targetWorkItemKey: string | null; target: ActionTarget }
|
||||
export interface Responsibility { kind: 'USER' | 'SUPPLIER' | 'EXTERNAL'; id: string | null; name: string }
|
||||
export interface WorkItem {
|
||||
workItemKey: string; version: number; type: WorkType; flow: WorkFlow
|
||||
title: string; businessNo: string | null; containers: Array<{ containerBookingId: string; bookingNo: string | null; containerNo: string | null }>
|
||||
requirementKey: string | null; batchKey: string | null; batchNo: string | null
|
||||
originName: string | null; destinationName: string | null
|
||||
planStatus: 'UNPLANNED' | 'TARGET_SET' | 'AWAITING_REPLY' | 'CONFIRMED' | 'NOT_REQUIRED'
|
||||
executionStatus: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED'; reviewRequired: boolean
|
||||
target: WorkSlot | null; confirmed: WorkSlot | null
|
||||
actual: { occurredAt: string | null; localOccurredAt: string | null; timezone: string | null } | null
|
||||
calendarDate: string | null; dateBasis: 'ACTUAL' | 'CONFIRMED' | 'TARGET' | 'NONE'; timezone: string | null
|
||||
owner: { id: string; name: string } | null; followupDate: string | null; overdueReasons: string[]
|
||||
allowedActions: WorkAction[]; nextAction: WorkAction | null; blockReasons: string[]; missingFields: string[]
|
||||
}
|
||||
export interface WorkDetail {
|
||||
item: WorkItem; relatedItems: WorkItem[]
|
||||
references: BusinessReference[]
|
||||
history: Array<{ occurredAt: string; operatorName: string | null; action: string; summary: string }>
|
||||
review: { version: number; reasons: string[] } | null
|
||||
requirement: { bookingRequired: boolean; receivingRequirements: string; reason: string | null } | null
|
||||
returnHandoff: { version: number; from: Responsibility; to: Responsibility; status: 'PENDING_ACCEPTANCE' | 'ACCEPTED'; reason: string } | null
|
||||
}
|
||||
export interface WorkSummary { all: number; followToday: number; overdue: number; undated: number; review: number }
|
||||
export interface WorkCounts { pending: number; confirmed: number; noBookingRequired: number; review: number; completed: number }
|
||||
export interface WorkDay { date: string; total: number; typeCounts: Partial<Record<WorkType, number>>; counts: WorkCounts }
|
||||
export interface WorkCalendar { from: string; to: string; total: number; counts: WorkCounts; days: WorkDay[] }
|
||||
export interface WorkPage { records: WorkItem[]; page: number; size: number; total: number; pages: number }
|
||||
export interface WorkCalendarQuery { from: string; to: string; flow?: WorkFlow; types?: WorkType[] }
|
||||
export interface WorkItemsQuery { view: WorkView; page: number; size: number; date?: string; flow?: WorkFlow; types?: WorkType[]; containerBookingId?: string; bucket?: WorkBucket; keyword?: string }
|
||||
export type WorkCommandPayload =
|
||||
| { command: 'CONFIRM_ROUTE'; payload: { flow: 'TRANSFER' | 'DIRECT'; reason: string | null } }
|
||||
| { command: 'SAVE_FOLLOWUP'; payload: { ownerId: string; followupDate: string | null; note: string } }
|
||||
| { command: 'RESOLVE_REVIEW'; payload: { reviewVersion: number; note: string } }
|
||||
| { command: 'SAVE_REQUIREMENT'; payload: { target: WorkSlot | null; bookingRequired: boolean; receivingRequirements: string; reason: string } }
|
||||
| { command: 'HANDOFF_RETURN'; payload: { recipient: Responsibility; reason: string } }
|
||||
| { command: 'ACCEPT_RETURN'; payload: { handoffVersion: number; note: string | null } }
|
||||
export type WorkCommand = WorkCommandPayload & { expectedVersion: number; idempotencyKey: string }
|
||||
export interface WorkCommandResult { item: WorkItem; affectedWorkItemKeys: string[] }
|
||||
const base = '/platform/v1/last-leg/appointment-work'
|
||||
|
||||
// Repeated types parameters are part of v1; Axios' default types[] syntax is not.
|
||||
function params(query: WorkCalendarQuery | WorkItemsQuery) {
|
||||
const result = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined) continue
|
||||
if (Array.isArray(value)) value.forEach(type => result.append(key, type))
|
||||
else result.set(key, String(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
export async function getWorkCalendar(query: WorkCalendarQuery, signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<WorkCalendar>, ApiEnvelope<WorkCalendar>>(`${base}/calendar`, { params: params(query), signal }))
|
||||
}
|
||||
export async function getWorkSummary(signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<WorkSummary>, ApiEnvelope<WorkSummary>>(`${base}/summary`, { signal }))
|
||||
}
|
||||
export async function getWorkItems(query: WorkItemsQuery, signal?: AbortSignal) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<WorkPage>, ApiEnvelope<WorkPage>>(`${base}/items`, { params: params(query), signal }))
|
||||
return { ...result, page: Number(result.page), size: Number(result.size), total: Number(result.total), pages: Number(result.pages) }
|
||||
}
|
||||
export async function getWorkDetail(key: string, signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<WorkDetail>, ApiEnvelope<WorkDetail>>(`${base}/items/${encodeURIComponent(key)}`, { signal }))
|
||||
}
|
||||
export async function submitWorkCommand(key: string, command: WorkCommand) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<WorkCommandResult>, ApiEnvelope<WorkCommandResult>>(`${base}/items/${encodeURIComponent(key)}/commands`, command))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { appointmentExternalTarget, appointmentReturnQuery, appointmentReturnTarget } from './workNavigation'
|
||||
it('preserves the item, prerequisite stack, global scope and page through an external action', () => {
|
||||
const target = appointmentExternalTarget('/overseas-master-data/trips/TR-001?tab=loading', { workAnchor: '2026-10-15', workItem: 'work:1', workOrigins: '["work:0"]', workScope: 'global', workView: 'UNDATED', workPage: '3' })
|
||||
expect(target.path).toBe('/overseas-master-data/trips/TR-001'); expect(target.query.tab).toBe('loading')
|
||||
const back = appointmentReturnTarget(target.query)!
|
||||
const query = new URL(back, 'https://appointment.invalid').searchParams
|
||||
expect(query.get('workOrigins')).toBe('["work:0"]'); expect(query.get('workPage')).toBe('3'); expect(query.get('workView')).toBe('UNDATED')
|
||||
expect(appointmentReturnQuery(target.query)).toEqual({ appointmentReturn: back })
|
||||
})
|
||||
it.each(['https://example.com/appointment-calendar?workItem=1', '//example.com/appointment-calendar?workItem=1', '/other?workItem=1', '/appointment-calendar/../other?x=1', '/appointment-calendar?x=1#fragment'])('rejects an unrelated return destination %s', value => {
|
||||
expect(appointmentReturnTarget({ appointmentReturn: value })).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { LocationQuery, LocationQueryRaw } from 'vue-router'
|
||||
|
||||
// Accept only this application's appointment route, never an arbitrary return URL.
|
||||
export function appointmentReturnTarget(query: LocationQuery): string | null {
|
||||
const value = query.appointmentReturn
|
||||
if (typeof value !== 'string' || value.length > 12000) return null
|
||||
try {
|
||||
const target = new URL(value, 'https://appointment.invalid')
|
||||
return target.origin === 'https://appointment.invalid' && target.pathname === '/appointment-calendar' && value.startsWith('/appointment-calendar?') && !target.hash ? `${target.pathname}${target.search}` : null
|
||||
} catch { return null }
|
||||
}
|
||||
export function appointmentReturnQuery(query: LocationQuery): LocationQueryRaw {
|
||||
const target = appointmentReturnTarget(query)
|
||||
return target ? { appointmentReturn: target } : {}
|
||||
}
|
||||
export function appointmentExternalTarget(path: string, context: LocationQueryRaw) {
|
||||
const target = new URL(path, 'https://appointment.invalid')
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(context)) if (value !== undefined && value !== null) params.set(key, String(value))
|
||||
const query: Record<string, string> = { ...Object.fromEntries(target.searchParams), appointmentReturn: `/appointment-calendar?${params}` }
|
||||
return { path: target.pathname, query }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { actionAllowed, actionRoute, referenceRoute, commandName, workDate, workStatus } from './workPresentation'
|
||||
import { followAction, workItem } from './work.test-support'
|
||||
import type { WorkAction } from './workApi'
|
||||
it('links original dispatch and delivery references by business number without inventing unknown routes', () => {
|
||||
expect(referenceRoute({ kind: 'DISPATCH', key: 'DISPATCH:9007199254740999', businessNo: 'DS/001', label: '客户仓排车' })).toBe('/overseas-warehouse/dispatches?dispatchNo=DS%2F001')
|
||||
expect(referenceRoute({ kind: 'DELIVERY', key: 'internal', businessNo: 'DL/001', label: '原派送' })).toBe('/overseas-warehouse/delivery-orders/DL%2F001')
|
||||
expect(referenceRoute({ kind: 'DELIVERY', key: 'internal', businessNo: null, label: '原派送' })).toBeNull()
|
||||
expect(referenceRoute({ kind: 'EXTERNAL_URL', key: 'https://example.com', businessNo: 'example', label: '未知' })).toBeNull()
|
||||
})
|
||||
it('shows both complete local dates for a confirmed cross-day slot', () => {
|
||||
expect(workDate(workItem({ calendarDate: '2026-10-15', dateBasis: 'CONFIRMED', confirmed: { date: '2026-10-15', startTime: '23:00', endDate: '2026-10-16', endTime: '01:00', timezone: 'America/Los_Angeles' } }))).toBe('确认 2026-10-15 23:00 → 2026-10-16 01:00')
|
||||
})
|
||||
it('keeps executed pickup on its actual location date after the plan moves', () => {
|
||||
const item = workItem({ type: 'PICKUP', dateBasis: 'ACTUAL', calendarDate: '2026-10-14', actual: { occurredAt: '2026-10-15T03:00:00Z', localOccurredAt: null, timezone: 'America/Los_Angeles' }, target: { date: '2026-10-20', startTime: null, endDate: null, endTime: null, timezone: 'America/Los_Angeles' } })
|
||||
expect(workDate(item)).toContain('2026/10/14'); expect(workDate(item)).not.toContain('2026-10-20')
|
||||
expect(item.target?.date).toBe('2026-10-20')
|
||||
})
|
||||
it('preserves original local actual and planned dates without imposing a browser or default timezone', () => {
|
||||
expect(workDate(workItem({ dateBasis: 'ACTUAL', calendarDate: '2026-10-15', timezone: null, actual: { occurredAt: null, localOccurredAt: '2026-10-15T23:30:00', timezone: null } }))).toBe('实际 2026-10-15 23:30:00')
|
||||
expect(workDate(workItem({ dateBasis: 'TARGET', calendarDate: '2026-10-15', timezone: null, target: { date: '2026-10-15', startTime: '23:30', endDate: '2026-10-16', endTime: '01:30', timezone: null } }))).toBe('目标 2026-10-15 23:30 → 2026-10-16 01:30')
|
||||
})
|
||||
it('does not turn a follow-up date into a work slot or no-booking into a confirmation', () => {
|
||||
expect(workDate(workItem())).toBe('作业日期未定')
|
||||
expect(workStatus(workItem({ planStatus: 'NOT_REQUIRED' }))).toBe('无需预约')
|
||||
})
|
||||
it('rejects unknown commands and never treats internal trip identifiers as business numbers', () => {
|
||||
const unknown = { ...followAction, target: { kind: 'WORK_COMMAND', command: 'MARK_COMPLETE' } } as unknown as WorkAction
|
||||
expect(commandName(unknown)).toBeNull(); expect(actionAllowed(workItem(), unknown)).toBe(false)
|
||||
const trip: WorkAction = { code: 'RECORD_EXECUTION', label: '登记发车', targetWorkItemKey: null, target: { kind: 'FLEET_TRIP', tripId: 'internal-1', batchKeys: [] } }
|
||||
expect(actionRoute(trip, [])).toBeNull()
|
||||
expect(actionRoute(trip, [{ kind: 'FLEET_TRIP', key: 'internal-1', businessNo: 'TR/2026-1', label: '车次' }])).toBe('/overseas-master-data/trips/TR%2F2026-1')
|
||||
})
|
||||
it('opens the original container operation with a real source selection for direct unloading', () => {
|
||||
const action: WorkAction = { code: 'RECORD_EXECUTION', label: '登记执行', targetWorkItemKey: null, target: { kind: 'FIRSTLEG_CONTAINER', containerBookingId: '9007199254740993' } }
|
||||
expect(actionRoute(action, [], workItem({ type: 'PICKUP' }))).toBe('/container-loading/overseas/9007199254740993?action=pickup')
|
||||
expect(actionRoute(action, [], workItem({ type: 'EMPTY_RETURN' }))).toBe('/container-loading/overseas/9007199254740993?action=empty-return')
|
||||
expect(actionRoute(action, [], workItem({ type: 'DELIVERY', flow: 'DIRECT', requirementKey: 'INBOUND:9007199254740999' }))).toBe('/container-loading/overseas/9007199254740993?action=direct-unload&requirementKey=INBOUND%3A9007199254740999')
|
||||
})
|
||||
it('routes receiving by plan business number plus customer scope, never the internal plan ID', () => {
|
||||
const action: WorkAction = { code: 'RECORD_EXECUTION', label: '查看接仓', targetWorkItemKey: null, target: { kind: 'WAREHOUSE_RECEIPT', inboundPlanId: 'internal-plan', inboundPlanNo: 'IP/TEST', customerId: '9007199254740999', containerBookingId: 'container-1' } }
|
||||
expect(actionRoute(action, [])).toBe('/overseas-warehouse/inbound-orders/IP%2FTEST?customerId=9007199254740999')
|
||||
expect(actionRoute({ ...action, target: { kind: 'WAREHOUSE_RECEIPT', inboundPlanId: null, inboundPlanNo: null, customerId: null, containerBookingId: 'container-1' } }, [])).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { WorkAction, WorkItem, WorkType, WorkView, BusinessReference } from './workApi'
|
||||
import { actionCodes, workCommands, type WorkCommandName } from './workApi'
|
||||
export const typeLabels: Record<WorkType, string> = { PICKUP: '提柜', WAREHOUSE_RECEIPT: '接仓卸柜', DELIVERY: '送仓 / FBA', TRANSPORT: '用车计划', EMPTY_RETURN: '还空柜', ROUTE: '确认路线' }
|
||||
export const flowLabels = { TRANSFER: '海外仓中转', DIRECT: '整柜直送', UNKNOWN: '路线待确认' }
|
||||
export const viewLabels: Record<WorkView, string> = { ALL: '全部待办', FOLLOW_TODAY: '今日需跟进', OVERDUE: '已超期', UNDATED: '未定日期', REVIEW: '需复核' }
|
||||
export function workReason(reason: string) { return reason === 'SOURCE_TIMEZONE' ? '原业务作业地点时区待核实' : reason }
|
||||
export function workStatus(item: WorkItem) {
|
||||
if (item.executionStatus === 'COMPLETED') return '已完成'
|
||||
if (item.reviewRequired) return '需复核'
|
||||
if (item.executionStatus === 'IN_PROGRESS') return '执行中'
|
||||
return { UNPLANNED: '待安排', TARGET_SET: '已有目标日期', AWAITING_REPLY: '等待回复', CONFIRMED: '已落实', NOT_REQUIRED: '无需预约' }[item.planStatus]
|
||||
}
|
||||
export function localInstant(value: string, timezone: string | null) {
|
||||
if (!timezone) return '作业地点时区待核实'
|
||||
try { return new Intl.DateTimeFormat('zh-CN', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(new Date(value)) }
|
||||
catch { return '实际时间待核实' }
|
||||
}
|
||||
export function workDate(item: WorkItem) {
|
||||
if (item.dateBasis === 'ACTUAL') {
|
||||
const actual = item.actual
|
||||
if (actual?.localOccurredAt) return `实际 ${actual.localOccurredAt.replace('T', ' ')}`
|
||||
return actual?.occurredAt ? `实际 ${localInstant(actual.occurredAt, actual.timezone)}` : '实际时间待核实'
|
||||
}
|
||||
if (item.dateBasis === 'NONE' || !item.calendarDate) return '作业日期未定'
|
||||
const slot = item.dateBasis === 'CONFIRMED' ? item.confirmed : item.target
|
||||
const end = slot?.endDate && slot.endTime ? ` → ${slot.endDate} ${slot.endTime}` : ''
|
||||
return `${item.dateBasis === 'CONFIRMED' ? '确认' : '目标'} ${slot?.date || item.calendarDate}${slot?.startTime ? ` ${slot.startTime}${end}` : ' · 时段未定'}`
|
||||
}
|
||||
export function commandName(action: WorkAction): WorkCommandName | null {
|
||||
return action.target.kind === 'WORK_COMMAND' && workCommands.includes(action.target.command as WorkCommandName) ? action.target.command as WorkCommandName : null
|
||||
}
|
||||
export function sameAction(a: WorkAction, b: WorkAction) {
|
||||
return a.code === b.code && a.targetWorkItemKey === b.targetWorkItemKey && JSON.stringify(a.target) === JSON.stringify(b.target)
|
||||
}
|
||||
export function actionAllowed(item: WorkItem, action: WorkAction) { return actionCodes.includes(action.code) && item.allowedActions.some(candidate => sameAction(candidate, action)) }
|
||||
export function referenceRoute(reference: BusinessReference) {
|
||||
if (!reference.businessNo) return null
|
||||
if (reference.kind === 'DISPATCH') return `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(reference.businessNo)}`
|
||||
if (reference.kind === 'DELIVERY') return `/overseas-warehouse/delivery-orders/${encodeURIComponent(reference.businessNo)}`
|
||||
return null
|
||||
}
|
||||
export function actionRoute(action: WorkAction, references: BusinessReference[], item?: WorkItem) {
|
||||
// Only known application routes. No server-provided URL or method is executed.
|
||||
switch (action.target.kind) {
|
||||
case 'FIRSTLEG_CONTAINER': {
|
||||
const path = `/container-loading/overseas/${encodeURIComponent(action.target.containerBookingId)}`
|
||||
if (!item || action.code === 'VIEW_DETAIL' || action.code === 'VIEW_HISTORY') return path
|
||||
if (item.type === 'PICKUP') return `${path}?action=pickup`
|
||||
if (item.type === 'EMPTY_RETURN' && action.code === 'RECORD_EXECUTION') return `${path}?action=empty-return`
|
||||
if (item.type === 'DELIVERY' && item.flow === 'DIRECT' && action.code === 'RECORD_EXECUTION') return `${path}?action=direct-unload${item.requirementKey ? `&requirementKey=${encodeURIComponent(item.requirementKey)}` : ''}`
|
||||
return path
|
||||
}
|
||||
case 'FLEET_TRIP': {
|
||||
const id = action.target.tripId
|
||||
const trip = references.find(ref => ref.kind === 'FLEET_TRIP' && ref.key === id)
|
||||
return trip?.businessNo ? `/overseas-master-data/trips/${encodeURIComponent(trip.businessNo)}` : null
|
||||
}
|
||||
case 'WAREHOUSE_RECEIPT': return action.target.inboundPlanNo && action.target.customerId ? `/overseas-warehouse/inbound-orders/${encodeURIComponent(action.target.inboundPlanNo)}?customerId=${encodeURIComponent(action.target.customerId)}` : null
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,24 @@ import {
|
||||
recordTerminalDrayageEmptyReturn,
|
||||
scheduleTerminalDrayagePickup,
|
||||
selectTerminalDrayageFleet,
|
||||
getDirectDeliveries, recordDirectDelivery, recordTerminalPickup,
|
||||
} from './terminalDrayages'
|
||||
it('records only the original actual PICKED_UP node with an immutable observed time and idempotency header', async () => {
|
||||
const command = { standardNode: 'PICKED_UP' as const, observedAt: '2026-10-15T09:00:00', estimatedAt: null, actualAt: '2026-10-14T23:00:00', locationCode: 'TEST-PORT', locationName: '测试码头', reason: '司机确认已提柜' }
|
||||
vi.mocked(http.post).mockResolvedValue({ data: {} })
|
||||
await recordTerminalPickup('9007199254740993', command, 'same-pickup-key')
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/container-shipping-nodes/9007199254740993', command, { headers: { 'Idempotency-Key': 'same-pickup-key' } })
|
||||
})
|
||||
|
||||
it('uses the actual direct-delivery source and stable idempotency key without coercing long IDs', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: [] }); await getDirectDeliveries('9007199254740993')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/terminal-drayages/9007199254740993/direct-deliveries')
|
||||
const request = { expectedVersion: 0, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '测试签收人', evidenceNo: 'POD-001', remark: null }
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { requirementKey: 'INBOUND:9007199254740999', version: 1 } })
|
||||
await recordDirectDelivery('9007199254740993', 'INBOUND:9007199254740999', request, 'retry-same-key')
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/terminal-drayages/9007199254740993/direct-deliveries/9007199254740999', request, { headers: { 'Idempotency-Key': 'retry-same-key' } })
|
||||
await expect(recordDirectDelivery('1', 'ORDER:1', request, 'bad')).rejects.toThrow('标识无效')
|
||||
})
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn() },
|
||||
|
||||
@@ -108,21 +108,40 @@ export async function getTerminalDrayageHistory(containerBookingId: string) {
|
||||
return (unwrapApiResponse(response) || []).map((item) => ({ ...item, operatorId: id(item.operatorId) }))
|
||||
}
|
||||
|
||||
async function command(containerBookingId: string, action: string, body: object) {
|
||||
async function command(containerBookingId: string, action: string, body: object, idempotencyKey = operationId(action)) {
|
||||
const response = await http.post<ApiEnvelope<TerminalDrayageView>, ApiEnvelope<TerminalDrayageView>>(
|
||||
`${path}/${encodeURIComponent(containerBookingId)}/${action}`,
|
||||
body,
|
||||
{ headers: { 'Idempotency-Key': operationId(action) } },
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return normalize(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export function selectTerminalDrayageFleet(containerBookingId: string, body: { expectedVersion: number; fleetSupplierId: string; fleetSupplierName?: string; reason?: string }) {
|
||||
return command(containerBookingId, 'fleet', body)
|
||||
export function selectTerminalDrayageFleet(containerBookingId: string, body: { expectedVersion: number; fleetSupplierId: string; fleetSupplierName?: string; reason?: string }, idempotencyKey?: string) {
|
||||
return command(containerBookingId, 'fleet', body, idempotencyKey)
|
||||
}
|
||||
export function scheduleTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; pickupAppointmentAt: string; reason?: string }) {
|
||||
return command(containerBookingId, 'pickup-appointment', body)
|
||||
export function scheduleTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; pickupAppointmentAt: string; reason?: string }, idempotencyKey?: string) {
|
||||
return command(containerBookingId, 'pickup-appointment', body, idempotencyKey)
|
||||
}
|
||||
export function recordTerminalDrayageEmptyReturn(containerBookingId: string, body: { expectedVersion: number; actualAt: string; remark?: string }) {
|
||||
return command(containerBookingId, 'empty-return', body)
|
||||
export function recordTerminalDrayageEmptyReturn(containerBookingId: string, body: { expectedVersion: number; actualAt: string; remark?: string }, idempotencyKey?: string) {
|
||||
return command(containerBookingId, 'empty-return', body, idempotencyKey)
|
||||
}
|
||||
|
||||
export interface DirectDelivery {
|
||||
requirementKey: string; inboundCode: string | null; orderNo: string | null; destinationName: string | null
|
||||
packageCount: number | null; version: number; actualAt: string | null; timezone: string | null
|
||||
receivedBy: string | null; evidenceNo: string | null; remark: string | null
|
||||
}
|
||||
export interface DirectDeliveryCommand { expectedVersion: number; actualAt: string; timezone: string; receivedBy: string; evidenceNo: string; remark: string | null }
|
||||
export async function getDirectDeliveries(containerBookingId: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<DirectDelivery[]>, ApiEnvelope<DirectDelivery[]>>(`${path}/${encodeURIComponent(containerBookingId)}/direct-deliveries`))
|
||||
}
|
||||
export async function recordDirectDelivery(containerBookingId: string, requirementKey: string, body: DirectDeliveryCommand, idempotencyKey: string) {
|
||||
if (!/^INBOUND:\d+$/.test(requirementKey)) throw new Error('服务端入仓来源标识无效,请重新读取清单')
|
||||
const inboundId = requirementKey.slice('INBOUND:'.length)
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<DirectDelivery>, ApiEnvelope<DirectDelivery>>(`${path}/${encodeURIComponent(containerBookingId)}/direct-deliveries/${encodeURIComponent(inboundId)}`, body, { headers: { 'Idempotency-Key': idempotencyKey } }))
|
||||
}
|
||||
export interface TerminalPickupCommand { standardNode: 'PICKED_UP'; observedAt: string; estimatedAt: null; actualAt: string; locationCode: string | null; locationName: string | null; reason: string }
|
||||
export async function recordTerminalPickup(containerBookingId: string, body: TerminalPickupCommand, idempotencyKey: string) {
|
||||
unwrapApiResponse(await http.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(`/platform/v1/container-shipping-nodes/${encodeURIComponent(containerBookingId)}`, body, { headers: { 'Idempotency-Key': idempotencyKey } }))
|
||||
}
|
||||
|
||||
+97
-5
@@ -1,11 +1,13 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ContainerDetailView from './ContainerDetailView.vue'
|
||||
import { getContainer, getContainerOverview, linkContainerOceanBooking } from '../api/platformContainerLoading'
|
||||
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
|
||||
import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages'
|
||||
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, scheduleTerminalDrayagePickup, selectTerminalDrayageFleet, recordTerminalPickup } from '../api/terminalDrayages'
|
||||
import { listSuppliers } from '@/modules/suppliers'
|
||||
import { listContainerArrivalTimes } from '../api/containerArrivalTimes'
|
||||
import { getContainerOutboundDispatch } from '@/modules/outbound-dispatch'
|
||||
import { ref } from 'vue'
|
||||
@@ -16,7 +18,7 @@ const canAccess = vi.hoisted(() => vi.fn<(code: string) => boolean>(() => true))
|
||||
const routeState = vi.hoisted(() => ({ params: { containerId: '9001' }, query: {} as Record<string, string> }))
|
||||
const routerPush = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: routerPush }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/modules/loading-lists', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/modules/loading-lists')>()
|
||||
const api = { getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }
|
||||
@@ -32,7 +34,8 @@ vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.
|
||||
vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() }))
|
||||
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div />' }, ContainerPortSchedulePanel: { name: 'ContainerPortSchedulePanel', props: ['container'], emits: ['saved'], template: '<div />' } }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), linkContainerOceanBooking: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() }))
|
||||
vi.mock('../api/terminalDrayages', () => ({ recordTerminalDrayageEmptyReturn: vi.fn() }))
|
||||
vi.mock('../api/terminalDrayages', () => ({ recordTerminalDrayageEmptyReturn: vi.fn(), getDirectDeliveries: vi.fn(), recordDirectDelivery: vi.fn(), getTerminalDrayage: vi.fn(), scheduleTerminalDrayagePickup: vi.fn(), selectTerminalDrayageFleet: vi.fn(), recordTerminalPickup: vi.fn() }))
|
||||
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
|
||||
vi.mock('../api/containerArrivalTimes', () => ({ listContainerArrivalTimes: vi.fn() }))
|
||||
|
||||
const base = { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', planNo: 'LP-1', planStatus: 'COMPLETED', warehouseName: '义乌仓', destinationWarehouseCode: 'LAX01', destinationWarehouseName: '洛杉矶接柜仓', workbenchStage: 'UNLOADED', statusSummary: { terminalDrayageStatus: 'UNLOADED' }, allowedActions: ['RECORD_EMPTY_RETURN'], terminalDrayage: { containerBookingId: '9001', status: 'UNLOADED', version: 3, fleetSupplierId: '501', fleetSupplierName: '港运车队', pickupAppointmentAt: '2026-09-07T08:00:00', actualPickupAt: '2026-09-07T09:00:00', estimatedEmptyReturnAt: '2026-09-10T10:00:00', actualEmptyReturnAt: null, unloadingCompletedAt: '2026-09-07T10:00:00', emptyReturnWarning: { status: 'PENDING', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['RECORD_EMPTY_RETURN'], updatedAt: '2026-09-09T10:00:00' }, actualFacts: { unloadingCompletedAt: '2026-09-07T10:00:00' } }
|
||||
@@ -66,6 +69,13 @@ describe('ContainerDetailView empty return', () => {
|
||||
})
|
||||
beforeEach(() => {
|
||||
routeState.query = {}
|
||||
vi.mocked(getDirectDeliveries).mockReset().mockResolvedValue([])
|
||||
vi.mocked(recordDirectDelivery).mockReset()
|
||||
vi.mocked(getTerminalDrayage).mockReset().mockResolvedValue(base.terminalDrayage as never)
|
||||
vi.mocked(scheduleTerminalDrayagePickup).mockReset().mockResolvedValue(base.terminalDrayage as never)
|
||||
vi.mocked(selectTerminalDrayageFleet).mockReset().mockResolvedValue(base.terminalDrayage as never)
|
||||
vi.mocked(recordTerminalPickup).mockReset().mockResolvedValue(undefined)
|
||||
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [], total: 0 } as never)
|
||||
routerPush.mockReset()
|
||||
canAccess.mockReset().mockReturnValue(true)
|
||||
vi.mocked(getContainerOutboundDispatch).mockReset().mockResolvedValue({ aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', gate: { inbounds: [] } }, timeline: [] } as never)
|
||||
@@ -76,6 +86,86 @@ describe('ContainerDetailView empty return', () => {
|
||||
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
|
||||
})
|
||||
|
||||
it('records a real direct-delivery source with explicit local time and proof, retaining the request after an unknown outcome', async () => {
|
||||
const row = { requirementKey: 'INBOUND:9007199254740999', inboundCode: 'IN-TEST', orderNo: 'ORD-TEST', destinationName: '测试客户仓', packageCount: 10, version: 0, actualAt: null, timezone: null, receivedBy: null, evidenceNo: null, remark: null }
|
||||
vi.mocked(getDirectDeliveries).mockResolvedValue([row])
|
||||
vi.mocked(recordDirectDelivery).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...row, version: 1, actualAt: '2026-10-16T01:00:00' })
|
||||
const wrapper = await mountDetail()
|
||||
expect(getDirectDeliveries).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '直送卸货登记')!
|
||||
await dialog.findAll('button').find(button => button.text() === '登记本票')!.trigger('click'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
|
||||
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00')
|
||||
for (const [label, value] of [['地点时区(IANA)', 'America/Los_Angeles'], ['签收人', '测试签收人'], ['真实签收凭据号', 'POD-001']]) {
|
||||
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === label)!.findComponent(ElInput).vm.$emit('update:modelValue', value)
|
||||
}
|
||||
await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(recordDirectDelivery).mock.calls[0]!
|
||||
expect(first.slice(0, 3)).toEqual(['9001', row.requirementKey, { expectedVersion: 0, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '测试签收人', evidenceNo: 'POD-001', remark: null }])
|
||||
expect(dialog.text()).not.toContain('9007199254740999')
|
||||
const close = vi.fn(); await dialog.props('beforeClose')!(close); expect(close).not.toHaveBeenCalled()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(recordDirectDelivery).toHaveBeenNthCalledWith(2, ...first)
|
||||
})
|
||||
|
||||
it('requires the original direct-unload write permission and keeps completed source evidence read-only', async () => {
|
||||
canAccess.mockImplementation(code => code !== 'platform:firstleg:terminal-drayage:direct-unload:confirm')
|
||||
vi.mocked(getDirectDeliveries).mockResolvedValue([{ requirementKey: 'INBOUND:9', inboundCode: 'IN-TEST', orderNo: null, destinationName: '测试客户仓', packageCount: null, version: 1, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '签收人', evidenceNo: 'POD-001', remark: null }])
|
||||
const wrapper = await mountDetail()
|
||||
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '直送卸货登记')!
|
||||
await dialog.findAll('button').find(button => button.text() === '查看签收')!.trigger('click'); await flushPromises()
|
||||
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('签收事实只读')
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the pickup action and saves an actual overseas supplier before the explicit pickup appointment', async () => {
|
||||
routeState.query = { action: 'pickup' }
|
||||
const current = { ...base.terminalDrayage, actualPickupAt: null, allowedActions: ['SELECT_FLEET', 'SCHEDULE_PICKUP'] }
|
||||
vi.mocked(getTerminalDrayage).mockResolvedValueOnce(current as never).mockResolvedValue({ ...current, version: 4 } as never)
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740999', name: '测试海外车队' }], total: 1 } as never)
|
||||
const wrapper = await mountDetail()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
|
||||
expect(dialog.props('modelValue')).toBe(true); expect(selectTerminalDrayageFleet).not.toHaveBeenCalled()
|
||||
dialog.findComponent(ElSelect).vm.$emit('visible-change', true); await flushPromises()
|
||||
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '' })
|
||||
dialog.findComponent(ElSelect).vm.$emit('update:modelValue', '9007199254740999'); await flushPromises()
|
||||
await dialog.findAll('button').find(button => button.text() === '保存码头车队')!.trigger('click'); await flushPromises()
|
||||
expect(selectTerminalDrayageFleet).toHaveBeenCalledWith('9001', { expectedVersion: 3, fleetSupplierId: '9007199254740999', fleetSupplierName: '测试海外车队', reason: undefined }, expect.any(String))
|
||||
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00'); await flushPromises()
|
||||
await dialog.findAll('button').find(button => button.text() === '保存提柜预约')!.trigger('click'); await flushPromises()
|
||||
expect(scheduleTerminalDrayagePickup).toHaveBeenCalledWith('9001', { expectedVersion: 4, pickupAppointmentAt: '2026-10-16T01:00:00', reason: undefined }, expect.any(String))
|
||||
})
|
||||
|
||||
it('keeps the same pickup request and idempotency key after an unknown response', async () => {
|
||||
routeState.query = { action: 'pickup' }
|
||||
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...base.terminalDrayage, allowedActions: ['SCHEDULE_PICKUP'] } as never)
|
||||
vi.mocked(scheduleTerminalDrayagePickup).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = await mountDetail()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
|
||||
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00'); await flushPromises()
|
||||
await dialog.findAll('button').find(button => button.text() === '保存提柜预约')!.trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(scheduleTerminalDrayagePickup).mock.calls[0]!
|
||||
const done = vi.fn(); await dialog.props('beforeClose')!(done); expect(done).not.toHaveBeenCalled()
|
||||
await dialog.findAll('button').find(button => button.text() === '重试本次预约保存')!.trigger('click'); await flushPromises()
|
||||
expect(scheduleTerminalDrayagePickup).toHaveBeenNthCalledWith(2, ...first)
|
||||
})
|
||||
it('records actual pickup with an explicit actual time and preserves the same node on retry', async () => {
|
||||
routeState.query = { action: 'pickup' }
|
||||
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...base.terminalDrayage, actualPickupAt: null } as never)
|
||||
vi.mocked(recordTerminalPickup).mockRejectedValueOnce(new Error('lost response'))
|
||||
const wrapper = await mountDetail(), dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
|
||||
await dialog.findAll('button').find(button => button.text() === '登记实际提柜')!.trigger('click'); await flushPromises(); expect(recordTerminalPickup).not.toHaveBeenCalled()
|
||||
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === '实际提柜当地时间(登记实际时必填)')!.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-14T23:00:00')
|
||||
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === '处理说明')!.findComponent(ElInput).vm.$emit('update:modelValue', '司机提供已提柜记录')
|
||||
await flushPromises(); await dialog.findAll('button').find(button => button.text() === '登记实际提柜')!.trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(recordTerminalPickup).mock.calls[0]!
|
||||
expect(first[1]).toMatchObject({ standardNode: 'PICKED_UP', actualAt: '2026-10-14T23:00:00', estimatedAt: null, reason: '司机提供已提柜记录' })
|
||||
await dialog.findAll('button').find(button => button.text() === '重试本次实际提柜登记')!.trigger('click'); await flushPromises()
|
||||
expect(recordTerminalPickup).toHaveBeenNthCalledWith(2, ...first)
|
||||
})
|
||||
|
||||
it('loads departed gates exactly once when read permission arrives after the container detail', async () => {
|
||||
const granted = ref(false)
|
||||
canAccess.mockImplementation((code) => code === 'platform:firstleg:outbound-dispatch:view' ? granted.value : true)
|
||||
@@ -121,11 +211,13 @@ describe('ContainerDetailView empty return', () => {
|
||||
expect(wrapper.text()).not.toContain('装柜完成')
|
||||
await wrapper.findAll('button').find((item) => item.text() === '确认还柜')!.trigger('click')
|
||||
await flushPromises()
|
||||
const emptyDialog = wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title') === '确认还柜')!
|
||||
emptyDialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-09-09T12:00:00'); await flushPromises()
|
||||
const confirm = [...document.body.querySelectorAll('button')].filter((item) => item.textContent?.trim() === '确认还柜').at(-1) as HTMLButtonElement
|
||||
confirm.click()
|
||||
await flushPromises()
|
||||
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledTimes(1)
|
||||
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', expect.objectContaining({ expectedVersion: 3, actualAt: expect.any(String), remark: undefined }))
|
||||
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', expect.objectContaining({ expectedVersion: 3, actualAt: '2026-09-09T12:00:00', remark: undefined }), expect.any(String))
|
||||
expect(getContainer).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('已还柜')
|
||||
expect(wrapper.find('.detail-section__heading').findAll('button').some((item) => item.text() === '确认还柜')).toBe(false)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, FileUp } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { ApiResponseError, 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'
|
||||
@@ -38,7 +41,7 @@ import {
|
||||
uploadContainerDocument,
|
||||
updateContainerPickupCode,
|
||||
} from '../api/platformContainerLoading'
|
||||
import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages'
|
||||
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, selectTerminalDrayageFleet, scheduleTerminalDrayagePickup, recordTerminalPickup, type TerminalDrayageView, type DirectDelivery, type DirectDeliveryCommand } from '../api/terminalDrayages'
|
||||
import { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes'
|
||||
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
||||
|
||||
@@ -99,6 +102,136 @@ const canRecordEmptyReturn = computed(() => isOverseas.value
|
||||
&& access.can('platform:firstleg:terminal-drayage:empty-return:confirm')
|
||||
&& hasAction('RECORD_EMPTY_RETURN')
|
||||
&& Boolean(workbenchDetail.value?.terminalDrayage?.allowedActions.includes('RECORD_EMPTY_RETURN')))
|
||||
const canReadDirect = computed(() => isOverseas.value && access.can('platform:firstleg:terminal-drayage:list'))
|
||||
const terminalOpen = ref(false), terminalLoading = ref(false), terminalSaving = ref(false)
|
||||
const terminal = ref<TerminalDrayageView | null>(null), terminalError = ref<ApiErrorDetails | null>(null), terminalSaveError = ref<ApiErrorDetails | null>(null)
|
||||
const terminalForm = reactive({ supplierId: '', pickupAt: '', actualAt: '', locationCode: '', locationName: '', reason: '' })
|
||||
const terminalBaseline = ref(JSON.stringify(terminalForm))
|
||||
const terminalSuppliers = ref<Supplier[]>([]), supplierLoading = ref(false), supplierError = ref<ApiErrorDetails | null>(null)
|
||||
type TerminalRequest = { kind: 'fleet'; body: Parameters<typeof selectTerminalDrayageFleet>[1]; key: string } | { kind: 'appointment'; body: Parameters<typeof scheduleTerminalDrayagePickup>[1]; key: string } | { kind: 'actual'; body: Parameters<typeof recordTerminalPickup>[1]; key: string }
|
||||
const terminalPending = ref<TerminalRequest | null>(null)
|
||||
const canSelectFleet = computed(() => canReadDirect.value && access.can('platform:firstleg:terminal-drayage:fleet:edit') && terminal.value?.allowedActions.includes('SELECT_FLEET'))
|
||||
const canSchedulePickup = computed(() => canReadDirect.value && access.can('platform:firstleg:terminal-drayage:pickup-appointment:edit') && terminal.value?.allowedActions.includes('SCHEDULE_PICKUP'))
|
||||
const canRecordPickup = computed(() => canReadDirect.value && access.can('platform:firstleg:port-schedule-query:manual') && !!terminal.value && !terminal.value.actualPickupAt && !terminal.value.actualEmptyReturnAt)
|
||||
async function loadTerminal() {
|
||||
if (!canReadDirect.value) return
|
||||
terminalLoading.value = true; terminalError.value = null
|
||||
try { terminal.value = await getTerminalDrayage(containerId.value) }
|
||||
catch (cause) { terminalError.value = getApiErrorDetails(cause, '提柜资料读取失败') }
|
||||
finally { terminalLoading.value = false }
|
||||
}
|
||||
let supplierRequest = 0
|
||||
async function searchTerminalSuppliers(keyword = '') {
|
||||
if (!access.can('platform:supplier:list')) { supplierError.value = { message: '当前没有供应商读取权限' }; return }
|
||||
const request = ++supplierRequest; supplierLoading.value = true; supplierError.value = null
|
||||
try { const result = await listSuppliers({ page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword }); if (request === supplierRequest) terminalSuppliers.value = result.records }
|
||||
catch (cause) { if (request === supplierRequest) supplierError.value = getApiErrorDetails(cause, '海外车队读取失败') }
|
||||
finally { if (request === supplierRequest) supplierLoading.value = false }
|
||||
}
|
||||
async function openTerminal() {
|
||||
if (!canReadDirect.value || !await leaveDirect()) return
|
||||
terminalOpen.value = true; terminalSaveError.value = null; await loadTerminal()
|
||||
if (!terminal.value || terminalError.value) return
|
||||
Object.assign(terminalForm, { supplierId: terminal.value.fleetSupplierId || '', pickupAt: terminal.value.pickupAppointmentAt || '', actualAt: '', locationCode: '', locationName: '', reason: '' })
|
||||
terminalBaseline.value = JSON.stringify(terminalForm)
|
||||
}
|
||||
function isDefiniteRejection(cause: unknown) {
|
||||
const status = cause instanceof ApiResponseError ? cause.status : axios.isAxiosError(cause) ? cause.response?.status : undefined
|
||||
return [400, 403, 404, 409, 422].includes(status || 0) || /^(HTTP_|A0?)?(400|403|404|409|422)$/.test(getApiErrorDetails(cause).code || '')
|
||||
}
|
||||
async function saveTerminal(kind: 'fleet' | 'appointment' | 'actual') {
|
||||
if (terminalLoading.value || terminalSaving.value || terminalError.value || !terminal.value || (kind === 'fleet' ? !canSelectFleet.value : kind === 'appointment' ? !canSchedulePickup.value : !canRecordPickup.value)) return
|
||||
if (terminalPending.value && terminalPending.value.kind !== kind) return
|
||||
if (!terminalPending.value) {
|
||||
const key = crypto.randomUUID(), expectedVersion = terminal.value.version
|
||||
if (kind === 'fleet') {
|
||||
const supplier = terminalSuppliers.value.find(row => row.id === terminalForm.supplierId)
|
||||
if (!supplier || supplierLoading.value || supplierError.value) { ElMessage.warning('请选择查询结果中的有效海外车队'); return }
|
||||
terminalPending.value = { kind, key, body: { expectedVersion, fleetSupplierId: supplier.id, fleetSupplierName: supplier.name, reason: terminalForm.reason.trim() || undefined } }
|
||||
} else if (kind === 'appointment') {
|
||||
if (!terminalForm.pickupAt) { ElMessage.warning('请填写明确的当地提柜预约时间'); return }
|
||||
terminalPending.value = { kind, key, body: { expectedVersion, pickupAppointmentAt: terminalForm.pickupAt, reason: terminalForm.reason.trim() || undefined } }
|
||||
} else {
|
||||
if (!terminalForm.actualAt || !terminalForm.reason.trim()) { ElMessage.warning('请填写真实提柜当地时间和登记依据'); return }
|
||||
terminalPending.value = { kind, key, body: { standardNode: 'PICKED_UP', observedAt: localDateTimeValue(), estimatedAt: null, actualAt: terminalForm.actualAt, locationCode: terminalForm.locationCode.trim() || null, locationName: terminalForm.locationName.trim() || null, reason: terminalForm.reason.trim() } }
|
||||
}
|
||||
}
|
||||
terminalSaving.value = true; terminalSaveError.value = null
|
||||
try {
|
||||
const request = terminalPending.value
|
||||
if (request.kind === 'fleet') await selectTerminalDrayageFleet(containerId.value, request.body, request.key)
|
||||
else if (request.kind === 'appointment') await scheduleTerminalDrayagePickup(containerId.value, request.body, request.key)
|
||||
else await recordTerminalPickup(containerId.value, request.body, request.key)
|
||||
terminalPending.value = null; terminalBaseline.value = JSON.stringify(terminalForm)
|
||||
ElMessage.success(kind === 'fleet' ? '码头车队已保存' : kind === 'appointment' ? '提柜预约已保存' : '实际提柜已登记'); await Promise.all([loadTerminal(), load()])
|
||||
} catch (cause) {
|
||||
terminalSaveError.value = getApiErrorDetails(cause, '操作结果待确认,请重试本次提交')
|
||||
if (isDefiniteRejection(cause)) { terminalPending.value = null; await loadTerminal() }
|
||||
} finally { terminalSaving.value = false }
|
||||
}
|
||||
async function leaveTerminal() {
|
||||
if (terminalSaving.value || terminalPending.value) { ElMessage.warning('本次提柜安排结果尚未确定,请先重试确认结果'); return false }
|
||||
if (!terminalOpen.value || terminalBaseline.value === JSON.stringify(terminalForm)) return true
|
||||
try { await ElMessageBox.confirm('尚有未保存的提柜安排,确认放弃本次输入?', '未保存的安排', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
}
|
||||
async function closeTerminal(done: () => void) { if (await leaveTerminal()) { terminalBaseline.value = JSON.stringify(terminalForm); done() } }
|
||||
onBeforeRouteLeave(leaveTerminal)
|
||||
const canWriteDirect = computed(() => canReadDirect.value && access.can('platform:firstleg:terminal-drayage:direct-unload:confirm'))
|
||||
const directOpen = ref(false), directLoading = ref(false), directSaving = ref(false)
|
||||
const directRows = ref<DirectDelivery[]>([]), directSelected = ref<DirectDelivery | null>(null)
|
||||
const directError = ref<ApiErrorDetails | null>(null), directSaveError = ref<ApiErrorDetails | null>(null)
|
||||
const directForm = reactive({ actualAt: '', timezone: '', receivedBy: '', evidenceNo: '', remark: '' })
|
||||
const directBaseline = ref(JSON.stringify(directForm))
|
||||
const directPending = ref<{ containerId: string; requirementKey: string; body: DirectDeliveryCommand; key: string } | null>(null)
|
||||
async function leaveDirect() {
|
||||
if (directSaving.value || directPending.value) { ElMessage.warning('本次卸货登记结果尚未确定,请先重试确认结果'); return false }
|
||||
if (!directOpen.value || JSON.stringify(directForm) === directBaseline.value) return true
|
||||
try { await ElMessageBox.confirm('尚有未保存的卸货登记,确认放弃本次输入?', '未保存的登记', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
}
|
||||
onBeforeRouteLeave(leaveDirect)
|
||||
onBeforeRouteUpdate(async (to, from) => {
|
||||
if (to.params.containerId === from.params.containerId) return true
|
||||
if (!await leaveDirect() || !await leaveTerminal() || !await leaveEmptyReturn()) return false
|
||||
terminalOpen.value = false; terminal.value = null; emptyReturnOpen.value = false
|
||||
directOpen.value = false; directSelected.value = null; directRows.value = []
|
||||
directBaseline.value = JSON.stringify(directForm); return true
|
||||
})
|
||||
async function closeDirect(done: () => void) { if (await leaveDirect()) { directBaseline.value = JSON.stringify(directForm); done() } }
|
||||
async function loadDirect() {
|
||||
if (!canReadDirect.value) return
|
||||
directLoading.value = true; directError.value = null
|
||||
try {
|
||||
directRows.value = await getDirectDeliveries(containerId.value)
|
||||
if (directSelected.value) directSelected.value = directRows.value.find(row => row.requirementKey === directSelected.value?.requirementKey) || null
|
||||
} catch (cause) { directError.value = getApiErrorDetails(cause, '直送卸货清单读取失败') }
|
||||
finally { directLoading.value = false }
|
||||
}
|
||||
async function openDirect() { if (!canReadDirect.value || directSaving.value) return; directOpen.value = true; await loadDirect() }
|
||||
async function selectDirect(row: DirectDelivery) {
|
||||
if (!await leaveDirect()) return
|
||||
directSelected.value = row; directSaveError.value = null
|
||||
Object.assign(directForm, { actualAt: row.actualAt || '', timezone: row.timezone || '', receivedBy: row.receivedBy || '', evidenceNo: row.evidenceNo || '', remark: row.remark || '' })
|
||||
directBaseline.value = JSON.stringify(directForm)
|
||||
}
|
||||
async function saveDirect() {
|
||||
if (!canWriteDirect.value || directSaving.value || directLoading.value || directError.value || !directSelected.value || (directSelected.value.actualAt && !directPending.value)) return
|
||||
if (!directPending.value) {
|
||||
if (!directForm.actualAt || !directForm.timezone.trim() || !directForm.receivedBy.trim() || !directForm.evidenceNo.trim()) { ElMessage.warning('请填写实际当地时间、地点时区、签收人和真实凭据号'); return }
|
||||
try { new Intl.DateTimeFormat('en-US', { timeZone: directForm.timezone.trim() }) } catch { ElMessage.warning('请填写有效的 IANA 地点时区'); return }
|
||||
directPending.value = { containerId: containerId.value, requirementKey: directSelected.value.requirementKey, key: crypto.randomUUID(), body: { expectedVersion: directSelected.value.version, actualAt: directForm.actualAt.length === 16 ? `${directForm.actualAt}:00` : directForm.actualAt, timezone: directForm.timezone.trim(), receivedBy: directForm.receivedBy.trim(), evidenceNo: directForm.evidenceNo.trim(), remark: directForm.remark.trim() || null } }
|
||||
}
|
||||
directSaving.value = true; directSaveError.value = null
|
||||
try {
|
||||
const request = directPending.value
|
||||
await recordDirectDelivery(request.containerId, request.requirementKey, request.body, request.key)
|
||||
directPending.value = null; directBaseline.value = JSON.stringify(directForm)
|
||||
ElMessage.success('本票直送卸货已登记'); await Promise.all([loadDirect(), load()])
|
||||
} catch (cause) {
|
||||
directSaveError.value = getApiErrorDetails(cause, '卸货登记结果待确认,请重试本次登记')
|
||||
const status = cause instanceof ApiResponseError ? cause.status : axios.isAxiosError(cause) ? cause.response?.status : undefined
|
||||
if ([400, 403, 404, 409, 422].includes(status || 0) || /^(HTTP_|A0?)?(400|403|404|409|422)$/.test(directSaveError.value.code || '')) { directPending.value = null; await loadDirect() }
|
||||
} finally { directSaving.value = false }
|
||||
}
|
||||
const canListCustoms = computed(() => access.can('platform:firstleg:customs:list'))
|
||||
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
|
||||
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
|
||||
@@ -150,6 +283,14 @@ const emptyReturnAt = ref('')
|
||||
const emptyReturnReason = ref('')
|
||||
const emptyReturnSaving = ref(false)
|
||||
const emptyReturnError = ref<ApiErrorDetails | null>(null)
|
||||
const emptyReturnPending = ref<{ body: Parameters<typeof recordTerminalDrayageEmptyReturn>[1]; key: string } | null>(null)
|
||||
async function leaveEmptyReturn() {
|
||||
if (emptyReturnSaving.value || emptyReturnPending.value) { ElMessage.warning('还空登记结果尚未确定,请重试确认结果'); return false }
|
||||
if (!emptyReturnOpen.value || (!emptyReturnAt.value && !emptyReturnReason.value)) return true
|
||||
try { await ElMessageBox.confirm('尚有未保存的还空登记,确认放弃?', '未保存的登记', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
}
|
||||
async function closeEmptyReturn(done: () => void) { if (await leaveEmptyReturn()) done() }
|
||||
onBeforeRouteLeave(leaveEmptyReturn)
|
||||
const oceanBookingLinkOpen = ref(false)
|
||||
|
||||
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
|
||||
@@ -264,8 +405,8 @@ function localDateTimeValue() {
|
||||
return now.toISOString().slice(0, 19)
|
||||
}
|
||||
function openEmptyReturn() {
|
||||
if (!canRecordEmptyReturn.value || emptyReturnSaving.value) return
|
||||
emptyReturnAt.value = localDateTimeValue()
|
||||
if (!canRecordEmptyReturn.value || emptyReturnSaving.value || emptyReturnPending.value) return
|
||||
emptyReturnAt.value = ''
|
||||
emptyReturnReason.value = ''
|
||||
emptyReturnError.value = null
|
||||
emptyReturnOpen.value = true
|
||||
@@ -275,18 +416,16 @@ async function confirmEmptyReturn() {
|
||||
if (!current || !canRecordEmptyReturn.value || !emptyReturnAt.value || emptyReturnSaving.value) return
|
||||
emptyReturnSaving.value = true
|
||||
emptyReturnError.value = null
|
||||
emptyReturnPending.value ||= { body: { expectedVersion: current.version, actualAt: emptyReturnAt.value, remark: emptyReturnReason.value.trim() || undefined }, key: crypto.randomUUID() }
|
||||
try {
|
||||
await recordTerminalDrayageEmptyReturn(containerId.value, {
|
||||
expectedVersion: current.version,
|
||||
actualAt: emptyReturnAt.value,
|
||||
remark: emptyReturnReason.value.trim() || undefined,
|
||||
})
|
||||
await recordTerminalDrayageEmptyReturn(containerId.value, emptyReturnPending.value.body, emptyReturnPending.value.key)
|
||||
emptyReturnPending.value = null
|
||||
emptyReturnOpen.value = false
|
||||
ElMessage.success('还柜已确认')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '确认还柜失败')
|
||||
if (details.code === 'A0409') await load()
|
||||
if (isDefiniteRejection(cause)) { emptyReturnPending.value = null; await load() }
|
||||
emptyReturnError.value = details
|
||||
} finally { emptyReturnSaving.value = false }
|
||||
}
|
||||
@@ -505,15 +644,29 @@ async function completeClearance() {
|
||||
finally { clearanceSaving.value = false }
|
||||
}
|
||||
|
||||
function goBack() { void router.push({ name: isDomestic.value ? 'domestic-container-workbench' : 'overseas-container-workbench' }) }
|
||||
function goBack() { void router.push(appointmentReturnTarget(route.query) || { name: isDomestic.value ? 'domestic-container-workbench' : 'overseas-container-workbench' }) }
|
||||
|
||||
onMounted(async () => { await load(); if (isOverseas.value && route.query.action === 'documents') openDocument('DELIVERY_ORDER') })
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
if (!isOverseas.value) return
|
||||
if (route.query.action === 'documents') openDocument('DELIVERY_ORDER')
|
||||
if (route.query.action === 'pickup') await openTerminal()
|
||||
if (route.query.action === 'direct-unload') {
|
||||
await openDirect()
|
||||
const selected = directRows.value.find(row => row.requirementKey === route.query.requirementKey)
|
||||
if (selected) await selectDirect(selected)
|
||||
}
|
||||
if (route.query.action === 'empty-return') {
|
||||
if (canRecordEmptyReturn.value) openEmptyReturn()
|
||||
else ElMessage.warning('当前还空资格已变化,请核对卸货事实和当前操作权限。')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="container-detail-page admin-density">
|
||||
<section class="container-overview surface-panel">
|
||||
<header class="container-overview__header"><el-button link class="density-back" type="primary" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1>
|
||||
<header class="container-overview__header"><el-button link class="density-back" type="primary" @click="goBack"><ArrowLeft :size="16" />{{ appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表' }}</el-button><div><h1>{{ title }}</h1>
|
||||
<!-- <p>{{ isDomestic ? '送柜、装柜、出口报关与国内出仓' : '海运节点、目的港清关、提柜与还空' }}</p> -->
|
||||
</div><StatusBadge v-if="detail" :status="primaryStatusText" /></header>
|
||||
<div v-if="detail" class="container-overview__summary"><div><span>柜次号</span><strong>{{ detail.cabinetNo || '—' }}</strong></div><div><span>集装箱号</span><strong>{{ detail.containerNo || '待补录' }}</strong></div><div><span>{{ isDomestic ? '装柜清单号' : '当前海外阶段' }}</span><strong>{{ isDomestic ? (detail.planNo || '—') : primaryStatusText }}</strong></div><div><span>{{ isDomestic ? '柜型' : '起运仓' }}</span><strong>{{ isDomestic ? (detail.containerType || '—') : (detail.warehouseName || '—') }}</strong></div><div><span>{{ isDomestic ? '装柜仓库' : '目的仓 / 接柜仓' }}</span><strong :title="isDomestic ? detail.warehouseName || '—' : overseasDestinationText">{{ isDomestic ? (detail.warehouseName || '—') : overseasDestinationText }}</strong></div></div>
|
||||
@@ -570,7 +723,7 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
||||
</section>
|
||||
|
||||
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-overseas-receiving-heading">
|
||||
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></header>
|
||||
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canReadDirect" @click="openDirect">直送卸货清单</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
|
||||
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ formatTime(arrivalTime?.estimatedWarehouseArrivalAt) }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
|
||||
</section>
|
||||
|
||||
@@ -593,10 +746,35 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
||||
</template>
|
||||
|
||||
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
|
||||
<AppDialog class="admin-density-dialog" v-model="emptyReturnOpen" title="确认还柜" width="min(520px, calc(100vw - 28px))" confirm-text="确认还柜" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form" label-position="top"><el-form-item label="实际还柜时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="emptyReturnOpen" title="确认还柜" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form class="density-form" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入异常原因或取消备注" /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form class="density-form" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="directOpen" title="直送卸货登记" width="1080px" :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
|
||||
<p>按真实装柜来源逐票登记签收;全部来源完成后由服务端核验还空资格。海外仓接仓仍使用原仓库收货流程。</p>
|
||||
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
|
||||
<p v-if="directLoading">正在读取实际装柜来源…</p>
|
||||
<table v-if="!directError && !directLoading" class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table>
|
||||
<p v-if="!directLoading && !directError && !directRows.length">当前没有可登记的直送来源。</p>
|
||||
<template v-if="directSelected"><h3>{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="top" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><el-form-item label="实际卸货当地时间" required><el-date-picker v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="地点时区(IANA)" required><el-input v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item><el-form-item label="签收人" required><el-input v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item><el-form-item label="真实签收凭据号" required><el-input v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item><el-form-item label="备注"><el-input v-model="directForm.remark" type="textarea" maxlength="1000" placeholder="请输入卸货备注(选填)" /></el-form-item></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></template>
|
||||
<LoadErrorState v-if="directSaveError" title="卸货登记未完成" :message="directSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="directSaveError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="terminalOpen" title="提柜安排与执行" width="780px" :show-confirm="false" :before-close="closeTerminal">
|
||||
<LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState>
|
||||
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
|
||||
<template v-if="terminal"><p>{{ terminal.containerNo || title }} · 当前车队:{{ terminal.fleetSupplierName || '尚未安排' }}</p><p>实际提柜:{{ terminal.actualPickupAt || '尚未登记' }}。时间沿用原业务当地时间。</p>
|
||||
<el-form class="density-form" label-position="top" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError">
|
||||
<el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="visible => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item>
|
||||
<LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
|
||||
<el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||
<template v-if="canRecordPickup"><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" placeholder="请输入实际提柜地点代码(选填)" /></el-form-item><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" placeholder="请输入实际提柜地点名称(选填)" /></el-form-item></template>
|
||||
<el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item>
|
||||
</el-form>
|
||||
<div class="density-actions"><el-button v-if="canSelectFleet" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'fleet')" :loading="terminalSaving" @click="saveTerminal('fleet')">{{ terminalPending?.kind === 'fleet' ? '重试本次车队保存' : '保存码头车队' }}</el-button><el-button v-if="canSchedulePickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'appointment')" :loading="terminalSaving" @click="saveTerminal('appointment')">{{ terminalPending?.kind === 'appointment' ? '重试本次预约保存' : '保存提柜预约' }}</el-button></div>
|
||||
<el-button v-if="canRecordPickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'actual')" :loading="terminalSaving" @click="saveTerminal('actual')">{{ terminalPending?.kind === 'actual' ? '重试本次实际提柜登记' : '登记实际提柜' }}</el-button>
|
||||
<LoadErrorState v-if="terminalSaveError" title="提柜操作未完成" :message="terminalSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="terminalSaveError" /></template></LoadErrorState>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
|
||||
import { inquiryReturnTarget } from '../inquiryNavigation'
|
||||
import TripDispatchSelector from '../components/TripDispatchSelector.vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
@@ -186,7 +187,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车次详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push(inquiryReturnTarget(route.query) || { name: 'overseas-master-data-trips' })">{{ inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回车次列表' }}</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push(appointmentReturnTarget(route.query) || inquiryReturnTarget(route.query) || { name: 'overseas-master-data-trips' })">{{ appointmentReturnTarget(route.query) ? '返回预约工作' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回车次列表' }}</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
|
||||
<LoadErrorState v-if="error" title="车次详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="operationError" title="操作未完成" :message="operationError.message" @retry="load"><template #details><ApiErrorMeta :details="operationError" /></template></LoadErrorState>
|
||||
<div v-loading="loading"><template v-if="detail">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const tripQuery = vi.hoisted(() => ({ tab: '' }))
|
||||
const tripQuery = vi.hoisted(() => ({ tab: '', appointmentReturn: '' }))
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -40,12 +40,19 @@ function mount(component: object, props: Record<string, unknown> = {}): VueWrapp
|
||||
}
|
||||
function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); state.writable = true; tripQuery.tab = ''
|
||||
vi.clearAllMocks(); state.writable = true; tripQuery.tab = ''; tripQuery.appointmentReturn = ''
|
||||
vi.mocked(api.listTrips).mockResolvedValue({ records: [structuredClone(trip)], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(api.getTrip).mockResolvedValue(structuredClone(detail))
|
||||
vi.mocked(api.listTripHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
})
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
it('returns from a trip to the exact appointment work context and preserves it while choosing a trip', async () => {
|
||||
tripQuery.appointmentReturn = '/appointment-calendar?workItem=work-1&workPage=3'
|
||||
const list = mount(TripsView); await flushPromises(); await button(list, '详情').trigger('click')
|
||||
expect(state.push).toHaveBeenCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-TEST' }, query: { appointmentReturn: tripQuery.appointmentReturn } })
|
||||
const page = mount(TripDetailView); await flushPromises(); await button(page, '返回预约工作').trigger('click')
|
||||
expect(state.push).toHaveBeenLastCalledWith(tripQuery.appointmentReturn)
|
||||
})
|
||||
|
||||
it('shows mixed signature and return quantities from one lazy completion read', async () => {
|
||||
vi.mocked(api.getTrip).mockResolvedValue({ ...structuredClone(detail), trip: { ...trip, status: 'IN_TRANSIT' } })
|
||||
@@ -79,7 +86,7 @@ it('loads one trip page and opens a real detail route without fetching each row'
|
||||
const wrapper = mount(TripsView); await flushPromises()
|
||||
expect(api.listTrips).toHaveBeenCalledTimes(1); expect(api.getTrip).not.toHaveBeenCalled()
|
||||
await button(wrapper, '详情').trigger('click')
|
||||
expect(state.push).toHaveBeenCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-TEST' } })
|
||||
expect(state.push).toHaveBeenCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-TEST' }, query: {} })
|
||||
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
|
||||
expect(api.listTrips).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }))
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { appointmentReturnTarget, appointmentReturnQuery } from '@/modules/appointment-calendar'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
@@ -15,7 +16,7 @@ import { listTrips, type TripQuery } from '../api/trips'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { tripStatusLabels, type FleetTrip, type TripDetail } from '../tripTypes'
|
||||
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const rows = ref<FleetTrip[]>([]), total = ref(0), page = ref(1), size = ref(20), loading = ref(false), editorOpen = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), filters = ref<QueryModel>({ keyword: '', status: '' })
|
||||
let applied: Omit<TripQuery, 'page' | 'size'> = {}, sequence = 0
|
||||
@@ -39,14 +40,14 @@ function search() {
|
||||
applied = { keyword: String(filters.value.keyword || '').trim() || undefined, status: (filters.value.status || undefined) as TripQuery['status'] }
|
||||
if (page.value !== 1) page.value = 1; else void load()
|
||||
}
|
||||
function view(tripNo: string) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo } }) }
|
||||
function view(tripNo: string) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo }, query: appointmentReturnQuery(route.query) }) }
|
||||
function created(detail: TripDetail) { view(detail.trip.tripNo) }
|
||||
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density"><ListPageLayout aria-label="车次管理">
|
||||
<DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-trips-table" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="tripNo" empty-text="暂无车次,先建立计划再关联排车单">
|
||||
<template #toolbar-start><h1>车次管理</h1></template>
|
||||
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>车次管理</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车次查询" preference-key="fleet-trips-query" :loading="loading" @search="search" @reset="search" /></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="车次加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.tripCreate)" type="primary" @click="editorOpen = true">新建车次</el-button></template>
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } from './dispatches'
|
||||
import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch, getBatchDispatchCandidates } from './dispatches'
|
||||
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
|
||||
|
||||
describe('dispatch overseas API', () => {
|
||||
it('reads an immutable batch and preserves its exact per-source allocations on grouped submission', async () => {
|
||||
const allocation = { planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:9', customerId: '9007199254740999', sourceKey: 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 12 }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { batchKey: allocation.batchKey, version: 4, candidates: [], blockReasons: [] } })
|
||||
await getBatchDispatchCandidates(allocation.batchKey)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-planning/batches/FBA_APPOINTMENT%3A9/dispatch-candidates')
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [] })
|
||||
const request = { draftToken: 'draft-1', groups: [{ draftContainerId: 'container-1', planPalletIds: [allocation.planPalletId], returnReceiptIds: [], batchAllocations: [allocation] }] }
|
||||
await preArrangeDraftDispatch(request)
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/pre-arrange', request)
|
||||
})
|
||||
it('submits all explicit draft groups once and preserves container and source identities', async () => {
|
||||
const payload = { draftToken: 'original-draft', groups: [
|
||||
{ draftContainerId: '190000000000000099', planPalletIds: ['11', '13'], returnReceiptIds: [] },
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface DispatchDraftContainerDto { id: string; capacity: number; palle
|
||||
export interface DispatchDraftDto { draftToken: string; containers?: DispatchDraftContainerDto[] }
|
||||
|
||||
export interface PreArrangeDispatchPayload {
|
||||
batchAllocations?: DispatchBatchAllocation[]
|
||||
planPalletIds: string[]
|
||||
returnReceiptIds?: string[]
|
||||
fbaAppointmentNo?: string
|
||||
@@ -76,6 +77,7 @@ export interface PreArrangeDispatchPayload {
|
||||
}
|
||||
|
||||
export interface PreArrangeDispatchDraftGroup {
|
||||
batchAllocations?: DispatchBatchAllocation[]
|
||||
draftContainerId: string
|
||||
planPalletIds: string[]
|
||||
returnReceiptIds: string[]
|
||||
@@ -88,6 +90,12 @@ export interface PreArrangeDispatchDraftResult {
|
||||
draftContainerId: string
|
||||
dispatch: DispatchSummaryDto
|
||||
}
|
||||
export interface DispatchBatchAllocation { planPalletId: string; batchKey: string; customerId: string; sourceKey: string; destinationKey: string; packageCount: number }
|
||||
export interface BatchDispatchCandidate { planPalletId: string; palletNo: string; planId: string; planNo: string; customerId: string; sourceStage: string; packageCount: number | null; allocations: DispatchBatchAllocation[]; blockReasons: string[] }
|
||||
export interface BatchDispatchCandidates { batchKey: string; version: number; candidates: BatchDispatchCandidate[]; blockReasons: string[] }
|
||||
export async function getBatchDispatchCandidates(batchKey: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<BatchDispatchCandidates>, ApiEnvelope<BatchDispatchCandidates>>(`/platform/v1/last-leg/appointment-planning/batches/${encodeURIComponent(batchKey)}/dispatch-candidates`))
|
||||
}
|
||||
|
||||
export interface DispatchSummaryDto {
|
||||
workflow?: DispatchWorkflow | null
|
||||
|
||||
@@ -91,7 +91,11 @@ export interface FbaAppointmentDto {
|
||||
appointmentRemark?: string | null
|
||||
selectedCount: number
|
||||
fbaWarehouseCode: string
|
||||
items: Array<{ customerId: string; id: string; sourceKey: string; sourceVersion?: string | null; orderId?: string | null; inboundCode?: string | null; fbaWarehouseCode: string; packageCount?: number | null }>
|
||||
batchKey?: string
|
||||
replacementBatchKeys?: string[]
|
||||
replacementAppointmentNos?: string[]
|
||||
repartitionReason?: string | null
|
||||
items: Array<{ customerId: string; id: string; sourceKey: string; sourceVersion?: string | null; orderId?: string | null; inboundCode?: string | null; fbaWarehouseCode: string; packageCount?: number | null; cargoVerificationVersion?: number | null; shipmentReferences?: string[]; destinationKey?: string | null }>
|
||||
}
|
||||
|
||||
export interface UpdateFbaAppointmentPayload { details: FbaAppointmentDetailsPayload; version: number; idempotencyKey: string }
|
||||
|
||||
@@ -6,3 +6,8 @@ export type { FbaAppointmentDto, BatchFbaAppointmentPayload, UpdateFbaAppointmen
|
||||
export { default as FbaAppointmentFields } from './components/FbaAppointmentFields.vue'
|
||||
export { createAppointmentForm, appointmentPayload, appointmentFormError } from './appointmentForm'
|
||||
export { overseasWarehousePermissions } from './permissions'
|
||||
export { getDispatch, getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft } from './api/dispatches'
|
||||
export type { BatchDispatchCandidate, BatchDispatchCandidates, DispatchBatchAllocation, DispatchSummaryDto, PreArrangeDispatchDraftPayload } from './api/dispatches'
|
||||
export { dispatchVehicleSpecs } from './dispatchVehicleSpecs'
|
||||
export { getContainerPlanProgress, getContainerInboundPlanPreview, convertLastMileContainerToInboundPlans } from './api/lastMileOrders'
|
||||
export type { ContainerPlanProgressDetailDto, ContainerInboundPlanPreviewDto, ConvertContainerInboundPlanPayload } from './api/lastMileOrders'
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock('@/modules/warehouses', () => ({
|
||||
}))
|
||||
|
||||
const push = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push }), useRoute: () => ({ query: {} }) }))
|
||||
const canPermission = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
|
||||
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), removeDispatchItem: vi.fn(), getDispatchBolPdf: vi.fn(), getDispatchExternalFreight: vi.fn(), getDispatchFeeCoverage: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { appointmentReturnTarget, appointmentReturnQuery } from '@/modules/appointment-calendar'
|
||||
import { TripPreparationDialog, TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -24,7 +25,7 @@ import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { dispatchVehicleSizeOptions } from '../dispatchVehicleSpecs'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute(), router = useRouter()
|
||||
const routeOpen = ref(false)
|
||||
const routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
|
||||
function openRoute(row: DispatchListItem) {
|
||||
@@ -75,10 +76,10 @@ function switchStage(value: typeof stage.value) {
|
||||
}
|
||||
function openTrip(row: DispatchListItem, tab: 'overview' | 'loading') {
|
||||
if (!row.workflow || !access.can('platform:fleet:trip:list')) return
|
||||
void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.workflow.tripNo }, query: { tab } })
|
||||
void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.workflow.tripNo }, query: { tab, ...appointmentReturnQuery(route.query) } })
|
||||
}
|
||||
const filters = ref<QueryModel>({
|
||||
dispatchNo: '',
|
||||
dispatchNo: typeof route.query.dispatchNo === 'string' ? route.query.dispatchNo : '',
|
||||
vehiclePlate: '',
|
||||
status: '',
|
||||
fbaAppointmentNo: '',
|
||||
@@ -665,7 +666,7 @@ onMounted(load)
|
||||
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表">
|
||||
<OperationErrorToast :details="mutationError" /><TripPreparationDialog v-model="prepareOpen" :warehouse-id="prepareWarehouse" :warehouse-name="prepareWarehouseName" :dispatch-nos="prepareNumbers" @saved="load()" />
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="preparationColumns" @selection-change="selectedDispatches = $event" :loading="loading" :total="total" :preference-key="tablePreferenceKey" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><h1>排车列表</h1></template><template #toolbar-end><el-button v-if="canPrepare" type="primary" :disabled="!selectedDispatches.length" @click="beginPreparation">安排车次</el-button></template>
|
||||
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>排车列表</h1></template><template #toolbar-end><el-button v-if="canPrepare" type="primary" :disabled="!selectedDispatches.length" @click="beginPreparation">安排车次</el-button></template>
|
||||
<template #filters><div class="integrated-query-group">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="排车执行阶段">
|
||||
<button v-for="item in stages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }}</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -78,11 +79,11 @@ async function submitMove() {
|
||||
try { const warehouseId = String(detail.value?.overseasWarehouseId || ''); if (!warehouseId) { ElMessage.error('入库单未返回海外仓 ID,暂不能移库'); return }; const result = await moveInboundPallet({ overseasWarehouseId: warehouseId, sourcePalletCode: moveRow.value.palletNo, targetLocationCode: targetLocationCode.value, expectedPalletVersion: moveRow.value.palletVersion, reason: moveReason.value.trim(), idempotencyKey: `admin-inbound-move-${moveRow.value.palletNo}-${Date.now()}` }); if (result.canMove === false) { ElMessage.error(result.blockReason || '当前托盘不可移库'); return }; ElMessage.success('移库完成'); moveOpen.value = false; await load() } catch (error) { ElMessage.error(getApiErrorDetails(error, '移库失败').message) } finally { moveSaving.value = false }
|
||||
}
|
||||
async function load() { if (!planNo.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从入库单列表重新进入' }; return }; loading.value = true; loadError.value = null; try { detail.value = await getInboundPlan(planNo.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '入库单详情加载失败,请重试') } finally { loading.value = false } }
|
||||
function back() { void router.push({ name: 'overseas-inbound-orders' }) }
|
||||
function back() { void router.push(appointmentReturnTarget(route.query) || { name: 'overseas-inbound-orders' }) }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template><OverseasDetailShell class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
|
||||
<template><OverseasDetailShell class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" :back-text="appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表'" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
|
||||
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in summaryGroups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
|
||||
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细</h2><p class="pallet-note">PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></div><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!(row as typeof palletRows[number]).palletNo || !(row as typeof palletRows[number]).palletVersion" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></section>
|
||||
|
||||
@@ -2,8 +2,9 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import InboundOrdersView from './InboundOrdersView.vue'
|
||||
import { getFbaAppointment, updateFbaAppointment } from '../api/inboundOrders'
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({push: vi.fn()}), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
import { getFbaAppointment, updateFbaAppointment, listInboundOrders } from '../api/inboundOrders'
|
||||
const routing = vi.hoisted(() => ({ push: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
const access=vi.hoisted(()=>({canWrite:true}))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({can: (permission:string) => !permission.endsWith(':fba-appointment') || access.canWrite}) }))
|
||||
vi.mock('../api/inboundOrders', () => ({
|
||||
@@ -48,3 +49,22 @@ it('keeps the inbound appointment retry across close, reopen and route attempts'
|
||||
expect((vi.mocked(onBeforeRouteLeave).mock.calls[0]![0] as () => boolean)()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('renders MULTIPLE as multiple appointments and opens the exact source/customer instead of choosing an arbitrary appointment', async () => {
|
||||
vi.mocked(listInboundOrders).mockResolvedValueOnce({records:[{lineId:'1',planId:'plan-1',planNo:'IP-1',orderId:'9007199254740993',customerId:'customer-a',appointmentNo:null,appointmentStatus:'MULTIPLE',fbaWarehouseCode:'ONT8'}],total:1,page:1,size:10,pages:1})
|
||||
const wrapper = render(); await flushPromises()
|
||||
const button = wrapper.findAll('button').find(button => button.text() === '管理多批预约')!
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '查看预约' || button.text() === '预约')).toBe(false)
|
||||
await button.trigger('click'); await flushPromises()
|
||||
expect(routing.push).toHaveBeenCalledWith({path:'/appointment-calendar',query:{orderId:'9007199254740993',customerId:'customer-a',destinationKey:'FBA:ONT8'}})
|
||||
expect(getFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('keeps replaced appointment snapshots read-only in the inbound dialog', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValueOnce({id:'1',appointmentNo:'AP-1',batchKey:'FBA_APPOINTMENT:1',replacementBatchKeys:['FBA_APPOINTMENT:2'],replacementAppointmentNos:['AP-NEW'],version:3,originWarehouseId:'100',fbaWarehouseCode:'ONT8',appointmentStatus:'PENDING',selectedCount:1,items:[]})
|
||||
const wrapper=render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button=>button.text()==='预约')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('替代预约:AP-NEW')
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||
wrapper.findComponent({name:'AppDialog'}).vm.$emit('confirm'); await flushPromises()
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ 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: 'appointmentStatus', type: 'select', label: '预约状态', width: 145, placeholder: '全部预约状态', options: [{ label: '未预约', value: 'UNAPPOINTED' }, { label: '预约中', value: 'PENDING' }, { label: '预约成功', value: 'SUCCESS' }, { label: '预约失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }, { label: '多批预约', value: 'MULTIPLE' }] },
|
||||
{ key: 'dispatchStatus', type: 'select', label: '排车状态', width: 140, placeholder: '全部排车状态', options: [{ label: '未排车', value: 'UNSCHEDULED' }, { label: '已排车', value: 'SCHEDULED' }] },
|
||||
{ key: 'inboundDate', type: 'daterange', label: '入库时间', width: 260 },
|
||||
])
|
||||
@@ -59,6 +59,7 @@ 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 appointmentReplacementNos = ref<string[]>([])
|
||||
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[]>([])
|
||||
@@ -103,7 +104,7 @@ function receivingWarehouse(code: unknown, name: unknown) {
|
||||
}
|
||||
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 appointmentStatusLabel(value: unknown) { return !value ? '未预约' : ({ PENDING: '预约中', SUCCESS: '预约成功', FAILED: '预约失败', CANCELLED: '已取消', MULTIPLE: '多批预约' } as Record<string, string>)[String(value)] || text(value) }
|
||||
|
||||
function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
|
||||
return {
|
||||
@@ -122,6 +123,7 @@ function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
|
||||
function onSelectionChange(selection: object[]) { selectedRows.value = selection as InboundOrderListItem[] }
|
||||
function selectionError() {
|
||||
if (!selectedRows.value.length) return '请至少选择 1 条入库单'
|
||||
if (selectedRows.value.some(row => row.appointmentStatus === '多批预约')) return '所选货物已有多批预约,请从“管理多批预约”逐批处理,不能重新按整票创建'
|
||||
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 仓库'
|
||||
@@ -131,6 +133,7 @@ function selectionError() {
|
||||
function openAppointment() {
|
||||
if (!access.can(permission.inboundOrderAppointment) || !canLeaveAppointment()) return
|
||||
appointmentViewOnly.value = false
|
||||
appointmentReplacementNos.value = []
|
||||
const error = selectionError()
|
||||
if (error) { ElMessage.warning(error); return }
|
||||
appointmentMode.value = 'create'; editingAppointmentNo.value = ''
|
||||
@@ -148,12 +151,15 @@ function openSmartDispatch() {
|
||||
async function openAppointmentEdit(source: object, readOnly = false) {
|
||||
if (!canLeaveAppointment()) return
|
||||
const row = source as InboundOrderListItem
|
||||
if (row.appointmentStatus === '多批预约') { openMultipleAppointments(row); return }
|
||||
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)
|
||||
appointmentReplacementNos.value = detail.replacementAppointmentNos || []
|
||||
appointmentViewOnly.value = readOnly || detail.appointmentStatus === 'CANCELLED' || !!appointmentReplacementNos.value.length || !!detail.replacementBatchKeys?.length
|
||||
appointmentSelectedCount.value = detail.selectedCount
|
||||
appointmentFbaWarehouse.value = detail.fbaWarehouseCode || row.fbaWarehouse
|
||||
appointmentVersion.value = detail.version
|
||||
@@ -163,6 +169,11 @@ async function openAppointmentEdit(source: object, readOnly = false) {
|
||||
mutationError.value = getApiErrorDetails(caught, 'FBA 预约详情加载失败,请重试')
|
||||
} finally { appointmentLoading.value = false }
|
||||
}
|
||||
function openMultipleAppointments(row: InboundOrderListItem) {
|
||||
if (!canLeaveAppointment() || !access.can('platform:appointment:list')) return
|
||||
if (!row.orderId || !row.customerId || !row.fbaWarehouse || row.fbaWarehouse === '—') { ElMessage.warning('原货源或客户范围缺失,请重新读取入库单'); return }
|
||||
void router.push({ path: '/appointment-calendar', query: { orderId: row.orderId, customerId: row.customerId, destinationKey: `FBA:${row.fbaWarehouse}` } })
|
||||
}
|
||||
|
||||
function canLeaveAppointment() {
|
||||
if (saving.value || pendingAppointment.value) { ElMessage.warning('请先核实本次预约保存结果,再关闭或切换'); return false }
|
||||
@@ -224,14 +235,14 @@ onMounted(() => { void load() })
|
||||
|
||||
|
||||
<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 #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).appointmentStatus !== '多批预约' && (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).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button><el-button v-if="(row as InboundOrderListItem).appointmentStatus === '多批预约' && access.can('platform:appointment:list')" class="density-action" @click="openMultipleAppointments(row as InboundOrderListItem)">管理多批预约</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" />
|
||||
<p v-if="appointmentReplacementNos.length">原批次已被替代,当前只读。替代预约:{{ appointmentReplacementNos.join('、') }}</p><FbaAppointmentFields :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="appointmentReadOnly || appointmentLoading || saving || !!pendingAppointment" />
|
||||
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
Reference in New Issue
Block a user