2026-08-17-qc-inbound-lab-storage-code-sort.md 18 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,按实验室入库编码倒序、同编码按创建时间倒序分页与展示,跨页一致。

Architecture: 后端在入库两 Tab 的列表查询上用相关子查询解析 lab_storage_code(优先 reception_id,否则按样本编码),ORDER BY COALESCE(code,'') DESC, create_time DESC;前端入库卡片与我的任务聚合排序对齐同一比较规则;出库组内排序不动。

Tech Stack: FastAPI / SQLAlchemy 2.0 / Vue 3 / TypeScript;设计依据 docs/superpowers/specs/2026-08-17-qc-inbound-lab-storage-code-sort-design.md。

Spec: docs/superpowers/specs/2026-08-17-qc-inbound-lab-storage-code-sort-design.md


File map

File Responsibility
Create: backend/app/crud/qc_inbound_lab_storage_sort.py 判断是否启用编码排序、相关子查询表达式、order_by 元组
Modify: backend/app/crud/qc_work_order.py list_work_orders 入库两 Tab 使用新 order_by
Modify: backend/app/crud/qc_task.py list_mine_tasks 入库两 Tab 使用新 order_by
Create: backend/tests/test_qc_inbound_lab_storage_sort.py 纯函数:来源判断 + 排序键比较语义
Create: web-admin/src/utils/qc-inbound-lab-storage-sort.ts 前端统一比较:编码 DESC → 时间 DESC
Modify: web-admin/src/utils/qc-work-order-groups.ts 仅入库组内改为时间倒序
Modify: web-admin/src/utils/qc-work-order-inbound-sample-cards.ts 卡片列表主序改为编码/时间
Modify: web-admin/src/utils/qc-mine-task-groups.ts 导出按编码排序的工单条目
Modify: web-admin/src/views/quality-inspection/qc-work-order/composables/useQcWorkOrderTabPage.ts mine 模式分页前调用排序

Task 1: 后端纯函数 — 来源判断与比较语义

Files:

  • Create: backend/app/crud/qc_inbound_lab_storage_sort.py
  • Create: backend/tests/test_qc_inbound_lab_storage_sort.py

  • [ ] Step 1: Write the failing test

# backend/tests/test_qc_inbound_lab_storage_sort.py
"""入库质检列表:实验室入库编码排序语义。"""
import unittest

from app.crud.qc_inbound_lab_storage_sort import (
    compare_lab_storage_then_create_time_desc,
    uses_inbound_lab_storage_list_sort,
)


class TestUsesInboundLabStorageListSort(unittest.TestCase):
    def test_cell_storage_and_research_donation(self):
        self.assertTrue(uses_inbound_lab_storage_list_sort("cell_storage"))
        self.assertTrue(uses_inbound_lab_storage_list_sort("research_donation"))

    def test_other_sources_false(self):
        self.assertFalse(uses_inbound_lab_storage_list_sort(""))
        self.assertFalse(uses_inbound_lab_storage_list_sort(None))
        self.assertFalse(uses_inbound_lab_storage_list_sort("temporary"))


class TestCompareLabStorageThenCreateTimeDesc(unittest.TestCase):
    def test_code_desc_empty_as_empty_string(self):
        # '' < 'B0000001' lexicographically → DESC 时有编码在前
        self.assertEqual(
            compare_lab_storage_then_create_time_desc("B0000001", "2026-01-01", "", "2026-12-01"),
            -1,
        )
        self.assertEqual(
            compare_lab_storage_then_create_time_desc("", "2026-12-01", "B0000001", "2026-01-01"),
            1,
        )

    def test_same_code_newer_create_time_first(self):
        self.assertEqual(
            compare_lab_storage_then_create_time_desc(
                "B1", "2026-08-02 10:00:00", "B1", "2026-08-01 10:00:00"
            ),
            -1,
        )

    def test_higher_code_first(self):
        self.assertEqual(
            compare_lab_storage_then_create_time_desc("B2", "t", "B1", "t"),
            -1,
        )
  • Step 2: Run test to verify it fails

Run:

cd backend && source venv/bin/activate && python -m pytest tests/test_qc_inbound_lab_storage_sort.py -v

Expected: FAIL(模块不存在或导入失败)

  • Step 3: Write minimal implementation
# backend/app/crud/qc_inbound_lab_storage_sort.py
"""入库质检列表:按实验室入库编码排序的辅助逻辑。"""
from __future__ import annotations

from typing import Optional

from sqlalchemy import and_, func, select
from sqlalchemy.sql import ColumnElement

from app.models.models import BizQcWorkOrder, BizSampleReception

_INBOUND_LAB_SORT_SOURCES = frozenset({"cell_storage", "research_donation"})


def uses_inbound_lab_storage_list_sort(reception_source: Optional[str]) -> bool:
    return (reception_source or "").strip() in _INBOUND_LAB_SORT_SOURCES


def compare_lab_storage_then_create_time_desc(
    code_a: Optional[str],
    time_a: Optional[str],
    code_b: Optional[str],
    time_b: Optional[str],
) -> int:
    """列表序比较:编码 DESC,同编码时间 DESC。返回负值表示 a 应排在 b 前。"""
    ca = (code_a or "").strip()
    cb = (code_b or "").strip()
    if ca != cb:
        # DESC: 较大字符串在前
        return -1 if ca > cb else 1
    ta = (time_a or "").strip()
    tb = (time_b or "").strip()
    if ta != tb:
        return -1 if ta > tb else 1
    return 0


def lab_storage_code_expr_for_work_order() -> ColumnElement:
    """相关子查询:优先 reception_id,否则同租户 sample_code 最新一条接收记录的编码。"""
    by_id = (
        select(BizSampleReception.lab_storage_code)
        .where(
            and_(
                BizSampleReception.id == BizQcWorkOrder.reception_id,
                BizSampleReception.del_flag == 0,
            )
        )
        .correlate(BizQcWorkOrder)
        .scalar_subquery()
    )
    by_sample = (
        select(BizSampleReception.lab_storage_code)
        .where(
            and_(
                BizSampleReception.sample_code == BizQcWorkOrder.sample_code,
                BizSampleReception.tenant_no == BizQcWorkOrder.tenant_no,
                BizSampleReception.del_flag == 0,
                BizQcWorkOrder.sample_code.isnot(None),
                BizQcWorkOrder.sample_code != "",
            )
        )
        .order_by(BizSampleReception.create_time.desc())
        .limit(1)
        .correlate(BizQcWorkOrder)
        .scalar_subquery()
    )
    return func.coalesce(by_id, by_sample, "")


def inbound_lab_storage_order_by_clauses():
    """供 Query.order_by(*clauses) 使用。"""
    code = lab_storage_code_expr_for_work_order()
    return (
        code.desc(),
        BizQcWorkOrder.create_time.desc(),
    )
  • Step 4: Run tests to verify they pass
cd backend && source venv/bin/activate && python -m pytest tests/test_qc_inbound_lab_storage_sort.py -v

Expected: PASS

  • Step 5: Commit(仅当用户明确要求提交时执行)
git add backend/app/crud/qc_inbound_lab_storage_sort.py backend/tests/test_qc_inbound_lab_storage_sort.py
git commit -m "$(cat <<'EOF'
feat: add inbound QC list sort helpers by lab storage code

EOF
)"

Task 2: list_work_orders 应用入库排序

Files:

  • Modify: backend/app/crud/qc_work_order.py(list_work_orders 中约 1706–1715 行的 order_by)

  • [ ] Step 1: Wire order_by

在 list_work_orders 文件顶部附近增加导入:

from app.crud.qc_inbound_lab_storage_sort import (
    inbound_lab_storage_order_by_clauses,
    uses_inbound_lab_storage_list_sort,
)

将:

    rows = (
        _filtered()
        .order_by(BizQcWorkOrder.create_time.desc())
        .offset((page - 1) * page_size)
        .limit(page_size)
        .all()
    )

替换为:

    q_page = _filtered()
    if uses_inbound_lab_storage_list_sort(reception_source):
        q_page = q_page.order_by(*inbound_lab_storage_order_by_clauses())
    else:
        q_page = q_page.order_by(BizQcWorkOrder.create_time.desc())
    rows = (
        q_page
        .offset((page - 1) * page_size)
        .limit(page_size)
        .all()
    )
  • Step 2: Sanity import check
cd backend && source venv/bin/activate && python -c "from app.crud.qc_work_order import list_work_orders; print('ok')"

Expected: ok

  • Step 3: Commit(仅当用户明确要求提交时执行)
git add backend/app/crud/qc_work_order.py
git commit -m "$(cat <<'EOF'
feat: order inbound QC work orders by lab storage code then create time

EOF
)"

Task 3: list_mine_tasks 应用入库排序

Files:

  • Modify: backend/app/crud/qc_task.py(list_mine_tasks 的 order_by,约 637–644 行)

  • [ ] Step 1: Wire order_by

增加导入:

from app.crud.qc_inbound_lab_storage_sort import (
    inbound_lab_storage_order_by_clauses,
    uses_inbound_lab_storage_list_sort,
)

将 list_mine_tasks 内:

    rows = (
        _filtered()
        .order_by(
            BizQcTask.create_time.desc(),
            BizQcWorkOrder.work_order_no.desc(),
            BizQcTask.task_no.asc(),
        )
        .offset((page - 1) * page_size)
        .limit(page_size)
        .all()
    )

替换为:

    q_page = _filtered()
    if uses_inbound_lab_storage_list_sort(reception_source):
        q_page = q_page.order_by(
            *inbound_lab_storage_order_by_clauses(),
            BizQcWorkOrder.work_order_no.desc(),
            BizQcTask.task_no.asc(),
        )
    else:
        q_page = q_page.order_by(
            BizQcTask.create_time.desc(),
            BizQcWorkOrder.work_order_no.desc(),
            BizQcTask.task_no.asc(),
        )
    rows = (
        q_page
        .offset((page - 1) * page_size)
        .limit(page_size)
        .all()
    )

注意:确认 list_mine_tasks 形参名确为 reception_source(与现有签名一致)。

  • Step 2: Sanity import check
cd backend && source venv/bin/activate && python -c "from app.crud.qc_task import list_mine_tasks; print('ok')"

Expected: ok

  • Step 3: Commit(仅当用户明确要求提交时执行)
git add backend/app/crud/qc_task.py
git commit -m "$(cat <<'EOF'
feat: order mine QC tasks by lab storage code on inbound tabs

EOF
)"

Task 4: 前端统一比较工具

Files:

  • Create: web-admin/src/utils/qc-inbound-lab-storage-sort.ts

web-admin 无单测 runner;用纯函数 + 节点内联断言冒烟(或手动对照设计文档)。

  • Step 1: Add compare helpers
// web-admin/src/utils/qc-inbound-lab-storage-sort.ts

/** 编码 DESC → 时间 DESC;负值表示 a 排在 b 前(与后端一致) */
export function compareLabStorageThenCreateTimeDesc(
  codeA: string | null | undefined,
  timeA: string | null | undefined,
  codeB: string | null | undefined,
  timeB: string | null | undefined
): number {
  const ca = String(codeA || '').trim()
  const cb = String(codeB || '').trim()
  if (ca !== cb) {
    return ca > cb ? -1 : 1
  }
  const ta = String(timeA || '').trim()
  const tb = String(timeB || '').trim()
  if (ta !== tb) {
    return ta > tb ? -1 : 1
  }
  return 0
}

export function resolveGroupLabStorageCode(
  rows: { labStorageCode?: string | null }[]
): string {
  for (const row of rows) {
    const code = String(row.labStorageCode || '').trim()
    if (code) return code
  }
  return ''
}

export function resolveGroupMaxCreateTime(
  rows: { createTime?: string | null }[]
): string {
  let max = ''
  for (const row of rows) {
    const t = String(row.createTime || '')
    if (t > max) max = t
  }
  return max
}
  • Step 2: Smoke-check in Node(可选)

在仓库根用短脚本确认比较方向(复制函数体或通过构建后的 bundle 非必须;优先在实现后浏览器验)。

  • Step 3: Commit(仅当用户明确要求提交时执行)
git add web-admin/src/utils/qc-inbound-lab-storage-sort.ts
git commit -m "$(cat <<'EOF'
feat: add frontend inbound lab storage sort compare helpers

EOF
)"

Task 5: 入库组内与样本卡片排序

Files:

  • Modify: web-admin/src/utils/qc-work-order-groups.ts
  • Modify: web-admin/src/utils/qc-work-order-inbound-sample-cards.ts

  • [ ] Step 1: Inbound-only within-group sort

在 qc-work-order-groups.ts:

保留现有 sortWorkOrdersInGroup(出库继续用)。新增:

function sortWorkOrdersInInboundGroup(rows: QcWorkOrder[]) {
  return [...rows].sort((a, b) =>
    compareLabStorageThenCreateTimeDesc(
      '', // 同组编码相同,仅比时间;传空避免干扰
      a.createTime,
      '',
      b.createTime
    )
  )
}

更直接:

function sortWorkOrdersInInboundGroup(rows: QcWorkOrder[]) {
  return [...rows].sort((a, b) =>
    String(b.createTime || '').localeCompare(String(a.createTime || ''))
  )
}

将 groupInboundQcWorkOrders 内 sortWorkOrdersInGroup(workOrders) 改为 sortWorkOrdersInInboundGroup(workOrders)。

groupOutboundQcWorkOrders 仍调用 sortWorkOrdersInGroup。

顶部增加(若用 compare 工具则 import;若仅 localeCompare 可省略):

// 无需改 outbound
  • Step 2: Card list order in buildInboundSampleListItems

在 qc-work-order-inbound-sample-cards.ts 增加 import:

import {
  compareLabStorageThenCreateTimeDesc,
  resolveGroupLabStorageCode,
  resolveGroupMaxCreateTime
} from '@/utils/qc-inbound-lab-storage-sort'

将末尾:

  const sortedGroups = [...groups].sort((a, b) => {
    const headA = a.workOrders[0]
    const headB = b.workOrders[0]
    if (!headA || !headB) return 0
    const protocolA = resolveInboundProtocolGroupKey(headA)
    const protocolB = resolveInboundProtocolGroupKey(headB)
    if (protocolA !== protocolB) {
      return protocolSortTime(b).localeCompare(protocolSortTime(a))
    }
    return (a.sampleCode || '').localeCompare(b.sampleCode || '')
  })

替换为:

  const sortedGroups = [...groups].sort((a, b) =>
    compareLabStorageThenCreateTimeDesc(
      resolveGroupLabStorageCode(a.workOrders),
      resolveGroupMaxCreateTime(a.workOrders),
      resolveGroupLabStorageCode(b.workOrders),
      resolveGroupMaxCreateTime(b.workOrders)
    )
  )

若 protocolSortTime 不再被本文件使用,删除该死函数。

同协议胶囊内按 sampleCode 的排序(buildLinksFromVisibleGroups 等)保持不变。

  • Step 3: Commit(仅当用户明确要求提交时执行)
git add web-admin/src/utils/qc-work-order-groups.ts web-admin/src/utils/qc-work-order-inbound-sample-cards.ts
git commit -m "$(cat <<'EOF'
feat: sort inbound QC sample cards by lab storage code then create time

EOF
)"

Task 6: 我的质检任务分页前排序

Files:

  • Modify: web-admin/src/utils/qc-mine-task-groups.ts
  • Modify: web-admin/src/views/quality-inspection/qc-work-order/composables/useQcWorkOrderTabPage.ts

  • [ ] Step 1: Export sorter

在 qc-mine-task-groups.ts 增加:

import { compareLabStorageThenCreateTimeDesc } from '@/utils/qc-inbound-lab-storage-sort'
import { isInboundSampleQcTab } from '@/utils/qc-work-order-tabs'

export function sortMineEntriesForInboundLabStorage(
  entries: MineWorkOrderEntry[]
): MineWorkOrderEntry[] {
  return [...entries].sort((a, b) =>
    compareLabStorageThenCreateTimeDesc(
      a.workOrder.labStorageCode,
      a.workOrder.createTime,
      b.workOrder.labStorageCode,
      b.workOrder.createTime
    )
  )
}

确认 isInboundSampleQcTab 已从 tabs 导出;若已有同类 import,合并到现有 import 块。

  • Step 2: Call before paginate in mine loadData

在 useQcWorkOrderTabPage.ts 的 mine 分支,在 filterMineEntriesByWorkOrderSearch 之后、paginateMineEntries 之前:

        let entries = groupMineTasksByWorkOrder(tasks)
        entries = filterMineEntriesByWorkOrderSearch(entries, buildMineWorkOrderSearchFilters())
        if (isInboundSampleQcTab(tabKey.value)) {
          entries = sortMineEntriesForInboundLabStorage(entries)
        }
        const pageResult = paginateMineEntries(entries, pagination.page, pagination.pageSize)

补充 import:sortMineEntriesForInboundLabStorage、isInboundSampleQcTab(后者可能已导入)。

  • Step 3: Commit(仅当用户明确要求提交时执行)
git add web-admin/src/utils/qc-mine-task-groups.ts web-admin/src/views/quality-inspection/qc-work-order/composables/useQcWorkOrderTabPage.ts
git commit -m "$(cat <<'EOF'
feat: sort mine inbound QC entries by lab storage code before paging

EOF
)"

Task 7: 图谱更新与手工验收

Files:

  • 无业务代码;跑 scripts/update-graph.sh

  • [ ] Step 1: Update knowledge graph

./scripts/update-graph.sh all
  • Step 2: Manual QA checklist
  1. 打开 /#/quality-group/quality-manage/qc-work-order?tab=inbound_cell_storage:卡片标题(实验室入库编码)整体倒序;同编码时创建时间新的在上;翻页后仍符合该序。
  2. 切换 inbound_research_donation:同上。
  3. /#/quality-group/quality-manage/my-qc-task 对应两 Tab:同上。
  4. 无编码卡片按空串参与倒序(相对有编码的位置与字符串 DESC 一致)。
  5. 「全部 / 出库 / 临时」Tab 顺序与交互无明显回归;同协议样本胶囊顺序仍按样本编码。
  • Step 3: Mark design status(可选)

将 docs/superpowers/specs/2026-08-17-qc-inbound-lab-storage-code-sort-design.md 状态改为 已实现。


Spec coverage (self-review)

Spec 项 Task
仅两页两 Tab Task 2/3 条件 uses_inbound_*;Task 5/6 仅 inbound
编码 DESC + 时间 DESC Task 1–6
空串参与倒序 Task 1 测试 + COALESCE/COALESCE
后端分页 Task 2
我的任务 Task 3 + Task 6
组内时间倒序、出库不变 Task 5
互链不改 Task 5 明确保留
无 DB 迁移 全程 JOIN/子查询

无 TBD 占位;前后端比较函数命名与语义一致(负值 = a 在前)。