diff --git a/shierfeng-fastapi-backend/module_business/dao/report_dao.py b/shierfeng-fastapi-backend/module_business/dao/report_dao.py index fd6f2c0..2d3a609 100644 --- a/shierfeng-fastapi-backend/module_business/dao/report_dao.py +++ b/shierfeng-fastapi-backend/module_business/dao/report_dao.py @@ -490,6 +490,6 @@ class ReportDao: .outerjoin(after_line, after_line.line_id == BizPalletEvent.after_line_id) .outerjoin(operator_user, operator_user.user_id == BizPalletEvent.operator_id) .where(*conditions) - .order_by(BizPalletEvent.pallet_id, BizPalletEvent.event_seq, BizPalletEvent.event_id) + .order_by(BizPalletEvent.occurred_at, BizPalletEvent.event_id) ) return await cls._paginate(db, statement, query_object) diff --git a/shierfeng-fastapi-backend/tests/module_business/test_report_service.py b/shierfeng-fastapi-backend/tests/module_business/test_report_service.py index 678481f..61ec386 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_report_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_report_service.py @@ -603,7 +603,7 @@ class TestReportDaoQueries(unittest.IsolatedAsyncioTestCase): self.assertIn('biz_pallet_event.occurred_at >=', sql) self.assertIn('biz_pallet_event.occurred_at <', sql) self.assertIn( - 'order by biz_pallet_event.pallet_id, biz_pallet_event.event_seq, biz_pallet_event.event_id', + 'order by biz_pallet_event.occurred_at, biz_pallet_event.event_id', sql, ) diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index cf4ef7c..f9af49e 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs @@ -253,6 +253,83 @@ const businessPageContracts = { } } +const reportPageContracts = { + palletInventory: { + relativePath: 'src/views/report/palletInventory/index.vue', + apiName: 'listPalletInventory', + permission: 'report:palletinventory:list', + queryKeys: [ + 'pageNum', 'pageSize', 'beginTime', 'endTime', 'palletCode', 'flowState', + 'warehouseId', 'siteId', 'lineId', 'strainId', 'batchNo' + ], + columnKeys: [ + 'palletCode', 'flowState', 'warehouseName', 'siteName', 'lineName', + 'strainName', 'batchNo', 'initialPackages', 'remainingPackages' + ] + }, + filling: { + relativePath: 'src/views/report/filling/index.vue', + apiName: 'listFillingReport', + permission: 'report:filling:list', + queryKeys: ['pageNum', 'pageSize', 'beginTime', 'endTime', 'warehouseId', 'palletCode', 'strainId', 'batchNo'], + columnKeys: [ + 'operatedAt', 'warehouseName', 'palletCode', 'strainCodeSnapshot', + 'strainNameSnapshot', 'batchNo', 'packageSpec', 'initialPackages' + ] + }, + shipment: { + relativePath: 'src/views/report/shipment/index.vue', + apiName: 'listShipmentReport', + permission: 'report:shipment:list', + queryKeys: [ + 'pageNum', 'pageSize', 'beginTime', 'endTime', 'shipmentNo', + 'sourceWarehouseId', 'targetSiteId', 'palletCode', 'status' + ], + columnKeys: [ + 'shipmentNo', 'confirmedAt', 'sourceWarehouseName', 'customerName', + 'targetSiteName', 'palletCode', 'packageQty' + ] + }, + consumption: { + relativePath: 'src/views/report/consumption/index.vue', + apiName: 'listConsumptionReport', + permission: 'report:consumption:list', + queryKeys: [ + 'pageNum', 'pageSize', 'beginTime', 'endTime', 'customerId', + 'siteId', 'lineId', 'palletCode', 'strainId', 'batchNo' + ], + columnKeys: [ + 'occurredAt', 'customerName', 'siteName', 'lineName', 'palletCode', + 'strainName', 'batchNo', 'usedPackages', 'remainingPackagesAfter' + ] + }, + return: { + relativePath: 'src/views/report/return/index.vue', + apiName: 'listReturnReport', + permission: 'report:return:list', + queryKeys: [ + 'pageNum', 'pageSize', 'beginTime', 'endTime', 'returnNo', + 'sourceSiteId', 'targetWarehouseId', 'palletCode', 'status' + ], + columnKeys: [ + 'returnNo', 'status', 'requestedAt', 'confirmedAt', 'sourceSiteName', + 'targetWarehouseName', 'palletCode', 'remainingPackagesSnapshot' + ] + }, + palletTrace: { + relativePath: 'src/views/report/palletTrace/index.vue', + apiName: 'listPalletTrace', + permission: 'report:pallettrace:list', + queryKeys: ['pageNum', 'pageSize', 'beginTime', 'endTime', 'palletCode', 'eventType'], + columnKeys: [ + 'occurredAt', 'eventType', 'sourceType', 'sourceId', 'palletCode', + 'beforeState', 'afterState', 'beforeWarehouseName', 'beforeSiteName', + 'beforeLineName', 'afterWarehouseName', 'afterSiteName', 'afterLineName', + 'beforeRemainingPackages', 'afterRemainingPackages', 'operatorName' + ] + } +} + const delimiterPairs = { '(': ')', '[': ']', '{': '}' } function sanitizeSource(source, { allowTemplateInterpolation = false } = {}) { @@ -680,6 +757,66 @@ test('六个报表 API 只提供 GET /list,不提供导出接口', async () => } }) +test('六个只读报表页面逐页满足 API、权限、筛选、列和异步分页契约', async (t) => { + for (const [resource, contract] of Object.entries(reportPageContracts)) { + await t.test(contract.relativePath, async () => { + const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8') + const importedApiModules = [...source.matchAll(/from\s*['"]@\/api\/(report|master|business)\/([A-Za-z]+)['"]/g)] + .map((match) => `${match[1]}/${match[2]}`) + + assert.deepEqual(importedApiModules, [`report/${resource}`], `${resource} 页面只能导入自己的报表 API`) + assert.deepEqual(extractImportedNames(source, `@/api/report/${resource}`), [contract.apiName]) + assert.doesNotMatch(source, /@\/api\/(?:master|business)\//) + assert.match(source, new RegExp(`${contract.apiName}\\(listContext\\.query\\)`)) + + const actualPermissions = [...new Set( + [...source.matchAll(/['"](report:[a-z]+:[a-z]+)['"]/g)].map((match) => match[1]) + )] + assert.deepEqual(actualPermissions, [contract.permission], `${resource} 页面只能出现自己的 list 权限`) + assertButtonsUsePermission(source, 'handleQuery', contract.permission) + assertButtonsUsePermission(source, 'resetQuery', contract.permission) + assert.doesNotMatch(source, /report:[a-z]+:(?:add|edit|remove|export|print)/) + assert.doesNotMatch(source, /\bhandle(?:Add|Edit|Delete|Remove|Export|Print|Download)\b|proxy\.download\s*\(/) + + for (const queryKey of contract.queryKeys) { + assert.match(source, new RegExp(`\\b${queryKey}\\b`), `${resource} 缺少筛选键 ${queryKey}`) + } + for (const columnKey of contract.columnKeys) { + assert.match(source, new RegExp(`\\b${columnKey}\\b`), `${resource} 缺少核心列 ${columnKey}`) + } + + assert.match(source, /]*v-loading="loading"[^>]*:data="reportList"/) + assert.match(source, /[\s\S]*?]*v-if="loadError"/) + assert.match(source, / \{\s*invalidateListRequest\(\)\s*}\)/) + }) + } +}) + test('扫码组件声明交互契约并保持触控尺寸', async () => { const source = await readFile(path.join(frontendRoot, 'src/components/PalletScanInput/index.vue'), 'utf8') diff --git a/shierfeng-fastapi-frontend/src/views/report/consumption/index.vue b/shierfeng-fastapi-frontend/src/views/report/consumption/index.vue new file mode 100644 index 0000000..d062616 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/report/consumption/index.vue @@ -0,0 +1,148 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/report/filling/index.vue b/shierfeng-fastapi-frontend/src/views/report/filling/index.vue new file mode 100644 index 0000000..44caa26 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/report/filling/index.vue @@ -0,0 +1,135 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/report/palletInventory/index.vue b/shierfeng-fastapi-frontend/src/views/report/palletInventory/index.vue new file mode 100644 index 0000000..c056d90 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/report/palletInventory/index.vue @@ -0,0 +1,179 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/report/palletTrace/index.vue b/shierfeng-fastapi-frontend/src/views/report/palletTrace/index.vue new file mode 100644 index 0000000..0ea34dc --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/report/palletTrace/index.vue @@ -0,0 +1,161 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/report/return/index.vue b/shierfeng-fastapi-frontend/src/views/report/return/index.vue new file mode 100644 index 0000000..3498f41 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/report/return/index.vue @@ -0,0 +1,145 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/report/shipment/index.vue b/shierfeng-fastapi-frontend/src/views/report/shipment/index.vue new file mode 100644 index 0000000..683681a --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/report/shipment/index.vue @@ -0,0 +1,142 @@ + + +