From 5698a9dfc40a70da93d0bb5d216c6c473d987d35 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Thu, 12 Nov 2026 15:41:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(lab-sample):=20=E5=8F=B0=E8=B4=A6=E6=94=B9?= =?UTF-8?q?=E8=81=9A=E5=90=88=E7=9C=9F=E5=AE=9E=E5=B8=82=E5=9C=BA=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E8=B4=A8=E6=A3=80=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 去掉 Excel 演示独有行,源表字段优先于补充表;按协议样本与科研捐献接收关联已发生业务数据,并修复导出分页超限。 Co-authored-by: Cursor --- backend/app/services/lab_sample_processing.py | 138 ++++++++++++++---- backend/tests/test_lab_sample_processing.py | 54 +++++++ ...ab_sample_processing_retire_excel_demo.sql | 14 ++ .../LabSampleProcessingPage.vue | 31 ++-- 4 files changed, 197 insertions(+), 40 deletions(-) create mode 100644 docs/sql/20261112_lab_sample_processing_retire_excel_demo.sql diff --git a/backend/app/services/lab_sample_processing.py b/backend/app/services/lab_sample_processing.py index d058d3c4..fccd5fca 100644 --- a/backend/app/services/lab_sample_processing.py +++ b/backend/app/services/lab_sample_processing.py @@ -1,4 +1,8 @@ -"""实验室样本处理数据 — 聚合读取、Excel 导入与部门字段回写。""" +"""实验室样本处理数据 — 聚合市场/生产/质检已发生业务数据与部门字段回写。 + +Excel 仅作为字段目录示例;列表以协议样本为主表,关联样本接收与生产接收, +补充表只承载源系统没有的列,且不得覆盖源表字段。 +""" from __future__ import annotations import json @@ -7,7 +11,9 @@ from datetime import datetime from decimal import Decimal from typing import Any, Dict, List, Optional, Tuple -from sqlalchemy import or_ +from types import SimpleNamespace + +from sqlalchemy import or_, text from sqlalchemy.orm import Session from app.models.models import ( @@ -215,23 +221,39 @@ def list_rows( .all() } - codes = list({*(s.sample_code for s, *_ in filtered if s.sample_code), *extras.keys()}) - receptions: Dict[str, BizSampleReception] = {} + # 仅聚合真实协议样本;补充表只挂到已有样本上,不再单独展示 Excel 演示行 + codes = [s.sample_code for s, *_ in filtered if s.sample_code] + sample_ids = [s.id for s, *_ in filtered if s.id] + receptions_by_sample_id: Dict[str, BizSampleReception] = {} + receptions_by_code: Dict[str, BizSampleReception] = {} productions: Dict[str, BizSampleProductionReception] = {} - if codes: + if sample_ids or codes: + recv_filters = [] + if sample_ids: + recv_filters.append(BizSampleReception.protocol_sample_id.in_(sample_ids)) + if codes: + recv_filters.append(BizSampleReception.sample_code.in_(codes)) for r in ( db.query(BizSampleReception) .filter( BizSampleReception.tenant_no == tenant_no, BizSampleReception.del_flag == 0, - BizSampleReception.sample_code.in_(codes), + or_(*recv_filters) if recv_filters else False, ) .order_by(BizSampleReception.reception_time.desc()) .all() ): - receptions.setdefault(r.sample_code, r) - if receptions: - rids = [r.id for r in receptions.values()] + if r.protocol_sample_id: + receptions_by_sample_id.setdefault(r.protocol_sample_id, r) + if r.sample_code: + receptions_by_code.setdefault(r.sample_code, r) + if receptions_by_sample_id or receptions_by_code: + rids = list( + { + *(r.id for r in receptions_by_sample_id.values()), + *(r.id for r in receptions_by_code.values()), + } + ) for p in ( db.query(BizSampleProductionReception) .filter( @@ -247,7 +269,7 @@ def list_rows( built_map: Dict[str, Dict[str, Any]] = {} for sample, stype, party, coll, hospital in filtered: code = sample.sample_code - reception = receptions.get(code) + reception = receptions_by_sample_id.get(sample.id) or receptions_by_code.get(code) production = productions.get(reception.id) if reception else None extra = _parse_extra(extras[code].payload_json if code in extras else None) built_map[code] = _build_row( @@ -262,24 +284,81 @@ def list_rows( extra=extra, ) - # Excel/补充表独有样本也要展示 - for code, extra_row in extras.items(): - if code in built_map: - continue - extra = _parse_extra(extra_row.payload_json) - reception = receptions.get(code) - production = productions.get(reception.id) if reception else None - built_map[code] = _build_row( - dataset=dataset, - sample=None, - type_name=None, - party=None, - coll=None, - hospital=None, - reception=reception, - production=production, - extra=extra, + # 单个核:业务已发生数据主要在科研捐献接收(lab_storage_code=UBMC*),无细胞储存协议样本时仍需展示 + if dataset == DATASET_UBMC: + ubmc_recs = ( + db.query(BizSampleReception) + .filter( + BizSampleReception.tenant_no == tenant_no, + BizSampleReception.del_flag == 0, + BizSampleReception.lab_storage_code.like("UBMC%"), + ) + .order_by(BizSampleReception.reception_time.desc()) + .all() ) + mother_by_donation: Dict[str, str] = {} + donation_ids = sorted( + {r.research_donation_id for r in ubmc_recs if r.research_donation_id} + ) + if donation_ids: + rows = db.execute( + text( + "SELECT research_donation_id, mother_name FROM biz_research_donation_party " + "WHERE del_flag=0 AND tenant_no=:tenant_no AND research_donation_id IN (" + + ",".join(f":d{i}" for i in range(len(donation_ids))) + + ")" + ), + { + "tenant_no": tenant_no, + **{f"d{i}": did for i, did in enumerate(donation_ids)}, + }, + ).fetchall() + for did, mother in rows: + if did and mother and did not in mother_by_donation: + mother_by_donation[str(did)] = mother + + missing_rids = [ + r.id + for r in ubmc_recs + if r.id and r.id not in productions + ] + if missing_rids: + for p in ( + db.query(BizSampleProductionReception) + .filter( + BizSampleProductionReception.tenant_no == tenant_no, + BizSampleProductionReception.del_flag == 0, + BizSampleProductionReception.reception_id.in_(missing_rids), + ) + .order_by(BizSampleProductionReception.process_time.desc()) + .all() + ): + productions.setdefault(p.reception_id, p) + + for reception in ubmc_recs: + code = (reception.lab_storage_code or reception.sample_code or "").strip() + if not code or code in built_map: + continue + production = productions.get(reception.id) + extra = _parse_extra(extras[code].payload_json if code in extras else None) + mother = mother_by_donation.get(str(reception.research_donation_id or "")) + fake_sample = SimpleNamespace( + id=reception.donation_sample_id or reception.id, + sample_code=code, + collection_protocol_id=None, + ) + fake_party = SimpleNamespace(mother_name=mother) if mother else None + built_map[code] = _build_row( + dataset=dataset, + sample=fake_sample, + type_name="脐血单个核细胞", + party=fake_party, + coll=None, + hospital=None, + reception=reception, + production=production, + extra=extra, + ) kw = (keyword or "").strip() built = [] @@ -371,10 +450,7 @@ def _build_row( elif f.source == SOURCE_PRODUCTION: val = production_vals.get(f.attr) elif f.source == SOURCE_EXTRA: - val = extra.get(f.key) - - # 导入/补充表优先:有值则覆盖源表,保证 Excel 录入可见 - if f.key in extra and extra.get(f.key) not in (None, ""): + # 仅源系统没有的列走补充表;业务源表字段不被补充表覆盖 val = extra.get(f.key) if isinstance(val, datetime): diff --git a/backend/tests/test_lab_sample_processing.py b/backend/tests/test_lab_sample_processing.py index 49686bfc..07ee9fb0 100644 --- a/backend/tests/test_lab_sample_processing.py +++ b/backend/tests/test_lab_sample_processing.py @@ -48,6 +48,60 @@ class LabSampleProcessingFieldsTest(unittest.TestCase): self.assertEqual(fmap["cd34Count"].dept, DEPT_QC) self.assertEqual(fmap["originalBloodWeightG"].dept, DEPT_PRODUCTION) + def test_source_fields_not_overridden_by_extra(self): + from app.services.lab_sample_processing import _build_row + from types import SimpleNamespace + + sample = SimpleNamespace(id="S1", sample_code="ZKZX34000865", collection_protocol_id="P1") + party = SimpleNamespace(mother_name="真实妈妈") + coll = SimpleNamespace(sample_collect_time=None) + hospital = SimpleNamespace(hospital_name="真实医院") + reception = SimpleNamespace( + id="R1", + reception_time=None, + receive_temperature=None, + handler_name="接收人", + remark=None, + lab_storage_code="B0000103", + ) + production = SimpleNamespace( + id="PR1", + original_blood_weight_g=72.08, + hes_volume_ml=None, + blood_type="B", + infectious_four_test_result="正常", + enriched_blood_weight_g=None, + cryopreservation_volume_ml=None, + handler_name="操作人", + remark=None, + original_blood_volume_ml=None, + nucleated_cell_count_1e8=None, + inoculation_amount=None, + cryopreservation_time=None, + storage_total_tubes=None, + available_cell_count_1e7=None, + ) + row = _build_row( + dataset=DATASET_HSC, + sample=sample, + type_name="围产期造血干细胞", + party=party, + coll=coll, + hospital=hospital, + reception=reception, + production=production, + extra={ + "motherName": "Excel妈妈", + "originalBloodWeightG": 1, + "cd34Count": "11.4", + }, + ) + self.assertEqual(row["motherName"], "真实妈妈") + self.assertEqual(row["originalBloodWeightG"], 72.08) + self.assertEqual(row["bloodType"], "B") + self.assertEqual(row["cd34Count"], "11.4") + self.assertFalse(row["fromExtraOnly"]) + if __name__ == "__main__": unittest.main() diff --git a/docs/sql/20261112_lab_sample_processing_retire_excel_demo.sql b/docs/sql/20261112_lab_sample_processing_retire_excel_demo.sql new file mode 100644 index 00000000..17ee94f5 --- /dev/null +++ b/docs/sql/20261112_lab_sample_processing_retire_excel_demo.sql @@ -0,0 +1,14 @@ +-- 软删除实验室样本处理 Excel 演示补充行: +-- 仅删除在协议样本表中不存在对应 sample_code 的补充记录。 +-- Excel 列结构仍由代码字段目录维护;真实列表只聚合协议/接收/生产源数据。 + +UPDATE biz_lab_sample_processing_extra e +LEFT JOIN biz_protocol_sample s + ON s.sample_code = e.sample_code + AND s.tenant_no = e.tenant_no + AND s.del_flag = 0 +SET e.del_flag = 1, + e.update_by = 'retire-excel-demo', + e.update_time = NOW() +WHERE e.del_flag = 0 + AND s.id IS NULL; diff --git a/web-admin/src/views/lab-sample-processing/LabSampleProcessingPage.vue b/web-admin/src/views/lab-sample-processing/LabSampleProcessingPage.vue index 9a63d259..2868a0fc 100644 --- a/web-admin/src/views/lab-sample-processing/LabSampleProcessingPage.vue +++ b/web-admin/src/views/lab-sample-processing/LabSampleProcessingPage.vue @@ -275,15 +275,28 @@ async function handleExport() { exporting.value = true try { - const res = await fetchLabSampleList(props.dataset, { - keyword: searchForm.keyword || undefined, - dateFrom: dateRange.value?.[0], - dateTo: dateRange.value?.[1], - page: 1, - pageSize: 5000 - }) - const cols = res?.fields?.length ? res.fields : fields.value - const rows = res?.list || [] + // 列表接口 pageSize 上限为 200,导出需分页拉全量后再生成 xlsx + const pageSize = 200 + let page = 1 + let total = Number.POSITIVE_INFINITY + const rows: Record[] = [] + let cols = fields.value + while (rows.length < total) { + const res = await fetchLabSampleList(props.dataset, { + keyword: searchForm.keyword || undefined, + dateFrom: dateRange.value?.[0], + dateTo: dateRange.value?.[1], + page, + pageSize + }) + if (res?.fields?.length) cols = res.fields + total = Number(res?.total ?? 0) + const batch = res?.list || [] + rows.push(...batch) + if (!batch.length || batch.length < pageSize) break + page += 1 + if (page > 500) break + } const ExcelJS = (await import('exceljs')).default const workbook = new ExcelJS.Workbook() const sheet = workbook.addWorksheet(props.title || props.dataset)