From 29f9866a2444bcb852b3cb8b0ed8d197f24971bf Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Wed, 4 Nov 2026 14:14:50 +0800 Subject: [PATCH] feat(project): group assignment records by business type Add four business tabs and identifying fields so users can locate orders before assigning projects. Introduce a permission-scoped detail query with child EXISTS searches and batched enrichment, preserving statistics and assignment writes. Approved Figma v2; independent source review completed. Tests authored but not executed under the user self-test arrangement. Related to issue #13. --- backend/app/api/project_management.py | 16 ++ .../services/project_assignment_records.py | 175 ++++++++++++++++++ .../tests/test_project_assignment_records.py | 152 +++++++++++++++ .../project-business-statistics/design.md | 10 + .../specs/project-management/spec.md | 15 ++ .../project-business-statistics/tasks.md | 9 + web-admin/src/api/project-management.ts | 32 ++++ .../views/project-management/RecordList.vue | 168 ++++++++++++++--- .../project-management/assignment-columns.ts | 56 ++++++ 9 files changed, 611 insertions(+), 22 deletions(-) create mode 100644 backend/app/services/project_assignment_records.py create mode 100644 backend/tests/test_project_assignment_records.py create mode 100644 web-admin/src/views/project-management/assignment-columns.ts diff --git a/backend/app/api/project_management.py b/backend/app/api/project_management.py index cd4a32eb..7ea165e5 100644 --- a/backend/app/api/project_management.py +++ b/backend/app/api/project_management.py @@ -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])) diff --git a/backend/app/services/project_assignment_records.py b/backend/app/services/project_assignment_records.py new file mode 100644 index 00000000..3f078912 --- /dev/null +++ b/backend/app/services/project_assignment_records.py @@ -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) diff --git a/backend/tests/test_project_assignment_records.py b/backend/tests/test_project_assignment_records.py new file mode 100644 index 00000000..1b455b4d --- /dev/null +++ b/backend/tests/test_project_assignment_records.py @@ -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) diff --git a/openspec/changes/project-business-statistics/design.md b/openspec/changes/project-business-statistics/design.md index 20f2e69d..446ddf45 100644 --- a/openspec/changes/project-business-statistics/design.md +++ b/openspec/changes/project-business-statistics/design.md @@ -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。 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 246fdd09..20ed1127 100644 --- a/openspec/changes/project-business-statistics/specs/project-management/spec.md +++ b/openspec/changes/project-business-statistics/specs/project-management/spec.md @@ -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: 现有角色升级 diff --git a/openspec/changes/project-business-statistics/tasks.md b/openspec/changes/project-business-statistics/tasks.md index 90b95c5d..9d1eeb1c 100644 --- a/openspec/changes/project-business-statistics/tasks.md +++ b/openspec/changes/project-business-statistics/tasks.md @@ -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 再测试的安排,工单保持开放。 diff --git a/web-admin/src/api/project-management.ts b/web-admin/src/api/project-management.ts index 9e992676..1477300a 100644 --- a/web-admin/src/api/project-management.ts +++ b/web-admin/src/api/project-management.ts @@ -21,6 +21,33 @@ export type ProjectOption = { disabled?: boolean } export type ProjectRecord = Record +export type AssignmentBizType = Exclude +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) => url: `${base}/records`, params }) +export const fetchAssignmentRecords = (params: Record) => + request.get<{ list: AssignmentRecord[]; total: number; statusCounts: Record }>({ + url: `${base}/assignments/records`, + params + }) export const previewAssignment = (data: ProjectRecord) => request.post<{ records: ProjectRecord[] }>({ url: `${base}/assignments/preview`, data }) export const saveAssignment = (data: ProjectRecord) => diff --git a/web-admin/src/views/project-management/RecordList.vue b/web-admin/src/views/project-management/RecordList.vue index 1c4b25e3..a9dc433a 100644 --- a/web-admin/src/views/project-management/RecordList.vue +++ b/web-admin/src/views/project-management/RecordList.vue @@ -1,10 +1,29 @@