feat(phase4): automate admin integration acceptance
This commit is contained in:
@@ -55,6 +55,19 @@ describe('overseas master data real contract pages', () => {
|
||||
expect(view).toContain('access.can(permission.')
|
||||
expect(view).toContain('ElMessage.success')
|
||||
}
|
||||
expect(source('OverseasQuotesView')).toContain('导出CSV')
|
||||
expect(source('VehiclesView')).toContain('导出CSV')
|
||||
expect(source('ExchangeRatesView')).toContain("'monthly-exchange-rates.csv'")
|
||||
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
|
||||
expect(source(name)).not.toMatch(/deleteQuote|deleteExchangeRate|deleteVehicle/)
|
||||
}
|
||||
expect(source('OverseasQuotesView')).toContain('changeQuoteStatus(row.id,next,row.recordVersion)')
|
||||
expect(source('ExchangeRatesView')).toContain('changeExchangeRateStatus(row.month, next, row.recordVersion, null)')
|
||||
expect(source('VehiclesView')).toContain('changeVehicleStatus(row.id,statusTarget.value,row.recordVersion)')
|
||||
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
|
||||
expect(source(name)).toContain("masterDataErrorMessage(")
|
||||
expect(source(name)).toContain('saveCsv(await export')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses an independent API-backed vehicle detail page without inventing fulfillment history', () => {
|
||||
@@ -81,5 +94,13 @@ describe('overseas master data real contract pages', () => {
|
||||
expect(gaps).toContain('/overseas-quotes/warehouse-options')
|
||||
expect(gaps).toContain('浏览器不调用国内')
|
||||
expect(gaps).toContain('没有删除端点')
|
||||
expect(gaps).toContain('JSON 错误 Blob')
|
||||
expect(gaps).not.toContain('尚无已确认')
|
||||
expect(matrixSource()).not.toContain('写入口禁用')
|
||||
expect(matrixSource()).not.toContain('等待查询接口')
|
||||
})
|
||||
})
|
||||
|
||||
function matrixSource() {
|
||||
return readFileSync(resolve(process.cwd(), 'src/modules/overseas-master-data/PROTOTYPE_MATRIX.md'), 'utf8')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as api from '../api/overseasMasterData'
|
||||
import ExchangeRatesView from './ExchangeRatesView.vue'
|
||||
import OverseasQuotesView from './OverseasQuotesView.vue'
|
||||
import VehiclesView from './VehiclesView.vue'
|
||||
|
||||
vi.mock('element-plus', () => ({
|
||||
ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() },
|
||||
ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) },
|
||||
}))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('../api/overseasMasterData', () => ({
|
||||
listQuotes: vi.fn(), listQuoteWarehouseOptions: vi.fn(), getQuote: vi.fn(), createQuote: vi.fn(), updateQuote: vi.fn(), changeQuoteStatus: vi.fn(), exportQuotes: vi.fn(),
|
||||
listExchangeRates: vi.fn(), getExchangeRateSummary: vi.fn(), getExchangeRate: vi.fn(), createExchangeRate: vi.fn(), updateExchangeRate: vi.fn(), changeExchangeRateStatus: vi.fn(), exportExchangeRates: vi.fn(),
|
||||
listVehicles: vi.fn(), getVehicle: vi.fn(), createVehicle: vi.fn(), updateVehicle: vi.fn(), changeVehicleStatus: vi.fn(), exportVehicles: vi.fn(),
|
||||
}))
|
||||
|
||||
const longId = '9223372036854775807'
|
||||
const quote = { id: longId, name: '真实报价', warehouseId: '9223372036854775806', warehouseName: '合作仓', warehouseCode: 'US-LAX', countryCode: 'US', providerName: 'Provider', currency: 'USD' as const, effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1', status: 'INACTIVE' as const, updatedAt: '2026-08-24T10:00:00', updatedBy: 'admin', recordVersion: 7, remark: null }
|
||||
const quoteDetail = { summary: quote, warehouseFees: [{ item: '入库', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: null, remark: null }], transitPrices: [], createdBy: 'admin', createdAt: '2026-08-24T09:00:00' }
|
||||
const rate = { month: '2026-08', baseCurrency: 'USD', quoteCurrency: 'CNY', usdToCnyAverage: 7.12345678, status: 'ACTIVE' as const, recordVersion: 3, updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null }
|
||||
const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, status: 'IN_SERVICE' as const, remark: null, recordVersion: 4, updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }
|
||||
const emptyPage = { records: [], page: 1, size: 20, total: 0, pages: 0 }
|
||||
|
||||
const slot = { template: '<div><slot/><slot name="header"/><slot name="toolbar"/><slot name="metrics"/><slot name="state"/></div>' }
|
||||
const stubs = {
|
||||
AdminPage: slot,
|
||||
ListPageLayout: slot,
|
||||
QueryToolbar: true,
|
||||
LoadErrorState: true,
|
||||
DataTable: {
|
||||
props: ['data'],
|
||||
template: '<div><slot name="toolbar-actions"/><slot v-if="data && data[0]" name="actions" :row="data[0]"/></div>',
|
||||
},
|
||||
AppDialog: {
|
||||
props: ['modelValue'], emits: ['confirm', 'update:modelValue'],
|
||||
template: '<div v-if="modelValue" data-test="dialog"><slot/><button data-test="dialog-confirm" @click="$emit(\'confirm\')">confirm</button></div>',
|
||||
},
|
||||
ElButton: { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot/></button>' },
|
||||
ElForm: slot, ElFormItem: slot, ElInput: true, ElSelect: slot, ElOption: true, ElDatePicker: true, ElInputNumber: true,
|
||||
ElTable: slot, ElTableColumn: true, ElTag: slot,
|
||||
}
|
||||
const mountView = (component: object) => shallowMount(component, { global: { stubs } })
|
||||
const button = (wrapper: ReturnType<typeof mountView>, label: string) => {
|
||||
const found = wrapper.findAll('button').find((item) => item.text().includes(label))
|
||||
expect(found, `button ${label}`).toBeDefined()
|
||||
return found!
|
||||
}
|
||||
|
||||
describe('overseas master data failed writes preserve editor context', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.listQuotes).mockResolvedValue({ ...emptyPage, records: [quote], total: 1, pages: 1 })
|
||||
vi.mocked(api.listQuoteWarehouseOptions).mockResolvedValue([{ warehouseId: quote.warehouseId, warehouseCode: 'US-LAX', warehouseName: '合作仓', countryCode: 'US' }])
|
||||
vi.mocked(api.getQuote).mockResolvedValue(quoteDetail)
|
||||
vi.mocked(api.listExchangeRates).mockResolvedValue({ ...emptyPage, records: [rate], total: 1, pages: 1 })
|
||||
vi.mocked(api.getExchangeRateSummary).mockResolvedValue({ latestMonth: rate.month, latestUsdToCnyAverage: rate.usdToCnyAverage, updatedBy: 'admin', updatedAt: rate.updatedAt })
|
||||
vi.mocked(api.getExchangeRate).mockResolvedValue(rate)
|
||||
vi.mocked(api.listVehicles).mockResolvedValue({ ...emptyPage, records: [vehicle], total: 1, pages: 1 })
|
||||
vi.mocked(api.getVehicle).mockResolvedValue(vehicle)
|
||||
})
|
||||
|
||||
it('keeps quote edit open, sends only update fields, and shows A0409 request ID', async () => {
|
||||
vi.mocked(api.updateQuote).mockRejectedValue(new ApiResponseError('记录版本已变化', 'A0409', 200, null, 'req-quote-409'))
|
||||
const wrapper = mountView(OverseasQuotesView)
|
||||
await flushPromises()
|
||||
await button(wrapper, '编辑').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-test="dialog-confirm"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.updateQuote).toHaveBeenCalledWith(longId, expect.objectContaining({ recordVersion: 7, name: quote.name }))
|
||||
const payload = vi.mocked(api.updateQuote).mock.calls[0]?.[1]
|
||||
expect(payload).not.toHaveProperty('warehouseId')
|
||||
expect(payload).not.toHaveProperty('versionCode')
|
||||
expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true)
|
||||
expect(ElMessage.error).toHaveBeenCalledWith(expect.stringContaining('req-quote-409'))
|
||||
})
|
||||
|
||||
it('keeps exchange-rate decimal/version in the open editor after A0409', async () => {
|
||||
vi.mocked(api.updateExchangeRate).mockRejectedValue(new ApiResponseError('汇率版本已变化', 'A0409', 200, null, 'req-rate-409'))
|
||||
const wrapper = mountView(ExchangeRatesView)
|
||||
await flushPromises()
|
||||
await button(wrapper, '编辑').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-test="dialog-confirm"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.updateExchangeRate).toHaveBeenCalledWith('2026-08', { recordVersion: 3, usdToCnyAverage: 7.12345678, remark: null })
|
||||
expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true)
|
||||
expect(ElMessage.error).toHaveBeenCalledWith(expect.stringContaining('req-rate-409'))
|
||||
})
|
||||
|
||||
it('keeps vehicle long ID, decimal and recordVersion in the open editor after A0409', async () => {
|
||||
vi.mocked(api.updateVehicle).mockRejectedValue(new ApiResponseError('车辆版本已变化', 'A0409', 200, null, 'req-vehicle-409'))
|
||||
const wrapper = mountView(VehiclesView)
|
||||
await flushPromises()
|
||||
await button(wrapper, '编辑').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-test="dialog-confirm"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.updateVehicle).toHaveBeenCalledWith(longId, expect.objectContaining({ recordVersion: 4, fuelConsumptionPer100Km: 12.5, mileageKm: 101 }))
|
||||
expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true)
|
||||
expect(ElMessage.error).toHaveBeenCalledWith(expect.stringContaining('req-vehicle-409'))
|
||||
})
|
||||
})
|
||||
@@ -56,7 +56,7 @@ watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions(
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外仓报价筛选" preference-key="overseas-quotes-query" :loading="loading" @search="search" /></template>
|
||||
<template #state><LoadErrorState v-if="error" title="海外仓报价加载失败" :message="error.message" @retry="load" /></template>
|
||||
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-quotes-table" row-key="id" empty-text="暂无海外仓报价">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出Excel</el-button></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template>
|
||||
<template #actions="{row}"><el-button link type="primary" @click="openDetail(row as OverseasWarehouseQuote)"><Eye :size="15"/>详情</el-button><el-button v-if="access.can(permission.quoteEdit)" link type="primary" @click="openEdit(row as OverseasWarehouseQuote)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.quoteStatus)" link :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'danger':'success'" @click="toggle(row as OverseasWarehouseQuote)"><Power :size="15"/>{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'停用':'启用'}}</el-button></template>
|
||||
<template #status="{row}"><el-tag :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'success':'info'">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'启用':'停用'}}</el-tag></template>
|
||||
</DataTable></section>
|
||||
|
||||
@@ -22,6 +22,6 @@ function openStatus(row:VehicleDetail){statusRow.value=row;statusTarget.value=ro
|
||||
async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();saveCsv(await exportVehicles(q),'vehicles.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}}
|
||||
watch([page,pageSize],load);onMounted(load)
|
||||
</script>
|
||||
<template><AdminPage><ListPageLayout aria-label="车辆列表"><template #toolbar><div class="query-panel"><div class="status-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{'is-active':ownership===tab[1]}]" @click="selectOwnership(tab[1])">{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template><template #state><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template><section class="surface-panel vehicle-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆"><template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出Excel</el-button></template><template #actions="{row}"><el-button link type="primary" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})"><Eye :size="15"/>查看</el-button><el-button v-if="access.can(permission.vehicleEdit)" link type="primary" @click="openEdit(row as VehicleDetail)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.vehicleStatus)" link type="primary" @click="openStatus(row as VehicleDetail)"><Power :size="15"/>状态</el-button></template><template #status="{row}"><el-tag>{{(row as VehicleDetail).status}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
|
||||
<template><AdminPage><ListPageLayout aria-label="车辆列表"><template #toolbar><div class="query-panel"><div class="status-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{'is-active':ownership===tab[1]}]" @click="selectOwnership(tab[1])">{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template><template #state><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template><section class="surface-panel vehicle-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆"><template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template><template #actions="{row}"><el-button link type="primary" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})"><Eye :size="15"/>查看</el-button><el-button v-if="access.can(permission.vehicleEdit)" link type="primary" @click="openEdit(row as VehicleDetail)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.vehicleStatus)" link type="primary" @click="openStatus(row as VehicleDetail)"><Power :size="15"/>状态</el-button></template><template #status="{row}"><el-tag>{{(row as VehicleDetail).status}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
|
||||
<AppDialog v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(760px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form label-position="top"><div class="form-grid"><el-form-item label="车辆归属"><el-select v-model="form.ownership"><el-option label="自有车辆" value="OWNED"/><el-option label="外派车辆" value="DISPATCHED"/></el-select></el-form-item><el-form-item label="车牌号"><el-input v-model="form.plateNumber"/></el-form-item><el-form-item label="车型"><el-input v-model="form.model"/></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2"/></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0"/></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone"/></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany"/></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="s" :value="s"/></el-select></el-form-item><el-form-item label="备注"><el-input v-model="form.remark"/></el-form-item></div></el-form></AppDialog><AppDialog v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="s" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template>
|
||||
<style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{overflow:hidden;border:1px solid var(--cc-border);border-radius:8px;background:#fff}.status-tabs{display:flex;border-bottom:1px solid var(--cc-border);background:#f9fafb}.status-tab{min-height:44px;padding:0 18px;border:0;background:transparent;color:var(--cc-muted)}.status-tab.is-active{border-bottom:3px solid var(--cc-primary);color:var(--cc-primary);font-weight:600}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px}</style>
|
||||
|
||||
Reference in New Issue
Block a user