From 6e682f8d18d4123b400aba7a1334f7616111542d Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Wed, 4 Nov 2026 14:27:57 +0800 Subject: [PATCH] fix(project): identify storage and donation assignments by sample codes Replace order number columns with batched sample codes and support sample keyword searches without duplicating parent records. Preserve tenant and soft-delete boundaries, existing assignment writes and preview identifiers. Related to issue #13. Independent static review completed; tests authored but not run under user self-test arrangement. --- .../services/project_assignment_records.py | 17 +++++++- .../tests/test_project_assignment_records.py | 41 ++++++++++++++++++- .../project-business-statistics/design.md | 2 + .../specs/project-management/spec.md | 3 ++ web-admin/src/api/project-management.ts | 1 + .../project-management/assignment-columns.ts | 8 ++-- 6 files changed, 64 insertions(+), 8 deletions(-) diff --git a/backend/app/services/project_assignment_records.py b/backend/app/services/project_assignment_records.py index 3f078912..446e1c6a 100644 --- a/backend/app/services/project_assignment_records.py +++ b/backend/app/services/project_assignment_records.py @@ -6,9 +6,11 @@ from sqlalchemy import and_, func, or_ from app.models.models import ( BizChannelServiceOrder, BizCollectionHospital, - BizPilotPatient, BizProtocolCollectionInfo, BizProtocolPartyCustomer, SysUser, + BizPilotPatient, BizProtocolCollectionInfo, BizProtocolPartyCustomer, BizProtocolSample, SysUser, +) +from app.models.research_donation import ( + BizResearchDonationCollection, BizResearchDonationParty, BizResearchDonationSample, ) -from app.models.research_donation import BizResearchDonationCollection, BizResearchDonationParty from app.models.project_management import Project, ProjectTeam from app.services import project_management as projects @@ -17,6 +19,10 @@ PARTY_MODELS = { 'cell_storage': (BizProtocolPartyCustomer, BizProtocolCollectionInfo, 'collection_protocol_id'), 'research_donation': (BizResearchDonationParty, BizResearchDonationCollection, 'research_donation_id'), } +SAMPLE_MODELS = { + 'cell_storage': BizProtocolSample, + 'research_donation': BizResearchDonationSample, +} def keyword_condition(db, tenant, kind, keyword): @@ -25,6 +31,11 @@ def keyword_condition(db, tenant, kind, keyword): conditions = [getattr(model, number).contains(keyword, autoescape=True)] if kind in PARTY_MODELS: party, collection, parent_key = PARTY_MODELS[kind] + sample = SAMPLE_MODELS[kind] + conditions.append(projects.scoped(db, sample, tenant).filter( + getattr(sample, parent_key) == model.id, + sample.sample_code.contains(keyword, autoescape=True), + ).exists()) conditions.append(projects.scoped(db, party, tenant).filter( getattr(party, parent_key) == model.id, or_(party.mother_name.contains(keyword, autoescape=True), @@ -98,6 +109,7 @@ def business_details(db, tenant, kind, rows): 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')) + samples = child_rows(db, tenant, SAMPLE_MODELS[kind], parent_key, ids, ('sample_code',)) 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] @@ -112,6 +124,7 @@ def business_details(db, tenant, kind, rows): for row in rows: info = collections[row.id] details = dict( + sampleCodes=joined_values(samples[row.id], 'sample_code'), 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 diff --git a/backend/tests/test_project_assignment_records.py b/backend/tests/test_project_assignment_records.py index 1b455b4d..86cc0cce 100644 --- a/backend/tests/test_project_assignment_records.py +++ b/backend/tests/test_project_assignment_records.py @@ -10,9 +10,11 @@ from app.models.base import Base from app.models.models import ( BizChannelServiceOrder, BizCollectionHospital, BizCollectionProtocol, BizPilotApplication, BizPilotPatient, BizProtocolCollectionInfo, - BizProtocolPartyCustomer, SysMenu, SysRole, SysRoleMenu, SysUser, SysUserRole, + BizProtocolPartyCustomer, BizProtocolSample, SysMenu, SysRole, SysRoleMenu, SysUser, SysUserRole, +) +from app.models.research_donation import ( + BizResearchDonation, BizResearchDonationParty, BizResearchDonationSample, ) -from app.models.research_donation import BizResearchDonation, BizResearchDonationParty from app.services import project_assignment_records as svc from app.services import project_management @@ -150,3 +152,38 @@ class ProjectAssignmentRecordsTest(unittest.TestCase): self.assertEqual(len(statements), small) finally: event.remove(self.engine, 'before_cursor_execute', count_statement) + + def test_sample_codes_keep_one_main_row_and_respect_tenant_and_soft_delete(self): + for kind, model, parent_key in ( + ('cell_storage', BizProtocolSample, 'collection_protocol_id'), + ('research_donation', BizResearchDonationSample, 'research_donation_id'), + ): + with self.subTest(kind=kind): + if kind == 'cell_storage': + parents = [BizCollectionProtocol(id=kind + suffix, tenant_no='t', protocol_no=suffix) + for suffix in ('one', 'empty')] + else: + parents = [BizResearchDonation(id=kind + suffix, tenant_no='t', donation_no=suffix) + for suffix in ('one', 'empty')] + self.db.add_all(parents) + for row_id, code, tenant, deleted in ( + ('1', 'SAMPLE-A', 't', 0), ('2', 'SAMPLE-B', 't', 0), + ('3', 'SAMPLE-A', 't', 0), ('4', 'FOREIGN-SAMPLE', 'foreign', 0), + ('5', 'DELETED-SAMPLE', 't', 1), ('6', '', 't', 0), ('7', None, 't', 0), + ): + self.db.add(model(id=kind + row_id, tenant_no=tenant, del_flag=deleted, + sample_code=code, create_time=datetime(2026, 11, 4), + **{parent_key: kind + 'one'})) + self.db.commit() + for keyword in ('SAMPLE-A', 'SAMPLE-B', 'SAMPLE-'): + result = self.records(kind, keyword=keyword, pageSize=1) + self.assertEqual(result['total'], 1) + self.assertEqual(sum(result['statusCounts'].values()), 1) + self.assertEqual(result['list'][0]['sampleCodes'], 'SAMPLE-A、SAMPLE-B') + self.assertEqual(result['list'][0]['id'], kind + 'one') + self.assertEqual(self.records(kind, keyword='SAMPLE-', page=2, pageSize=1)['list'], []) + for keyword in ('FOREIGN-SAMPLE', 'DELETED-SAMPLE'): + self.assertEqual(self.records(kind, keyword=keyword)['total'], 0) + rows = {row['id']: row for row in self.records(kind)['list']} + self.assertEqual(len(rows), 2) + self.assertEqual(rows[kind + 'empty']['sampleCodes'], '') diff --git a/openspec/changes/project-business-statistics/design.md b/openspec/changes/project-business-statistics/design.md index 446ddf45..f5fe40f2 100644 --- a/openspec/changes/project-business-statistics/design.md +++ b/openspec/changes/project-business-statistics/design.md @@ -2,6 +2,8 @@ ## 历史归属维护 v2(2026-11-04 已确认) +后续用户明确调整:细胞存储、科研捐赠 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`,操作列固定右侧,搜索右对齐。 `RecordList` 仅在 assignment 模式显示四类正式业务 tab 和业务识别列。细胞存储/科研捐赠展示母亲姓名、电话、采集医院、市场专员及采集/捐赠日期;中试展示申请名称、渠道、服务订单名称、患者姓名/电话;基础实验展示课题、客户名称/联系人/电话。公共列保留序号、主单号、状态、项目组、所属项目、创建时间。多个有效子记录以稳定顺序去重连接展示,缺失值显示破折号,采集日期来自采集信息的样本采集时间。 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 20ed1127..d0253aec 100644 --- a/openspec/changes/project-business-statistics/specs/project-management/spec.md +++ b/openspec/changes/project-business-statistics/specs/project-management/spec.md @@ -2,6 +2,9 @@ ### Requirement: 按业务识别历史归属单据 历史归属维护 SHALL 按细胞存储、科研捐赠、中试服务、基础实验展示四个 tab,并提供对应业务识别字段和关键词搜索;列表保持主单分页,右侧固定归属与审计操作。 +#### Scenario: 存储和捐赠以样本编码识别 +- **WHEN** 查看细胞存储或科研捐赠 tab +- **THEN** 展示样本编码列而非协议单号/捐赠单号列;多个有效编码在同一主单行去重展示,无编码显示破折号,支持按任一编码搜索;跨租户或已删除的样本不用于展示和匹配,分页和归属仍以主单为单位。 #### Scenario: 按联系人或业务信息定位 - **WHEN** 用户在某类业务输入姓名、电话、医院/专员、渠道/服务订单或课题/客户等该业务支持的关键词 - **THEN** 仅返回当前租户及数据权限内匹配的有效主单,展示对应业务信息,多条子记录不重复计数;删除和跨租户关联数据不用于匹配或展示。 diff --git a/web-admin/src/api/project-management.ts b/web-admin/src/api/project-management.ts index 1477300a..2104e036 100644 --- a/web-admin/src/api/project-management.ts +++ b/web-admin/src/api/project-management.ts @@ -32,6 +32,7 @@ export interface AssignmentRecord { teamId: string | null teamName: string | null createTime: string | null + sampleCodes?: string motherName?: string motherPhone?: string hospitalName?: string diff --git a/web-admin/src/views/project-management/assignment-columns.ts b/web-admin/src/views/project-management/assignment-columns.ts index 6c18b1bb..b443d9c2 100644 --- a/web-admin/src/views/project-management/assignment-columns.ts +++ b/web-admin/src/views/project-management/assignment-columns.ts @@ -22,12 +22,12 @@ const collectionColumns: AssignmentColumn[] = [ export const assignmentColumns: Record = { cell_storage: [ - { prop: 'bizNo', label: '协议单号', minWidth: 180 }, + { prop: 'sampleCodes', label: '样本编码', minWidth: 180 }, ...collectionColumns, { prop: 'collectionDate', label: '采集日期', minWidth: 140 } ], research_donation: [ - { prop: 'bizNo', label: '捐赠单号', minWidth: 180 }, + { prop: 'sampleCodes', label: '样本编码', minWidth: 180 }, ...collectionColumns, { prop: 'donationDate', label: '捐赠日期', minWidth: 140 } ], @@ -49,8 +49,8 @@ export const assignmentColumns: Record = } export const assignmentKeywordHints: Record = { - cell_storage: '协议单号 / 母亲姓名、电话 / 医院 / 市场专员', - research_donation: '捐赠单号 / 母亲姓名、电话 / 医院 / 市场专员', + cell_storage: '样本编码 / 母亲姓名、电话 / 医院 / 市场专员', + research_donation: '样本编码 / 母亲姓名、电话 / 医院 / 市场专员', pilot_service: '申请单号、名称 / 渠道 / 服务订单 / 患者姓名、电话', basic_experiment: '申请单号 / 课题 / 客户名称、联系人、电话' } -- 2.54.0