feat(project): show business fields in statistics details
Reuse assignment business columns and keyword searches in the four statistics drill-down lists. Add a statistics-permission read endpoint sharing tenant-scoped detail queries without granting assignment access. Related to issue #13. Independent static review completed. User self-test arrangement: regression cases authored, no tests or builds executed.
This commit is contained in:
1 parent
f04e24186f
commit
fa5efa9629
7 files changed
+106
-8
No files matched your search
@@ -159,6 +159,21 @@ def records(bizType: str, page: int=Query(1, ge=1), pageSize: int=Query(20, ge=1
|
||||
return success_response(svc.records(db, *ctx, bizType, page, pageSize, teamId=teamId, projectId=projectId, unassigned=unassigned, status=status, keyword=keyword, startDate=startDate, endDate=endDate))
|
||||
|
||||
|
||||
@router.get(PREFIX + '/statistics/records')
|
||||
def statistics_records(
|
||||
bizType: Literal['cell_storage', 'research_donation', 'pilot_service', 'basic_experiment'],
|
||||
page: int = Query(1, ge=1), pageSize: int = Query(20, ge=1, le=200),
|
||||
teamId: Optional[str] = None, projectId: Optional[str] = None,
|
||||
unassigned: bool = False, status: Optional[str] = None, keyword: Optional[str] = None,
|
||||
startDate: Optional[str] = None, endDate: Optional[str] = None,
|
||||
ctx=Depends(guard('project:statistics:list')), db: Session=Depends(get_db),
|
||||
):
|
||||
return success_response(project_assignment_records.statistics_records(
|
||||
db, *ctx, bizType, page, pageSize, teamId=teamId, projectId=projectId,
|
||||
unassigned=unassigned, status=status, keyword=keyword, startDate=startDate, endDate=endDate,
|
||||
))
|
||||
|
||||
|
||||
@router.post(PREFIX + '/assignments/preview')
|
||||
def preview(body: PreviewBody, ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db)):
|
||||
return success_response(svc.preview_assignment(db, *ctx, body.bizType, body.id, body.projectId))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""历史归属维护专用检索;统计明细不返回业务联系信息。"""
|
||||
"""项目业务明细检索;统计读取与归属维护分别校验菜单权限。"""
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -162,8 +162,17 @@ def business_details(db, tenant, kind, rows):
|
||||
|
||||
def records(db, tenant, user, biz_type, page=1, pageSize=20, **filters):
|
||||
projects.check_permission(db, tenant, user, 'project:assignment:edit')
|
||||
return _records(db, tenant, user, biz_type, page, pageSize, **filters)
|
||||
|
||||
|
||||
def statistics_records(db, tenant, user, biz_type, page=1, pageSize=20, **filters):
|
||||
projects.check_permission(db, tenant, user, 'project:statistics:list')
|
||||
return _records(db, tenant, user, biz_type, page, pageSize, **filters)
|
||||
|
||||
|
||||
def _records(db, tenant, user, biz_type, page, pageSize, **filters):
|
||||
if biz_type not in projects.COUNT_TYPES:
|
||||
raise HTTPException(400, '归属维护仅支持四类正式业务')
|
||||
raise HTTPException(400, '业务明细仅支持四类正式业务')
|
||||
model, number, status = projects.BUSINESS[biz_type]
|
||||
# 继续使用现有租户、主单数据范围、项目/项目组和日期条件。
|
||||
query = projects.filtered_query(db, tenant, user, biz_type, {**filters, 'keyword': None})
|
||||
|
||||
@@ -59,6 +59,65 @@ class ProjectAssignmentRecordsTest(unittest.TestCase):
|
||||
self.db.commit()
|
||||
self.assertEqual([r['id'] for r in self.records()['list']], ['own'])
|
||||
|
||||
def test_statistics_detail_permission_is_separate_from_assignment(self):
|
||||
self.user['role'] = 'admin'
|
||||
self.grant('project:statistics:list')
|
||||
self.db.add_all([
|
||||
BizCollectionProtocol(id='own', tenant_no='t', protocol_no='OWN', create_by='u'),
|
||||
BizCollectionProtocol(id='other', tenant_no='t', protocol_no='OTHER', create_by='other'),
|
||||
BizCollectionProtocol(id='foreign', tenant_no='foreign', protocol_no='FOREIGN', create_by='u'),
|
||||
BizCollectionProtocol(id='deleted', tenant_no='t', protocol_no='DELETED', create_by='u', del_flag=1),
|
||||
BizProtocolPartyCustomer(id='p', tenant_no='t', collection_protocol_id='own', mother_name='Mother', mother_phone='13800000000'),
|
||||
BizProtocolSample(id='s', tenant_no='t', collection_protocol_id='own', sample_code='SAMPLE'),
|
||||
])
|
||||
self.db.commit()
|
||||
result = svc.statistics_records(self.db, 't', self.user, 'cell_storage', keyword='SAMPLE')
|
||||
self.assertEqual(result['total'], 1)
|
||||
self.assertEqual(result['list'][0]['motherName'], 'Mother')
|
||||
self.assertEqual(result['list'][0]['motherPhone'], '13800000000')
|
||||
self.assertEqual(result['list'][0]['sampleCodes'], 'SAMPLE')
|
||||
self.assertEqual(svc.statistics_records(self.db, 't', self.user, 'cell_storage')['total'], 1)
|
||||
with self.assertRaises(HTTPException) as denied:
|
||||
self.records()
|
||||
self.assertEqual(denied.exception.status_code, 403)
|
||||
self.db.get(SysRole, 'project:statistics:list').status = '0'
|
||||
self.db.commit()
|
||||
self.grant('project:assignment:edit')
|
||||
self.assertEqual(self.records()['total'], 1)
|
||||
with self.assertRaises(HTTPException) as denied:
|
||||
svc.statistics_records(self.db, 't', self.user, 'cell_storage')
|
||||
self.assertEqual(denied.exception.status_code, 403)
|
||||
|
||||
def test_four_statistics_details_share_fields_and_match_summary_counts(self):
|
||||
cases = (
|
||||
('cell_storage', BizCollectionProtocol, dict(protocol_no='C'), 'sampleCodes'),
|
||||
('research_donation', BizResearchDonation, dict(donation_no='D'), 'motherName'),
|
||||
('pilot_service', BizPilotApplication, dict(application_no='P', channel_service_order_id='', biz_type='pilot_service', application_name='Pilot'), 'applicationName'),
|
||||
('basic_experiment', BizPilotApplication, dict(application_no='B', channel_service_order_id='', biz_type='basic_experiment', project_title='Study'), 'projectTitle'),
|
||||
)
|
||||
for kind, model, fields, detail_key in cases:
|
||||
self.db.add_all([
|
||||
model(id=kind + '-assigned', tenant_no='t', project_id='project', create_time=datetime(2026, 11, 4), **fields),
|
||||
model(id=kind + '-empty', tenant_no='t', create_time=datetime(2026, 11, 4), **fields),
|
||||
model(id=kind + '-old', tenant_no='t', create_time=datetime(2026, 11, 1), **fields),
|
||||
])
|
||||
self.db.commit()
|
||||
filters = dict(startDate='2026-11-04', endDate='2026-11-04')
|
||||
summary = project_management.statistics(self.db, 't', self.user, **filters)
|
||||
for kind, _, _, detail_key in cases:
|
||||
with self.subTest(kind=kind):
|
||||
details = svc.statistics_records(self.db, 't', self.user, kind, **filters)
|
||||
self.assertEqual(details, self.records(kind, **filters))
|
||||
self.assertEqual(details['total'], summary['totals'][kind])
|
||||
self.assertEqual(details['total'], 2)
|
||||
self.assertIn(detail_key, details['list'][0])
|
||||
unassigned = svc.statistics_records(self.db, 't', self.user, kind, unassigned=True, **filters)
|
||||
self.assertEqual(unassigned['total'], summary['unassignedTotals'][kind])
|
||||
self.assertEqual(unassigned['list'][0]['id'], kind + '-empty')
|
||||
assigned = svc.statistics_records(self.db, 't', self.user, kind, projectId='project', **filters)
|
||||
self.assertEqual(assigned['total'], 1)
|
||||
self.assertEqual(assigned['list'][0]['id'], kind + '-assigned')
|
||||
|
||||
def test_children_do_not_duplicate_counts_or_leak_foreign_deleted_data(self):
|
||||
self.db.add_all([
|
||||
BizCollectionProtocol(id='a', tenant_no='t', protocol_no='A', protocol_status='sxz'),
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## 历史归属维护 v2(2026-11-04 已确认)
|
||||
|
||||
统计明细后续反馈:用户明确要求业务统计四类明细弹窗复用历史归属维护的全部业务识别列。沿用原明细弹窗、P02 表格和已确认列配置,无新弹窗或视觉布局。新增 `GET /web/admin/project-management/statistics/records`,在路由及服务入口校验 `project:statistics:list`,与归属维护接口复用内部明细查询;两接口分别检查统计/归属维护权限,统计接口只读,不授予调整权限。保留认证租户、主单数据范围、软删除、项目/项目组、日期、未归属、状态条件及主单分页,展示与关键词匹配四业务字段。前端复用同一列配置与查询提示,不显示归属操作;预签约沿用旧通用接口和列。以下 v2 初始方案中“统计接口不返回新增联系信息”的约束仅继续适用于旧 `/records`,本次新统计专用接口按用户明确要求提供这些字段;汇总计数及移动接口不变。
|
||||
|
||||
后续用户明确调整:细胞存储、科研捐赠 tab 的单号列替换为“样本编码”,沿用已确认表格的列宽、位置及样式,不新增界面或视觉布局。专用接口新增只读 `sampleCodes`,按主单的同租户有效样本子行批量读取,多个编码稳定去重后用顿号连接,无样本时显示破折号;关键词增加样本编码 EXISTS 匹配。列表仍一张主单一行,归属操作仍作用于主单及关联链;主单编号保留在接口和原关联预览中供现有逻辑使用。此处仅读取本单样本子行,不涉及接收/质检等下游跨表匹配。
|
||||
|
||||
用户明确采用 [Figma 原型 v2](https://www.figma.com/design/wOQLDAhr1gdlQEXnVaFO9S?node-id=13-23)。范围为 Web 历史归属维护及其后端读取接口,沿用用户先合入 V2.0 再自测的安排。复用 P02 列表和 `pilot-service/pilot-application/index.vue` 的 `panel-pill-tabs`,操作列固定右侧,搜索右对齐。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
- **THEN** 切换时清空关键词、状态、项目组/项目并回到第一页,保留日期与仅未归属条件;过时响应不得覆盖当前业务的列表或计数,失败可重试且不保留旧列表。
|
||||
#### Scenario: 业务联系信息权限
|
||||
- **WHEN** 仅有统计权限的用户读取统计钻取或尝试调用归属维护详情接口
|
||||
- **THEN** 统计接口不返回新增联系信息,归属维护详情接口拒绝无 `project:assignment:edit` 权限的访问;全部项目选择权限不扩大主单数据范围。
|
||||
- **THEN** 统计专用详情接口按 `project:statistics:list` 返回其租户及主单数据权限内的业务识别和联系信息;归属维护详情接口仍拒绝无 `project:assignment:edit` 权限的访问,全部项目选择权限不扩大主单数据范围。
|
||||
#### Scenario: 归属调整仍按关联链执行
|
||||
- **WHEN** 用户从某个业务 tab 调整归属
|
||||
- **THEN** 目标候选按该单据业务类型及项目权限加载,仍先预览关联单据再确认提交;关联预签约纳入预览和审计,不新增独立预签约 tab。
|
||||
@@ -50,6 +50,9 @@ Web SHALL 使用“项目 → 项目管理 → 项目组管理 / 项目管理 /
|
||||
#### Scenario: 汇总钻取
|
||||
- **WHEN** 用户打开某项目的某类业务明细
|
||||
- **THEN** 明细总数与相同筛选条件汇总一致,不因子行联接重复计数。
|
||||
#### Scenario: 四类统计明细展示业务字段
|
||||
- **WHEN** 点击细胞存储、科研捐赠、中试服务、基础实验的统计数量
|
||||
- **THEN** 原明细弹窗复用历史归属维护对应 tab 的业务列与关键词能力,存储/捐赠显示样本编码,保留汇总传入的筛选条件和只读权限,不显示归属维护操作;预签约仍沿用原行为。
|
||||
|
||||
### Requirement: 沿用现有界面样式
|
||||
Web与移动 SHALL 复用同端现有组件、布局模式和全局样式。
|
||||
|
||||
@@ -91,6 +91,11 @@ export const fetchAssignmentRecords = (params: Record<string, unknown>) =>
|
||||
url: `${base}/assignments/records`,
|
||||
params
|
||||
})
|
||||
export const fetchStatisticsRecords = (params: Record<string, unknown>) =>
|
||||
request.get<{ list: AssignmentRecord[]; total: number; statusCounts: Record<string, number> }>({
|
||||
url: `${base}/statistics/records`,
|
||||
params
|
||||
})
|
||||
export const previewAssignment = (data: ProjectRecord) =>
|
||||
request.post<{ records: ProjectRecord[] }>({ url: `${base}/assignments/preview`, data })
|
||||
export const saveAssignment = (data: ProjectRecord) =>
|
||||
|
||||
@@ -241,6 +241,7 @@
|
||||
bizLabels,
|
||||
fetchProjectRecords,
|
||||
fetchAssignmentRecords,
|
||||
fetchStatisticsRecords,
|
||||
previewAssignment,
|
||||
saveAssignment,
|
||||
fetchAssignmentHistory,
|
||||
@@ -274,12 +275,12 @@
|
||||
historyVisible = ref(false),
|
||||
historyRows = ref<ProjectRecord[]>([])
|
||||
const visibleColumns = computed(() =>
|
||||
props.assignment
|
||||
? assignmentColumns[query.bizType as AssignmentBizType]
|
||||
: [{ prop: 'bizNo', label: '单据编号', minWidth: 180 }]
|
||||
query.bizType === 'presign'
|
||||
? [{ prop: 'bizNo', label: '单据编号', minWidth: 180 }]
|
||||
: assignmentColumns[query.bizType]
|
||||
)
|
||||
const keywordHint = computed(() =>
|
||||
props.assignment ? assignmentKeywordHints[query.bizType as AssignmentBizType] : '请输入单据编号'
|
||||
query.bizType === 'presign' ? '请输入单据编号' : assignmentKeywordHints[query.bizType]
|
||||
)
|
||||
let loadVersion = 0
|
||||
function statusLabel(status: string) {
|
||||
@@ -306,7 +307,11 @@
|
||||
loadError.value = false
|
||||
rows.value = []
|
||||
try {
|
||||
const fetchRecords = props.assignment ? fetchAssignmentRecords : fetchProjectRecords
|
||||
const fetchRecords = props.assignment
|
||||
? fetchAssignmentRecords
|
||||
: query.bizType === 'presign'
|
||||
? fetchProjectRecords
|
||||
: fetchStatisticsRecords
|
||||
const result = await fetchRecords({
|
||||
...props.filters,
|
||||
...query,
|
||||
|
||||
Reference in new issue
Block a user