Compare commits
17
Commits
dev
...
8dc0d2d3b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dc0d2d3b8 | ||
|
|
538970797d | ||
|
|
60378fea9b | ||
|
|
f9193a3564 | ||
|
|
db1e9ee27b | ||
|
|
b769d2479c | ||
|
|
af2e1481a6 | ||
|
|
092690791d | ||
|
|
9a10fb6657 | ||
|
|
68284ad2bf | ||
|
|
5e8c17c82f | ||
|
|
50b06dca10 | ||
|
|
11e348a29f | ||
|
|
08fac66fe1 | ||
|
|
8663a829e2 | ||
|
|
2975ba5a7f | ||
|
|
2ade6bfa3c |
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeJwtClaims } from './jwt'
|
||||
|
||||
function token(payload: string) {
|
||||
return `header.${Buffer.from(payload).toString('base64url')}.signature`
|
||||
}
|
||||
|
||||
describe('JWT claim decoding', () => {
|
||||
it.each(['2110664514823360515', '"2110664514823360515"'])('preserves the exact account ID encoded as %s', userId => {
|
||||
const claims = decodeJwtClaims(token(`{"user_id":${userId},"app_code":"PLATFORM","exp":1792060000,"iat":1792058200}`))
|
||||
expect(claims.user_id).toBe('2110664514823360515')
|
||||
expect(claims.exp).toBe(1792060000)
|
||||
expect(claims).toHaveProperty('iat', 1792058200)
|
||||
})
|
||||
|
||||
it('retains safe numeric IDs and ordinary claims', () => {
|
||||
expect(decodeJwtClaims(token('{"user_id":123,"username":"operator","roles":["ADMIN"],"exp":1}')))
|
||||
.toEqual({ user_id: 123, username: 'operator', roles: ['ADMIN'], exp: 1 })
|
||||
})
|
||||
|
||||
it.each(['', 'header..signature'])('rejects a missing payload: %s', value => {
|
||||
expect(() => decodeJwtClaims(value)).toThrow('登录响应中的 accessToken 格式无效')
|
||||
})
|
||||
|
||||
it.each(['header.%.signature', token('{"user_id":2110664514823360515,')])('rejects malformed encoding or JSON: %s', value => {
|
||||
expect(() => decodeJwtClaims(value)).toThrow('登录响应中的 accessToken 无法解析')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { parseJsonWithSafeIntegers } from '@chongchong/shared'
|
||||
|
||||
export interface PlatformJwtClaims {
|
||||
user_id?: string | number
|
||||
username?: string
|
||||
@@ -19,7 +21,7 @@ export function decodeJwtClaims(token: string): PlatformJwtClaims {
|
||||
const payload = token.split('.')[1]
|
||||
if (!payload) throw new Error('登录响应中的 accessToken 格式无效')
|
||||
try {
|
||||
return JSON.parse(decodeBase64Url(payload)) as PlatformJwtClaims
|
||||
return parseJsonWithSafeIntegers(decodeBase64Url(payload)) as PlatformJwtClaims
|
||||
} catch {
|
||||
throw new Error('登录响应中的 accessToken 无法解析')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
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()
|
||||
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)
|
||||
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'})
|
||||
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()
|
||||
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)
|
||||
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('does not request pickup records without pickup list permission and discloses the omitted source', async () => {
|
||||
routing.canPickup=false
|
||||
it('returns from a prerequisite to the original item and preserves the original undated list context', async () => {
|
||||
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)
|
||||
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('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'}])
|
||||
it('goes directly to the current container unfinished list after route confirmation', async () => {
|
||||
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)
|
||||
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('omits trip requests without trip list permission and keeps other sources usable', async () => {
|
||||
routing.canTrips=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(listTripCalendar).not.toHaveBeenCalled();expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('无车次查看权限')
|
||||
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()
|
||||
})
|
||||
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'))
|
||||
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()
|
||||
})
|
||||
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 } })
|
||||
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})
|
||||
|
||||
})
|
||||
|
||||
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('目的地已变化'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'AppointmentFollowupDialog' }).exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('预约货源核对失败')
|
||||
})
|
||||
|
||||
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
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'AppointmentFollowupDialog' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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, '可预约货物加载失败')
|
||||
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 }
|
||||
}
|
||||
} finally { if (request === candidateRequest) candidateLoading.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 }
|
||||
}
|
||||
|
||||
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
|
||||
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()
|
||||
}
|
||||
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
|
||||
}
|
||||
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 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()
|
||||
}
|
||||
|
||||
function selectCandidates(rows: object[]) {
|
||||
selected.value = rows as InboundOrderSummaryDto[]
|
||||
async function closePanel() {
|
||||
if (!await canLeave()) return
|
||||
scope.value = null; detailKey.value = null; initialAction.value = null; sourceLink.value = null; origins.value = []; ++itemsRequest; itemsController?.abort()
|
||||
}
|
||||
|
||||
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 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()])
|
||||
}
|
||||
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 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 cancelled() {
|
||||
cancellationTarget.value = null; ++cancellationsRevision.value
|
||||
ElMessage.success('预约已取消,可在取消记录中查看原因与原记录')
|
||||
await Promise.all([load(),loadUnscheduled()])
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
watch(dialogOpen, open => {
|
||||
if (!open) { ++candidateRequest; ++detailRequest; ++warehouseRequest; candidateLoading.value = false; warehouseLoading.value = false; detailLoading.value = false; detailReady.value = false }
|
||||
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>
|
||||
<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 #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>
|
||||
</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>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</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,73 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import WorkCommandForm from './WorkCommandForm.vue'
|
||||
import { submitWorkCommand } from './workApi'
|
||||
import { followAction, workDetail, workItem } from './work.test-support'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
|
||||
vi.mock('@/core/auth/platformAuthorization', () => ({ listCurrentPlatformPermissionCodes: vi.fn().mockResolvedValue(['platform:appointment:followup']) }))
|
||||
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('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), submitWorkCommand: vi.fn() }))
|
||||
|
||||
function token(userId: string, exp = Math.floor(Date.now() / 1000) + 1800) {
|
||||
// Keep the numeric JSON literal intact; constructing a JS number would lose precision before decoding.
|
||||
const payload = `{"user_id":${userId},"username":"test_operator","app_code":"PLATFORM","exp":${exp}}`
|
||||
return `header.${Buffer.from(payload).toString('base64url')}.signature`
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const values = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
})
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(submitWorkCommand).mockResolvedValue({ item: workItem(), affectedWorkItemKeys: [] })
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
it.each([
|
||||
['login', '2110664514823360515'], ['login', '"2110664514823360515"'],
|
||||
['restore', '2110664514823360515'], ['restore', '"2110664514823360515"'],
|
||||
])('preserves the %s session ID through the real follow-up form for JSON %s', async (mode, userId) => {
|
||||
const accessToken = token(userId)
|
||||
if (mode === 'restore') {
|
||||
localStorage.setItem('cc-admin-token', accessToken)
|
||||
localStorage.setItem('cc-admin-permission-codes', '[]')
|
||||
}
|
||||
const auth = useAuthStore()
|
||||
if (mode === 'login') {
|
||||
vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { accessToken, refreshToken: 'test-refresh', expiresIn: 1800, refreshExpiresIn: 604800 } })
|
||||
await auth.login('test_operator', 'test-password')
|
||||
}
|
||||
expect(auth.user?.id).toBe('2110664514823360515')
|
||||
expect(auth.isAuthenticated).toBe(true)
|
||||
const wrapper = mount(WorkCommandForm, {
|
||||
props: { detail: workDetail({ item: workItem({ owner: null }) }), action: followAction, refreshing: false },
|
||||
global: {
|
||||
provide: { 'appointment-work-leave-guard': () => () => undefined },
|
||||
stubs: { ElButton: { template: '<button><slot /></button>' }, LoadErrorState: true, ApiErrorMeta: true },
|
||||
},
|
||||
})
|
||||
try {
|
||||
expect((wrapper.get('select').element as HTMLSelectElement).value).toBe('2110664514823360515')
|
||||
await wrapper.get('textarea').setValue('已联系负责人')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({
|
||||
command: 'SAVE_FOLLOWUP', payload: { ownerId: '2110664514823360515', followupDate: '2026-10-15', note: '已联系负责人' },
|
||||
}))
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it.each([token('2110664514823360515', 1), 'header.%.signature'])('does not restore an expired or malformed token: %s', accessToken => {
|
||||
localStorage.setItem('cc-admin-token', accessToken)
|
||||
const auth = useAuthStore()
|
||||
expect(auth.user).toBeNull()
|
||||
expect(auth.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('cc-admin-token')).toBeNull()
|
||||
})
|
||||
@@ -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,55 @@
|
||||
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('shows readable preview requirements without exposing missing-field machine codes', async () => {
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({ ...preview, missingFields: ['READY_WINDOW_UNVERIFIED', 'CARGO_QUANTITY_REVIEW'] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('货物备妥窗口待核实')
|
||||
expect(wrapper.text()).toContain('货量或重复关联需要核对。')
|
||||
expect(wrapper.text()).not.toContain('READY_WINDOW_UNVERIFIED')
|
||||
expect(wrapper.text()).not.toContain('CARGO_QUANTITY_REVIEW')
|
||||
wrapper.unmount()
|
||||
})
|
||||
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,61 @@
|
||||
<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'
|
||||
import { workReason } from './workPresentation'
|
||||
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">{{ workReason(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,57 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { actionAllowed, actionRoute, referenceRoute, commandName, workDate, workStatus, workReason } from './workPresentation'
|
||||
import { followAction, workItem } from './work.test-support'
|
||||
import type { WorkAction } from './workApi'
|
||||
it.each([
|
||||
['SOURCE_TIMEZONE', '原业务作业地点时区待核实'],
|
||||
['SOURCE_CONFIGURATION', '原货源或仓库配置待核实'],
|
||||
['LASTLEG_SOURCE', '尾程货源待建立'],
|
||||
['READY_WINDOW_UNVERIFIED', '货物备妥窗口待核实'],
|
||||
['CARGO_QUANTITY_REVIEW', '货量或重复关联需要核对。'],
|
||||
])('shows readable text for work item reason %s', (code, expected) => {
|
||||
expect(workReason(code)).toBe(expected)
|
||||
})
|
||||
it('preserves the server explanation for an overdue or blocked work item', () => {
|
||||
expect(workReason('作业日期已过,尚未完成')).toBe('作业日期已过,尚未完成')
|
||||
expect(workReason('实际提柜后才能记录直送卸货')).toBe('实际提柜后才能记录直送卸货')
|
||||
})
|
||||
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,70 @@
|
||||
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) {
|
||||
switch (reason) {
|
||||
case 'SOURCE_TIMEZONE': return '原业务作业地点时区待核实'
|
||||
case 'SOURCE_CONFIGURATION': return '原货源或仓库配置待核实'
|
||||
case 'LASTLEG_SOURCE': return '尾程货源待建立'
|
||||
case 'READY_WINDOW_UNVERIFIED': return '货物备妥窗口待核实'
|
||||
case 'CARGO_QUANTITY_REVIEW': return '货量或重复关联需要核对。'
|
||||
default: return 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: boolean) => 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>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export type KnownExportTaskType =
|
||||
| 'VEHICLE_LIST'
|
||||
| 'FLEET_QUOTE_LIST'
|
||||
| 'TRIP_OPERATIONS_LIST'
|
||||
| 'SELFOP_INVENTORY_TURNOVER'
|
||||
| 'CUSTOMER_LIST'
|
||||
| 'DOMESTIC_WAREHOUSE_ORDER_LIST'
|
||||
| 'FINANCE_CUSTOMER_ACCOUNT_LIST'
|
||||
|
||||
@@ -34,6 +34,7 @@ const exportTypeMeta: Record<string, { label: string; retryPermission: string }>
|
||||
VEHICLE_LIST: { label: '车辆', retryPermission: 'platform:overseas-master-data:vehicle:export' },
|
||||
FLEET_QUOTE_LIST: { label: '车队报价', retryPermission: 'platform:fleet:quote:export' },
|
||||
TRIP_OPERATIONS_LIST: { label: '出车记录', retryPermission: 'platform:fleet:operations:export' },
|
||||
SELFOP_INVENTORY_TURNOVER: { label: '库存周转', retryPermission: 'platform:self-operated-analytics:export' },
|
||||
FINANCE_RECEIVABLE_STATISTICS: { label: '应收统计', retryPermission: 'platform:finance:receivable-statistics:export' },
|
||||
LOADING_LIST_BUY_DECLARATION_ITEMS: { label: '买单报关明细', retryPermission: 'platform:firstleg:customs:manage' },
|
||||
CUSTOMER_LIST: { label: '客户列表', retryPermission: 'platform:customer:export' },
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('download center export submission', () => {
|
||||
expect(showExportTaskCreatedDialog).not.toHaveBeenCalled()
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each([["overseas-warehouse/pages/LastMileOrdersView.vue","LAST_MILE_ORDER_LIST","platform:overseas-warehouse:last-mile-order:export"],["overseas-warehouse/pages/InventoryView.vue","OVERSEAS_INVENTORY_BALANCE","platform:overseas-warehouse:inventory:export"],["overseas-warehouse/pages/InventoryTransactionsView.vue","OVERSEAS_INVENTORY_LEDGER","platform:overseas-warehouse:inventory:export"],["overseas-master-data/pages/OverseasQuotesView.vue","OVERSEAS_QUOTE_LIST","platform:overseas-master-data:quote:export"],["overseas-master-data/pages/ExchangeRatesView.vue","MONTHLY_EXCHANGE_RATE_LIST","platform:overseas-master-data:exchange-rate:export"],["overseas-master-data/pages/VehiclesView.vue","VEHICLE_LIST","platform:overseas-master-data:vehicle:export"],["overseas-master-data/pages/FleetQuotesView.vue","FLEET_QUOTE_LIST","platform:fleet:quote:export"],["overseas-master-data/pages/TripOperationsView.vue","TRIP_OPERATIONS_LIST","platform:fleet:operations:export"]])('connects %s with the correct type and download-center retry permission', (path, type, permission) => {
|
||||
it.each([["self-operated/components/InventoryTurnoverView.vue","SELFOP_INVENTORY_TURNOVER","platform:self-operated-analytics:export"],["overseas-warehouse/pages/LastMileOrdersView.vue","LAST_MILE_ORDER_LIST","platform:overseas-warehouse:last-mile-order:export"],["overseas-warehouse/pages/InventoryView.vue","OVERSEAS_INVENTORY_BALANCE","platform:overseas-warehouse:inventory:export"],["overseas-warehouse/pages/InventoryTransactionsView.vue","OVERSEAS_INVENTORY_LEDGER","platform:overseas-warehouse:inventory:export"],["overseas-master-data/pages/OverseasQuotesView.vue","OVERSEAS_QUOTE_LIST","platform:overseas-master-data:quote:export"],["overseas-master-data/pages/ExchangeRatesView.vue","MONTHLY_EXCHANGE_RATE_LIST","platform:overseas-master-data:exchange-rate:export"],["overseas-master-data/pages/VehiclesView.vue","VEHICLE_LIST","platform:overseas-master-data:vehicle:export"],["overseas-master-data/pages/FleetQuotesView.vue","FLEET_QUOTE_LIST","platform:fleet:quote:export"],["overseas-master-data/pages/TripOperationsView.vue","TRIP_OPERATIONS_LIST","platform:fleet:operations:export"]])('connects %s with the correct type and download-center retry permission', (path, type, permission) => {
|
||||
const page = readFileSync(new URL('../' + path, import.meta.url), 'utf8')
|
||||
expect(page).toContain("await submitExport('" + type + "'")
|
||||
expect(page).not.toMatch(/saveCsv\(|saveFleetCsv\(/)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
|
||||
export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string }
|
||||
export interface CargoDiscardSummary { id: string; customerId?: string | null; discardNo: string; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null }
|
||||
export interface CargoDiscardItem { id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
|
||||
export interface CargoDiscardSummary { id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null }
|
||||
export interface CargoDiscardItem { cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
|
||||
export interface CargoDiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string }
|
||||
export interface CargoDiscardDetail { photos: CargoDiscardPhoto[]; summary: CargoDiscardSummary; feePerPackage?: number | string | null; remark?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; items: CargoDiscardItem[]; histories: { fromStatus?: string | null; toStatus?: string | null; operatorId?: string | null; remark?: string | null; createdAt?: string | null }[] }
|
||||
export interface CargoDiscardCandidate { orderId?: string | null; sourceType: string; returnCartonId?: string | null; cartonMark?: string | null; inboundCode?: string | null; inboundOrderNo?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; availablePackages?: number; skus: { skuCode: string; productName?: string | null; availablePackages?: number }[] }
|
||||
@@ -20,12 +20,6 @@ export async function reviewCargoDiscard(discardNo: string, decision: 'APPROVE'
|
||||
const response = await http.post<ApiEnvelope<CargoDiscardDetail>, ApiEnvelope<CargoDiscardDetail>>(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/review`, { decision, remark }, { params: customerId ? { customerId } : undefined })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function completeCargoDiscard(discardNo: string, customerId?: string) {
|
||||
const response = await http.post<ApiEnvelope<CargoDiscardDetail>, ApiEnvelope<CargoDiscardDetail>>(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/complete`, undefined, { params: customerId ? { customerId } : undefined })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
|
||||
export function downloadCargoDiscardPhoto(discardNo: string, fileId: string, customerId: string) {
|
||||
return http.get<Blob, Blob>(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/photos/files/${encodeURIComponent(fileId)}/download`, { params: { customerId }, responseType: 'blob' })
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { allocateWarehouseTransfer, getWarehouseTransfer, listWarehouseTransfers, reviewWarehouseTransfer, outboundWarehouseTransfer, receiveWarehouseTransfer, listTransferReceivingWarehouses } from './warehouseTransfers'
|
||||
import { allocateWarehouseTransfer, getWarehouseTransferCartonLabel, getWarehouseTransfer, listWarehouseTransfers, reviewWarehouseTransfer, outboundWarehouseTransfer, receiveWarehouseTransfer, listTransferReceivingWarehouses } from './warehouseTransfers'
|
||||
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
describe('warehouseTransfers API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('loads server-rendered carton labels using the source transfer and customer scope', async () => {
|
||||
const label = { transferNo: 'WT/1', cartons: [{ cartonMark: 'BOX-1' }], printableHtml: '<html>BOX-1</html>' }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: label })
|
||||
expect(await getWarehouseTransferCartonLabel('customer-1', 'WT/1')).toEqual(label)
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/warehouse-transfers/WT%2F1/carton-label', { params: { customerId: 'customer-1' } })
|
||||
})
|
||||
it('uses the resource-role receiving options contract, never warehouse master list', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ id: 'NY', customerId: 0, warehouseCode: 'NY', warehouseName: '纽约', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], total: 1, page: 1, size: 200, pages: 1 } })
|
||||
expect((await listTransferReceivingWarehouses()).records[0]?.customerId).toBe('0')
|
||||
|
||||
@@ -35,11 +35,22 @@ export interface WarehouseTransferItemDto {
|
||||
id: string
|
||||
skuCode: string
|
||||
productNameZh: string
|
||||
quantity?: number | string | null
|
||||
packageCount: number
|
||||
shippingMark?: string | null
|
||||
}
|
||||
|
||||
export interface WarehouseTransferCartonDto {
|
||||
items?: Array<{ transferItemId: string; skuCode: string; quantity: number | string }>
|
||||
transferItemId?: string | null
|
||||
transferPalletId?: string | null
|
||||
length?: number | string | null
|
||||
width?: number | string | null
|
||||
height?: number | string | null
|
||||
weight?: number | string | null
|
||||
quantity?: number | string | null
|
||||
countedAt?: string | null
|
||||
discrepancyReason?: string | null
|
||||
cartonMark?: string | null
|
||||
customerCartonMark?: string | null
|
||||
cartonIndex: number
|
||||
@@ -47,6 +58,19 @@ export interface WarehouseTransferCartonDto {
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface WarehouseTransferCartonLabelDto {
|
||||
transferNo: string
|
||||
cartons: WarehouseTransferCartonDto[]
|
||||
printableHtml: string
|
||||
}
|
||||
|
||||
export async function getWarehouseTransferCartonLabel(customerId: string, transferNo: string) {
|
||||
const response = await http.get<ApiEnvelope<WarehouseTransferCartonLabelDto>, ApiEnvelope<WarehouseTransferCartonLabelDto>>(
|
||||
`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/carton-label`, { params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export interface WarehouseTransferRelabelBindingDto {
|
||||
operationType: string
|
||||
customerCartonMark: string
|
||||
@@ -61,6 +85,7 @@ export interface WarehouseTransferRelabelBindingDto {
|
||||
export interface WarehouseTransferPalletDto {
|
||||
id: string
|
||||
sequenceNo: number
|
||||
warehouseLocationCode?: string | null
|
||||
palletNo?: string | null
|
||||
cartonCount: number
|
||||
grossWeightKg: number
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import WarehouseTransferReceiptPanel from './WarehouseTransferReceiptPanel.vue'
|
||||
import { getWarehouseTransfer, getWarehouseTransferCartonLabel, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
|
||||
|
||||
vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn(), getWarehouseTransferCartonLabel: vi.fn() }))
|
||||
enableAutoUnmount(afterEach)
|
||||
const DataTableStub = defineComponent({
|
||||
props: ['data', 'columns'],
|
||||
setup(props, { slots }) { return () => h('div', { class: 'test-table' }, [JSON.stringify(props.data), ...(props.data || []).flatMap((row: object) => [slots.boxAction?.({ row }), slots.quantityDifference?.({ row }), slots.boxDifference?.({ row })])]) },
|
||||
})
|
||||
const DialogStub = defineComponent({ props: ['modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', { 'data-test': 'box-dialog' }, slots.default?.()) : null } })
|
||||
const render = (transferNo = 'WT-SOURCE') => mount(WarehouseTransferReceiptPanel, { props: { customerId: 'CUSTOMER', transferNo }, global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, AppDialog: DialogStub } } })
|
||||
function fixture(): WarehouseTransferDetailDto {
|
||||
return {
|
||||
id: '1', transferNo: 'WT-SOURCE', status: 'WAIT_CUSTOMER_CONFIRMATION', needRelabel: false, packageCount: 4,
|
||||
items: [{ id: '10', skuCode: 'SKU-A', productNameZh: '产品 A', quantity: '120', packageCount: 3 }, { id: '20', skuCode: 'SKU-B', productNameZh: '产品 B', quantity: 30, packageCount: 1 }],
|
||||
cartons: [
|
||||
{ cartonMark: 'BOX-1', cartonIndex: 1, totalCartons: 4, status: 'COUNTED', transferItemId: '10', transferPalletId: '100', quantity: '8', length: 100, width: 20, height: 50, weight: '2.5', countedAt: '2026-10-15T12:00:00' },
|
||||
{ cartonMark: 'BOX-2', cartonIndex: 2, totalCartons: 4, status: 'COUNTED', transferItemId: '10', transferPalletId: '100', quantity: 12, length: 100, width: 20, height: 50, weight: 3.5 },
|
||||
{ cartonMark: 'BOX-3', cartonIndex: 3, totalCartons: 4, status: 'COUNTED', transferItemId: '20', transferPalletId: '200', quantity: 7, length: 100, width: 20, height: 50, weight: 4 },
|
||||
{ cartonMark: 'BOX-MISSING', cartonIndex: 4, totalCartons: 4, status: 'NOT_RECEIVED', transferItemId: '10', quantity: 999, weight: 999 },
|
||||
],
|
||||
pallets: [{ id: '100', sequenceNo: 1, palletNo: 'PALLET-A', cartonCount: 2, grossWeightKg: 6, volumeCbm: .2, status: 'COMPLETED' }, { id: '200', sequenceNo: 2, palletNo: 'PALLET-B', cartonCount: 1, grossWeightKg: 4, volumeCbm: .1, status: 'COMPLETED' }], relabelBindings: [],
|
||||
}
|
||||
}
|
||||
beforeEach(() => { vi.mocked(getWarehouseTransfer).mockReset(); vi.mocked(getWarehouseTransferCartonLabel).mockReset() })
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
describe('转运预报与实际签收明细', () => {
|
||||
it('shows every SKU in a mixed box and counts its physical measurements once', async () => {
|
||||
const value = fixture()
|
||||
value.cartons = [{ ...value.cartons[0]!, quantity: 10, items: [{ transferItemId: '10', skuCode: 'SKU-A', quantity: 3 }, { transferItemId: '20', skuCode: 'SKU-B', quantity: 7 }] }]
|
||||
value.pallets = [value.pallets[0]!]
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.emitted('summary')!.at(-1)![0]).toMatchObject({ boxes: 1, skus: 2, quantity: 10, weight: 2.5, volume: .1 })
|
||||
const comparison = wrapper.findAllComponents(DataTableStub)[0]!.props('data')
|
||||
expect(comparison[0]).toMatchObject({ receivedQuantity: 3, receivedBoxes: 1 })
|
||||
expect(comparison[1]).toMatchObject({ receivedQuantity: 7, receivedBoxes: 1 })
|
||||
expect(wrapper.findAll('.test-table')[1]!.text()).toContain('SKU-A × 3 件;SKU-B × 7 件')
|
||||
await wrapper.findAll('button').find(button => button.text() === '查看箱号明细')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '箱内明细')!.trigger('click')
|
||||
const contents = wrapper.findAll('[data-test="box-dialog"]')[1]!.text()
|
||||
expect(contents).toContain('SKU-A'); expect(contents).toContain('SKU-B')
|
||||
expect(contents).toContain('产品 A'); expect(contents).toContain('产品 B')
|
||||
})
|
||||
it('keeps declarations separate and totals only counted boxes across pallets', async () => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture())
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledWith('CUSTOMER', 'WT-SOURCE')
|
||||
const summary = wrapper.emitted('summary')!.at(-1)![0]
|
||||
expect(summary).toMatchObject({ forecastBoxes: 4, boxes: 3, quantity: 27, weight: 10 })
|
||||
expect((summary as { volume: number }).volume).toBeCloseTo(.3)
|
||||
const rows = wrapper.findAllComponents(DataTableStub)[0]!.props('data')
|
||||
expect(rows[0]).toMatchObject({ quantity: '120', receivedQuantity: 20, receivedBoxes: 2, quantityDifference: -100, boxDifference: -1 })
|
||||
expect(rows[1]).toMatchObject({ receivedQuantity: 7, quantityDifference: -23, boxDifference: 0 })
|
||||
const tables = wrapper.findAll('.test-table')
|
||||
expect(tables[0]!.text()).toContain('120')
|
||||
expect(tables[1]!.text()).toContain('SKU-A × 20 件'); expect(tables[1]!.text()).toContain('SKU-B × 7 件')
|
||||
expect(tables).toHaveLength(2)
|
||||
await wrapper.findAll('button').find(button => button.text() === '查看箱号明细')!.trigger('click')
|
||||
expect(wrapper.findAll('.test-table')[2]!.text()).not.toContain('BOX-MISSING')
|
||||
await wrapper.findAll('button').find(button => button.text() === '箱内明细')!.trigger('click')
|
||||
const box = wrapper.findAll('[data-test="box-dialog"]')[1]!
|
||||
expect(box.text()).toContain('BOX-1'); expect(box.text()).toContain('产品 A'); expect(box.text()).toContain('"quantity":"8"')
|
||||
expect(box.text()).not.toContain('120')
|
||||
})
|
||||
it('shows zero receipts before signing without treating forecast cartons as actual receipts', async () => {
|
||||
const value = fixture(); value.pallets = []; value.cartons.forEach(box => { box.status = 'EXPECTED' })
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.emitted('summary')!.at(-1)![0]).toMatchObject({ boxes: 0, quantity: 0 })
|
||||
expect(wrapper.findAll('.test-table')[0]!.text()).toContain('待签收')
|
||||
expect(wrapper.findAll('.test-table')).toHaveLength(2)
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '查看箱号明细')!.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
it('shows signed positive and zero differences against each forecast row', async () => {
|
||||
const value = fixture(); value.items[0]!.quantity = 10; value.items[0]!.packageCount = 2
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
|
||||
const wrapper = render(); await flushPromises()
|
||||
const table = wrapper.findAll('.test-table')[0]!
|
||||
expect(table.text()).toContain('+10')
|
||||
expect(table.findAll('.receipt-difference').map(cell => cell.text())).toEqual(['+10', '-23'])
|
||||
expect(wrapper.findAllComponents(DataTableStub)[0]!.props('data')[0]).toMatchObject({ quantityDifference: 10, boxDifference: 0 })
|
||||
})
|
||||
it('prints server label HTML with the selected customer and source reference', async () => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture())
|
||||
vi.mocked(getWarehouseTransferCartonLabel).mockResolvedValue({ transferNo: 'WT-SOURCE', cartons: fixture().cartons, printableHtml: '<html>SERVER-BOX-LABEL</html>' })
|
||||
const popup = { opener: window, document: { open: vi.fn(), write: vi.fn(), close: vi.fn() }, focus: vi.fn(), print: vi.fn(), close: vi.fn() }
|
||||
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '打印箱唛')!.trigger('click'); await flushPromises()
|
||||
expect(getWarehouseTransferCartonLabel).toHaveBeenCalledExactlyOnceWith('CUSTOMER', 'WT-SOURCE')
|
||||
expect(popup.document.write).toHaveBeenCalledWith('<html>SERVER-BOX-LABEL</html>')
|
||||
expect(popup.print).toHaveBeenCalledOnce()
|
||||
expect(popup.opener).toBeNull()
|
||||
})
|
||||
it('closes the print window and leaves receipt details available when labels fail', async () => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture())
|
||||
vi.mocked(getWarehouseTransferCartonLabel).mockRejectedValue(new Error('标签暂不可用'))
|
||||
const popup = { close: vi.fn(), print: vi.fn() }
|
||||
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '打印箱唛')!.trigger('click'); await flushPromises()
|
||||
expect(popup.close).toHaveBeenCalledOnce(); expect(popup.print).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('箱唛加载失败,请重试')
|
||||
expect(wrapper.text()).toContain('签收箱号明细(3 箱)')
|
||||
})
|
||||
it('does not request another order when the source reference is missing', async () => {
|
||||
const wrapper = render(''); await flushPromises()
|
||||
expect(getWarehouseTransfer).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('缺少客户或来源转运单号')
|
||||
})
|
||||
it('discards a stale response after switching transfers', async () => {
|
||||
let resolveOld!: (value: WarehouseTransferDetailDto) => void
|
||||
vi.mocked(getWarehouseTransfer).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })).mockResolvedValueOnce({ ...fixture(), transferNo: 'WT-NEW', packageCount: 9 })
|
||||
const wrapper = render(); await wrapper.setProps({ transferNo: 'WT-NEW' }); await flushPromises()
|
||||
resolveOld(fixture()); await flushPromises()
|
||||
expect(wrapper.emitted('summary')!.at(-1)![0]).toMatchObject({ forecastBoxes: 9 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { Eye, Printer } from '@lucide/vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getWarehouseTransfer, getWarehouseTransferCartonLabel, type WarehouseTransferCartonDto, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
|
||||
const props = defineProps<{ customerId: string; transferNo: string }>()
|
||||
const emit = defineEmits<{ summary: [value: { boxes: number; forecastBoxes: number; forecastSkus: number; skus: number; quantity: number; pallets: number; weight: number; volume: number; warehouseId?: string | null } | null] }>()
|
||||
const detail = ref<WarehouseTransferDetailDto | null>(null)
|
||||
const printing = ref(false)
|
||||
const printError = ref<ApiErrorDetails | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const selectedBox = ref<WarehouseTransferCartonDto | null>(null)
|
||||
const boxOpen = ref(false)
|
||||
const cartonsOpen = ref(false)
|
||||
let generation = 0
|
||||
const cartons = computed(() => (detail.value?.cartons || []).filter(box => box.status === 'COUNTED'))
|
||||
function itemFor(box: WarehouseTransferCartonDto) { return detail.value?.items.find(item => box.transferItemId != null && String(item.id) === String(box.transferItemId)) }
|
||||
function boxLines(box: WarehouseTransferCartonDto) {
|
||||
if (box.items?.length) return box.items
|
||||
return [{ transferItemId: box.transferItemId, skuCode: itemFor(box)?.skuCode || '—', quantity: box.quantity || 0 }]
|
||||
}
|
||||
function volume(box: WarehouseTransferCartonDto) { return box.length == null || box.width == null || box.height == null ? null : Number(box.length) * Number(box.width) * Number(box.height) / 1_000_000 }
|
||||
function formatNumber(value: number | null, digits = 0) { return value == null || !Number.isFinite(value) ? '—' : value.toFixed(digits) }
|
||||
const totals = computed(() => ({
|
||||
quantity: cartons.value.reduce((sum, box) => sum + Number(box.quantity || 0), 0),
|
||||
weight: cartons.value.reduce((sum, box) => sum + Number(box.weight || 0), 0),
|
||||
volume: cartons.value.reduce((sum, box) => sum + Number(volume(box) || 0), 0),
|
||||
skus: new Set(cartons.value.flatMap(box => boxLines(box).map(line => line.skuCode)).filter(Boolean)).size,
|
||||
}))
|
||||
watch([detail, totals], () => emit('summary', detail.value ? { ...totals.value, boxes: cartons.value.length, forecastBoxes: detail.value.packageCount, forecastSkus: new Set(detail.value.items.map(item => item.skuCode).filter(Boolean)).size, pallets: detail.value.pallets.length, warehouseId: detail.value.operatingWarehouseId } : null))
|
||||
const comparisonRows = computed(() => (detail.value?.items || []).map(item => {
|
||||
const boxes = cartons.value.filter(box => boxLines(box).some(line => String(line.transferItemId) === String(item.id)))
|
||||
const receivedQuantity = boxes.reduce((sum, box) => sum + boxLines(box).filter(line => String(line.transferItemId) === String(item.id)).reduce((total, line) => total + Number(line.quantity), 0), 0)
|
||||
return { ...item, receivedQuantity, receivedBoxes: boxes.length, quantityDifference: receivedQuantity - Number(item.quantity || 0), boxDifference: boxes.length - Number(item.packageCount || 0) }
|
||||
}))
|
||||
function differenceText(value: unknown) {
|
||||
if (!cartons.value.length) return '待签收'
|
||||
const difference = Number(value)
|
||||
return difference > 0 ? `+${difference}` : String(difference)
|
||||
}
|
||||
const palletRows = computed(() => (detail.value?.pallets || []).map(pallet => {
|
||||
const boxes = cartons.value.filter(box => box.transferPalletId != null && String(box.transferPalletId) === String(pallet.id))
|
||||
const skus = new Map<string, number>()
|
||||
for (const box of boxes) for (const line of boxLines(box)) skus.set(line.skuCode, (skus.get(line.skuCode) || 0) + Number(line.quantity))
|
||||
return { ...pallet, receivedBoxes: boxes.length, skuCount: skus.size, quantity: boxes.reduce((sum, box) => sum + Number(box.quantity || 0), 0), skuSummary: [...skus].map(([sku, quantity]) => `${sku} × ${quantity} 件`).join(';') || '—' }
|
||||
}))
|
||||
const cartonRows = computed(() => cartons.value.map(box => ({ ...box, sku: boxLines(box).map(line => line.skuCode).join(';'), palletNo: detail.value?.pallets.find(pallet => box.transferPalletId != null && String(pallet.id) === String(box.transferPalletId))?.palletNo || '未关联托盘', dimensions: [box.length, box.width, box.height].every(value => value != null) ? `${box.length} × ${box.width} × ${box.height}` : '—', volumeText: formatNumber(volume(box), 6) })))
|
||||
const boxItems = computed(() => selectedBox.value ? boxLines(selectedBox.value).map(line => ({ sku: line.skuCode, name: detail.value?.items.find(item => String(item.id) === String(line.transferItemId))?.productNameZh || '—', quantity: line.quantity })) : [])
|
||||
const receiptLeadingColumns: DataTableColumn[] = [
|
||||
{ key: 'receivedQuantity', label: '实收件数', width: 100, align: 'center', headerAlign: 'center' },
|
||||
{ key: 'receivedBoxes', label: '实收箱数', width: 100, align: 'center', headerAlign: 'center' },
|
||||
]
|
||||
const forecastColumns: DataTableColumn[] = [
|
||||
{ key: 'skuCode', label: 'SKU', width: 500, singleLine: true }, { key: 'productNameZh', label: '中文品名', width: 200, singleLine: false },
|
||||
...receiptLeadingColumns,
|
||||
{ key: 'quantity', label: '预报件数', width: 100, align: 'center', headerAlign: 'center' },
|
||||
{ key: 'packageCount', label: '预计箱数', width: 120, align: 'center', headerAlign: 'center' },
|
||||
{ key: 'quantityDifference', label: '件数差异', slotName: 'quantityDifference', width: 130, align: 'center', headerAlign: 'center' },
|
||||
{ key: 'boxDifference', label: '箱数差异', slotName: 'boxDifference', width: 240, align: 'center', headerAlign: 'center' },
|
||||
]
|
||||
const cartonColumns: DataTableColumn[] = [
|
||||
{ key: 'action', label: '操作', slotName: 'boxAction', width: 140, fixed: 'left' },
|
||||
{ key: 'cartonMark', label: '实收箱唛', minWidth: 220 }, { key: 'palletNo', label: '所属托盘', minWidth: 210 },
|
||||
{ key: 'sku', label: 'SKU', minWidth: 160 }, { key: 'quantity', label: '实收件数', width: 100 },
|
||||
{ key: 'dimensions', label: '长 × 宽 × 高(cm)', minWidth: 150 }, { key: 'weight', label: '重量(kg)', width: 110 }, { key: 'volumeText', label: '体积(m³)', width: 120 },
|
||||
]
|
||||
const palletColumns: DataTableColumn[] = [
|
||||
{ key: 'palletNo', label: '托盘号', width: 500, singleLine: false }, { key: 'warehouseLocationCode', label: '库位', width: 200, singleLine: false },
|
||||
...receiptLeadingColumns.map(column => column.key === 'receivedQuantity' ? { ...column, key: 'quantity' } : column),
|
||||
{ key: 'skuCount', label: 'SKU 种数', width: 100, align: 'center', headerAlign: 'center' }, { key: 'grossWeightKg', label: '总重量(kg)', width: 120, align: 'center', headerAlign: 'center' },
|
||||
{ key: 'volumeCbm', label: '总体积(m³)', width: 130, align: 'center', headerAlign: 'center' }, { key: 'skuSummary', label: 'SKU / 件数汇总', width: 240, singleLine: false },
|
||||
]
|
||||
const boxItemColumns: DataTableColumn[] = [{ key: 'sku', label: 'SKU', minWidth: 180 }, { key: 'name', label: '品名', minWidth: 150 }, { key: 'quantity', label: '实际清点件数', width: 120 }]
|
||||
function showBox(cartonMark: unknown) {
|
||||
const box = cartons.value.find(value => value.cartonMark === cartonMark)
|
||||
if (!box) return
|
||||
selectedBox.value = box; boxOpen.value = true
|
||||
}
|
||||
async function printCartons() {
|
||||
if (printing.value || !detail.value?.cartons.length) return
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (!printWindow) { ElMessage.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); return }
|
||||
printWindow.opener = null
|
||||
printing.value = true; printError.value = null
|
||||
const ticket = generation
|
||||
try {
|
||||
const label = await getWarehouseTransferCartonLabel(props.customerId, props.transferNo)
|
||||
if (ticket !== generation) { printWindow.close(); return }
|
||||
if (!label.cartons.length) { printWindow.close(); ElMessage.warning('暂无可打印的箱唛'); return }
|
||||
printWindow.document.open(); printWindow.document.write(label.printableHtml); printWindow.document.close()
|
||||
printWindow.focus(); printWindow.print()
|
||||
} catch (cause) {
|
||||
printWindow.close()
|
||||
if (ticket === generation) printError.value = getApiErrorDetails(cause, '箱唛加载失败,请重试')
|
||||
} finally { printing.value = false }
|
||||
}
|
||||
async function load() {
|
||||
const ticket = ++generation
|
||||
detail.value = null; selectedBox.value = null; boxOpen.value = false; cartonsOpen.value = false; error.value = null; printError.value = null
|
||||
if (!props.customerId || !props.transferNo) { loading.value = false; error.value = { message: '缺少客户或来源转运单号,无法查询签收明细' }; return }
|
||||
loading.value = true
|
||||
try { const value = await getWarehouseTransfer(props.customerId, props.transferNo); if (ticket === generation) detail.value = value }
|
||||
catch (cause) { if (ticket === generation) error.value = getApiErrorDetails(cause, '转运预报与签收明细加载失败') }
|
||||
finally { if (ticket === generation) loading.value = false }
|
||||
}
|
||||
watch(() => [props.customerId, props.transferNo], load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="transfer-receipt-panel" aria-label="仓储转运预报与实际签收">
|
||||
<LoadErrorState v-if="error" title="签收明细加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<h3>预报与实际签收对比 <small>差异 = 实收 − 预报{{ cartons.length ? '(按当前已清点箱汇总)' : ',待签收后显示' }}</small></h3>
|
||||
<DataTable class="receipt-aligned-table" :fit="false" :data="comparisonRows" :columns="forecastColumns" row-key="id" :show-pagination="false" empty-text="暂无预报货物明细">
|
||||
<template #quantityDifference="{ row }"><span :class="{ 'receipt-difference': cartons.length && Number(row.quantityDifference) !== 0 }">{{ differenceText(row.quantityDifference) }}</span></template>
|
||||
<template #boxDifference="{ row }"><span :class="{ 'receipt-difference': cartons.length && Number(row.boxDifference) !== 0 }">{{ differenceText(row.boxDifference) }}</span></template>
|
||||
</DataTable>
|
||||
<h3>托盘签收汇总</h3><DataTable class="receipt-aligned-table" :fit="false" :data="palletRows" :columns="palletColumns" row-key="id" :show-pagination="false" empty-text="尚未创建托盘" />
|
||||
<div class="receipt-box-heading"><h3>签收箱号明细({{ cartons.length }} 箱)</h3><el-button type="primary" size="small" :disabled="!cartons.length" @click="cartonsOpen = true">查看箱号明细</el-button><el-button v-if="detail.status !== 'DRAFT'" type="primary" size="small" :loading="printing" :disabled="!detail.cartons.length" @click="printCartons"><Printer :size="14" />打印箱唛</el-button></div>
|
||||
<LoadErrorState v-if="printError" title="箱唛打印失败" :message="printError.message" :loading="printing" @retry="printCartons"><template #details><ApiErrorMeta :details="printError" /></template></LoadErrorState>
|
||||
<AppDialog v-model="cartonsOpen" class="admin-density-dialog" title="签收箱号明细" width="1120px" :show-footer="false"><DataTable :data="cartonRows" :columns="cartonColumns" row-key="cartonMark" :show-pagination="false" empty-text="暂无实际签收箱,等待 PDA 清点">
|
||||
<template #boxAction="{ row }"><el-button link type="primary" @click="showBox(row.cartonMark)"><Eye :size="14" />箱内明细</el-button></template>
|
||||
</DataTable></AppDialog>
|
||||
</template>
|
||||
<AppDialog class="admin-density-dialog" v-model="boxOpen" title="箱内清点明细" width="820px" :show-footer="false">
|
||||
<template v-if="selectedBox"><p>{{ selectedBox.cartonMark }} · 清点时间 {{ formatOverseasDateTime(selectedBox.countedAt) }}</p><DataTable :data="boxItems" :columns="boxItemColumns" row-key="sku" :show-pagination="false" /><p v-if="selectedBox.discrepancyReason">差异说明:{{ selectedBox.discrepancyReason }}</p></template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.receipt-box-heading{display:flex;align-items:center;justify-content:flex-start;flex-wrap:wrap;gap:12px}
|
||||
.transfer-receipt-panel{display:grid;gap:var(--admin-space-2)}
|
||||
.receipt-aligned-table{width:100%;max-width:1490px;min-width:0}
|
||||
.receipt-difference{color:var(--el-color-danger);font-weight:600}
|
||||
h3 small{margin-left:8px;color:var(--cc-muted);font-size:var(--admin-font-caption);font-weight:400}
|
||||
h3{margin:0;font-size:var(--admin-font-section)}
|
||||
</style>
|
||||
@@ -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'
|
||||
|
||||
@@ -14,9 +14,9 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
|
||||
const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: 'PDA处理中', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }]
|
||||
const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }]
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '总箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }]
|
||||
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待仓库确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
|
||||
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
|
||||
const photoUrls = reactive<Record<string, string>>({}); const photoLoading = reactive<Record<string, boolean>>({}); const photoErrors = reactive<Record<string, boolean>>({})
|
||||
const photoPreviewUrls = computed(() => (detail.value?.photos || []).map(photo => photoUrls[photo.fileId]).filter(Boolean))
|
||||
let photoGeneration = 0
|
||||
@@ -39,7 +39,7 @@ onMounted(async () => { try { const result = await listPlatformTenants({ page: 1
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density"><ListPageLayout aria-label="废弃单列表"><section><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button size="small" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || String((row as CargoDiscardSummary).status)" /></template><template #pagination-left>共 {{ total }} 条废弃单</template></DataTable></section></ListPageLayout></AdminPage>
|
||||
<AppDialog v-model="detailOpen" title="废弃单详情" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))"><template v-if="detail"><el-descriptions :column="3" border><el-descriptions-item label="废弃单号">{{ detail.summary.discardNo }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabels[String(detail.summary.status)] }}</el-descriptions-item><el-descriptions-item label="仓库">{{ detail.summary.overseasWarehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="入仓码" :span="2">{{ detail.summary.inboundCodes || '—' }}</el-descriptions-item><el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item></el-descriptions><h3>废弃明细</h3><el-table :data="detail.items" border><el-table-column prop="inboundCode" label="入仓码"/><el-table-column prop="skuCode" label="SKU"/><el-table-column prop="productName" label="品名"/><el-table-column prop="discardPackages" label="申请箱数" width="100"/></el-table><h3>废弃现场照片</h3><div v-if="detail.photos.length" class="discard-photo-grid"><div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item"><el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported /><span v-else-if="photoLoading[photo.fileId]">照片加载中…</span><el-button v-else @click="loadPhoto(photo.fileId)">{{ photoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</el-button><small>托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}</small></div></div><p v-else>暂无已提交的废弃现场照片</p><h3>状态流转</h3><el-timeline><el-timeline-item v-for="(item, index) in detail.histories" :key="index" :timestamp="item.createdAt || ''">{{ statusLabels[item.toStatus || ''] || item.toStatus }} {{ item.remark ? `— ${item.remark}` : '' }}</el-timeline-item></el-timeline></template></AppDialog>
|
||||
<AppDialog v-model="detailOpen" title="废弃单详情" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))"><template v-if="detail"><el-descriptions :column="3" border><el-descriptions-item label="废弃单号">{{ detail.summary.discardNo }}</el-descriptions-item><el-descriptions-item label="出库单号">{{ detail.summary.outboundNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabels[String(detail.summary.status)] }}</el-descriptions-item><el-descriptions-item label="仓库">{{ detail.summary.overseasWarehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="入仓码" :span="2">{{ detail.summary.inboundCodes || '—' }}</el-descriptions-item><el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item></el-descriptions><h3>废弃明细</h3><el-table :data="detail.items" border><el-table-column prop="inboundCode" label="入仓码"/><el-table-column prop="skuCode" label="SKU"/><el-table-column prop="productName" label="品名"/><el-table-column prop="discardPackages" label="申请数量" width="100"/><el-table-column label="所选箱唛" min-width="180"><template #default="{ row }">{{ row.cartonMarks?.join('、') || '全部库存(历史记录无箱唛快照)' }}</template></el-table-column></el-table><h3>废弃现场照片</h3><div v-if="detail.photos.length" class="discard-photo-grid"><div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item"><el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported /><span v-else-if="photoLoading[photo.fileId]">照片加载中…</span><el-button v-else @click="loadPhoto(photo.fileId)">{{ photoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</el-button><small>托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}</small></div></div><p v-else>暂无已提交的废弃现场照片</p><h3>状态流转</h3><el-timeline><el-timeline-item v-for="(item, index) in detail.histories" :key="index" :timestamp="item.createdAt || ''">{{ statusLabels[item.toStatus || ''] || item.toStatus }} {{ item.remark ? `— ${item.remark}` : '' }}</el-timeline-item></el-timeline></template></AppDialog>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,9 +3,11 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getLastMileOrder } from '../api/lastMileOrders'
|
||||
import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
|
||||
import { getPlatformWarehouse } from '@/modules/warehouses'
|
||||
import { getWarehouseTransfer } from '../api/warehouseTransfers'
|
||||
|
||||
const push = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/modules/warehouses', () => ({ getPlatformWarehouse: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'internal-order-id' } }), useRouter: () => ({ push }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => false }) }))
|
||||
vi.mock('../api/lastMileOrders', () => ({ getLastMileOrder: vi.fn(), createLastMileOrderFee: vi.fn() }))
|
||||
@@ -63,9 +65,34 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
|
||||
expect(wrapper.get('[aria-label="本票转运与换标履约"]').text()).toContain('不得提前出库')
|
||||
})
|
||||
|
||||
it('fills the receiving warehouse from its ID when the order name is absent', async () => {
|
||||
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'tail', customerId: 'C1', orderNo: 'WT-TAIL', orderType: 'WAREHOUSE_TRANSFER', overseasWarehouseId: '193000000000200003' }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
|
||||
vi.mocked(getPlatformWarehouse).mockResolvedValue({ warehouse: { id: '193000000000200003', warehouseName: '洛杉矶收货仓', warehouseCode: 'US-LAX-01', tenantId: '0', internalCode: null, warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }, addresses: [], capabilities: [] })
|
||||
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(getPlatformWarehouse).toHaveBeenCalledWith('193000000000200003')
|
||||
expect(wrapper.get('[aria-label="尾程订单概览"]').text()).toContain('海外仓洛杉矶收货仓')
|
||||
expect(wrapper.get('[aria-label="基本信息"]').text()).toContain('海外仓洛杉矶收货仓')
|
||||
})
|
||||
|
||||
it('loads transfer declarations and actual cartons on the cargo tab using the source transfer', async () => {
|
||||
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'tail', customerId: 'customer-1', orderNo: 'TAIL-NO', sourceOrderNo: 'WT-SOURCE', orderType: 'WAREHOUSE_TRANSFER' }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue({ id: 'transfer', transferNo: 'WT-SOURCE', status: 'WAIT_PDA_RECEIVING', needRelabel: false, packageCount: 5, items: [], cartons: [], pallets: [], relabelBindings: [] })
|
||||
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(getWarehouseTransfer).not.toHaveBeenCalled()
|
||||
await wrapper.get('#tab-cargo').trigger('click'); await flushPromises()
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('customer-1', 'WT-SOURCE')
|
||||
const panel = wrapper.get('[aria-label="仓储转运预报与实际签收"]')
|
||||
expect(wrapper.get('[aria-label="尾程订单概览"]').text()).toContain('预报箱数5 箱')
|
||||
expect(wrapper.get('[aria-label="尾程订单概览"]').text()).toContain('实收箱数0 箱')
|
||||
expect(panel.text()).toContain('预报与实际签收对比')
|
||||
expect(panel.text()).toContain('签收箱号明细(0 箱)')
|
||||
expect(panel.text()).toContain('查看箱号明细')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
push.mockReset()
|
||||
vi.mocked(getWarehouseTransfer).mockReset()
|
||||
vi.mocked(getPlatformWarehouse).mockReset()
|
||||
vi.mocked(getLastMileOrder).mockResolvedValue({
|
||||
order: { id: 'internal-order-id', customerId: 'customer-1', orderNo: 'FL-WH-001', currentStatus: 'PENDING_ASSIGNMENT', customerName: '示例客户', overseasWarehouseName: '洛杉矶仓', packageCount: 0, palletCount: 2, grossWeightKg: 30, netWeightKg: 22, volumeCbm: 6.374, inboundCode: 'IN-001', containerNo: 'CONT-001', customerRemark: '完整保留客户备注', updatedAt: '2026-09-14T10:00:00' },
|
||||
cargo: [{ id: 'cargo-1', skuCode: 'SKU-001', productNameZh: '测试货物' }], fees: [], statusHistory: [], operationHistory: [],
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getPlatformWarehouse } from '@/modules/warehouses'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -19,6 +20,7 @@ import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
import { businessOrderLink } from '../businessOrderLink'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import WarehouseTransferFulfillmentPanel from '../components/WarehouseTransferFulfillmentPanel.vue'
|
||||
import WarehouseTransferReceiptPanel from '../components/WarehouseTransferReceiptPanel.vue'
|
||||
|
||||
interface DetailField { label: string; value: string | number; wide?: boolean }
|
||||
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
|
||||
@@ -31,6 +33,20 @@ const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const orderId = computed(() => String(route.params.orderId || ''))
|
||||
const detail = ref<LastMileOrderDetailDto | null>(null)
|
||||
const receiptSummary = ref<{ boxes: number; forecastBoxes: number; forecastSkus: number; skus: number; quantity: number; pallets: number; weight: number; volume: number; warehouseId?: string | null } | null>(null)
|
||||
const resolvedWarehouseName = ref('')
|
||||
const warehouseLookupFailed = ref(false)
|
||||
const warehouseDisplayName = computed(() => detail.value?.order.overseasWarehouseName || resolvedWarehouseName.value || detail.value?.order.overseasWarehouseCode || (warehouseLookupFailed.value ? '仓库名称加载失败' : '—'))
|
||||
watch(() => [detail.value?.order.overseasWarehouseId || receiptSummary.value?.warehouseId, detail.value?.order.overseasWarehouseName] as const, async ([warehouseId, warehouseName], _, onCleanup) => {
|
||||
let current = true
|
||||
onCleanup(() => { current = false })
|
||||
resolvedWarehouseName.value = ''; warehouseLookupFailed.value = false
|
||||
if (!warehouseId || warehouseName) return
|
||||
try {
|
||||
const result = await getPlatformWarehouse(String(warehouseId))
|
||||
if (current) resolvedWarehouseName.value = result.warehouse.warehouseName || result.warehouse.warehouseCode
|
||||
} catch { if (current) warehouseLookupFailed.value = true }
|
||||
})
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -76,7 +92,7 @@ const orderFields = computed<DetailField[]>(() => {
|
||||
{ label: '履约类型', value: text(order?.orderTypeName || lastMileOrderTypeLabel(order?.orderType)) },
|
||||
{ label: '入仓码', value: text(order?.inboundCode) },
|
||||
{ label: '货主', value: text(order?.customerName) },
|
||||
{ label: '海外仓', value: text(order?.overseasWarehouseName) },
|
||||
{ label: '海外仓', value: warehouseDisplayName.value },
|
||||
{ label: '转仓目的仓', value: text(order?.transferWarehouseName) },
|
||||
{ label: '目的仓', value: text(order?.fbaWarehouseCode || order?.transferWarehouseName) },
|
||||
{ label: '是否暂扣', value: yesNo(order?.detained) },
|
||||
@@ -115,9 +131,25 @@ const overviewFields = computed(() => {
|
||||
const order = detail.value?.order
|
||||
const warehouseTransfer = order?.orderType === 'WAREHOUSE_TRANSFER'
|
||||
const hasReceivedPackages = warehouseTransfer && order?.receivedPackageCount != null
|
||||
if (warehouseTransfer && receiptSummary.value) {
|
||||
const summary = receiptSummary.value
|
||||
return [
|
||||
{ label: '客户', value: text(order?.customerName) },
|
||||
{ label: '海外仓', value: text(order?.overseasWarehouseName) },
|
||||
{ label: '海外仓', value: warehouseDisplayName.value },
|
||||
{ label: '预报箱数', value: `${summary.forecastBoxes} 箱` },
|
||||
{ label: '预报 SKU', value: `${summary.forecastSkus} 种` },
|
||||
{ label: '实收箱数', value: `${summary.boxes} 箱` },
|
||||
{ label: '实收 SKU', value: `${summary.skus} 种` },
|
||||
{ label: '实收件数', value: `${summary.quantity} 件` },
|
||||
{ label: '总托数', value: `${summary.pallets} 托` },
|
||||
{ label: '总重量', value: `${summary.weight.toFixed(3)} kg` },
|
||||
{ label: '总体积', value: `${summary.volume.toFixed(6)} m³` },
|
||||
{ label: '最近更新', value: formatOverseasDateTime(order?.updatedAt) },
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ label: '客户', value: text(order?.customerName) },
|
||||
{ label: '海外仓', value: warehouseDisplayName.value },
|
||||
...(isReturnOrder.value ? [{ label: showReturnForecast.value ? '预报总件数' : '实收总件数', value: `${showReturnForecast.value ? detail.value?.returnReceipt?.order.expectedQuantity ?? order?.productQuantity ?? 0 : detail.value?.returnReceipt?.order.receivedQuantity ?? order?.productQuantity ?? 0} 件` }]
|
||||
: [{ label: warehouseTransfer ? (hasReceivedPackages ? '实收总箱数' : '客户预报总箱数') : '总箱数', value: (hasReceivedPackages ? order?.receivedPackageCount : order?.packageCount) == null ? '—' : `${hasReceivedPackages ? order?.receivedPackageCount : order?.packageCount} 箱` }]),
|
||||
{ label: '总托数', value: order?.palletCount == null ? '—' : order.palletCount + ' 托' },
|
||||
@@ -248,7 +280,7 @@ onMounted(load)
|
||||
<div v-for="field in basicFields" :key="field.label" :class="{ wide: field.wide }"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field), 'package-difference--nonzero': isNonzeroDifference(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div>
|
||||
</dl>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="cargo"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>货物信息</span></template><section class="table-panel"><DataTable :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" :empty-text="isReturnOrder ? '暂无客户预报货物明细' : '暂无货物明细'"></DataTable></section></el-tab-pane>
|
||||
<el-tab-pane name="cargo"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>货物信息</span></template><section class="table-panel"><WarehouseTransferReceiptPanel v-if="detail?.order.orderType === 'WAREHOUSE_TRANSFER' && activeTab === 'cargo'" :customer-id="String(detail.order.customerId || '')" :transfer-no="String(detail.order.sourceOrderNo || '')" @summary="receiptSummary = $event" /><DataTable v-else-if="detail?.order.orderType !== 'WAREHOUSE_TRANSFER'" :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" :empty-text="isReturnOrder ? '暂无客户预报货物明细' : '暂无货物明细'"></DataTable></section></el-tab-pane>
|
||||
<el-tab-pane v-if="detail?.order.orderType === 'RETURN_RELABEL'" name="return-receipt"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>退货签收与箱内清点</span></template>
|
||||
<section v-if="detail.returnReceipt" class="table-panel" aria-label="退货签收与箱内清点">
|
||||
<h3>{{ detail.returnReceipt.order.returnNo }} · 实收 {{ detail.returnReceipt.order.receivedQuantity }} / 预报 {{ detail.returnReceipt.order.expectedQuantity }} 件</h3>
|
||||
|
||||
@@ -44,8 +44,8 @@ const warehouseLabel = computed(() => {
|
||||
return outboundWarehouseId && delivery?.overseasWarehouseId === outboundWarehouseId ? show(delivery.overseasWarehouseName) : '—'
|
||||
})
|
||||
function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) }
|
||||
function outboundTypeLabel(value?: string | null) { return value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : show(value) }
|
||||
const canCancel = computed(() => access.can(permission.outboundCancel) && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(detail.value?.outbound.status || '')))
|
||||
function outboundTypeLabel(value?: string | null) { return value === 'RELABEL_OUTBOUND' ? '贴标出库' : value === 'DISCARD_OUTBOUND' ? '废弃出库' : value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : show(value) }
|
||||
const canCancel = computed(() => access.can(permission.outboundCancel) && detail.value?.outbound.outboundType !== 'DISCARD_OUTBOUND' && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(detail.value?.outbound.status || '')))
|
||||
async function load() { if (!outboundNo.value) { loadError.value = { message: '详情缺少出库单号,请从出库单列表重新进入' }; return } loading.value = true; loadError.value = null; try { detail.value = await getOutbound(outboundNo.value) } catch (cause) { detail.value = null; loadError.value = getApiErrorDetails(cause, '出库单详情加载失败,请重试') } finally { loading.value = false } }
|
||||
async function cancel() { if (!canCancel.value || cancelling.value) return; let reason = ''; try { const result = await ElMessageBox.prompt(`确认取消出库单 ${outboundNo.value}?`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return } cancelling.value = true; mutationError.value = null; try { await cancelOutbound(outboundNo.value, reason); ElMessage.success('出库单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败,请重试') } finally { cancelling.value = false } }
|
||||
function back() { void router.push({ name: 'overseas-outbounds' }) }
|
||||
|
||||
@@ -95,10 +95,10 @@ const somePageSelected = computed(() => selectedPageCount.value > 0 && !allPageS
|
||||
const selectedDropshipCustomerCount = computed(() => new Set(dropshipCandidates.value.filter(item => selectedDropshipKeys.value.has(dropshipCandidateKey(item))).map(item => item.customerId).filter(Boolean)).size)
|
||||
const oneClickMarks = computed(() => typeof cartonMarks.value === 'string' ? cartonMarks.value.split(/[,\n,]+/).map(value => value.trim()).filter(Boolean) : [])
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'outboundType', prop: 'outboundType', label: '出库类型', width: 150, slotName: 'outboundType' }, { key: 'lxCodes', prop: 'lxCodes', label: 'LX代码', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'grossWeightKg', prop: 'grossWeightKg', label: '毛重(kg)', width: 120, align: 'right' }, { key: 'volumeWeightKg', prop: 'volumeWeightKg', label: '材积重(kg)', width: 130, align: 'right' }, { key: 'volumeCbm', prop: 'volumeCbm', label: '体积(m³)', width: 120, align: 'right' }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
|
||||
const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号', width: 220 }, { key: 'lxCode', type: 'input', label: 'LX代码', placeholder: '请输入领星入库单代码', width: 220 }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓', width: 220 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '全部', value: '' }, { label: '待拣货', value: 'WAIT_PICK' }, { label: '拣货中', value: 'PICKING' }, { label: '已拣货', value: 'PICKED' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }, { label: '已取消', value: 'CANCELLED' }] }, { key: 'outboundType', type: 'select', label: '出库类型', placeholder: '全部出库类型', width: 170, options: [{ label: '全部', value: '' }, { label: '仓储转运出库', value: 'WAREHOUSE_TRANSFER_OUTBOUND' }, { label: '一件代发出库', value: 'ONE_CLICK_DROPSHIP' }, { label: '退货出库', value: 'RETURN_OUTBOUND' }, { label: '派送出库', value: 'DISPATCH_DELIVERY' }] }]
|
||||
const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号', width: 220 }, { key: 'lxCode', type: 'input', label: 'LX代码', placeholder: '请输入领星入库单代码', width: 220 }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓', width: 220 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '全部', value: '' }, { label: '待拣货', value: 'WAIT_PICK' }, { label: '拣货中', value: 'PICKING' }, { label: '已拣货', value: 'PICKED' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }, { label: '已取消', value: 'CANCELLED' }] }, { key: 'outboundType', type: 'select', label: '出库类型', placeholder: '全部出库类型', width: 170, options: [{ label: '全部', value: '' }, { label: '仓储转运出库', value: 'WAREHOUSE_TRANSFER_OUTBOUND' }, { label: '一件代发出库', value: 'ONE_CLICK_DROPSHIP' }, { label: '退货出库', value: 'RETURN_OUTBOUND' }, { label: '派送出库', value: 'DISPATCH_DELIVERY' }, { label: '废弃出库', value: 'DISCARD_OUTBOUND' }, { label: '贴标出库', value: 'RELABEL_OUTBOUND' }] }]
|
||||
|
||||
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, lxCode: String(filters.value.lxCode || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined, outboundType: String(filters.value.outboundType || '').trim() || undefined }); rows.value = result.records.map((row) => ({ ...row, createdAt: formatOverseasDateTime(row.createdAt) })); total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } }
|
||||
function outboundTypeLabel(value?: string | null) { return value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : value || '—' }
|
||||
function outboundTypeLabel(value?: string | null) { return value === 'RELABEL_OUTBOUND' ? '贴标出库' : value === 'DISCARD_OUTBOUND' ? '废弃出库' : value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : value || '—' }
|
||||
async function loadCustomerOptions() {
|
||||
const records: PlatformTenantResource[] = []
|
||||
let page = 1
|
||||
@@ -235,7 +235,7 @@ async function submit() {
|
||||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '出库单创建失败').message) } finally { saving.value = false }
|
||||
}
|
||||
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
|
||||
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(row.status || '')) }
|
||||
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && row.outboundType !== 'DISCARD_OUTBOUND' && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(row.status || '')) }
|
||||
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}。已关联车次请先解除关联;取消将申请撤销未履行的派送应收,装车、买约及其他费用保留待核对。`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消;派送应收撤销结果请查看费用明细,排车占用需回排车列表取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
|
||||
@@ -5,6 +5,11 @@ const http = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }))
|
||||
vi.mock('@/shared/api/http', () => ({ http, unwrapApiResponse: (value: { data: unknown }) => value.data }))
|
||||
beforeEach(() => { vi.clearAllMocks(); http.get.mockResolvedValue({ data: {} }); http.post.mockResolvedValue({ data: { taskNo: 'COL-001', status: 'QUEUED' } }) })
|
||||
describe('self-operated platform transport contract', () => {
|
||||
it('sends multiple turnover owners as repeated query keys, without bracket suffixes', async () => {
|
||||
const params = { sourceAccount: 'primary', warehouseCode: 'W', endDate: '2026-10-14', baseWindowDays: 7, groupBy: 'SKU' as const, customerCodes: ['A', 'B'], focus: 'ALL' as const, sortBy: 'PHYSICAL_DESC' as const, current: 1, size: 20, includeOverview: true }
|
||||
await analyticsApi.turnover(params)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/self-operated-analytics/inventory-turnover', { params, paramsSerializer: { indexes: null }, signal: undefined })
|
||||
})
|
||||
it('forwards the inventory window, inclusive end and pinned page without renaming them', async () => {
|
||||
const params = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', salesWindowDays: 10 as const, salesWindowEndDate: '2026-10-12', current: 2, size: 20, salesRunId: '11', inventoryRunId: '12', queryAsOf: '2026-10-13T00:00:00Z' }
|
||||
await analyticsApi.inventory(params)
|
||||
|
||||
@@ -9,7 +9,10 @@ async function get<T>(path: string, params: object, signal?: AbortSignal): Promi
|
||||
}
|
||||
export const analyticsApi = {
|
||||
ownerDirectory: (query: { sourceAccount: string; warehouseCode: string } & Partial<BusinessQuery>, signal?: AbortSignal) => get<OwnerOption[]>('owner-directory', query, signal),
|
||||
turnover: (query: TurnoverQuery, signal?: AbortSignal) => get<TurnoverResponse>('inventory-turnover', query, signal),
|
||||
async turnover(query: TurnoverQuery, signal?: AbortSignal): Promise<TurnoverResponse> {
|
||||
const response = await http.get<ApiEnvelope<TurnoverResponse>, ApiEnvelope<TurnoverResponse>>(`${base}/inventory-turnover`, { params: query, paramsSerializer: { indexes: null }, signal })
|
||||
return unwrapApiResponse(response)
|
||||
},
|
||||
dashboard: (query: DashboardQuery, signal?: AbortSignal) => get<Dashboard>('dashboard', query, signal),
|
||||
owners: (query: OwnerQuery, signal?: AbortSignal) => get<BusinessPage<OwnerOption>>('owners', query, signal),
|
||||
ownerOverview: (query: OwnerOverviewQuery, signal?: AbortSignal) => get<OwnerOverview | null>('owner-overview', query, signal),
|
||||
|
||||
@@ -10,6 +10,17 @@ function point(date: string, value: number | null, dataStatus = 'COMPLETE'): Dai
|
||||
return { businessDate: date, outboundQuantity: dataStatus === 'COMPLETE' ? value : null, observedQuantity: value, dataStatus, reasonCodes: [], quantityUnit: 'PCS' }
|
||||
}
|
||||
describe('dashboard chart integrity and interaction', () => {
|
||||
it('keeps dates and line explanations behind the description action', async () => {
|
||||
const wrapper = mount(DashboardTrend, { props: { points: [point('2026-10-01', 2)] }, slots: { explanation: '<p>统计日期示例</p>' } })
|
||||
expect(wrapper.text()).not.toContain('统计日期示例')
|
||||
expect(wrapper.text()).not.toContain('实线/实心点')
|
||||
await wrapper.findAll('button').find(button => button.text() === '说明')!.trigger('click')
|
||||
expect(wrapper.text()).toContain('统计日期示例')
|
||||
expect(wrapper.text()).toContain('实线/实心点')
|
||||
await wrapper.setProps({ points: [point('2026-10-02', 3)] })
|
||||
expect(wrapper.find('[role=dialog]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('fills only contiguous known segments and keeps unknown days unavailable for drilldown', async () => {
|
||||
const wrapper = mount(DashboardTrend, { props: { points: [point('2026-10-01', 2), point('2026-10-02', 4), point('2026-10-03', null, 'UNKNOWN'), point('2026-10-04', 1, 'PARTIAL')] } })
|
||||
const chart = wrapper.findComponent({ name: 'VChart' })
|
||||
|
||||
@@ -19,10 +19,12 @@ function selectChart(event: ECElementEvent) { const point = props.points[event.d
|
||||
<template>
|
||||
<div>
|
||||
<DashboardChartHeading title="出库趋势">
|
||||
<ChartDetailsDialog v-model="detailsOpen" inline button-label="每日数值与覆盖原因" title="查看每日数值与覆盖原因" :reset-on="points"><table class="density-table"><thead><tr><th>日期(点击查看)</th><th>正式量</th><th>已采集量</th><th>状态</th><th>原因</th></tr></thead><tbody><tr v-for="point in points" :key="point.businessDate"><td><button type="button" :disabled="!available(point)" @click="select(point)">{{ point.businessDate }}</button></td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr></tbody></table></ChartDetailsDialog>
|
||||
</DashboardChartHeading>
|
||||
<slot name="intro" />
|
||||
<ChartDetailsDialog v-model="detailsOpen" inline button-label="明细" title="查看每日数值与覆盖原因" :reset-on="points"><table class="density-table"><thead><tr><th>日期(点击查看)</th><th>正式量</th><th>已采集量</th><th>状态</th><th>原因</th></tr></thead><tbody><tr v-for="point in points" :key="point.businessDate"><td><button type="button" :disabled="!available(point)" @click="select(point)">{{ point.businessDate }}</button></td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr></tbody></table></ChartDetailsDialog>
|
||||
<ChartDetailsDialog inline button-label="说明" title="出库趋势 · 统计说明" width="760px" :reset-on="points">
|
||||
<slot name="explanation" />
|
||||
<p class="trend-legend">实线/实心点:完整日 · 虚线/空心点:未完整日 · 未知及缺失日期断线;今日为已采集累计</p>
|
||||
</ChartDetailsDialog>
|
||||
</DashboardChartHeading>
|
||||
<AnalyticsChart :option="option" class="dashboard-trend" aria-label="全仓每日出库趋势" @select="selectChart" />
|
||||
<p v-if="!hasData">所选日期暂无可靠出库资料,未知不按零显示。</p>
|
||||
|
||||
|
||||
@@ -1,44 +1,187 @@
|
||||
import { defineComponent, h, reactive } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { ElCheckbox } from 'element-plus'
|
||||
import InventoryTurnoverView from './InventoryTurnoverView.vue'
|
||||
import type { TurnoverQuery, TurnoverResponse, TurnoverRow } from '../turnoverTypes'
|
||||
const mocks = vi.hoisted(() => ({ route: { query: {} as Record<string, string> }, allowed: true, ownerDirectory: vi.fn(), overview: vi.fn(), turnover: vi.fn(), daily: vi.fn(), push: vi.fn() }))
|
||||
import { turnoverPercent } from '../turnoverPresentation'
|
||||
vi.mock('@/modules/downloads', () => ({ useExportTask: () => mocks.exportTask }))
|
||||
const mocks = vi.hoisted(() => ({ route: { query: {} as Record<string, string> }, allowed: true, exportAllowed: true, exportTask: vi.fn(), ownerDirectory: vi.fn(), overview: vi.fn(), turnover: vi.fn(), daily: vi.fn(), push: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) }))
|
||||
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) }))
|
||||
const toolbar = defineComponent({ name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search', 'reset'], setup: () => () => h('div') })
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':export') ? mocks.exportAllowed : mocks.allowed }) }))
|
||||
const toolbar = defineComponent({ name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search', 'reset'], setup(_, { slots }) { return () => h('div', slots.actions?.()) } })
|
||||
const table = defineComponent({ name: 'DataTable', props: ['data', 'columns', 'total', 'page'], emits: ['update:page', 'update:pageSize'], setup(p, { slots }) { return () => h('div', [slots.filters?.(), slots['toolbar-start']?.(), slots['toolbar-actions']?.(), slots['before-results']?.(), ...(p.columns ?? []).map((column: { headerSlotName?: string; prop: string; label: string }) => column.headerSlotName ? slots[column.headerSlotName]?.({ column: { property: column.prop, label: column.label } }) : null), ...(p.data ?? []).flatMap((row: TurnoverRow) => (p.columns ?? []).map((column: { slotName?: string; prop: keyof TurnoverRow }) => column.slotName && slots[column.slotName] ? slots[column.slotName]?.({ row }) : String(row[column.prop] ?? '')))]) } })
|
||||
const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } })
|
||||
const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: (event: MouseEvent) => emit('click', event) }, slots.default?.()) } })
|
||||
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
|
||||
const total = { value: 100, knownSubtotal: 100, unknownSkuCount: 0, reasonCodes: [] }
|
||||
const risk = { matchedSkuCount: 0, knownPhysicalQuantity: 0, unknownSkuCount: 0, physicalQuantityRatio: 0 }
|
||||
const row: TurnoverRow = { customerCode: 'OWNER-A', customerName: null, sku: 'SAME', productName: '商品', physicalQuantity: total, availableQuantity: total, frozenQuantity: total, defectiveQuantity: total, transitQuantity: total, physicalSkuCount: 1, windows: [7, 15, 30].map(days => ({ days: days as 7 | 15 | 30, startDate: '2026-09-13', endDateExclusive: '2026-10-13', outboundQuantity: 300, observedQuantity: 300, averageDailyOutboundQuantity: 10, completeDayCount: days, partialDayCount: 0, unknownDayCount: 0, dataStatus: 'COMPLETE', reasonCodes: [] })), averageDailyOutboundQuantity: 10, inventoryCoverageDays: 10, coverageStatus: 'VALID', averagePhysicalQuantity: null, quantityTurnoverCount: null, turnoverStatus: 'INSUFFICIENT', qualifiedSampleDays: 0, requiredSampleDays: 30, missingSampleDates: ['2026-10-12'], sampleReasonCodes: [], lastObservedOutboundDate: null, attentionReasons: [], noOutboundInWindow: risk, slowCoverage: risk, unassessableSkuCount: 0 }
|
||||
const volume = { value: 1.25, knownSubtotal: 1.25, missingDimensionQuantity: 0, missingDimensionSkuCount: 0, unknownQuantitySkuCount: 0, declaredDimensionQuantity: 0, reasonCodes: [] }
|
||||
const volumes = [7, 15, 30].map(days => ({ days, startDate: '2026-09-13', endDateExclusive: '2026-10-13', volume }))
|
||||
const row: TurnoverRow = { inventoryVolumeM3: volume, outboundVolumesM3: volumes, unitVolumeM3: 0.0125, dimensionSource: 'WAREHOUSE_MEASURED', customerCode: 'OWNER-A', customerName: null, sku: 'SAME', productName: '商品', physicalQuantity: total, availableQuantity: total, frozenQuantity: total, defectiveQuantity: total, transitQuantity: total, physicalSkuCount: 1, windows: [7, 15, 30].map(days => ({ days: days as 7 | 15 | 30, startDate: '2026-09-13', endDateExclusive: '2026-10-13', outboundQuantity: 300, observedQuantity: 300, averageDailyOutboundQuantity: 10, completeDayCount: days, partialDayCount: 0, unknownDayCount: 0, dataStatus: 'COMPLETE', reasonCodes: [] })), averageDailyOutboundQuantity: 10, inventoryCoverageDays: 10, coverageStatus: 'VALID', averagePhysicalQuantity: null, quantityTurnoverCount: null, turnoverStatus: 'INSUFFICIENT', qualifiedSampleDays: 0, requiredSampleDays: 30, missingSampleDates: ['2026-10-12'], sampleReasonCodes: [], lastObservedOutboundDate: null, attentionReasons: [], noOutboundInWindow: risk, slowCoverage: risk, unassessableSkuCount: 0 }
|
||||
function response(q: TurnoverQuery): TurnoverResponse {
|
||||
return { context: { snapshotContext: q.snapshotContext || `token-${q.warehouseCode}`, sourceAccount: q.sourceAccount, warehouseCode: q.warehouseCode, endDate: q.endDate, baseWindowDays: q.baseWindowDays, queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', inventoryObservedFrom: null, inventoryObservedUntil: null, sampleRuleVersion: 'DAILY_SCHEDULED_FIRST_V1', dailySamples: [], missingSampleDates: ['2026-10-12'], accumulationStartedOn: null, analyticsContext: { salesRunId: '11', inventoryRunId: '22', salesScopeHash: 'a', inventoryScopeHash: 'b', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13' } }, summary: q.includeOverview ? { physicalQuantity: total, availableQuantity: total, noOutboundSkuCount: total, unassessableSkuCount: total } : null, charts: q.includeOverview ? { ownerPhysicalTop: [], unknownOwnerCount: 0, averageDaily: row.windows } : null, rows: [{ ...row, sku: q.groupBy === 'OWNER' ? null : row.sku }], total: 40 }
|
||||
return { context: { snapshotContext: q.snapshotContext || `token-${q.warehouseCode}`, sourceAccount: q.sourceAccount, warehouseCode: q.warehouseCode, endDate: q.endDate, baseWindowDays: q.baseWindowDays, queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', inventoryObservedFrom: null, inventoryObservedUntil: null, sampleRuleVersion: 'FLOW_EOD_V1', dailySamples: [], missingSampleDates: ['2026-10-12'], accumulationStartedOn: null, analyticsContext: { salesRunId: '11', inventoryRunId: '22', salesScopeHash: 'a', inventoryScopeHash: 'b', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13' } }, summary: q.includeOverview ? { inventoryVolumeM3: volume, outboundVolumesM3: volumes, physicalQuantity: total, availableQuantity: total, noOutboundSkuCount: total, unassessableSkuCount: total } : null, charts: q.includeOverview ? { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: row.windows } : null, rows: [{ ...row, sku: q.groupBy === 'OWNER' ? null : row.sku }], total: 40 }
|
||||
}
|
||||
const wrappers: ReturnType<typeof mount>[] = []
|
||||
function page() { const w = mount(InventoryTurnoverView, { global: { stubs: { AdminPage: shell, DataTable: table, QueryToolbar: toolbar, AnalyticsChart: true, ChartDetailsDialog: true, AppDialog: true, ApiErrorMeta: true, 'el-button': button, 'el-tag': shell, 'el-popover': defineComponent({ setup(_, { slots }) { return () => h('div', [slots.reference?.(), slots.default?.()]) } }) } } }); wrappers.push(w); return w }
|
||||
function page() { const w = mount(InventoryTurnoverView, { global: { components: { ElCheckbox }, stubs: { AdminPage: shell, DataTable: table, QueryToolbar: toolbar, AnalyticsChart: true, ChartDetailsDialog: true, AppDialog: defineComponent({ name: 'AppDialog', props: ['modelValue', 'title'], setup(p, { slots }) { return () => h('div', p.modelValue ? slots.default?.() : []) } }), ApiErrorMeta: true, 'el-button': button, 'el-tag': shell, 'el-popover': defineComponent({ setup(_, { slots }) { return () => h('div', [slots.reference?.(), slots.default?.()]) } }) } } }); wrappers.push(w); return w }
|
||||
async function change(w: ReturnType<typeof mount>, patch: object, search = false) {
|
||||
const t = w.findComponent(toolbar); t.vm.$emit('update:modelValue', { ...t.props('modelValue'), ...patch }); await flushPromises()
|
||||
if (search) { t.vm.$emit('search'); await flushPromises() }
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); mocks.ownerDirectory.mockResolvedValue([{ customerCode: "OWNER-A", customerName: "货主甲" }]); mocks.allowed = true; mocks.route = reactive({ query: { endDate: '2026-10-12' } }); mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A' }, { warehouseCode: 'B' }] }); mocks.turnover.mockImplementation(async (q: TurnoverQuery) => response(q)); sessionStorage.clear() })
|
||||
beforeEach(() => { vi.clearAllMocks(); mocks.exportAllowed = true; mocks.exportTask.mockResolvedValue(undefined); mocks.ownerDirectory.mockResolvedValue([{ customerCode: "OWNER-A", customerName: "货主甲" }]); mocks.allowed = true; mocks.route = reactive({ query: { endDate: '2026-10-12' } }); mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A' }, { warehouseCode: 'B' }] }); mocks.turnover.mockImplementation(async (q: TurnoverQuery) => response(q)); sessionStorage.clear() })
|
||||
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
|
||||
describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
it('keeps multiple owners through paging and grouping, clears them on reset', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
const field = w.findComponent(toolbar).props('fields').find((f: {key: string}) => f.key === 'customerCodes')
|
||||
expect(field.props).toMatchObject({ multiple: true, filterable: true, collapseTags: true })
|
||||
field.props.onChange(['B', 'OWNER-A']); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCodes: ['B', 'OWNER-A'], current: 1, includeOverview: true })
|
||||
expect(w.text()).toContain('所选货主概览')
|
||||
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCodes: ['B', 'OWNER-A'], current: 2, snapshotContext: 'token-A', includeOverview: false })
|
||||
await change(w, { groupBy: 'OWNER' })
|
||||
await w.findAll('button').find(b => b.text() === 'OWNER-A')!.trigger('click'); await flushPromises()
|
||||
await w.findAll('button').find(b => b.text() === '返回货主汇总')!.trigger('click'); await flushPromises()
|
||||
expect(w.findComponent(toolbar).props('modelValue').customerCodes).toEqual(['B', 'OWNER-A'])
|
||||
w.findComponent(toolbar).vm.$emit('reset'); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0].customerCodes).toEqual([])
|
||||
})
|
||||
it('submits one backend export with applied filters, visible columns and fixed version without paging in the browser', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
await change(w, { customerCodes: ['OWNER-A'], sortBy: 'TURNOVER_DESC' }, true)
|
||||
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
|
||||
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
|
||||
const before = mocks.turnover.mock.calls.length
|
||||
await w.findAll('button').find(b => b.text() === '导出')!.trigger('click'); await flushPromises()
|
||||
expect(mocks.turnover).toHaveBeenCalledTimes(before)
|
||||
expect(mocks.exportTask).toHaveBeenCalledTimes(1)
|
||||
const [type, , parameters] = mocks.exportTask.mock.calls[0]!
|
||||
expect(type).toBe('SELFOP_INVENTORY_TURNOVER')
|
||||
expect(parameters).toMatchObject({ customerCodes: ['OWNER-A'], sortBy: 'TURNOVER_DESC', snapshotContext: 'token-A', hideZeroInventory: true, expectedTotal: 40 })
|
||||
expect(parameters.columns).toEqual(w.findComponent(table).props('columns').map((c: { prop: string }) => c.prop))
|
||||
expect(parameters.columns).toContain('quantityTurnoverRate')
|
||||
expect(parameters.columns).toContain('averagePhysicalQuantity')
|
||||
for (const key of ['current', 'size', 'includeOverview']) expect(parameters).not.toHaveProperty(key)
|
||||
expect(w.findComponent(table).props('page')).toBe(2)
|
||||
expect(w.text()).toContain('导出任务已提交')
|
||||
expect(turnoverPercent(null)).toBe('—'); expect(turnoverPercent(0)).toBe('0%'); expect(turnoverPercent(1.25)).toBe('125%')
|
||||
})
|
||||
it('requires independent export permission and displays submission failures', async () => {
|
||||
mocks.exportAllowed = false
|
||||
const denied = page(); await flushPromises()
|
||||
expect(denied.findAll('button').some(b => b.text() === '导出')).toBe(false)
|
||||
mocks.exportAllowed = true
|
||||
const w = page(); await flushPromises()
|
||||
mocks.exportTask.mockRejectedValueOnce(new Error('export unavailable'))
|
||||
await w.findAll('button').find(b => b.text() === '导出')!.trigger('click'); await flushPromises()
|
||||
expect(w.text()).toContain('导出任务提交失败,请重试')
|
||||
expect(w.text()).not.toContain('导出任务已提交')
|
||||
})
|
||||
it('hides zero inventory by default, applies it before pagination and restores it on reset', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
const checkbox = () => w.get('.turnover-zero-filter input[type="checkbox"]')
|
||||
expect((checkbox().element as HTMLInputElement).checked).toBe(true)
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ hideZeroInventory: true })
|
||||
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
|
||||
await checkbox().setValue(false); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ hideZeroInventory: false, current: 1, snapshotContext: 'token-A' })
|
||||
await change(w, { groupBy: 'OWNER' })
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ hideZeroInventory: false, groupBy: 'OWNER' })
|
||||
w.findComponent(toolbar).vm.$emit('reset'); await flushPromises()
|
||||
expect((checkbox().element as HTMLInputElement).checked).toBe(true)
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ hideZeroInventory: true, current: 1 })
|
||||
})
|
||||
it('accepts an explicit show-zero route and restores the default on an unqualified route', async () => {
|
||||
mocks.route.query = { endDate: '2026-10-12', hideZeroInventory: 'false' }
|
||||
const w = page(); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ hideZeroInventory: false })
|
||||
mocks.route.query = { endDate: '2026-10-12' }; await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ hideZeroInventory: true })
|
||||
})
|
||||
|
||||
it('keeps sample diagnostics in statistics help and missing metrics distinct from zero', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
expect(w.findComponent(table).props('columns').some((column: { prop: string }) => column.prop === 'attentionReasons')).toBe(false)
|
||||
expect(w.text()).not.toContain('日样本 0/30')
|
||||
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
|
||||
expect(w.findAll('span[title="历史库存记录不足,暂不能计算期间平均库存和周转次数"]').map(n => n.text())).toEqual(['—', '—', '—'])
|
||||
// Object-valued native options retain the selected row, including its frozen evidence.
|
||||
const select = w.get('select[aria-label="选择历史库存记录"]')
|
||||
;(select.element as HTMLSelectElement).selectedIndex = 1
|
||||
await select.trigger('change')
|
||||
const calls = mocks.turnover.mock.calls.length
|
||||
await w.findAll('button').find(b => b.text() === '查看记录明细')!.trigger('click')
|
||||
expect(w.text()).toContain('历史库存记录 0/30天')
|
||||
expect(mocks.turnover).toHaveBeenCalledTimes(calls)
|
||||
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
|
||||
const result = response(q)
|
||||
result.rows[0] = { ...result.rows[0]!, averagePhysicalQuantity: 0, quantityTurnoverCount: 0, qualifiedSampleDays: 30 }
|
||||
return result
|
||||
})
|
||||
await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises()
|
||||
expect((w.get('select[aria-label="选择历史库存记录"]').element as HTMLSelectElement).selectedIndex).toBe(0)
|
||||
expect(w.find('span[title="历史库存记录不足,暂不能计算期间平均库存和周转次数"]').exists()).toBe(false)
|
||||
expect(w.text()).toContain('0 次/30日')
|
||||
})
|
||||
|
||||
it('defaults to volume ranking, switches locally and drills into the same owner snapshot', async () => {
|
||||
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
|
||||
const result = response(q)
|
||||
if (result.charts) {
|
||||
result.charts.ownerVolumeTop = [{ customerCode: 'OWNER-A', customerName: '货主甲', inventoryVolumeM3: { ...volume, value: 1234.56789 } }]
|
||||
result.charts.unknownVolumeOwnerCount = 2
|
||||
}
|
||||
return result
|
||||
})
|
||||
const w = page(); await flushPromises()
|
||||
expect(w.get('[aria-label="图表指标"] button[aria-pressed="true"]').text()).toBe('货主库存立方数 Top10')
|
||||
expect(w.text()).toContain('2 个货主因缺尺寸或库存数量未知,未参与排名')
|
||||
const chart = w.findComponent({ name: 'AnalyticsChart' })
|
||||
const option = chart.props('option') as { series: { data: number[]; label: { position: string; formatter: (p: {value: number}) => string } }[] }
|
||||
expect(option.series[0]!.data).toEqual([1234.56789])
|
||||
expect(option.series[0]!.label.position).toBe('right')
|
||||
expect(option.series[0]!.label.formatter({ value: 1234.56789 })).toBe('1,234.57 m³')
|
||||
const calls = mocks.turnover.mock.calls.length
|
||||
await w.get('[aria-label="图表指标"] button:last-child').trigger('click')
|
||||
expect(w.get('[aria-label="图表指标"] button[aria-pressed="true"]').text()).toBe('日均出库对比')
|
||||
await w.get('[aria-label="图表指标"] button:first-child').trigger('click')
|
||||
expect(mocks.turnover).toHaveBeenCalledTimes(calls)
|
||||
w.findComponent({ name: 'AnalyticsChart' }).vm.$emit('select', { dataIndex: 0 }); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCodes: ['OWNER-A'], groupBy: 'SKU', current: 1, snapshotContext: 'token-A' })
|
||||
})
|
||||
|
||||
it('shows customer and SKU volume, missing dimensions and estimates in the selected window', async () => {
|
||||
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
|
||||
const result = response(q)
|
||||
result.rows[0]!.inventoryVolumeM3 = { ...volume, value: null, knownSubtotal: 0.125, missingDimensionQuantity: 12, missingDimensionSkuCount: 2 }
|
||||
result.rows[0]!.outboundVolumesM3 = [{ days: q.baseWindowDays, startDate: '2026-10-10', endDateExclusive: '2026-10-13', volume: { ...volume, value: 2.5, declaredDimensionQuantity: 40 } }]
|
||||
return result
|
||||
})
|
||||
const w = page(); await flushPromises()
|
||||
expect(w.text()).toContain('缺尺寸 2 SKU / 12 件')
|
||||
expect(w.text()).toContain('已知小计 0.125 m³')
|
||||
expect(w.text()).toContain('其中 40 件按商品尺寸估算')
|
||||
expect(w.findComponent(table).props('columns').some((c: {prop: string}) => c.prop === 'unitVolumeM3')).toBe(true)
|
||||
await change(w, { groupBy: 'OWNER', rangePreset: 'LAST_3_DAYS' })
|
||||
expect(w.findComponent(table).props('columns').find((c: {prop: string}) => c.prop === 'outboundVolumesM3').label).toContain('3日')
|
||||
expect(w.findComponent(table).props('columns').some((c: {prop: string}) => c.prop === 'unitVolumeM3')).toBe(false)
|
||||
expect(w.text()).toContain('缺尺寸 2 SKU / 12 件')
|
||||
})
|
||||
|
||||
it('filters turnover by directory customer and keeps the code in pagination', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
expect(w.findComponent(toolbar).props('fields').find((f: { key: string }) => f.key === 'customerCode').options).toEqual([{ value: 'OWNER-A', label: '货主甲 (OWNER-A)' }])
|
||||
await change(w, { customerCode: 'OWNER-A' })
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCode: 'OWNER-A', current: 1, includeOverview: true })
|
||||
expect(w.findComponent(toolbar).props('fields').find((f: { key: string }) => f.key === 'customerCodes').options).toEqual([{ value: 'OWNER-A', label: '货主甲 (OWNER-A)' }])
|
||||
await change(w, { customerCodes: ['OWNER-A'] })
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCodes: ['OWNER-A'], current: 1, includeOverview: true })
|
||||
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCode: 'OWNER-A', current: 2, snapshotContext: 'token-A' })
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCodes: ['OWNER-A'], current: 2, snapshotContext: 'token-A' })
|
||||
})
|
||||
|
||||
it('sorts every numeric header on the server in both directions with the same snapshot', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
|
||||
const metrics = { physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', transitQuantity: 'TRANSIT', outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30', averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE', frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE', averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER' }
|
||||
const metrics = { inventoryVolumeM3: 'INVENTORY_VOLUME', outboundVolumesM3: 'OUTBOUND_VOLUME', unitVolumeM3: 'UNIT_VOLUME', physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', transitQuantity: 'TRANSIT', outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30', averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE', frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE', averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER' }
|
||||
for (const [prop, metric] of Object.entries(metrics)) {
|
||||
// Start on a later page and with another sort to prove that a header resets pagination.
|
||||
await change(w, { sortBy: metric === 'PHYSICAL' ? 'AVAILABLE_DESC' : 'PHYSICAL_DESC' }, true)
|
||||
@@ -56,6 +199,26 @@ describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
await w.get('button[data-column="averageDailyOutboundQuantity"]').trigger('click'); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ baseWindowDays: 7, sortBy: 'AVERAGE_DAILY_DESC' })
|
||||
})
|
||||
it.each(['SKU', 'OWNER'] as const)('sorts the default percentage column for %s and keeps formula help independent', async (groupBy) => {
|
||||
const w = page(); await flushPromises()
|
||||
await change(w, { groupBy, customerCodes: ['OWNER-A'], rangePreset: 'LAST_7_DAYS' }, true)
|
||||
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
|
||||
expect(w.find('button[data-column="quantityTurnoverCount"]').exists()).toBe(false)
|
||||
const before = mocks.turnover.mock.calls.length
|
||||
await w.get('button[aria-label="期间周转率计算说明"]').trigger('click'); await flushPromises()
|
||||
expect(mocks.turnover).toHaveBeenCalledTimes(before)
|
||||
expect(w.text()).toContain('期间周转率=所选 7 天完整出库量 ÷ 同期平均实物库存 × 100%')
|
||||
expect(w.text()).toContain('平均实物库存=同期每日末实物库存之和 ÷ 天数')
|
||||
const header = w.get('button[data-column="quantityTurnoverRate"]')
|
||||
for (const direction of ['DESC', 'ASC']) {
|
||||
await header.trigger('click'); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy, customerCodes: ['OWNER-A'], baseWindowDays: 7, endDate: '2026-10-12', hideZeroInventory: true, sortBy: `TURNOVER_${direction}`, current: 1, snapshotContext: 'token-A', includeOverview: false })
|
||||
expect(header.attributes('aria-label')).toContain(direction === 'ASC' ? '当前升序' : '当前降序')
|
||||
expect(w.findComponent(toolbar).props('modelValue').sortBy).toBe(`TURNOVER_${direction}`)
|
||||
}
|
||||
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
|
||||
expect(w.get('button[data-column="quantityTurnoverCount"]').attributes('aria-label')).toContain('当前升序')
|
||||
})
|
||||
it('keeps the successful sort marker and page when a header query fails', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
|
||||
@@ -70,6 +233,11 @@ describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
const w = page(); await flushPromises()
|
||||
await change(w, { groupBy: 'OWNER' })
|
||||
expect(w.find('button[data-column="inventoryCoverageDays"]').exists()).toBe(false)
|
||||
expect(w.find('button[data-column="unitVolumeM3"]').exists()).toBe(false)
|
||||
for (const [prop, metric] of [['inventoryVolumeM3', 'INVENTORY_VOLUME'], ['outboundVolumesM3', 'OUTBOUND_VOLUME']]) {
|
||||
await w.get(`button[data-column="${prop}"]`).trigger('click'); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', sortBy: `${metric}_DESC` })
|
||||
}
|
||||
await w.get('button[data-column="averageDailyOutboundQuantity"]').trigger('click'); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', sortBy: 'AVERAGE_DAILY_DESC' })
|
||||
w.findComponent(toolbar).vm.$emit('reset'); await flushPromises()
|
||||
@@ -89,6 +257,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
return result
|
||||
})
|
||||
const w = page(); await flushPromises()
|
||||
await w.get('[aria-label="图表指标"] button:last-child').trigger('click')
|
||||
const chart = w.findComponent({ name: 'AnalyticsChart' }).props('option') as { series: { data: (number | null)[]; label: { show: boolean; formatter: (params: { value: number | null }) => string } }[] }
|
||||
expect(chart.series[0]!.label.show).toBe(true)
|
||||
expect(chart.series[0]!.label.formatter({ value: chart.series[0]!.data[0]! })).toBe('1,234.57')
|
||||
@@ -145,14 +314,14 @@ describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
expect(w.text()).toContain('当前库存已过期')
|
||||
expect(w.text()).toContain('库存分页出现重复记录')
|
||||
expect(w.text()).toContain('LXCOL-TEST-STOCK')
|
||||
expect(w.findAll('.turnover-cards article strong').map(n => n.text())).toEqual(['—', '100', '—', '100'])
|
||||
expect(w.findAll('.turnover-cards article small')[2]!.text()).toBe('已知小计 0 · 5402 SKU待核验')
|
||||
expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong').map(n => n.text())).toEqual(['—', '100', '—', '100'])
|
||||
expect(w.get('article[data-metric="no-outbound"] small').text()).toBe('已知小计 0 · 5402 SKU待核验')
|
||||
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A', dataFamily: 'INVENTORY', currentTask: { taskNo: 'LXCOL-TEST-STOCK', taskStatus: 'SUCCEEDED' } }] })
|
||||
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => { const result = response(q); result.context.analyticsContext.inventoryStatus = 'VALID'; return result })
|
||||
await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises()
|
||||
expect(mocks.overview).toHaveBeenCalledTimes(2)
|
||||
expect(w.text()).not.toContain('当前库存已过期'); expect(w.text()).not.toContain('库存分页出现重复记录')
|
||||
expect(w.findAll('.turnover-cards article strong')[0]!.text()).toBe('100')
|
||||
expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong')[0]!.text()).toBe('100')
|
||||
})
|
||||
it('does not show another warehouse failure and separates runtime refresh failure from report data', async () => {
|
||||
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A', dataFamily: 'INVENTORY' }, { warehouseCode: 'B', dataFamily: 'INVENTORY', currentTask: { taskNo: 'LXCOL-OTHER', taskStatus: 'DEAD', latestAttempt: { status: 'FAILED', errorCode: 'SELFOP_STOCK_DUPLICATE' } } }] })
|
||||
@@ -162,7 +331,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises()
|
||||
expect(w.text()).toContain('采集状态更新失败')
|
||||
expect(w.text()).not.toContain('库存周转读取失败')
|
||||
expect(w.findAll('.turnover-cards article strong')[0]!.text()).toBe('100')
|
||||
expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong')[0]!.text()).toBe('100')
|
||||
})
|
||||
it('renders quantity and window cells through the shared table slot contract', async () => {
|
||||
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
|
||||
@@ -173,10 +342,10 @@ describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
const w = page(); await flushPromises()
|
||||
expect(w.text()).not.toContain('[object Object]')
|
||||
expect(w.text()).not.toContain('UNASSESSABLE')
|
||||
expect(w.text()).toContain('数据不足/待观察')
|
||||
expect(w.text()).not.toContain('日样本 0/30')
|
||||
expect(w.text()).toContain('300')
|
||||
expect(w.text()).toContain('暂无可排名的库存数据')
|
||||
expect(w.findAll('.turnover-cards article strong').map(n => n.text())).toEqual(['100', '100', '100', '100'])
|
||||
expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong').map(n => n.text())).toEqual(['100', '100', '100', '100'])
|
||||
expect(w.findAllComponents({ name: 'AppDialog' }).every(d => d.attributes('append-to-body') !== undefined)).toBe(true)
|
||||
})
|
||||
it('honors an explicit new route token in the same scope and reloads its overview', async () => {
|
||||
@@ -196,7 +365,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-A', includeOverview: false, sku: 'SAME', current: 1 })
|
||||
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-A', current: 2, includeOverview: false })
|
||||
expect(w.findAll('.turnover-cards article')).toHaveLength(4)
|
||||
expect(w.findAll('.turnover-cards article')).toHaveLength(6)
|
||||
})
|
||||
it('rejects unauthorized deep links and does not query without warehouses or permission', async () => {
|
||||
mocks.route.query = { warehouseCode: 'NO' }; const w = page(); await flushPromises()
|
||||
@@ -223,7 +392,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', sku: undefined, sortBy: 'PHYSICAL_DESC', snapshotContext: 'token-A', includeOverview: true })
|
||||
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
|
||||
await w.findAll('button').find(b => b.text() === 'OWNER-A')!.trigger('click'); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'SKU', customerCode: 'OWNER-A', snapshotContext: 'token-A' })
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'SKU', customerCodes: ['OWNER-A'], snapshotContext: 'token-A' })
|
||||
await w.findAll('button').find(b => b.text() === '返回货主汇总')!.trigger('click'); await flushPromises()
|
||||
expect(w.findComponent(table).props('page')).toBe(2)
|
||||
expect(w.findComponent(toolbar).props('modelValue').groupBy).toBe('OWNER')
|
||||
@@ -240,6 +409,14 @@ describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
expect(w.findComponent(table).props('data')).toHaveLength(1)
|
||||
expect(w.text()).toContain('保留')
|
||||
})
|
||||
it('does not reserve an empty overview row or claim previous results on initial failure', async () => {
|
||||
mocks.turnover.mockRejectedValue(new Error('库存周转不接受其他视图的查询参数'))
|
||||
const w = page(); await flushPromises()
|
||||
expect(w.find('.turnover-context').exists()).toBe(false)
|
||||
expect(w.get('.turnover-filter-feedback').text()).toContain('库存周转读取失败')
|
||||
expect(w.text()).not.toContain('当前保留上次结果')
|
||||
expect(w.get('.turnover-zero-filter input').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
it('passes exact SKU identity and fixed versions into trend and cancels a closed request', async () => {
|
||||
let finish!: (value: object) => void
|
||||
mocks.daily.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
|
||||
@@ -16,14 +16,16 @@ import { RequestFence, beijingTime, contextParams } from '../state'
|
||||
import type { DailyResponse, Overview } from '../types'
|
||||
import { overviewRange, overviewRangeOptions, beijingDate } from '../overviewRange'
|
||||
import type { RangePreset } from '../types'
|
||||
import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow } from '../turnoverTypes'
|
||||
import { turnoverRoute, validateTurnover, yesterdayBeijing } from '../turnoverNavigation'
|
||||
import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow, TurnoverTotal, TurnoverVolume } from '../turnoverTypes'
|
||||
import { normalizeTurnoverOwners, turnoverRoute, validateTurnover, yesterdayBeijing } from '../turnoverNavigation'
|
||||
import { turnoverSortColumns, turnoverSortOptions } from '../turnoverSorting'
|
||||
import { totalText, turnoverTotalNote, inventoryCollectionNotice, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation'
|
||||
import { totalText, turnoverTotalNote, volumeText, volumeNote, dimensionSourceText, inventoryCollectionNotice, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation'
|
||||
import AnalyticsChart from './AnalyticsChart.vue'
|
||||
import ChartDetailsDialog from './ChartDetailsDialog.vue'
|
||||
import SkuTrend from './SkuTrend.vue'
|
||||
import { useOwnerDirectory } from '../useOwnerDirectory'
|
||||
import { turnoverPercent } from '../turnoverPresentation'
|
||||
import { useExportTask } from '@/modules/downloads'
|
||||
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const ownerDirectory = useOwnerDirectory()
|
||||
@@ -32,12 +34,28 @@ class TurnoverContractError extends Error {}
|
||||
const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
|
||||
const runtime = ref<Overview | null>(null), data = ref<TurnoverResponse | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null), inputError = ref(''), notice = ref(''), loading = ref(false)
|
||||
const filters = ref<QueryModel>({ warehouseCode: '', endDate: yesterdayBeijing(), baseWindowDays: 30, rangePreset: 'LAST_30_DAYS', customRange: [], groupBy: 'SKU', sku: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' })
|
||||
const filters = ref<QueryModel>({ warehouseCode: '', endDate: yesterdayBeijing(), baseWindowDays: 30, rangePreset: 'LAST_30_DAYS', customRange: [], groupBy: 'SKU', hideZeroInventory: true, sku: '', customerCodes: [], focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' })
|
||||
const page = ref(1), size = ref(20), expanded = ref(false), chartsOpen = ref(true)
|
||||
const rightChart = ref<'volume' | 'daily'>('volume')
|
||||
const fence = new RequestFence(), runtimeFence = new RequestFence(), trendFence = new RequestFence()
|
||||
const trendOpen = ref(false), trendLoading = ref(false), daily = ref<DailyResponse | null>(null), trendError = ref<ApiErrorDetails | null>(null)
|
||||
const evidence = ref<TurnoverRow | null>(null), evidenceOpen = ref(false)
|
||||
const applied = ref<TurnoverQuery | null>(null)
|
||||
const exporting = ref(false), exportFeedback = ref('')
|
||||
const submitExport = useExportTask()
|
||||
const canExport = computed(() => access.can('platform:self-operated-analytics:export'))
|
||||
async function exportRows() {
|
||||
if (exporting.value || loading.value || !canExport.value || !applied.value || !data.value) return
|
||||
const { current: _current, size: _size, includeOverview: _overview, ...query } = applied.value
|
||||
const parameters = { ...query, customerCodes: [...(query.customerCodes ?? [])], snapshotContext: data.value.context.snapshotContext,
|
||||
columns: columns.value.map(c => String(c.prop)), expectedTotal: Number(data.value.total) }
|
||||
exporting.value = true; exportFeedback.value = ''
|
||||
try {
|
||||
await submitExport('SELFOP_INVENTORY_TURNOVER', `库存周转_${query.warehouseCode}_${query.endDate}`, parameters)
|
||||
exportFeedback.value = '导出任务已提交,请到下载中心查看进度和下载 Excel'
|
||||
} catch (cause) { exportFeedback.value = getApiErrorDetails(cause, '导出任务提交失败,请重试').message }
|
||||
finally { exporting.value = false }
|
||||
}
|
||||
let linkedSnapshot: string | undefined
|
||||
let ownerReturn: { query: TurnoverQuery; result: TurnoverResponse; filters: QueryModel } | null = null
|
||||
let initialized = false, active = true
|
||||
@@ -47,23 +65,32 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'rangePreset', label: '统计时间范围', type: 'select', width: 180, props: { clearable: false }, options: overviewRangeOptions },
|
||||
...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'customRange', label: '日期范围', type: 'daterange' as const, width: 300, props: { disabledDate: (date: Date) => date.toLocaleDateString('en-CA') > yesterdayBeijing() } }] : []),
|
||||
{ key: 'groupBy', label: '分组', type: 'select', width: 120, options: [{ value: 'SKU', label: '按SKU' }, { value: 'OWNER', label: '按货主' }], props: { clearable: false } },
|
||||
{ key: 'customerCode', label: '客户', type: 'select', width: 210, options: ownerDirectory.options.value, placeholder: '名称或编码搜索', props: { filterable: true, clearable: true } },
|
||||
{ key: 'customerCodes', label: '货主', type: 'select', width: 260, options: ownerDirectory.options.value, placeholder: '全部货主(可多选)', props: { multiple: true, multipleLimit: 100, collapseTags: true, collapseTagsTooltip: true, maxCollapseTags: 1, filterable: true, clearable: true, onChange: changeOwners } },
|
||||
...(filters.value.groupBy === 'SKU' ? [{ key: 'sku', label: 'SKU', type: 'input' as const, width: 175, placeholder: 'SKU精确查询' }] : []),
|
||||
{ key: 'focus', label: '关注条件', type: 'select', width: 165, props: { clearable: false }, options: [{ value: 'ALL', label: '全部' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT', label: '消化较慢' }, { value: 'COVERAGE_LE', label: '消化较快' }, { value: 'FROZEN', label: '有冻结库存' }, { value: 'DEFECTIVE', label: '有次品库存' }, { value: 'UNASSESSABLE', label: '待评估' }] },
|
||||
{ key: 'turnoverStatus', label: '周转样本', type: 'select', width: 160, options: [{ value: '', label: '全部样本状态' }, { value: 'INSUFFICIENT', label: '周转样本不足' }] },
|
||||
{ key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSortOptions.filter(({ value }) => filters.value.groupBy === 'OWNER' ? !value.startsWith('COVERAGE_') : value !== 'NO_OUTBOUND_PHYSICAL_DESC') },
|
||||
{ key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSortOptions.filter(({ value }) => filters.value.groupBy === 'OWNER' ? !value.startsWith('COVERAGE_') && !value.startsWith('UNIT_VOLUME_') : value !== 'NO_OUTBOUND_PHYSICAL_DESC') },
|
||||
])
|
||||
const selectedOwners = computed(() => Array.isArray(filters.value.customerCodes) ? filters.value.customerCodes as string[] : [])
|
||||
function ownerLabel(code: string) { return ownerDirectory.options.value.find(option => option.value === code)?.label ?? code }
|
||||
function changeOwners(codes: string[]) {
|
||||
filters.value = { ...filters.value, customerCodes: [...codes] }
|
||||
void search()
|
||||
}
|
||||
const ownerMode = computed(() => applied.value?.groupBy === 'OWNER')
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
...(ownerMode.value ? [] : [{ prop: 'sku', label: 'SKU', slotName: 'sku', minWidth: 155 }, { prop: 'productName', label: '商品名称', minWidth: 170 }]),
|
||||
{ prop: 'customerCode', label: '所属货主', slotName: 'owner', minWidth: 160 },
|
||||
...(ownerMode.value ? [{ prop: 'physicalSkuCount', label: '有实物SKU数', minWidth: 125 }] : []),
|
||||
{ prop: 'physicalQuantity', label: '实物件数', slotName: 'physicalQuantity', minWidth: 145 }, { prop: 'availableQuantity', label: '正品可售', slotName: 'availableQuantity', minWidth: 145 },
|
||||
{ prop: 'inventoryVolumeM3', label: '库存立方数', slotName: 'inventoryVolume', minWidth: 145 },
|
||||
{ prop: 'outboundVolumesM3', label: `${applied.value?.baseWindowDays ?? 30}日出库立方数`, slotName: 'outboundVolume', minWidth: 170 },
|
||||
...(ownerMode.value ? [] : [{ prop: 'unitVolumeM3', label: '单件体积', slotName: 'unitVolume', minWidth: 135 }]),
|
||||
{ prop: 'transitQuantity', label: '在途', slotName: 'transitQuantity', minWidth: 125 },
|
||||
...([7, 15, 30] as const).map(n => ({ prop: `outbound${n}`, label: `${n}日出库`, slotName: `outbound${n}`, minWidth: 160 })),
|
||||
{ prop: 'averageDailyOutboundQuantity', label: `${applied.value?.baseWindowDays ?? 30}日日均`, slotName: 'daily', minWidth: 110 },
|
||||
{ prop: 'quantityTurnoverRate', label: '期间周转率', slotName: 'turnoverRate', minWidth: 180 },
|
||||
...(ownerMode.value ? [{ prop: 'noOutboundInWindow', label: '无出库SKU / 实物', slotName: 'noOutbound', minWidth: 190 }, { prop: 'slowCoverage', label: '慢消化SKU / 待判断', slotName: 'slow', minWidth: 175 }] : [{ prop: 'inventoryCoverageDays', label: '预计可售天数', slotName: 'coverage', minWidth: 150 }]),
|
||||
{ prop: 'attentionReasons', label: '状态与样本', slotName: 'status', minWidth: 180 },
|
||||
...(expanded.value ? [{ prop: 'frozenQuantity', label: '冻结件数', slotName: 'frozenQuantity', minWidth: 125 }, { prop: 'defectiveQuantity', label: '次品实物', slotName: 'defectiveQuantity', minWidth: 125 }, { prop: 'averagePhysicalQuantity', label: '期间平均实物', slotName: 'averageStock', minWidth: 145 }, { prop: 'quantityTurnoverCount', label: '期间件数周转', slotName: 'turnover', minWidth: 145 }, { prop: 'lastObservedOutboundDate', label: '最近已观测出库日', slotName: 'lastDate', minWidth: 180 }] : []),
|
||||
].map(column => ({ ...column, headerSlotName: turnoverSortColumns[column.prop] ? 'sortHeader' : undefined })))
|
||||
function columnSortDirection(prop: string) {
|
||||
@@ -78,11 +105,26 @@ async function sortColumn(prop: string) {
|
||||
filters.value = { ...filters.value, sortBy }
|
||||
await search()
|
||||
}
|
||||
const cards = computed(() => data.value?.summary ? [
|
||||
{ name: '实物件数', value: data.value.summary.physicalQuantity }, { name: '正品可售件数', value: data.value.summary.availableQuantity },
|
||||
{ name: '所选时段无出库SKU', value: data.value.summary.noOutboundSkuCount }, { name: '待评估SKU', value: data.value.summary.unassessableSkuCount },
|
||||
] : [])
|
||||
const incompleteDailyWindows = computed(() => data.value?.charts?.averageDaily.filter(w => w.dataStatus !== 'COMPLETE' || w.averageDailyOutboundQuantity == null) ?? [])
|
||||
const summaryGroups = computed(() => {
|
||||
const summary = data.value?.summary
|
||||
if (!summary) return []
|
||||
const quantity = (key: string, name: string, value: TurnoverTotal, tone: string) => ({ key, name, kind: 'quantity', tone, text: turnoverNumber(value.value), note: turnoverTotalNote(value), title: undefined as string | undefined })
|
||||
const volume = (key: string, name: string, value: TurnoverVolume | undefined, outbound: boolean, tone: string) => ({ key, name, kind: 'volume', tone, text: turnoverNumber(value?.value, 2), note: volumeNote(value, outbound, 2), title: value?.value != null ? volumeText(value.value) : undefined })
|
||||
const days = applied.value?.baseWindowDays ?? 30
|
||||
return [
|
||||
{ key: 'stock', name: '当前库存', note: '使用当前库存快照,不随统计时段变化', cards: [
|
||||
quantity('physical', '实物件数', summary.physicalQuantity, 'primary'),
|
||||
quantity('available', '正品可售件数', summary.availableQuantity, 'green'),
|
||||
volume('inventory-volume', '库存立方数(m³)', summary.inventoryVolumeM3, false, 'purple'),
|
||||
] },
|
||||
{ key: 'period', name: '所选时段分析', note: `${days}天 · 截至 ${applied.value?.endDate ?? '—'}`, cards: [
|
||||
quantity('no-outbound', '所选时段无出库SKU', summary.noOutboundSkuCount, 'orange'),
|
||||
volume('outbound-volume', `${days}日出库立方数(m³)`, summary.outboundVolumesM3.find(w => w.days === days)?.volume, true, 'green'),
|
||||
quantity('unassessable', '待评估SKU', summary.unassessableSkuCount, 'purple'),
|
||||
] },
|
||||
]
|
||||
})
|
||||
const runtimeError = ref('')
|
||||
const inventoryRuntime = computed(() => runtime.value?.warehouses.find(w => w.warehouseCode === applied.value?.warehouseCode && w.dataFamily === 'INVENTORY'))
|
||||
const collectionNotice = computed(() => !data.value || runtimeError.value ? '' : inventoryCollectionNotice(inventoryRuntime.value))
|
||||
@@ -94,6 +136,11 @@ async function refreshRuntime() {
|
||||
} catch { if (request.current()) runtimeError.value = '采集状态更新失败,请到数据采集查看;库存查询结果仍按其观测时间展示。' }
|
||||
}
|
||||
function castRow(row: unknown) { return row as TurnoverRow }
|
||||
function historicalMetricHint(row: TurnoverRow) {
|
||||
return row.qualifiedSampleDays < row.requiredSampleDays
|
||||
? '历史库存记录不足,暂不能计算期间平均库存和周转次数'
|
||||
: '暂不能计算;请在统计说明中查看历史库存记录及销量覆盖原因'
|
||||
}
|
||||
function restoreThreshold(warehouse: string) {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(`selfop-turnover-threshold:${warehouse}`)
|
||||
@@ -111,13 +158,13 @@ function query(refresh = false): TurnoverQuery {
|
||||
const anchor = !refresh && data.value ? beijingDate(Date.parse(data.value.context.queryAsOf)) : beijingDate(Date.now())
|
||||
const range = overviewRange(filters.value.rangePreset as RangePreset, anchor, String(custom[0] ?? ''), String(custom[1] ?? ''))
|
||||
const days = Math.round((Date.parse(range.endDate) - Date.parse(range.startDate)) / 86400000) + 1
|
||||
return { sourceAccount: 'lingxing-primary', warehouseCode: String(filters.value.warehouseCode || ''), endDate: range.endDate, baseWindowDays: days, groupBy: filters.value.groupBy as TurnoverQuery['groupBy'], customerCode: String(filters.value.customerCode || '') || undefined, sku: String(filters.value.sku || '') || undefined, focus: filters.value.focus as TurnoverQuery['focus'], coverageDaysThreshold: !['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.value.focus)) || filters.value.coverageDaysThreshold === '' || filters.value.coverageDaysThreshold == null ? undefined : Number(filters.value.coverageDaysThreshold), turnoverStatus: (filters.value.turnoverStatus || undefined) as TurnoverQuery['turnoverStatus'], sortBy: filters.value.sortBy as TurnoverSort, current: page.value, size: size.value, includeOverview: true }
|
||||
return { sourceAccount: 'lingxing-primary', warehouseCode: String(filters.value.warehouseCode || ''), endDate: range.endDate, baseWindowDays: days, groupBy: filters.value.groupBy as TurnoverQuery['groupBy'], customerCodes: normalizeTurnoverOwners(filters.value.customerCodes), sku: String(filters.value.sku || '') || undefined, focus: filters.value.focus as TurnoverQuery['focus'], hideZeroInventory: filters.value.hideZeroInventory !== false, coverageDaysThreshold: !['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.value.focus)) || filters.value.coverageDaysThreshold === '' || filters.value.coverageDaysThreshold == null ? undefined : Number(filters.value.coverageDaysThreshold), turnoverStatus: (filters.value.turnoverStatus || undefined) as TurnoverQuery['turnoverStatus'], sortBy: filters.value.sortBy as TurnoverSort, current: page.value, size: size.value, includeOverview: true }
|
||||
}
|
||||
function scopeKey(q: TurnoverQuery) { return [q.warehouseCode, q.endDate, q.baseWindowDays].join('|') }
|
||||
function overviewKey(q: TurnoverQuery) { return [scopeKey(q), q.customerCode, q.groupBy].join('|') }
|
||||
function overviewKey(q: TurnoverQuery) { return [scopeKey(q), JSON.stringify(q.customerCodes ?? []), q.groupBy].join('|') }
|
||||
async function load(refresh = false) {
|
||||
if (!permitted.value || !active) return
|
||||
fence.cancel(); inputError.value = ''; evidenceOpen.value = false; trendOpen.value = false
|
||||
fence.cancel(); inputError.value = ''; evidenceOpen.value = false; evidence.value = null; trendOpen.value = false
|
||||
let q: TurnoverQuery
|
||||
try { q = query(refresh) } catch (cause) { loading.value = false; inputError.value = (cause as Error).message; return }
|
||||
if (applied.value && applied.value.warehouseCode !== q.warehouseCode) data.value = null
|
||||
@@ -149,9 +196,13 @@ async function load(refresh = false) {
|
||||
finally { if (request.current()) loading.value = false }
|
||||
}
|
||||
async function search() { page.value = 1; await load() }
|
||||
function toggleZeroInventory(checked: string | number | boolean) {
|
||||
filters.value = { ...filters.value, hideZeroInventory: checked === true }
|
||||
void search()
|
||||
}
|
||||
function reset() {
|
||||
const warehouseCode = filters.value.warehouseCode || applied.value?.warehouseCode || warehouses.value[0]?.value || ''
|
||||
filters.value = { warehouseCode, endDate: yesterdayBeijing(), baseWindowDays: 30, rangePreset: 'LAST_30_DAYS', customRange: [], groupBy: 'SKU', sku: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' }
|
||||
filters.value = { warehouseCode, endDate: yesterdayBeijing(), baseWindowDays: 30, rangePreset: 'LAST_30_DAYS', customRange: [], groupBy: 'SKU', hideZeroInventory: true, sku: '', customerCodes: [], focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' }
|
||||
ownerReturn = null; page.value = 1; void load(true)
|
||||
}
|
||||
function changeFilters(next: QueryModel) {
|
||||
@@ -159,20 +210,20 @@ function changeFilters(next: QueryModel) {
|
||||
if (next.groupBy !== filters.value.groupBy) {
|
||||
next = { ...next, sku: '', sortBy: 'PHYSICAL_DESC' }; notice.value = '已切换分组,SKU条件及专属排序已清除'
|
||||
}
|
||||
const changed = ['warehouseCode', 'rangePreset', 'groupBy', 'customerCode'].some(k => next[k] !== filters.value[k])
|
||||
const changed = ['warehouseCode', 'rangePreset', 'groupBy', 'customerCodes'].some(k => next[k] !== filters.value[k])
|
||||
if (next.warehouseCode !== filters.value.warehouseCode) next = { ...next, coverageDaysThreshold: restoreThreshold(String(next.warehouseCode || '')) }
|
||||
filters.value = next
|
||||
if (changed) { page.value = 1; void load() }
|
||||
}
|
||||
function selectOwner(code: string) {
|
||||
if (applied.value?.groupBy === 'OWNER' && data.value) ownerReturn = { query: { ...applied.value }, result: data.value, filters: { ...filters.value, customRange: [...(filters.value.customRange as string[] ?? [])] } }
|
||||
filters.value = { ...filters.value, groupBy: 'SKU', customerCode: code, sku: '', sortBy: 'PHYSICAL_DESC' }; page.value = 1; void load()
|
||||
if (applied.value?.groupBy === 'OWNER' && data.value) ownerReturn = { query: { ...applied.value }, result: data.value, filters: { ...filters.value, customerCodes: [...selectedOwners.value], customRange: [...(filters.value.customRange as string[] ?? [])] } }
|
||||
filters.value = { ...filters.value, groupBy: 'SKU', customerCodes: [code], sku: '', sortBy: 'PHYSICAL_DESC' }; page.value = 1; void load()
|
||||
}
|
||||
function returnOwners() {
|
||||
if (!ownerReturn) return
|
||||
if (Date.parse(ownerReturn.result.context.queryExpiresAt) <= Date.now()) { inputError.value = '原货主汇总版本已过期,请整体刷新'; return }
|
||||
fence.cancel(); trendOpen.value = false; evidenceOpen.value = false; loading.value = false; error.value = null
|
||||
const saved = ownerReturn; filters.value = { ...saved.filters, ...saved.query, sku: '', customerCode: saved.query.customerCode || '', coverageDaysThreshold: saved.query.coverageDaysThreshold ?? '', turnoverStatus: saved.query.turnoverStatus || '' }
|
||||
fence.cancel(); trendOpen.value = false; evidenceOpen.value = false; evidence.value = null; loading.value = false; error.value = null
|
||||
const saved = ownerReturn; filters.value = { ...saved.filters, ...saved.query, sku: '', customerCodes: [...(saved.query.customerCodes ?? [])], coverageDaysThreshold: saved.query.coverageDaysThreshold ?? '', turnoverStatus: saved.query.turnoverStatus || '' }
|
||||
applied.value = saved.query; page.value = saved.query.current; data.value = saved.result; ownerReturn = null
|
||||
}
|
||||
async function showTrend(row: TurnoverRow) {
|
||||
@@ -194,7 +245,7 @@ watch(trendOpen, open => { if (!open) { trendFence.cancel(); daily.value = null;
|
||||
function readRoute() {
|
||||
try {
|
||||
const initial = turnoverRoute(route.query); linkedSnapshot = initial.snapshotContext
|
||||
filters.value = { ...filters.value, ...initial, ...(initial.endDate ? { rangePreset: 'CUSTOM', customRange: [new Date(Date.parse(initial.endDate) - ((initial.baseWindowDays ?? 30) - 1) * 86400000).toISOString().slice(0, 10), initial.endDate] } : {}) }; page.value = initial.current ?? 1; size.value = initial.size ?? 20
|
||||
filters.value = { ...filters.value, hideZeroInventory: true, ...initial, ...(initial.endDate ? { rangePreset: 'CUSTOM', customRange: [new Date(Date.parse(initial.endDate) - ((initial.baseWindowDays ?? 30) - 1) * 86400000).toISOString().slice(0, 10), initial.endDate] } : {}) }; page.value = initial.current ?? 1; size.value = initial.size ?? 20
|
||||
return true
|
||||
} catch (cause) { fence.cancel(); data.value = null; inputError.value = (cause as Error).message; return false }
|
||||
}
|
||||
@@ -225,34 +276,89 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<p v-if="inputError" role="alert">{{ inputError }}</p><p v-if="notice" role="status">{{ notice }}</p>
|
||||
<DataTable :data="data?.rows ?? []" :columns="columns" :loading="loading" :total="data?.total ?? 0" :page="page" :page-size="size" appearance="integrated" :empty-text="!warehouses.length ? '暂无可用仓库' : error ? '读取失败,请重试' : '暂无符合条件的数据'" @update:page="page = $event; load()" @update:page-size="size = $event; search()">
|
||||
<template #sortHeader="{ column }">
|
||||
<span class="turnover-sort-heading">
|
||||
<button type="button" class="turnover-sort" :data-column="column.property" :disabled="loading" :aria-label="`${column.label}:${columnSortDirection(column.property) === 'ASC' ? '当前升序,' : columnSortDirection(column.property) === 'DESC' ? '当前降序,' : ''}点击${columnSortDirection(column.property) === 'DESC' ? '升序' : '降序'}排列`" @click.stop="sortColumn(column.property)">
|
||||
{{ column.label }}
|
||||
<span class="turnover-sort-arrows" aria-hidden="true"><span :class="{ active: columnSortDirection(column.property) === 'ASC' }">▲</span><span :class="{ active: columnSortDirection(column.property) === 'DESC' }">▼</span></span>
|
||||
</button>
|
||||
<el-popover v-if="column.property === 'quantityTurnoverRate'" title="期间周转率计算说明" trigger="click" placement="bottom" :width="380" teleported>
|
||||
<template #reference><el-button link aria-label="期间周转率计算说明" title="查看计算公式" @click.stop><Info :size="14" /></el-button></template>
|
||||
<div class="turnover-rules">
|
||||
<p>期间周转率=所选 {{ applied?.baseWindowDays ?? 30 }} 天完整出库量 ÷ 同期平均实物库存 × 100%。</p>
|
||||
<p>平均实物库存=同期每日末实物库存之和 ÷ 天数。历史库存由流水还原并核对;包含冻结、次品实物,不含在途。</p>
|
||||
<p>货主汇总使用汇总出库量 ÷ 汇总平均实物库存,不取各 SKU 百分比的平均值。</p>
|
||||
<p>缺少完整销量、合格历史库存或平均库存为 0 时显示“—”;当前库存不代替历史平均库存。100% 表示所选期间出库量相当于平均库存周转一次。</p>
|
||||
<p>点击列名切换从高到低/从低到高,对全部匹配记录排序,“—”排在最后。</p>
|
||||
</div>
|
||||
</el-popover>
|
||||
</span>
|
||||
</template>
|
||||
<template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title">库存周转<span>销量 · 库存 · 周转效率</span></h2></slot></template>
|
||||
<template #toolbar-actions><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules"><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷每日首次合格计划库存的实物均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p></div></el-popover></template>
|
||||
<template #toolbar-actions><el-button v-if="canExport" :disabled="loading || !data || exporting" title="后台导出全部匹配记录与当前显示列,在下载中心下载 Excel" @click="exportRows">{{ exporting ? '提交中…' : '导出' }}</el-button><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules"><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷流水还原并经库存锚点核对的每日末实物库存均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p>
|
||||
<p>历史库存记录用于计算期间平均库存和周转次数,与已采集的销量天数不同;记录不足不代表销量采集失败。</p>
|
||||
<template v-if="data?.rows.length">
|
||||
<label>查看本页历史库存记录
|
||||
<select v-model="evidence" aria-label="选择历史库存记录" :disabled="loading">
|
||||
<option :value="null">请选择商品或货主</option>
|
||||
<option v-for="row in data.rows" :key="JSON.stringify([row.customerCode, row.sku])" :value="row">{{ row.customerName || row.customerCode }} · {{ row.sku || '货主汇总' }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<el-button link :disabled="!evidence || loading" @click="evidenceOpen = true">查看记录明细</el-button>
|
||||
</template>
|
||||
</div></el-popover></template>
|
||||
<template #filters>
|
||||
<p v-if="ownerDirectory.error.value" role="alert">{{ ownerDirectory.error.value }}</p>
|
||||
<QueryToolbar :model-value="filters" :fields="fields" ariaLabel="库存周转查询" preference-key="selfop-turnover-query" @update:model-value="changeFilters" @search="search" @reset="reset" />
|
||||
</template>
|
||||
<template #before-results>
|
||||
<div class="turnover-context">
|
||||
<template v-if="filters.customerCode"><el-tag closable @close="filters.customerCode = ''; search()">货主:{{ filters.customerCode }}</el-tag><el-button v-if="ownerReturn" link @click="returnOwners">返回货主汇总</el-button></template>
|
||||
<template v-if="data"><span>{{ applied?.customerCode ? '本货主概览' : '本仓概览' }} · 截止 {{ applied?.endDate }} · 基准 {{ applied?.baseWindowDays }} 天</span><span>当前库存采集 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}</span><span v-if="error">保留 {{ beijingTime(data.context.queryAsOf) }} 查询结果</span></template>
|
||||
<div v-if="data || selectedOwners.length" class="turnover-context">
|
||||
<template v-if="selectedOwners.length"><el-tag v-for="code in selectedOwners" :key="code" closable @close="changeOwners(selectedOwners.filter(item => item !== code))">{{ ownerLabel(code) }}</el-tag><el-button v-if="ownerReturn" link @click="returnOwners">返回货主汇总</el-button></template>
|
||||
<template v-if="data"><span>{{ applied?.customerCodes?.length ? '所选货主概览' : '本仓概览' }} · 截止 {{ applied?.endDate }} · 基准 {{ applied?.baseWindowDays }} 天</span><span>当前库存采集 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}</span><span v-if="error">保留 {{ beijingTime(data.context.queryAsOf) }} 查询结果</span></template>
|
||||
</div>
|
||||
<p v-if="data?.context.analyticsContext.inventoryStatus === 'STALE'" class="turnover-context" role="status">当前库存已过期,实物、可售、货主排名及预计可售天数暂不可用;最近库存观测完成于 {{ beijingTime(data.context.inventoryObservedUntil) }}。采集成功后请整体刷新。</p>
|
||||
<p v-else-if="data?.context.analyticsContext.inventoryStatus === 'UNKNOWN'" class="turnover-context" role="status">当前尚无可用库存版本,请查看数据采集。</p>
|
||||
<p v-if="collectionNotice" class="turnover-context" role="status">{{ collectionNotice }};采集状态截至 {{ beijingTime(inventoryRuntime?.observedAt) }}。<el-button link @click="router.push('/self-operated/collection')">查看数据采集</el-button></p>
|
||||
<p v-if="runtimeError" class="turnover-context" role="status">{{ runtimeError }}</p>
|
||||
<div v-if="cards.length" class="turnover-cards"><article v-for="card in cards" :key="card.name"><span>{{ card.name }}</span><strong>{{ turnoverNumber(card.value.value) }}</strong><small>{{ turnoverTotalNote(card.value) }}</small></article></div>
|
||||
<div v-if="summaryGroups.length" class="turnover-summary">
|
||||
<section v-for="group in summaryGroups" :key="group.key" class="turnover-summary-group" :data-group="group.key" :aria-label="group.name">
|
||||
<header><h4>{{ group.name }}</h4><span>{{ group.note }}</span></header>
|
||||
<div class="turnover-cards">
|
||||
<article v-for="card in group.cards" :key="card.key" :data-metric="card.key" :data-kind="card.kind" :data-tone="card.tone">
|
||||
<span>{{ card.name }}</span><strong :title="card.title">{{ card.text }}</strong><small>{{ card.note }}</small>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p v-if="data" class="turnover-context">体积优先采用完整仓库实测尺寸,缺失时按商品尺寸估算;库存按当前全品质实物件数计算,历史出库按当前商品尺寸估算,不含在途及库位空隙。缺尺寸或数量未知时展示已知小计。</p>
|
||||
<template v-if="data?.charts">
|
||||
<div class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCode ? '本货主' : '本仓' }}整体,不随列表SKU或关注筛选改变</span></div>
|
||||
<div class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCodes?.length ? '所选货主' : '本仓' }}整体,不随列表SKU或关注筛选改变</span></div>
|
||||
<div v-if="chartsOpen" class="turnover-charts">
|
||||
<section><header class="turnover-chart-header"><h3>货主实物件数 Top10</h3><ChartDetailsDialog inline button-label="查看明细" title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></header><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p v-if="data.charts.unknownOwnerCount" class="turnover-chart-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p></section>
|
||||
<section><header class="turnover-chart-header"><h3>日均出库对比(含所选时段)</h3><ChartDetailsDialog inline button-label="查看明细" title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></header><p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div v-if="incompleteDailyWindows.length" class="turnover-window-gaps" role="status"><span v-for="window in incompleteDailyWindows" :key="window.days">{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? '日均暂不可用' : '覆盖不足' }}(完整 {{ window.completeDayCount }} / {{ window.days }} 日)</span></div></section>
|
||||
<section aria-label="库存体积与出库对比">
|
||||
<header class="turnover-chart-header">
|
||||
<div class="density-status-tabs" role="group" aria-label="图表指标">
|
||||
<button type="button" :class="{ 'is-active': rightChart === 'volume' }" :aria-pressed="rightChart === 'volume'" @click="rightChart = 'volume'">货主库存立方数 Top10</button>
|
||||
<button type="button" :class="{ 'is-active': rightChart === 'daily' }" :aria-pressed="rightChart === 'daily'" @click="rightChart = 'daily'">日均出库对比</button>
|
||||
</div>
|
||||
<ChartDetailsDialog v-if="rightChart === 'volume'" key="volume" inline button-label="查看明细" title="货主库存立方数明细" :reset-on="data.context.snapshotContext">
|
||||
<p>当前库存,不随统计时段变化;按完整体积排名,不含在途及库位空隙。</p>
|
||||
<p v-if="data.charts.unknownVolumeOwnerCount">{{ data.charts.unknownVolumeOwnerCount }} 个货主因缺尺寸或库存数量未知,未参与排名。</p>
|
||||
<table class="density-table"><thead><tr><th>货主</th><th>库存立方数</th><th>计算说明</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerVolumeTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td :title="volumeText(owner.inventoryVolumeM3.value)">{{ volumeText(owner.inventoryVolumeM3.value, 2) }}</td><td>{{ volumeNote(owner.inventoryVolumeM3) }}</td></tr></tbody></table>
|
||||
</ChartDetailsDialog>
|
||||
<ChartDetailsDialog v-else key="daily" inline button-label="查看明细" title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog>
|
||||
</header>
|
||||
<template v-if="rightChart === 'volume'">
|
||||
<AnalyticsChart v-if="data.charts.ownerVolumeTop.length" key="volume" class="turnover-chart" :option="turnoverChart(data.charts, 'volume')" @select="event => { const owner = data?.charts?.ownerVolumeTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" />
|
||||
<div v-else class="turnover-chart-empty"><strong>暂无可排名的库存体积数据</strong><span>需有可用库存及有效尺寸;未知体积不按零参与排名</span></div>
|
||||
<p class="turnover-chart-note">当前库存,不随统计时段变化;尺寸口径见明细。<span v-if="data.charts.unknownVolumeOwnerCount">{{ data.charts.unknownVolumeOwnerCount }} 个货主因缺尺寸或库存数量未知,未参与排名。</span></p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div v-if="incompleteDailyWindows.length" class="turnover-window-gaps" role="status"><span v-for="window in incompleteDailyWindows" :key="window.days">{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? '日均暂不可用' : '覆盖不足' }}(完整 {{ window.completeDayCount }} / {{ window.days }} 日)</span></div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<div class="turnover-list-filters">
|
||||
<nav class="turnover-focus" aria-label="库存关注快捷筛选">
|
||||
<button type="button" :aria-pressed="applied?.focus === 'ALL'" @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部</button>
|
||||
<button type="button" :aria-pressed="applied?.focus === 'NO_OUTBOUND_IN_WINDOW'" @click="quickFocus('NO_OUTBOUND_IN_WINDOW', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">所选时段无出库</button>
|
||||
@@ -260,35 +366,41 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<button type="button" :aria-pressed="applied?.focus === 'COVERAGE_LE'" @click="quickFocus('COVERAGE_LE', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_ASC')">消化较快</button>
|
||||
<button type="button" :aria-pressed="applied?.focus === 'UNASSESSABLE'" @click="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')">待评估</button>
|
||||
</nav>
|
||||
<el-checkbox class="turnover-zero-filter" border aria-label="隐藏 0 库存" title="按当前实物件数筛选;库存未知仍保留" :model-value="filters.hideZeroInventory !== false" :disabled="loading" @change="toggleZeroInventory">隐藏 0 库存</el-checkbox>
|
||||
</div>
|
||||
<div v-if="['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.focus))" class="turnover-threshold">
|
||||
<label>可售天数阈值 <input v-model="filters.coverageDaysThreshold" type="number" min="1" step="1" aria-label="可售天数阈值(天)" @keydown.enter.prevent="search()" /> 天</label>
|
||||
<el-button @click="search()">应用</el-button>
|
||||
<span>按当前可售库存预计还能销售的天数筛选:超过阈值为较慢,不超过阈值为较快。此数值不是上方的统计时间范围;快捷筛选首次默认30天,可修改。零可售或无出库不归为较快。</span>
|
||||
</div>
|
||||
<p v-if="inputError" class="turnover-filter-feedback" role="alert">{{ inputError }}</p>
|
||||
<p v-if="exportFeedback" class="turnover-context" role="status">{{ exportFeedback }}</p><p v-if="inputError" class="turnover-filter-feedback" role="alert">{{ inputError }}</p>
|
||||
<p v-else-if="loading" class="turnover-filter-feedback" role="status">正在更新筛选结果…</p>
|
||||
<p v-else-if="error" class="turnover-filter-feedback" role="alert">筛选未更新,当前保留上次结果。{{ error.message }}</p>
|
||||
<p v-else-if="error" class="turnover-filter-feedback" role="alert">{{ data ? '筛选未更新,当前保留上次结果。' : '库存周转读取失败。' }}{{ error.message }}</p>
|
||||
|
||||
</template>
|
||||
<template #sku="{ row }"><el-button link type="primary" @click="showTrend(castRow(row))">{{ castRow(row).sku }}</el-button></template>
|
||||
<template #owner="{ row }"><el-button v-if="ownerMode" link type="primary" @click="selectOwner(castRow(row).customerCode)">{{ castRow(row).customerName || castRow(row).customerCode }}</el-button><span v-else>{{ castRow(row).customerName || castRow(row).customerCode }}</span></template>
|
||||
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'transitQuantity', 'frozenQuantity', 'defectiveQuantity'] as const" :key="key" #[key]="{ row }">{{ totalText(castRow(row)[key]) }}</template>
|
||||
<template v-for="n in [7, 15, 30] as TurnoverWindow[]" :key="n" #[`outbound${n}`]="{ row }">{{ windowText(castRow(row).windows.find(w => w.days === n)) }}</template>
|
||||
<template #inventoryVolume="{ row }">{{ volumeText(castRow(row).inventoryVolumeM3.value) }}<small>{{ volumeNote(castRow(row).inventoryVolumeM3) }}</small></template>
|
||||
<template #outboundVolume="{ row }">{{ volumeText(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume.value) }}<small>{{ volumeNote(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume, true) }}</small></template>
|
||||
<template #unitVolume="{ row }">{{ volumeText(castRow(row).unitVolumeM3, 9) }}<small>{{ dimensionSourceText(castRow(row).dimensionSource) }}</small></template>
|
||||
<template #daily="{ row }">{{ turnoverNumber(castRow(row).averageDailyOutboundQuantity) }}</template>
|
||||
<template #turnoverRate="{ row }"><span :title="castRow(row).quantityTurnoverCount == null ? historicalMetricHint(castRow(row)) : `所选${applied?.baseWindowDays}天出库量 ÷ 期间平均实物库存 × 100%`">{{ turnoverPercent(castRow(row).quantityTurnoverCount) }}</span></template>
|
||||
<template #coverage="{ row }">{{ turnoverNumber(castRow(row).inventoryCoverageDays, 1) }}<small>{{ reasonsText([castRow(row).coverageStatus]) }}</small></template>
|
||||
<template #status="{ row }"><small>{{ reasonsText(castRow(row).attentionReasons) }}</small><span v-if="ownerMode">待评估 {{ castRow(row).unassessableSkuCount }} SKU</span><el-button link @click="evidence = castRow(row); evidenceOpen = true">日样本 {{ castRow(row).qualifiedSampleDays }}/{{ castRow(row).requiredSampleDays }}</el-button></template>
|
||||
<template #noOutbound="{ row }">{{ turnoverNumber(castRow(row).noOutboundInWindow.matchedSkuCount) }} SKU / {{ turnoverNumber(castRow(row).noOutboundInWindow.knownPhysicalQuantity) }} 件<small>待判断 {{ castRow(row).noOutboundInWindow.unknownSkuCount }} SKU</small></template>
|
||||
<template #slow="{ row }">{{ applied?.coverageDaysThreshold ? `${turnoverNumber(castRow(row).slowCoverage.matchedSkuCount)} SKU / ${castRow(row).slowCoverage.unknownSkuCount} SKU` : '未设查询阈值' }}</template>
|
||||
<template #averageStock="{ row }">{{ turnoverNumber(castRow(row).averagePhysicalQuantity) }}</template>
|
||||
<template #turnover="{ row }">{{ turnoverNumber(castRow(row).quantityTurnoverCount) }} 次/{{ applied?.baseWindowDays }}日</template>
|
||||
<template #averageStock="{ row }"><span :title="castRow(row).averagePhysicalQuantity == null ? historicalMetricHint(castRow(row)) : undefined">{{ turnoverNumber(castRow(row).averagePhysicalQuantity) }}</span></template>
|
||||
<template #turnover="{ row }"><span :title="castRow(row).quantityTurnoverCount == null ? historicalMetricHint(castRow(row)) : undefined">{{ castRow(row).quantityTurnoverCount == null ? '—' : `${turnoverNumber(castRow(row).quantityTurnoverCount)} 次/${applied?.baseWindowDays}日` }}</span></template>
|
||||
<template #lastDate="{ row }">{{ castRow(row).lastObservedOutboundDate || '已采集历史内未见出库' }}</template>
|
||||
</DataTable>
|
||||
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" append-to-body fullscreenable><LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError.message" compact><template #details><ApiErrorMeta :details="trendError" /></template></LoadErrorState><SkuTrend v-if="daily" :data="daily" /></AppDialog>
|
||||
<AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="周转样本与覆盖证据" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};合格日样本 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }};积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}</p><p>缺口日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。</p><p>日样本是每日库存观测,不是出库历史天数;补采和验证任务不计入。今天采集成功的合格计划库存样本,最早次日查询时才能纳入窗口;当天样本数仍为0不代表采集未成功,历史缺日不能补零。</p></template></AppDialog>
|
||||
<AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="历史库存记录与销量覆盖" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerName || evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};历史库存记录 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }}天;历史覆盖起点 {{ data.context.accumulationStartedOn || '尚无合格历史记录' }}</p><p>缺少库存记录的日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日库存口径:北京时间每日结束时的全品质实物库存,由完整库存流水和独立库存锚点还原;本次查询固定于 {{ beijingTime(data.context.queryAsOf) }} 的已发布版本。</p><p>历史库存记录与销量覆盖分别校验。流水缺页、库存锚点不符或正在校正的日期保持未知;不以当前库存代替历史库存,也不将缺失数据补零。</p></template></AppDialog>
|
||||
</template>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>
|
||||
.turnover-sort-heading { display: inline-flex; align-items: center; gap: var(--admin-space-1); }
|
||||
.turnover-sort { display: inline-flex; align-items: center; gap: 6px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; }
|
||||
.turnover-sort:disabled { cursor: wait; }
|
||||
.turnover-sort:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 3px; }
|
||||
@@ -300,14 +412,19 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
.turnover-title { margin: 0; font-size: var(--admin-font-section); display: flex; align-items: center; gap: 16px; }
|
||||
.turnover-title span { font-size: var(--admin-font-caption); font-weight: 400; color: var(--el-text-color-secondary); }
|
||||
.turnover-context { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.turnover-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; padding: 4px 0 12px; }
|
||||
.turnover-cards article { position: relative; overflow: hidden; padding: 16px 20px; border: 1px solid var(--el-color-primary-light-8); background: linear-gradient(120deg, #fff, var(--el-color-primary-light-9)); border-radius: 10px; color: var(--el-color-primary); }
|
||||
.turnover-cards article:nth-child(2) { color: #248260; border-color: #dbeee5; background: linear-gradient(120deg, #fff, #edf8f1); }
|
||||
.turnover-cards article:nth-child(3) { color: #b37a22; border-color: #f4e7d3; background: linear-gradient(120deg, #fff, #fff6e8); }
|
||||
.turnover-cards article:nth-child(4) { color: #8a62bf; border-color: #e9e0f5; background: linear-gradient(120deg, #fff, #f6f0fc); }
|
||||
.turnover-summary { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; padding: 4px 0 12px; }
|
||||
.turnover-summary-group { min-width: 0; display: flex; flex-direction: column; }
|
||||
.turnover-summary-group > header { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 8px; }
|
||||
.turnover-summary-group h4 { margin: 0; font-size: var(--admin-font-body); }
|
||||
.turnover-summary-group > header span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.turnover-cards { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; flex: 1; }
|
||||
.turnover-cards article { position: relative; overflow: hidden; min-width: 0; padding: 12px; border: 1px solid var(--el-color-primary-light-8); background: linear-gradient(120deg, #fff, var(--el-color-primary-light-9)); border-radius: 10px; color: var(--el-color-primary); }
|
||||
.turnover-cards article[data-tone="green"] { color: #248260; border-color: #dbeee5; background: linear-gradient(120deg, #fff, #edf8f1); }
|
||||
.turnover-cards article[data-tone="orange"] { color: #b37a22; border-color: #f4e7d3; background: linear-gradient(120deg, #fff, #fff6e8); }
|
||||
.turnover-cards article[data-tone="purple"] { color: #8a62bf; border-color: #e9e0f5; background: linear-gradient(120deg, #fff, #f6f0fc); }
|
||||
.turnover-cards article::after { content: ''; position: absolute; width: 88px; height: 88px; border-radius: 50%; right: -22px; top: -30px; background: currentColor; opacity: .07; pointer-events: none; }
|
||||
.turnover-cards article > span { color: var(--el-text-color-regular); }
|
||||
.turnover-cards strong { display: block; font-size: 28px; margin: 8px 0 4px; font-variant-numeric: tabular-nums; }
|
||||
.turnover-cards strong { display: block; font-size: 24px; margin: 6px 0 4px; font-variant-numeric: tabular-nums; }
|
||||
.turnover-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 0 12px; align-items: stretch; }
|
||||
.turnover-charts section { height: 360px; box-sizing: border-box; display: flex; flex-direction: column; min-width: 0; background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); padding: 16px; border-radius: 10px; }
|
||||
.turnover-chart { flex: 1 1 0; height: 0; min-height: 120px; width: 100%; }
|
||||
@@ -319,7 +436,9 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
.turnover-chart-empty strong { color: var(--el-text-color-regular); }
|
||||
.turnover-chart-note { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); margin: 8px 0 0; }
|
||||
.turnover-window-gaps { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 8px; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.turnover-focus { display: flex; flex-wrap: wrap; gap: 24px; margin: 4px 0 0; border-bottom: 1px solid var(--el-border-color-light); }
|
||||
.turnover-list-filters { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); border-bottom: 1px solid var(--el-border-color-light); }
|
||||
.turnover-zero-filter { flex: 0 0 auto; margin: var(--admin-space-1) 0; }
|
||||
.turnover-focus { display: flex; flex-wrap: wrap; gap: 24px; margin: 0; }
|
||||
.turnover-focus button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 12px 4px; font: inherit; color: var(--el-text-color-regular); cursor: pointer; }
|
||||
.turnover-focus button[aria-pressed="true"] { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; }
|
||||
.turnover-focus button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
| 页面 | 操作 | 依据与承载结论 | 布局与验证 |
|
||||
| --- | --- | --- | --- |
|
||||
| 库存周转 | 库存/出库立方数与客户汇总 | 2026-10-14 用户确认:仓库实测尺寸优先,商品尺寸补充并标估算;历史出库按当前尺寸估算。沿现有客户筛选、SKU/货主分组和客户下钻,增加两项概览、体积列与单件尺寸来源,不新增弹框 | 正式总量未知显示“—”;小计、缺尺寸件数/SKU和数量待确认分开显示;出库体积跟随所选时间;待人工1920×1080、100%截图确认 |
|
||||
| 库存周转 | 库存过期、采集失败与未知总量 | 2026-10-14 用户库存空白截图及本轮修复确认:沿现有同页提示展示过期时间、同仓失败原因与任务号;整体刷新同步只读采集状态,失败独立提示;指标主数值未知显示“—”,小计留辅助说明;原样本证据弹框解释库存日样本与出库历史的区别 | 保留原数据采集入口与弹框承载,不新增写入操作;回归未知/真实0、切仓和刷新恢复,待人工1920×1080、100%截图确认 |
|
||||
| /self-operated/inventory | 页签与操作栏整合 | 2026-10-14 用户截图 `codex-clipboard-784338aa-b985-4eb7-9f5e-b14f87f6d5e8.png`:页签独立在外、操作栏左侧空白;两个视图的切换页签统一放入DataTable的toolbar-start,操作按钮保留右侧,筛选紧接下方;阈值提示缩短为“天数阈值(天)” | 保留视图选中态、切仓与原图下钻;不改接口与计算,待人工1920×1080、100%截图确认 |
|
||||
| /self-operated/collection | 原型排版融入后台风格 | 用户截图 `codex-clipboard-9f9be8ba-792e-4f17-a07b-6ac38531e294.png`:保留左侧进度环、中部三项数量和预计时间、右侧后续安排、底部阶段流程;使用后台浅色卡片与蓝紫主色,完成/剩余采用绿/橙强调 | 压缩空态与区块间距,读取失败不显示阶段正在处理;刷新仍为原地只读操作,不新增配置或详情入口;待人工1920×1080、100%截图确认 |
|
||||
@@ -142,3 +143,28 @@
|
||||
|---|---|---|---|
|
||||
| 销量分析、库存分析 | 名称或编码选客户 | 本轮确认;复用现有 QueryToolbar 可搜索下拉框,标签为名称及编码,提交仍使用 customerCode;服务端先筛选再排名分页 | 客户筛选、固定分页、名称展示定向测试及类型检查通过;视觉待人工1920×1080、100%截图 |
|
||||
| 总览、销量、库存周转 | 货主归属展示 | 本轮客户截图与名称映射确认;名称优先、编码辅助或名称缺失时显示编码 | 待人工截图 |
|
||||
|
||||
| 经营总览/趋势与库存分布精简 | 用户截图要求与Top10一致 | 日期、线型图例、候选与排除数量、分组口径收进各图右上角“说明”;趋势每日数据入口简称“明细”;保留图轴、空态、点击下钻及明细 | 交互回归及构建;视觉待人工截图确认 |
|
||||
|
||||
| 库存周转/体积列紧凑展示与排序 | 用户截图 `codex-clipboard-e7ad0387-165e-4c53-b4d6-eff3d357888c.png` 要求单位随值、缩短表头并排序 | 三列表头移除单位,数值后带m³,未知只显示—;列宽收紧,估算及缺尺寸说明保留;库存/所选窗口出库体积支持SKU和货主全量排序,单件体积仅SKU;未知末尾,切换回第一页 | 前后端排序、分页和格式回归;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
| 库存周转/体积汇总精度 | 用户截图 `codex-clipboard-d53f8b33-b441-4f46-9ed0-1ac931757d44.png` 指出6位小数冗长 | 两张体积卡片及已知小计最多2位小数,省略末尾零,极小正数显示<0.01;主值悬停保留6位精度,未知仍为—;明细精度、计算及排序不变 | 定向回归与类型检查;视觉待人工截图确认 |
|
||||
|
||||
| 库存周转/六项概览单排 | 用户要求六张卡片压紧放一排 | 1920×1080基线下件数/SKU四项与体积两项共用六列网格,收紧内边距及数字字号;体积最多两位小数,出库估算说明及待核验信息保留 | 定向回归与类型检查;视觉待人工截图确认 |
|
||||
|
||||
| 库存周转/库存与时段指标分组 | 用户明确三项当前库存不受时段影响,要求按含义优化排版 | 保持六卡一排,左组“当前库存”含实物、可售、库存体积并说明库存快照口径;右组“所选时段分析”含无出库、出库体积、待评估并显示所选天数及截止日;出库估算及未知说明保留 | 定向回归与类型检查;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
| 库存周转/右侧图表切换 | 本轮用户截图及确认方案(2026-10-14) | 保留左右两栏,右侧默认货主库存立方数 Top10,标题处切换日均出库对比;复用现有图表明细框和货主下钻 | 条末显示两位小数及 m³;缺尺寸或数量未知不排名并提示数量 | 待人工 1920×1080、100% 截图确认 |
|
||||
|
||||
| 库存周转/隐藏状态与样本列 | 用户截图及本轮确认 | 主表移除状态与样本列;期间平均实物、期间件数周转未知只显示—并悬停说明;现有统计说明内选择本页商品/货主,复用1040px居中明细框查看历史库存记录与销量覆盖;冻结件数、筛选及后台校验保留 | 定向交互与零值回归;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
| 库存周转/隐藏零库存 | 2026-10-15用户要求默认选中 | 查询区复选框“隐藏 0 库存”,变化即时查询并回第一页;按SKU/货主实物明确为零筛除,未知保留;服务端全量筛选后分页,概览图表不变,重置恢复选中 | 默认/取消/重置/路由与SQL分页回归;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
| 库存周转/零库存筛选样式 | 用户截图 `codex-clipboard-c21dd284-0aed-4db0-840b-172260c2e3fb.png` | 将原生复选框从查询按钮组移到列表快捷筛选右侧,使用 Element 带边框复选框并垂直居中;无概览、无货主条件时不渲染空白概览行;首次读取失败不提示保留上次结果 | 保留默认选中、即时查询及禁用行为;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
| 库存周转/多选货主、导出及周转率 | 用户截图 `codex-clipboard-b295bb06-d54f-45a8-9d9f-d7562da01ac8.png` 与本轮追加要求 | 原货主下拉改为可搜索多选,选项折叠并可清空,SKU/货主两视图及概览、图表同集合查询;列表顶部通过独立导出权限提交后台XLSX任务,包含当前显示列与扩展列;下载中心查看进度、下载及重试,离开页面不取消已提交任务;默认显示期间周转率=期间件数周转次数×100%,未知保留—,悬停说明原因及口径 | 多选范围、后台任务固定筛选与列顺序、独立权限、空值与零值回归;视觉待人工1920×1080、100%截图确认 |
|
||||
| 库存周转/期间周转率表头 | 用户截图 `codex-clipboard-94ff8645-7783-42b1-a82d-3170295b823a.png` 要求计算口径与排序直接放在默认百分比列 | 默认百分比列接入 TURNOVER 服务端全量升降序,与扩展周转次数及排序下拉同步;保留货主、日期、固定版本、隐藏零库存并回到第一页。列名旁信息按钮独立展示公式、日末均值、货主汇总与未知口径,不触发查询 | 默认列实际点击、两种分组、双向排序与说明按钮回归;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
| 经营总览/指标卡说明 | 用户反馈“完整”下方破折号含义不清 | 无异常原因时不渲染说明占位符,待核验为0时隐藏该行;真实异常、正数待核验、未知主数值及已知小计保留 | 页面回归及构建;视觉待人工截图确认 |
|
||||
|
||||
| 库存周转/后台导出 | 用户指出前端拼文件,要求改后端导出 | 复用统一下载中心成功提示;提交期间按钮禁用,成功可进入下载中心;任务固定筛选/排序/版本/列顺序,页面不再拉取全量明细或提供前端取消。失败保留原查询结果 | 单次提交、无额外查询、权限隐藏、失败提示及下载中心映射回归;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
@@ -93,8 +93,8 @@ describe('warehouse operating dashboard', () => {
|
||||
data.noOutboundSkus = { ...data.noOutboundSkus, items: [], eligibleCount: '0', unassessableCount: '2741', dataStatus: 'PARTIAL' }
|
||||
mocks.dashboard.mockResolvedValue(data)
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
expect(wrapper.findAll('.dashboard-chart-actions button').filter(node => node.text() === '说明')).toHaveLength(3)
|
||||
await wrapper.findAll('.dashboard-chart-actions button').filter(node => node.text() === '说明')[1]!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.findAll('.dashboard-rankings .dashboard-chart-actions button').filter(node => node.text() === '说明')).toHaveLength(3)
|
||||
await wrapper.findAll('.dashboard-rankings .dashboard-chart-actions button').filter(node => node.text() === '说明')[1]!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('全仓库存分布的待核验原因')
|
||||
expect(wrapper.text()).toContain('待评估 2711 SKU')
|
||||
expect(wrapper.text()).toContain('待评估 2741 SKU')
|
||||
@@ -218,6 +218,7 @@ describe('warehouse operating dashboard', () => {
|
||||
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, inventoryDrilldownExpiresAt: null }, coverageDistribution: { ...dashboardFixture.coverageDistribution, candidateSkuCount: null, buckets: [], dataStatus: 'UNKNOWN', reasonCodes: ['INVENTORY_UNAVAILABLE'] } })
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
expect(wrapper.text()).toContain('无可用库存分布资料')
|
||||
await wrapper.findAll('.dashboard-main-charts button').filter(node => node.text() === '说明')[1]!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('库存资料不可用')
|
||||
expect(wrapper.findComponent(DashboardTrend).exists()).toBe(true)
|
||||
expect(findButton(wrapper, '查看Top100').attributes('disabled')).toBeUndefined()
|
||||
@@ -302,7 +303,7 @@ describe('warehouse operating dashboard', () => {
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
expect(wrapper.findAllComponents(DashboardBars)[1]!.props('rows')).toEqual([])
|
||||
expect(wrapper.find('.dashboard-rankings').text()).not.toContain('未采集日期不计为零出库')
|
||||
await findButton(wrapper, '说明').trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('.dashboard-rankings button').find(node => node.text() === '说明')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('未采集日期不计为零出库')
|
||||
await findButton(wrapper, '查看Top100').trigger('click')
|
||||
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ startDate: '2026-10-13', endDate: '2026-10-13', rangePreset: 'CUSTOM' })
|
||||
|
||||
@@ -217,15 +217,15 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
|
||||
<div class="dashboard-main-charts">
|
||||
<section class="dashboard-chart">
|
||||
<DashboardTrend :points="data.salesTrend.points" :disabled="contextExpired" @select="date => drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: date, endDate: date })">
|
||||
<template #intro><p>{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)</p></template>
|
||||
<template #explanation><p>{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)</p></template>
|
||||
</DashboardTrend>
|
||||
</section>
|
||||
<section class="dashboard-chart">
|
||||
<DashboardBars :rows="data.coverageDistribution.candidateSkuCount == null ? [] : bucketRows" title="库存消化天数分布" unit="SKU" :disabled="stockExpired || contextExpired" @select="code => drill('inventory', { coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' })">
|
||||
<template #actions>
|
||||
<ChartDetailsDialog inline button-label="分组说明" title="分组说明与待核验原因" width="760px" :reset-on="data"><p>30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。</p><p v-for="(count, reason) in data.coverageDistribution.unknownReasons" :key="reason">{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU</p></ChartDetailsDialog>
|
||||
<ChartDetailsDialog inline button-label="说明" title="库存消化天数分布 · 统计说明" width="760px" :reset-on="data"><p>有实物库存或库存待核验 SKU;{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)。</p><p>候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}</p><p>30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。</p><p v-for="(count, reason) in data.coverageDistribution.unknownReasons" :key="reason">{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU</p></ChartDetailsDialog>
|
||||
</template>
|
||||
<template #intro><p>有实物库存或库存待核验 SKU;{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)。</p><p>候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}</p></template>
|
||||
|
||||
<template #empty><p>无可用库存分布资料;销售候选不作为库存总量。</p></template>
|
||||
</DashboardBars>
|
||||
</section>
|
||||
|
||||
@@ -3,6 +3,21 @@ import { inventoryView, turnoverRoute, validateTurnover, yesterdayBeijing } from
|
||||
import type { TurnoverQuery } from './turnoverTypes'
|
||||
const valid: TurnoverQuery = { sourceAccount: 'primary', warehouseCode: 'W', groupBy: 'SKU', endDate: '2026-10-12', baseWindowDays: 30, focus: 'ALL', sortBy: 'PHYSICAL_DESC', current: 1, size: 20, includeOverview: true }
|
||||
describe('inventory view and turnover query contract', () => {
|
||||
it('normalizes repeated owner keys and validates the bounded selection', () => {
|
||||
expect(turnoverRoute({ customerCodes: ['B', 'A', 'A'] }).customerCodes).toEqual(['A', 'B'])
|
||||
expect(turnoverRoute({ customerCodes: 'A' }).customerCodes).toEqual(['A'])
|
||||
expect(turnoverRoute({}).customerCodes).toEqual([])
|
||||
for (const value of ['', [null], Array(101).fill('A')]) expect(() => turnoverRoute({ customerCodes: value })).toThrow()
|
||||
expect(() => turnoverRoute({ customerCode: 'A' })).toThrow()
|
||||
expect(() => inventoryView({ view: 'DETAIL', customerCodes: ['A'] })).toThrow()
|
||||
})
|
||||
it('parses only explicit booleans for the zero-inventory filter', () => {
|
||||
expect(turnoverRoute({ hideZeroInventory: 'false' })).toMatchObject({ hideZeroInventory: false })
|
||||
expect(turnoverRoute({ hideZeroInventory: 'true' })).toMatchObject({ hideZeroInventory: true })
|
||||
for (const value of ['0', '1', 'yes', ['false']]) expect(() => turnoverRoute({ hideZeroInventory: value })).toThrow()
|
||||
expect(() => inventoryView({ view: 'DETAIL', hideZeroInventory: 'true' })).toThrow()
|
||||
})
|
||||
|
||||
it('defaults to turnover but preserves strict dashboard and legacy detail links', () => {
|
||||
expect(inventoryView({ warehouseCode: 'W', sku: 'X' })).toBe('TURNOVER')
|
||||
for (const q of [{ from: 'overview' }, { drilldownFrom: 'DASHBOARD' }, { salesWindowDays: '10' }, { attention: 'FROZEN_STOCK' }, { view: 'DETAIL' }]) expect(inventoryView(q)).toBe('DETAIL')
|
||||
@@ -14,7 +29,7 @@ describe('inventory view and turnover query contract', () => {
|
||||
})
|
||||
it('validates independent focus, threshold, group, date and pagination boundaries', () => {
|
||||
expect(() => validateTurnover(valid, '2026-10-12')).not.toThrow()
|
||||
for (const patch of [{ endDate: '2026-02-30' }, { endDate: '2026-10-13' }, { groupBy: 'OWNER', sku: 'X' }, { groupBy: 'OWNER', sortBy: 'COVERAGE_DESC' }, { focus: 'COVERAGE_GT' }, { coverageDaysThreshold: 1.5 }, { size: 101 }, { current: 0 }, { baseWindowDays: 367 }]) expect(() => validateTurnover({ ...valid, ...patch } as TurnoverQuery, '2026-10-12')).toThrow()
|
||||
for (const patch of [{ endDate: '2026-02-30' }, { endDate: '2026-10-13' }, { groupBy: 'OWNER', sku: 'X' }, { groupBy: 'OWNER', sortBy: 'COVERAGE_DESC' }, { groupBy: 'OWNER', sortBy: 'UNIT_VOLUME_DESC' }, { focus: 'COVERAGE_GT' }, { coverageDaysThreshold: 1.5 }, { size: 101 }, { current: 0 }, { baseWindowDays: 367 }]) expect(() => validateTurnover({ ...valid, ...patch } as TurnoverQuery, '2026-10-12')).toThrow()
|
||||
expect(() => validateTurnover({ ...valid, focus: 'COVERAGE_LE', coverageDaysThreshold: 45 }, '2026-10-12')).not.toThrow()
|
||||
})
|
||||
it('uses Beijing yesterday across UTC day boundaries', () => {
|
||||
|
||||
@@ -2,7 +2,12 @@ import type { TurnoverQuery, TurnoverSort } from './turnoverTypes'
|
||||
import { turnoverSortOptions } from './turnoverSorting'
|
||||
export const turnoverSorts: TurnoverSort[] = turnoverSortOptions.map(option => option.value)
|
||||
const detailOnly = ['salesWindowDays', 'salesWindowEndDate', 'attention', 'coverageSelection', 'coverageBucket', 'bucketRuleVersion']
|
||||
const turnoverOnly = ['groupBy', 'baseWindowDays', 'focus', 'turnoverStatus', 'snapshotContext']
|
||||
const turnoverOnly = ['customerCodes', 'hideZeroInventory', 'groupBy', 'baseWindowDays', 'focus', 'turnoverStatus', 'snapshotContext']
|
||||
export function normalizeTurnoverOwners(value: unknown): string[] {
|
||||
if (value == null) return []
|
||||
if (!Array.isArray(value) || value.length > 100 || value.some(code => typeof code !== 'string' || !code.trim() || code.length > 64)) throw new Error('请选择有效货主,最多100个')
|
||||
return [...new Set(value as string[])].sort()
|
||||
}
|
||||
function scalar(q: Record<string, unknown>, key: string) {
|
||||
const v = q[key]
|
||||
if (v == null) return undefined
|
||||
@@ -24,6 +29,8 @@ export function yesterdayBeijing(now = new Date()) {
|
||||
return new Date(Date.parse(`${date}T00:00:00Z`) - 86400000).toISOString().slice(0, 10)
|
||||
}
|
||||
export function validateTurnover(q: TurnoverQuery, latestDate = yesterdayBeijing()) {
|
||||
normalizeTurnoverOwners(q.customerCodes)
|
||||
if (q.hideZeroInventory != null && typeof q.hideZeroInventory !== 'boolean') throw new Error('隐藏零库存参数无效')
|
||||
if (!q.warehouseCode.trim()) throw new Error('请选择可用仓库')
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(q.endDate) || !Number.isFinite(Date.parse(q.endDate)) || new Date(q.endDate).toISOString().slice(0, 10) !== q.endDate || q.endDate > latestDate) throw new Error('截止日必须是已结束的北京时间日期')
|
||||
if (!Number.isInteger(q.baseWindowDays) || q.baseWindowDays < 1 || q.baseWindowDays > 366) throw new Error('统计区间须为1至366个完整自然日')
|
||||
@@ -33,16 +40,25 @@ export function validateTurnover(q: TurnoverQuery, latestDate = yesterdayBeijing
|
||||
if (q.coverageDaysThreshold != null && (!Number.isSafeInteger(q.coverageDaysThreshold) || q.coverageDaysThreshold <= 0)) throw new Error('阈值必须为正整数天数')
|
||||
if (['COVERAGE_GT', 'COVERAGE_LE'].includes(q.focus) && q.coverageDaysThreshold == null) throw new Error('请填写快慢判断的天数阈值')
|
||||
if (q.turnoverStatus && q.turnoverStatus !== 'INSUFFICIENT') throw new Error('周转样本条件无效')
|
||||
if (!turnoverSorts.includes(q.sortBy) || q.groupBy === 'OWNER' && ['COVERAGE_ASC', 'COVERAGE_DESC'].includes(q.sortBy) || q.groupBy === 'SKU' && q.sortBy === 'NO_OUTBOUND_PHYSICAL_DESC') throw new Error('排序不适用于当前分组')
|
||||
if (!turnoverSorts.includes(q.sortBy) || q.groupBy === 'OWNER' && ['COVERAGE_ASC', 'COVERAGE_DESC', 'UNIT_VOLUME_ASC', 'UNIT_VOLUME_DESC'].includes(q.sortBy) || q.groupBy === 'SKU' && q.sortBy === 'NO_OUTBOUND_PHYSICAL_DESC') throw new Error('排序不适用于当前分组')
|
||||
if (!Number.isSafeInteger(q.current) || q.current < 1 || !Number.isSafeInteger(q.size) || q.size < 1 || q.size > 100) throw new Error('分页参数无效')
|
||||
}
|
||||
export function turnoverRoute(q: Record<string, unknown>): Partial<TurnoverQuery> {
|
||||
if (inventoryView(q) !== 'TURNOVER') throw new Error('当前为库存明细入口')
|
||||
const allowed = new Set(['view', 'warehouseCode', 'sku', 'customerCode', 'endDate', 'baseWindowDays', 'groupBy', 'focus', 'coverageDaysThreshold', 'turnoverStatus', 'sortBy', 'snapshotContext', 'current', 'size'])
|
||||
const allowed = new Set(['view', 'warehouseCode', 'hideZeroInventory', 'sku', 'customerCodes', 'endDate', 'baseWindowDays', 'groupBy', 'focus', 'coverageDaysThreshold', 'turnoverStatus', 'sortBy', 'snapshotContext', 'current', 'size'])
|
||||
for (const key of Object.keys(q)) if (!allowed.has(key)) throw new Error(`库存周转不接受参数 ${key}`)
|
||||
const result: Record<string, string | number> = {}
|
||||
const result: Record<string, string | number | boolean | string[]> = {}
|
||||
for (const key of allowed) {
|
||||
if (key === 'customerCodes') {
|
||||
result[key] = normalizeTurnoverOwners(q[key] == null ? [] : Array.isArray(q[key]) ? q[key] : [q[key]])
|
||||
continue
|
||||
}
|
||||
const value = scalar(q, key)
|
||||
if (key === 'hideZeroInventory' && value != null) {
|
||||
if (!['true', 'false'].includes(value)) throw new Error('隐藏零库存参数无效')
|
||||
result[key] = value === 'true'
|
||||
continue
|
||||
}
|
||||
if (value != null && key !== 'view') result[key] = ['baseWindowDays', 'coverageDaysThreshold', 'current', 'size'].includes(key) ? Number(value) : value
|
||||
}
|
||||
return result as Partial<TurnoverQuery>
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { turnoverChart, turnoverNumber, windowText, turnoverReasonsText } from './turnoverPresentation'
|
||||
import { turnoverChart, turnoverNumber, volumeText, windowText, turnoverReasonsText, volumeNote } from './turnoverPresentation'
|
||||
import type { TurnoverCharts, TurnoverWindowMetric } from './turnoverTypes'
|
||||
describe('turnover honest precision and charts', () => {
|
||||
it('keeps unknown volume distinct from zero and labels historical volume as estimated', () => {
|
||||
const value = { value: null, knownSubtotal: 0, missingDimensionQuantity: 15, missingDimensionSkuCount: 2, unknownQuantitySkuCount: 1, declaredDimensionQuantity: 0, reasonCodes: [] }
|
||||
expect(volumeNote(value)).toContain('已知小计 0 m³')
|
||||
expect(volumeNote(value)).toContain('缺尺寸 2 SKU / 15 件')
|
||||
expect(volumeNote(value)).toContain('数量待确认 1 SKU')
|
||||
expect(volumeNote({ ...value, value: 0, missingDimensionSkuCount: 0, unknownQuantitySkuCount: 0 })).toBe('已知体积为0')
|
||||
expect(volumeNote(value, true)).toContain('按当前商品尺寸估算')
|
||||
})
|
||||
it('explains missing samples and zero average stock without exposing internal statuses', () => {
|
||||
expect(turnoverReasonsText(['INSUFFICIENT'])).toBe('合格日库存样本不足')
|
||||
expect(turnoverReasonsText(['ZERO_AVERAGE_STOCK'])).toContain('每日观测未捕获存量')
|
||||
expect(turnoverReasonsText(['INSUFFICIENT'])).toBe('合格日末库存记录不足')
|
||||
expect(turnoverReasonsText(['ZERO_AVERAGE_STOCK'])).toContain('期间平均日末库存为0')
|
||||
expect(turnoverReasonsText(['INVENTORY_UNKNOWN_OR_STALE'])).toContain('已过期')
|
||||
})
|
||||
it('keeps tiny positive values distinct from zero and unknown', () => {
|
||||
expect(turnoverNumber(0.000001)).toBe('<0.01')
|
||||
expect(turnoverNumber(0.01, 1)).toBe('<0.1')
|
||||
expect(turnoverNumber(0)).toBe('0'); expect(turnoverNumber(null)).toBe('—')
|
||||
expect(volumeText(1.25)).toBe('1.25 m³')
|
||||
expect(volumeText(0)).toBe('0 m³')
|
||||
expect(volumeText(null)).toBe('—')
|
||||
expect(volumeText(0.0000000001, 9)).toBe('<0.000000001 m³')
|
||||
})
|
||||
it('does not draw partial-window totals as daily averages or zero bars', () => {
|
||||
const partial: TurnoverWindowMetric = { days: 15, startDate: '2026-09-28', endDateExclusive: '2026-10-13', dataStatus: 'PARTIAL', observedQuantity: 150, outboundQuantity: null, averageDailyOutboundQuantity: null, completeDayCount: 7, partialDayCount: 1, unknownDayCount: 7, reasonCodes: [] }
|
||||
const charts: TurnoverCharts = { ownerPhysicalTop: [], unknownOwnerCount: 0, averageDaily: [{ ...partial, days: 7, dataStatus: 'COMPLETE', averageDailyOutboundQuantity: 10 }, partial] }
|
||||
const charts: TurnoverCharts = { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: [{ ...partial, days: 7, dataStatus: 'COMPLETE', averageDailyOutboundQuantity: 10 }, partial] }
|
||||
const option = turnoverChart(charts, 'daily')
|
||||
const tooltip = option.tooltip as { valueFormatter: (value: number | null) => string }
|
||||
expect(tooltip.valueFormatter(2165.133333)).toBe('2,165.13 件/日')
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import type { Quantity, WarehouseOverview } from './types'
|
||||
import type { TurnoverCharts, TurnoverTotal, TurnoverWindowMetric } from './turnoverTypes'
|
||||
import type { TurnoverCharts, TurnoverTotal, TurnoverVolume, TurnoverWindowMetric } from './turnoverTypes'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { reasonsText } from './state'
|
||||
const turnoverLabels: Record<string, string> = {
|
||||
INSUFFICIENT: '合格日库存样本不足', ZERO_AVERAGE_STOCK: '每日观测未捕获存量,无法计算周转',
|
||||
INDEPENDENT_ANCHOR_MISSING: '缺少独立库存对账记录', INDEPENDENT_ANCHOR_QUALITY_MISSING: '独立库存品质记录不完整',
|
||||
INDEPENDENT_ANCHOR_INVALID: '独立库存记录无效', INDEPENDENT_ANCHOR_COVERAGE_MISSING: '库存对账区间流水未齐',
|
||||
INDEPENDENT_ANCHOR_MISMATCH: '流水与独立库存记录不符', INDEPENDENT_ANCHOR_FLOW_UNKNOWN: '库存对账流水待核验',
|
||||
ANCHOR_TIME_AMBIGUOUS: '采集库存时发生变动,待稳定库存记录核验', ANCHOR_MISSING: '缺少库存锚点',
|
||||
FLOW_COVERAGE_GAP: '库存流水覆盖不完整', FLOW_TYPE_UNVERIFIED: '流水类型尚未核验', CHAIN_DISCONTINUITY: '批次结存不连续',
|
||||
CHAIN_ORDER_AMBIGUOUS: '同秒流水顺序待核验', FLOW_QUANTITY_OR_TIME_INVALID: '流水数量或时间待核验',
|
||||
FLOW_QUANTITY_CONFLICT: '流水变动数量不一致', NEGATIVE_CHAIN_STOCK: '批次结存出现负数',
|
||||
NEGATIVE_RECONSTRUCTED_STOCK: '还原库存出现负数', UNIT_UNKNOWN: '库存数量单位待确认',
|
||||
ANCHOR_DUPLICATE: '库存锚点重复', ANCHOR_QUANTITY_INVALID: '库存锚点数量无效',
|
||||
ANCHOR_INTERVAL_INVALID: '库存观测时间无效', ANCHOR_BEFORE_DAY_END: '库存锚点早于日末',
|
||||
FLOW_ID_INVALID: '流水标识无效', FLOW_ID_CONFLICT: '流水标识存在冲突', UNKNOWN_QUALITY: '库存品质待确认',
|
||||
CHAIN_IDENTITY_MISSING: '流水库位或批次缺失',
|
||||
INSUFFICIENT: '合格日末库存记录不足', ZERO_AVERAGE_STOCK: '期间平均日末库存为0,无法计算周转',
|
||||
INVENTORY_UNKNOWN_OR_STALE: '当前库存未知、异常或已过期', NOT_APPLICABLE: '货主不计算整体可售天数',
|
||||
SALES_OR_STOCK_UNASSESSABLE: '部分商品的销量或库存不足以判断',
|
||||
SALES_WINDOW_UNAVAILABLE: '同期销量或商品观察证据不足',
|
||||
SALES_WINDOW_UNAVAILABLE: '同期完整销量不足',
|
||||
}
|
||||
export function turnoverReasonsText(reasons: readonly string[] | null | undefined) {
|
||||
return reasons?.map(reason => turnoverLabels[reason] ?? reasonsText([reason])).join('、') || '—'
|
||||
@@ -17,6 +29,13 @@ export function turnoverNumber(value: Quantity | undefined, decimals = 2) {
|
||||
if (n > 0 && n < 10 ** -decimals) return `<${(10 ** -decimals).toFixed(decimals)}`
|
||||
return n.toLocaleString('zh-CN', { maximumFractionDigits: decimals })
|
||||
}
|
||||
export function turnoverPercent(value: Quantity | undefined) {
|
||||
return value == null ? '—' : `${turnoverNumber(Number(value) * 100)}%`
|
||||
}
|
||||
export function volumeText(value: Quantity | undefined, decimals = 6) {
|
||||
const text = turnoverNumber(value, decimals)
|
||||
return text === '—' ? text : `${text} m³`
|
||||
}
|
||||
export function totalText(total: TurnoverTotal) {
|
||||
if (total.value == null && total.knownSubtotal == null && total.unknownSkuCount === 0) return turnoverReasonsText(total.reasonCodes)
|
||||
return total.value != null ? turnoverNumber(total.value) : `已知 ${turnoverNumber(total.knownSubtotal)};${total.unknownSkuCount} SKU待核验`
|
||||
@@ -26,6 +45,21 @@ export function turnoverTotalNote(total: TurnoverTotal) {
|
||||
const known = total.knownSubtotal == null ? '暂无已知小计' : `已知小计 ${turnoverNumber(total.knownSubtotal)}`
|
||||
return `${known} · ${total.unknownSkuCount} SKU待核验`
|
||||
}
|
||||
export function volumeNote(volume: TurnoverVolume | undefined, outbound = false, decimals = 6) {
|
||||
if (!volume) return '数量或尺寸待确认'
|
||||
const notes: string[] = []
|
||||
if (volume.value == null) notes.push(volume.knownSubtotal == null ? '暂无已知小计' : `已知小计 ${turnoverNumber(volume.knownSubtotal, decimals)} m³`)
|
||||
if (volume.missingDimensionSkuCount) notes.push(`缺尺寸 ${volume.missingDimensionSkuCount} SKU / ${turnoverNumber(volume.missingDimensionQuantity)} 件`)
|
||||
if (volume.unknownQuantitySkuCount) notes.push(`数量待确认 ${volume.unknownQuantitySkuCount} SKU`)
|
||||
if (Number(volume.declaredDimensionQuantity) > 0) notes.push(`其中 ${turnoverNumber(volume.declaredDimensionQuantity)} 件按商品尺寸估算`)
|
||||
if (outbound) notes.push('按当前商品尺寸估算')
|
||||
else if (volume.value != null && Number(volume.value) === 0) notes.push('已知体积为0')
|
||||
else if (volume.value != null && !Number(volume.declaredDimensionQuantity)) notes.push('按仓库实测尺寸计算')
|
||||
return notes.join(';') || '数量或尺寸待确认'
|
||||
}
|
||||
export function dimensionSourceText(source: string | null) {
|
||||
return source === 'WAREHOUSE_MEASURED' ? '仓库实测尺寸' : source === 'PRODUCT_DECLARED' ? '商品尺寸估算' : '缺少有效尺寸'
|
||||
}
|
||||
export function inventoryCollectionNotice(warehouse: WarehouseOverview | undefined) {
|
||||
const task = warehouse?.currentTask
|
||||
if (!task || !['DEAD', 'RETRY_WAIT'].includes(task.taskStatus) || task.latestAttempt?.status !== 'FAILED') return ''
|
||||
@@ -41,18 +75,22 @@ export function windowText(metric: TurnoverWindowMetric | undefined) {
|
||||
if (!metric) return '—'
|
||||
return metric.dataStatus === 'COMPLETE' ? turnoverNumber(metric.outboundQuantity) : `已采集 ${turnoverNumber(metric.observedQuantity)},覆盖不足`
|
||||
}
|
||||
export function turnoverChart(charts: TurnoverCharts, kind: 'owners' | 'daily'): EChartsOption {
|
||||
const owners = kind === 'owners'
|
||||
const rows = owners ? charts.ownerPhysicalTop : charts.averageDaily
|
||||
export function turnoverChart(charts: TurnoverCharts, kind: 'owners' | 'volume' | 'daily'): EChartsOption {
|
||||
const owners = kind !== 'daily', volume = kind === 'volume'
|
||||
const ownerRows = volume ? charts.ownerVolumeTop : charts.ownerPhysicalTop
|
||||
const unit = volume ? 'm³' : owners ? '件' : '件/日'
|
||||
const values = volume ? charts.ownerVolumeTop.map(w => w.inventoryVolumeM3.value)
|
||||
: owners ? charts.ownerPhysicalTop.map(w => w.physicalQuantity)
|
||||
: charts.averageDaily.map(w => w.dataStatus === 'COMPLETE' ? w.averageDailyOutboundQuantity : null)
|
||||
return {
|
||||
aria: { enabled: true }, animation: typeof window === 'undefined' || !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches,
|
||||
animationDuration: 450, color: ['#6c7bea'],
|
||||
tooltip: { trigger: 'axis', renderMode: 'richText', valueFormatter: value => `${turnoverNumber(typeof value === 'number' || typeof value === 'string' ? value : null)} ${owners ? '件' : '件/日'}` }, grid: { left: 12, right: 40, top: owners ? 12 : 48, bottom: 12, containLabel: true },
|
||||
xAxis: owners ? { type: 'value', name: '件' } : { type: 'category', data: charts.averageDaily.map(w => `${w.days}日`) },
|
||||
yAxis: owners ? { type: 'category', inverse: true, data: charts.ownerPhysicalTop.map(w => w.customerName || w.customerCode) } : { type: 'value', name: '件/日', nameGap: 12, nameTextStyle: { align: 'left' } },
|
||||
series: [{ type: 'bar', barMaxWidth: 24, itemStyle: { borderRadius: 4 }, label: { show: !owners, position: 'top', distance: 8, formatter: params => turnoverNumber(typeof params.value === 'number' ? params.value : null) }, data: rows.map((_, i) => {
|
||||
const value = owners ? charts.ownerPhysicalTop[i]?.physicalQuantity : charts.averageDaily[i]?.dataStatus === 'COMPLETE' ? charts.averageDaily[i]?.averageDailyOutboundQuantity : null
|
||||
return value == null ? null : Number(value)
|
||||
}) }],
|
||||
tooltip: { trigger: 'axis', renderMode: 'richText', valueFormatter: value => `${turnoverNumber(typeof value === 'number' || typeof value === 'string' ? value : null)} ${unit}` },
|
||||
grid: { left: 12, right: volume ? 110 : 40, top: owners ? 12 : 48, bottom: 12, containLabel: true },
|
||||
xAxis: owners ? { type: 'value', name: unit } : { type: 'category', data: charts.averageDaily.map(w => `${w.days}日`) },
|
||||
yAxis: owners ? { type: 'category', inverse: true, data: ownerRows.map(w => w.customerName || w.customerCode) } : { type: 'value', name: unit, nameGap: 12, nameTextStyle: { align: 'left' } },
|
||||
series: [{ type: 'bar', barMaxWidth: 24, itemStyle: { borderRadius: 4 },
|
||||
label: { show: kind !== 'owners', position: volume ? 'right' : 'top', distance: 8, formatter: params => volume ? volumeText(typeof params.value === 'number' ? params.value : null, 2) : turnoverNumber(typeof params.value === 'number' ? params.value : null) },
|
||||
data: values.map(value => value == null ? null : Number(value)) }],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { TurnoverSort, TurnoverSortMetric } from './turnoverTypes'
|
||||
|
||||
export const turnoverSortColumns: Record<string, TurnoverSortMetric> = {
|
||||
inventoryVolumeM3: 'INVENTORY_VOLUME', outboundVolumesM3: 'OUTBOUND_VOLUME', unitVolumeM3: 'UNIT_VOLUME',
|
||||
physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', transitQuantity: 'TRANSIT',
|
||||
outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30',
|
||||
averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE',
|
||||
frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE',
|
||||
averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER',
|
||||
averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER', quantityTurnoverRate: 'TURNOVER',
|
||||
}
|
||||
const metricLabels: Record<TurnoverSortMetric, string> = {
|
||||
INVENTORY_VOLUME: '库存立方数', OUTBOUND_VOLUME: '所选时段出库立方数(估算)', UNIT_VOLUME: '单件体积',
|
||||
PHYSICAL: '实物件数', AVAILABLE: '正品可售', TRANSIT: '在途', OUTBOUND_7: '完整7日出库',
|
||||
OUTBOUND_15: '完整15日出库', OUTBOUND_30: '完整30日出库', AVERAGE_DAILY: '基准窗口日均出库',
|
||||
COVERAGE: '预计可售天数', FROZEN: '冻结件数', DEFECTIVE: '次品实物',
|
||||
AVERAGE_PHYSICAL: '期间平均实物', TURNOVER: '期间件数周转',
|
||||
AVERAGE_PHYSICAL: '期间平均实物', TURNOVER: '期间周转率',
|
||||
}
|
||||
export const turnoverSortOptions: { value: TurnoverSort; label: string }[] = [
|
||||
...(Object.keys(metricLabels) as TurnoverSortMetric[]).flatMap(metric => [
|
||||
{ value: `${metric}_DESC` as TurnoverSort, label: `${metricLabels[metric]}从多到少` },
|
||||
{ value: `${metric}_ASC` as TurnoverSort, label: `${metricLabels[metric]}从少到多` },
|
||||
{ value: `${metric}_DESC` as TurnoverSort, label: `${metricLabels[metric]}${metric === 'TURNOVER' ? '从高到低' : '从多到少'}` },
|
||||
{ value: `${metric}_ASC` as TurnoverSort, label: `${metricLabels[metric]}${metric === 'TURNOVER' ? '从低到高' : '从少到多'}` },
|
||||
]),
|
||||
{ value: 'NO_OUTBOUND_PHYSICAL_DESC', label: '已确认无出库实物最多' },
|
||||
]
|
||||
|
||||
@@ -4,11 +4,11 @@ import type { QueryContext, Quantity } from './types'
|
||||
export type TurnoverWindow = number
|
||||
export type TurnoverGroup = 'SKU' | 'OWNER'
|
||||
export type TurnoverFocus = 'ALL' | 'NO_OUTBOUND_IN_WINDOW' | 'COVERAGE_GT' | 'COVERAGE_LE' | 'FROZEN' | 'DEFECTIVE' | 'UNASSESSABLE'
|
||||
export type TurnoverSortMetric = 'PHYSICAL' | 'AVAILABLE' | 'TRANSIT' | 'OUTBOUND_7' | 'OUTBOUND_15' | 'OUTBOUND_30' | 'AVERAGE_DAILY' | 'COVERAGE' | 'FROZEN' | 'DEFECTIVE' | 'AVERAGE_PHYSICAL' | 'TURNOVER'
|
||||
export type TurnoverSortMetric = 'PHYSICAL' | 'AVAILABLE' | 'TRANSIT' | 'OUTBOUND_7' | 'OUTBOUND_15' | 'OUTBOUND_30' | 'AVERAGE_DAILY' | 'COVERAGE' | 'FROZEN' | 'DEFECTIVE' | 'AVERAGE_PHYSICAL' | 'TURNOVER' | 'INVENTORY_VOLUME' | 'OUTBOUND_VOLUME' | 'UNIT_VOLUME'
|
||||
export type TurnoverSort = `${TurnoverSortMetric}_${'ASC' | 'DESC'}` | 'NO_OUTBOUND_PHYSICAL_DESC'
|
||||
export interface TurnoverQuery {
|
||||
sourceAccount: string; warehouseCode: string; endDate: string; baseWindowDays: TurnoverWindow
|
||||
groupBy: TurnoverGroup; customerCode?: string; sku?: string; focus: TurnoverFocus
|
||||
groupBy: TurnoverGroup; hideZeroInventory?: boolean; customerCodes?: string[]; sku?: string; focus: TurnoverFocus
|
||||
coverageDaysThreshold?: number; turnoverStatus?: 'INSUFFICIENT'; sortBy: TurnoverSort
|
||||
current: number; size: number; includeOverview: boolean; snapshotContext?: string
|
||||
}
|
||||
@@ -19,6 +19,11 @@ export interface TurnoverWindowMetric {
|
||||
dataStatus: string; reasonCodes: string[]
|
||||
}
|
||||
export interface TurnoverTotal { value: Quantity; knownSubtotal: Quantity; unknownSkuCount: number; reasonCodes: string[] }
|
||||
export interface TurnoverVolume {
|
||||
value: Quantity; knownSubtotal: Quantity; missingDimensionQuantity: Quantity; missingDimensionSkuCount: number
|
||||
unknownQuantitySkuCount: number; declaredDimensionQuantity: Quantity; reasonCodes: string[]
|
||||
}
|
||||
export interface TurnoverVolumeWindow { days: number; startDate: string; endDateExclusive: string; volume: TurnoverVolume }
|
||||
export interface TurnoverRisk { matchedSkuCount: Quantity; knownPhysicalQuantity: Quantity; unknownSkuCount: number; physicalQuantityRatio: Quantity }
|
||||
export interface TurnoverRow {
|
||||
customerCode: string; customerName: string | null; sku: string | null; productName: string | null
|
||||
@@ -28,19 +33,24 @@ export interface TurnoverRow {
|
||||
quantityTurnoverCount: Quantity; turnoverStatus: string; qualifiedSampleDays: number; requiredSampleDays: number
|
||||
missingSampleDates: string[]; sampleReasonCodes: string[]; lastObservedOutboundDate: string | null
|
||||
attentionReasons: string[]; noOutboundInWindow: TurnoverRisk; slowCoverage: TurnoverRisk; unassessableSkuCount: number
|
||||
unitVolumeM3: Quantity; dimensionSource: 'WAREHOUSE_MEASURED' | 'PRODUCT_DECLARED' | 'MISSING' | null
|
||||
inventoryVolumeM3: TurnoverVolume; outboundVolumesM3: TurnoverVolumeWindow[]
|
||||
}
|
||||
export interface TurnoverContext {
|
||||
snapshotContext: string; sourceAccount: string; warehouseCode: string; endDate: string
|
||||
baseWindowDays: TurnoverWindow; queryAsOf: string; queryExpiresAt: string; analyticsContext: QueryContext
|
||||
inventoryObservedFrom: string | null; inventoryObservedUntil: string | null
|
||||
sampleRuleVersion: 'DAILY_SCHEDULED_FIRST_V1'; dailySamples: { date: string; runId: string | null }[]
|
||||
sampleRuleVersion: 'FLOW_EOD_V1'; dailySamples: { date: string; dayVersionId: string | null }[]
|
||||
missingSampleDates: string[]; accumulationStartedOn: string | null
|
||||
}
|
||||
export interface TurnoverOverview {
|
||||
physicalQuantity: TurnoverTotal; availableQuantity: TurnoverTotal; noOutboundSkuCount: TurnoverTotal; unassessableSkuCount: TurnoverTotal
|
||||
inventoryVolumeM3: TurnoverVolume; outboundVolumesM3: TurnoverVolumeWindow[]
|
||||
}
|
||||
export interface TurnoverCharts {
|
||||
ownerPhysicalTop: { customerCode: string; customerName: string | null; physicalQuantity: Quantity }[]
|
||||
ownerVolumeTop: { customerCode: string; customerName: string | null; inventoryVolumeM3: TurnoverVolume }[]
|
||||
unknownVolumeOwnerCount: number
|
||||
unknownOwnerCount: number; averageDaily: TurnoverWindowMetric[]
|
||||
}
|
||||
export interface TurnoverResponse {
|
||||
|
||||
Reference in New Issue
Block a user