Files
zkxw/backend/app/services/project_management.py
T
zlwu 79ca01ac0d feat(project): show presign counts as a statistics column
Group presign counts by project and team, include totals and unassigned rows, and reuse count drill-down. Remove the standalone footer while retaining presignTotal compatibility and the four formal business types.

Related to issue #13. Independent source review completed; no tests or builds run under user self-test arrangement.
2026-11-04 14:53:07 +08:00

417 lines
23 KiB
Python

"""项目资料、权限内统计和关联归属事务。"""
from datetime import date, datetime, timedelta
from fastapi import HTTPException
from sqlalchemy import or_, and_, func
from app.core.data_scope import apply_data_scope
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.project_management import (Project, ProjectTeam, ProjectTeamMember,
ProjectTeamRegion, ProjectTeamHospital, ProjectAssignmentAudit)
from app.models.models import (SysUser, BizCollectionRegion, BizCollectionHospital,
BizPresignOrder, BizCollectionProtocol, BizPilotApplication)
from app.models.research_donation import BizResearchDonation, BizProtocolDonationConvert
from app.services.web_auth_roles import iter_user_menu_perm_codes
BUSINESS = {
'presign': (BizPresignOrder, 'protocol_no', 'order_status'),
'cell_storage': (BizCollectionProtocol, 'protocol_no', 'protocol_status'),
'research_donation': (BizResearchDonation, 'donation_no', 'donation_status'),
'pilot_service': (BizPilotApplication, 'application_no', 'application_status'),
'basic_experiment': (BizPilotApplication, 'application_no', 'application_status'),
}
COUNT_TYPES = tuple(k for k in BUSINESS if k != 'presign')
ALL_PROJECTS_PERMISSION = 'project:project:all'
def scoped(db, model, tenant):
return db.query(model).filter(model.tenant_no == tenant, model.del_flag == 0)
def get_row(db, model, tenant, row_id, lock=False):
query = scoped(db, model, tenant).filter(model.id == row_id)
row = (query.populate_existing().with_for_update() if lock else query).first()
if not row:
raise HTTPException(404, '记录不存在或无权访问')
return row
def has_permission(db, tenant, user, code):
return user.get('role') == 'super_admin' or code in iter_user_menu_perm_codes(db, user.get('user_id', ''), tenant_no=tenant)
def check_permission(db, tenant, user, *codes):
if not any(has_permission(db, tenant, user, code) for code in codes):
raise HTTPException(403, '无对应菜单权限')
def project_brief(db, tenant_no, project_id):
result = dict(projectId=project_id, projectName=None, teamId=None, teamName=None)
if project_id:
row = scoped(db, Project, tenant_no).filter(Project.id == project_id).first()
if row:
team = scoped(db, ProjectTeam, tenant_no).filter(ProjectTeam.id == row.team_id).first()
result.update(projectName=row.project_name, teamId=row.team_id, teamName=team.team_name if team else None)
return result
def validate_assignment(db, tenant_no, user, project_id, biz_type, *, current_project_id=None, required=False):
project_id = project_id or None
if current_project_id:
if project_id != current_project_id:
raise HTTPException(400, '请通过项目归属维护调整')
return current_project_id
if not project_id:
if required:
raise HTTPException(400, '请选择项目')
return None
row = get_row(db, Project, tenant_no, project_id, lock=True)
team = get_row(db, ProjectTeam, tenant_no, row.team_id, lock=True)
if row.status != 'active' or not team.enabled:
raise HTTPException(400, '项目或项目组已停用或尚未启用')
effective_type = 'cell_storage' if biz_type == 'presign' else biz_type
if effective_type not in row.biz_types.split(','):
raise HTTPException(400, '项目不支持该业务类型')
uid = user.get('user_id')
member = uid == team.leader_user_id or scoped(db, ProjectTeamMember, tenant_no).filter(ProjectTeamMember.team_id == team.id, ProjectTeamMember.user_id == uid).with_for_update().first()
if not member and not has_permission(db, tenant_no, user, ALL_PROJECTS_PERMISSION):
raise HTTPException(403, '仅可选择所在项目组的项目,跨组选择需全部项目权限')
return project_id
def business_query(db, tenant, user, kind):
if kind not in BUSINESS:
raise HTTPException(400, '业务类型不支持')
model = BUSINESS[kind][0]
query = scoped(db, model, tenant)
if model is BizPilotApplication:
query = query.filter(model.biz_type == kind)
return apply_data_scope(query, model, user, db, tenant)
def filtered_query(db, tenant, user, kind, filters):
model, number, status = BUSINESS[kind]
q = business_query(db, tenant, user, kind)
if filters.get('projectId'):
q = q.filter(model.project_id == filters['projectId'])
if filters.get('teamId'):
q = q.filter(model.project_id.in_(scoped(db, Project, tenant).filter(Project.team_id == filters['teamId']).with_entities(Project.id)))
if filters.get('unassigned'):
q = q.filter(model.project_id.is_(None))
if filters.get('keyword'):
q = q.filter(getattr(model, number).contains(filters['keyword']))
for key, op in [('startDate', 'start'), ('endDate', 'end')]:
if filters.get(key):
try:
day = date.fromisoformat(str(filters[key]))
except ValueError:
raise HTTPException(400, '日期格式应为 YYYY-MM-DD')
q = q.filter(model.create_time >= datetime.combine(day, datetime.min.time())) if op == 'start' else q.filter(model.create_time < datetime.combine(day + timedelta(days=1), datetime.min.time()))
return q
def record_dict(db, tenant, kind, row):
return dict(id=row.id, bizType=kind, bizNo=getattr(row, BUSINESS[kind][1]), status=getattr(row, BUSINESS[kind][2]), createTime=row.create_time, **project_brief(db, tenant, row.project_id))
def records(db, tenant, user, biz_type, page=1, pageSize=20, **filters):
if biz_type not in BUSINESS:
raise HTTPException(400, '业务类型不支持')
model, _, status = BUSINESS[biz_type]
q = filtered_query(db, tenant, user, biz_type, filters)
status_column = getattr(model, status)
status_counts = {str(value or ''): count for value, count in
q.with_entities(status_column, func.count(model.id)).group_by(status_column).all()}
if filters.get('status'):
q = q.filter(getattr(model, status) == filters['status'])
total = q.count()
rows = q.order_by(model.create_time.desc(), model.id).offset((page-1)*pageSize).limit(pageSize).all()
return dict(list=[record_dict(db, tenant, biz_type, r) for r in rows], total=total, statusCounts=status_counts)
def statistics(db, tenant, user, groupBy='project', **filters):
if groupBy not in ('project', 'team'):
raise HTTPException(400, '分组方式不支持')
statistics_types = ('presign', *COUNT_TYPES)
totals = dict.fromkeys(statistics_types, 0)
unassigned = dict.fromkeys(statistics_types, 0)
counts = {}
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()
project_ids = {pid for values in counts.values() for pid, _ in values if pid}
metadata = {}
if project_ids:
rows = scoped(db, Project, tenant).outerjoin(ProjectTeam, and_(
ProjectTeam.id == Project.team_id, ProjectTeam.tenant_no == tenant,
ProjectTeam.del_flag == 0)).filter(Project.id.in_(project_ids)).with_entities(
Project.id, Project.project_name, Project.team_id, ProjectTeam.team_name).all()
metadata = {r.id: r for r in rows}
items = {}
for kind, values in counts.items():
for project_id, count in values:
totals[kind] += count
if not project_id:
unassigned[kind] += count
continue
info = metadata.get(project_id)
team_id = info.team_id if info else None
team_name = info.team_name if info else None
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(statistics_types, 0)))
item[kind] += count
return dict(items=list(items.values()), totals=totals, unassignedTotals=unassigned,
presignTotal=totals['presign'])
def linked_records(db, tenant, user, kind, row_id, lock=False, current_read=False):
"""构建双向来源链;无权关联记录导致整次操作拒绝。"""
def graph_query(model):
query = scoped(db, model, tenant).populate_existing()
return query.with_for_update() if current_read else query
seen, pending, rows = set(), [(kind, row_id)], []
while pending:
current, rid = pending.pop()
if (current, rid) in seen:
continue
seen.add((current, rid))
model = BUSINESS[current][0]
row = get_row(db, model, tenant, rid, lock=current_read)
if not business_query(db, tenant, user, current).filter(model.id == rid).first():
raise HTTPException(403, '关联记录不存在或无权访问')
rows.append((current, row))
if current == 'presign':
pending += [('cell_storage', r.id) for r in graph_query(BizCollectionProtocol).filter(BizCollectionProtocol.source_presign_order_id == rid).all()]
if row.converted_protocol_id:
pending.append(('cell_storage', row.converted_protocol_id))
if current == 'cell_storage':
if row.source_presign_order_id:
pending.append(('presign', row.source_presign_order_id))
pending += [('presign', r.id) for r in graph_query(BizPresignOrder).filter(BizPresignOrder.converted_protocol_id == rid).all()]
pending += [('research_donation', r.id) for r in graph_query(BizResearchDonation).filter(BizResearchDonation.source_protocol_id == rid).all()]
if current == 'research_donation' and row.source_protocol_id:
pending.append(('cell_storage', row.source_protocol_id))
if current in ('cell_storage', 'research_donation'):
col = BizProtocolDonationConvert.protocol_id if current == 'cell_storage' else BizProtocolDonationConvert.research_donation_id
for link in graph_query(BizProtocolDonationConvert).filter(col == rid).all():
pending.extend([('cell_storage', link.protocol_id), ('research_donation', link.research_donation_id)])
rows.sort(key=lambda pair: (pair[0], pair[1].id))
if lock:
rows = [(k, get_row(db, BUSINESS[k][0], tenant, r.id, lock=True)) for k, r in rows]
return rows
def preview_assignment(db, tenant, user, kind, row_id, project_id):
check_permission(db, tenant, user, 'project:assignment:edit')
rows = linked_records(db, tenant, user, kind, row_id)
for k, row in rows:
validate_assignment(db, tenant, user, project_id, k, required=True)
return {'records': [record_dict(db, tenant, k, r) for k, r in rows]}
def assign(db, tenant, user, kind, row_id, project_id, reason, expected):
check_permission(db, tenant, user, 'project:assignment:edit')
if not reason or not reason.strip():
raise HTTPException(400, '请填写调整原因')
rows = linked_records(db, tenant, user, kind, row_id, lock=True)
actual = {(k, r.id, r.project_id) for k, r in rows}
refreshed = linked_records(db, tenant, user, kind, row_id, current_read=True)
snapshot = {(r['bizType'], r['id'], r.get('projectId')) for r in expected}
if actual != snapshot or actual != {(k, r.id, r.project_id) for k, r in refreshed} or len(expected) != len(actual):
raise HTTPException(409, '关联记录或归属已变更,请重新预览')
for k, row in rows:
validate_assignment(db, tenant, user, project_id, k, required=True)
new_name = project_brief(db, tenant, project_id)['projectName']
operator = scoped(db, SysUser, tenant).filter(SysUser.id == user.get('user_id')).first()
for k, row in rows:
db.add(ProjectAssignmentAudit(id=generate_uuid(), tenant_no=tenant, biz_type=k, biz_id=row.id, old_project_id=row.project_id, new_project_id=project_id, old_project_name=project_brief(db, tenant, row.project_id)['projectName'], new_project_name=new_name, reason=reason.strip(), operator_name=operator.nick_name if operator else user.get('user_id'), create_by=user.get('user_id'), create_dept_id=user.get('dept_id') or ''))
row.project_id = project_id
row.update_by = user.get('user_id')
row.update_time = datetime.now()
db.commit()
return {'updated': len(rows)}
def assignment_history(db, tenant, user, kind, row_id):
model = BUSINESS.get(kind, (None,))[0]
if model is None or not business_query(db, tenant, user, kind).filter(model.id == row_id).first():
raise HTTPException(404, '记录不存在或无权访问')
rows = scoped(db, ProjectAssignmentAudit, tenant).filter(ProjectAssignmentAudit.biz_type == kind, ProjectAssignmentAudit.biz_id == row_id).order_by(ProjectAssignmentAudit.create_time.desc()).all()
return {'list': [dict(id=r.id, oldProjectName=r.old_project_name, newProjectName=r.new_project_name, reason=r.reason, operatorName=r.operator_name, createTime=r.create_time) for r in rows]}
CHILDREN = (
('members', ProjectTeamMember, 'userId', 'user_id', SysUser, 'nick_name'),
('regions', ProjectTeamRegion, 'regionId', 'region_id', BizCollectionRegion, 'region_name'),
('hospitals', ProjectTeamHospital, 'hospitalId', 'hospital_id', BizCollectionHospital, 'hospital_name'),
)
def serialize_entity(db, tenant, row):
leader = scoped(db, SysUser, tenant).filter(SysUser.id == row.leader_user_id).first()
result = dict(id=row.id, leaderUserId=row.leader_user_id, leaderName=leader.nick_name if leader else '', remark=row.remark, createTime=row.create_time)
if isinstance(row, ProjectTeam):
result.update(teamName=row.team_name, enabled=bool(row.enabled))
for key, model, public, attr, ref_model, name in CHILDREN:
children = scoped(db, model, tenant).filter(model.team_id == row.id).order_by(model.order_num).all()
result[key] = []
for child in children:
ref = scoped(db, ref_model, tenant).filter(ref_model.id == getattr(child, attr)).first()
result[key].append(dict(id=child.id, **{public: getattr(child, attr)}, name=getattr(ref, name) if ref else ''))
else:
result.update(projectNo=row.project_no, projectName=row.project_name, teamId=row.team_id, teamName=project_brief(db, tenant, row.id)['teamName'], bizTypes=row.biz_types.split(','), startDate=row.start_date, endDate=row.end_date, status=row.status)
return result
def list_entities(db, tenant, model, page=1, pageSize=20, **filters):
q = scoped(db, model, tenant)
name = model.team_name if model is ProjectTeam else model.project_name
if filters.get('keyword'):
q = q.filter(name.contains(filters['keyword']))
for public, attr in [('leaderUserId', 'leader_user_id'), ('teamId', 'team_id'), ('status', 'status'), ('enabled', 'enabled')]:
if filters.get(public) not in (None, '') and hasattr(model, attr):
q = q.filter(getattr(model, attr) == filters[public])
return dict(total=q.count(), list=[serialize_entity(db, tenant, r) for r in q.order_by(model.create_time.desc(), model.id).offset((page-1)*pageSize).limit(pageSize).all()])
def references(db, tenant):
return dict(users=[dict(id=r.id, name=r.nick_name) for r in scoped(db, SysUser, tenant).filter(SysUser.status == '1').all()], regions=[dict(id=r.id, name=r.region_name) for r in scoped(db, BizCollectionRegion, tenant).all()], hospitals=[dict(id=r.id, name=r.hospital_name, regionId=r.region_id) for r in scoped(db, BizCollectionHospital, tenant).all()])
def has_business(db, tenant, project_id):
# Deleted business is still a historical reference and prevents structural changes.
return any(db.query(model.id).filter(model.tenant_no == tenant, model.project_id == project_id).with_for_update().first() for model in {v[0] for v in BUSINESS.values()})
def save_entity(db, tenant, user, model, body, row_id=None):
row = get_row(db, model, tenant, row_id, lock=True) if row_id else model(id=generate_uuid(), tenant_no=tenant, create_by=user.get('user_id'), create_dept_id=user.get('dept_id') or '')
name_key = 'teamName' if model is ProjectTeam else 'projectName'
if not str(body.get(name_key) or '').strip():
raise HTTPException(400, '名称不能为空')
leader = get_row(db, SysUser, tenant, body.get('leaderUserId'))
if leader.status != '1':
raise HTTPException(400, '负责人已停用')
row.leader_user_id = leader.id
row.remark = body.get('remark')
row.update_by = user.get('user_id')
row.update_time = datetime.now()
if model is ProjectTeam:
row.team_name = body[name_key].strip()
row.enabled = int(bool(body.get('enabled', True)))
db.add(row)
for key, child_model, public, attr, ref_model, _ in CHILDREN:
children = body.get(key, [])
seen = set()
for child in children:
ref_id = child.get(public)
if ref_id in seen:
raise HTTPException(400, '关联资料不能重复')
seen.add(ref_id)
get_row(db, ref_model, tenant, ref_id)
if child.get('id'):
existing = db.query(child_model).filter(child_model.id == child['id']).first()
if existing and (existing.tenant_no != tenant or existing.team_id != row.id or existing.del_flag):
raise HTTPException(400, '子行不属于当前项目组')
try:
sync_child_rows(db, child_model, parent_attr='team_id', parent_id=row.id, tenant_no=tenant, user_id=user.get('user_id'), items=children,
build_row=lambda rid, cm=child_model: cm(id=rid, tenant_no=tenant, team_id=row.id, create_by=user.get('user_id'), create_dept_id=user.get('dept_id') or ''),
apply_fields=lambda child, item, index, now, a=attr, p=public: (setattr(child, a, item[p]), setattr(child, 'order_num', index)),
natural_key=lambda item, p=public: item[p], row_natural_key=lambda child, a=attr: getattr(child, a))
except ValueError as exc:
raise HTTPException(400, str(exc))
else:
team = get_row(db, ProjectTeam, tenant, body.get('teamId'), lock=True)
if row_id and row.team_id != team.id and has_business(db, tenant, row.id):
raise HTTPException(400, '项目已有业务,不可更换项目组')
kinds = body.get('bizTypes') or []
if not kinds or any(k not in COUNT_TYPES for k in kinds):
raise HTTPException(400, '请选择有效业务类型')
status = body.get('status', 'pending')
if status not in ('pending', 'active', 'completed', 'disabled'):
raise HTTPException(400, '项目状态无效')
row.project_name, row.team_id, row.biz_types, row.status = body[name_key].strip(), team.id, ','.join(dict.fromkeys(kinds)), status
if not row_id:
row.project_no = 'XM' + datetime.now().strftime('%Y%m%d') + generate_uuid().replace('-', '')[:16].upper()
try:
row.start_date = date.fromisoformat(body['startDate']) if body.get('startDate') else None
row.end_date = date.fromisoformat(body['endDate']) if body.get('endDate') else None
except ValueError:
raise HTTPException(400, '日期格式应为 YYYY-MM-DD')
if row.start_date and row.end_date and row.start_date > row.end_date:
raise HTTPException(400, '结束日期不能早于开始日期')
db.add(row)
db.commit()
return serialize_entity(db, tenant, row)
def delete_entity(db, tenant, user, model, row_id):
row = get_row(db, model, tenant, row_id, lock=True)
if model is Project and has_business(db, tenant, row.id):
raise HTTPException(400, '项目已被业务引用,请停用')
if model is ProjectTeam and db.query(Project.id).filter(Project.tenant_no == tenant, Project.team_id == row.id).with_for_update().first():
raise HTTPException(400, '项目组已被项目引用,请停用')
row.del_flag, row.update_by = 1, user.get('user_id')
if model is ProjectTeam:
for _, child_model, *_ in CHILDREN:
for child in scoped(db, child_model, tenant).filter(child_model.team_id == row.id).all():
child.del_flag, child.update_by = 1, user.get('user_id')
db.commit()
return {'id': row.id}
def apply_project_choice_scope(query, db, tenant, user):
"""Apply team membership or the explicit tenant-wide project-choice permission.
The caller joins the tenant-scoped, non-deleted ProjectTeam table. Business
data scope and project availability remain separate caller constraints.
"""
if has_permission(db, tenant, user, ALL_PROJECTS_PERMISSION):
return query
memberships = scoped(db, ProjectTeamMember, tenant).filter(
ProjectTeamMember.user_id == user.get('user_id')).with_entities(ProjectTeamMember.team_id)
return query.filter(or_(ProjectTeam.leader_user_id == user.get('user_id'),
Project.team_id.in_(memberships)))
def options(db, tenant, user, biz_type, keyword=None):
if biz_type not in BUSINESS:
raise HTTPException(400, '业务类型不支持')
effective_type = 'cell_storage' if biz_type == 'presign' else biz_type
q = scoped(db, Project, tenant).join(ProjectTeam, and_(
ProjectTeam.id == Project.team_id, ProjectTeam.tenant_no == tenant,
ProjectTeam.del_flag == 0, ProjectTeam.enabled == 1)).filter(Project.status == 'active')
q = apply_project_choice_scope(q, db, tenant, user)
if keyword:
q = q.filter(or_(Project.project_name.contains(keyword), Project.project_no.contains(keyword)))
rows = q.with_entities(Project.id, Project.project_name, Project.project_no,
Project.team_id, Project.biz_types, ProjectTeam.team_name).order_by(
Project.create_time.desc(), Project.id).all()
result = [dict(id=r.id, projectName=r.project_name, projectNo=r.project_no,
teamId=r.team_id, teamName=r.team_name, disabled=False)
for r in rows if effective_type in r.biz_types.split(',')]
from app.core.config import settings
return {'list': result, 'required': settings.PROJECT_ASSIGNMENT_REQUIRED}
def filter_options(db, tenant, user, biz_type, keyword=None, *, business_scope=None):
"""Intersect project-choice scope with visible history; include stopped projects."""
if biz_type not in BUSINESS:
raise HTTPException(400, '业务类型不支持')
model = BUSINESS[biz_type][0]
visible = business_scope if business_scope is not None else business_query(db, tenant, user, biz_type)
ids = visible.with_entities(model.project_id).filter(model.project_id.isnot(None)).distinct()
q = scoped(db, Project, tenant).join(ProjectTeam, and_(
ProjectTeam.id == Project.team_id, ProjectTeam.tenant_no == tenant,
ProjectTeam.del_flag == 0)).filter(Project.id.in_(ids))
q = apply_project_choice_scope(q, db, tenant, user)
if keyword:
q = q.filter(or_(Project.project_name.contains(keyword), Project.project_no.contains(keyword)))
rows = q.with_entities(Project.id, Project.project_name, Project.project_no,
Project.team_id, ProjectTeam.team_name).order_by(Project.create_time.desc(), Project.id).all()
return {'list': [dict(id=r.id, projectName=r.project_name, projectNo=r.project_no,
teamId=r.team_id, teamName=r.team_name, disabled=False) for r in rows],
'required': False}