Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a81c7e30bb | ||
|
|
97277b031d | ||
|
|
8b02f1d870 |
@@ -0,0 +1,98 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import AppointmentConfirmationCargo from './AppointmentConfirmationCargo.vue'
|
||||||
|
import { resolvePlanningOrder, resolvePlanningSource, type ResolvedPlanningSource } from './planningApi'
|
||||||
|
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
|
||||||
|
|
||||||
|
const access = vi.hoisted(() => ({ read: true }))
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.read }) }))
|
||||||
|
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||||
|
vi.mock('./planningApi', () => ({ resolvePlanningOrder: vi.fn(), resolvePlanningSource: vi.fn() }))
|
||||||
|
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
|
||||||
|
const source: ResolvedPlanningSource['source'] = { sourceKey: 'BOOKING:BK-TEST', customerId: 'customer-1', orderId: '9007199254740993', inboundCode: 'IN-TEST', bookingNo: 'BK-TEST', containerNo: null, originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'BOOKING:1/ORDER:1', canFollowup: true, blockReasons: [] }
|
||||||
|
const resolved: ResolvedPlanningSource = { source, coverage: { status: 'PENDING', appointments: [], missingFields: ['FBA_SHIPMENT_REFERENCES'], changeReviewRequired: false } }
|
||||||
|
const context: CargoVerificationContext = { source, verification: null, allocatedPackages: 20, unknownAllocationCount: 0, effectivePackages: 20, availablePackages: 0, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
|
||||||
|
const lookup = { orderId: source.orderId!, customerId: source.customerId, destinationKey: source.destinationKey }
|
||||||
|
function render(active = true, disabled = false) {
|
||||||
|
return mount(AppointmentConfirmationCargo, { props: { sources: [lookup], active, disabled }, global: { stubs: {
|
||||||
|
ElButton: { props: ['disabled', 'nativeType'], template: '<button :disabled="disabled" :type="nativeType || \'button\'"><slot /></button>' },
|
||||||
|
InlineHelp: { template: '<span />' }, ApiErrorMeta: true,
|
||||||
|
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<button @click="$emit(\'retry\')">重试核对</button></div>' },
|
||||||
|
} } })
|
||||||
|
}
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks(); access.read = true
|
||||||
|
vi.mocked(resolvePlanningOrder).mockResolvedValue(resolved)
|
||||||
|
vi.mocked(resolvePlanningSource).mockResolvedValue(resolved)
|
||||||
|
vi.mocked(getCargoVerification).mockResolvedValue(context)
|
||||||
|
vi.mocked(saveCargoVerification).mockResolvedValue({ version: 1, sourceVersion: source.sourceVersion, destinationKey: source.destinationKey, sourcePackages: 20, packageCount: 20, shipmentReferences: ['FBA-TEST'], remark: null, operatorName: '测试员', verifiedAt: '2026-10-21T03:00:00Z' })
|
||||||
|
})
|
||||||
|
it('names the missing material and supplements the canonical booking inside the appointment', async () => {
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
expect(resolvePlanningOrder).toHaveBeenCalledWith(lookup)
|
||||||
|
expect(wrapper.text()).toContain('IN-TEST'); expect(wrapper.text()).toContain('缺少本票 FBA 货件号或追踪号')
|
||||||
|
expect(wrapper.text()).toContain('不能代替货件号'); expect(wrapper.text()).not.toContain(source.orderId)
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
|
||||||
|
expect((wrapper.get('input[type=number]').element as HTMLInputElement).value).toBe('20')
|
||||||
|
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||||
|
expect(saveCargoVerification).not.toHaveBeenCalled()
|
||||||
|
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST')
|
||||||
|
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
|
||||||
|
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||||
|
expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ packageCount: 20, shipmentReferences: ['FBA-TEST'], expectedSourceVersion: source.sourceVersion }))
|
||||||
|
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
|
||||||
|
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('leaves pending arrangements lazy and reads only when formal confirmation is selected', async () => {
|
||||||
|
const wrapper = render(false); await flushPromises()
|
||||||
|
expect(resolvePlanningOrder).not.toHaveBeenCalled()
|
||||||
|
await wrapper.setProps({ active: true }); await flushPromises()
|
||||||
|
expect(resolvePlanningOrder).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('does not ask for known materials to be entered again', async () => {
|
||||||
|
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
|
||||||
|
expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
|
||||||
|
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('retains unsaved inputs when the appointment mode changes and blocks leaving an unknown save outcome', async () => {
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
|
||||||
|
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-KEEP')
|
||||||
|
await wrapper.setProps({ active: false })
|
||||||
|
expect(wrapper.get('textarea[aria-label="本票货件号"]').isVisible()).toBe(true)
|
||||||
|
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce(new Error('keep editing'))
|
||||||
|
expect(await wrapper.vm.canLeave()).toBe(false)
|
||||||
|
vi.mocked(saveCargoVerification).mockRejectedValueOnce(new Error('connection lost'))
|
||||||
|
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||||
|
expect(await wrapper.vm.canLeave()).toBe(false)
|
||||||
|
const payload = vi.mocked(saveCargoVerification).mock.calls[0]![1]
|
||||||
|
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||||
|
expect(saveCargoVerification).toHaveBeenNthCalledWith(2, source, payload); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('reports unknown quantity and unmaterialized cargo without inventing values', async () => {
|
||||||
|
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, source: { ...source, orderId: null } })
|
||||||
|
vi.mocked(getCargoVerification).mockResolvedValue({ ...context, effectivePackages: null })
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('缺少本票箱数'); expect(wrapper.text()).toContain('原来货登记完成实收关联')
|
||||||
|
expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
|
||||||
|
expect(wrapper.emitted('ready')?.at(-1)).toEqual([false]); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('keeps failed reads blocked, supports retry, and denies reads without permission', async () => {
|
||||||
|
vi.mocked(resolvePlanningOrder).mockRejectedValueOnce(new Error('network lost'))
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
expect(wrapper.find('[role=alert]').exists()).toBe(true)
|
||||||
|
expect(wrapper.emitted('ready')?.at(-1)).toEqual([false])
|
||||||
|
await wrapper.get('[role=alert] button').trigger('click'); await flushPromises()
|
||||||
|
expect(resolvePlanningOrder).toHaveBeenCalledTimes(2); wrapper.unmount()
|
||||||
|
vi.clearAllMocks(); access.read = false
|
||||||
|
const denied = render(); await flushPromises()
|
||||||
|
expect(resolvePlanningOrder).not.toHaveBeenCalled(); expect(denied.text()).toContain('查看权限'); denied.unmount()
|
||||||
|
})
|
||||||
|
it('keeps the supplement action disabled for read-only appointment views', async () => {
|
||||||
|
const wrapper = render(true, true); await flushPromises()
|
||||||
|
expect(wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.attributes('disabled')).toBeDefined()
|
||||||
|
expect(saveCargoVerification).not.toHaveBeenCalled(); wrapper.unmount()
|
||||||
|
})
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject, onBeforeUnmount, provide, ref, watch } from 'vue'
|
||||||
|
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 CargoVerificationForm from './CargoVerificationForm.vue'
|
||||||
|
import { getCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
|
||||||
|
import { resolvePlanningOrder, resolvePlanningSource, type PlanningSourceLookup, type ResolvedPlanningSource } from './planningApi'
|
||||||
|
import type { LeaveGuard } from './useWorkDraft'
|
||||||
|
|
||||||
|
const props = defineProps<{ sources: PlanningSourceLookup[]; active: boolean; disabled?: boolean }>()
|
||||||
|
const emit = defineEmits<{ ready: [value: boolean] }>()
|
||||||
|
const access = useAccessPreviewStore()
|
||||||
|
type Row = { resolved: ResolvedPlanningSource; cargo: CargoVerificationContext }
|
||||||
|
const rows = ref<Row[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||||
|
const editing = ref<string | null>(null), editor = ref<InstanceType<typeof CargoVerificationForm> | null>(null)
|
||||||
|
const editingRow = computed(() => rows.value.find(row => key(row) === editing.value))
|
||||||
|
let generation = 0
|
||||||
|
const guards = new Set<LeaveGuard>()
|
||||||
|
const register = inject<((guard: LeaveGuard) => () => void) | undefined>('appointment-work-leave-guard', undefined)
|
||||||
|
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
|
||||||
|
async function canLeave() { for (const guard of guards) if (!await guard()) return false; return true }
|
||||||
|
const unregister = register?.(canLeave)
|
||||||
|
defineExpose({ canLeave })
|
||||||
|
function key(row: Row) { return `${row.resolved.source.customerId}:${row.resolved.source.sourceKey}` }
|
||||||
|
function issues(row: Row) {
|
||||||
|
const missing: string[] = []
|
||||||
|
if (!row.resolved.source.orderId) missing.push('尚未来货建单,请先在原来货登记完成实收关联')
|
||||||
|
if (row.cargo.effectivePackages == null || row.cargo.effectivePackages <= 0) missing.push('缺少本票箱数')
|
||||||
|
if (row.resolved.coverage.missingFields.includes('FBA_SHIPMENT_REFERENCES')) missing.push('缺少本票 FBA 货件号或追踪号(至少一种)')
|
||||||
|
if (row.cargo.sourceChanged) missing.push('原货物资料已变化,请重新核对箱数和货件号')
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
const ready = computed(() => rows.value.length === props.sources.length && rows.value.length > 0 && !loading.value && !error.value && !editing.value && rows.value.every(row => !issues(row).length))
|
||||||
|
watch(ready, value => emit('ready', value), { immediate: true })
|
||||||
|
async function load() {
|
||||||
|
const current = ++generation
|
||||||
|
loading.value = true; error.value = null
|
||||||
|
try {
|
||||||
|
if (!access.can('platform:appointment:list') || !access.can('platform:overseas-warehouse:last-mile-order:list')) {
|
||||||
|
error.value = { message: '无法读取本票核实资料,请联系有预约列表和尾程订单查看权限的同事补齐。' }; return
|
||||||
|
}
|
||||||
|
const result: Row[] = []
|
||||||
|
// 只核对本次明确选择的货物,并由服务端解析原 ORDER / BOOKING 身份。
|
||||||
|
for (const lookup of props.sources) {
|
||||||
|
const resolved = 'sourceKey' in lookup ? await resolvePlanningSource(lookup) : await resolvePlanningOrder(lookup)
|
||||||
|
if (current !== generation) return
|
||||||
|
const cargo = await getCargoVerification(resolved.source)
|
||||||
|
if (current !== generation) return
|
||||||
|
result.push({ resolved, cargo })
|
||||||
|
}
|
||||||
|
if (current === generation) rows.value = result
|
||||||
|
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '货物缺项读取失败,请重试核对') }
|
||||||
|
finally { if (current === generation) loading.value = false }
|
||||||
|
}
|
||||||
|
async function closeEditor() {
|
||||||
|
if (editor.value && !await editor.value.canLeave()) return
|
||||||
|
editing.value = null
|
||||||
|
}
|
||||||
|
async function saved() { editing.value = null; await load() }
|
||||||
|
watch(() => [props.active, JSON.stringify(props.sources)] as const, ([active]) => { if (active && !editing.value) void load() }, { immediate: true })
|
||||||
|
onBeforeUnmount(() => { ++generation; unregister?.() })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section v-show="active || editing" class="density-dialog-section" data-tone="source" aria-label="正式确认前的货物资料" :aria-busy="loading">
|
||||||
|
<h3 class="density-dialog-section__title">正式确认前:核对本次送仓货物</h3>
|
||||||
|
<el-button :disabled="loading || !!editing || disabled" :loading="loading" @click="load">重新核对资料</el-button>
|
||||||
|
<p>本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。</p>
|
||||||
|
<LoadErrorState v-if="error" title="货物资料未核对" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
|
<table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 外接登记号</th><th>本票总箱数</th><th>需要补充的资料</th></tr></thead><tbody>
|
||||||
|
<tr v-for="row in rows" :key="key(row)">
|
||||||
|
<td><el-button v-if="issues(row).length && row.resolved.source.orderId" :disabled="disabled || loading || !!editing || !row.cargo.allowedActions.includes('VERIFY_CARGO')" @click="editing = key(row)">在此补齐资料</el-button><span v-else>—</span></td>
|
||||||
|
<td>{{ row.resolved.source.inboundCode || row.resolved.source.bookingNo || '未提供业务编号' }}</td>
|
||||||
|
<td>{{ row.cargo.effectivePackages ?? '待补充' }}</td>
|
||||||
|
<td>{{ issues(row).join(';') || '资料齐全,无需重复填写' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
|
<div v-if="editingRow">
|
||||||
|
<CargoVerificationForm ref="editor" :source="editingRow.resolved.source" :disabled="disabled" confirmation @saved="saved" />
|
||||||
|
<el-button :disabled="disabled" @click="closeEditor">收起补充资料</el-button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!loading && !error && !ready" role="status">请先在上表补齐并保存资料,再点击“保存预约安排”。暂时拿不到资料,可先选择“先保存安排,等待对方确认”。</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -42,13 +42,13 @@ const columns: DataTableColumn[] = [
|
|||||||
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
|
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
|
||||||
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
|
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
|
||||||
{ prop: 'packages', label: '箱数', width: 80 },
|
{ prop: 'packages', label: '箱数', width: 80 },
|
||||||
{ key: 'eligibility', label: '预约资格', minWidth: 130, slotName: 'eligibility' },
|
{ key: 'eligibility', label: '预约资格', minWidth: 260, singleLine: false, slotName: 'eligibility' },
|
||||||
]
|
]
|
||||||
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
|
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
|
||||||
function reason(row: PlanningCargo) {
|
function reason(row: PlanningCargo) {
|
||||||
if (row.detained) return '货物暂扣中'
|
if (row.detained) return '货物暂扣中'
|
||||||
if (row.mixedDestinations) return '多个目的地,需先核对'
|
if (row.mixedDestinations) return '多个目的地,需先核对'
|
||||||
if (row.hasActiveAppointment) return '已有有效预约'
|
if (row.hasActiveAppointment) return '已有预约安排,请到入库单编辑原预约'
|
||||||
const anchor = selectedRows.value[0]
|
const anchor = selectedRows.value[0]
|
||||||
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
|
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
|
||||||
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
|
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
|
||||||
|
|||||||
@@ -10,15 +10,16 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext, type CargoVerificationSource, type SaveCargoVerification } from './cargoVerificationApi'
|
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext, type CargoVerificationSource, type SaveCargoVerification } from './cargoVerificationApi'
|
||||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||||
const props = defineProps<{ source: CargoVerificationSource }>()
|
const props = defineProps<{ source: CargoVerificationSource; confirmation?: boolean; disabled?: boolean }>()
|
||||||
const emit = defineEmits<{ saved: [] }>()
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
const context = ref<CargoVerificationContext | null>(null), loading = ref(false), saving = ref(false)
|
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 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 form = reactive({ packages: '', references: '', remark: '' }), baseline = ref(JSON.stringify(form))
|
||||||
const dirty = computed(() => JSON.stringify(form) !== baseline.value)
|
const dirty = computed(() => JSON.stringify(form) !== baseline.value)
|
||||||
useWorkDraft(dirty, computed(() => saving.value || !!pending.value))
|
const canLeave = 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'))
|
defineExpose({ canLeave })
|
||||||
|
const canWrite = computed(() => !props.disabled && access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && context.value?.allowedActions.includes('VERIFY_CARGO'))
|
||||||
async function load(preserve = false) {
|
async function load(preserve = false) {
|
||||||
loading.value = true; error.value = null
|
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 }
|
if (!access.can('platform:appointment:list') || !access.can('platform:overseas-warehouse:last-mile-order:list')) { error.value = { message: '当前账号没有该货源的读取权限,请联系预约负责人处理。' }; loading.value = false; return }
|
||||||
@@ -26,7 +27,7 @@ async function load(preserve = false) {
|
|||||||
context.value = await getCargoVerification(props.source)
|
context.value = await getCargoVerification(props.source)
|
||||||
if (!preserve) {
|
if (!preserve) {
|
||||||
const record = context.value.verification
|
const record = context.value.verification
|
||||||
Object.assign(form, { packages: record?.packageCount == null ? '' : String(record.packageCount), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' })
|
Object.assign(form, { packages: context.value.effectivePackages == null ? '' : String(context.value.effectivePackages), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' })
|
||||||
baseline.value = JSON.stringify(form)
|
baseline.value = JSON.stringify(form)
|
||||||
}
|
}
|
||||||
} catch (cause) { error.value = getApiErrorDetails(cause, '核实资料读取失败') }
|
} catch (cause) { error.value = getApiErrorDetails(cause, '核实资料读取失败') }
|
||||||
@@ -39,6 +40,7 @@ async function save() {
|
|||||||
if (count !== null && (!Number.isSafeInteger(count) || count < 1)) { ElMessage.warning('核实箱数须为正整数,暂未核实可留空'); return }
|
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 }
|
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))]
|
const references = [...new Set(form.references.split(/\r?\n/).map(value => value.trim()).filter(Boolean))]
|
||||||
|
if (props.confirmation && (count === null || !references.length)) { ElMessage.warning('请填写本票箱数,以及至少一个 FBA 货件号或追踪号;外部约号不能代替货件号'); return }
|
||||||
if (references.length > 100 || references.some(value => value.length > 128)) { ElMessage.warning('最多填写100条货件号,每条不超过128字'); return }
|
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() }
|
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() }
|
||||||
}
|
}
|
||||||
@@ -56,14 +58,15 @@ onMounted(() => load())
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section aria-label="货物核实资料">
|
<section aria-label="货物核实资料">
|
||||||
<h3>核实货物资料<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
|
<h3>{{ confirmation ? '补齐本票送仓资料' : '核实货物资料' }}<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
|
||||||
|
<p v-if="confirmation">请向客户或货物负责人取得本票完整的 FBA 货件号或追踪号,每行一个;不要填写预约外部约号。下方保存只补充货物资料,完成后还需保存预约安排。</p>
|
||||||
<LoadErrorState v-if="error" title="核实资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<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>
|
<p v-if="loading">正在读取货物资料…</p>
|
||||||
<template v-if="context">
|
<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>
|
<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><template v-if="!confirmation"><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></template></dl>
|
||||||
<p v-if="context.sourceChanged" role="alert">原货源已变化,请核对当前原票和已分配量后重新保存。</p><p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
|
<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 appointment-native-form" @submit.prevent="save"><fieldset :disabled="!canWrite || loading || !!error || saving || !!pending">
|
<form class="density-form work-form appointment-native-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="核实箱数" class="workbench-input"></label>
|
<label>{{ confirmation ? '本票核实总箱数(必填)' : '核实箱数(可暂不填)' }}<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数" class="workbench-input"></label>
|
||||||
<label>本票货件号 / 追踪号(每行一个)<textarea v-model="form.references" rows="4" aria-label="本票货件号" class="workbench-input" /></label>
|
<label>本票货件号 / 追踪号(每行一个)<textarea v-model="form.references" rows="4" aria-label="本票货件号" class="workbench-input" /></label>
|
||||||
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" class="workbench-input" /></label>
|
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" class="workbench-input" /></label>
|
||||||
</fieldset><p v-if="!canWrite">当前仅可查看核实资料;需核实权限及有效货源资格才能保存。</p>
|
</fieldset><p v-if="!canWrite">当前仅可查看核实资料;需核实权限及有效货源资格才能保存。</p>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn()
|
|||||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getFbaAppointment: vi.fn(), updateFbaAppointment: 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() }))
|
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' }
|
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: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true } } }) }
|
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', mounted() { this.$emit('confirmationReady', true) }, props: ['model', 'disabled'], template: '<div />' }, LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true } } }) }
|
||||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getFbaAppointment).mockResolvedValue(detail); vi.mocked(updateFbaAppointment).mockResolvedValue(detail) })
|
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getFbaAppointment).mockResolvedValue(detail); vi.mocked(updateFbaAppointment).mockResolvedValue(detail) })
|
||||||
it('initializes confirmed facts after an initial read failure instead of showing an empty editable plan', async () => {
|
it('initializes confirmed facts after an initial read failure instead of showing an empty editable plan', async () => {
|
||||||
vi.mocked(getFbaAppointment).mockRejectedValueOnce(new Error('来源暂不可用'))
|
vi.mocked(getFbaAppointment).mockRejectedValueOnce(new Error('来源暂不可用'))
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
|||||||
const props = defineProps<{ appointmentNo: string; cancel: boolean; canWrite: boolean }>()
|
const props = defineProps<{ appointmentNo: string; cancel: boolean; canWrite: boolean }>()
|
||||||
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto]; batch: [key: string]; receiving: [key: string] }>()
|
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto]; batch: [key: string]; receiving: [key: string] }>()
|
||||||
const access = useAccessPreviewStore(), detail = ref<FbaAppointmentDto | null>(null)
|
const access = useAccessPreviewStore(), detail = ref<FbaAppointmentDto | null>(null)
|
||||||
|
const confirmationReady = ref(false)
|
||||||
|
const appointmentFields = ref<InstanceType<typeof FbaAppointmentFields> | null>(null)
|
||||||
const form = reactive(createAppointmentForm()), reason = ref(''), baseline = ref(JSON.stringify(appointmentPayload(form)))
|
const form = reactive(createAppointmentForm()), reason = ref(''), baseline = ref(JSON.stringify(appointmentPayload(form)))
|
||||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||||
let initialized = false
|
let initialized = false
|
||||||
@@ -44,6 +46,9 @@ async function save() {
|
|||||||
pending.value = { kind: 'cancel', version: detail.value.version, reason: reason.value.trim() }
|
pending.value = { kind: 'cancel', version: detail.value.version, reason: reason.value.trim() }
|
||||||
} else {
|
} else {
|
||||||
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
|
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
|
||||||
|
if (form.appointmentStatus === 'SUCCESS' && !confirmationReady.value) { ElMessage.warning('请先在“正式确认前”区域补齐并保存每票货物资料'); return }
|
||||||
|
if (appointmentFields.value?.canLeave && !await appointmentFields.value.canLeave()) return
|
||||||
|
if (saving.value || !writable.value) return
|
||||||
pending.value = { kind: 'update', payload: { details: appointmentPayload(form), version: detail.value.version, idempotencyKey: crypto.randomUUID() } }
|
pending.value = { kind: 'update', payload: { details: appointmentPayload(form), version: detail.value.version, idempotencyKey: crypto.randomUUID() } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +75,7 @@ onMounted(() => load())
|
|||||||
<el-button v-if="detail.batchKey && !saving && !pending" @click="emit('batch', detail.batchKey)">{{ replaced ? '查看替代批次' : '分批 / 合并与货物来源' }}</el-button>
|
<el-button v-if="detail.batchKey && !saving && !pending" @click="emit('batch', detail.batchKey)">{{ replaced ? '查看替代批次' : '分批 / 合并与货物来源' }}</el-button>
|
||||||
<el-button v-if="detail.batchKey && !replaced && !saving && !pending" :disabled="loading || !!error" @click="emit('receiving', detail.batchKey)">客户调整 / 共同预约范围</el-button>
|
<el-button v-if="detail.batchKey && !replaced && !saving && !pending" :disabled="loading || !!error" @click="emit('receiving', detail.batchKey)">客户调整 / 共同预约范围</el-button>
|
||||||
<template v-if="cancel"><p>取消原预约并保留历史;工作需求与后续跟进仍保留。已有运输安排须满足原业务取消条件。</p><label>取消原因<textarea v-model="reason" :disabled="!writable || saving || !!pending" maxlength="1000" class="workbench-input" /></label></template>
|
<template v-if="cancel"><p>取消原预约并保留历史;工作需求与后续跟进仍保留。已有运输安排须满足原业务取消条件。</p><label>取消原因<textarea v-model="reason" :disabled="!writable || saving || !!pending" maxlength="1000" class="workbench-input" /></label></template>
|
||||||
<FbaAppointmentFields v-else :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
|
<FbaAppointmentFields v-else ref="appointmentFields" :sources="detail.items.map(item => ({ sourceKey: item.sourceKey, customerId: item.customerId, destinationKey: item.destinationKey || `FBA:${item.fbaWarehouseCode}` }))" @confirmation-ready="confirmationReady = $event" :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
|
||||||
<p v-if="!writable">当前预约仅可查看。</p>
|
<p v-if="!writable">当前预约仅可查看。</p>
|
||||||
<LoadErrorState v-if="saveError" title="预约未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
<LoadErrorState v-if="saveError" title="预约未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||||
<p v-if="!cancel && !rebooking && writable && !changed && !pending">预约内容未修改,无需重复保存。</p>
|
<p v-if="!cancel && !rebooking && writable && !changed && !pending">预约内容未修改,无需重复保存。</p>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { workReason } from './workPresentation'
|
|||||||
const props = defineProps<{ sources: SourceReference[]; canWrite: boolean }>()
|
const props = defineProps<{ sources: SourceReference[]; canWrite: boolean }>()
|
||||||
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto] }>()
|
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto] }>()
|
||||||
const access = useAccessPreviewStore(), preview = ref<PlanningBatchPreview | null>(null)
|
const access = useAccessPreviewStore(), preview = ref<PlanningBatchPreview | null>(null)
|
||||||
|
const confirmationReady = ref(false)
|
||||||
|
const appointmentFields = ref<InstanceType<typeof FbaAppointmentFields> | null>(null)
|
||||||
const form = reactive(createAppointmentForm()), baseline = ref(JSON.stringify(form))
|
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 loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||||
const pending = ref<SourceAppointmentPayload | null>(null)
|
const pending = ref<SourceAppointmentPayload | null>(null)
|
||||||
@@ -29,6 +31,9 @@ async function save() {
|
|||||||
if (!writable.value || loading.value || saving.value || error.value || !preview.value) return
|
if (!writable.value || loading.value || saving.value || error.value || !preview.value) return
|
||||||
if (!pending.value) {
|
if (!pending.value) {
|
||||||
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
|
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
|
||||||
|
if (form.appointmentStatus === 'SUCCESS' && !confirmationReady.value) { ElMessage.warning('请先在“正式确认前”区域补齐并保存每票货物资料'); return }
|
||||||
|
if (appointmentFields.value?.canLeave && !await appointmentFields.value.canLeave()) return
|
||||||
|
if (saving.value || !writable.value) 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() }
|
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
|
saving.value = true; saveError.value = null
|
||||||
@@ -58,7 +63,7 @@ onMounted(load)
|
|||||||
<p v-for="field in preview.missingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
|
<p v-for="field in preview.missingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
|
||||||
<p v-if="preview.group.readinessIncomplete" role="status" class="density-dialog-note">货物备妥窗口待核实。</p>
|
<p v-if="preview.group.readinessIncomplete" role="status" class="density-dialog-note">货物备妥窗口待核实。</p>
|
||||||
</section>
|
</section>
|
||||||
<FbaAppointmentFields continuous :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
|
<FbaAppointmentFields continuous ref="appointmentFields" :sources="sources.map(source => ({ ...source, destinationKey: source.destinationKey || undefined }))" @confirmation-ready="confirmationReady = $event" :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>
|
<LoadErrorState v-if="saveError" title="预约尚未创建" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||||
<div class="density-actions density-actions--footer"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div>
|
<div class="density-actions density-actions--footer"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
export { appointmentReturnTarget, appointmentReturnQuery } from './workNavigation'
|
export { appointmentReturnTarget, appointmentReturnQuery } from './workNavigation'
|
||||||
export { default as TripAppointmentPanel } from './TripAppointmentPanel.vue'
|
export { default as TripAppointmentPanel } from './TripAppointmentPanel.vue'
|
||||||
|
export { default as AppointmentConfirmationCargo } from './AppointmentConfirmationCargo.vue'
|
||||||
|
export type { PlanningSourceLookup } from './planningApi'
|
||||||
|
|||||||
@@ -63,6 +63,16 @@
|
|||||||
|
|
||||||
续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。
|
续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。
|
||||||
|
|
||||||
|
## 自有业务行操作样式核对(2026-10-21)
|
||||||
|
|
||||||
|
依据本轮用户截图 `codex-clipboard-eb6aa95b-c8a2-4b85-9294-22f3127cf72c.png` 与 Admin 界面规范“列表”纯文字行操作规则;仅修正列表按钮外观。
|
||||||
|
|
||||||
|
| 页面 | 操作 | 已确认承载 | 当前问题与修正 | 验收 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 自有业务列表 | 查看明细 | 原独立详情路由 | 旧描边粗体按钮接入公共纯文字行操作类,保留路由和业务编号 | 77项定向检查及类型/构建通过;修改后待人工截图确认 |
|
||||||
|
| 自有业务列表 | 登记运营信息 | 原1080px居中弹框 | 同上;保留原权限、状态及处理中禁用 | 77项定向检查及类型/构建通过;修改后待人工截图确认 |
|
||||||
|
| 自有业务列表 | 上传/查看照片 | 原760px居中弹框 | 同上;保留原读写权限与按钮文案 | 77项定向检查及类型/构建通过;修改后待人工截图确认 |
|
||||||
|
|
||||||
## 自有业务实现核对(2026-10-20,主规则已确认)
|
## 自有业务实现核对(2026-10-20,主规则已确认)
|
||||||
|
|
||||||
用户当前要求逐页处理,先看自有业务;HTTP 扩展由既有排车后端对话独立负责。本节记录原型与已确认规则的映射,不授权外配页面改造。自有与外配均为原排车装车后的车辆归属视图,不另建任务或履约流程;自有车增加运营细项,外配管理细度后续确认。视觉沿当前 Admin 高密度规范,业务与操作承载依据用户提供的 `C:/Users/BY-OY/Documents/WXWork/1688854456873012/Cache/File/2026-10/悦/悦/新版.html` 及本轮截图。
|
用户当前要求逐页处理,先看自有业务;HTTP 扩展由既有排车后端对话独立负责。本节记录原型与已确认规则的映射,不授权外配页面改造。自有与外配均为原排车装车后的车辆归属视图,不另建任务或履约流程;自有车增加运营细项,外配管理细度后续确认。视觉沿当前 Admin 高密度规范,业务与操作承载依据用户提供的 `C:/Users/BY-OY/Documents/WXWork/1688854456873012/Cache/File/2026-10/悦/悦/新版.html` 及本轮截图。
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ beforeEach(() => vi.clearAllMocks())
|
|||||||
const base = '/platform/v1/last-leg/fleet/outsourced-business'
|
const base = '/platform/v1/last-leg/fleet/outsourced-business'
|
||||||
const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
|
const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
|
||||||
describe('outsourced business HTTP contract', () => {
|
describe('outsourced business HTTP contract', () => {
|
||||||
|
it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified pooled %s vehicles in lists, details and write responses', async ownership => {
|
||||||
|
const detail = outsourcedBusinessFixture(); detail.row.vehicle.ownership = ownership
|
||||||
|
vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail })
|
||||||
|
expect((await listOutsourcedBusiness({})).records[0]!.vehicle).toEqual(detail.row.vehicle)
|
||||||
|
expect((await getOutsourcedBusiness(detail.row.dispatchNo)).row.vehicle.ownership).toBe(ownership)
|
||||||
|
vi.mocked(http.put).mockResolvedValue({ data: detail })
|
||||||
|
expect((await saveOutsourcedBusinessOperations(detail.row.dispatchNo, { recordVersion: detail.row.recordVersion, idempotencyKey: 'pool-ownership', operations: detail.row.operations, reason: null })).row.vehicle.ownership).toBe(ownership)
|
||||||
|
})
|
||||||
|
it.each(['UNKNOWN', null, undefined])('rejects invalid vehicle ownership %s', async ownership => {
|
||||||
|
const detail = outsourcedBusinessFixture()
|
||||||
|
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, vehicle: { ...detail.row.vehicle, ownership } } } })
|
||||||
|
await expect(getOutsourcedBusiness(detail.row.dispatchNo)).rejects.toThrow('来源或阶段')
|
||||||
|
})
|
||||||
it('uses one list response for matched statistics and keeps physical count independent of cargo rows', async () => {
|
it('uses one list response for matched statistics and keeps physical count independent of cargo rows', async () => {
|
||||||
const detail = outsourcedBusinessFixture()
|
const detail = outsourcedBusinessFixture()
|
||||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [detail.row], page: '1', size: '20', total: '41', summary: { dispatchCount: '41', afterLoadingCount: '40', deliveringCount: '1', deliveredCount: '0' } } })
|
vi.mocked(http.get).mockResolvedValue({ data: { records: [detail.row], page: '1', size: '20', total: '41', summary: { dispatchCount: '41', afterLoadingCount: '40', deliveringCount: '1', deliveredCount: '0' } } })
|
||||||
@@ -69,10 +82,8 @@ describe('outsourced business HTTP contract', () => {
|
|||||||
expect(value.row.vehicle).toMatchObject({ vehicleId, ownership: 'DISPATCHED', carrierName: null })
|
expect(value.row.vehicle).toMatchObject({ vehicleId, ownership: 'DISPATCHED', carrierName: null })
|
||||||
expect(http.get).toHaveBeenCalledTimes(1)
|
expect(http.get).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
it('rejects owned vehicle facts and unsupported fuel or mileage writes before any request', async () => {
|
it('rejects unsupported fuel or mileage writes before any request', async () => {
|
||||||
const detail = outsourcedBusinessFixture()
|
const detail = outsourcedBusinessFixture()
|
||||||
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, vehicle: { ...detail.row.vehicle, ownership: 'OWNED' } } } })
|
|
||||||
await expect(getOutsourcedBusiness('DS-OUT-1')).rejects.toThrow('来源或阶段')
|
|
||||||
for (const field of ['actualDistanceMiles','fuelConsumptionLitresPer100Km','actualFuelLitres']) {
|
for (const field of ['actualDistanceMiles','fuelConsumptionLitresPer100Km','actualFuelLitres']) {
|
||||||
const body = { recordVersion:'0', idempotencyKey:'original-key', operations:{ ...detail.row.operations, [field]:'1' }, reason:null }
|
const body = { recordVersion:'0', idempotencyKey:'original-key', operations:{ ...detail.row.operations, [field]:'1' }, reason:null }
|
||||||
await expect(saveOutsourcedBusinessOperations('DS-OUT-1', body)).rejects.toThrow('不支持里程或油耗')
|
await expect(saveOutsourcedBusinessOperations('DS-OUT-1', body)).rejects.toThrow('不支持里程或油耗')
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function operations(value: OutsourcedBusinessOperations): OutsourcedBusinessOper
|
|||||||
}
|
}
|
||||||
function requireSlot(slot: OutsourcedBusinessPhotoSlot) { if (!outsourcedBusinessPhotoRoles.some(role => role.slot === slot)) throw new Error('外配任务不支持该照片分类') }
|
function requireSlot(slot: OutsourcedBusinessPhotoSlot) { if (!outsourcedBusinessPhotoRoles.some(role => role.slot === slot)) throw new Error('外配任务不支持该照片分类') }
|
||||||
function row(value: OutsourcedBusinessRow): OutsourcedBusinessRow {
|
function row(value: OutsourcedBusinessRow): OutsourcedBusinessRow {
|
||||||
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || value.vehicle?.ownership !== 'DISPATCHED') throw new Error('外配排车来源或阶段不完整,请重新读取')
|
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || !['OWNED','DISPATCHED'].includes(value.vehicle?.ownership)) throw new Error('外配排车来源或阶段不完整,请重新读取')
|
||||||
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: outsourcedBusinessVersion(value.recordVersion), dispatchRecordVersion: outsourcedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount) } }
|
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: outsourcedBusinessVersion(value.recordVersion), dispatchRecordVersion: outsourcedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount) } }
|
||||||
}
|
}
|
||||||
function detail(value: OutsourcedBusinessDetail): OutsourcedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
|
function detail(value: OutsourcedBusinessDetail): OutsourcedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ beforeEach(() => vi.clearAllMocks())
|
|||||||
const base = '/platform/v1/last-leg/fleet/owned-business'
|
const base = '/platform/v1/last-leg/fleet/owned-business'
|
||||||
const photo: OwnedBusinessPhoto = { slot: 'FUEL_START', category: 'FUEL', fileId: '9007199254740993', fileName: 'fuel.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
|
const photo: OwnedBusinessPhoto = { slot: 'FUEL_START', category: 'FUEL', fileId: '9007199254740993', fileName: 'fuel.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
|
||||||
describe('owned business HTTP contract', () => {
|
describe('owned business HTTP contract', () => {
|
||||||
|
it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified non-pooled %s vehicles in lists, details and write responses', async ownership => {
|
||||||
|
const detail = ownedBusinessFixture(); detail.row.vehicle.ownership = ownership; detail.row.vehicle.vehicleId = null
|
||||||
|
vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail })
|
||||||
|
expect((await listOwnedBusiness({})).records[0]!.vehicle).toEqual(detail.row.vehicle)
|
||||||
|
expect((await getOwnedBusiness(detail.row.dispatchNo)).row.vehicle.ownership).toBe(ownership)
|
||||||
|
vi.mocked(http.put).mockResolvedValue({ data: detail })
|
||||||
|
expect((await saveOwnedBusinessOperations(detail.row.dispatchNo, { recordVersion: detail.row.recordVersion, idempotencyKey: 'non-pool-ownership', operations: detail.row.operations, reason: null })).row.vehicle.ownership).toBe(ownership)
|
||||||
|
})
|
||||||
|
it.each(['UNKNOWN', null, undefined])('rejects invalid vehicle ownership %s', async ownership => {
|
||||||
|
const detail = ownedBusinessFixture()
|
||||||
|
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, vehicle: { ...detail.row.vehicle, ownership } } } })
|
||||||
|
await expect(getOwnedBusiness(detail.row.dispatchNo)).rejects.toThrow('来源或阶段')
|
||||||
|
})
|
||||||
it('uses one list response for matched statistics and keeps physical count independent of cargo rows', async () => {
|
it('uses one list response for matched statistics and keeps physical count independent of cargo rows', async () => {
|
||||||
const detail = ownedBusinessFixture()
|
const detail = ownedBusinessFixture()
|
||||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [detail.row], page: '1', size: '20', total: '41', summary: { dispatchCount: '41', afterLoadingCount: '40', deliveringCount: '1', deliveredCount: '0' } } })
|
vi.mocked(http.get).mockResolvedValue({ data: { records: [detail.row], page: '1', size: '20', total: '41', summary: { dispatchCount: '41', afterLoadingCount: '40', deliveringCount: '1', deliveredCount: '0' } } })
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function operations(value: OwnedBusinessOperations): OwnedBusinessOperations {
|
|||||||
return { ...value, money: value.money ? { ...value.money, amount: decimal(value.money.amount, 2, 18)! } : null, actualDistanceMiles: decimal(value.actualDistanceMiles,3,12), fuelConsumptionLitresPer100Km: decimal(value.fuelConsumptionLitresPer100Km,3,12) }
|
return { ...value, money: value.money ? { ...value.money, amount: decimal(value.money.amount, 2, 18)! } : null, actualDistanceMiles: decimal(value.actualDistanceMiles,3,12), fuelConsumptionLitresPer100Km: decimal(value.fuelConsumptionLitresPer100Km,3,12) }
|
||||||
}
|
}
|
||||||
function row(value: OwnedBusinessRow): OwnedBusinessRow {
|
function row(value: OwnedBusinessRow): OwnedBusinessRow {
|
||||||
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || value.vehicle?.ownership !== 'OWNED') throw new Error('自有排车来源或阶段不完整,请重新读取')
|
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || !['OWNED','DISPATCHED'].includes(value.vehicle?.ownership)) throw new Error('自有排车来源或阶段不完整,请重新读取')
|
||||||
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: ownedBusinessVersion(value.recordVersion), dispatchRecordVersion: ownedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount), fuelCount: count(value.photoSummary.fuelCount) } }
|
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: ownedBusinessVersion(value.recordVersion), dispatchRecordVersion: ownedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount), fuelCount: count(value.photoSummary.fuelCount) } }
|
||||||
}
|
}
|
||||||
function detail(value: OwnedBusinessDetail): OwnedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
|
function detail(value: OwnedBusinessDetail): OwnedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
|
|||||||
<template #toolbar-start><h1>自有业务<InlineHelp title="查询范围说明">仅统计未实际接入拼车货物的已装车排车,与车辆归属无关;日期和时间按浏览器本地时区显示,统计覆盖同一完整筛选范围。</InlineHelp></h1></template>
|
<template #toolbar-start><h1>自有业务<InlineHelp title="查询范围说明">仅统计未实际接入拼车货物的已装车排车,与车辆归属无关;日期和时间按浏览器本地时区显示,统计覆盖同一完整筛选范围。</InlineHelp></h1></template>
|
||||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自有业务筛选" preference-key="owned-business-query" :loading="loading" @search="search" /></template>
|
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自有业务筛选" preference-key="owned-business-query" :loading="loading" @search="search" /></template>
|
||||||
<template #notices><LoadErrorState v-if="error" title="自有业务读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="warehouseError">{{ warehouseError.message }} <el-button link @click="loadWarehouses">重试目录</el-button></p><p v-if="!access.can('platform:warehouse:list')">没有公共仓库目录读取权限,仓库筛选暂不可选。</p></template>
|
<template #notices><LoadErrorState v-if="error" title="自有业务读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="warehouseError">{{ warehouseError.message }} <el-button link @click="loadWarehouses">重试目录</el-button></p><p v-if="!access.can('platform:warehouse:list')">没有公共仓库目录读取权限,仓库筛选暂不可选。</p></template>
|
||||||
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" link @click="router.push(`/overseas-master-data/owned-business/${encodeURIComponent(row.dispatchNo)}`)">查看明细</el-button><el-button v-if="access.can(permission.ownedBusinessEdit)" class="density-action" link :disabled="opening || mutating || !row.actions.edit.allowed" @click="openAction(row as OwnedBusinessRow, 'operations')">登记运营信息</el-button><el-button class="density-action" link :disabled="opening || mutating" @click="openAction(row as OwnedBusinessRow, 'photos')">{{ access.can(permission.ownedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></div></template>
|
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" link @click="router.push(`/overseas-master-data/owned-business/${encodeURIComponent(row.dispatchNo)}`)">查看明细</el-button><el-button v-if="access.can(permission.ownedBusinessEdit)" class="density-action" link :disabled="opening || mutating || !row.actions.edit.allowed" @click="openAction(row as OwnedBusinessRow, 'operations')">登记运营信息</el-button><el-button class="density-action" link :disabled="opening || mutating" @click="openAction(row as OwnedBusinessRow, 'photos')">{{ access.can(permission.ownedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></div></template>
|
||||||
<template #driver="{ row }">{{ text(row.driver.name) }}<br>{{ text(row.driver.phone) }}</template>
|
<template #driver="{ row }">{{ text(row.driver.name) }}<br>{{ text(row.driver.phone) }}</template>
|
||||||
<template #appointments="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template>
|
<template #appointments="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template>
|
||||||
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约 {{ time(appointment.scheduledAt) }}</div><div>实到 {{ time(destination.actualArrivedAt) }}</div></div></template>
|
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约 {{ time(appointment.scheduledAt) }}</div><div>实到 {{ time(destination.actualArrivedAt) }}</div></div></template>
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||||
|
import { AppointmentConfirmationCargo, type PlanningSourceLookup } from '@/modules/appointment-calendar'
|
||||||
|
import { ref } from 'vue'
|
||||||
import type { AppointmentForm } from '../appointmentForm'
|
import type { AppointmentForm } from '../appointmentForm'
|
||||||
defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean }>()
|
defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean; sources?: PlanningSourceLookup[] }>()
|
||||||
|
const emit = defineEmits<{ confirmationReady: [ready: boolean] }>()
|
||||||
|
const cargo = ref<InstanceType<typeof AppointmentConfirmationCargo> | null>(null)
|
||||||
|
defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true) })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
<p v-if="model.appointmentStatus === 'SUCCESS' && timezone && /^(UTC|GMT|Etc\/(GMT|UTC)|[+-])/.test(timezone)" role="alert">目的仓当前只有固定时差,尚未设置地区时区。请到“仓库管理”编辑本次目的 FBA 仓,按实际地址设置地区时区(如 America/New_York),再重新打开预约;固定时差无法判断夏令时。</p>
|
||||||
|
<AppointmentConfirmationCargo v-if="sources?.length" ref="cargo" :sources="sources" :active="model.appointmentStatus === 'SUCCESS'" :disabled="disabled" @ready="emit('confirmationReady', $event)" />
|
||||||
<div v-if="continuous" class="density-dialog-sections">
|
<div v-if="continuous" class="density-dialog-sections">
|
||||||
<section class="density-dialog-section" data-tone="source" aria-label="预约来源信息"><h3 class="density-dialog-section__title">预约信息</h3><table class="density-table"><tbody><tr><th scope="row">系统单号</th><td>{{ appointmentNo || '保存后生成' }}</td><th scope="row">目的仓时区</th><td>{{ timezone || '待核实' }}</td></tr></tbody></table></section>
|
<section class="density-dialog-section" data-tone="source" aria-label="预约来源信息"><h3 class="density-dialog-section__title">预约信息</h3><table class="density-table"><tbody><tr><th scope="row">系统单号</th><td>{{ appointmentNo || '保存后生成' }}</td><th scope="row">目的仓时区</th><td>{{ timezone || '待核实' }}</td></tr></tbody></table></section>
|
||||||
<section class="density-dialog-section" data-tone="work" aria-label="预约安排登记"><h3 class="density-dialog-section__title">预约安排</h3>
|
<section class="density-dialog-section" data-tone="work" aria-label="预约安排登记"><h3 class="density-dialog-section__title">预约安排</h3>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { overseasWarehousePermissions as permission } from '../permissions'
|
|||||||
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: 'C-1' } }), useRouter: () => ({ push: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: 'C-1' } }), useRouter: () => ({ push: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||||
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
|
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null, AppointmentConfirmationCargo: { name: 'AppointmentConfirmationCargo', mounted() { this.$emit('ready', true) }, methods: { canLeave: async () => true }, template: '<div />' } }))
|
||||||
vi.mock('../components/SmartDispatchDialog.vue', () => ({ default: { name: 'SmartDispatchDialog', template: '<div />' } }))
|
vi.mock('../components/SmartDispatchDialog.vue', () => ({ default: { name: 'SmartDispatchDialog', template: '<div />' } }))
|
||||||
vi.mock('../api/inboundOrders', () => ({ createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(), updateInboundPlan: vi.fn() }))
|
vi.mock('../api/inboundOrders', () => ({ createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(), updateInboundPlan: vi.fn() }))
|
||||||
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
|
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
|
||||||
@@ -99,7 +99,7 @@ describe('inbound appointment native dialogs', () => {
|
|||||||
expect(dialog.text()).toContain('appointment-popup-request')
|
expect(dialog.text()).toContain('appointment-popup-request')
|
||||||
await dialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
|
await dialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
|
||||||
expect(dialog.props('modelValue')).toBe(true)
|
expect(dialog.props('modelValue')).toBe(true)
|
||||||
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
|
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) expect(await (vi.mocked(hook).mock.calls[0]![0] as () => Promise<boolean>)()).toBe(false)
|
||||||
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||||||
expect(vi.mocked(updateFbaAppointment).mock.calls[1]).toEqual(original)
|
expect(vi.mocked(updateFbaAppointment).mock.calls[1]).toEqual(original)
|
||||||
expect(dialog.props('modelValue')).toBe(false)
|
expect(dialog.props('modelValue')).toBe(false)
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ it('keeps the inbound appointment retry across close, reopen and route attempts'
|
|||||||
const done=vi.fn();dialog.props('beforeClose')(done);expect(done).not.toHaveBeenCalled()
|
const done=vi.fn();dialog.props('beforeClose')(done);expect(done).not.toHaveBeenCalled()
|
||||||
expect(wrapper.find('[data-save]').exists()).toBe(true)
|
expect(wrapper.find('[data-save]').exists()).toBe(true)
|
||||||
await open();await flushPromises();expect(getFbaAppointment).toHaveBeenCalledTimes(1)
|
await open();await flushPromises();expect(getFbaAppointment).toHaveBeenCalledTimes(1)
|
||||||
for(const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
|
for(const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect(await (vi.mocked(hook).mock.calls[0]![0] as () => Promise<boolean>)()).toBe(false)
|
||||||
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||||
expect(updateFbaAppointment).toHaveBeenNthCalledWith(2,'AP-1',original)
|
expect(updateFbaAppointment).toHaveBeenNthCalledWith(2,'AP-1',original)
|
||||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||||
expect((vi.mocked(onBeforeRouteLeave).mock.calls[0]![0] as () => boolean)()).toBe(true)
|
expect(await (vi.mocked(onBeforeRouteLeave).mock.calls[0]![0] as () => Promise<boolean>)()).toBe(true)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('renders MULTIPLE as multiple appointments and opens the exact source/customer instead of choosing an arbitrary appointment', async () => {
|
it('renders MULTIPLE as multiple appointments and opens the exact source/customer instead of choosing an arbitrary appointment', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
import InboundOrdersView from './InboundOrdersView.vue'
|
||||||
|
import { getFbaAppointment, listInboundOrders, updateFbaAppointment, type FbaAppointmentDto } from '../api/inboundOrders'
|
||||||
|
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||||
|
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||||
|
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
|
||||||
|
vi.mock('../api/inboundOrders', async original => ({ ...await original<typeof import('../api/inboundOrders')>(), listInboundOrders: vi.fn(), getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn() }))
|
||||||
|
const appointment: FbaAppointmentDto = { id: '1', appointmentNo: 'YY-TEST', version: 2, originWarehouseId: 'warehouse-1', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ONT8', timezone: 'America/Los_Angeles', items: [{ id: 'item-1', customerId: 'customer-1', sourceKey: 'BOOKING:BK-TEST', orderId: '9007199254740993', inboundCode: 'IN-TEST', fbaWarehouseCode: 'ONT8', packageCount: 20 }] }
|
||||||
|
function render() {
|
||||||
|
return mount(InboundOrdersView, { global: { stubs: {
|
||||||
|
AdminPage: { template: '<main><slot /></main>' }, ListPageLayout: { template: '<div><slot /></div>' },
|
||||||
|
DataTable: { props: ['data'], template: '<div><slot name="actions" v-if="data.length" :row="data[0]" /></div>' },
|
||||||
|
AppDialog: { props: ['modelValue', 'showConfirm', 'confirmText'], template: '<section v-if="modelValue"><slot /><button v-if="showConfirm" @click="$emit(\'confirm\')">{{ confirmText }}</button></section>' },
|
||||||
|
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'sources', 'disabled'], template: '<div />' },
|
||||||
|
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
|
||||||
|
SmartDispatchDialog: true, QueryToolbar: true, OperationErrorToast: true, LoadErrorState: true, ApiErrorMeta: true,
|
||||||
|
} } })
|
||||||
|
}
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(listInboundOrders).mockResolvedValue({ records: [{ lineId: 'line-1', planId: 'plan-1', planNo: 'PLAN-TEST', customerId: 'customer-1', orderId: '9007199254740993', overseasWarehouseId: 'warehouse-1', fbaWarehouseCode: 'ONT8', packageCount: 20, status: 'COMPLETED', appointmentNo: appointment.appointmentNo, appointmentStatus: 'PENDING' }], total: 1, page: 1, size: 10 })
|
||||||
|
vi.mocked(getFbaAppointment).mockResolvedValue(appointment)
|
||||||
|
vi.mocked(updateFbaAppointment).mockResolvedValue({ ...appointment, version: 3, appointmentStatus: 'SUCCESS' })
|
||||||
|
})
|
||||||
|
it('uses all canonical appointment items and blocks formal submission until cargo is verified', async () => {
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '预约')!.trigger('click'); await flushPromises()
|
||||||
|
const fields = wrapper.getComponent({ name: 'FbaAppointmentFields' })
|
||||||
|
expect(fields.props('sources')).toEqual([{ sourceKey: 'BOOKING:BK-TEST', customerId: 'customer-1', destinationKey: 'FBA:ONT8' }])
|
||||||
|
Object.assign(fields.props('model'), { appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-TEST', appointmentAt: '2026-10-21T10:00:00' })
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||||
|
expect(updateFbaAppointment).not.toHaveBeenCalled()
|
||||||
|
fields.vm.$emit('confirmationReady', true); await flushPromises()
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||||
|
expect(updateFbaAppointment).toHaveBeenCalledWith('YY-TEST', expect.objectContaining({ version: 2, details: expect.objectContaining({ appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-TEST' }) }))
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('keeps cargo supplementation disabled when viewing an appointment', async () => {
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '查看预约')!.trigger('click'); await flushPromises()
|
||||||
|
expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('disabled')).toBe(true)
|
||||||
|
expect(wrapper.findAll('button').some(button => button.text() === '保存预约安排')).toBe(false)
|
||||||
|
expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
|
||||||
|
})
|
||||||
@@ -17,6 +17,7 @@ import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
|||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
|
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
|
||||||
import FbaAppointmentFields from '../components/FbaAppointmentFields.vue'
|
import FbaAppointmentFields from '../components/FbaAppointmentFields.vue'
|
||||||
|
import type { PlanningSourceLookup } from '@/modules/appointment-calendar'
|
||||||
import { createAppointmentForm, appointmentPayload, appointmentFormError } from '../appointmentForm'
|
import { createAppointmentForm, appointmentPayload, appointmentFormError } from '../appointmentForm'
|
||||||
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type InboundOrderSummaryDto } from '../api/inboundOrders'
|
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type InboundOrderSummaryDto } from '../api/inboundOrders'
|
||||||
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||||
@@ -59,6 +60,9 @@ const appointmentFbaWarehouse = ref('—')
|
|||||||
const appointmentVersion = ref(0)
|
const appointmentVersion = ref(0)
|
||||||
const appointmentForm = reactive(createAppointmentForm())
|
const appointmentForm = reactive(createAppointmentForm())
|
||||||
const appointmentTimezone = ref<string | null>(null)
|
const appointmentTimezone = ref<string | null>(null)
|
||||||
|
const appointmentSources = ref<PlanningSourceLookup[]>([])
|
||||||
|
const confirmationReady = ref(false)
|
||||||
|
const appointmentFields = ref<InstanceType<typeof FbaAppointmentFields> | 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 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 appointmentViewOnly = ref(false)
|
||||||
const appointmentReplacementNos = ref<string[]>([])
|
const appointmentReplacementNos = ref<string[]>([])
|
||||||
@@ -132,13 +136,15 @@ function selectionError() {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAppointment() {
|
async function openAppointment() {
|
||||||
if (!access.can(permission.inboundOrderAppointment) || !canLeaveAppointment()) return
|
if (!access.can(permission.inboundOrderAppointment) || !await canLeaveAppointment()) return
|
||||||
appointmentViewOnly.value = false
|
appointmentViewOnly.value = false
|
||||||
appointmentReplacementNos.value = []
|
appointmentReplacementNos.value = []
|
||||||
const error = selectionError()
|
const error = selectionError()
|
||||||
if (error) { ElMessage.warning(error); return }
|
if (error) { ElMessage.warning(error); return }
|
||||||
appointmentMode.value = 'create'; editingAppointmentNo.value = ''
|
appointmentMode.value = 'create'; editingAppointmentNo.value = ''
|
||||||
|
confirmationReady.value = false
|
||||||
|
appointmentSources.value = selectedRows.value.map(row => ({ orderId: row.orderId, customerId: row.customerId, destinationKey: `FBA:${row.fbaWarehouse}` }))
|
||||||
appointmentSelectedCount.value = selectedRows.value.length; appointmentFbaWarehouse.value = selectedRows.value[0]?.fbaWarehouse || '—'
|
appointmentSelectedCount.value = selectedRows.value.length; appointmentFbaWarehouse.value = selectedRows.value[0]?.fbaWarehouse || '—'
|
||||||
appointmentVersion.value = 0
|
appointmentVersion.value = 0
|
||||||
Object.assign(appointmentForm, createAppointmentForm()); appointmentTimezone.value = null; pendingAppointment.value = null
|
Object.assign(appointmentForm, createAppointmentForm()); appointmentTimezone.value = null; pendingAppointment.value = null
|
||||||
@@ -152,7 +158,7 @@ function openSmartDispatch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openAppointmentEdit(source: object, readOnly = false) {
|
async function openAppointmentEdit(source: object, readOnly = false) {
|
||||||
if (!canLeaveAppointment()) return
|
if (!await canLeaveAppointment()) return
|
||||||
const row = source as InboundOrderListItem
|
const row = source as InboundOrderListItem
|
||||||
if (row.appointmentStatus === '多批预约') { openMultipleAppointments(row); return }
|
if (row.appointmentStatus === '多批预约') { openMultipleAppointments(row); return }
|
||||||
if (!row.appointmentNo || row.appointmentNo === '—') return
|
if (!row.appointmentNo || row.appointmentNo === '—') return
|
||||||
@@ -166,24 +172,27 @@ async function openAppointmentEdit(source: object, readOnly = false) {
|
|||||||
appointmentSelectedCount.value = detail.selectedCount
|
appointmentSelectedCount.value = detail.selectedCount
|
||||||
appointmentFbaWarehouse.value = detail.fbaWarehouseCode || row.fbaWarehouse
|
appointmentFbaWarehouse.value = detail.fbaWarehouseCode || row.fbaWarehouse
|
||||||
appointmentVersion.value = detail.version
|
appointmentVersion.value = detail.version
|
||||||
|
confirmationReady.value = false
|
||||||
|
appointmentSources.value = detail.items.map(item => ({ sourceKey: item.sourceKey, customerId: item.customerId, destinationKey: item.destinationKey || `FBA:${item.fbaWarehouseCode}` }))
|
||||||
Object.assign(appointmentForm, createAppointmentForm(detail)); appointmentTimezone.value = detail.timezone || null; pendingAppointment.value = null
|
Object.assign(appointmentForm, createAppointmentForm(detail)); appointmentTimezone.value = detail.timezone || null; pendingAppointment.value = null
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
appointmentOpen.value = false
|
appointmentOpen.value = false
|
||||||
mutationError.value = getApiErrorDetails(caught, 'FBA 预约详情加载失败,请重试')
|
mutationError.value = getApiErrorDetails(caught, 'FBA 预约详情加载失败,请重试')
|
||||||
} finally { appointmentLoading.value = false }
|
} finally { appointmentLoading.value = false }
|
||||||
}
|
}
|
||||||
function openMultipleAppointments(row: InboundOrderListItem) {
|
async function openMultipleAppointments(row: InboundOrderListItem) {
|
||||||
if (!canLeaveAppointment() || !access.can('platform:appointment:list')) return
|
if (!await canLeaveAppointment() || !access.can('platform:appointment:list')) return
|
||||||
if (!row.orderId || !row.customerId || !row.fbaWarehouse || row.fbaWarehouse === '—') { ElMessage.warning('原货源或客户范围缺失,请重新读取入库单'); 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}` } })
|
void router.push({ path: '/appointment-calendar', query: { orderId: row.orderId, customerId: row.customerId, destinationKey: `FBA:${row.fbaWarehouse}` } })
|
||||||
}
|
}
|
||||||
|
|
||||||
function canLeaveAppointment() {
|
async function canLeaveAppointment() {
|
||||||
|
if (!appointmentOpen.value) return true
|
||||||
if (saving.value || pendingAppointment.value) { ElMessage.warning('请先核实本次预约保存结果,再关闭或切换'); return false }
|
if (saving.value || pendingAppointment.value) { ElMessage.warning('请先核实本次预约保存结果,再关闭或切换'); return false }
|
||||||
return true
|
return await appointmentFields.value?.canLeave?.() ?? true
|
||||||
}
|
}
|
||||||
function closeAppointment() { if (canLeaveAppointment()) appointmentOpen.value = false }
|
async function closeAppointment() { if (await canLeaveAppointment()) appointmentOpen.value = false }
|
||||||
function beforeCloseAppointment(done: () => void) { if (canLeaveAppointment()) done() }
|
async function beforeCloseAppointment(done: () => void) { if (await canLeaveAppointment()) { appointmentOpen.value = false; done() } }
|
||||||
onBeforeRouteLeave(canLeaveAppointment)
|
onBeforeRouteLeave(canLeaveAppointment)
|
||||||
onBeforeRouteUpdate(canLeaveAppointment)
|
onBeforeRouteUpdate(canLeaveAppointment)
|
||||||
async function submitAppointment() {
|
async function submitAppointment() {
|
||||||
@@ -191,6 +200,9 @@ async function submitAppointment() {
|
|||||||
if (appointmentMode.value === 'create') { const error = selectionError(); if (error) { ElMessage.warning(error); return } }
|
if (appointmentMode.value === 'create') { const error = selectionError(); if (error) { ElMessage.warning(error); return } }
|
||||||
const validation = appointmentFormError(appointmentForm)
|
const validation = appointmentFormError(appointmentForm)
|
||||||
if (!pendingAppointment.value && validation) { ElMessage.warning(validation); return }
|
if (!pendingAppointment.value && validation) { ElMessage.warning(validation); return }
|
||||||
|
if (!pendingAppointment.value && appointmentForm.appointmentStatus === 'SUCCESS' && !confirmationReady.value) { ElMessage.warning('请先在“正式确认前”区域补齐并保存每票货物资料,再保存预约安排'); return }
|
||||||
|
if (!pendingAppointment.value && appointmentFields.value?.canLeave && !await appointmentFields.value.canLeave()) return
|
||||||
|
if (saving.value || appointmentReadOnly.value) return
|
||||||
saving.value = true; mutationError.value = null
|
saving.value = true; mutationError.value = null
|
||||||
try {
|
try {
|
||||||
pendingAppointment.value ||= {appointmentNo: appointmentMode.value === 'edit' ? editingAppointmentNo.value : null, version: appointmentVersion.value, originWarehouseId: selectedRows.value[0]?.originWarehouseId || '', fbaWarehouseCode: selectedRows.value[0]?.fbaWarehouse || '', lineIds: selectedRows.value.map(row => row.lineId), details: appointmentPayload(appointmentForm), idempotencyKey: crypto.randomUUID()}
|
pendingAppointment.value ||= {appointmentNo: appointmentMode.value === 'edit' ? editingAppointmentNo.value : null, version: appointmentVersion.value, originWarehouseId: selectedRows.value[0]?.originWarehouseId || '', fbaWarehouseCode: selectedRows.value[0]?.fbaWarehouse || '', lineIds: selectedRows.value.map(row => row.lineId), details: appointmentPayload(appointmentForm), idempotencyKey: crypto.randomUUID()}
|
||||||
@@ -247,7 +259,7 @@ onMounted(() => { void load() })
|
|||||||
<section class="density-dialog-section" data-tone="summary" aria-label="预约范围核对"><h3 class="density-dialog-section__title">预约范围</h3><table class="density-table"><tbody><tr><th scope="row">已选择</th><td>{{ appointmentSelectedCount }} 条</td><th scope="row">FBA仓库</th><td>{{ appointmentFbaWarehouse }}</td></tr></tbody></table></section>
|
<section class="density-dialog-section" data-tone="summary" aria-label="预约范围核对"><h3 class="density-dialog-section__title">预约范围</h3><table class="density-table"><tbody><tr><th scope="row">已选择</th><td>{{ appointmentSelectedCount }} 条</td><th scope="row">FBA仓库</th><td>{{ appointmentFbaWarehouse }}</td></tr></tbody></table></section>
|
||||||
<el-alert v-if="appointmentReplacementNos.length" :title="`原批次已被替代,当前只读。替代预约:${appointmentReplacementNos.join('、')}`" type="warning" :closable="false" show-icon />
|
<el-alert v-if="appointmentReplacementNos.length" :title="`原批次已被替代,当前只读。替代预约:${appointmentReplacementNos.join('、')}`" type="warning" :closable="false" show-icon />
|
||||||
<LoadErrorState v-if="mutationError" compact title="预约保存失败" :message="mutationError.message" :loading="saving" :show-retry="!appointmentReadOnly" :retry-text="pendingAppointment ? '重试本次保存' : '重新提交'" @retry="submitAppointment"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
<LoadErrorState v-if="mutationError" compact title="预约保存失败" :message="mutationError.message" :loading="saving" :show-retry="!appointmentReadOnly" :retry-text="pendingAppointment ? '重试本次保存' : '重新提交'" @retry="submitAppointment"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
||||||
<FbaAppointmentFields continuous :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="appointmentReadOnly || appointmentLoading || saving || !!pendingAppointment" />
|
<FbaAppointmentFields continuous v-if="appointmentOpen && !appointmentLoading" ref="appointmentFields" :sources="appointmentSources" @confirmation-ready="confirmationReady = $event" :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="appointmentReadOnly || appointmentLoading || saving || !!pendingAppointment" />
|
||||||
</div>
|
</div>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
|
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
|
||||||
|
|||||||
@@ -1189,6 +1189,7 @@ describe('approved Admin density ownership', () => {
|
|||||||
})
|
})
|
||||||
expect(actionWeights.get('.row-actions.admin-text-actions .el-button.el-button')).toBe('400')
|
expect(actionWeights.get('.row-actions.admin-text-actions .el-button.el-button')).toBe('400')
|
||||||
expect(actionWeights.get('.admin-text-actions.el-popper .el-dropdown-menu__item')).toBe('400')
|
expect(actionWeights.get('.admin-text-actions.el-popper .el-dropdown-menu__item')).toBe('400')
|
||||||
|
expect(read('modules/overseas-master-data/pages/OwnedBusinessView.vue')).toContain('class="row-actions density-actions admin-text-actions"')
|
||||||
const dispatches = read('modules/overseas-warehouse/pages/DispatchesView.vue')
|
const dispatches = read('modules/overseas-warehouse/pages/DispatchesView.vue')
|
||||||
expect(dispatches).toContain('appearance="integrated"')
|
expect(dispatches).toContain('appearance="integrated"')
|
||||||
expect(dispatches).toContain('<template #filters><div class="integrated-query-group">')
|
expect(dispatches).toContain('<template #filters><div class="integrated-query-group">')
|
||||||
|
|||||||
Reference in New Issue
Block a user