2026-10-05-unified-sample-info.md 29 KB

统一样本信息管理 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 细胞存储与科研捐赠共用一个「样本信息管理」页(两个来源 Tab、一条样本一行),列表只读展示采集/接收/质检/入库标识,细胞存储另展示证/保险已办理,详情里轻量办理证与保险;采集协议单去掉储存证、满月待办 Tab。

Architecture: 进度标识由现有接收单、入库质检工单、入库放行审核按 tenant_no + sample_code 派生,不建总表。证/保险仍写 biz_protocol_storage_cert / biz_protocol_fullmoon_todo(协议级),补 handle_time、handler_user_id。列表扩展现有两个 sample-collections 接口;办理走独立 PUT。前端两路由共用同一列表页。

Tech Stack: MySQL 5.7 / FastAPI + SQLAlchemy / pytest unittest / Vue 3 + Element Plus / pnpm

Design Doc: docs/superpowers/specs/2026-10-05-unified-sample-info-design.md

Reuse: backend/app/services/protocol_sample_list_summary.py(接收/质检摘要)、qc_sms_notification._qc_status_label 语义、storage_entry_sms_notification._storage_status_label、协议详情里已有的接收/质检/入库只读组件。


File Structure Map

New files

File Responsibility
docs/sql/migrations/2026-10-05-sample-info-handle-fields.sql 两表 ALTER 增加办理时间、办理人
backend/app/services/sample_info_progress.py 采集/接收/质检/入库展示码、证/保险已办理判定、同步 status 日期
backend/tests/test_sample_info_progress.py 上述纯函数单测
backend/tests/test_sample_info_handle.py 办理 upsert 同步字段单测(mock db 或纯函数 + crud 辅助)
web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue 两来源 Tab + 筛选 + 列表 + 打开详情
web-admin/src/views/cell-storage/sample-info/SampleProgressTags.ts 列表 Tag 的 type/label 映射(可与 registry 合并,见 Task 7)
web-admin/src/components/business/protocol-handle-panel/index.vue 证/保险办理表单(时间、人员、附件、备注、保存)

Modified files

File Change
backend/app/models/models.py BizProtocolStorageCert、BizProtocolFullmoonTodo 加列
backend/app/crud/collection_protocol.py dict/upsert 支持新字段;PUT 办理辅助
backend/app/crud/protocol_sample_collection.py 列表富化 + 进度筛选
backend/app/crud/research_donation_sample_collection.py 同上(无证/保险)
backend/app/api/protocol_sample_collection.py 新 query 参数
backend/app/api/research_donation_sample_collection.py 新 query 参数
backend/app/api/collection_protocol.py PUT /storage-certs/{id}、PUT /fullmoon-todos/{id}/handle;bundle 返回办理字段
web-admin/src/api/protocol-sample-collection.ts 筛选参数
web-admin/src/api/research-donation-sample-collection.ts 筛选参数
web-admin/src/api/admin.ts 办理 PUT(或新 sample-info-handle.ts)
web-admin/src/views/cell-storage/sample-info/index.vue 改为挂载统一页,默认细胞存储
web-admin/src/views/research-donation/sample-info/index.vue 挂载同一页,默认科研捐赠
web-admin/src/views/cell-storage/protocol-audit/index.vue 只留协议单 Tab
web-admin/src/views/cell-storage/storage-cert/index.vue 跳转样本信息
web-admin/src/views/cell-storage/fullmoon-todo/index.vue 跳转样本信息
web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue 高亮样本;证/保险改为办理面板
web-admin/src/views/research-donation/list/DonationViewDialog.vue 高亮样本(进度组件已有则只加 highlight)
web-admin/src/utils/status-tag-registry.ts 进度/办理 Tag

Stop using as workbenches (delete after统一页可用,或先停引用)

  • protocol-audit/StorageCertTab.vue、FullmoonTodoTab.vue
  • SampleCollectionInfoTab.vue、SampleReceptionInfoTab.vue、SampleQcInfoTab.vue、StorageEntryInfoTab.vue
  • research-donation/sample-info/DonationSampleCollectionInfoTab.vue

短信 API 不要删。


Task 1: SQL 与 Model 列

Files:

  • Create: docs/sql/migrations/2026-10-05-sample-info-handle-fields.sql
  • Modify: backend/app/models/models.py(BizProtocolStorageCert 约 L1597、BizProtocolFullmoonTodo 约 L3613)

  • [ ] Step 1: 写迁移 SQL

MySQL 5.7,列加在业务字段末、公共字段前。handle_date 已存在于满月表,新列名为 handle_time(DATETIME)。

-- 储存证 / 满月保险轻量办理字段(2026-10-05)
ALTER TABLE `biz_protocol_storage_cert`
  ADD COLUMN `handle_time` datetime DEFAULT NULL COMMENT '办理时间' AFTER `make_date`,
  ADD COLUMN `handler_user_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '办理人用户ID' AFTER `handle_time`;

ALTER TABLE `biz_protocol_fullmoon_todo`
  ADD COLUMN `handle_time` datetime DEFAULT NULL COMMENT '办理时间' AFTER `handle_date`,
  ADD COLUMN `handler_user_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '办理人用户ID' AFTER `handle_time`;
  • Step 2: Model 增加同名 Column

在 make_date 后、tenant_no 前:

handle_time = Column(DateTime, nullable=True, comment="办理时间")
handler_user_id = Column(String(64), nullable=True, comment="办理人用户ID")

满月表:handle_date 列之后同样两列。不要删除保险公司旧列。

  • Step 3: Commit
git add docs/sql/migrations/2026-10-05-sample-info-handle-fields.sql backend/app/models/models.py
git commit -m "$(cat <<'EOF'
feat(db): add handle_time and handler_user_id for storage cert and fullmoon todo.

EOF
)"

Task 2: 进度与已办理纯函数(TDD)

Files:

  • Create: backend/app/services/sample_info_progress.py
  • Test: backend/tests/test_sample_info_progress.py

在仓库根目录 backend/ 下跑 pytest(已有 tests/test_protocol_list_sample_reception_qc.py 风格:unittest + 直接 import 纯函数)。

  • Step 1: 写失败测试
"""样本信息管理:进度展示码与证/保险已办理判定。"""
import unittest
from datetime import datetime
from types import SimpleNamespace

from app.services.sample_info_progress import (
    collection_progress,
    insurance_handled,
    qc_progress,
    reception_progress,
    storage_cert_handled,
    storage_progress,
    sync_handle_status_fields,
)


class TestCollectionProgress(unittest.TestCase):
    def test_empty(self):
        self.assertEqual(collection_progress(""), ("uncollected", "未采集"))

    def test_collected_includes_mid_states(self):
        for st in ("ycj", "jcz", "yrk"):
            self.assertEqual(collection_progress(st)[0], "collected")
        self.assertEqual(collection_progress("ycj")[1], "已采集")

    def test_void(self):
        self.assertEqual(collection_progress("yzf"), ("voided", "已作废"))


class TestReceptionProgress(unittest.TestCase):
    def test_missing_or_pending(self):
        self.assertEqual(reception_progress(None)[0], "none")
        self.assertEqual(reception_progress({"acceptConclusion": "dqr"})[0], "none")
        self.assertEqual(reception_progress({"acceptConclusion": ""})[0], "none")

    def test_accept_reject(self):
        self.assertEqual(reception_progress({"acceptConclusion": "js"}), ("js", "已接收"))
        self.assertEqual(reception_progress({"acceptConclusion": "bj"}), ("bj", "拒收"))


class TestQcProgress(unittest.TestCase):
    def test_none(self):
        self.assertEqual(qc_progress(None)[0], "incomplete")

    def test_complete_pass_or_fail(self):
        self.assertEqual(qc_progress({"qcConclusion": "tg", "qcWorkOrderStatus": "ywc"})[0], "complete")
        self.assertEqual(qc_progress({"qcConclusion": "btg", "qcWorkOrderStatus": "ywc"})[0], "complete")

    def test_void(self):
        self.assertEqual(qc_progress({"qcWorkOrderStatus": "yzf"})[0], "voided")

    def test_in_progress(self):
        self.assertEqual(qc_progress({"qcWorkOrderStatus": "ypz", "qcConclusion": ""})[0], "incomplete")


class TestStorageProgress(unittest.TestCase):
    def test_none_or_pending(self):
        self.assertEqual(storage_progress(None)[0], "none")
        self.assertEqual(storage_progress({"storageStatus": "drk"})[0], "none")

    def test_in_and_reject(self):
        self.assertEqual(storage_progress({"storageStatus": "yrk"})[0], "yrk")
        self.assertEqual(
            storage_progress({"storageStatus": "drk", "reviewConclusion": "btg"})[0],
            "rejected",
        )


class TestHandleFlags(unittest.TestCase):
    def test_cert_handle_time_wins(self):
        row = SimpleNamespace(handle_time=datetime(2026, 10, 1, 8, 0), make_status="0", make_date="")
        self.assertTrue(storage_cert_handled(row))

    def test_cert_legacy_make_status(self):
        row = SimpleNamespace(handle_time=None, make_status="1", make_date="")
        self.assertTrue(storage_cert_handled(row))

    def test_cert_legacy_make_date(self):
        row = SimpleNamespace(handle_time=None, make_status="0", make_date="20260101")
        self.assertTrue(storage_cert_handled(row))

    def test_cert_empty(self):
        self.assertFalse(storage_cert_handled(None))
        row = SimpleNamespace(handle_time=None, make_status="0", make_date="")
        self.assertFalse(storage_cert_handled(row))

    def test_insurance_legacy_todo_or_date(self):
        self.assertTrue(
            insurance_handled(SimpleNamespace(handle_time=None, todo_status="1", handle_date=""))
        )
        self.assertTrue(
            insurance_handled(SimpleNamespace(handle_time=None, todo_status="0", handle_date="20260131"))
        )
        self.assertFalse(
            insurance_handled(SimpleNamespace(handle_time=None, todo_status="0", handle_date=""))
        )


class TestSyncHandleStatus(unittest.TestCase):
    def test_set_and_clear(self):
        ht = datetime(2026, 10, 5, 15, 0, 0)
        st, ymd = sync_handle_status_fields(ht)
        self.assertEqual(st, "1")
        self.assertEqual(ymd, "20261005")
        st2, ymd2 = sync_handle_status_fields(None)
        self.assertEqual(st2, "0")
        self.assertEqual(ymd2, "")
  • Step 2: 跑测确认失败

Run: cd backend && python -m pytest tests/test_sample_info_progress.py -v

Expected: FAIL(sample_info_progress 未定义)

  • Step 3: 实现 sample_info_progress.py

约定展示码(列表 xxxFlag / xxxFlagLabel):

领域 code 文案
采集 uncollected / collected / voided 未采集 / 已采集 / 已作废
接收 none / js / bj 未接收 / 已接收 / 拒收
质检 incomplete / complete / voided 未完成 / 已完成 / 已作废
入库 none / yrk / rejected 未入库 / 已入库 / 拒绝入库

入库拒绝:与短信列表一致——storage_status != yrk 且 review_conclusion == btg。

def collection_progress(sample_status: str) -> tuple:
    st = (sample_status or "").strip()
    if not st:
        return "uncollected", "未采集"
    if st == "yzf":
        return "voided", "已作废"
    if st in ("ycj", "jcz", "yrk"):
        return "collected", "已采集"
    return "uncollected", "未采集"


def reception_progress(reception: dict | None) -> tuple:
    if not reception:
        return "none", "未接收"
    c = (reception.get("acceptConclusion") or "").strip()
    if c == "js":
        return "js", "已接收"
    if c == "bj":
        return "bj", "拒收"
    return "none", "未接收"


def qc_progress(qc: dict | None) -> tuple:
    if not qc:
        return "incomplete", "未完成"
    if (qc.get("qcWorkOrderStatus") or "").strip() == "yzf":
        return "voided", "已作废"
    conc = (qc.get("qcConclusion") or "").strip()
    if conc in ("tg", "btg"):
        return "complete", "已完成"
    return "incomplete", "未完成"


def storage_progress(review: dict | None) -> tuple:
    if not review:
        return "none", "未入库"
    if (review.get("storageStatus") or "").strip() == "yrk":
        return "yrk", "已入库"
    if (review.get("reviewConclusion") or "").strip() == "btg":
        return "rejected", "拒绝入库"
    return "none", "未入库"


def storage_cert_handled(row) -> bool:
    if row is None:
        return False
    if getattr(row, "handle_time", None):
        return True
    if (getattr(row, "make_status", None) or "").strip() == "1":
        return True
    return bool((getattr(row, "make_date", None) or "").strip())


def insurance_handled(row) -> bool:
    if row is None:
        return False
    if getattr(row, "handle_time", None):
        return True
    if (getattr(row, "todo_status", None) or "").strip() == "1":
        return True
    return bool((getattr(row, "handle_date", None) or "").strip())


def sync_handle_status_fields(handle_time):
    if handle_time is None:
        return "0", ""
    return "1", handle_time.strftime("%Y%m%d")

把 sync_handle_status_fields 写清楚:handle_time is None → ("0", ""),否则 ("1", YYYYMMDD)。

  • Step 4: pytest 通过

Run: cd backend && python -m pytest tests/test_sample_info_progress.py -v

Expected: PASS

  • Step 5: Commit
git add backend/app/services/sample_info_progress.py backend/tests/test_sample_info_progress.py
git commit -m "$(cat <<'EOF'
feat(backend): add sample info progress flag helpers.

EOF
)"

Task 3: 办理 upsert 同步字段(TDD)

Files:

  • Modify: backend/app/crud/collection_protocol.py(_storage_cert_to_dict L607、upsert_storage_cert L3348、_fullmoon_todo_to_dict L3434、upsert_fullmoon_todo L3476)
  • Test: backend/tests/test_sample_info_handle.py

  • [ ] Step 1: 测试 apply_handle_payload 纯逻辑(可放在 sample_info_progress.py)

from datetime import datetime
from app.services.sample_info_progress import apply_handle_payload

def test_apply_sets_status():
    out = apply_handle_payload(
        handle_time=datetime(2026, 10, 5, 9, 0, 0),
        handler_user_id="u1",
        attachments="http://a.png",
        remark="ok",
    )
    assert out["handle_time"].year == 2026
    assert out["handler_user_id"] == "u1"
    assert out["make_or_todo_status"] == "1"
    assert out["legacy_date"] == "20261005"

def test_apply_clear():
    out = apply_handle_payload(handle_time=None, handler_user_id="u1", attachments="", remark="")
    assert out["handle_time"] is None
    assert out["handler_user_id"] is None
    assert out["make_or_todo_status"] == "0"
    assert out["legacy_date"] == ""

规则:清空办理时间时 同时清空 handler_user_id(与 spec 一致)。有办理时间时若 handler_user_id 为空,upsert 层抛 ValueError("填写办理时间时必须指定办理人员")。

  • Step 2: pytest 失败后实现 apply_handle_payload,再接到 upsert

upsert_storage_cert 增加字段:handle_time、handler_user_id;写入时调用 sync_handle_status_fields 设置 make_status、make_date。

upsert_fullmoon_todo:写入 handle_time、handler_user_id,同步 todo_status、handle_date。调用方 不要 传入 insurance_company_* 等键(exclude)。

_storage_cert_to_dict / _fullmoon_todo_to_dict 增加:

"handleTime": _fmt_dt(s.handle_time),
"handlerUserId": s.handler_user_id or "",
"handled": storage_cert_handled(s),  # 保险用 insurance_handled

办理人姓名在 API 层批量查 sys_user.nick_name,CRUD dict 可只带 id。

  • Step 3: pytest 通过后 Commit
git commit -m "$(cat <<'EOF'
feat(backend): sync storage cert and insurance handle time with legacy status.

EOF
)"

Task 4: 列表富化与筛选

Files:

  • Modify: backend/app/crud/protocol_sample_collection.py
  • Modify: backend/app/crud/research_donation_sample_collection.py
  • Modify: backend/app/api/protocol_sample_collection.py
  • Modify: backend/app/api/research_donation_sample_collection.py
  • Modify: backend/app/services/protocol_sample_list_summary.py(接收多条时按 reception_time 取最晚)

列表每行增加(camelCase):

  • collectionFlag, collectionFlagLabel
  • receptionFlag, receptionFlagLabel, receptionTime(可空)
  • qcFlag, qcFlagLabel, qcCompleteTime(可空)
  • storageFlag, storageFlagLabel, storageTime(可空)
  • 仅细胞存储:storageCertHandled, insuranceHandled(bool)

科研捐赠 禁止 返回证/保险 handled 字段。

Query(两接口): collectionFlag, receptionFlag, qcFlag, storageFlag;细胞存储另加 storageCertHandled、insuranceHandled(1/0 或 true/false)。

实现要点:

  1. 先按现有分页查出样本行。
  2. 收集本页 sample_code、collection_protocol_id。
  3. 接收:在 get_list_reception_summary_map_by_sample_codes 中同码多条时保留 reception_time 最大的一条(改循环:不要 if code in out: continue 先到先得;改为比较时间)。同时返回 receptionTime。
  4. 质检:复用 get_list_qc_conclusion_map_by_sample_codes(..., reception_source=...)。
  5. 入库:按 tenant_no + sample_code + reception_source 查 BizStorageReleaseReview,del_flag=0,同码多条取 update_time/storage_time 最晚。字段 storageStatus、reviewConclusion、storageTime。
  6. 证/保险:按本页 protocol_id 批量查两表,用 Task 2 的 handled。
  7. 筛选必须在 count/offset 之前生效,避免分页错位:对 flag 过滤用 EXISTS / 子查询,或先筛 sample id 再 paginate。不要先 page 再在 Python 里丢掉行。

采集筛选 SQL:

  • uncollected:sample_status IS NULL OR ''
  • collected:IN ('ycj','jcz','yrk')
  • voided:= 'yzf'

保留旧参数 sampleStatus 时:若传入 ycj 等原始码仍过滤该码;新前端只传 collectionFlag。

  • Step 1: 补测接收 map 取最晚

在 tests/test_protocol_list_sample_reception_qc.py 增加:两条 mock reception_time 不同,断言留下较晚且 js。

  • Step 2: 实现 map + list enrich + API 参数

细胞存储 list_protocol_sample_collections 签名增加上述 filter。捐赠 crud 同样,无 cert 参数。

  • Step 3: cd backend && python -m pytest tests/test_protocol_list_sample_reception_qc.py tests/test_sample_info_progress.py -v

Expected: PASS

  • Step 4: Commit
git commit -m "$(cat <<'EOF'
feat(backend): enrich sample collection lists with progress and handle flags.

EOF
)"

Task 5: 办理 HTTP API 与详情 bundle

Files:

  • Modify: backend/app/api/collection_protocol.py

  • [ ] Step 1: Pydantic body

class StorageCertHandleBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    handle_time: Optional[datetime] = Field(None, alias="handleTime")
    handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
    cert_image_urls: Optional[str] = Field(None, alias="certImageUrls")
    remark: Optional[str] = None

class FullmoonHandleBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    handle_time: Optional[datetime] = Field(None, alias="handleTime")
    handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
    insurance_attachments: Optional[str] = Field(None, alias="insuranceAttachments")
    handle_remark: Optional[str] = Field(None, alias="handleRemark")
  • [ ] Step 2: 路由

  • PUT /web/admin/storage-certs/{protocol_id}:鉴权与协议更新相同(require_admin_or_super_admin 或样本信息 menu code 并集,与现有 storage-certs GET 对齐)。校验协议存在、租户、数据权限。handle_time 有值则 handler_user_id 必填。调用 upsert_storage_cert。

  • PUT /web/admin/fullmoon-todos/{protocol_id}/handle:同样。model_dump 只含 上述四字段,禁止 spread 进保险公司字段。

  • 不删除 PUT .../insurance。

协议详情 bundle 里 storageCert、fullmoonTodo 带上 handleTime、handlerUserId、handlerUserName、handled。

  • [ ] Step 3: 无 DB 的校验测(handler 缺失 → ValueError)放 test_sample_info_handle.py。

  • [ ] Step 4: Commit

git commit -m "$(cat <<'EOF'
feat(api): add storage cert and fullmoon lightweight handle endpoints.

EOF
)"

Task 6: 前端 API 与 Tag 注册

Files:

  • Modify: web-admin/src/api/protocol-sample-collection.ts
  • Modify: web-admin/src/api/research-donation-sample-collection.ts
  • Modify: web-admin/src/api/admin.ts(或新建 web-admin/src/api/sample-info-handle.ts)
  • Modify: web-admin/src/utils/status-tag-registry.ts

  • [ ] Step 1: list params 增加 flag 字段(与后端 alias 一致)。

  • [ ] Step 2: 办理函数

export function fetchUpdateStorageCertHandle(protocolId: string, data: {
  handleTime?: string | null
  handlerUserId?: string | null
  certImageUrls?: string
  remark?: string
}) {
  return request.put({ url: `/web/admin/storage-certs/${protocolId}`, params: data })
}

export function fetchUpdateFullmoonHandle(protocolId: string, data: {
  handleTime?: string | null
  handlerUserId?: string | null
  insuranceAttachments?: string
  handleRemark?: string
}) {
  return request.put({ url: `/web/admin/fullmoon-todos/${protocolId}/handle`, params: data })
}
  • Step 3: registry
export const sampleCollectionFlag: StatusRegistry = {
  uncollected: { label: '未采集', tagType: 'info' },
  collected: { label: '已采集', tagType: 'success' },
  voided: { label: '已作废', tagType: 'danger' }
}
export const sampleReceptionFlag: StatusRegistry = {
  none: { label: '未接收', tagType: 'info' },
  js: { label: '已接收', tagType: 'success' },
  bj: { label: '拒收', tagType: 'danger' }
}
export const sampleQcFlag: StatusRegistry = {
  incomplete: { label: '未完成', tagType: 'warning' },
  complete: { label: '已完成', tagType: 'success' },
  voided: { label: '已作废', tagType: 'danger' }
}
export const sampleStorageFlag: StatusRegistry = {
  none: { label: '未入库', tagType: 'info' },
  yrk: { label: '已入库', tagType: 'success' },
  rejected: { label: '拒绝入库', tagType: 'danger' }
}
export const handleDoneFlag: StatusRegistry = {
  '1': { label: '已办理', tagType: 'success' },
  '0': { label: '未办理', tagType: 'info' }
}

导出 label/tagType getter,与文件内现有工厂一致。

  • Step 4: Commit
git commit -m "$(cat <<'EOF'
feat(web-admin): add sample info list params, handle APIs, and progress tags.

EOF
)"

Task 7: 统一列表页

Files:

  • Create: web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue
  • Modify: web-admin/src/views/cell-storage/sample-info/index.vue
  • Modify: web-admin/src/views/research-donation/sample-info/index.vue

对照:SampleCollectionInfoTab.vue 的表格骨架 + protocol-audit/index.vue 的 pill Tab。UI:Fresh Mint、筛选无重复标题、按钮带 el-icon。

Props:defaultSource: 'cell_storage' | 'research_donation'。

  • Step 1: Tab 与权限

两 Tab 文案:细胞存储 / 科研捐赠。query.tab 同步。用当前路由 meta.menuCode 与用户菜单树判断 Tab 可见性(无科研捐赠权限则只显示细胞存储,反之亦然)。从细胞存储菜单进入默认 cell_storage。

  • Step 2: 筛选

关键词 placeholder:姓名/样本编码/入库编码。采集/接收/质检/入库下拉用 Task 2 的 code。细胞存储 Tab 增加储存证、保险:已办理 / 未办理。防抖 300ms 与现页一致。

  • Step 3: 表格列

与 spec §4.2:状态 Tag、样本编码用 SampleCodeWithSpecialDisplay。四个进度 +(存储 Tab)两列办理。操作列仅「查看」,fixed="right",min-width ≥ 260。双击查看。分页 10/20/50。

细胞存储 fetchProtocolSampleCollectionList;捐赠 fetchResearchDonationSampleCollectionList。

  • Step 4: 查看

细胞存储:ProtocolViewDialog,protocol-id = collectionProtocolId,传入 highlight-sample-code。捐赠:DonationViewDialog + researchDonationId。show-audit-history 可保留;不要再靠 show-storage-cert 只读拼盘作为办理入口。

  • Step 5: 两个 index 只包一层
<UnifiedSampleInfoPage default-source="cell_storage" />

科研捐赠:default-source="research_donation"。科研捐赠页的 panel-top「样本信息管理」标题:统一页自带 pill Tab 后,按 Fresh Mint 单标题——标题放统一页 h2 一次即可,子 index 不要再套一个 h2。

  • Step 6: Commit
git commit -m "$(cat <<'EOF'
feat(web-admin): unify sample info management into two source tabs.

EOF
)"

Task 8: 办理面板 + 协议详情

Files:

  • Create: web-admin/src/components/business/protocol-handle-panel/index.vue
  • Modify: web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue
  • Modify: web-admin/src/views/research-donation/list/DonationViewDialog.vue(高亮当前样本)

  • [ ] Step 1: protocol-handle-panel

label-position="top",.dialog-form-layout 内嵌即可(本身是 section 不是独立 dialog)。字段:el-date-picker datetime value-format="YYYY-MM-DD HH:mm:ss";办理人 el-select filterable,远程 fetchUserList({ page: 1, pageSize: 50, keyword, status: '1' }),空表单默认 userStore 当前用户 id/名。附件卡片对齐 ProtocolFormDialog 协议附件(fetchUploadAttachment,folder 用 storage-cert / fullmoon-todo)。备注 textarea。底栏「保存」Check 图标。有办理时间无办理人前端拦截。保存成功 ElMessage.success 并 emit('saved')。

两种 mode:storage-cert | insurance,调用对应 PUT。

  • [ ] Step 2: ProtocolViewDialog

  • props:highlightSampleCode?: string。匹配的 .sample-card 加高亮 class(主色描边)。onMounted/watch bundle 就绪后 scrollIntoView 该卡片。

  • 替换基本信息里旧储存证只读行、客户下「新生儿保险信息」只读大段:改为两个 protocol-handle-panel(仅 source 为细胞存储详情时显示)。打开后用 bundle 预填;保存后 reload bundle。

  • 样本卡片已有接收/质检/入库只读,不重复造四段表单;确保入库组件在捐赠详情同样存在。

  • [ ] Step 3: DonationViewDialog

同样 highlightSampleCode + 滚动。不要加证/保险面板。

  • Step 4: Commit
git commit -m "$(cat <<'EOF'
feat(web-admin): add cert and insurance handle panels on protocol detail.

EOF
)"

Task 9: 撤协议单 Tab 与旧路由

Files:

  • Modify: web-admin/src/views/cell-storage/protocol-audit/index.vue
  • Modify: web-admin/src/views/cell-storage/storage-cert/index.vue
  • Modify: web-admin/src/views/cell-storage/fullmoon-todo/index.vue
  • Modify: web-admin/src/views/cell-storage/sample-reception-info/index.vue(若仍指向旧 tab)
  • Modify: web-admin/src/views/cell-storage/storage-entry-info/index.vue

  • [ ] Step 1: protocol-audit/index.vue 的 entityTabs 只留 protocol;删除 StorageCertTab/FullmoonTodoTab 引用。?tab=storage-cert|fullmoon-todo 时 replace 到 CellStorageSampleInfo。

  • [ ] Step 2: 储存证/满月隐藏页:

router.replace({ name: 'CellStorageSampleInfo', query: { ...route.query, tab: 'cell_storage' } })
  • [ ] Step 3: 确认样本信息页不再 import 四个旧 Tab。删除或停止引用 StorageCertTab.vue、FullmoonTodoTab.vue、四个 Sample*InfoTab.vue、DonationSampleCollectionInfoTab.vue(无引用后再删文件,避免死代码)。

  • [ ] Step 4: Commit

git commit -m "$(cat <<'EOF'
refactor(web-admin): remove storage cert and fullmoon tabs from protocol audit.

EOF
)"

Task 10: 图谱与回归

  • [ ] Step 1: ./scripts/update-graph.sh all(或分别 backend/frontend)。

  • [ ] Step 2: cd backend && python -m pytest tests/test_sample_info_progress.py tests/test_sample_info_handle.py tests/test_protocol_list_sample_reception_qc.py -v

  • [ ] Step 3: 浏览器(有环境时):细胞存储样本信息四标识、多样本改证后两行已办理一致、清空办理时间变未办理、科研捐赠无证列、采集协议单无证/满月 Tab、旧储存证 URL 跳转。无浏览器则注明未做 UI 点验。

  • [ ] Step 4: Commit 图谱(若有 diff)

git commit -m "$(cat <<'EOF'
chore: update code graphs after unified sample info.

EOF
)"

Spec coverage

Spec Task
一页两 Tab、默认来源、权限 7
样本行、列、只读进度 2, 4, 7
证/保险已办理兼容旧数据 2, 3, 4
详情进度 + 办理四字段 5, 8(进度复用已有只读组件)
独立 PUT、不同步保险公司字段 3, 5
协议单撤 Tab、旧路由 9
不发短信、不建总表 全程禁止
测试要点 2–5, 10

Placeholder / 一致性

  • Flag code 全计划统一为 uncollected|collected|voided、none|js|bj、incomplete|complete|voided、none|yrk|rejected。
  • 办理清空时 handler 一并清空。
  • 列表筛选不得「先分页再过滤」。