Compare commits

..
Author SHA1 Message Date
zlwuandCursor c0aba421da fix(lab-sample): 新旧实验室样本台账补录双向联动
新模块读取旧台账补录并映射字段;旧台账保存时同步到新补充表与生产传染四项,新模块保存时回写旧补录。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:19:19 +08:00
zlwuandCursor 2aecb69cc4 fix(lab-sample): 细胞编码、母血等字段与传染四项显示
列表细胞编码改展示实验室入库编码;从母血采集时间、协议附件与收样检查推导是否有母血等列;传染四项 zc/bzc 显示为正常/不正常。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:01:05 +08:00
zlwuandCursor 2878c6889d fix(lab-sample): 归一化市场生产质检字段 ACL 授权
撤销历史过宽角色菜单后,仅按部门员工/主管/只读角色重授,确保员工仅可编辑本部门字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 15:54:59 +08:00
zlwuandCursor 4aee2b1fe5 fix(web-admin): 恢复实验室样本处理三表前端路由
合入 #100 后静态路由缺失导致刷新后顶级菜单与页面不可达,补回 hsc/ubmc/msc 路由注册。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 15:47:03 +08:00
zlwuandCursor 5698a9dfc4 fix(lab-sample): 台账改聚合真实市场生产质检数据
去掉 Excel 演示独有行,源表字段优先于补充表;按协议样本与科研捐献接收关联已发生业务数据,并修复导出分页超限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 15:41:26 +08:00
zlwuandCursor 77d45d0bc5 feat(menu): 实验室样本处理数据挂到市场/生产/质检并分端授权
continuous-integration/drone/tag Build is passing
质检入口调到入库放行审核下方;存储服务挂缴费财务确认下方;生产管理挂废弃物管理下方。三处权限码分离,拥有对应顶级菜单的角色授予本端入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 14:18:27 +08:00
cykj dc0f8541c4 merge: 合入 #100 移除多余一级菜单实验室样本处理数据
merge: 合入 #100 移除多余一级菜单实验室样本处理数据
2026-11-12 06:02:59 +00:00
zlwuandCursor 4fd6ef4be5 fix(web-admin): 移除多余一级菜单实验室样本处理数据
软删除 menu:lab-sample-processing 及子菜单/按钮与角色授权,并去掉静态路由;保留质检下 lab-sample-process 台账。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 14:01:46 +08:00
zlwuandCursor b3c191ee99 fix(web-admin): 实验室样本处理数据 Tab 对齐样本接收胶囊样式
与质检样本接收页共用 panel-pill-tabs,保持质检管理列表页视觉一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 13:33:44 +08:00
zlwu bbef46b645 merge: 合入 #99 实验室样本处理三表台账与部门字段 ACL 2026-11-12 12:50:29 +08:00
11 changed files with 1108 additions and 96 deletions

No files matched your search

+15 -6
View File
@@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.menu_guard import require_menu_code
from app.core.menu_guard import require_any_menu_code
from app.core.response import error_response, success_response
from app.crud import lab_sample_process as svc
from app.models.models import SysUser
@@ -14,8 +14,17 @@ from app.services import lab_sample_process as ledger
router = APIRouter(prefix="/web/admin", tags=["质检管理"])
LIST_MENU = "biz:labSampleProcess:list"
SUPPLEMENT_MENU = "biz:labSampleProcess:supplement"
# 市场/生产/质检三处菜单权限码不同,接口按并集鉴权
LIST_MENU_CODES = (
"biz:labSampleProcess:list",
"production:labSampleProcess:list",
"cellStorage:labSampleProcess:list",
)
SUPPLEMENT_MENU_CODES = (
"biz:labSampleProcess:supplement",
"production:labSampleProcess:supplement",
"cellStorage:labSampleProcess:supplement",
)
def _tenant_no(db: Session, user: dict) -> str:
@@ -59,7 +68,7 @@ def list_lab_sample_process(
createTimeStart: Optional[str] = Query(None),
createTimeEnd: Optional[str] = Query(None),
db: Session = Depends(get_db),
user=Depends(require_menu_code(LIST_MENU)),
user=Depends(require_any_menu_code(*LIST_MENU_CODES)),
):
try:
tenant_no = _tenant_no(db, user)
@@ -98,7 +107,7 @@ def list_lab_sample_process(
def get_supplement(
labStorageCode: str = Query(...),
db: Session = Depends(get_db),
user=Depends(require_menu_code(LIST_MENU)),
user=Depends(require_any_menu_code(*LIST_MENU_CODES)),
):
try:
tenant_no = _tenant_no(db, user)
@@ -114,7 +123,7 @@ def get_supplement(
def save_supplement(
body: SupplementBody,
db: Session = Depends(get_db),
user=Depends(require_menu_code(SUPPLEMENT_MENU)),
user=Depends(require_any_menu_code(*SUPPLEMENT_MENU_CODES)),
):
try:
tenant_no = _tenant_no(db, user)
+11
View File
@@ -179,6 +179,17 @@ def upsert_supplement(
del_flag=0,
)
db.add(row)
# 同步到新模块补充表/生产接收,保证实验室样本处理数据联动
from app.services.lab_sample_processing import sync_legacy_supplement_to_processing
sync_legacy_supplement_to_processing(
db,
tenant_no=tenant_no,
lab_storage_code=code,
sample_type_name=type_name,
payload=clean_payload,
operator_id=user_id,
)
db.commit()
db.refresh(row)
return get_supplement(db, tenant_no=tenant_no, lab_storage_code=code) or {}
+616 -54
View File
@@ -1,4 +1,8 @@
"""实验室样本处理数据 — 聚合读取、Excel 导入与部门字段回写。"""
"""实验室样本处理数据 — 聚合市场/生产/质检已发生业务数据与部门字段回写。
Excel 仅作为字段目录示例;列表以协议样本为主表,关联样本接收与生产接收,
补充表只承载源系统没有的列,且不得覆盖源表字段。
"""
from __future__ import annotations
import json
@@ -7,17 +11,21 @@ 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 (
BizCellSampleType,
BizCollectionHospital,
BizCollectionProtocol,
BizProtocolCollectionInfo,
BizProtocolPartyCustomer,
BizProtocolSample,
BizSampleProductionReception,
BizSampleReception,
BizSampleReceptionCheckResult,
)
from app.services.lab_sample_processing_fields import (
DATASET_HSC,
@@ -106,10 +114,332 @@ def _code_prefix_filters(dataset: str) -> List[str]:
def _match_keyword(row: Dict[str, Any], kw: str) -> bool:
if not kw:
return True
text = f"{row.get('sampleCode') or ''} {row.get('motherName') or ''}"
text = (
f"{row.get('sampleCode') or ''} "
f"{row.get('protocolSampleCode') or ''} "
f"{row.get('motherName') or ''}"
)
return kw.lower() in text.lower()
def _infectious_label(code: Optional[str]) -> Optional[str]:
c = ("" if code is None else str(code)).strip()
if not c:
return None
if c == "zc":
return "正常"
if c == "bzc":
return "不正常"
return c
def _infectious_code(label: Optional[Any]) -> Optional[str]:
if label is None:
return None
s = str(label).strip()
if not s:
return None
if s == "正常":
return "zc"
if s == "不正常":
return "bzc"
return s
def _yes_no_from_bool(flag: Optional[bool]) -> Optional[str]:
if flag is None:
return None
return "是" if flag else "否"
def _derive_receive_check(flags: Dict[str, bool]) -> Optional[str]:
if not flags:
return None
skip = {"知情同意书", "健康调查表", "样本信息正确,标识清晰"}
preferred = [
"针路密闭",
"容器无破损",
"外观正常",
"样本无破损",
"针卡关紧",
"两端系紧",
"温度4-25",
"采集至到库时间≤24小时",
]
parts = [name for name in preferred if flags.get(name)]
if not parts:
parts = [name for name, checked in flags.items() if checked and name not in skip]
return "、".join(parts) if parts else None
def _check_flag_map(
db: Session, tenant_no: str, reception_ids: List[str]
) -> Dict[str, Dict[str, bool]]:
ids = [i for i in reception_ids if i]
if not ids:
return {}
rows = (
db.query(BizSampleReceptionCheckResult)
.filter(
BizSampleReceptionCheckResult.tenant_no == tenant_no,
BizSampleReceptionCheckResult.del_flag == 0,
BizSampleReceptionCheckResult.reception_id.in_(ids),
)
.all()
)
out: Dict[str, Dict[str, bool]] = {}
for r in rows:
bucket = out.setdefault(r.reception_id, {})
name = (r.item_name or "").strip()
if name:
bucket[name] = bool(r.checked)
return out
def _pick_extra(
extras: Dict[str, Any], *codes: Optional[str]
) -> Dict[str, Any]:
for code in codes:
key = (code or "").strip()
if key and key in extras:
return _parse_extra(extras[key].payload_json)
return {}
# 旧台账补录字段名 → 新模块字段名(同名键保持不变)
_LEGACY_SUPPLEMENT_TO_NEW: Dict[str, str] = {
"receiveCheckText": "receiveCheck",
"infectiousFourTestLabel": "infectiousFourCard",
"preOpNucleatedCount": "preOpTnc",
"preEnrichNucleatedCount": "preEnrichTnc",
"postEnrichNucleatedCount": "postEnrichTnc",
"cd34Count1e4": "cd34Count",
"transportPackingList": "hasPackingList",
"cordBloodReceiveConfirm": "hasReceiveConfirm",
"remarkProcess": "processRemark",
"motherHbsag": "hbsag",
"motherAntiHcv": "antiHcv",
"motherAntiTp": "antiTp",
"motherAntiHiv": "antiHiv",
"motherCmvIgm": "cmvIgm",
}
_NEW_TO_LEGACY_SUPPLEMENT: Dict[str, str] = {
v: k for k, v in _LEGACY_SUPPLEMENT_TO_NEW.items()
}
# MSC 等仍使用 mother* 键名,双向都保留
for _same in (
"labMaterialCode",
"storageStatus",
"discardReason",
"hasMaternalBlood",
"remeasureMaternalBlood",
"infectiousFourExternal",
"endotoxinTest",
"bacteriaTest",
"fungusTest",
"mycoplasmaTest",
"motherHbsag",
"motherHbvDna",
"motherAntiHcv",
"motherHcvRna",
"motherAntiTp",
"motherCmvIgm",
"motherAntiHiv",
"motherHiv1Rna",
"storageDateP2",
"storageTotalTubes",
"availableCellCount1e7",
):
_LEGACY_SUPPLEMENT_TO_NEW.setdefault(_same, _same)
_NEW_TO_LEGACY_SUPPLEMENT.setdefault(_same, _same)
def _is_blank(val: Any) -> bool:
return val is None or (isinstance(val, str) and not val.strip())
def _map_legacy_supplement(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
for key, value in (payload or {}).items():
if _is_blank(value):
continue
new_key = _LEGACY_SUPPLEMENT_TO_NEW.get(str(key), str(key))
out[new_key] = value
# MSC 场景保留 mother* 原键,避免被 UBMC 别名覆盖后丢失
if str(key).startswith("mother") and new_key != str(key):
out[str(key)] = value
return out
def _map_new_to_legacy_supplement(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
for key, value in (payload or {}).items():
if _is_blank(value):
continue
legacy_key = _NEW_TO_LEGACY_SUPPLEMENT.get(str(key), str(key))
out[legacy_key] = value
return out
def _merge_extra_with_legacy(
extra: Optional[Dict[str, Any]], legacy: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
"""新补充表优先;旧台账补录填空(含字段名映射)。"""
mapped = _map_legacy_supplement(legacy)
merged = dict(mapped)
for key, value in (extra or {}).items():
if not _is_blank(value):
merged[key] = value
return merged
def _load_legacy_supplements(
db: Session, tenant_no: str, codes: List[str]
) -> Dict[str, Dict[str, Any]]:
from app.models.models import BizLabSampleProcessSupplement
from app.services import lab_sample_process as legacy_ledger
uniq = [c for c in {(x or "").strip() for x in codes} if c]
if not uniq:
return {}
rows = (
db.query(BizLabSampleProcessSupplement)
.filter(
BizLabSampleProcessSupplement.tenant_no == tenant_no,
BizLabSampleProcessSupplement.del_flag == 0,
BizLabSampleProcessSupplement.lab_storage_code.in_(uniq),
)
.all()
)
out: Dict[str, Dict[str, Any]] = {}
for row in rows:
out[row.lab_storage_code] = legacy_ledger.parse_payload(row.payload)
return out
def sync_legacy_supplement_to_processing(
db: Session,
*,
tenant_no: str,
lab_storage_code: str,
sample_type_name: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None,
operator_id: str = "legacy-sync",
) -> None:
"""旧台账补录保存后,同步到新模块补充表,并把传染四项写回生产接收。"""
code = (lab_storage_code or "").strip()
if not code:
return
mapped = _map_legacy_supplement(payload)
if not mapped:
return
dataset = resolve_dataset_by_type_name(sample_type_name or "", code)
if not dataset:
if code.upper().startswith("UBMC"):
dataset = DATASET_UBMC
elif code.upper().startswith(("BC", "UC")):
dataset = DATASET_MSC
else:
dataset = DATASET_HSC
upsert_extra_payload(
db,
tenant_no=tenant_no,
dataset=dataset,
sample_code=code,
payload=mapped,
operator_id=operator_id,
replace=False,
)
infectious = mapped.get("infectiousFourCard")
if infectious not in (None, ""):
reception = (
db.query(BizSampleReception)
.filter(
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.del_flag == 0,
or_(
BizSampleReception.lab_storage_code == code,
BizSampleReception.sample_code == code,
),
)
.order_by(BizSampleReception.reception_time.desc())
.first()
)
if reception:
production = (
db.query(BizSampleProductionReception)
.filter(
BizSampleProductionReception.tenant_no == tenant_no,
BizSampleProductionReception.reception_id == reception.id,
BizSampleProductionReception.del_flag == 0,
)
.order_by(BizSampleProductionReception.process_time.desc())
.first()
)
if production is not None:
production.infectious_four_test_result = _infectious_code(infectious)
production.update_by = operator_id
production.update_time = datetime.now()
def sync_processing_to_legacy_supplement(
db: Session,
*,
tenant_no: str,
lab_storage_code: str,
sample_type_name: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None,
operator_id: str = "processing-sync",
) -> None:
"""新模块保存后,回写旧台账补录,保持两边可见字段一致。"""
from app.models.models import BizLabSampleProcessSupplement
from app.services import lab_sample_process as legacy_ledger
code = (lab_storage_code or "").strip()
mapped = _map_new_to_legacy_supplement(payload)
if not code or not mapped:
return
now = datetime.now()
row = (
db.query(BizLabSampleProcessSupplement)
.filter(
BizLabSampleProcessSupplement.tenant_no == tenant_no,
BizLabSampleProcessSupplement.lab_storage_code == code,
BizLabSampleProcessSupplement.del_flag == 0,
)
.first()
)
current = legacy_ledger.parse_payload(row.payload) if row else {}
current.update(mapped)
src = "research_donation" if code.upper().startswith("UBMC") else "cell_storage"
if row is None:
db.add(
BizLabSampleProcessSupplement(
id=_new_id(),
lab_storage_code=code,
reception_source=src,
sample_type_name=(sample_type_name or None),
payload=legacy_ledger.dumps_payload(current),
tenant_no=tenant_no,
create_by=operator_id,
create_dept_id="",
create_time=now,
update_by=operator_id,
update_time=now,
del_flag=0,
)
)
else:
if sample_type_name:
row.sample_type_name = sample_type_name
row.payload = legacy_ledger.dumps_payload(current)
row.update_by = operator_id
row.update_time = now
def _match_date_range(row: Dict[str, Any], date_from: Optional[str], date_to: Optional[str]) -> bool:
if not date_from and not date_to:
return True
@@ -215,23 +545,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(
@@ -244,13 +590,55 @@ def list_rows(
):
productions.setdefault(p.reception_id, p)
protocol_ids = sorted(
{
s.collection_protocol_id
for s, *_ in filtered
if getattr(s, "collection_protocol_id", None)
}
)
protocols: Dict[str, BizCollectionProtocol] = {}
if protocol_ids:
for p in (
db.query(BizCollectionProtocol)
.filter(
BizCollectionProtocol.tenant_no == tenant_no,
BizCollectionProtocol.del_flag == 0,
BizCollectionProtocol.id.in_(protocol_ids),
)
.all()
):
protocols[p.id] = p
reception_ids_for_flags = list(
{
*(r.id for r in receptions_by_sample_id.values() if r and r.id),
*(r.id for r in receptions_by_code.values() if r and r.id),
}
)
check_flags_by_reception = _check_flag_map(db, tenant_no, reception_ids_for_flags)
legacy_codes: List[str] = []
for sample, *_ in filtered:
legacy_codes.append(sample.sample_code or "")
reception = receptions_by_sample_id.get(sample.id) or receptions_by_code.get(sample.sample_code or "")
if reception and reception.lab_storage_code:
legacy_codes.append(reception.lab_storage_code)
legacy_supplements = _load_legacy_supplements(db, tenant_no, legacy_codes)
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(
lab_code = (reception.lab_storage_code if reception else None) or ""
extra = _merge_extra_with_legacy(
_pick_extra(extras, lab_code, code),
legacy_supplements.get(lab_code) or legacy_supplements.get(code or ""),
)
protocol = protocols.get(sample.collection_protocol_id) if sample.collection_protocol_id else None
check_flags = check_flags_by_reception.get(reception.id, {}) if reception else {}
row = _build_row(
dataset=dataset,
sample=sample,
type_name=(stype.type_name if stype else None),
@@ -260,26 +648,109 @@ def list_rows(
reception=reception,
production=production,
extra=extra,
protocol=protocol,
check_flags=check_flags,
)
built_map[row.get("sampleCode") or code] = row
# 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)
ubmc_flags = _check_flag_map(
db,
tenant_no,
[r.id for r in ubmc_recs if r.id and r.id not in check_flags_by_reception],
)
check_flags_by_reception.update(ubmc_flags)
ubmc_legacy = _load_legacy_supplements(
db,
tenant_no,
[
(r.lab_storage_code or r.sample_code or "")
for r in ubmc_recs
],
)
legacy_supplements.update(ubmc_legacy)
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 = _merge_extra_with_legacy(
_pick_extra(extras, code, reception.sample_code),
legacy_supplements.get(code)
or legacy_supplements.get((reception.sample_code or "").strip()),
)
mother = mother_by_donation.get(str(reception.research_donation_id or ""))
fake_sample = SimpleNamespace(
id=reception.donation_sample_id or reception.id,
sample_code=reception.sample_code or 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,
protocol=None,
check_flags=check_flags_by_reception.get(reception.id, {}),
)
kw = (keyword or "").strip()
built = []
@@ -311,9 +782,23 @@ def _build_row(
reception: Optional[BizSampleReception],
production: Optional[BizSampleProductionReception],
extra: Dict[str, Any],
protocol: Optional[BizCollectionProtocol] = None,
check_flags: Optional[Dict[str, bool]] = None,
) -> Dict[str, Any]:
flags = check_flags or {}
protocol_code = (
(sample.sample_code if sample else None)
or extra.get("protocolSampleCode")
or ""
)
lab_code = ((reception.lab_storage_code if reception else None) or "").strip()
# 台账「细胞编码」展示实验室入库编码(B000…),协议样本号保留检索
display_code = lab_code if lab_code and len(lab_code) > 1 else (
protocol_code or extra.get("sampleCode") or ""
)
protocol_vals = {
"sample_code": sample.sample_code if sample else extra.get("sampleCode"),
"sample_code": display_code,
"mother_name": party.mother_name if party else None,
"sample_collect_time": coll.sample_collect_time if coll else None,
"hospital_name": hospital.hospital_name if hospital else None,
@@ -346,15 +831,11 @@ def _build_row(
"available_cell_count_1e7": production.available_cell_count_1e7,
}
sample_code = (
(sample.sample_code if sample else None)
or extra.get("sampleCode")
or ""
)
data: Dict[str, Any] = {
"id": (sample.id if sample else f"extra:{dataset}:{sample_code}"),
"id": (sample.id if sample else f"extra:{dataset}:{display_code}"),
"dataset": dataset,
"sampleCode": sample_code,
"sampleCode": display_code,
"protocolSampleCode": protocol_code or None,
"sampleTypeName": type_name,
"protocolSampleId": sample.id if sample else None,
"collectionProtocolId": sample.collection_protocol_id if sample else None,
@@ -371,19 +852,57 @@ 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 f.source != SOURCE_EXTRA and _is_blank(val):
fallback = extra.get(f.key)
if not _is_blank(fallback):
val = fallback
if isinstance(val, datetime):
val = _fmt_dt(val)
elif isinstance(val, Decimal):
val = _fmt_num(val)
data[f.key] = val
if not data.get("sampleCode"):
data["sampleCode"] = sample_code
data["sampleCode"] = display_code
# 从协议采集信息 / 附件 / 收样检查项推导补充列(补充表有值时不覆盖)
if not data.get("hasMaternalBlood"):
if coll is not None:
data["hasMaternalBlood"] = "是" if coll.maternal_blood_collect_time else "否"
if not data.get("hasConsent"):
if protocol and (protocol.consent_form_urls or "").strip():
data["hasConsent"] = "是"
else:
flagged = _yes_no_from_bool(flags.get("知情同意书"))
if flagged is not None:
data["hasConsent"] = flagged
elif protocol is not None:
data["hasConsent"] = "否"
if not data.get("hasHealthSurvey"):
if protocol and (protocol.health_survey_form_urls or "").strip():
data["hasHealthSurvey"] = "是"
else:
flagged = _yes_no_from_bool(flags.get("健康调查表"))
if flagged is not None:
data["hasHealthSurvey"] = flagged
elif protocol is not None:
data["hasHealthSurvey"] = "否"
if not data.get("labelClear"):
data["labelClear"] = _yes_no_from_bool(flags.get("样本信息正确,标识清晰"))
if not data.get("receiveCheck"):
data["receiveCheck"] = _derive_receive_check(flags)
if data.get("infectiousFourCard") is not None:
data["infectiousFourCard"] = _infectious_label(data.get("infectiousFourCard"))
return data
@@ -473,6 +992,19 @@ def update_row(
if not accepted:
raise ValueError("没有可保存的字段")
reception = (
db.query(BizSampleReception)
.filter(
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.del_flag == 0,
or_(
BizSampleReception.lab_storage_code == code,
BizSampleReception.sample_code == code,
),
)
.order_by(BizSampleReception.reception_time.desc())
.first()
)
sample = (
db.query(BizProtocolSample)
.filter(
@@ -482,16 +1014,30 @@ def update_row(
)
.first()
)
reception = (
db.query(BizSampleReception)
.filter(
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.sample_code == code,
BizSampleReception.del_flag == 0,
if sample is None and reception is not None and reception.protocol_sample_id:
sample = (
db.query(BizProtocolSample)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.id == reception.protocol_sample_id,
BizProtocolSample.del_flag == 0,
)
.first()
)
if reception is None and sample is not None:
reception = (
db.query(BizSampleReception)
.filter(
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.del_flag == 0,
or_(
BizSampleReception.protocol_sample_id == sample.id,
BizSampleReception.sample_code == sample.sample_code,
),
)
.order_by(BizSampleReception.reception_time.desc())
.first()
)
.order_by(BizSampleReception.reception_time.desc())
.first()
)
production = None
if reception:
production = (
@@ -585,9 +1131,12 @@ def update_row(
elif f.attr in numeric_attrs:
production_val = None if value in (None, "") else Decimal(str(value))
setattr(production, f.attr, production_val)
elif f.attr == "infectious_four_test_result":
production.infectious_four_test_result = (
None if value in (None, "") else _infectious_code(value)
)
elif f.attr in {
"blood_type",
"infectious_four_test_result",
"handler_name",
"remark",
"inoculation_amount",
@@ -610,11 +1159,24 @@ def update_row(
operator_id=operator_id,
replace=False,
)
legacy_code = (
(reception.lab_storage_code if reception else None)
or (sample.sample_code if sample else None)
or code
)
sync_processing_to_legacy_supplement(
db,
tenant_no=tenant_no,
lab_storage_code=str(legacy_code),
sample_type_name=None,
payload=extra_payload,
operator_id=operator_id,
)
db.commit()
rows, _ = list_rows(db, tenant_no=tenant_no, dataset=dataset, keyword=code, page=1, page_size=20)
for row in rows:
if row.get("sampleCode") == code:
if row.get("sampleCode") == code or row.get("protocolSampleCode") == code:
return row
raise LookupError("保存成功但读取失败")
+133
View File
@@ -48,6 +48,139 @@ 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 datetime import datetime
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,
maternal_blood_collect_time=datetime(2026, 11, 4, 5, 20),
)
hospital = SimpleNamespace(hospital_name="真实医院")
protocol = SimpleNamespace(
consent_form_urls="protocol-consent/a",
health_survey_form_urls="",
)
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="zc",
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",
},
protocol=protocol,
check_flags={
"健康调查表": True,
"样本信息正确,标识清晰": True,
"针路密闭": True,
"容器无破损": True,
},
)
self.assertEqual(row["sampleCode"], "B0000103")
self.assertEqual(row["protocolSampleCode"], "ZKZX34000865")
self.assertEqual(row["motherName"], "真实妈妈")
self.assertEqual(row["originalBloodWeightG"], 72.08)
self.assertEqual(row["bloodType"], "B")
self.assertEqual(row["cd34Count"], "11.4")
self.assertEqual(row["hasMaternalBlood"], "是")
self.assertEqual(row["hasConsent"], "是")
self.assertEqual(row["hasHealthSurvey"], "是")
self.assertEqual(row["labelClear"], "是")
self.assertEqual(row["infectiousFourCard"], "正常")
self.assertIn("针路密闭", row["receiveCheck"] or "")
self.assertFalse(row["fromExtraOnly"])
def test_infectious_code_roundtrip(self):
from app.services.lab_sample_processing import _infectious_code, _infectious_label
self.assertEqual(_infectious_label("zc"), "正常")
self.assertEqual(_infectious_label("bzc"), "不正常")
self.assertEqual(_infectious_code("正常"), "zc")
self.assertEqual(_infectious_code("不正常"), "bzc")
def test_legacy_supplement_fills_empty_source_fields(self):
from app.services.lab_sample_processing import _build_row, _merge_extra_with_legacy
from types import SimpleNamespace
extra = _merge_extra_with_legacy(
{},
{"infectiousFourTestLabel": "正常", "labMaterialCode": "202611006"},
)
sample = SimpleNamespace(id="S1", sample_code="UBMC202611006", collection_protocol_id=None)
reception = SimpleNamespace(
id="R1",
reception_time=None,
receive_temperature=None,
handler_name=None,
remark=None,
lab_storage_code="UBMC202611006",
)
production = SimpleNamespace(
id="PR1",
original_blood_weight_g=None,
hes_volume_ml=None,
blood_type=None,
infectious_four_test_result=None,
enriched_blood_weight_g=None,
cryopreservation_volume_ml=None,
handler_name=None,
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_UBMC,
sample=sample,
type_name="脐血单个核细胞",
party=None,
coll=None,
hospital=None,
reception=reception,
production=production,
extra=extra,
)
self.assertEqual(row["infectiousFourCard"], "正常")
self.assertEqual(row["labMaterialCode"], "202611006")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,21 @@
-- 实验室样本处理 ACL 归一化(可重复执行)
-- 1) 撤销所有 lab:sample-processing* / menu:lab-sample-processing 角色菜单
-- 2) 仅按下列角色重授:
-- 市场编辑:市场专员/临床销售/销售助理/客服/采集专员
-- 生产编辑:生产专员/实验专员/细胞实验员/研发专员
-- 质检编辑:质检专员/质量安全员
-- 主管:生产主管/质量主管/采集主管/采集经理/研发主管/研发总监/总经理/副总经理/董事长/超级管理员
-- 只读:采集接受人
-- 说明:正式执行请用 Agent 脚本 tmp_normalize_lab_acl.py(含幂等 undelete/insert)。
-- 本文件作为规则记录;勿在未核对角色名时直接全量套用到其他环境。
UPDATE sys_role_menu rm
JOIN sys_menu m ON m.id = rm.menu_id
SET rm.del_flag = 1,
rm.update_by = 'normalize-acl',
rm.update_time = NOW()
WHERE rm.del_flag = 0
AND (
m.perms LIKE 'lab:sample-processing%'
OR m.perms = 'menu:lab-sample-processing'
);
@@ -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;
@@ -0,0 +1,175 @@
-- Issue #102:实验室样本处理数据挂到市场/生产/质检,权限码分端
-- 1) 质检管理:调到「入库放行审核」下方
-- 2) 存储服务:挂到「缴费财务确认」下方(cellStorage:labSampleProcess:*)
-- 3) 生产管理:挂到「废弃物管理」下方(production:labSampleProcess:*;若废弃物菜单缺失则先补齐)
-- 4) 拥有 menu:market / menu:production / menu:quality 的角色,授予对应大菜单下入口
-- MySQL 5.7,可重复执行
-- ── 0) 生产管理补齐「废弃物管理」(仅当缺失时,便于排序锚定)────────────────
INSERT INTO sys_menu (
id, menu_name, parent_id, menu_type, path, component, perms,
is_frame, visible, status, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
INSERT(INSERT(MD5(CONCAT('prod-waste-output:', p.tenant_no, ':', p.id)), 13, 1, '3'), 17, 1, '8'),
'废弃物管理', p.id, 'C', 'waste-output',
'/production/waste-output/index', 'production:waste-output:list',
1, '1', '1', p.tenant_no,
COALESCE((SELECT MAX(c.order_num) FROM sys_menu c WHERE c.parent_id = p.id AND c.del_flag = 0), 0) + 1,
'issue#102 补齐生产废弃物管理以便台账排序',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu p
WHERE p.del_flag = 0 AND p.status = '1' AND p.menu_name = '生产管理'
AND NOT EXISTS (
SELECT 1 FROM sys_menu e
WHERE e.tenant_no = p.tenant_no AND e.parent_id = p.id
AND e.perms = 'production:waste-output:list' AND e.del_flag = 0
);
-- ── 1) 质检:调到入库放行审核下方(可重复;不连带改动其他菜单 order)────────
UPDATE sys_menu lab
JOIN sys_menu release_m
ON release_m.parent_id = lab.parent_id
AND release_m.del_flag = 0
AND release_m.menu_name = '入库放行审核'
AND release_m.perms = 'quality:storageReleaseReview:list'
SET lab.order_num = release_m.order_num + 1,
lab.update_by = 'migration',
lab.update_time = NOW()
WHERE lab.del_flag = 0
AND lab.perms = 'biz:labSampleProcess:list'
AND lab.path = 'lab-sample-process'
AND (lab.order_num IS NULL OR lab.order_num <> release_m.order_num + 1);
-- ── 2) 存储服务:缴费财务确认下方 ───────────────────────────────────────────
INSERT INTO sys_menu (
id, menu_name, parent_id, menu_type, path, component, perms,
is_frame, visible, status, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
INSERT(INSERT(MD5(CONCAT('cell-lab-sample-process:', anchor.tenant_no, ':', anchor.id)), 13, 1, '3'), 17, 1, '8'),
'实验室样本处理数据', anchor.parent_id, 'C', 'lab-sample-process',
'quality-inspection/lab-sample-process/index', 'cellStorage:labSampleProcess:list',
1, '1', '1', anchor.tenant_no, anchor.order_num + 1,
'市场-存储服务-实验室样本处理数据',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu anchor
WHERE anchor.del_flag = 0 AND anchor.status = '1'
AND anchor.perms = 'biz:cellStoragePaymentConfirm:list'
AND anchor.menu_name = '缴费财务确认'
AND NOT EXISTS (
SELECT 1 FROM sys_menu e
WHERE e.tenant_no = anchor.tenant_no
AND e.parent_id = anchor.parent_id
AND e.perms = 'cellStorage:labSampleProcess:list'
AND e.del_flag = 0
);
-- ── 3) 生产管理:废弃物管理下方 ─────────────────────────────────────────────
INSERT INTO sys_menu (
id, menu_name, parent_id, menu_type, path, component, perms,
is_frame, visible, status, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
INSERT(INSERT(MD5(CONCAT('prod-lab-sample-process:', anchor.tenant_no, ':', anchor.id)), 13, 1, '3'), 17, 1, '8'),
'实验室样本处理数据', anchor.parent_id, 'C', 'lab-sample-process',
'quality-inspection/lab-sample-process/index', 'production:labSampleProcess:list',
1, '1', '1', anchor.tenant_no, anchor.order_num + 1,
'生产-生产管理-实验室样本处理数据',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu anchor
WHERE anchor.del_flag = 0 AND anchor.status = '1'
AND anchor.perms = 'production:waste-output:list'
AND anchor.menu_name = '废弃物管理'
AND NOT EXISTS (
SELECT 1 FROM sys_menu e
WHERE e.tenant_no = anchor.tenant_no
AND e.parent_id = anchor.parent_id
AND e.perms = 'production:labSampleProcess:list'
AND e.del_flag = 0
);
-- ── 4) 补录 / 导出按钮(三处列表菜单各挂一套)──────────────────────────────
INSERT INTO sys_menu (
id, menu_name, parent_id, menu_type, path, component, perms,
is_frame, visible, status, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
INSERT(INSERT(MD5(CONCAT('lab-sp-btn:', m.tenant_no, ':', m.id, ':', btn.perms)), 13, 1, '3'), 17, 1, '8'),
btn.menu_name, m.id, 'F', '', NULL, btn.perms,
1, '1', '1', m.tenant_no, btn.order_num, btn.remark,
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu m
JOIN (
SELECT '补录' AS menu_name, 'cellStorage:labSampleProcess:supplement' AS perms, 10 AS order_num,
'存储服务-实验室样本处理数据-补录' AS remark, 'cellStorage:labSampleProcess:list' AS parent_perms
UNION ALL
SELECT '导出', 'cellStorage:labSampleProcess:export', 20,
'存储服务-实验室样本处理数据-导出', 'cellStorage:labSampleProcess:list'
UNION ALL
SELECT '补录', 'production:labSampleProcess:supplement', 10,
'生产-实验室样本处理数据-补录', 'production:labSampleProcess:list'
UNION ALL
SELECT '导出', 'production:labSampleProcess:export', 20,
'生产-实验室样本处理数据-导出', 'production:labSampleProcess:list'
) btn ON btn.parent_perms = m.perms
WHERE m.del_flag = 0 AND m.status = '1'
AND NOT EXISTS (
SELECT 1 FROM sys_menu e
WHERE e.tenant_no = m.tenant_no AND e.parent_id = m.id
AND e.perms = btn.perms AND e.del_flag = 0
);
-- ── 5) 按顶级大菜单授权对应入口(含列表/补录/导出)─────────────────────────
INSERT INTO sys_role_menu (
id, role_id, menu_id, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
INSERT(INSERT(MD5(CONCAT('lab-sp-rm:', r.tenant_no, ':', r.id, ':', m.id)), 13, 1, '3'), 17, 1, '8'),
r.id, m.id, r.tenant_no, NULL,
CONCAT('issue#102 grant from ', map.top_perms),
'migration', '', NOW(), 'migration', NOW(), 0
FROM (
SELECT 'menu:market' AS top_perms, 'cellStorage:labSampleProcess:list' AS list_perms
UNION ALL SELECT 'menu:market', 'cellStorage:labSampleProcess:supplement'
UNION ALL SELECT 'menu:market', 'cellStorage:labSampleProcess:export'
UNION ALL SELECT 'menu:production', 'production:labSampleProcess:list'
UNION ALL SELECT 'menu:production', 'production:labSampleProcess:supplement'
UNION ALL SELECT 'menu:production', 'production:labSampleProcess:export'
UNION ALL SELECT 'menu:quality', 'biz:labSampleProcess:list'
UNION ALL SELECT 'menu:quality', 'biz:labSampleProcess:supplement'
UNION ALL SELECT 'menu:quality', 'biz:labSampleProcess:export'
) map
JOIN sys_role r ON r.del_flag = 0
JOIN sys_role_menu src
ON src.role_id = r.id AND src.tenant_no = r.tenant_no AND src.del_flag = 0
JOIN sys_menu top
ON top.id = src.menu_id AND top.tenant_no = src.tenant_no AND top.del_flag = 0
AND top.perms = map.top_perms
JOIN sys_menu m
ON m.tenant_no = r.tenant_no AND m.del_flag = 0 AND m.status = '1'
AND m.perms = map.list_perms
WHERE NOT EXISTS (
SELECT 1 FROM sys_role_menu existing
WHERE existing.role_id = r.id
AND existing.menu_id = m.id
AND existing.tenant_no = r.tenant_no
AND existing.del_flag = 0
);
-- 核对
SELECT p.menu_name AS parent_name, c.menu_name, c.path, c.perms, c.order_num
FROM sys_menu c
JOIN sys_menu p ON p.id = c.parent_id
WHERE c.del_flag = 0
AND (
c.perms LIKE '%labSampleProcess%'
OR (c.menu_name IN ('缴费财务确认', '废弃物管理', '入库放行审核')
AND p.menu_name IN ('存储服务', '生产管理', '质检管理'))
)
ORDER BY p.menu_name, c.order_num, c.menu_name;
@@ -0,0 +1,50 @@
-- Issue #100:移除多余一级菜单「实验室样本处理数据」及其子菜单/按钮
-- 软删除 sys_menu 与对应 sys_role_menu;不影响质检管理下 lab-sample-process 台账
-- MySQL 5.7,可重复执行
-- 1) 角色菜单授权
UPDATE sys_role_menu rm
JOIN sys_menu m ON m.id = rm.menu_id
SET rm.del_flag = 1,
rm.update_by = 'migration',
rm.update_time = NOW()
WHERE rm.del_flag = 0
AND (
m.perms IN (
'menu:lab-sample-processing',
'lab:sample-processing:list',
'lab:sample-processing:market:edit',
'lab:sample-processing:production:edit',
'lab:sample-processing:qc:edit',
'lab:sample-processing:manager'
)
OR m.path IN (
'/lab-sample-processing',
'lab-sample-processing/hsc',
'lab-sample-processing/ubmc',
'lab-sample-processing/msc'
)
);
-- 2) 按钮 / 子菜单 / 一级菜单(先叶子后父级,同批软删即可)
UPDATE sys_menu
SET del_flag = 1,
update_by = 'migration',
update_time = NOW()
WHERE del_flag = 0
AND (
perms IN (
'menu:lab-sample-processing',
'lab:sample-processing:list',
'lab:sample-processing:market:edit',
'lab:sample-processing:production:edit',
'lab:sample-processing:qc:edit',
'lab:sample-processing:manager'
)
OR path IN (
'/lab-sample-processing',
'lab-sample-processing/hsc',
'lab-sample-processing/ubmc',
'lab-sample-processing/msc'
)
);
+33 -11
View File
@@ -190,6 +190,17 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'biz:cellStoragePaymentConfirm:list'
}
},
{
id: 'CEL0000000000000000000000000LSP1',
name: 'CellStorageLabSampleProcess',
path: 'lab-sample-process',
component: '/quality-inspection/lab-sample-process/index',
meta: {
title: '实验室样本处理数据',
keepAlive: true,
menuCode: 'cellStorage:labSampleProcess:list'
}
},
// 市场侧入库放行审核(总览)暂下线:改由生产/质检入口审核
// {
// id: '70000000000000000000000000000011',
@@ -922,6 +933,17 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
keepAlive: true,
menuCode: 'production:waste-output:list'
}
},
{
id: 'PRM0000000000000000000000000LSP1',
name: 'ProductionLabSampleProcess',
path: 'lab-sample-process',
component: '/quality-inspection/lab-sample-process/index',
meta: {
title: '实验室样本处理数据',
keepAlive: true,
menuCode: 'production:labSampleProcess:list'
}
}
]
},
@@ -1123,17 +1145,6 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'biz:sampleReception:list'
}
},
{
id: '70000000000000000000000000000015',
name: 'QualityInspectionLabSampleProcess',
path: 'lab-sample-process',
component: '/quality-inspection/lab-sample-process/index',
meta: {
title: '实验室样本处理数据',
keepAlive: true,
menuCode: 'biz:labSampleProcess:list'
}
},
{
id: '70000000000000000000000000000007',
name: 'QualityInspectionQcWorkOrder',
@@ -1201,6 +1212,17 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'quality:storageReleaseReview:list'
},
props: { reviewRole: 'qc' }
},
{
id: '70000000000000000000000000000015',
name: 'QualityInspectionLabSampleProcess',
path: 'lab-sample-process',
component: '/quality-inspection/lab-sample-process/index',
meta: {
title: '实验室样本处理数据',
keepAlive: true,
menuCode: 'biz:labSampleProcess:list'
}
}
]
},
@@ -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<string, any>[] = []
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)
@@ -3,19 +3,23 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">实验室样本处理数据</h2>
<div class="panel-pill-tabs panel-pill-tabs--lg">
<div class="panel-pill-tabs__track" role="tablist">
<button
v-for="tab in LAB_PROCESS_TABS"
:key="tab.key"
type="button"
role="tab"
class="panel-pill-tabs__btn"
:class="{ 'is-active': activeTab === tab.key }"
:aria-selected="activeTab === tab.key"
@click="handleTabChange(tab.key)"
>
{{ tab.label }}
</button>
</div>
</div>
<el-tabs v-model="activeTab" class="lab-process-tabs" @tab-change="handleTabChange">
<el-tab-pane
v-for="tab in LAB_PROCESS_TABS"
:key="tab.key"
:label="tab.label"
:name="tab.key"
/>
</el-tabs>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
@@ -252,7 +256,9 @@
handleSearch()
}
function handleTabChange() {
function handleTabChange(name: LabSampleProcessTabKey) {
if (activeTab.value === name) return
activeTab.value = name
pagination.page = 1
loadData()
}
@@ -335,10 +341,6 @@
</script>
<style scoped lang="scss">
.lab-process-tabs {
margin: 0 0 12px;
}
.form-tip {
margin: 0 0 12px;
color: var(--mint-text-muted, #6b7280);