diff --git a/src/modules/appointment-calendar/AppointmentConfirmationCargo.test.ts b/src/modules/appointment-calendar/AppointmentConfirmationCargo.test.ts new file mode 100644 index 00000000..931cac25 --- /dev/null +++ b/src/modules/appointment-calendar/AppointmentConfirmationCargo.test.ts @@ -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: '' }, + InlineHelp: { template: '' }, ApiErrorMeta: true, + LoadErrorState: { props: ['message'], template: '
{{ message }}
' }, + } } }) +} +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() +}) diff --git a/src/modules/appointment-calendar/AppointmentConfirmationCargo.vue b/src/modules/appointment-calendar/AppointmentConfirmationCargo.vue new file mode 100644 index 00000000..1e45c0db --- /dev/null +++ b/src/modules/appointment-calendar/AppointmentConfirmationCargo.vue @@ -0,0 +1,85 @@ + + diff --git a/src/modules/appointment-calendar/AppointmentCreatePanel.vue b/src/modules/appointment-calendar/AppointmentCreatePanel.vue index b08d5eed..8da2ea1d 100644 --- a/src/modules/appointment-calendar/AppointmentCreatePanel.vue +++ b/src/modules/appointment-calendar/AppointmentCreatePanel.vue @@ -42,13 +42,13 @@ const columns: DataTableColumn[] = [ { prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' }, { prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 }, { prop: 'packages', label: '箱数', width: 80 }, - { key: 'eligibility', label: '预约资格', minWidth: 130, slotName: 'eligibility' }, + { key: 'eligibility', label: '预约资格', minWidth: 260, singleLine: false, slotName: 'eligibility' }, ] function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) } function reason(row: PlanningCargo) { if (row.detained) return '货物暂扣中' if (row.mixedDestinations) return '多个目的地,需先核对' - if (row.hasActiveAppointment) return '已有有效预约' + if (row.hasActiveAppointment) return '已有预约安排,请到入库单编辑原预约' const anchor = selectedRows.value[0] if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓' if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票' diff --git a/src/modules/appointment-calendar/CargoVerificationForm.vue b/src/modules/appointment-calendar/CargoVerificationForm.vue index 4c9f587d..5b02ec3d 100644 --- a/src/modules/appointment-calendar/CargoVerificationForm.vue +++ b/src/modules/appointment-calendar/CargoVerificationForm.vue @@ -10,15 +10,16 @@ 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 props = defineProps<{ source: CargoVerificationSource; confirmation?: boolean; disabled?: boolean }>() const emit = defineEmits<{ saved: [] }>() const access = useAccessPreviewStore() const context = ref(null), loading = ref(false), saving = ref(false) const error = ref(null), saveError = ref(null), pending = ref(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')) +const canLeave = useWorkDraft(dirty, computed(() => saving.value || !!pending.value)) +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) { 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 } @@ -26,7 +27,7 @@ async function load(preserve = false) { 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 || '' }) + 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) } } 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 && (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 (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 } 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())