Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
4 changed files with 58 additions and 9 deletions
Showing only changes of commit 87fc08b06e - Show all commits
@@ -6,6 +6,7 @@ import { listPlatformTenants } from '@/modules/iam'
import { listPlatformPartners } from '@/modules/master-data'
import { listPlatformWarehouses } from '@/modules/warehouses'
import {
createOceanBooking,
getOceanBooking,
listOceanBookingAgents,
listWarehouseLogisticsChannels,
@@ -16,8 +17,9 @@ import type { OceanBookingDetail, OceanBookingStatus } from '../types'
import OceanBookingDetailView from './OceanBookingDetailView.vue'
const routerReplace = vi.hoisted(() => vi.fn())
const routeState = vi.hoisted(() => ({ name: 'ocean-booking-detail', params: { bookingId: '123' } }))
vi.mock('vue-router', () => ({
useRoute: () => ({ name: 'ocean-booking-detail', params: { bookingId: '123' } }),
useRoute: () => routeState,
useRouter: () => ({ push: vi.fn(), replace: routerReplace }),
}))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
@@ -61,7 +63,10 @@ function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLading
}
type ViewModel = {
form: { data: { billOfLadingNo: string } }
form: { data: import('../types').OceanBookingDraftData }
save: () => Promise<void>
selectCustomer: (value: unknown) => void
canManageDocuments: boolean
detail: OceanBookingDetail | null
actionError: { code?: string; requestId?: string; traceId?: string; message: string } | null
actionSaving: boolean
@@ -86,6 +91,7 @@ async function mountView() {
describe('OceanBookingDetailView one-click release', () => {
beforeEach(() => {
routeState.name = 'ocean-booking-detail'; routeState.params.bookingId = '123'
vi.clearAllMocks()
vi.mocked(getOceanBooking).mockResolvedValue(booking())
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
@@ -110,6 +116,49 @@ describe('OceanBookingDetailView one-click release', () => {
expect(vm.detail?.version).toBe(5)
})
it('saves a new booking without a customer using explicit nulls', async () => {
routeState.name = 'ocean-booking-create'; routeState.params.bookingId = 'new'
vi.mocked(createOceanBooking).mockResolvedValue(booking())
const { vm, wrapper } = await mountView()
await vm.save()
expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ customerId: null, customerName: null }) }))
wrapper.unmount()
})
it('clears an existing customer before saving and releasing with the returned version', async () => {
const saved = booking('DRAFT', 4)
saved.customerId = null; saved.customerName = null; saved.oceanBooking.customerId = null; saved.oceanBooking.customerName = null
vi.mocked(updateOceanBookingDraft).mockResolvedValue(saved)
vi.mocked(releaseOceanBooking).mockResolvedValue({ ...saved, status: 'RELEASED', version: 5 })
const { vm, wrapper } = await mountView()
vm.selectCustomer('')
await vm.submitRelease()
expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ customerId: null, customerName: null }) }))
expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 4 })
wrapper.unmount()
})
it('allows documents and release for a saved booking with no single customer', async () => {
const empty = booking()
empty.customerId = null; empty.customerName = null; empty.oceanBooking.customerId = null; empty.oceanBooking.customerName = null
vi.mocked(getOceanBooking).mockResolvedValue(empty)
vi.mocked(releaseOceanBooking).mockResolvedValue({ ...empty, status: 'RELEASED', version: 4 })
const { vm, wrapper } = await mountView()
expect(vm.canManageDocuments).toBe(true)
await vm.submitRelease()
expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 3 })
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
wrapper.unmount()
})
it('preserves a selected single customer when saving', async () => {
vi.mocked(updateOceanBookingDraft).mockResolvedValue(booking('DRAFT', 4))
const { vm, wrapper } = await mountView()
await vm.save()
expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ customerId: '456', customerName: '测试客户' }) }))
wrapper.unmount()
})
it('does not call release when automatic draft saving fails', async () => {
vi.mocked(updateOceanBookingDraft).mockRejectedValue(new ApiResponseError('保存草稿失败', 'A0409', 409, undefined, 'request-save', 'trace-save'))
const { vm } = await mountView()
@@ -57,7 +57,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).toContain('放舱前请填写起运港代码或名称')
expect(source).toContain('放舱前请填写目的港代码或名称')
expect(source).toContain("[form.data.placeOfDelivery, '交货地点']")
expect(source).toContain("[form.data.customerId, '客户']")
expect(source).not.toContain("[form.data.customerId, '客户']")
expect(source).toContain("if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码'")
expect(source).not.toContain('请先保存草稿后再放舱')
expect(source).toContain('放舱前请填写${missing[1]}')
@@ -88,7 +88,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
it('keeps warehouse-scoped channel and server-filtered booking-agent selectors accessible', () => {
expect(source).toContain("warehouseType: 'DOMESTIC', status: 'ACTIVE'")
expect(source).toContain('listWarehouseLogisticsChannels')
expect(source).toContain('placeholder="草稿可暂不选择"')
expect(source).toContain('placeholder="单客户可选择,多客户可留空"')
expect(source).toContain('当前船公司暂无可用订舱代理')
expect(source).toContain(':label="supplier.name"')
expect(source).toContain('filterable clearable')
@@ -108,8 +108,8 @@ const formWritable = computed(() => canSave.value)
const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete'))
const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release'))
const canPrint = computed(() => detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:list'))
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && Boolean(detail.value.customerId) && access.can('platform:firstleg:ocean-booking:edit'))
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : !detail.value?.customerId ? '请先选择客户并保存订舱草稿,再上传订舱资料' : '')
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:edit'))
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : '')
const workflowBusy = computed(() => saving.value || actionSaving.value)
const releaseDialogOpen = ref(false)
const printOpen = ref(false)
@@ -303,7 +303,7 @@ function releaseValidationError() {
if (!form.data.departurePortCode?.trim() && !form.data.departurePortName?.trim()) return '放舱前请填写起运港代码或名称'
if (!form.data.destinationPortCode?.trim() && !form.data.destinationPortName?.trim()) return '放舱前请填写目的港代码或名称'
const requiredFields: Array<[unknown, string]> = [
[form.data.customerId, '客户'], [form.data.warehouseId, '装柜仓库'], [form.data.channelCode, '物流渠道'],
[form.data.warehouseId, '装柜仓库'], [form.data.channelCode, '物流渠道'],
[form.data.billOfLadingNo, '提单号'], [form.data.vesselName, '船名'], [form.data.voyageNo, '航次'],
[form.data.estimatedSailingAt, '预计开船时间'], [form.data.estimatedArrivalAt, '预计到港时间'],
[form.data.serviceTerm, 'Service Term'], [form.data.freightTerm, 'Freight Term'],
@@ -396,7 +396,7 @@ onMounted(() => { void load(); void loadOptions() })
<section class="booking-group" aria-label="基本信息">
<h3>基本信息</h3><div class="booking-group__body booking-group__body--fields">
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
<el-form-item label="客户(放舱前必填)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="草稿可暂不选择" no-data-text="暂无可选客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item label="客户(选填)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="单客户可选择,多客户可留空" no-data-text="暂无可选客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '保存后为草稿'" disabled /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channels.some((channel) => channel.channelCode === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode"><span>{{ channel.channelName }}</span><small class="channel-code">{{ channel.channelCode }}</small></el-option></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
@@ -41,7 +41,7 @@ const fields = computed<QueryField[]>(() => [
const columns = [
{ key: 'actions', label: '操作', fixed: 'left', width: 196, slotName: 'actions', hideable: false, movable: false },
{ prop: 'bookingNo', label: '订舱号', minWidth: 150 },
{ prop: 'customerName', label: '客户', minWidth: 160 },
{ prop: 'customerName', label: '客户', minWidth: 160, formatter: (row: OceanBookingResource) => row.customerName || '—' },
{ prop: 'billOfLadingNo', label: '提单号', minWidth: 155, formatter: (row: OceanBookingResource) => row.billOfLadingNo || '—' },
{ prop: 'shipperName', label: '提单发货人', minWidth: 160, formatter: (row: OceanBookingResource) => row.shipperName || '—' },
{ prop: 'consigneeName', label: '提单收货人', minWidth: 160, formatter: (row: OceanBookingResource) => row.consigneeName || '—' },