diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index 1446c8e..caab020 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs @@ -2414,3 +2414,74 @@ test('业务用户分配组件使用固定映射并允许空数组完整替换', assert.match(source, /]*v-if="loadError"/) assert.match(source, /width="min\(720px, 92vw\)"/) }) + +test('测试批次管理与旧数据纳入遵守 root、权限和预览提交契约', async () => { + const apiPath = 'src/api/system/testBatch.js' + const apiSource = await readFile(path.join(frontendRoot, apiPath), 'utf8') + const expectedTestBatchRequests = [ + { url: "'/system/test-batch/options'", method: 'get' }, + { url: "'/system/test-batch/list'", method: 'get' }, + { url: "'/system/test-batch/' + batchId", method: 'get' }, + { url: "'/system/test-batch'", method: 'post' }, + { url: "'/system/test-batch/' + batchId", method: 'put' }, + { url: "'/system/test-batch/' + batchId + '/users'", method: 'put' }, + { url: "'/system/test-batch/' + batchId + '/close'", method: 'put' }, + { url: "'/system/test-batch/' + batchId + '/reopen'", method: 'put' }, + { url: "'/system/test-batch/' + batchId + '/adoption/preview'", method: 'post' }, + { url: "'/system/test-batch/' + batchId + '/adoption/execute'", method: 'post' }, + { url: "'/system/test-batch/' + batchId + '/purge/preview'", method: 'post' }, + { url: "'/system/test-batch/' + batchId + '/purge'", method: 'post' } + ] + assert.deepEqual(extractRequests(apiSource), expectedTestBatchRequests) + + const adoptionRoots = { + strain: { path: 'src/views/master/strain/index.vue', id: 'strainId' }, + customer: { path: 'src/views/master/customer/index.vue', id: 'customerId' }, + site: { path: 'src/views/master/site/index.vue', id: 'siteId' }, + line: { path: 'src/views/master/line/index.vue', id: 'lineId' }, + warehouse: { path: 'src/views/master/warehouse/index.vue', id: 'warehouseId' }, + pallet: { path: 'src/views/master/pallet/index.vue', id: 'palletId' }, + inbound: { path: 'src/views/business/inbound/index.vue', id: 'inboundId' }, + filling: { path: 'src/views/business/filling/index.vue', id: 'fillingId' }, + shipment: { path: 'src/views/business/shipment/index.vue', id: 'shipmentId' }, + usage: { path: 'src/views/business/usage/index.vue', id: 'usageId' }, + return: { path: 'src/views/business/return/index.vue', id: 'returnId' } + } + assert.equal(Object.keys(adoptionRoots).length, 11, '纳入来源必须固定为 11 类') + for (const [rootType, contract] of Object.entries(adoptionRoots)) { + const source = await readFile(path.join(frontendRoot, contract.path), 'utf8') + assert.match(source, /import TestBatchAdoptionDialog from ['"]@\/components\/TestBatchAdoptionDialog['"]/) + assert.match(source, /useUserStore\(\)/, `${rootType} 必须读取 admin 可见性`) + assert.match(source, /useTestBatchStore\(\)/, `${rootType} 必须读取测试模式`) + assert.match(source, /userStore\.admin === true\s*&&\s*!testBatchStore\.isTestMode/, `${rootType} 纳入入口必须只在管理员正式模式显示`) + assert.match(source, new RegExp(`\\s*item\\.${contract.id}\\)`), `${rootType} 必须传递正整数根 ID`) + } + + for (const rootType of ['strain', 'customer', 'site', 'line', 'warehouse', 'pallet']) { + const source = await readFile(path.join(frontendRoot, adoptionRoots[rootType].path), 'utf8') + assert.match(source, /userStore\.admin === true\s*\|\|\s*!testBatchStore\.isTestMode/, `${rootType} 测试模式主数据写操作必须要求管理员`) + } + + const adoptionDialogPath = 'src/components/TestBatchAdoptionDialog/index.vue' + const adoptionDialog = await readFile(path.join(frontendRoot, adoptionDialogPath), 'utf8') + assert.match(adoptionDialog, /item\.status === ['"]ACTIVE['"]/) + assert.match(adoptionDialog, /Number\.isInteger\(id\)\s*&&\s*id\s*>\s*0/) + assert.match(adoptionDialog, /previewTestBatchAdoption\(.*\{\s*roots:\s*roots\.value\s*}\s*\)/s) + assert.match(adoptionDialog, /executeTestBatchAdoption\([^,]+,\s*\{\s*roots:\s*roots\.value,\s*batchName:\s*batchName\.value,\s*previewToken:\s*previewToken\.value\s*}\s*\)/s) + assert.match(adoptionDialog, /batchName\.value\s*!==\s*preview\.value\.batchName/) + assert.match(adoptionDialog, /function invalidatePreview\(\)[\s\S]*?previewToken\.value\s*=\s*['"][\s\S]*?batchName\.value\s*=\s*['"]/) + + const managementPath = 'src/views/system/testBatch/index.vue' + const management = await readFile(path.join(frontendRoot, managementPath), 'utf8') + assert.match(management, /const actionsFor\s*=\s*row\s*=>\s*\(\{[\s\S]*?edit:\s*row\.status\s*!==\s*['"]PURGED['"][\s\S]*?assign:\s*row\.status\s*===\s*['"]ACTIVE['"][\s\S]*?close:\s*row\.status\s*===\s*['"]ACTIVE['"][\s\S]*?reopen:\s*row\.status\s*===\s*['"]CLOSED['"][\s\S]*?purgePreview:\s*row\.status\s*===\s*['"]CLOSED['"][\s\S]*?audit:\s*row\.status\s*===\s*['"]PURGED['"][\s\S]*?}\)/) + assert.match(management, /onlinePurgeAllowed\.value\s*===\s*false/) + assert.match(management, /blockingReason/) + assert.match(management, /testBatchStore\.leaveTestMode\(\)/) + + const reportPaths = Object.values(reportPageContracts).map((contract) => contract.relativePath) + for (const reportPath of reportPaths) { + const source = await readFile(path.join(frontendRoot, reportPath), 'utf8') + assert.doesNotMatch(source, /TestBatchAdoptionDialog/, `${reportPath} 不得提供纳入入口`) + } +}) diff --git a/shierfeng-fastapi-frontend/src/api/system/testBatch.js b/shierfeng-fastapi-frontend/src/api/system/testBatch.js index 5035aab..ba60e45 100644 --- a/shierfeng-fastapi-frontend/src/api/system/testBatch.js +++ b/shierfeng-fastapi-frontend/src/api/system/testBatch.js @@ -7,3 +7,87 @@ export function listAvailableTestBatches() { method: 'get' }) } + +export function listTestBatches(params) { + return request({ + url: '/system/test-batch/list', + method: 'get', + params: params + }) +} + +export function getTestBatch(batchId) { + return request({ + url: '/system/test-batch/' + batchId, + method: 'get' + }) +} + +export function addTestBatch(data) { + return request({ + url: '/system/test-batch', + method: 'post', + data: data + }) +} + +export function updateTestBatch(batchId, data) { + return request({ + url: '/system/test-batch/' + batchId, + method: 'put', + data: data + }) +} + +export function assignTestBatchUsers(batchId, data) { + return request({ + url: '/system/test-batch/' + batchId + '/users', + method: 'put', + data: data + }) +} + +export function closeTestBatch(batchId) { + return request({ + url: '/system/test-batch/' + batchId + '/close', + method: 'put' + }) +} + +export function reopenTestBatch(batchId) { + return request({ + url: '/system/test-batch/' + batchId + '/reopen', + method: 'put' + }) +} + +export function previewTestBatchAdoption(batchId, data) { + return request({ + url: '/system/test-batch/' + batchId + '/adoption/preview', + method: 'post', + data: data + }) +} + +export function executeTestBatchAdoption(batchId, data) { + return request({ + url: '/system/test-batch/' + batchId + '/adoption/execute', + method: 'post', + data: data + }) +} + +export function previewTestBatchPurge(batchId) { + return request({ + url: '/system/test-batch/' + batchId + '/purge/preview', + method: 'post' + }) +} + +export function purgeTestBatch(batchId, data) { + return request({ + url: '/system/test-batch/' + batchId + '/purge', + method: 'post', + data: data + }) +} diff --git a/shierfeng-fastapi-frontend/src/components/TestBatchAdoptionDialog/index.vue b/shierfeng-fastapi-frontend/src/components/TestBatchAdoptionDialog/index.vue new file mode 100644 index 0000000..aa18312 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/TestBatchAdoptionDialog/index.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/views/business/filling/index.vue b/shierfeng-fastapi-frontend/src/views/business/filling/index.vue index 2a11e61..78feff1 100644 --- a/shierfeng-fastapi-frontend/src/views/business/filling/index.vue +++ b/shierfeng-fastapi-frontend/src/views/business/filling/index.vue @@ -32,7 +32,9 @@ - +
纳入测试批次
+ + @@ -56,6 +58,7 @@ v-model:limit="queryParams.pageSize" @pagination="getList" /> + import PalletScanInput from '@/components/PalletScanInput' import { createIdempotencyKey } from '@/utils/palletScan' +import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog' +import useUserStore from '@/store/modules/user' +import useTestBatchStore from '@/store/modules/testBatch' import { addFilling, getFillingStrainOptions, listFilling, scanFillingPallet } from '@/api/business/filling' const { proxy } = getCurrentInstance() +const userStore = useUserStore() +const testBatchStore = useTestBatchStore() +const adoptionDialogRef = ref() +const selectedIds = ref([]) const fillingList = ref([]) const strainOptions = ref([]) const scannedPallet = ref(null) @@ -246,6 +256,9 @@ function getList() { }) } +function handleSelectionChange(selection) { selectedIds.value = selection.map((item) => item.fillingId) } +function openAdoptionDialog() { adoptionDialogRef.value?.open() } + function invalidateOptionRequest() { optionGeneration += 1 optionLoading.value = false diff --git a/shierfeng-fastapi-frontend/src/views/business/inbound/index.vue b/shierfeng-fastapi-frontend/src/views/business/inbound/index.vue index b869004..61670c8 100644 --- a/shierfeng-fastapi-frontend/src/views/business/inbound/index.vue +++ b/shierfeng-fastapi-frontend/src/views/business/inbound/index.vue @@ -41,7 +41,9 @@ - +
纳入测试批次
+ + @@ -99,6 +101,7 @@ v-model:limit="queryParams.pageSize" @pagination="getList" /> + import PalletScanInput from '@/components/PalletScanInput' import { createIdempotencyKey } from '@/utils/palletScan' +import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog' +import useUserStore from '@/store/modules/user' +import useTestBatchStore from '@/store/modules/testBatch' import { addInbound, confirmInbound, @@ -211,6 +217,10 @@ import { } from '@/api/business/inbound' const { proxy } = getCurrentInstance() +const userStore = useUserStore() +const testBatchStore = useTestBatchStore() +const adoptionDialogRef = ref() +const selectedIds = ref([]) const inboundList = ref([]) const warehouseOptions = ref([]) const scannedPallets = ref([]) @@ -287,6 +297,9 @@ function getList() { }) } +function handleSelectionChange(selection) { selectedIds.value = selection.map((item) => item.inboundId) } +function openAdoptionDialog() { adoptionDialogRef.value?.open() } + function invalidateOptionRequest() { optionGeneration += 1 optionLoading.value = false diff --git a/shierfeng-fastapi-frontend/src/views/business/return/index.vue b/shierfeng-fastapi-frontend/src/views/business/return/index.vue index 1d43397..6a539de 100644 --- a/shierfeng-fastapi-frontend/src/views/business/return/index.vue +++ b/shierfeng-fastapi-frontend/src/views/business/return/index.vue @@ -173,7 +173,9 @@ - +
纳入测试批次
+ + @@ -212,12 +214,16 @@ v-model:limit="queryParams.pageSize" @pagination="getList" /> + + +