diff --git a/backend/app/services/project_management.py b/backend/app/services/project_management.py
index 1253a4a0..40b87a3c 100644
--- a/backend/app/services/project_management.py
+++ b/backend/app/services/project_management.py
@@ -131,10 +131,11 @@ def records(db, tenant, user, biz_type, page=1, pageSize=20, **filters):
def statistics(db, tenant, user, groupBy='project', **filters):
if groupBy not in ('project', 'team'):
raise HTTPException(400, '分组方式不支持')
- totals = dict.fromkeys(COUNT_TYPES, 0)
- unassigned = dict.fromkeys(COUNT_TYPES, 0)
+ statistics_types = ('presign', *COUNT_TYPES)
+ totals = dict.fromkeys(statistics_types, 0)
+ unassigned = dict.fromkeys(statistics_types, 0)
counts = {}
- for kind in COUNT_TYPES:
+ for kind in statistics_types:
model = BUSINESS[kind][0]
counts[kind] = filtered_query(db, tenant, user, kind, filters).with_entities(
model.project_id, func.count(model.id)).group_by(model.project_id).all()
@@ -159,10 +160,10 @@ def statistics(db, tenant, user, groupBy='project', **filters):
key = team_id if groupBy == 'team' else project_id
item = items.setdefault(key, dict(id=key,
name=team_name if groupBy == 'team' else (info.project_name if info else None),
- teamName=team_name, **dict.fromkeys(COUNT_TYPES, 0)))
+ teamName=team_name, **dict.fromkeys(statistics_types, 0)))
item[kind] += count
return dict(items=list(items.values()), totals=totals, unassignedTotals=unassigned,
- presignTotal=filtered_query(db, tenant, user, 'presign', filters).count())
+ presignTotal=totals['presign'])
def linked_records(db, tenant, user, kind, row_id, lock=False, current_read=False):
diff --git a/backend/tests/test_project_management.py b/backend/tests/test_project_management.py
index 9e31a410..0d848fd7 100644
--- a/backend/tests/test_project_management.py
+++ b/backend/tests/test_project_management.py
@@ -70,6 +70,54 @@ class ProjectManagementTest(unittest.TestCase):
self.assertEqual(self.db.get(BizPresignOrder, 's').project_id, 'p')
self.assertEqual(len(self.svc.assignment_history(self.db, 't', self.user, 'cell_storage', 'a')['list']), 1)
+ def test_presign_statistics_column_groups_totals_and_drill_down(self):
+ from datetime import datetime
+ from app.models.models import BizCollectionProtocol, BizPresignOrder
+ self.db.add_all([
+ self.Team(id='g2', tenant_no='t', team_name='Presign only', leader_user_id='u'),
+ self.Project(id='p2', tenant_no='t', project_no='P2', project_name='Presign only',
+ team_id='g2', leader_user_id='u', biz_types='cell_storage', status='active'),
+ BizCollectionProtocol(id='protocol', tenant_no='t', protocol_no='CP', project_id='p',
+ create_time=datetime(2026, 11, 4)),
+ ])
+ for row_id, project_id, status, tenant, deleted, day in (
+ ('active', 'p', 'active', 't', 0, 4),
+ ('converted', 'p', 'converted', 't', 0, 4),
+ ('draft', 'p2', 'draft', 't', 0, 4),
+ ('void', None, 'void', 't', 0, 4),
+ ('foreign', 'p', 'active', 'foreign', 0, 4),
+ ('deleted', 'p', 'active', 't', 1, 4),
+ ('old', 'p', 'active', 't', 0, 1),
+ ):
+ self.db.add(BizPresignOrder(
+ id=row_id, tenant_no=tenant, protocol_no=row_id, project_id=project_id,
+ order_status=status, del_flag=deleted, create_by='u',
+ create_time=datetime(2026, 11, day),
+ converted_protocol_id='protocol' if status == 'converted' else None,
+ ))
+ self.db.commit()
+ filters = dict(startDate='2026-11-04', endDate='2026-11-04')
+ for group_by, expected in (('project', {'p': 2, 'p2': 1}), ('team', {'g': 2, 'g2': 1})):
+ with self.subTest(group_by=group_by):
+ result = self.svc.statistics(self.db, 't', self.user, groupBy=group_by, **filters)
+ self.assertEqual(result['totals']['presign'], 4)
+ self.assertEqual(result['presignTotal'], 4)
+ self.assertEqual(result['unassignedTotals']['presign'], 1)
+ self.assertEqual(result['totals']['cell_storage'], 1)
+ self.assertEqual({r['id']: r['presign'] for r in result['items']}, expected)
+ for row in result['items']:
+ drill = {('teamId' if group_by == 'team' else 'projectId'): row['id']}
+ self.assertEqual(self.svc.records(self.db, 't', self.user, 'presign', **drill, **filters)['total'], row['presign'])
+ for selection in (dict(teamId='g'), dict(projectId='p')):
+ result = self.svc.statistics(self.db, 't', self.user, **selection, **filters)
+ self.assertEqual(result['totals']['presign'], 2)
+ self.assertEqual(result['unassignedTotals']['presign'], 0)
+ self.assertEqual(self.svc.records(self.db, 't', self.user, 'presign', unassigned=True, **filters)['total'], 1)
+ denied = self.svc.statistics(self.db, 't', dict(user_id='outsider', role='admin'), **filters)
+ self.assertEqual(denied['totals']['presign'], 0)
+ self.assertEqual(denied['items'], [])
+ self.assertNotIn('presign', self.svc.COUNT_TYPES)
+
def test_team_child_ids_survive_edits(self):
from app.models.models import SysUser
self.db.add(SysUser(id='u', tenant_no='t', user_no='u', nick_name='User'))
diff --git a/openspec/changes/project-business-statistics/design.md b/openspec/changes/project-business-statistics/design.md
index fb4a97c1..abd486b5 100644
--- a/openspec/changes/project-business-statistics/design.md
+++ b/openspec/changes/project-business-statistics/design.md
@@ -1,5 +1,9 @@
# 实现设计与接口契约
+## 预签约统计列(2026-11-04 用户明确调整)
+
+将统计表左下角预签约计数移为独立“预签约”列,放在细胞存储前,复用现有数量列、图标按钮及明细弹窗。项目/项目组行、业务总量行、未归属行均返回 `presign` 数量;只有预签约的项目/组也显示。预签约仍按当前筛选下未删除的全状态主单计数(含已转正式),与四类正式业务分别统计,不相加。点击数量沿用预签约明细与汇总筛选。统计服务局部增加预签约聚合,保留 `presignTotal` 作为 `totals.presign` 的兼容字段;`COUNT_TYPES` 及历史归属四 tab 范围保持四类正式业务不变。无新界面、弹窗、样式或 SQL,继续用户自测。
+
## 历史归属维护 v2(2026-11-04 已确认)
统计明细后续反馈:用户明确要求业务统计四类明细弹窗复用历史归属维护的全部业务识别列。沿用原明细弹窗、P02 表格和已确认列配置,无新弹窗或视觉布局。新增 `GET /web/admin/project-management/statistics/records`,在路由及服务入口校验 `project:statistics:list`,与归属维护接口复用内部明细查询;两接口分别检查统计/归属维护权限,统计接口只读,不授予调整权限。保留认证租户、主单数据范围、软删除、项目/项目组、日期、未归属、状态条件及主单分页,展示与关键词匹配四业务字段。前端复用同一列配置与查询提示,不显示归属操作;预签约沿用旧通用接口和列。以下 v2 初始方案中“统计接口不返回新增联系信息”的约束仅继续适用于旧 `/records`,本次新统计专用接口按用户明确要求提供这些字段;汇总计数及移动接口不变。
diff --git a/openspec/changes/project-business-statistics/specs/project-management/spec.md b/openspec/changes/project-business-statistics/specs/project-management/spec.md
index 33f0223b..1cd8d04b 100644
--- a/openspec/changes/project-business-statistics/specs/project-management/spec.md
+++ b/openspec/changes/project-business-statistics/specs/project-management/spec.md
@@ -47,6 +47,9 @@ Web SHALL 使用“项目 → 项目管理 → 项目组管理 / 项目管理 /
### Requirement: 权限内统计
系统 SHALL 按认证租户和现有数据权限统计四类业务,预签约另计,未归属单据单列。
+#### Scenario: 预签约独立统计列
+- **WHEN** 按项目或项目组查看业务统计
+- **THEN** 表格以“预签约”独立列展示每组、业务总量、未归属的全状态有效预签约单数,包含只有预签约的组;移除左下角独立计数文字,点击数量可查看同筛选明细,不将预签约数量加入四类正式业务数量。
#### Scenario: 汇总钻取
- **WHEN** 用户打开某项目的某类业务明细
- **THEN** 明细总数与相同筛选条件汇总一致,不因子行联接重复计数。
diff --git a/web-admin/src/views/project-management/statistics/index.vue b/web-admin/src/views/project-management/statistics/index.vue
index 1857ea07..7da93a2e 100644
--- a/web-admin/src/views/project-management/statistics/index.vue
+++ b/web-admin/src/views/project-management/statistics/index.vue
@@ -47,7 +47,7 @@
min-width="150"
/>
预签约:{{ data.presignTotal }} 单(单独计数)
+