feat(project): add business tabs and identifying fields for assignment #19
No files matched your search
@@ -8,6 +8,7 @@ from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import success_response
|
||||
from app.services import project_management as svc
|
||||
from app.services import project_assignment_records
|
||||
|
||||
router = APIRouter(tags=['项目管理'])
|
||||
PREFIX = '/web/admin/project-management'
|
||||
@@ -163,6 +164,21 @@ def preview(body: PreviewBody, ctx=Depends(guard('project:assignment:edit')), db
|
||||
return success_response(svc.preview_assignment(db, *ctx, body.bizType, body.id, body.projectId))
|
||||
|
||||
|
||||
@router.get(PREFIX + '/assignments/records')
|
||||
def assignment_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:assignment:edit')), db: Session=Depends(get_db),
|
||||
):
|
||||
return success_response(project_assignment_records.records(
|
||||
db, *ctx, bizType, page, pageSize, teamId=teamId, projectId=projectId,
|
||||
unassigned=unassigned, status=status, keyword=keyword, startDate=startDate, endDate=endDate,
|
||||
))
|
||||
|
||||
|
||||
@router.post(PREFIX + '/assignments')
|
||||
def assign(body: AssignmentBody, ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db)):
|
||||
return success_response(svc.assign(db, *ctx, body.bizType, body.id, body.projectId, body.reason, [r.model_dump() for r in body.expectedAssignments]))
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""历史归属维护专用检索;统计明细不返回业务联系信息。"""
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, func, or_
|
||||
|
||||
from app.models.models import (
|
||||
BizChannelServiceOrder, BizCollectionHospital,
|
||||
BizPilotPatient, BizProtocolCollectionInfo, BizProtocolPartyCustomer, SysUser,
|
||||
)
|
||||
from app.models.research_donation import BizResearchDonationCollection, BizResearchDonationParty
|
||||
from app.models.project_management import Project, ProjectTeam
|
||||
from app.services import project_management as projects
|
||||
|
||||
|
||||
PARTY_MODELS = {
|
||||
'cell_storage': (BizProtocolPartyCustomer, BizProtocolCollectionInfo, 'collection_protocol_id'),
|
||||
'research_donation': (BizResearchDonationParty, BizResearchDonationCollection, 'research_donation_id'),
|
||||
}
|
||||
|
||||
|
||||
def keyword_condition(db, tenant, kind, keyword):
|
||||
"""用 EXISTS 检索子表,避免多条患者/采集信息放大主单计数。"""
|
||||
model, number, _ = projects.BUSINESS[kind]
|
||||
conditions = [getattr(model, number).contains(keyword, autoescape=True)]
|
||||
if kind in PARTY_MODELS:
|
||||
party, collection, parent_key = PARTY_MODELS[kind]
|
||||
conditions.append(projects.scoped(db, party, tenant).filter(
|
||||
getattr(party, parent_key) == model.id,
|
||||
or_(party.mother_name.contains(keyword, autoescape=True),
|
||||
party.mother_phone.contains(keyword, autoescape=True)),
|
||||
).exists())
|
||||
hospitals = projects.scoped(db, BizCollectionHospital, tenant).filter(
|
||||
BizCollectionHospital.hospital_name.contains(keyword, autoescape=True),
|
||||
).with_entities(BizCollectionHospital.id)
|
||||
sales_users = projects.scoped(db, SysUser, tenant).filter(
|
||||
SysUser.nick_name.contains(keyword, autoescape=True),
|
||||
).with_entities(SysUser.id)
|
||||
conditions.append(projects.scoped(db, collection, tenant).filter(
|
||||
getattr(collection, parent_key) == model.id,
|
||||
or_(collection.hospital_id.in_(hospitals), collection.sales_user_id.in_(sales_users)),
|
||||
).exists())
|
||||
elif kind == 'pilot_service':
|
||||
conditions.extend(column.contains(keyword, autoescape=True) for column in (
|
||||
model.application_name, model.channel_name,
|
||||
))
|
||||
conditions.append(projects.scoped(db, BizPilotPatient, tenant).filter(
|
||||
BizPilotPatient.pilot_application_id == model.id,
|
||||
or_(BizPilotPatient.patient_name.contains(keyword, autoescape=True),
|
||||
BizPilotPatient.phone_no.contains(keyword, autoescape=True)),
|
||||
).exists())
|
||||
conditions.append(projects.scoped(db, BizChannelServiceOrder, tenant).filter(
|
||||
BizChannelServiceOrder.id == model.channel_service_order_id,
|
||||
BizChannelServiceOrder.order_name.contains(keyword, autoescape=True),
|
||||
).exists())
|
||||
else:
|
||||
conditions.extend(column.contains(keyword, autoescape=True) for column in (
|
||||
model.project_title, model.customer_name, model.customer_contact, model.customer_phone,
|
||||
))
|
||||
return or_(*conditions)
|
||||
|
||||
|
||||
def child_rows(db, tenant, model, parent_key, ids, fields):
|
||||
"""仅批量读取当前页所需字段;稳定排序保留多条有效子记录。"""
|
||||
result = defaultdict(list)
|
||||
if ids:
|
||||
parent = getattr(model, parent_key)
|
||||
rows = projects.scoped(db, model, tenant).filter(parent.in_(ids)).with_entities(
|
||||
parent, *(getattr(model, field) for field in fields),
|
||||
).order_by(model.create_time, model.id).all()
|
||||
for row in rows:
|
||||
result[getattr(row, parent_key)].append(row)
|
||||
return result
|
||||
|
||||
|
||||
def joined_values(rows, field):
|
||||
return '、'.join(dict.fromkeys(str(getattr(row, field)) for row in rows if getattr(row, field)))
|
||||
|
||||
|
||||
def project_metadata(db, tenant, rows):
|
||||
ids = {row.project_id for row in rows if row.project_id}
|
||||
if not ids:
|
||||
return {}
|
||||
values = projects.scoped(db, Project, tenant).outerjoin(ProjectTeam, and_(
|
||||
ProjectTeam.id == Project.team_id, ProjectTeam.tenant_no == tenant, ProjectTeam.del_flag == 0,
|
||||
)).filter(Project.id.in_(ids)).with_entities(
|
||||
Project.id, Project.project_name, Project.team_id, ProjectTeam.team_name,
|
||||
).all()
|
||||
return {row.id: dict(projectName=row.project_name, teamId=row.team_id, teamName=row.team_name)
|
||||
for row in values}
|
||||
|
||||
|
||||
def business_details(db, tenant, kind, rows):
|
||||
ids = [row.id for row in rows]
|
||||
result = {row.id: {} for row in rows}
|
||||
if not ids:
|
||||
return result
|
||||
if kind in PARTY_MODELS:
|
||||
party, collection, parent_key = PARTY_MODELS[kind]
|
||||
parties = child_rows(db, tenant, party, parent_key, ids, ('mother_name', 'mother_phone'))
|
||||
collections = child_rows(db, tenant, collection, parent_key, ids,
|
||||
('hospital_id', 'sales_user_id', 'sample_collect_time'))
|
||||
all_collections = [item for values in collections.values() for item in values]
|
||||
hospital_ids = {item.hospital_id for item in all_collections if item.hospital_id}
|
||||
sales_ids = {item.sales_user_id for item in all_collections if item.sales_user_id}
|
||||
hospitals = dict(projects.scoped(db, BizCollectionHospital, tenant).filter(
|
||||
BizCollectionHospital.id.in_(hospital_ids),
|
||||
).with_entities(BizCollectionHospital.id, BizCollectionHospital.hospital_name).all()) if hospital_ids else {}
|
||||
sales_users = dict(projects.scoped(db, SysUser, tenant).filter(
|
||||
SysUser.id.in_(sales_ids),
|
||||
).with_entities(SysUser.id, SysUser.nick_name).all()) if sales_ids else {}
|
||||
for row in rows:
|
||||
info = collections[row.id]
|
||||
details = dict(
|
||||
motherName=joined_values(parties[row.id], 'mother_name'),
|
||||
motherPhone=joined_values(parties[row.id], 'mother_phone'),
|
||||
hospitalName='、'.join(dict.fromkeys(hospitals[item.hospital_id] for item in info
|
||||
if hospitals.get(item.hospital_id))),
|
||||
salesUserName='、'.join(dict.fromkeys(sales_users[item.sales_user_id] for item in info
|
||||
if sales_users.get(item.sales_user_id))),
|
||||
)
|
||||
if kind == 'cell_storage':
|
||||
details['collectionDate'] = '、'.join(dict.fromkeys(
|
||||
item.sample_collect_time.strftime('%Y-%m-%d') for item in info if item.sample_collect_time))
|
||||
else:
|
||||
value = row.donation_date or ''
|
||||
details['donationDate'] = f'{value[:4]}-{value[4:6]}-{value[6:]}' if len(value) == 8 else value
|
||||
result[row.id] = details
|
||||
elif kind == 'pilot_service':
|
||||
patients = child_rows(db, tenant, BizPilotPatient, 'pilot_application_id', ids,
|
||||
('patient_name', 'phone_no'))
|
||||
order_ids = {row.channel_service_order_id for row in rows if row.channel_service_order_id}
|
||||
orders = dict(projects.scoped(db, BizChannelServiceOrder, tenant).filter(
|
||||
BizChannelServiceOrder.id.in_(order_ids),
|
||||
).with_entities(BizChannelServiceOrder.id, BizChannelServiceOrder.order_name).all()) if order_ids else {}
|
||||
for row in rows:
|
||||
result[row.id] = dict(
|
||||
applicationName=row.application_name, channelName=row.channel_name,
|
||||
serviceOrderName=orders.get(row.channel_service_order_id),
|
||||
patientName=joined_values(patients[row.id], 'patient_name'),
|
||||
patientPhone=joined_values(patients[row.id], 'phone_no'),
|
||||
)
|
||||
else:
|
||||
for row in rows:
|
||||
result[row.id] = dict(projectTitle=row.project_title, customerName=row.customer_name,
|
||||
customerContact=row.customer_contact, customerPhone=row.customer_phone)
|
||||
return result
|
||||
|
||||
|
||||
def records(db, tenant, user, biz_type, page=1, pageSize=20, **filters):
|
||||
projects.check_permission(db, tenant, user, 'project:assignment:edit')
|
||||
if biz_type not in projects.COUNT_TYPES:
|
||||
raise HTTPException(400, '归属维护仅支持四类正式业务')
|
||||
model, number, status = projects.BUSINESS[biz_type]
|
||||
# 继续使用现有租户、主单数据范围、项目/项目组和日期条件。
|
||||
query = projects.filtered_query(db, tenant, user, biz_type, {**filters, 'keyword': None})
|
||||
keyword = (filters.get('keyword') or '').strip()
|
||||
if keyword:
|
||||
query = query.filter(keyword_condition(db, tenant, biz_type, keyword))
|
||||
status_column = getattr(model, status)
|
||||
status_counts = {str(value or ''): count for value, count in query.with_entities(
|
||||
status_column, func.count(model.id),
|
||||
).group_by(status_column).all()}
|
||||
if filters.get('status'):
|
||||
query = query.filter(status_column == filters['status'])
|
||||
total = query.count()
|
||||
rows = query.order_by(model.create_time.desc(), model.id).offset((page - 1) * pageSize).limit(pageSize).all()
|
||||
metadata = project_metadata(db, tenant, rows)
|
||||
details = business_details(db, tenant, biz_type, rows)
|
||||
return dict(list=[dict(
|
||||
id=row.id, bizType=biz_type, bizNo=getattr(row, number), status=getattr(row, status),
|
||||
createTime=row.create_time, projectId=row.project_id,
|
||||
**metadata.get(row.project_id, dict(projectName=None, teamId=None, teamName=None)),
|
||||
**details[row.id],
|
||||
) for row in rows], total=total, statusCounts=status_counts)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""归属维护业务检索的权限、分页及关联资料边界。"""
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.base import Base
|
||||
from app.models.models import (
|
||||
BizChannelServiceOrder, BizCollectionHospital, BizCollectionProtocol,
|
||||
BizPilotApplication, BizPilotPatient, BizProtocolCollectionInfo,
|
||||
BizProtocolPartyCustomer, SysMenu, SysRole, SysRoleMenu, SysUser, SysUserRole,
|
||||
)
|
||||
from app.models.research_donation import BizResearchDonation, BizResearchDonationParty
|
||||
from app.services import project_assignment_records as svc
|
||||
from app.services import project_management
|
||||
|
||||
|
||||
class ProjectAssignmentRecordsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine('sqlite:///:memory:')
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.db = Session(self.engine)
|
||||
self.user = dict(user_id='u', role='super_admin', tenant_no='t')
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def records(self, kind='cell_storage', **filters):
|
||||
return svc.records(self.db, 't', self.user, kind, **filters)
|
||||
|
||||
def grant(self, code):
|
||||
self.db.add_all([
|
||||
SysRole(id=code, tenant_no='t', role_name=code, data_scope='4'),
|
||||
SysMenu(id=code, tenant_no='t', menu_name=code, menu_type='F', perms=code),
|
||||
SysRoleMenu(id=code, tenant_no='t', role_id=code, menu_id=code),
|
||||
SysUserRole(id=code, tenant_no='t', role_id=code, user_id='u'),
|
||||
])
|
||||
self.db.commit()
|
||||
|
||||
def test_assignment_permission_and_parent_data_scope(self):
|
||||
self.user['role'] = 'admin'
|
||||
self.grant('project:statistics:list')
|
||||
with self.assertRaises(HTTPException) as denied:
|
||||
self.records()
|
||||
self.assertEqual(denied.exception.status_code, 403)
|
||||
self.grant('project:assignment:edit')
|
||||
self.grant('project:project:all')
|
||||
for row_id, tenant, owner, deleted in [
|
||||
('own', 't', 'u', 0), ('other', 't', 'other', 0),
|
||||
('foreign', 'foreign', 'u', 0), ('deleted', 't', 'u', 1),
|
||||
]:
|
||||
self.db.add(BizCollectionProtocol(id=row_id, tenant_no=tenant, protocol_no=row_id,
|
||||
create_by=owner, del_flag=deleted))
|
||||
self.db.commit()
|
||||
self.assertEqual([r['id'] for r in self.records()['list']], ['own'])
|
||||
|
||||
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'),
|
||||
BizCollectionProtocol(id='b', tenant_no='t', protocol_no='B', protocol_status='dsx'),
|
||||
BizProtocolPartyCustomer(id='p1', tenant_no='t', collection_protocol_id='a', mother_name='Alice', mother_phone='13800000001'),
|
||||
BizProtocolPartyCustomer(id='p2', tenant_no='t', collection_protocol_id='a', mother_name='Alice'),
|
||||
BizProtocolPartyCustomer(id='foreign', tenant_no='foreign', collection_protocol_id='a', mother_name='ForeignOnly'),
|
||||
BizProtocolPartyCustomer(id='deleted', tenant_no='t', collection_protocol_id='a', mother_name='DeletedOnly', del_flag=1),
|
||||
BizCollectionHospital(id='h', tenant_no='t', region_id='region', hospital_name='Central'),
|
||||
SysUser(id='sales', tenant_no='t', user_no='sales', nick_name='Salesperson'),
|
||||
BizProtocolCollectionInfo(id='c', tenant_no='t', collection_protocol_id='a', hospital_id='h', sales_user_id='sales', sample_collect_time=datetime(2026, 11, 4, 10)),
|
||||
])
|
||||
self.db.commit()
|
||||
for keyword in ('Alice', '13800000001', 'Central', 'Salesperson'):
|
||||
result = self.records(keyword=keyword, pageSize=1)
|
||||
self.assertEqual(result['total'], 1)
|
||||
self.assertEqual(result['statusCounts'], {'sxz': 1})
|
||||
self.assertEqual(result['list'][0]['motherName'], 'Alice')
|
||||
self.assertEqual(result['list'][0]['collectionDate'], '2026-11-04')
|
||||
for keyword in ('ForeignOnly', 'DeletedOnly', '%', '_'):
|
||||
self.assertEqual(self.records(keyword=keyword)['total'], 0)
|
||||
self.assertEqual(self.records(status='sxz')['statusCounts'], {'sxz': 1, 'dsx': 1})
|
||||
self.assertEqual(self.records(status='sxz')['total'], 1)
|
||||
self.assertEqual(self.records(keyword='Alice', page=2, pageSize=1)['list'], [])
|
||||
self.db.get(BizCollectionHospital, 'h').tenant_no = 'foreign'
|
||||
self.db.get(SysUser, 'sales').del_flag = 1
|
||||
self.db.commit()
|
||||
row = self.records(keyword='Alice')['list'][0]
|
||||
self.assertEqual(row['hospitalName'], '')
|
||||
self.assertEqual(row['salesUserName'], '')
|
||||
self.assertEqual(self.records(keyword='Central')['total'], 0)
|
||||
self.assertEqual(self.records(keyword='Salesperson')['total'], 0)
|
||||
|
||||
def test_donation_fields_and_statistics_contract(self):
|
||||
self.db.add_all([
|
||||
BizResearchDonation(id='d', tenant_no='t', donation_no='D1', donation_date='20261104'),
|
||||
BizResearchDonationParty(id='p', tenant_no='t', research_donation_id='d', mother_name='Donor', mother_phone='13900000000'),
|
||||
])
|
||||
self.db.commit()
|
||||
detail = self.records('research_donation', keyword='13900000000')['list'][0]
|
||||
self.assertEqual(detail['donationDate'], '2026-11-04')
|
||||
self.assertEqual(detail['motherName'], 'Donor')
|
||||
old = project_management.records(self.db, 't', self.user, 'research_donation')['list'][0]
|
||||
self.assertNotIn('motherName', old)
|
||||
self.assertNotIn('motherPhone', old)
|
||||
with self.assertRaises(HTTPException) as unsupported:
|
||||
self.records('presign')
|
||||
self.assertEqual(unsupported.exception.status_code, 400)
|
||||
|
||||
def test_pilot_and_basic_search_and_relation_boundaries(self):
|
||||
self.db.add_all([
|
||||
BizPilotApplication(id='pilot', tenant_no='t', application_no='P1', application_name='Trial', channel_name='Channel', channel_service_order_id='order', biz_type='pilot_service'),
|
||||
BizPilotApplication(id='basic', tenant_no='t', application_no='B1', channel_service_order_id='', biz_type='basic_experiment', project_title='Study', customer_name='Customer', customer_contact='Contact', customer_phone='13600000000'),
|
||||
BizChannelServiceOrder(id='order', tenant_no='t', order_name='Service', channel_id='channel', order_type='gm'),
|
||||
BizPilotPatient(id='p1', tenant_no='t', pilot_application_id='pilot', patient_name='Patient', phone_no='13700000000'),
|
||||
BizPilotPatient(id='p2', tenant_no='t', pilot_application_id='pilot', patient_name='Patient'),
|
||||
BizPilotPatient(id='foreign', tenant_no='foreign', pilot_application_id='pilot', patient_name='ForeignPatient'),
|
||||
BizPilotPatient(id='deleted', tenant_no='t', pilot_application_id='pilot', patient_name='DeletedPatient', del_flag=1),
|
||||
])
|
||||
self.db.commit()
|
||||
for keyword in ('P1', 'Trial', 'Channel', 'Service', 'Patient', '13700000000'):
|
||||
result = self.records('pilot_service', keyword=keyword)
|
||||
self.assertEqual(result['total'], 1)
|
||||
self.assertEqual(result['list'][0]['patientName'], 'Patient')
|
||||
self.assertEqual(result['list'][0]['serviceOrderName'], 'Service')
|
||||
for keyword in ('B1', 'Study', 'Customer', 'Contact', '13600000000'):
|
||||
self.assertEqual(self.records('basic_experiment', keyword=keyword)['total'], 1)
|
||||
self.assertEqual(self.records('pilot_service', keyword=keyword)['total'], 0)
|
||||
for keyword in ('ForeignPatient', 'DeletedPatient'):
|
||||
self.assertEqual(self.records('pilot_service', keyword=keyword)['total'], 0)
|
||||
self.db.get(BizChannelServiceOrder, 'order').tenant_no = 'foreign'
|
||||
self.db.commit()
|
||||
self.assertEqual(self.records('pilot_service', keyword='Service')['total'], 0)
|
||||
self.assertIsNone(self.records('pilot_service')['list'][0]['serviceOrderName'])
|
||||
|
||||
def test_batch_enrichment_does_not_query_per_row(self):
|
||||
for index in range(20):
|
||||
row_id = str(index)
|
||||
self.db.add(BizCollectionProtocol(id=row_id, tenant_no='t', protocol_no=row_id))
|
||||
self.db.add(BizProtocolPartyCustomer(id=row_id, tenant_no='t', collection_protocol_id=row_id, mother_name='Mother'))
|
||||
self.db.commit()
|
||||
statements = []
|
||||
def count_statement(conn, cursor, statement, parameters, context, executemany):
|
||||
statements.append(statement)
|
||||
event.listen(self.engine, 'before_cursor_execute', count_statement)
|
||||
try:
|
||||
self.records(pageSize=1)
|
||||
small = len(statements)
|
||||
statements.clear()
|
||||
self.assertEqual(len(self.records(pageSize=20)['list']), 20)
|
||||
self.assertEqual(len(statements), small)
|
||||
finally:
|
||||
event.remove(self.engine, 'before_cursor_execute', count_statement)
|
||||
@@ -1,5 +1,15 @@
|
||||
# 实现设计与接口契约
|
||||
|
||||
## 历史归属维护 v2(2026-11-04 已确认)
|
||||
|
||||
用户明确采用 [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`,操作列固定右侧,搜索右对齐。
|
||||
|
||||
`RecordList` 仅在 assignment 模式显示四类正式业务 tab 和业务识别列。细胞存储/科研捐赠展示母亲姓名、电话、采集医院、市场专员及采集/捐赠日期;中试展示申请名称、渠道、服务订单名称、患者姓名/电话;基础实验展示课题、客户名称/联系人/电话。公共列保留序号、主单号、状态、项目组、所属项目、创建时间。多个有效子记录以稳定顺序去重连接展示,缺失值显示破折号,采集日期来自采集信息的样本采集时间。
|
||||
|
||||
新增 `GET /web/admin/project-management/assignments/records`,严格要求 `project:assignment:edit`,仅接受四类正式业务。原 `/records` 继续服务统计明细,不扩展联系信息;移动接口不变。专用服务复用主单租户、软删除、数据权限和归属筛选。关键词支持单号、姓名/电话、医院/专员、渠道/服务订单名称、课题/客户信息(日期使用现有创建日期条件);子表使用同租户未删除记录 EXISTS 匹配,不增加主单行数。分页后批量补充项目、医院、人员、患者和服务订单资料,避免逐行查询。
|
||||
|
||||
切换 tab 清空关键词、状态、项目组/项目并回第一页,保留日期和仅未归属条件;重新挂载候选选择器避免短暂显示上一个业务的选项。列表请求按序号丢弃过时响应,提供加载、空态和失败重试。归属弹窗绑定选中单据的业务类型,沿用原项目选择权限、关联单据预览、并发快照及审计。预签约不单列 tab,但关联预签约仍随正式单预览和调整。不新增批量操作、数据库字段或迁移。
|
||||
|
||||
## 模块边界
|
||||
|
||||
新增 models/project_management.py、services/project_management.py、api/project_management.py。现有四种主表(预签约、采集协议、科研捐赠、中试/基础实验申请)增加可空 project_id,不向下游样本表复制归属。不更改已有课题名称 project_title。
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 按业务识别历史归属单据
|
||||
历史归属维护 SHALL 按细胞存储、科研捐赠、中试服务、基础实验展示四个 tab,并提供对应业务识别字段和关键词搜索;列表保持主单分页,右侧固定归属与审计操作。
|
||||
#### Scenario: 按联系人或业务信息定位
|
||||
- **WHEN** 用户在某类业务输入姓名、电话、医院/专员、渠道/服务订单或课题/客户等该业务支持的关键词
|
||||
- **THEN** 仅返回当前租户及数据权限内匹配的有效主单,展示对应业务信息,多条子记录不重复计数;删除和跨租户关联数据不用于匹配或展示。
|
||||
#### Scenario: 切换业务及请求竞争
|
||||
- **WHEN** 用户切换 tab 或旧请求在新请求后返回
|
||||
- **THEN** 切换时清空关键词、状态、项目组/项目并回到第一页,保留日期与仅未归属条件;过时响应不得覆盖当前业务的列表或计数,失败可重试且不保留旧列表。
|
||||
#### Scenario: 业务联系信息权限
|
||||
- **WHEN** 仅有统计权限的用户读取统计钻取或尝试调用归属维护详情接口
|
||||
- **THEN** 统计接口不返回新增联系信息,归属维护详情接口拒绝无 `project:assignment:edit` 权限的访问;全部项目选择权限不扩大主单数据范围。
|
||||
#### Scenario: 归属调整仍按关联链执行
|
||||
- **WHEN** 用户从某个业务 tab 调整归属
|
||||
- **THEN** 目标候选按该单据业务类型及项目权限加载,仍先预览关联单据再确认提交;关联预签约纳入预览和审计,不新增独立预签约 tab。
|
||||
|
||||
### Requirement: 项目管理三级导航
|
||||
Web SHALL 使用“项目 → 项目管理 → 项目组管理 / 项目管理 / 业务统计 / 历史归属维护”层级,复用现有导航组件;原页面权限、名称和功能保持。
|
||||
#### Scenario: 现有角色升级
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
**架构:** 新管理模块统一校验与统计;现有业务显式接入;前端复用已有样式。
|
||||
**技术栈:** FastAPI/SQLAlchemy/Pydantic、Vue3/Element Plus、Vue3/Vant。
|
||||
|
||||
## 2026-11-04 历史归属维护四业务 tab
|
||||
|
||||
用户已明确“采用原型 v2,继续开发”,继续按用户自测与先合入 V2.0 再测试的指令交付;本轮不运行测试、构建或浏览器业务验收,不关闭工单。
|
||||
- [x] 核对四类业务真实字段、现有样式和权限,完成 Figma v2 及用户确认。
|
||||
- [x] 新增归属维护专用读取接口:四类主单、业务关键词、同租户有效子表匹配和分页后批量资料读取;统计及移动契约不变。
|
||||
- [x] 实现四 tab、各业务识别列、关键词首位、状态语义、固定操作、筛选重置及过时响应防护。
|
||||
- [x] 编写 5 个后端回归用例供后续运行;独立源码审查未发现新增 P1/P2,Git 差异核对通过。本轮未执行测试/构建或浏览器验收。
|
||||
- [ ] 提交推送、PR 合入 V2.0 并同步主目录,回写待用户测试的提交、入口和覆盖点,保留工单及任务环境。
|
||||
|
||||
## 2026-11-04 菜单增加项目管理分组
|
||||
|
||||
用户明确指定层级:项目 → 项目管理 → 项目组管理 / 项目管理 / 业务统计 / 历史归属维护。仅重组现有菜单配置,复用市场三级导航,不新增业务页面或修改页面样式。沿用用户自测及先合入 V2.0 再测试的安排,工单保持开放。
|
||||
|
||||
@@ -21,6 +21,33 @@ export type ProjectOption = {
|
||||
disabled?: boolean
|
||||
}
|
||||
export type ProjectRecord = Record<string, any>
|
||||
export type AssignmentBizType = Exclude<BizType, 'presign'>
|
||||
export interface AssignmentRecord {
|
||||
id: string
|
||||
bizType: AssignmentBizType
|
||||
bizNo: string | null
|
||||
status: string
|
||||
projectId: string | null
|
||||
projectName: string | null
|
||||
teamId: string | null
|
||||
teamName: string | null
|
||||
createTime: string | null
|
||||
motherName?: string
|
||||
motherPhone?: string
|
||||
hospitalName?: string
|
||||
salesUserName?: string
|
||||
collectionDate?: string
|
||||
donationDate?: string
|
||||
applicationName?: string | null
|
||||
channelName?: string | null
|
||||
serviceOrderName?: string | null
|
||||
patientName?: string
|
||||
patientPhone?: string
|
||||
projectTitle?: string | null
|
||||
customerName?: string | null
|
||||
customerContact?: string | null
|
||||
customerPhone?: string | null
|
||||
}
|
||||
const base = '/web/admin/project-management'
|
||||
export const fetchProjectOptions = (params: {
|
||||
bizType?: BizType
|
||||
@@ -58,6 +85,11 @@ export const fetchProjectRecords = (params: Record<string, unknown>) =>
|
||||
url: `${base}/records`,
|
||||
params
|
||||
})
|
||||
export const fetchAssignmentRecords = (params: Record<string, unknown>) =>
|
||||
request.get<{ list: AssignmentRecord[]; total: number; statusCounts: Record<string, number> }>({
|
||||
url: `${base}/assignments/records`,
|
||||
params
|
||||
})
|
||||
export const previewAssignment = (data: ProjectRecord) =>
|
||||
request.post<{ records: ProjectRecord[] }>({ url: `${base}/assignments/preview`, data })
|
||||
export const saveAssignment = (data: ProjectRecord) =>
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div v-if="assignment" class="panel-pill-tabs panel-pill-tabs--lg">
|
||||
<div class="panel-pill-tabs__track" role="tablist" aria-label="业务类型">
|
||||
<button
|
||||
v-for="item in assignmentTabs"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="panel-pill-tabs__btn"
|
||||
:class="{ 'is-active': query.bizType === item.key }"
|
||||
:aria-selected="query.bizType === item.key"
|
||||
@click="switchBusiness(item.key)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-block">
|
||||
<el-form class="filter-form" label-position="top" @submit.prevent="search"
|
||||
><div class="filter-grid">
|
||||
<el-form-item label="业务类型"
|
||||
><el-select v-model="query.bizType" @change="search"
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="query.keyword" :placeholder="keywordHint" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!assignment" label="业务类型"
|
||||
><el-select v-model="query.bizType" @change="resetBusinessFilters"
|
||||
><el-option
|
||||
v-for="(label, key) in bizLabels"
|
||||
:key="key"
|
||||
@@ -12,12 +31,20 @@
|
||||
:label="label" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="项目组"
|
||||
><ProjectSelect filter team v-model="query.teamId" :biz-type="query.bizType"
|
||||
><ProjectSelect
|
||||
:key="`team-${query.bizType}`"
|
||||
filter
|
||||
team
|
||||
v-model="query.teamId"
|
||||
:biz-type="query.bizType"
|
||||
/></el-form-item>
|
||||
<el-form-item label="项目"
|
||||
><ProjectSelect filter v-model="query.projectId" :biz-type="query.bizType"
|
||||
><ProjectSelect
|
||||
:key="`project-${query.bizType}`"
|
||||
filter
|
||||
v-model="query.projectId"
|
||||
:biz-type="query.bizType"
|
||||
/></el-form-item>
|
||||
<el-form-item label="关键词"><el-input v-model="query.keyword" clearable /></el-form-item>
|
||||
<el-form-item label="当前状态"
|
||||
><el-select v-model="query.status" clearable
|
||||
><el-option
|
||||
@@ -46,8 +73,20 @@
|
||||
></el-form
|
||||
>
|
||||
</div>
|
||||
<el-alert v-if="loadError" type="error" :closable="false" title="列表加载失败,请重试">
|
||||
<el-button link type="primary" @click="load">
|
||||
<el-icon><RefreshRight /></el-icon>重试
|
||||
</el-button>
|
||||
</el-alert>
|
||||
<div class="list-body list-wrap"
|
||||
><el-table :data="rows" v-loading="loading" class="list-table" stripe>
|
||||
><el-table
|
||||
:key="query.bizType"
|
||||
:data="rows"
|
||||
v-loading="loading"
|
||||
class="list-table"
|
||||
stripe
|
||||
:empty-text="loading ? '正在加载…' : loadError ? '数据加载失败' : '暂无符合条件的单据'"
|
||||
>
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
@@ -56,18 +95,29 @@
|
||||
header-align="center"
|
||||
:index="(index) => (query.page - 1) * query.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="bizNo" label="单据编号" min-width="180" />
|
||||
<el-table-column
|
||||
v-for="column in visibleColumns"
|
||||
:key="column.prop"
|
||||
:prop="column.prop"
|
||||
:label="column.label"
|
||||
:min-width="column.minWidth"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ formatDisplayOrDash(row[column.prop]) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="110"
|
||||
><template #default="{ row }"
|
||||
><el-tag :type="statusTagType(row.status)" size="small">{{
|
||||
statusLabel(row.status)
|
||||
}}</el-tag></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column prop="teamName" label="项目组" min-width="140" />
|
||||
<el-table-column prop="projectName" label="项目" min-width="160"
|
||||
<el-table-column prop="projectName" label="所属项目" min-width="160"
|
||||
><template #default="{ row }">{{
|
||||
row.projectName || '未归属'
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="状态" min-width="110"
|
||||
><template #default="{ row }"
|
||||
><el-tag type="info">{{ statusLabel(row.status) }}</el-tag></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
@@ -83,11 +133,12 @@
|
||||
> </el-table
|
||||
><div class="pagination list-pager-bar"
|
||||
><el-pagination
|
||||
v-model:current-page="query.page"
|
||||
:current-page="query.page"
|
||||
:page-size="query.pageSize"
|
||||
:disabled="loading"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="load" /></div
|
||||
@current-change="changePage" /></div
|
||||
></div>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
@@ -102,7 +153,8 @@
|
||||
><el-form-item label="目标项目" required
|
||||
><ProjectSelect
|
||||
v-model="target"
|
||||
:biz-type="query.bizType"
|
||||
:key="selected.id"
|
||||
:biz-type="selected.bizType"
|
||||
@update:model-value="previewRows = []" /></el-form-item
|
||||
><el-form-item label="调整原因" required
|
||||
><el-input v-model="reason" type="textarea" :maxlength="500" /></el-form-item
|
||||
@@ -175,18 +227,25 @@
|
||||
protocolStatusLabel,
|
||||
donationStatusLabel,
|
||||
presignOrderStatusLabel,
|
||||
applicationStatusLabel
|
||||
applicationStatusLabel,
|
||||
protocolStatusTagType,
|
||||
donationStatusTagType,
|
||||
presignOrderStatusTagType,
|
||||
applicationStatusTagType
|
||||
} from '@/utils/status-tag-registry'
|
||||
import { Search, Edit, Clock, Close, View, Check } from '@element-plus/icons-vue'
|
||||
import { Search, Edit, Clock, Close, View, Check, RefreshRight } from '@element-plus/icons-vue'
|
||||
import { assignmentTabs, assignmentColumns, assignmentKeywordHints } from './assignment-columns'
|
||||
import ProjectSelect from '@/components/business/ProjectSelect.vue'
|
||||
import { assignmentSnapshot } from '@/utils/project-management'
|
||||
import {
|
||||
bizLabels,
|
||||
fetchProjectRecords,
|
||||
fetchAssignmentRecords,
|
||||
previewAssignment,
|
||||
saveAssignment,
|
||||
fetchAssignmentHistory,
|
||||
type BizType,
|
||||
type AssignmentBizType,
|
||||
type ProjectRecord
|
||||
} from '@/api/project-management'
|
||||
const props = defineProps<{ assignment?: boolean; filters?: Record<string, any> }>()
|
||||
@@ -205,6 +264,7 @@
|
||||
total = ref(0),
|
||||
statusCounts = ref<Record<string, number>>({}),
|
||||
loading = ref(false),
|
||||
loadError = ref(false),
|
||||
visible = ref(false),
|
||||
busy = ref(false),
|
||||
target = ref(''),
|
||||
@@ -213,6 +273,15 @@
|
||||
previewRows = ref<ProjectRecord[]>([]),
|
||||
historyVisible = ref(false),
|
||||
historyRows = ref<ProjectRecord[]>([])
|
||||
const visibleColumns = computed(() =>
|
||||
props.assignment
|
||||
? assignmentColumns[query.bizType as AssignmentBizType]
|
||||
: [{ prop: 'bizNo', label: '单据编号', minWidth: 180 }]
|
||||
)
|
||||
const keywordHint = computed(() =>
|
||||
props.assignment ? assignmentKeywordHints[query.bizType as AssignmentBizType] : '请输入单据编号'
|
||||
)
|
||||
let loadVersion = 0
|
||||
function statusLabel(status: string) {
|
||||
return query.bizType === 'presign'
|
||||
? presignOrderStatusLabel(status)
|
||||
@@ -222,27 +291,69 @@
|
||||
? donationStatusLabel(status)
|
||||
: applicationStatusLabel(status)
|
||||
}
|
||||
function statusTagType(status: string) {
|
||||
return query.bizType === 'presign'
|
||||
? presignOrderStatusTagType(status)
|
||||
: query.bizType === 'cell_storage'
|
||||
? protocolStatusTagType(status)
|
||||
: query.bizType === 'research_donation'
|
||||
? donationStatusTagType(status)
|
||||
: applicationStatusTagType(status)
|
||||
}
|
||||
async function load() {
|
||||
const version = ++loadVersion
|
||||
loading.value = true
|
||||
loadError.value = false
|
||||
rows.value = []
|
||||
try {
|
||||
const result = await fetchProjectRecords({
|
||||
const fetchRecords = props.assignment ? fetchAssignmentRecords : fetchProjectRecords
|
||||
const result = await fetchRecords({
|
||||
...props.filters,
|
||||
...query,
|
||||
teamId: query.teamId || undefined,
|
||||
startDate: dateRange.value?.[0],
|
||||
endDate: dateRange.value?.[1]
|
||||
})
|
||||
if (version !== loadVersion) return
|
||||
rows.value = result.list
|
||||
total.value = result.total
|
||||
statusCounts.value = result.statusCounts || {}
|
||||
} catch {
|
||||
if (version === loadVersion) {
|
||||
loadError.value = true
|
||||
total.value = 0
|
||||
statusCounts.value = {}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (version === loadVersion) loading.value = false
|
||||
}
|
||||
}
|
||||
function resetBusinessFilters() {
|
||||
query.page = 1
|
||||
total.value = 0
|
||||
query.status = ''
|
||||
query.teamId = ''
|
||||
query.projectId = ''
|
||||
query.keyword = ''
|
||||
statusCounts.value = {}
|
||||
visible.value = false
|
||||
historyVisible.value = false
|
||||
search()
|
||||
}
|
||||
function switchBusiness(kind: AssignmentBizType) {
|
||||
if (query.bizType === kind) return
|
||||
query.bizType = kind
|
||||
resetBusinessFilters()
|
||||
}
|
||||
function search() {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
function changePage(page: number) {
|
||||
if (loading.value || loadError.value || page === query.page) return
|
||||
query.page = page
|
||||
load()
|
||||
}
|
||||
function open(row: ProjectRecord) {
|
||||
selected.value = row
|
||||
target.value = ''
|
||||
@@ -257,13 +368,16 @@
|
||||
}
|
||||
busy.value = true
|
||||
const previewTarget = target.value
|
||||
const previewRecord = selected.value
|
||||
try {
|
||||
const result = await previewAssignment({
|
||||
bizType: selected.value.bizType,
|
||||
id: selected.value.id,
|
||||
projectId: target.value
|
||||
})
|
||||
previewRows.value = target.value === previewTarget ? result.records : []
|
||||
if (selected.value === previewRecord && target.value === previewTarget && visible.value) {
|
||||
previewRows.value = result.records
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
@@ -288,7 +402,9 @@
|
||||
}
|
||||
}
|
||||
async function history(row: ProjectRecord) {
|
||||
const version = loadVersion
|
||||
const result = await fetchAssignmentHistory({ bizType: row.bizType, id: row.id })
|
||||
if (version !== loadVersion) return
|
||||
historyRows.value = result.list
|
||||
historyVisible.value = true
|
||||
}
|
||||
@@ -301,11 +417,19 @@
|
||||
: []
|
||||
Object.assign(
|
||||
query,
|
||||
{ bizType: 'cell_storage', projectId: '', teamId: '', unassigned: false },
|
||||
{
|
||||
bizType: 'cell_storage',
|
||||
projectId: '',
|
||||
teamId: '',
|
||||
keyword: '',
|
||||
status: '',
|
||||
unassigned: false
|
||||
},
|
||||
props.filters || {}
|
||||
)
|
||||
search()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
onBeforeUnmount(() => loadVersion++)
|
||||
</script>
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AssignmentBizType, AssignmentRecord } from '@/api/project-management'
|
||||
|
||||
interface AssignmentColumn {
|
||||
prop: keyof AssignmentRecord
|
||||
label: string
|
||||
minWidth: number
|
||||
}
|
||||
|
||||
export const assignmentTabs: { key: AssignmentBizType; title: string }[] = [
|
||||
{ key: 'cell_storage', title: '细胞存储' },
|
||||
{ key: 'research_donation', title: '科研捐赠' },
|
||||
{ key: 'pilot_service', title: '中试服务' },
|
||||
{ key: 'basic_experiment', title: '基础实验' }
|
||||
]
|
||||
|
||||
const collectionColumns: AssignmentColumn[] = [
|
||||
{ prop: 'motherName', label: '母亲姓名', minWidth: 120 },
|
||||
{ prop: 'motherPhone', label: '母亲电话', minWidth: 150 },
|
||||
{ prop: 'hospitalName', label: '采集医院', minWidth: 180 },
|
||||
{ prop: 'salesUserName', label: '市场专员', minWidth: 120 }
|
||||
]
|
||||
|
||||
export const assignmentColumns: Record<AssignmentBizType, AssignmentColumn[]> = {
|
||||
cell_storage: [
|
||||
{ prop: 'bizNo', label: '协议单号', minWidth: 180 },
|
||||
...collectionColumns,
|
||||
{ prop: 'collectionDate', label: '采集日期', minWidth: 140 }
|
||||
],
|
||||
research_donation: [
|
||||
{ prop: 'bizNo', label: '捐赠单号', minWidth: 180 },
|
||||
...collectionColumns,
|
||||
{ prop: 'donationDate', label: '捐赠日期', minWidth: 140 }
|
||||
],
|
||||
pilot_service: [
|
||||
{ prop: 'bizNo', label: '申请单号', minWidth: 180 },
|
||||
{ prop: 'applicationName', label: '申请名称', minWidth: 180 },
|
||||
{ prop: 'channelName', label: '渠道名称', minWidth: 160 },
|
||||
{ prop: 'serviceOrderName', label: '服务订单名称', minWidth: 180 },
|
||||
{ prop: 'patientName', label: '患者姓名', minWidth: 120 },
|
||||
{ prop: 'patientPhone', label: '患者电话', minWidth: 150 }
|
||||
],
|
||||
basic_experiment: [
|
||||
{ prop: 'bizNo', label: '申请单号', minWidth: 180 },
|
||||
{ prop: 'projectTitle', label: '课题名称', minWidth: 200 },
|
||||
{ prop: 'customerName', label: '客户名称', minWidth: 180 },
|
||||
{ prop: 'customerContact', label: '客户联系人', minWidth: 140 },
|
||||
{ prop: 'customerPhone', label: '联系电话', minWidth: 150 }
|
||||
]
|
||||
}
|
||||
|
||||
export const assignmentKeywordHints: Record<AssignmentBizType, string> = {
|
||||
cell_storage: '协议单号 / 母亲姓名、电话 / 医院 / 市场专员',
|
||||
research_donation: '捐赠单号 / 母亲姓名、电话 / 医院 / 市场专员',
|
||||
pilot_service: '申请单号、名称 / 渠道 / 服务订单 / 患者姓名、电话',
|
||||
basic_experiment: '申请单号 / 课题 / 客户名称、联系人、电话'
|
||||
}
|
||||
Reference in new issue
Block a user