fix(project): show sample codes in storage and donation assignments #20

Merged
cykj merged 1 commits from codex/issue-13-project-statistics into V2.0 2026-11-04 06:30:25 +00:00
6 changed files with 64 additions and 8 deletions

No files matched your search

@@ -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
@@ -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'], '')
@@ -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 和业务识别列。细胞存储/科研捐赠展示母亲姓名、电话、采集医院、市场专员及采集/捐赠日期;中试展示申请名称、渠道、服务订单名称、患者姓名/电话;基础实验展示课题、客户名称/联系人/电话。公共列保留序号、主单号、状态、项目组、所属项目、创建时间。多个有效子记录以稳定顺序去重连接展示,缺失值显示破折号,采集日期来自采集信息的样本采集时间。
@@ -2,6 +2,9 @@
### Requirement: 按业务识别历史归属单据
历史归属维护 SHALL 按细胞存储、科研捐赠、中试服务、基础实验展示四个 tab,并提供对应业务识别字段和关键词搜索;列表保持主单分页,右侧固定归属与审计操作。
#### Scenario: 存储和捐赠以样本编码识别
- **WHEN** 查看细胞存储或科研捐赠 tab
- **THEN** 展示样本编码列而非协议单号/捐赠单号列;多个有效编码在同一主单行去重展示,无编码显示破折号,支持按任一编码搜索;跨租户或已删除的样本不用于展示和匹配,分页和归属仍以主单为单位。
#### Scenario: 按联系人或业务信息定位
- **WHEN** 用户在某类业务输入姓名、电话、医院/专员、渠道/服务订单或课题/客户等该业务支持的关键词
- **THEN** 仅返回当前租户及数据权限内匹配的有效主单,展示对应业务信息,多条子记录不重复计数;删除和跨租户关联数据不用于匹配或展示。
+1
View File
@@ -32,6 +32,7 @@ export interface AssignmentRecord {
teamId: string | null
teamName: string | null
createTime: string | null
sampleCodes?: string
motherName?: string
motherPhone?: string
hospitalName?: string
@@ -22,12 +22,12 @@ const collectionColumns: AssignmentColumn[] = [
export const assignmentColumns: Record<AssignmentBizType, AssignmentColumn[]> = {
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<AssignmentBizType, AssignmentColumn[]> =
}
export const assignmentKeywordHints: Record<AssignmentBizType, string> = {
cell_storage: '协议单号 / 母亲姓名、电话 / 医院 / 市场专员',
research_donation: '捐赠单号 / 母亲姓名、电话 / 医院 / 市场专员',
cell_storage: '样本编码 / 母亲姓名、电话 / 医院 / 市场专员',
research_donation: '样本编码 / 母亲姓名、电话 / 医院 / 市场专员',
pilot_service: '申请单号、名称 / 渠道 / 服务订单 / 患者姓名、电话',
basic_experiment: '申请单号 / 课题 / 客户名称、联系人、电话'
}