Compare commits

..
Author SHA1 Message Date
zlwu 1afbe06021 fix(web-admin): 采集大表导出自存退款与科研/预签约对齐
自存表写入当月核量号与负值退款金额;科研废弃行第10列复用储存状态;预签约到账金额带定金后缀并填充退款。
2026-11-06 12:41:05 +08:00
cykj 3ad1a08e30 Merge pull request 'fix(web-admin): 细胞存储导出协议编号改样本编码并补齐运费' (#70) from codex/issue-26-cell-freight into V2.0
Merge pull request 'fix(web-admin): 细胞存储导出协议编号改样本编码并补齐运费' (#70) from codex/issue-26-cell-freight into V2.0
2026-11-06 04:12:39 +00:00
zlwu a4425addb0 fix(web-admin): 细胞存储导出协议编号改样本编码并补齐运费
自存表导出列值改为样本编码;列表补运费后写入运输方式/运费列,并按同批次合并单元格。
2026-11-06 12:11:06 +08:00
cykj 471a6823e2 Merge pull request 'fix(web-admin): 科研捐赠导出补齐运费并按批次合并单元格' (#69) from codex/issue-26-research-freight into V2.0
科研捐赠导出补齐运费并按批次合并单元格。
2026-11-06 04:07:37 +00:00
zlwuandCursor d413398b3d fix(web-admin): 科研捐赠导出补齐运费并按批次合并单元格
列表未返回样本运费批次导致运费列为空。导出按同一运费批次合并运输方式与运费列,并按模板对齐储存状态等字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 12:06:49 +08:00
cykj 6be55186b0 Merge pull request 'fix(web-admin): 采集大表导出将 sdfq 等作废编码显示为文案' (#68) from codex/issue-26-sdfq-label into V2.0
采集大表导出将 sdfq 等作废编码显示为文案。
2026-11-06 03:59:45 +00:00
zlwuandCursor 348c5c1d3c fix(web-admin): 采集大表导出将 sdfq 等作废编码显示为文案
科研捐赠导出把作废类型编码原样写入储存状态和废弃原因。改为走字典并回退协议状态文案。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 11:58:48 +08:00
cykj 411a4cb57c Merge pull request 'fix(web-admin): 客服导出协议编号列改用样本编码' (#67) from codex/issue-26-cs-export-samplecode into V2.0
客服导出协议编号列改用样本编码。
2026-11-06 03:53:52 +00:00
zlwuandCursor 6df6306ef0 fix(web-admin): 客服导出协议编号列改用样本编码
验收反馈要求客户服务管理导出表头「协议编号」填样本编码,避免与协议号混淆。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 11:52:45 +08:00
cykj 57049f17b0 Merge pull request 'feat(web-admin): 按采集大表导出预签约、自存、科研和客服表' (#66) from codex/issue-26-collection-ledger-b0b170257c35 into V2.0
按采集大表导出预签约、自存、科研和客服表。
2026-11-06 02:25:49 +00:00
zlwuandCursor a214a35f4f feat(web-admin): 按采集大表导出预签约、自存、科研和客服表
市场人员需要把四张列表按采集大表对应工作表导出为 Excel 文本。补预签约列表的地区、医院、样本类型和转账时间,并沿用现有工具栏导出。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 10:05:11 +08:00
cykj faef6adf94 Merge pull request 'fix(web-admin): 样本接收弹窗不展示协议附件项' (#65) from codex/issue-43-reception-atts-fix-859e92e16ac2 into V2.0
fix(web-admin): 样本接收弹窗不展示协议附件项 (#65)

Refs #43
2026-11-05 15:05:59 +00:00
zlwuandCursor 9f37181478 fix(web-admin): 样本接收弹窗不展示协议附件项
验收要求仅展示知情同意书与孕妇健康调查表,移除第三项「协议附件」分组。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 23:00:37 +08:00
cykj 00635740f1 Merge pull request 'fix(web-admin): 修复样本接收弹窗协议附件不展示' (#64) from codex/issue-43-reception-atts-fix-859e92e16ac2 into V2.0
fix(web-admin): 修复样本接收弹窗协议附件不展示

Merge PR #64 for issue #43
2026-11-05 14:43:28 +00:00
zlwuandCursor 76316fad7a fix(web-admin): 修复样本接收弹窗协议附件不展示
弹窗改用与列表一致的 MiniReadonly 缩略图,并统一 attachmentFullUrl 解析 /minio 路径;编辑时合并详情附件字段,分组顺序优先知情同意书与调查表。

关联 #43

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 22:42:47 +08:00
cykj d4617b4988 Merge pull request 'fix(web-admin): 样本接收展示协议知情同意书等附件' (#63) from codex/issue-43-sample-reception-atts-f8ee4652859e into V2.0
fix(web-admin): 样本接收展示协议知情同意书等附件

Merge PR #63 for issue #43
2026-11-05 13:56:45 +00:00
zlwuandCursor c40feab76a fix(web-admin): 样本接收展示协议知情同意书等附件
质检样本接收列表与弹窗需查看采集协议侧附件。后端待接收/已接收列表补齐协议附件字段;前端列表增加知情同意书缩略图,接收弹窗右侧改为上运输、下协议附件分组展示。

关联 #43

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 21:53:56 +08:00
cykj 9cf1110495 fix(mobile): 协议单表单项目归属移入协议信息 (#61)
fix(mobile): 协议单表单项目归属移入协议信息 (#61)

Merged from PR #62
2026-11-05 09:59:44 +00:00
zlwuandCursor fefef41524 fix(mobile): 协议单表单将项目归属移入协议信息分区
按专员/经理协议单诉求,把项目归属嵌入协议信息卡片并置于协议状态上方,避免独立成卡打断填写顺序。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 17:57:57 +08:00
cykj e97f47b06a fix(mobile): 协议单列表项目筛选下移并补齐科研捐赠筛选 (#59)
fix(mobile): 协议单列表项目筛选下移并补齐科研捐赠筛选 (#59)
2026-11-05 09:14:29 +00:00
zlwuandCursor a4defb2c94 fix(mobile): 协议单列表项目筛选下移并补齐科研捐赠筛选
将项目筛选并入搜索框下方的 van-dropdown 行,与协议状态样式一致;科研捐赠 Tab 同步展示两项筛选,并修复捐赠列表按 protocolStatus 过滤 donation_status。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 17:13:15 +08:00
cykj 8717702b58 fix(personal-dev): 结束 Gitea 短页评论翻页 (#57)
Merge pull request #58 from cursor/issue-57-gitea-pagination

#57
2026-11-05 09:00:54 +00:00
zlwuandCursor d30dbad788 fix(personal-dev): 结束 Gitea 短页评论翻页 (#57)
避免评论列表短于 PAGE_SIZE 时因忽略 page 参数反复同页而触发分页上限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 17:00:16 +08:00
cykj e92112e9fd fix(web-admin): 简化项目归属维护调整弹窗 (#52)
Merge pull request #56 from cursor/issue-52-assignment-dialog

关联 #52
2026-11-05 08:56:30 +00:00
zlwuandCursor d6dc768efb fix(web-admin): 加宽归属审计记录弹窗 (#52)
历史归属维护页审计表格列较多,800px 偏挤,调整为 1100px。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 16:54:22 +08:00
zlwuandCursor 96518c08fb fix(web-admin): 简化项目归属维护调整弹窗 (#52)
展示原项目组/项目,录入新项目组/项目与原因;确认时静默预览并仍级联关联单据。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 16:54:22 +08:00
cykj 54a34b24ba fix(web-admin): 协议单扁平行列表展示所属项目与项目组 (#55)
fix(web-admin): 协议单扁平行列表展示所属项目与项目组 (#55)

Refs: #54
2026-11-05 08:47:38 +00:00
zlwuandCursor 5dc695a138 fix(web-admin): 协议单扁平行列表展示所属项目与项目组
采集协议单管理(细胞存储/科研捐赠)展平样本行时未拷贝项目归属字段,导致表格列空白;补齐展平字段并增加回归单测。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 16:45:33 +08:00
cykj 406ed0ee6d feat(project): 项目组变更写入版本历史快照 (#50)
feat(project): 项目组变更写入版本历史快照 (#50)

Merged from PR #51 / issue #50.
2026-11-05 07:09:37 +00:00
zlwuandCursor 6cba55f5c8 feat(project): 项目组变更写入版本历史快照
每次创建/编辑/软删除项目组时追加 biz_project_team_history,
用完整 serialize 快照保留负责人、成员、地区与医院等信息,便于追溯。

Refs: #50
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 15:05:44 +08:00
cykj 352a4b7402 Merge pull request 'feat(project): 项目编号支持新增编辑自由填写' (#49) from codex/issue-45-project-leader-autofill-a9912a48015b into V2.0 2026-11-05 06:47:23 +00:00
zlwuandCursor e895daf8fc feat(project): 项目编号支持新增编辑自由填写
项目管理弹窗增加项目编号输入;后端保存改为接收客户端编号并校验租户内唯一,不再自动生成。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:41:41 +08:00
cykj 3bdbefb56e Merge pull request 'feat(project): 选定所属项目组后自动带出负责人' (#46) from codex/issue-45-project-leader-autofill-a9912a48015b into V2.0 2026-11-05 06:39:14 +00:00
cykj ecfdf65d4f feat(web-admin): 项目组列表展示成员与负责医院 (#47)
feat(web-admin): 项目组列表展示成员与负责医院

合入 #44 / PR #47
2026-11-05 06:39:08 +00:00
zlwuandCursor 1fd1100b8f feat(web-admin): 项目组列表展示成员与负责医院
在负责人列后追加成员、负责医院,复用列表接口已有子行名称并用顿号拼接。
关联 #44

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:33:02 +08:00
zlwuandCursor 2b2ddc8dd5 feat(project): 选定所属项目组后自动带出负责人
项目管理新增/编辑弹窗选择或更换项目组时,用该组负责人填充表单负责人字段,仍可手工修改。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:32:13 +08:00
cykj 9af53e81f4 Merge pull request 'fix(qc): 调整检测清空重配不再误标工单已完成' (#42) from codex/issue-32-qc-adjust-status-84b58d9e903e into V2.0
fix(qc): 调整检测清空重配不再误标工单已完成 (#42)

关联 #32
2026-11-05 04:28:16 +00:00
46 changed files with 2910 additions and 192 deletions

No files matched your search

@@ -240,6 +240,10 @@ class Tracker:
if not value:
return records
records.extend(value)
# Gitea comment pages may ignore page and keep returning the same short
# list; a short page is the conventional last-page signal.
if len(value) < PAGE_SIZE:
return records
raise InstallError('issue tracker pagination limit exceeded')
def issues(self):
+1
View File
@@ -45,6 +45,7 @@ class TeamBody(Body):
class ProjectBody(Body):
projectName: str = Field(min_length=1, max_length=200)
projectNo: str = Field(min_length=1, max_length=64)
teamId: str
leaderUserId: str
bizTypes: List[str]
+15
View File
@@ -1291,10 +1291,23 @@ def list_collection_protocols(
)
from app.services.qc_work_order_fee_summary import build_protocol_payment_summaries
from app.crud import sample_freight as sample_freight_svc
payment_summary_map = build_protocol_payment_summaries(
db, protocol_ids, tenant_no, include_sample_payment_modes=True
)
all_list_sample_ids = [
s.id
for samples in sample_map.values()
for s in samples
if getattr(s, "id", None)
]
freight_by_sample = sample_freight_svc.freight_map_for_samples(
db,
tenant_no=tenant_no,
sample_source=sample_freight_svc.SAMPLE_SOURCE_CELL,
sample_ids=all_list_sample_ids,
)
# 合并主表 + 子表数据
result = []
@@ -1376,11 +1389,13 @@ def list_collection_protocols(
}
sample_items.append(
{
"id": s.id,
"sampleTypeName": type_name,
"sampleCode": code,
"sampleStatus": (s.sample_status or "").strip(),
"labStorageCode": lab_code_map.get(code, "") if code else "",
"transport": transport_dict,
"freight": freight_by_sample.get(s.id),
**sample_special_to_dict(s),
**build_sample_item_reception_qc_fields(
reception_summary_map.get(code) if code else None,
+103 -3
View File
@@ -17,7 +17,9 @@ from app.crud.biz_no_generator import allocate_next_biz_no
from app.crud.collection_protocol import urls_to_attachment_list
from app.models.models import (
BizCellSampleType,
BizCollectionHospital,
BizCollectionProtocol,
BizCollectionRegion,
BizPresignOrder,
BizPresignOrderSampleType,
BizPresignTransfer,
@@ -503,9 +505,107 @@ def list_presign_orders(
.limit(page_size)
.all()
)
return [
order_to_dict(db, o, tenant_no, with_children=False) for o in rows
], int(total)
items = [order_to_dict(db, o, tenant_no, with_children=False) for o in rows]
attach_presign_ledger_fields(db, items, tenant_no)
return items, int(total)
def attach_presign_ledger_fields(
db: Session, items: List[Dict[str, Any]], tenant_no: str
) -> None:
"""补列表导出需要的地区、医院、样本类型和转账时间。含已停用主数据名称。"""
if not items:
return
order_ids = [str(item.get("id") or "") for item in items if item.get("id")]
region_ids = {str(item.get("regionId") or "") for item in items if item.get("regionId")}
hospital_ids = {
str(item.get("hospitalId") or "") for item in items if item.get("hospitalId")
}
region_names: Dict[str, str] = {}
if region_ids:
region_names = {
str(row.id): str(row.region_name or "").strip()
for row in db.query(BizCollectionRegion).filter(
BizCollectionRegion.id.in_(region_ids),
BizCollectionRegion.tenant_no == tenant_no,
)
}
hospital_names: Dict[str, str] = {}
if hospital_ids:
hospital_names = {
str(row.id): str(row.hospital_name or "").strip()
for row in db.query(BizCollectionHospital).filter(
BizCollectionHospital.id.in_(hospital_ids),
BizCollectionHospital.tenant_no == tenant_no,
)
}
type_ids_by_order: Dict[str, List[str]] = {order_id: [] for order_id in order_ids}
if order_ids:
links = (
db.query(BizPresignOrderSampleType)
.filter(
BizPresignOrderSampleType.presign_order_id.in_(order_ids),
BizPresignOrderSampleType.tenant_no == tenant_no,
BizPresignOrderSampleType.del_flag == 0,
)
.order_by(
BizPresignOrderSampleType.order_num.asc(),
BizPresignOrderSampleType.create_time.asc(),
BizPresignOrderSampleType.id.asc(),
)
.all()
)
for link in links:
if link.sample_type_id:
type_ids_by_order.setdefault(link.presign_order_id, []).append(
str(link.sample_type_id)
)
type_ids = {
type_id
for order_type_ids in type_ids_by_order.values()
for type_id in order_type_ids
}
type_names: Dict[str, str] = {}
if type_ids:
type_names = {
str(row.id): str(row.type_name or "").strip()
for row in db.query(BizCellSampleType).filter(
BizCellSampleType.id.in_(type_ids),
BizCellSampleType.tenant_no == tenant_no,
)
}
times_by_order: Dict[str, List[str]] = {}
if order_ids:
transfers = (
db.query(BizPresignTransfer)
.filter(
BizPresignTransfer.presign_order_id.in_(order_ids),
BizPresignTransfer.tenant_no == tenant_no,
BizPresignTransfer.del_flag == 0,
)
.order_by(
BizPresignTransfer.transfer_time.asc(),
BizPresignTransfer.create_time.asc(),
BizPresignTransfer.id.asc(),
)
.all()
)
for transfer in transfers:
if not transfer.transfer_time:
continue
times_by_order.setdefault(transfer.presign_order_id, []).append(
transfer.transfer_time.strftime("%Y-%m-%d %H:%M:%S")
)
for item in items:
order_id = str(item.get("id") or "")
item["regionName"] = region_names.get(str(item.get("regionId") or ""), "")
item["hospitalName"] = hospital_names.get(str(item.get("hospitalId") or ""), "")
names = [
type_names.get(type_id, "")
for type_id in type_ids_by_order.get(order_id, [])
]
item["sampleTypeNames"] = [name for name in names if name]
item["transferTimes"] = times_by_order.get(order_id, [])
def get_presign_order(
@@ -409,6 +409,8 @@ def list_protocol_sample_collections(
item["newbornSex"] = (party.newborn_sex or "") if party else ""
item["newbornDeliveryTime"] = _fmt_dt(party.newborn_delivery_time) if party else ""
item["protocolRemark"] = (proto.remark or "") if proto else ""
item["voidType"] = (proto.void_type or "") if proto else ""
item["voidDesc"] = (proto.void_desc or "") if proto else ""
item["protocolAttachments"] = (proto.protocol_attachments or "") if proto else ""
item["consentFormUrls"] = (proto.consent_form_urls or "") if proto else ""
item["healthSurveyFormUrls"] = (proto.health_survey_form_urls or "") if proto else ""
+16
View File
@@ -879,6 +879,20 @@ def list_donations(
if t.express_company_id
],
)
from app.crud import sample_freight as sample_freight_svc
all_list_sample_ids = [
s.id
for samples in sample_map.values()
for s in samples
if getattr(s, "id", None)
]
freight_by_sample = sample_freight_svc.freight_map_for_samples(
db,
tenant_no=tenant_no,
sample_source=sample_freight_svc.SAMPLE_SOURCE_DONATION,
sample_ids=all_list_sample_ids,
)
result = []
for r in rows:
@@ -957,11 +971,13 @@ def list_donations(
}
sample_items.append(
{
"id": s.id,
"sampleTypeName": type_name,
"sampleCode": code,
"sampleStatus": (s.sample_status or "").strip(),
"labStorageCode": lab_code_map.get(code, "") if code else "",
"transport": transport_dict,
"freight": freight_by_sample.get(s.id),
**sample_special_to_dict(s),
**build_sample_item_reception_qc_fields(
reception_summary_map.get(code) if code else None,
+12
View File
@@ -287,9 +287,15 @@ def _donation_rows_to_sample_list_items(
),
"sourceType": "research_donation",
"sourceTypeLabel": "科研捐赠",
"consentAttachmentFiles": urls_to_attachment_list(
donation.consent_form_urls if donation else None, db, tenant_no
),
"infectiousDiseaseReportAttachmentFiles": urls_to_attachment_list(
donation.infectious_disease_report_urls if donation else None, db, tenant_no
),
"attachmentFiles": urls_to_attachment_list(
donation.attachment_urls if donation else None, db, tenant_no
),
"etaTime": _fmt_dt(transport.eta_time) if transport and transport.eta_time else "",
"transportType": (transport.transport_type if transport else "") or "",
"waybillNo": (transport.waybill_no if transport else "") or "",
@@ -462,6 +468,12 @@ def _rows_to_sample_list_items(
**sample_special_to_dict(sample),
"sourceType": "cell_storage",
"sourceTypeLabel": "细胞存储",
"protocolAttachmentFiles": urls_to_attachment_list(
protocol.protocol_attachments if protocol else None, db, tenant_no
),
"consentAttachmentFiles": urls_to_attachment_list(
protocol.consent_form_urls if protocol else None, db, tenant_no
),
"healthSurveyAttachmentFiles": urls_to_attachment_list(
protocol.health_survey_form_urls if protocol else None, db, tenant_no
),
+2 -2
View File
@@ -75,12 +75,12 @@ ApiRequestLog = SysApiRequestLog
from app.models.project_management import (
Project, ProjectTeam, ProjectTeamMember, ProjectTeamRegion,
ProjectTeamHospital, ProjectAssignmentAudit,
ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory,
)
__all__ = [
"Project", "ProjectTeam", "ProjectTeamMember", "ProjectTeamRegion",
"ProjectTeamHospital", "ProjectAssignmentAudit",
"ProjectTeamHospital", "ProjectAssignmentAudit", "ProjectTeamHistory",
"BaseModel",
"SoftDeleteModel",
"SysUser",
+14 -1
View File
@@ -1,6 +1,6 @@
"""租户项目及归属审计;区域医院关联只记录组织资料。"""
from datetime import datetime
from sqlalchemy import Column, String, Integer, SmallInteger, DateTime, Date, UniqueConstraint
from sqlalchemy import Column, String, Integer, SmallInteger, DateTime, Date, Text, UniqueConstraint
from app.models.base import Base
@@ -65,3 +65,16 @@ class ProjectAssignmentAudit(ProjectFields, Base):
new_project_name = Column(String(200))
reason = Column(String(512), nullable=False)
operator_name = Column(String(100))
class ProjectTeamHistory(ProjectFields, Base):
"""项目组版本历史;每次成功写入记完整快照。"""
__tablename__ = 'biz_project_team_history'
__table_args__ = (UniqueConstraint('tenant_no', 'team_id', 'version_no', name='uk_team_version'),)
team_id = Column(String(64), nullable=False, index=True)
version_no = Column(Integer, nullable=False)
action = Column(String(16), nullable=False)
snapshot_json = Column(Text, nullable=False)
operator_user_id = Column(String(64))
operator_user_name = Column(String(100))
operate_time = Column(DateTime, nullable=False, default=datetime.now)
+10 -5
View File
@@ -984,11 +984,14 @@ def list_protocols(
for row in cell_rows
)
if source in (None, SOURCE_DONATION):
if status or collected_from or collected_until:
donation_enabled = False
else:
donation_enabled = True
if not donation_enabled:
# 混合列表(source=None)下状态/采集时间只覆盖细胞存储,捐赠侧跳过。
# 科研捐赠单源列表支持 protocolStatus→donation_status;采集时间筛仍不支持捐赠。
skip_donation = False
if source is None and (status or collected_from or collected_until):
skip_donation = True
elif source == SOURCE_DONATION and (collected_from or collected_until):
skip_donation = True
if skip_donation:
query = None
else:
query = apply_donation_scope(
@@ -1001,6 +1004,8 @@ def list_protocols(
if query is None:
donation_rows = []
else:
if status and source == SOURCE_DONATION:
query = query.filter(BizResearchDonation.donation_status == status)
if created_from:
query = query.filter(BizResearchDonation.create_time >= created_from)
if created_until:
+54 -3
View File
@@ -1,4 +1,5 @@
"""项目资料、权限内统计和关联归属事务。"""
import json
from datetime import date, datetime, timedelta
from fastapi import HTTPException
from sqlalchemy import or_, and_, func
@@ -6,7 +7,7 @@ from app.core.data_scope import apply_data_scope
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.project_management import (Project, ProjectTeam, ProjectTeamMember,
ProjectTeamRegion, ProjectTeamHospital, ProjectAssignmentAudit)
ProjectTeamRegion, ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory)
from app.models.models import (SysUser, BizCollectionRegion, BizCollectionHospital,
BizPresignOrder, BizCollectionProtocol, BizPilotApplication)
from app.models.research_donation import BizResearchDonation, BizProtocolDonationConvert
@@ -250,6 +251,44 @@ CHILDREN = (
)
def _next_team_history_version(db, tenant, team_id):
current = (db.query(ProjectTeamHistory.version_no)
.filter(ProjectTeamHistory.tenant_no == tenant,
ProjectTeamHistory.team_id == team_id,
ProjectTeamHistory.del_flag == 0)
.order_by(ProjectTeamHistory.version_no.desc())
.with_for_update()
.first())
return (current[0] if current else 0) + 1
def record_team_history(db, tenant, user, row, action):
"""Append-only team snapshot; caller owns the surrounding transaction."""
if not isinstance(row, ProjectTeam):
return
snapshot = serialize_entity(db, tenant, row)
operator_id = user.get('user_id')
operator = scoped(db, SysUser, tenant).filter(SysUser.id == operator_id).first() if operator_id else None
operator_name = (operator.nick_name if operator else None) or (operator_id or '')
now = datetime.now()
db.add(ProjectTeamHistory(
id=generate_uuid(),
tenant_no=tenant,
team_id=row.id,
version_no=_next_team_history_version(db, tenant, row.id),
action=action,
snapshot_json=json.dumps(snapshot, ensure_ascii=False, default=str, separators=(',', ':')),
operator_user_id=operator_id,
operator_user_name=operator_name,
operate_time=now,
create_by=operator_id,
create_dept_id=user.get('dept_id') or '',
update_by=operator_id,
create_time=now,
update_time=now,
))
def serialize_entity(db, tenant, row):
leader = scoped(db, SysUser, tenant).filter(SysUser.id == row.leader_user_id).first()
result = dict(id=row.id, leaderUserId=row.leader_user_id, leaderName=leader.nick_name if leader else '', remark=row.remark, createTime=row.create_time)
@@ -322,6 +361,7 @@ def save_entity(db, tenant, user, model, body, row_id=None):
natural_key=lambda item, p=public: item[p], row_natural_key=lambda child, a=attr: getattr(child, a))
except ValueError as exc:
raise HTTPException(400, str(exc))
record_team_history(db, tenant, user, row, 'update' if row_id else 'create')
else:
team = get_row(db, ProjectTeam, tenant, body.get('teamId'), lock=True)
if row_id and row.team_id != team.id and has_business(db, tenant, row.id):
@@ -333,8 +373,17 @@ def save_entity(db, tenant, user, model, body, row_id=None):
if status not in ('pending', 'active', 'completed', 'disabled'):
raise HTTPException(400, '项目状态无效')
row.project_name, row.team_id, row.biz_types, row.status = body[name_key].strip(), team.id, ','.join(dict.fromkeys(kinds)), status
if not row_id:
row.project_no = 'XM' + datetime.now().strftime('%Y%m%d') + generate_uuid().replace('-', '')[:16].upper()
project_no = str(body.get('projectNo') or '').strip()
if not project_no:
raise HTTPException(400, '项目编号不能为空')
if len(project_no) > 64:
raise HTTPException(400, '项目编号不能超过 64 个字符')
conflict = scoped(db, Project, tenant).filter(Project.project_no == project_no)
if row_id:
conflict = conflict.filter(Project.id != row.id)
if conflict.with_for_update().first():
raise HTTPException(400, '项目编号已存在')
row.project_no = project_no
try:
row.start_date = date.fromisoformat(body['startDate']) if body.get('startDate') else None
row.end_date = date.fromisoformat(body['endDate']) if body.get('endDate') else None
@@ -353,6 +402,8 @@ def delete_entity(db, tenant, user, model, row_id):
raise HTTPException(400, '项目已被业务引用,请停用')
if model is ProjectTeam and db.query(Project.id).filter(Project.tenant_no == tenant, Project.team_id == row.id).with_for_update().first():
raise HTTPException(400, '项目组已被项目引用,请停用')
if model is ProjectTeam:
record_team_history(db, tenant, user, row, 'delete')
row.del_flag, row.update_by = 1, user.get('user_id')
if model is ProjectTeam:
for _, child_model, *_ in CHILDREN:
@@ -127,6 +127,7 @@ def _payment_detail_row(d: BizProtocolPaymentDetail, pay_mode: str, idx: int) ->
"installmentDueDate": _fmt_ymd(d.installment_due_date),
"dueAmount": _dec(d.due_amount),
"transferAmount": _dec(d.transfer_amount),
"refundAmount": _dec(getattr(d, "refund_amount", None)),
"payStatus": st,
"payStatusLabel": _PAY_STATUS_LABELS.get(st, st or "—"),
"transferTime": _fmt_dt(getattr(d, "transfer_time", None)),
@@ -156,6 +157,14 @@ def build_protocol_payment_summary(
detail_rows = [_payment_detail_row(d, pay_mode, i) for i, d in enumerate(sorted_details)]
received = _received_amount_from_details(sorted_details)
pending = max(contract - received, 0) if contract is not None else None
refund_total = 0.0
has_refund = False
for d in sorted_details:
amt = _dec(getattr(d, "refund_amount", None))
if amt is None:
continue
has_refund = True
refund_total += float(amt)
statuses = [(d.pay_status or "").strip() for d in sorted_details]
overall = _protocol_overall_status(statuses, contract, pending)
hint = _protocol_status_hint(pay_mode, statuses) or overall["overallPayStatusLabel"]
@@ -165,6 +174,7 @@ def build_protocol_payment_summary(
"contractAmount": contract,
"receivedAmount": received,
"pendingAmount": pending,
"refundAmount": refund_total if has_refund else None,
"statusHint": hint,
"installmentCount": len(detail_rows) if pay_mode in ("fq", "djfq") else None,
"paymentDetails": detail_rows,
+38
View File
@@ -2550,3 +2550,41 @@ process.stdout.write(JSON.stringify([
'¥9,999,999,999,999,999.99',
'¥9,999,999,999,999,999.99',
]
def test_web_presign_list_includes_ledger_fields():
from datetime import datetime
from app.crud.presign_order import list_presign_orders
db = _session()
_add_master_data(db)
order = _order('ledger-1', region_id='region-a', sales_user_id='u1')
order.hospital_id = 'hospital-a'
order.mother_name = '陈影'
transfer = _transfer('ledger-transfer', order_id='ledger-1', pay_status='yjf')
transfer.transfer_time = datetime(2026, 4, 2, 16, 49, 42)
db.add_all(
[
order,
transfer,
BizPresignOrderSampleType(
id='ledger-type',
presign_order_id='ledger-1',
sample_type_id='sample-a',
tenant_no='tenant-a',
create_dept_id='',
order_num=1,
del_flag=0,
),
]
)
db.commit()
items, total = list_presign_orders(db, tenant_no='tenant-a', page_size=10)
assert total == 1
assert items[0]['regionName'] == 'A区'
assert items[0]['hospitalName'] == 'A医院'
assert items[0]['sampleTypeNames'] == ['样本A']
assert items[0]['transferTimes'] == ['2026-04-02 16:49:42']
+23
View File
@@ -3733,6 +3733,29 @@ def test_protocol_list_filters_status_create_time_and_sample_collect_time():
assert [item['id'] for item in items] == ['hit']
def test_donation_list_filters_by_protocol_status_alias():
from app.services.market_protocol import list_protocols
db = _session()
_masters(db)
hit = _donation(db, 'don-hit', created=datetime(2026, 10, 8, 9, 0))
hit.donation_status = 'sxz'
miss = _donation(db, 'don-miss', created=datetime(2026, 10, 8, 9, 0))
miss.donation_status = 'dsx'
db.commit()
items, total = list_protocols(
db,
ctx=_ctx('specialist'),
user_info=_user(),
source='research_donation',
protocol_status='sxz',
)
assert total == 1
assert [item['id'] for item in items] == ['don-hit']
def test_cell_create_rolls_back_main_customer_and_children_on_child_failure():
from app.services.market_protocol import create_protocol
+56 -3
View File
@@ -10,9 +10,10 @@ class ProjectManagementTest(unittest.TestCase):
def setUp(self):
self.assertIsNotNone(importlib.util.find_spec('app.services.project_management'), 'project service missing')
from app.services import project_management as svc
from app.models.project_management import Project, ProjectTeam, ProjectTeamMember
from app.models.project_management import Project, ProjectTeam, ProjectTeamMember, ProjectTeamHistory
from app.models.base import Base
self.svc, self.Project, self.Team = svc, Project, ProjectTeam
self.History = ProjectTeamHistory
self.engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
@@ -148,8 +149,8 @@ class ProjectManagementTest(unittest.TestCase):
self.db.add(SysUser(id='u', tenant_no='t', user_no='u', nick_name='User'))
self.db.commit()
result = self.svc.save_entity(self.db, 't', self.user, self.Project,
dict(projectName='New', leaderUserId='u', teamId='g', bizTypes=['basic_experiment'], startDate='', endDate=''))
self.assertTrue(result['projectNo'].startswith('XM'))
dict(projectName='New', projectNo='CUSTOM-001', leaderUserId='u', teamId='g', bizTypes=['basic_experiment'], startDate='', endDate=''))
self.assertEqual(result['projectNo'], 'CUSTOM-001')
self.assertIsNone(result['startDate'])
def test_conversion_table_reverse_graph_and_audit(self):
@@ -235,6 +236,58 @@ class ProjectManagementTest(unittest.TestCase):
self.assertIn("parent.perms='project:project:list'", menus)
self.assertNotIn('INSERT INTO sys_role_menu', menus)
def test_team_history_versions_on_create_update_delete(self):
import json
from app.models.models import SysUser, BizCollectionHospital, BizCollectionRegion
from app.models.project_management import ProjectTeamHistory
self.db.add_all([
SysUser(id='u', tenant_no='t', user_no='u', nick_name='User', status='1'),
SysUser(id='m', tenant_no='t', user_no='m', nick_name='Member', status='1'),
BizCollectionRegion(id='r1', tenant_no='t', region_name='华东'),
BizCollectionHospital(id='h1', tenant_no='t', hospital_name='一院', region_id='r1'),
])
self.db.commit()
created = self.svc.save_entity(self.db, 't', self.user, self.Team, dict(
teamName='Alpha', leaderUserId='u', enabled=True,
members=[{'userId': 'm'}], regions=[{'regionId': 'r1'}], hospitals=[{'hospitalId': 'h1'}],
remark='v1',
))
team_id = created['id']
rows = (self.db.query(ProjectTeamHistory)
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
.order_by(ProjectTeamHistory.version_no).all())
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].action, 'create')
self.assertEqual(rows[0].version_no, 1)
snap = json.loads(rows[0].snapshot_json)
self.assertEqual(snap['teamName'], 'Alpha')
self.assertEqual(snap['leaderName'], 'User')
self.assertEqual([m['userId'] for m in snap['members']], ['m'])
self.assertEqual([h['hospitalId'] for h in snap['hospitals']], ['h1'])
self.svc.save_entity(self.db, 't', self.user, self.Team, dict(
teamName='Alpha2', leaderUserId='u', enabled=True,
members=[{'userId': 'm'}, {'userId': 'u'}], regions=[{'regionId': 'r1'}],
hospitals=[{'hospitalId': 'h1'}], remark='v2',
), team_id)
rows = (self.db.query(ProjectTeamHistory)
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
.order_by(ProjectTeamHistory.version_no).all())
self.assertEqual(len(rows), 2)
self.assertEqual(rows[1].action, 'update')
self.assertEqual(rows[1].version_no, 2)
self.assertEqual(json.loads(rows[1].snapshot_json)['teamName'], 'Alpha2')
self.assertEqual(len(json.loads(rows[1].snapshot_json)['members']), 2)
self.svc.delete_entity(self.db, 't', self.user, self.Team, team_id)
rows = (self.db.query(ProjectTeamHistory)
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
.order_by(ProjectTeamHistory.version_no).all())
self.assertEqual(len(rows), 3)
self.assertEqual(rows[2].action, 'delete')
self.assertEqual(rows[2].version_no, 3)
self.assertEqual(json.loads(rows[2].snapshot_json)['teamName'], 'Alpha2')
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,129 @@
"""样本接收列表项应携带协议侧附件字段,供质检查看。"""
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
class _FakeQuery:
def filter(self, *args, **kwargs):
return self
def all(self):
return []
def first(self):
return None
class _FakeDb:
def query(self, *args, **kwargs):
return _FakeQuery()
class TestSampleReceptionProtocolAttachments(unittest.TestCase):
def test_cell_storage_item_includes_protocol_consent_health_files(self):
from app.crud import sample_reception as svc
protocol = SimpleNamespace(
id="proto-1",
protocol_no="P1",
protocol_status="effective",
protocol_attachments="http://a/p.pdf",
consent_form_urls="http://a/c.pdf",
health_survey_form_urls="http://a/h.pdf",
)
sample = SimpleNamespace(
id="sample-1",
sample_code="S001",
sample_type_id="t1",
collect_amount=None,
sample_status="",
)
party = SimpleNamespace(mother_name="妈妈", newborn_name="宝宝")
transport = SimpleNamespace(
eta_time=None,
transport_type="",
waybill_no="",
express_company_id="",
carrier_name="",
carrier_phone="",
)
with patch.object(
svc,
"urls_to_attachment_list",
side_effect=lambda urls, *_a, **_k: [{"fileUrl": urls}] if urls else [],
), patch.object(svc, "sample_special_to_dict", return_value={}), patch.object(
svc.prod_reception_svc, "get_map_by_reception_ids", return_value={}
), patch(
"app.services.qc_work_order_fee_summary.build_protocol_payment_summaries",
return_value={},
):
items = svc._rows_to_sample_list_items(
_FakeDb(),
[(sample, protocol, party, transport)],
"t1",
reception_map={},
)
self.assertEqual(len(items), 1)
item = items[0]
self.assertEqual(item["protocolAttachmentFiles"][0]["fileUrl"], "http://a/p.pdf")
self.assertEqual(item["consentAttachmentFiles"][0]["fileUrl"], "http://a/c.pdf")
self.assertEqual(item["healthSurveyAttachmentFiles"][0]["fileUrl"], "http://a/h.pdf")
def test_donation_item_includes_consent_infectious_other_files(self):
from app.crud import sample_reception as svc
donation = SimpleNamespace(
id="don-1",
donation_no="D1",
donation_status="effective",
consent_form_urls="http://a/c.pdf",
infectious_disease_report_urls="http://a/i.pdf",
attachment_urls="http://a/o.pdf",
)
sample = SimpleNamespace(
id="ds-1",
sample_code="D001",
sample_type_id="t1",
collect_amount=None,
)
party = SimpleNamespace(mother_name="妈妈", newborn_name="宝宝")
transport = SimpleNamespace(
eta_time=None,
transport_type="",
waybill_no="",
express_company_id="",
carrier_name="",
carrier_phone="",
)
with patch.object(
svc,
"urls_to_attachment_list",
side_effect=lambda urls, *_a, **_k: [{"fileUrl": urls}] if urls else [],
), patch.object(svc, "sample_special_to_dict", return_value={}), patch.object(
svc.prod_reception_svc, "get_map_by_reception_ids", return_value={}
):
items = svc._donation_rows_to_sample_list_items(
_FakeDb(),
[(sample, donation, party, transport)],
"t1",
reception_map={},
)
self.assertEqual(len(items), 1)
item = items[0]
self.assertEqual(item["consentAttachmentFiles"][0]["fileUrl"], "http://a/c.pdf")
self.assertEqual(
item["infectiousDiseaseReportAttachmentFiles"][0]["fileUrl"], "http://a/i.pdf"
)
self.assertEqual(item["attachmentFiles"][0]["fileUrl"], "http://a/o.pdf")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,25 @@
-- Issue #50: 项目组版本历史(append-only JSON 快照)。MySQL 5.7.
-- DDL 隐式提交;该脚本不作为自动应用启动步骤。上线前仍须备份并在目标副本验证。
CREATE TABLE IF NOT EXISTS biz_project_team_history (
id VARCHAR(64) NOT NULL,
team_id VARCHAR(64) NOT NULL,
version_no INT NOT NULL,
action VARCHAR(16) NOT NULL,
snapshot_json TEXT NOT NULL,
operator_user_id VARCHAR(64) NULL,
operator_user_name VARCHAR(100) NULL,
operate_time DATETIME NOT NULL,
tenant_no VARCHAR(64) NOT NULL,
order_num INT NULL,
remark VARCHAR(512) NULL,
create_by VARCHAR(64) NULL,
create_dept_id VARCHAR(64) NOT NULL DEFAULT '',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64) NULL,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
del_flag SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_team_version (tenant_no, team_id, version_no),
KEY ix_team_history (tenant_no, team_id, version_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,309 @@
# 项目组版本历史(仅落库)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:** 项目组每次创建/编辑/软删除成功后,向 `biz_project_team_history` 追加一条带递增 `version_no` 的完整 JSON 快照,无需 UI。
**Architecture:** 在 `backend/app/services/project_management.py` 的 `save_entity` / `delete_entity` 中,对 `ProjectTeam` 在 `commit` 前调用历史写入助手;快照复用 `serialize_entity`;表结构与协议操作日志同类,但按版本号语义建模。
**Tech Stack:** FastAPI / SQLAlchemy / MySQL 5.7 DDL / unittest + SQLite in-memory(沿用 `test_project_management.py`)
**Spec:** `docs/superpowers/specs/2026-11-05-project-team-history-design.md`
**修改端:** 仅 `backend`(不改 `web-admin`)
---
## File map
| 文件 | 职责 |
| --- | --- |
| `docs/sql/migrations/2026-11-05-project_team_history.sql` | MySQL 建表 |
| `backend/app/models/project_management.py` | `ProjectTeamHistory` ORM |
| `backend/app/models/__init__.py` | 导出模型 |
| `backend/app/services/project_management.py` | `record_team_history` + 挂钩 save/delete |
| `backend/tests/test_project_management.py` | 创建/多次更新/删除历史断言 |
---
### Task 1: 失败测试(TDD 红)
**Files:**
- Modify: `backend/tests/test_project_management.py`
- (实现尚未存在时导入 `ProjectTeamHistory` 会失败,或断言历史行数为 0→期望 ≥1)
- [ ] **Step 1: 在 `ProjectManagementTest` 增加测试方法**
在 `backend/tests/test_project_management.py` 末尾(同类测试旁)加入:
```python
def test_team_history_versions_on_create_update_delete(self):
import json
from app.models.models import SysUser, BizCollectionHospital, BizCollectionRegion
from app.models.project_management import ProjectTeamHistory
self.db.add_all([
SysUser(id='u', tenant_no='t', user_no='u', nick_name='User', status='1'),
SysUser(id='m', tenant_no='t', user_no='m', nick_name='Member', status='1'),
BizCollectionRegion(id='r1', tenant_no='t', region_name='华东'),
BizCollectionHospital(id='h1', tenant_no='t', hospital_name='一院', region_id='r1'),
])
self.db.commit()
created = self.svc.save_entity(self.db, 't', self.user, self.Team, dict(
teamName='Alpha', leaderUserId='u', enabled=True,
members=[{'userId': 'm'}], regions=[{'regionId': 'r1'}], hospitals=[{'hospitalId': 'h1'}],
remark='v1',
))
team_id = created['id']
rows = (self.db.query(ProjectTeamHistory)
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
.order_by(ProjectTeamHistory.version_no).all())
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].action, 'create')
self.assertEqual(rows[0].version_no, 1)
snap = json.loads(rows[0].snapshot_json)
self.assertEqual(snap['teamName'], 'Alpha')
self.assertEqual(snap['leaderName'], 'User')
self.assertEqual([m['userId'] for m in snap['members']], ['m'])
self.assertEqual([h['hospitalId'] for h in snap['hospitals']], ['h1'])
self.svc.save_entity(self.db, 't', self.user, self.Team, dict(
teamName='Alpha2', leaderUserId='u', enabled=True,
members=[{'userId': 'm'}, {'userId': 'u'}], regions=[{'regionId': 'r1'}],
hospitals=[{'hospitalId': 'h1'}], remark='v2',
), team_id)
rows = (self.db.query(ProjectTeamHistory)
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
.order_by(ProjectTeamHistory.version_no).all())
self.assertEqual(len(rows), 2)
self.assertEqual(rows[1].action, 'update')
self.assertEqual(rows[1].version_no, 2)
self.assertEqual(json.loads(rows[1].snapshot_json)['teamName'], 'Alpha2')
self.assertEqual(len(json.loads(rows[1].snapshot_json)['members']), 2)
# 解除「已被项目引用」阻挡:setUp 里的项目挂在 'g',新建组可删
self.svc.delete_entity(self.db, 't', self.user, self.Team, team_id)
rows = (self.db.query(ProjectTeamHistory)
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
.order_by(ProjectTeamHistory.version_no).all())
self.assertEqual(len(rows), 3)
self.assertEqual(rows[2].action, 'delete')
self.assertEqual(rows[2].version_no, 3)
self.assertEqual(json.loads(rows[2].snapshot_json)['teamName'], 'Alpha2')
```
注意:若 `BizCollectionRegion` / `BizCollectionHospital` 构造字段与模型不一致,按 `backend/app/models/models.py` 实际必填列补齐(与 `references()` 所用表一致)。`SysUser.status` 须为可被 `save_entity` 接受的启用态(现有 `'1'`)。
- [ ] **Step 2: 跑测试确认失败**
```bash
cd backend && python -m pytest tests/test_project_management.py::ProjectManagementTest::test_team_history_versions_on_create_update_delete -v
```
Expected: FAIL(`ProjectTeamHistory` 不存在,或历史行数为 0)
- [ ] **Step 3: Commit(仅测试,待用户要求提交时执行;实现会话可与后续任务合并提交)**
---
### Task 2: DDL 迁移
**Files:**
- Create: `docs/sql/migrations/2026-11-05-project_team_history.sql`
- [ ] **Step 1: 写入迁移**
```sql
-- Project team version history (append-only JSON snapshots). MySQL 5.7.
-- DDL 隐式提交;不作为应用启动自动步骤。上线前备份并在目标副本验证。
CREATE TABLE IF NOT EXISTS biz_project_team_history (
id VARCHAR(64) NOT NULL,
team_id VARCHAR(64) NOT NULL,
version_no INT NOT NULL,
action VARCHAR(16) NOT NULL,
snapshot_json TEXT NOT NULL,
operator_user_id VARCHAR(64) NULL,
operator_user_name VARCHAR(100) NULL,
operate_time DATETIME NOT NULL,
tenant_no VARCHAR(64) NOT NULL,
order_num INT NULL,
remark VARCHAR(512) NULL,
create_by VARCHAR(64) NULL,
create_dept_id VARCHAR(64) NOT NULL DEFAULT '',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64) NULL,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
del_flag SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_team_version (tenant_no, team_id, version_no),
KEY ix_team_history (tenant_no, team_id, version_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
- [ ] **Step 2: 确认无应用启动自动执行该 SQL 的挂钩**(仅文档/运维手工应用,与 `2026-11-02-project_management.sql` 一致)
---
### Task 3: ORM 模型
**Files:**
- Modify: `backend/app/models/project_management.py`
- Modify: `backend/app/models/__init__.py`
- [ ] **Step 1: 在 `project_management.py` 增加模型**(需 `from sqlalchemy import Text` 若尚未导入)
```python
class ProjectTeamHistory(ProjectFields, Base):
"""项目组版本历史;每次成功写入记完整快照。"""
__tablename__ = 'biz_project_team_history'
__table_args__ = (UniqueConstraint('tenant_no', 'team_id', 'version_no', name='uk_team_version'),)
team_id = Column(String(64), nullable=False, index=True)
version_no = Column(Integer, nullable=False)
action = Column(String(16), nullable=False)
snapshot_json = Column(Text, nullable=False)
operator_user_id = Column(String(64))
operator_user_name = Column(String(100))
operate_time = Column(DateTime, nullable=False, default=datetime.now)
```
- [ ] **Step 2: 更新 `__init__.py` 导入与 `__all__`**
```python
from app.models.project_management import (
Project, ProjectTeam, ProjectTeamMember, ProjectTeamRegion,
ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory,
)
# __all__ 中追加 "ProjectTeamHistory"
```
---
### Task 4: 服务层写入挂钩
**Files:**
- Modify: `backend/app/services/project_management.py`
- [ ] **Step 1: 导入**
```python
import json
from app.models.project_management import (
Project, ProjectTeam, ProjectTeamMember, ProjectTeamRegion,
ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory,
)
```
- [ ] **Step 2: 增加助手函数**(放在 `CHILDREN` / `serialize_entity` 附近)
```python
def _next_team_history_version(db, tenant, team_id):
current = (db.query(ProjectTeamHistory.version_no)
.filter(ProjectTeamHistory.tenant_no == tenant,
ProjectTeamHistory.team_id == team_id,
ProjectTeamHistory.del_flag == 0)
.order_by(ProjectTeamHistory.version_no.desc())
.with_for_update()
.first())
return (current[0] if current else 0) + 1
def record_team_history(db, tenant, user, row, action):
"""Append-only team snapshot; caller owns the surrounding transaction."""
if not isinstance(row, ProjectTeam):
return
snapshot = serialize_entity(db, tenant, row)
operator_id = user.get('user_id')
leader = scoped(db, SysUser, tenant).filter(SysUser.id == operator_id).first() if operator_id else None
# Prefer nick_name when operator is a tenant user; fall back to user_id.
operator_name = (leader.nick_name if leader else None) or (operator_id or '')
now = datetime.now()
db.add(ProjectTeamHistory(
id=generate_uuid(),
tenant_no=tenant,
team_id=row.id,
version_no=_next_team_history_version(db, tenant, row.id),
action=action,
snapshot_json=json.dumps(snapshot, ensure_ascii=False, default=str, separators=(',', ':')),
operator_user_id=operator_id,
operator_user_name=operator_name,
operate_time=now,
create_by=operator_id,
create_dept_id=user.get('dept_id') or '',
update_by=operator_id,
create_time=now,
update_time=now,
))
```
修正:上面用 `leader` 变量名易混淆,实现时用 `operator = scoped(...).filter(SysUser.id == operator_id).first()`。
- [ ] **Step 3: 挂钩 `save_entity`**
在 `ProjectTeam` 分支完成子行 `sync_child_rows` 之后、最终 `db.add(row)` / `db.commit()` 之前(确保 `row.id` 已有),调用:
```python
record_team_history(db, tenant, user, row, 'update' if row_id else 'create')
```
注意:`save_entity` 对 Project 与 ProjectTeam 共用末尾 `db.commit()`。仅当 `model is ProjectTeam` 时记录。将调用放在 Team 分支末尾(子行同步成功后),Project 分支不调用。
- [ ] **Step 4: 挂钩 `delete_entity`**
```python
def delete_entity(db, tenant, user, model, row_id):
row = get_row(db, model, tenant, row_id, lock=True)
if model is Project and has_business(db, tenant, row.id):
raise HTTPException(400, '项目已被业务引用,请停用')
if model is ProjectTeam and db.query(Project.id).filter(Project.tenant_no == tenant, Project.team_id == row.id).with_for_update().first():
raise HTTPException(400, '项目组已被项目引用,请停用')
if model is ProjectTeam:
record_team_history(db, tenant, user, row, 'delete') # 先快照
row.del_flag, row.update_by = 1, user.get('user_id')
if model is ProjectTeam:
for _, child_model, *_ in CHILDREN:
for child in scoped(db, child_model, tenant).filter(child_model.team_id == row.id).all():
child.del_flag, child.update_by = 1, user.get('user_id')
db.commit()
return {'id': row.id}
```
- [ ] **Step 5: 跑目标测试**
```bash
cd backend && python -m pytest tests/test_project_management.py::ProjectManagementTest::test_team_history_versions_on_create_update_delete -v
```
Expected: PASS
- [ ] **Step 6: 回归相关测试**
```bash
cd backend && python -m pytest tests/test_project_management.py tests/test_project_assignment_integration.py -v
```
Expected: PASS(或仅记录与本改动无关的既有失败)
---
### Task 5: 收尾核对
- [ ] **Step 1:** 确认未改 `web-admin`、未加 History API、未引入 Figma。
- [ ] **Step 2:** 规格验收条目可映射到测试与代码路径。
- [ ] **Step 3:** 按 personal-dev 交付:工单回写、推送/PR/合并仅在授权下执行;当前工程 `write_issue`/`push_task_branch`/`create_pr`/`merge` 默认 false,缺权时保留本地完成态并报告缺口。
---
## Spec coverage checklist
| 规格要求 | Task |
| --- | --- |
| `biz_project_team_history` + 唯一版本约束 | 2, 3 |
| create/update/delete 记版 | 4 |
| 完整 serialize 快照 | 4 + 测试 1 |
| 删除先快照再软删 | 4 Step 4 |
| 无 UI / 无查询 API | Task 5 |
| 自动化测试 | 1, 4 |
## 执行说明
实现前按 personal-dev:创建/领取 Gitea 工单 → worktree(基线 `V2.0`)→ 在 worktree 内执行本计划。提交仅在用户要求或交付流程授权时进行。
@@ -0,0 +1,107 @@
# 项目组版本历史(仅落库)设计
日期:2026-11-05
状态:待用户确认规格后进入实现计划
范围:`backend`(Web 项目组管理经现有 API 写入;本期不改 `web-admin` UI,不画 Figma)
## 背景与目标
项目组管理(`/project/management/teams`)每次新增、编辑或软删除后,需要可追溯的版本历史,使 N 次修改后仍能还原当时完整信息:名称、负责人、成员、负责地区、负责医院、启用状态、备注及操作元数据。
本期只保证**数据正确落库**;不做历史列表/详情界面。
## 已确认决策
| 项 | 结论 |
| --- | --- |
| 交互 | 无界面;不生成 Figma |
| 存储 | 方案 A:独立历史表 + 完整 JSON 快照 |
| 业务范围 | 仅项目组(`ProjectTeam`);不含项目(`Project`) |
| 修改端 | `backend`;Web 继续走现有保存/删除 API |
| 查询 API | 本期不暴露只读接口;用自动化测试验证落库 |
| 与现有审计关系 | 不复用 `biz_project_assignment_audit`(仅业务归属变更) |
## 架构
在 `save_entity` / `delete_entity` 中,当模型为 `ProjectTeam` 且事务即将成功提交前:
1. 用现有 `serialize_entity` 生成当前(删除场景为软删后)完整字典;
2. 计算该 `team_id` 在租户内下一 `version_no`;
3. 追加写入 `biz_project_team_history` 一行(append-only,软删除标记仅遵循表结构惯例,业务不改历史行)。
对齐协议操作日志(`biz_protocol_operation_log`)的「动作 + 快照 JSON」思路,但本表按项目组版本号递增,强调「第 N 版完整态」而非前后 diff。
```
Web 保存/删除
→ API project_management
→ save_entity / delete_entity
→ 同步子行 / 软删
→ 写入 ProjectTeamHistory(version_no, action, snapshot_json, operator_*)
→ commit
```
失败时与主写操作同一事务回滚,避免「主数据已改、历史未记」。
## 数据模型
表名:`biz_project_team_history`
迁移:`docs/sql/migrations/` 下新增脚本(命名对齐现有 `2026-11-02-project_management.sql` 风格)。
| 字段 | 说明 |
| --- | --- |
| `id` | UUID PK |
| `team_id` | 项目组 ID(软删后仍可关联) |
| `version_no` | 租户内同一 `team_id` 从 1 递增 |
| `action` | `create` / `update` / `delete` |
| `snapshot_json` | Text,UTF-8 JSON;结构等于当时 `serialize_entity` 输出(含 `teamName`、`leaderUserId`、`leaderName`、`members`、`regions`、`hospitals`、`enabled`、`remark` 等) |
| `operator_user_id` / `operator_user_name` | 操作人 ID 与姓名快照 |
| `operate_time` | 业务操作时间 |
| `tenant_no` 及标准审计字段 | 与工程表字段顺序约定一致;`del_flag` 默认 0 |
唯一约束:`(tenant_no, team_id, version_no)`。
索引:`(tenant_no, team_id, version_no DESC)` 便于日后按组查历史。
ORM:在 `backend/app/models/project_management.py` 增加 `ProjectTeamHistory`,并在 `models/__init__.py` 导出。
## 写入规则
| 场景 | `action` | 快照时机 |
| --- | --- | --- |
| 新增项目组 | `create` | 子行同步完成后 |
| 编辑项目组 | `update` | 子行同步完成后(每次成功保存记一版,不要求字段 diff) |
| 软删除项目组 | `delete` | **先**用 `serialize_entity` 打删除前快照,**再**对主表与子行置 `del_flag=1`,最后写入历史行并 `commit`(`scoped` 默认排除已删行,禁止先软删再序列化) |
`version_no`:同事务内对 `(tenant_no, team_id)` 取 `MAX(version_no)+1`,无历史则从 1 开始;配合行锁/唯一约束防止并发撞号。
不在「无字段变化的重复保存」上做跳过优化(YAGNI);每次成功保存都记一版,满足「N 次修改 N 条历史」的验收语义。
## 非目标(本期不做)
- Web/小程序/移动 OA 任何历史入口或展示
- 历史恢复(rollback)到某版本
- 字段级 diff 展示
- 项目(非项目组)版本历史
- 对外只读 History API(可后续单独开需求)
## 测试
在现有项目组相关测试附近增加用例(隔离库):
1. 创建项目组 → 存在 `version_no=1`、`action=create`,`snapshot_json` 含负责人与成员/医院等;
2. 连续更新 N 次 → 存在版本 1…N+1,最新快照与当前 `serialize_entity` 一致;
3. 软删除 → 新增 `action=delete` 版本,且主流程仍成功。
不要求启动 `web-admin` 或改前端。
## 验收条件
1. 对任一项目组完成创建与若干次编辑后,库中按 `version_no` 可还原每一次的完整快照(负责人、成员、地区、医院等)。
2. 软删除同样产生历史行。
3. Web 项目组管理界面行为与现网一致(无新增按钮/弹窗)。
4. 相关自动化测试通过;字段/表结构有 `docs/sql` 迁移可查。
## 风险与备注
- `snapshot_json` 含展示名,人员改名后历史仍保留操作当时姓名(刻意为之)。
- 工程 `permissions.write_issue` 当前为 false:实现阶段创建/回写 Gitea 工单需用户授权或代建;本地代码与测试仍可推进。
- 集成分支:`V2.0`。
@@ -28,25 +28,27 @@
class="market-form-fields"
:class="{ 'market-form-fields--grid-2': section.fieldsLayout === 'grid-2' }"
>
<MarketFormField
v-for="field in visibleFields"
:key="field.key"
:field="field"
:model-value="value?.[field.key]"
:options="fieldOptions(field)"
:uploading="uploading"
:disabled="fieldDisabled(field)"
:search-customers="searchCustomers"
@update:model-value="setValue(field, $event)"
@upload="$emit('upload', { section, field, file: $event })"
@customer-select="$emit('customer-select', { section, ...$event })"
/>
<template v-for="item in fieldsWithInsert" :key="item.field.key">
<slot v-if="item.insertBefore" name="insert" />
<MarketFormField
:field="item.field"
:model-value="value?.[item.field.key]"
:options="fieldOptions(item.field)"
:uploading="uploading"
:disabled="fieldDisabled(item.field)"
:search-customers="searchCustomers"
@update:model-value="setValue(item.field, $event)"
@upload="$emit('upload', { section, field: item.field, file: $event })"
@customer-select="$emit('customer-select', { section, ...$event })"
/>
</template>
<slot v-if="appendInsert" name="insert" />
</div>
</section>
</template>
<script setup>
import { computed } from 'vue'
import { computed, useSlots } from 'vue'
import MarketFormField from './MarketFormField.vue'
import { sampleCodeOptionsForRow } from '../reference-options.js'
@@ -58,9 +60,12 @@ const props = defineProps({
mode: { type: String, default: 'create' },
uploading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
searchCustomers: { type: Function, default: null }
searchCustomers: { type: Function, default: null },
/** 在首个匹配可见字段前插入 #insert 插槽(如协议状态下的项目归属) */
insertBeforeKeys: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue', 'upload', 'field-change', 'customer-select'])
const slots = useSlots()
const value = computed(() => props.modelValue || {})
const rows = computed(() => Array.isArray(props.modelValue) ? props.modelValue : [])
const visibleFields = computed(() => (props.section.fields || []).filter(
@@ -71,6 +76,19 @@ const visibleFields = computed(() => (props.section.fields || []).filter(
!(typeof field.showWhen === 'function' && field.showWhen(value.value) === false) &&
!(field.createOnly && props.mode !== 'create')
))
const fieldsWithInsert = computed(() => {
const keys = props.insertBeforeKeys || []
let inserted = false
return visibleFields.value.map((field) => {
const insertBefore = Boolean(slots.insert) && !inserted && keys.includes(field.key)
if (insertBefore) inserted = true
return { field, insertBefore }
})
})
const appendInsert = computed(() => {
if (!slots.insert || !(props.insertBeforeKeys || []).length) return false
return !fieldsWithInsert.value.some((item) => item.insertBefore)
})
function fieldOptions(field, rowIndex = -1) {
const base = field.options || props.options[field.optionKey] || (field.optionKey === 'users' ? props.options.staff : []) || []
@@ -1,27 +1,49 @@
<template>
<section class="market-form-section">
<h2>{{ filter ? '项目筛选' : '项目归属' }}<span v-if="required && !editing && !filter"> *</span></h2>
<component :is="embedded ? 'div' : 'section'" :class="rootClass">
<h2 v-if="!embedded">{{ filter ? '项目筛选' : '项目归属' }}<span v-if="required && !editing && !filter"> *</span></h2>
<label v-else :id="labelId">所属项目<b v-if="required && !editing && !filter">*</b></label>
<template v-if="editing">
<van-cell title="所属项目" :value="projectLabel(record)" />
<van-cell title="项目组" :value="record.teamName || '—'" />
<van-cell label="归属调整请使用 Web 项目归属维护" />
<template v-if="embedded">
<div class="market-form-field__control market-form-field__control--text" :aria-labelledby="labelId">
{{ projectLabel(record) }}
</div>
<p class="market-form-field__tip">项目组:{{ record.teamName || '—' }};归属调整请使用 Web 项目归属维护</p>
</template>
<template v-else>
<van-cell title="所属项目" :value="projectLabel(record)" />
<van-cell title="项目组" :value="record.teamName || '—'" />
<van-cell label="归属调整请使用 Web 项目归属维护" />
</template>
</template>
<template v-else>
<PresignSelectField
:model-value="modelValue || ''"
:options="selectOptions"
:disabled="disabled || loading || Boolean(error)"
:loading="loading"
searchable
title="所属项目"
:placeholder="filter ? '全部项目' : '请选择所属项目'"
@update:model-value="emit('update:modelValue', $event)"
/>
<van-cell v-if="loading" title="正在加载项目…" />
<van-cell v-else-if="error" :label="error" title="项目加载失败,点击重试" is-link @click="load" />
<van-cell v-else-if="!options.length" :label="required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目'" />
<div :class="embedded ? 'market-form-field__control' : undefined">
<PresignSelectField
:model-value="modelValue || ''"
:options="selectOptions"
:disabled="disabled || loading || Boolean(error)"
:loading="loading"
searchable
title="所属项目"
:placeholder="filter ? '全部项目' : '请选择所属项目'"
@update:model-value="emit('update:modelValue', $event)"
/>
</div>
<template v-if="embedded">
<p v-if="loading" class="market-form-field__tip">正在加载项目…</p>
<button v-else-if="error" type="button" class="market-form-field__tip market-form-field__tip--action" @click="load">
项目加载失败,点击重试({{ error }})
</button>
<p v-else-if="!options.length" class="market-form-field__tip">
{{ required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目' }}
</p>
</template>
<template v-else>
<van-cell v-if="loading" title="正在加载项目…" />
<van-cell v-else-if="error" :label="error" title="项目加载失败,点击重试" is-link @click="load" />
<van-cell v-else-if="!options.length" :label="required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目'" />
</template>
</template>
</section>
</component>
</template>
<script setup>
@@ -35,6 +57,7 @@ const props = defineProps({
bizType: { type: String, required: true },
editing: { type: Boolean, default: false },
filter: { type: Boolean, default: false },
embedded: { type: Boolean, default: false },
context: { type: Object, default: null },
disabled: { type: Boolean, default: false },
record: { type: Object, default: () => ({}) }
@@ -44,7 +67,9 @@ const options = ref([])
const required = ref(false)
const loading = ref(true)
const error = ref('')
const labelId = `project-assignment-${Math.random().toString(36).slice(2, 9)}`
let controller
const rootClass = computed(() => (props.embedded ? 'market-form-field' : 'market-form-section'))
const selectOptions = computed(() => [
...(!required.value || props.filter ? [{ text: props.filter ? '全部项目' : '暂不归属', value: '' }] : []),
...options.value
+2 -1
View File
@@ -40,7 +40,8 @@ export function createListFilters(module, source = '') {
result.source = SOURCES.has(text(source)) ? text(source) : 'cell_storage'
return {
source: result.source,
protocolStatus: result.protocolStatus
protocolStatus: result.protocolStatus,
projectId: ''
}
}
return result
+11 -3
View File
@@ -15,11 +15,13 @@ test('creates isolated defaults for every market module', () => {
assert.deepEqual(first, {
source: 'cell_storage',
protocolStatus: 'sxz'
protocolStatus: 'sxz',
projectId: ''
})
assert.deepEqual(second, {
source: 'research_donation',
protocolStatus: ''
protocolStatus: '',
projectId: ''
})
assert.deepEqual(createListFilters('presign'), { orderStatus: '' })
assert.deepEqual(createListFilters('payment-chase'), { chaseType: '', payMode: '' })
@@ -66,8 +68,14 @@ test('drops incomplete ranges and counts only user-selectable filters', () => {
}), { progressStatus: 'yjq' })
assert.equal(activeFilterCount('protocol', {
source: 'cell_storage',
protocolStatus: 'sxz'
protocolStatus: 'sxz',
projectId: ''
}), 1)
assert.equal(activeFilterCount('protocol', {
source: 'research_donation',
protocolStatus: 'sxz',
projectId: 'p1'
}), 2)
assert.equal(activeFilterCount('freight', {
transportMode: '', sampleSource: 'research_donation'
}), 1)
@@ -313,6 +313,7 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
assert.match(protocol, /inlineDropdownFilters/)
assert.match(protocol, /全部协议状态/)
assert.match(protocol, /source:\s*'research_donation'/)
assert.match(protocol, /filterFields:\s*\[\]/)
assert.doesNotMatch(protocol, /createTimeRange/)
assert.doesNotMatch(protocol, /sampleCollectTimeRange/)
@@ -320,6 +321,25 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
assert.doesNotMatch(filters, /createTimeRange/)
assert.match(genericList, /inlineDropdownFilters/)
assert.match(genericList, /market-list__filter-row/)
assert.match(genericList, /showProtocolProjectFilter/)
assert.match(genericList, /全部项目/)
assert.doesNotMatch(genericList, /ProjectAssignmentField/)
})
test('protocol form embeds project assignment inside protocol info before status fields', () => {
const form = source('../views/market/MarketModuleForm.vue')
const formSection = source('./components/MarketFormSection.vue')
const projectField = source('./components/ProjectAssignmentField.vue')
assert.match(form, /insert-before-keys="\['protocolStatus', 'donationStatus'\]"/)
assert.match(form, /#insert/)
assert.match(form, /embedded/)
assert.match(form, /<ProjectAssignmentField[\s\S]*?embedded/)
assert.doesNotMatch(form, /ProjectAssignmentField v-if="context\.module === 'protocol'"/)
assert.match(formSection, /insertBeforeKeys/)
assert.match(formSection, /name="insert"/)
assert.match(projectField, /embedded/)
assert.match(projectField, /market-form-field/)
})
test('protocol list exposes mobile batch selection and sequential authorized deletion', () => {
@@ -478,6 +478,12 @@ export default {
allText: '全部协议状态',
source: 'cell_storage',
options: CELL_PROTOCOL_STATUS_OPTIONS.map((item) => ({ text: item.label, value: item.value }))
},
{
key: 'protocolStatus',
allText: '全部协议状态',
source: 'research_donation',
options: DONATION_PROTOCOL_STATUS_OPTIONS.map((item) => ({ text: item.label, value: item.value }))
}
],
statusLabel(detail) {
+19
View File
@@ -1247,6 +1247,25 @@ body.van-toast--unclickable .presign-order-page * {
font-weight: 500;
}
.market-form-field > .market-form-field__tip,
.market-form-field .market-form-field__control--text .market-form-field__tip {
display: block;
margin: 0;
color: #64748b;
font-size: 11px;
font-weight: 500;
line-height: 1.4;
}
.market-form-field > .market-form-field__tip--action {
padding: 0;
border: 0;
background: transparent;
color: #0a9f6d;
text-align: left;
cursor: pointer;
}
.market-form-field > .van-field,
.market-form-field__control {
box-sizing: border-box;
@@ -12,25 +12,52 @@
</van-empty>
</div>
<template v-else>
<ProjectAssignmentField v-if="context.module === 'protocol'" ref="projectAssignment"
v-model="formData.projectId" :biz-type="formData.source" :editing="mode === 'edit'"
:record="formData" :disabled="fieldsLocked" @update:model-value="dirty = true" />
<MarketFormSection
v-for="section in leadingSections"
:key="section.key"
:section="section"
:model-value="sectionValue(section)"
:options="formOptions"
:source="formData.source"
:mode="mode"
:uploading="false"
:disabled="fieldsLocked"
:search-customers="searchProtocolCustomers"
@update:model-value="setSectionValue(section, $event)"
@upload="uploadField"
@field-change="handleFieldChange"
@customer-select="handleCustomerSelect"
/>
<template v-for="section in leadingSections" :key="section.key">
<MarketFormSection
v-if="!(context.module === 'protocol' && section.key === 'protocol')"
:section="section"
:model-value="sectionValue(section)"
:options="formOptions"
:source="formData.source"
:mode="mode"
:uploading="false"
:disabled="fieldsLocked"
:search-customers="searchProtocolCustomers"
@update:model-value="setSectionValue(section, $event)"
@upload="uploadField"
@field-change="handleFieldChange"
@customer-select="handleCustomerSelect"
/>
<MarketFormSection
v-else
:section="section"
:model-value="sectionValue(section)"
:options="formOptions"
:source="formData.source"
:mode="mode"
:uploading="false"
:disabled="fieldsLocked"
:search-customers="searchProtocolCustomers"
:insert-before-keys="['protocolStatus', 'donationStatus']"
@update:model-value="setSectionValue(section, $event)"
@upload="uploadField"
@field-change="handleFieldChange"
@customer-select="handleCustomerSelect"
>
<template #insert>
<ProjectAssignmentField
ref="projectAssignment"
embedded
v-model="formData.projectId"
:biz-type="formData.source"
:editing="mode === 'edit'"
:record="formData"
:disabled="fieldsLocked"
@update:model-value="dirty = true"
/>
</template>
</MarketFormSection>
</template>
<section
v-if="context.module === 'protocol' && formData.source === 'cell_storage' && showDepositSection"
@@ -6,8 +6,6 @@
:show-header="false"
>
<div class="market-list__filters">
<ProjectAssignmentField v-if="context.module === 'protocol'" v-model="listFilters.projectId"
:biz-type="listStatus" filter :context="context" @update:model-value="search" />
<div
v-if="listFilterTabs.length"
class="market-source-tabs"
@@ -59,15 +57,24 @@
<span v-if="activeFilters" class="market-list__filter-count">{{ activeFilters }}</span>
</button>
</div>
<div v-if="inlineDropdownFilters.length" class="market-list__filter-row">
<div
v-if="inlineDropdownFilters.length || showProtocolProjectFilter"
class="market-list__filter-row"
>
<van-dropdown-menu active-color="#0a9f6d">
<van-dropdown-item
v-for="field in inlineDropdownFilters"
:key="field.key"
:key="`${field.source || 'all'}:${field.key}`"
:model-value="listFilters[field.key]"
:options="inlineFilterOptionsByKey[field.key] || []"
@change="(value) => onInlineFilterChange(field.key, value)"
/>
<van-dropdown-item
v-if="showProtocolProjectFilter"
:model-value="listFilters.projectId || ''"
:options="protocolProjectFilterOptions"
@change="(value) => onInlineFilterChange('projectId', value)"
/>
</van-dropdown-menu>
</div>
</div>
@@ -154,11 +161,15 @@
</template>
<script setup>
import ProjectAssignmentField from '../../market/components/ProjectAssignmentField.vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
import { useRoute, useRouter } from 'vue-router'
import { fetchMarketFormOptions, fetchMarketList, performMarketAction } from '../../api/market.js'
import {
fetchMarketFormOptions,
fetchMarketList,
fetchProjectOptions,
performMarketAction
} from '../../api/market.js'
import { intersectAllowedActions } from '../../market/action-policy.js'
import { deleteMarketItemsSequentially } from '../../market/batch-delete.js'
import {
@@ -169,6 +180,7 @@ import {
import { marketSearchPlaceholder } from '../../market/list-presenter.js'
import { marketErrorMessage } from '../../market/market-error.js'
import { getMarketModule } from '../../market/module-registry.js'
import { projectOptions } from '../../market/project-assignment.js'
import {
getMarketRouteContext,
MARKET_MODULE_TITLES,
@@ -247,6 +259,12 @@ const inlineDropdownFilters = computed(() => {
const source = listFilters.value.source || listStatus.value
return rows.filter((field) => !field.source || field.source === source)
})
const showProtocolProjectFilter = computed(() => context.value.module === 'protocol')
const protocolProjectOptions = ref([])
const protocolProjectFilterOptions = computed(() => [
{ text: '全部项目', value: '' },
...protocolProjectOptions.value
])
const activeFilters = computed(() =>
activeFilterCount(context.value.module, listFilters.value)
)
@@ -299,6 +317,7 @@ const allDeletableSelected = computed(() =>
)
let requestSequence = 0
let abortController = null
let projectOptionsController = null
let searchTimer = null
function normalizeList(result) {
@@ -360,7 +379,32 @@ function queueSearch() {
searchTimer = window.setTimeout(search, 350)
}
async function loadProtocolProjectOptions() {
projectOptionsController?.abort()
if (!showProtocolProjectFilter.value) {
protocolProjectOptions.value = []
return
}
const current = new AbortController()
projectOptionsController = current
const bizType = listFilters.value.source || listStatus.value || 'cell_storage'
try {
const data = await fetchProjectOptions(bizType, {
signal: current.signal,
filter: true,
context: context.value
})
if (projectOptionsController !== current) return
protocolProjectOptions.value = projectOptions(data)
} catch (error) {
if (projectOptionsController === current && error?.name !== 'AbortError') {
protocolProjectOptions.value = []
}
}
}
async function loadListFilterOptions() {
await loadProtocolProjectOptions()
const optionKeys = [
...sheetFilterFields.value.map((field) => field.optionKey),
...inlineDropdownFilters.value.map((field) => field.optionKey)
@@ -596,6 +640,7 @@ function switchListStatus(value) {
tab: next
}
router.replace({ query: nextQuery }).catch(() => {})
loadProtocolProjectOptions()
loadPage(true)
}
@@ -644,6 +689,7 @@ onBeforeUnmount(() => {
const requestId = ++requestSequence
listState.value = invalidateMarketRequestState(listState.value, requestId)
abortController?.abort()
projectOptionsController?.abort()
window.clearTimeout(searchTimer)
})
</script>
@@ -20,7 +20,13 @@
v-for="option in displayOptions"
:key="option.id"
:value="option.id"
:label="team ? option.teamName : `${option.projectName} · ${option.teamName}`"
:label="
team
? option.teamName
: teamId
? option.projectName
: `${option.projectName} · ${option.teamName}`
"
:disabled="!filter && option.disabled"
/>
</el-select>
@@ -43,6 +49,8 @@
const props = defineProps<{
filter?: boolean
team?: boolean
/** When selecting projects, keep options under this team. */
teamId?: string
modelValue?: string
bizType?: BizType
disabled?: boolean
@@ -54,15 +62,18 @@
ready: [value: boolean]
}>()
const options = ref<ProjectOption[]>([])
const displayOptions = computed(() =>
props.team
? [
...new Map(
options.value.map((option) => [option.teamId, { ...option, id: option.teamId }])
).values()
]
const displayOptions = computed(() => {
if (props.team) {
return [
...new Map(
options.value.map((option) => [option.teamId, { ...option, id: option.teamId }])
).values()
]
}
return props.teamId
? options.value.filter((option) => option.teamId === props.teamId)
: options.value
)
})
const loading = ref(false)
const error = ref(false),
retry = ref(0)
@@ -0,0 +1,368 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
CELL_STORAGE_HEADERS,
CUSTOMER_SERVICE_HEADERS,
PRESIGN_HEADERS,
RESEARCH_HEADERS,
buildCellStorageLedgerRows,
buildCustomerServiceLedgerRows,
buildFreightMergeRanges,
buildPresignLedgerRows,
buildResearchLedgerRows,
formatLedgerDateTime
} from './collection-ledger-export'
import type { ProtocolAuditFlatRow } from './protocol-audit-flatten'
describe('formatLedgerDateTime', () => {
it('uses Chinese text and keeps seconds', () => {
assert.equal(formatLedgerDateTime('2026-04-02 16:49:42'), '2026年4月2日16时49分42秒')
assert.equal(formatLedgerDateTime('20250907'), '2025年9月7日')
})
})
describe('buildPresignLedgerRows', () => {
it('maps order fields into the presign sheet columns', () => {
const [row] = buildPresignLedgerRows([
{
orderDate: '20260402',
protocolNo: 'ZKZX1',
motherName: '杨育蕾',
sampleTypeNames: ['围产期造血干细胞'],
regionName: '亳州区域',
hospitalName: '涡阳县人民医院',
payMode: 'dj',
sampleCollectTime: '2026-04-02 16:49:42',
contractAmount: 22880,
confirmedDepositAmount: 1000,
transferTimes: ['2026-04-02 16:49:42'],
expectedDeliveryDate: '20260930',
salesUserName: '张慧茹',
regionManagerUserName: '张颖',
remark: '已存'
}
])
assert.equal(row.length, PRESIGN_HEADERS.length)
assert.deepEqual(row.slice(0, 6), ['1', '2026', '4', '2', 'ZKZX1', '杨育蕾'])
assert.equal(row[9], '趸交')
assert.equal(row[12], '1000元(定金)')
assert.equal(row[13], '2026年4月2日16时49分42秒')
assert.equal(row[18], '')
})
it('fills refund amount for voided deposit orders', () => {
const [row] = buildPresignLedgerRows([
{
orderDate: '20250919',
motherName: '陈影',
confirmedDepositAmount: 1000,
transferTimes: ['2025-09-19 17:26:13'],
orderStatus: 'void',
remark: '悔存-申请退定金'
}
])
assert.equal(row[12], '1000元(定金)')
assert.equal(row[18], '1000元')
})
})
describe('buildCellStorageLedgerRows', () => {
it('places installment amounts and keeps template header width', () => {
const flat = {
protocolId: 'p1',
protocol: {
protocolStatus: 'sxz',
remark: '分期备注',
voidType: '',
voidDesc: ''
},
sampleIndex: 0,
isCoStored: false,
sampleCollectTime: '2025-09-15 08:51:00',
motherName: '郭梦朵',
protocolNo: 'PROTO-SHOULD-NOT-EXPORT',
sampleCode: 'ZKZX34000849',
sampleTypeName: '围产期造血干细胞',
regionName: '亳州区域',
hospitalName: '涡阳县人民医院',
labStorageCode: 'B0000004',
salesUserName: '马丽',
regionManagerUserName: '张颖',
transportType: 'kdys',
freightId: 'fr-cell-1',
freightTransportLabel: '专线车',
freightAmountLabel: '120',
monthlyVerifyNo: '202509012',
paymentSummary: {
payMode: 'fq',
contractAmount: 22880,
receivedAmount: 6480,
pendingAmount: 16400,
firstPayTransferTime: '2025-09-15 11:13:01',
paymentDetails: [
{
installmentNo: 1,
dueAmount: 6480,
transferAmount: 6480,
installmentDueDate: '20250915',
transferTime: '2025-09-15 11:13:01'
},
{
installmentNo: 2,
dueAmount: 16400,
transferAmount: 0,
installmentDueDate: '20251215',
transferTime: ''
}
]
}
} as unknown as ProtocolAuditFlatRow
const { rows } = buildCellStorageLedgerRows([flat])
const row = rows[0]
assert.equal(row.length, CELL_STORAGE_HEADERS.length)
assert.equal(CELL_STORAGE_HEADERS[13], '涡阳县人民医院')
assert.equal(row[4], '202509012')
assert.equal(row[5], 'B0000004')
assert.equal(row[6], '生效中')
assert.equal(row[9], 'ZKZX34000849')
assert.equal(row[11], '否')
assert.equal(row[13], '涡阳县人民医院')
assert.equal(row[14], '分期')
assert.equal(row[16], '22880')
assert.equal(row[17], '6480')
assert.equal(row[20], '')
assert.equal(row[22], '2025年9月15日11时13分01秒')
assert.equal(row[24], '16400')
assert.equal(row[25], '16400')
assert.equal(row[26], '2025年12月15日')
assert.equal(row[50], '马丽')
assert.equal(row[52], '专线车')
assert.equal(row[53], '120')
})
it('writes negative refund amount for voided protocols', () => {
const { rows } = buildCellStorageLedgerRows([
{
protocol: { protocolStatus: 'yzf', remark: '', voidType: 'khhc', voidDesc: '悔存' },
sampleCollectTime: '2025-09-07 13:45:00',
motherName: '王晴晴',
sampleCode: 'S-VOID',
sampleTypeName: '围产期造血干细胞',
paymentSummary: {
payMode: 'dj',
contractAmount: 5000,
receivedAmount: 5000,
refundAmount: 5000,
paymentDetails: []
}
} as unknown as ProtocolAuditFlatRow
])
assert.equal(rows[0][19], '5000')
assert.equal(rows[0][20], '-5000')
})
it('merges transport and freight columns for the same freight batch', () => {
const base = {
protocol: { protocolStatus: 'sxz', remark: '', voidType: '', voidDesc: '' },
sampleCollectTime: '2025-09-11 18:13:00',
motherName: '甲',
sampleCode: 'S1',
sampleTypeName: 'NK',
regionName: '常州',
hospitalName: '溧阳市人民医院',
labStorageCode: 'UBMC1',
freightId: 'batch-cell',
freightTransportLabel: '专线车',
freightAmountLabel: '100'
}
const { rows, merges } = buildCellStorageLedgerRows([
{ ...base, sampleIndex: 0 } as unknown as ProtocolAuditFlatRow,
{
...base,
sampleIndex: 1,
motherName: '乙',
sampleCode: 'S2',
labStorageCode: 'UBMC2'
} as unknown as ProtocolAuditFlatRow
])
assert.equal(rows[0][52], '专线车')
assert.equal(rows[0][53], '100')
assert.equal(rows[1][52], '')
assert.equal(rows[1][53], '')
assert.deepEqual(merges, [
{ startRow: 2, endRow: 3, startCol: 53, endCol: 53 },
{ startRow: 2, endRow: 3, startCol: 54, endCol: 54 }
])
})
})
describe('buildResearchLedgerRows', () => {
it('maps donation fields and consent flag', () => {
const flat = {
protocol: {
donationStatus: 'yzf',
voidType: 'sysfq',
voidDesc: '母血乙肝阳性',
monthlyVerifyNo: '202508001'
},
sampleIndex: 0,
sampleCollectTime: '2025-08-29 12:27:00',
motherName: '曹陆双',
sampleTypeName: '围产期造血干细胞',
regionName: '亳州',
hospitalName: '涡阳县人民医院',
labStorageCode: 'A0000001',
salesUserName: '杨莉莉',
regionManagerUserName: '张颖',
transportType: '',
newbornDeliveryTime: '2025-08-29 12:26:00',
newbornSex: '0',
newbornWeightGram: 3000,
deliveryMode: 'sc',
birthOrder: 1,
maternalBloodCollectTime: '2025-08-29 13:30:00',
consentFormUrls: 'http://example/consent',
remark: '',
freightId: 'fr1',
freightTransportLabel: '专线车',
freightAmountLabel: '100'
} as unknown as ProtocolAuditFlatRow
const { rows } = buildResearchLedgerRows([flat])
const row = rows[0]
assert.equal(row.length, RESEARCH_HEADERS.length)
assert.equal(row[1], '2025年')
assert.equal(row[4], '202508001')
assert.equal(row[6], '实验室废弃')
assert.equal(row[7], '母血乙肝阳性')
assert.equal(row[9], '实验室废弃')
assert.equal(row[16], '专线车')
assert.equal(row[17], '100')
assert.equal(row[18], '女')
assert.equal(row[19], '3000g')
assert.equal(row[20], '顺产')
assert.equal(row[23], '1')
})
it('keeps sample type in column 10 for active research rows', () => {
const { rows } = buildResearchLedgerRows([
{
protocol: { donationStatus: 'sxz', voidType: '', voidDesc: '', monthlyVerifyNo: '202508010' },
sampleCollectTime: '2025-08-30 10:00:00',
motherName: '正常',
sampleTypeName: '围产期造血干细胞',
regionName: '亳州',
hospitalName: '涡阳县人民医院',
freightId: '',
freightTransportLabel: '',
freightAmountLabel: ''
} as unknown as ProtocolAuditFlatRow
])
assert.equal(rows[0][6], '')
assert.equal(rows[0][9], '围产期造血干细胞')
})
it('maps sdfq void type code to 属地废弃 labels', () => {
const flat = {
protocol: {
donationStatus: 'yzf',
voidType: 'sdfq',
voidDesc: '',
monthlyVerifyNo: ''
},
sampleIndex: 0,
sampleCollectTime: '2025-08-29 12:27:00',
motherName: '测试',
sampleTypeName: '围产期造血干细胞',
regionName: '亳州',
hospitalName: '涡阳县人民医院',
labStorageCode: 'A0000002',
salesUserName: '',
regionManagerUserName: '',
transportType: '',
newbornDeliveryTime: '',
newbornSex: '',
newbornWeightGram: '',
deliveryMode: 'pgc',
birthOrder: '',
maternalBloodCollectTime: '',
consentFormUrls: '',
remark: '',
freightId: '',
freightTransportLabel: '',
freightAmountLabel: ''
} as unknown as ProtocolAuditFlatRow
const { rows } = buildResearchLedgerRows([flat])
const row = rows[0]
assert.equal(row[6], '属地废弃')
assert.equal(row[7], '属地废弃')
assert.equal(row[9], '属地废弃')
assert.equal(row[20], '剖腹产')
assert.equal(row[22], '无母血')
})
it('merges transport and freight columns for the same freight batch', () => {
const base = {
protocol: { donationStatus: 'sxz', voidType: '', voidDesc: '', monthlyVerifyNo: '' },
sampleCollectTime: '2025-09-11 18:13:00',
motherName: '甲',
sampleTypeName: 'NK',
regionName: '常州',
hospitalName: '溧阳市人民医院',
labStorageCode: 'UBMC1',
freightId: 'batch-a',
freightTransportLabel: '专线车',
freightAmountLabel: '100'
}
const { rows, merges } = buildResearchLedgerRows([
{ ...base, sampleIndex: 0 } as unknown as ProtocolAuditFlatRow,
{
...base,
sampleIndex: 1,
motherName: '乙',
labStorageCode: 'UBMC2'
} as unknown as ProtocolAuditFlatRow
])
assert.equal(rows[0][16], '专线车')
assert.equal(rows[0][17], '100')
assert.equal(rows[1][16], '')
assert.equal(rows[1][17], '')
assert.deepEqual(merges, [
{ startRow: 2, endRow: 3, startCol: 17, endCol: 17 },
{ startRow: 2, endRow: 3, startCol: 18, endCol: 18 }
])
assert.deepEqual(buildFreightMergeRanges(['', 'x', 'x', 'y']), [
{ startRow: 3, endRow: 4, startCol: 17, endCol: 17 },
{ startRow: 3, endRow: 4, startCol: 18, endCol: 18 }
])
})
})
describe('buildCustomerServiceLedgerRows', () => {
it('fills protocol-number column with sample code', () => {
const [row] = buildCustomerServiceLedgerRows([
{
labStorageCode: 'B0000002',
protocolStatus: 'sxz',
smsSendDate: '20250908',
smsSendStatus: '已发',
sampleCode: 'ZKZX34000852',
protocolNo: 'PROTO-SHOULD-NOT-EXPORT',
motherName: '高居巧',
motherPhone: '17860507206',
motherIdCard: '370911199712254424',
protocolAttachmentPresent: true,
insuranceDocPresent: false,
informedConsentPresent: true,
healthSurveyPresent: true,
payModeLabel: '趸交'
}
])
assert.equal(row.length, CUSTOMER_SERVICE_HEADERS.length)
assert.equal(row[0], 'B0000002')
assert.equal(row[2], '1')
assert.equal(row[5], 'ZKZX34000852')
assert.equal(row[8], '370911199712254424')
assert.equal(row[29], '1')
assert.equal(row[30], '0')
})
})
@@ -0,0 +1,745 @@
import type { ProtocolAuditFlatRow } from './protocol-audit-flatten'
type Row = Record<string, unknown>
type PayDetail = {
installmentNo?: number | null
dueAmount?: number | null
transferAmount?: number | null
installmentDueDate?: string
transferTime?: string
}
export const PRESIGN_HEADERS = [
'序号',
'年',
'月',
'日',
'协议编号',
'母亲姓名',
'样本类型',
'采集地区',
'采集医院',
'缴费方式',
'采集时间',
'合同金额',
'到账金额',
'刷卡时间',
'预产期',
'市场专员',
'地区经理',
'备注',
'退款金额'
]
/** 表头与《采集大表》自存表第 1 行一致,含原表里的医院名和「分期」。 */
export const CELL_STORAGE_HEADERS = [
'标准化加工全流程',
'年',
'月',
'日',
'当月核量号',
'实验室\n流转编码',
'储存状态',
'废弃\n原因',
'母亲姓名',
'协议编号',
'样本类型',
'是否同存',
'采集地区',
'涡阳县人民医院',
'分期',
'采集时间',
'合同金额\n(元)',
'首期应缴费\n金额(元)',
'已缴金额\n(元)',
'悔存/废弃\n已缴金额',
'退款金额',
'备注',
'首期\n缴费时间',
'首期欠缴\n金额(元)',
'分期欠缴\n金额(元)',
'第二次应缴费\n金额(元)',
'第二次应缴费\n时间',
'第二次已\n缴金额(元)',
'第二次已\n缴费时间',
'第二次欠缴\n金额(元)',
'第三次应缴费\n金额(元)',
'第三次应缴费\n时间',
'第三次已\n缴金额(元)',
'第三次已\n缴费时间',
'第三次欠缴\n金额(元)',
'第四次应缴费\n金额(元)',
'第四次应缴费\n时间',
'第四次已\n缴金额(元)',
'第四次已\n缴费时间',
'第四次欠缴\n金额(元)',
'第五次应缴费\n金额(元)',
'第五次应缴费\n时间',
'第五次已\n缴金额(元)',
'第五次已\n缴费时间',
'第五次欠缴\n金额(元)',
'第六次应缴费\n金额(元)',
'第六次应缴费\n时间',
'第六次已\n缴金额(元)',
'第六次已\n缴费时间',
'第六次欠缴\n金额(元)',
'市场专员',
'地区经理',
'运输方式',
'运费',
'回单超链接-快捷方式',
'发票',
'特殊情况说明'
]
export const RESEARCH_HEADERS = [
'序号',
'年份',
'月',
'日',
'当月核量号',
'实验室编码',
'储存状态',
'废弃原因',
'母亲姓名',
'围产期造血干细胞',
'采集\n地区',
'采集医院',
'分娩时间',
'采集时间',
'市场专员@@',
'地区经理',
'运输方式',
'运费',
'性别',
'新生儿体重',
'分娩方式',
'胎次',
'母血采集时间',
'知情同意书',
'备注'
]
export const CUSTOMER_SERVICE_HEADERS = [
'实验室流转编码',
'储存状态',
'序号',
'发送日期',
'短信发送\n情况',
'协议编号',
'母亲姓名',
'母亲电话',
'母亲身份证号码',
'父亲姓名',
'父亲电话',
'父亲身份证号码',
'回访日期',
'微生物\n回访情况',
'满月\n回访时间',
'回访情况',
'是否\n上户口',
'宝宝姓名',
'宝宝身份证号码',
'性别',
'分娩时间',
'购买保险时间',
'保单发送时间',
'电子发票\n发送时间',
'邮寄储存证时间',
'有效地址',
'市场专员',
'缴费方式',
'备注',
'协议书',
'保险单',
'知情同意书',
'健康调查表'
]
const PROTOCOL_STATUS: Record<string, string> = {
dsx: '待生效',
sxz: '生效中',
ygq: '已过期',
yzf: '已作废',
sdfq: '属地废弃',
zky: '转科研',
zckc: '转细胞存储'
}
/** 作废类型兜底;与 void-type-dict 默认项一致,并覆盖误写入作废类型的状态码。 */
const VOID_TYPE: Record<string, string> = {
khhc: '客户毁存',
jcbhg: '检测不合格',
sysfq: '实验室废弃'
}
/** 导出前由字典填充;单元测试环境可为空。 */
let runtimeVoidLabels: Record<string, string> = {}
const PAY_MODE: Record<string, string> = {
dj: '趸交',
fq: '分期',
djfq: '趸交+分期',
fgd: '分改趸'
}
const TRANSPORT: Record<string, string> = {
kdys: '快递运输',
ryxd: '人员携带'
}
const SEX: Record<string, string> = {
'1': '男',
'0': '女'
}
const DELIVERY: Record<string, string> = {
sc: '顺产',
pgc: '剖宫产'
}
/** 科研表模板多用「剖腹产」。 */
const RESEARCH_DELIVERY: Record<string, string> = {
sc: '顺产',
pgc: '剖腹产'
}
export type LedgerMergeRange = {
startRow: number
endRow: number
startCol: number
endCol: number
}
function text(value: unknown): string {
return value == null ? '' : String(value).trim()
}
function label(map: Record<string, string>, value: unknown): string {
const code = text(value)
if (!code) return ''
return map[code] || code
}
export function formatLedgerMoney(value: unknown): string {
if (value == null || value === '') return ''
const amount = Number(value)
if (!Number.isFinite(amount)) return text(value)
if (Math.abs(amount - Math.round(amount)) < 1e-9) return String(Math.round(amount))
return amount.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
}
export function formatLedgerDate(value: unknown): string {
const parts = dateParts(value)
if (!parts) return text(value)
return `${parts.year}年${parts.month}月${parts.day}日`
}
function dateParts(value: unknown): {
year: number
month: number
day: number
hour?: number
minute?: number
second?: number
} | null {
const raw = text(value)
if (!raw) return null
if (/^\d{8}$/.test(raw)) {
return {
year: Number(raw.slice(0, 4)),
month: Number(raw.slice(4, 6)),
day: Number(raw.slice(6, 8))
}
}
const matched = raw.match(
/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/
)
if (!matched) return null
return {
year: Number(matched[1]),
month: Number(matched[2]),
day: Number(matched[3]),
hour: matched[4] == null ? undefined : Number(matched[4]),
minute: matched[5] == null ? undefined : Number(matched[5]),
second: matched[6] == null ? undefined : Number(matched[6])
}
}
export function formatLedgerDateTime(value: unknown): string {
const parts = dateParts(value)
if (!parts) return text(value)
let result = `${parts.year}年${parts.month}月${parts.day}日`
if (parts.hour == null) return result
result += `${parts.hour}时${String(parts.minute ?? 0).padStart(2, '0')}分`
if (parts.second) result += `${String(parts.second).padStart(2, '0')}秒`
return result
}
function yearText(value: unknown, suffix: string): string {
const parts = dateParts(value)
if (!parts) return ''
return `${parts.year}${suffix}`
}
function monthText(value: unknown): string {
const parts = dateParts(value)
return parts ? String(parts.month) : ''
}
function dayText(value: unknown): string {
const parts = dateParts(value)
return parts ? String(parts.day) : ''
}
/** 作废类型:字典 → 本地兜底 → 协议状态码文案(如 sdfq→属地废弃)。 */
function voidTypeText(value: unknown): string {
const code = text(value)
if (!code) return ''
return (
runtimeVoidLabels[code] ||
VOID_TYPE[code] ||
PROTOCOL_STATUS[code] ||
code
)
}
function statusText(status: unknown, voidType: unknown): string {
const code = text(status)
const base = PROTOCOL_STATUS[code] || code
if (code !== 'yzf') return base
const reason = voidTypeText(voidType)
return reason ? `${base}(${reason})` : base
}
/** 科研表储存状态:废弃写类别文案,正常样本留空,对齐线下大表。 */
function researchStorageStatus(status: unknown, voidType: unknown): string {
const code = text(status)
if (code === 'yzf') return voidTypeText(voidType)
if (code === 'sdfq') return PROTOCOL_STATUS.sdfq
return ''
}
function voidReason(row: Row): string {
return text(row.voidDesc) || voidTypeText(row.voidType)
}
function researchTransportText(row: ProtocolAuditFlatRow): string {
return text(row.freightTransportLabel) || transportText(row)
}
function researchFreightText(row: ProtocolAuditFlatRow): string {
return text(row.freightAmountLabel)
}
function researchMaternalBloodText(value: unknown): string {
const formatted = formatLedgerDateTime(value)
if (formatted) return formatted
return text(value) ? text(value) : '无母血'
}
/**
* 相邻且 freightId 相同的导出行,合并运输方式/运费列(Excel 行号含表头)。
* @param transportCol 运输方式列(1-based)
* @param freightCol 运费列(1-based)
*/
export function buildFreightMergeRanges(
freightIds: string[],
transportCol = 17,
freightCol = 18
): LedgerMergeRange[] {
const merges: LedgerMergeRange[] = []
let index = 0
while (index < freightIds.length) {
const freightId = text(freightIds[index])
if (!freightId) {
index += 1
continue
}
let end = index + 1
while (end < freightIds.length && text(freightIds[end]) === freightId) end += 1
if (end - index >= 2) {
const startRow = index + 2
const endRow = end + 1
merges.push({ startRow, endRow, startCol: transportCol, endCol: transportCol })
merges.push({ startRow, endRow, startCol: freightCol, endCol: freightCol })
}
index = end
}
return merges
}
function applyFreightMerges(
built: string[][],
freightIds: string[],
transportCol: number,
freightCol: number
): LedgerMergeRange[] {
const merges = buildFreightMergeRanges(freightIds, transportCol, freightCol)
for (const merge of merges) {
const colIndex = merge.startCol - 1
for (let excelRow = merge.startRow + 1; excelRow <= merge.endRow; excelRow += 1) {
const dataIndex = excelRow - 2
if (dataIndex >= 0 && dataIndex < built.length) built[dataIndex][colIndex] = ''
}
}
return merges
}
/** 导出前加载作废类型字典,避免列表外的编码原样写入 Excel。 */
export async function prepareCollectionLedgerExport(): Promise<void> {
const { loadVoidTypeDict, getVoidTypeOptions } = await import('./void-type-dict')
await loadVoidTypeDict()
runtimeVoidLabels = Object.fromEntries(
getVoidTypeOptions().map((item) => [String(item.value), String(item.label)])
)
}
function payModeText(value: unknown, fallback = ''): string {
return label(PAY_MODE, value) || text(fallback)
}
function joined(value: unknown): string {
if (Array.isArray(value)) return value.map((item) => text(item)).filter(Boolean).join('、')
return text(value)
}
function flag01(value: unknown): string {
if (value === true || value === 1 || value === '1') return '1'
if (value === false || value === 0 || value === '0') return '0'
return ''
}
function weightText(value: unknown): string {
const raw = text(value)
if (!raw) return ''
if (/g$/i.test(raw)) return raw
return `${formatLedgerMoney(raw) || raw}g`
}
function paymentDetails(summary: unknown): PayDetail[] {
if (!summary || typeof summary !== 'object') return []
const details = (summary as Row).paymentDetails
return Array.isArray(details) ? (details as PayDetail[]) : []
}
function installmentGroup(details: PayDetail[], installmentNo: number) {
let rows = details.filter((item) => Number(item.installmentNo) === installmentNo)
const numbered = details.some(
(item) => item.installmentNo != null && text(item.installmentNo) !== ''
)
if (!rows.length && installmentNo === 1 && !numbered) rows = details.slice(0, 1)
const dueValues = rows
.map((item) => item.dueAmount)
.filter((item) => item != null && item !== ('' as unknown))
const paidValues = rows
.map((item) => item.transferAmount)
.filter((item) => item != null && item !== ('' as unknown))
const due = dueValues.length
? dueValues.reduce<number>((sum, item) => sum + Number(item || 0), 0)
: null
const paid = paidValues.length
? paidValues.reduce<number>((sum, item) => sum + Number(item || 0), 0)
: null
const dueDate = rows.map((item) => text(item.installmentDueDate)).find(Boolean) || ''
const paidTime = rows
.map((item) => text(item.transferTime))
.filter(Boolean)
.join(',')
return { due, paid, dueDate, paidTime }
}
function gapText(due: number | null, paid: number | null): string {
if (due == null || paid == null) return ''
return formatLedgerMoney(due - paid)
}
function sampleTransport(row: ProtocolAuditFlatRow): Row {
const items = row.protocol.sampleItems
if (!Array.isArray(items)) return {}
const item = items[row.sampleIndex]
if (!item || typeof item !== 'object') return {}
const transport = (item as Row).transport
return transport && typeof transport === 'object' ? (transport as Row) : {}
}
function transportText(row: ProtocolAuditFlatRow): string {
const transport = sampleTransport(row)
const company = text(transport.expressCompanyName)
if (company) return company
return label(TRANSPORT, row.transportType || transport.transportType)
}
function presignDepositText(amount: unknown): string {
const money = formatLedgerMoney(amount)
return money ? `${money}元(定金)` : ''
}
function presignRefundText(row: Row): string {
const explicit = formatLedgerMoney(row.refundAmount)
if (explicit) return `${explicit}元`
const status = text(row.orderStatus)
const remark = text(row.remark)
const voided = status === 'void' || remark.includes('悔存') || remark.includes('退定金')
if (!voided) return ''
const deposit = formatLedgerMoney(row.confirmedDepositAmount)
return deposit ? `${deposit}元` : ''
}
export function buildPresignLedgerRows(rows: Row[]): string[][] {
return rows.map((row, index) => {
const orderDate = row.orderDate
const times = Array.isArray(row.transferTimes) ? row.transferTimes : []
return [
String(index + 1),
yearText(orderDate, ''),
monthText(orderDate),
dayText(orderDate),
text(row.protocolNo),
text(row.motherName),
joined(row.sampleTypeNames),
text(row.regionName),
text(row.hospitalName),
payModeText(row.payMode),
formatLedgerDateTime(row.sampleCollectTime),
formatLedgerMoney(row.contractAmount),
presignDepositText(row.confirmedDepositAmount),
times.map((item) => formatLedgerDateTime(item)).filter(Boolean).join(','),
formatLedgerDate(row.expectedDeliveryDate),
text(row.salesUserName),
text(row.regionManagerUserName),
text(row.remark),
presignRefundText(row)
]
})
}
/** 自存表退款金额:有退费记录写负值;无退费但已作废时写 -已缴金额。 */
function cellStorageRefundText(summary: Row | null, voided: boolean, received: string): string {
const explicit = summary?.refundAmount
if (explicit != null && explicit !== '') {
const amount = Number(explicit)
if (Number.isFinite(amount) && amount !== 0) {
const abs = formatLedgerMoney(Math.abs(amount))
return abs ? `-${abs}` : ''
}
}
if (!voided || !received) return ''
return `-${received}`
}
function storageLedgerRow(row: ProtocolAuditFlatRow, index: number, research: boolean): string[] {
const protocol = row.protocol
const summary = (row.paymentSummary || protocol.paymentSummary) as Row | null
const details = paymentDetails(summary)
const first = installmentGroup(details, 1)
const collectTime = row.sampleCollectTime
const status = research ? protocol.donationStatus || protocol.status : protocol.protocolStatus
const received = summary ? formatLedgerMoney(summary.receivedAmount) : ''
const contract = formatLedgerMoney(
summary?.contractAmount ?? protocol.protocolTotalAmount
)
const payMode = text(summary?.payMode)
const firstPaidTime = text(summary?.firstPayTransferTime) || first.paidTime
const voided = text(status) === 'yzf' || text(status) === 'sdfq'
const cells = research ? new Array<string>(RESEARCH_HEADERS.length) : new Array<string>(CELL_STORAGE_HEADERS.length)
cells.fill('')
if (research) {
cells[0] = String(index + 1)
cells[1] = yearText(collectTime, '年')
cells[2] = monthText(collectTime)
cells[3] = dayText(collectTime)
cells[4] = text(protocol.monthlyVerifyNo)
cells[5] = text(row.labStorageCode)
cells[6] = researchStorageStatus(status, protocol.voidType)
cells[7] = voidReason(protocol)
cells[8] = text(row.motherName)
// 标准大表废弃行第 10 列复用储存状态文案;正常行写样本类型
cells[9] = cells[6] || text(row.sampleTypeName)
cells[10] = text(row.regionName)
cells[11] = text(row.hospitalName)
cells[12] = formatLedgerDateTime(row.newbornDeliveryTime)
cells[13] = formatLedgerDateTime(collectTime)
cells[14] = text(row.salesUserName)
cells[15] = text(row.regionManagerUserName)
cells[16] = researchTransportText(row)
cells[17] = researchFreightText(row)
cells[18] = label(SEX, row.newbornSex)
cells[19] = weightText(row.newbornWeightGram)
cells[20] = label(RESEARCH_DELIVERY, row.deliveryMode) || label(DELIVERY, row.deliveryMode)
cells[21] = text(row.birthOrder)
cells[22] = researchMaternalBloodText(row.maternalBloodCollectTime)
cells[23] = text(row.consentFormUrls) ? '1' : ''
cells[24] = text(row.remark)
return cells
}
cells[0] = String(index + 1)
cells[1] = yearText(collectTime, '')
cells[2] = monthText(collectTime)
cells[3] = dayText(collectTime)
cells[4] = text(row.monthlyVerifyNo) || text(protocol.monthlyVerifyNo)
cells[5] = text(row.labStorageCode)
cells[6] = statusText(status, protocol.voidType)
cells[7] = voidReason(protocol)
cells[8] = text(row.motherName)
cells[9] = text(row.sampleCode)
cells[10] = text(row.sampleTypeName)
cells[11] = row.isCoStored ? '是' : '否'
cells[12] = text(row.regionName)
cells[13] = text(row.hospitalName)
cells[14] = payModeText(payMode, text(summary?.payModeLabel))
cells[15] = formatLedgerDateTime(collectTime)
cells[16] = contract
cells[17] = formatLedgerMoney(first.due)
cells[18] = received
cells[19] = voided ? received : ''
cells[20] = cellStorageRefundText(summary, voided, received)
cells[21] = text(protocol.remark)
cells[22] = formatLedgerDateTime(firstPaidTime)
cells[23] = gapText(first.due, first.paid)
cells[24] =
payMode === 'fq' || payMode === 'djfq'
? formatLedgerMoney(summary?.pendingAmount)
: contract
? '0'
: ''
let cursor = 25
for (const installmentNo of [2, 3, 4, 5, 6]) {
const group = installmentGroup(details, installmentNo)
cells[cursor++] = formatLedgerMoney(group.due)
cells[cursor++] = formatLedgerDate(group.dueDate)
cells[cursor++] = formatLedgerMoney(group.paid)
cells[cursor++] = formatLedgerDateTime(group.paidTime)
cells[cursor++] = gapText(group.due, group.paid)
}
cells[50] = text(row.salesUserName)
cells[51] = text(row.regionManagerUserName)
cells[52] = researchTransportText(row)
cells[53] = researchFreightText(row)
return cells
}
export function buildCellStorageLedgerRows(rows: ProtocolAuditFlatRow[]): {
rows: string[][]
merges: LedgerMergeRange[]
} {
const freightIds = rows.map((row) => text(row.freightId))
const built = rows.map((row, index) => storageLedgerRow(row, index, false))
// 自存表:运输方式=53,运费=54
return { rows: built, merges: applyFreightMerges(built, freightIds, 53, 54) }
}
export function buildResearchLedgerRows(rows: ProtocolAuditFlatRow[]): {
rows: string[][]
merges: LedgerMergeRange[]
} {
const freightIds = rows.map((row) => text(row.freightId))
const built = rows.map((row, index) => storageLedgerRow(row, index, true))
// 科研表:运输方式=17,运费=18
return { rows: built, merges: applyFreightMerges(built, freightIds, 17, 18) }
}
export function buildCustomerServiceLedgerRows(rows: Row[]): string[][] {
return rows.map((row, index) => [
text(row.labStorageCode),
statusText(row.protocolStatus, row.voidType),
String(index + 1),
formatLedgerDate(row.smsSendDate),
text(row.smsSendStatus),
text(row.sampleCode),
text(row.motherName),
text(row.motherPhone),
text(row.motherIdCard),
text(row.fatherName),
text(row.fatherPhone),
text(row.fatherIdCard),
formatLedgerDate(row.microbeFollowupDate),
text(row.microbeFollowupStatus),
formatLedgerDate(row.fullmoonFollowupDate),
text(row.fullmoonFollowupStatus),
text(row.householdRegistered),
text(row.newbornName),
text(row.newbornIdCard),
label(SEX, row.newbornSex),
formatLedgerDateTime(row.newbornDeliveryTime),
formatLedgerDateTime(row.purchaseInsuranceTime),
formatLedgerDateTime(row.policySendTime),
formatLedgerDateTime(row.einvoiceSendTime),
formatLedgerDateTime(row.mailCertTime),
text(row.effectiveAddress),
text(row.marketSpecialistName),
text(row.payModeLabel) || payModeText(row.payMode),
text(row.protocolRemark),
flag01(row.protocolAttachmentPresent),
flag01(row.insuranceDocPresent),
flag01(row.informedConsentPresent),
flag01(row.healthSurveyPresent)
])
}
export async function fetchAllPages<T>(
load: (page: number, pageSize: number) => Promise<{ list?: T[]; total?: number }>
): Promise<T[]> {
const pageSize = 100
const all: T[] = []
let page = 1
let total = Number.POSITIVE_INFINITY
while (all.length < total && page <= 200) {
const result = await load(page, pageSize)
const list = result.list || []
total = result.total ?? list.length
all.push(...list)
if (!list.length || list.length < pageSize) break
page += 1
}
return all
}
function fileStamp(): string {
const stamp = new Date()
const pad = (value: number) => String(value).padStart(2, '0')
return `${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}${pad(stamp.getHours())}${pad(stamp.getMinutes())}${pad(stamp.getSeconds())}`
}
export async function saveCollectionLedgerWorkbook(
sheetName: string,
headers: string[],
rows: string[][],
filenamePrefix: string,
merges: LedgerMergeRange[] = []
) {
const ExcelJS = (await import('exceljs')).default
const FileSaver = (await import('file-saver')).default
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet(sheetName)
const headerRow = sheet.addRow(headers)
headerRow.eachCell((cell) => {
cell.numFmt = '@'
cell.alignment = { wrapText: true, vertical: 'middle' }
})
rows.forEach((line) => {
const added = sheet.addRow(line)
line.forEach((value, index) => {
const cell = added.getCell(index + 1)
cell.value = value
cell.numFmt = '@'
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }
})
})
merges.forEach((merge) => {
if (merge.endRow <= merge.startRow) return
sheet.mergeCells(merge.startRow, merge.startCol, merge.endRow, merge.endCol)
const cell = sheet.getCell(merge.startRow, merge.startCol)
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }
})
headers.forEach((header, index) => {
sheet.getColumn(index + 1).width = Math.min(28, Math.max(12, header.replace(/\n/g, '').length + 2))
})
const buffer = await workbook.xlsx.writeBuffer()
FileSaver.saveAs(
new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}),
`${filenamePrefix}-${fileStamp()}.xlsx`
)
}
+10 -1
View File
@@ -22,13 +22,14 @@ export function assignmentSnapshot(records: ProjectRecord[]) {
}))
}
/** Write bodies intentionally exclude response-only labels, timestamps and generated numbers. */
/** Write bodies intentionally exclude response-only labels and timestamps. */
export function managementPayload(kind: 'teams' | 'projects', form: ProjectRecord): ProjectRecord {
const fields =
kind === 'teams'
? ['teamName', 'leaderUserId', 'enabled', 'remark']
: [
'projectName',
'projectNo',
'teamId',
'leaderUserId',
'bizTypes',
@@ -58,3 +59,11 @@ export function managementPayload(kind: 'teams' | 'projects', form: ProjectRecor
export function projectOptionsParams(bizType: BizType, filter = false) {
return filter ? { bizType, usage: 'filter' as const } : { bizType }
}
/** List cells join child display names; empty entries are dropped. */
export function joinChildNames(items?: ProjectRecord[]) {
return (items || [])
.map((item) => item?.name)
.filter(Boolean)
.join('、')
}
@@ -26,6 +26,8 @@ describe('flattenProtocolAuditRows', () => {
id: 'p1',
protocolNo: 'ZKZX1',
motherName: '郭梦朵',
projectName: '测试项目',
teamName: '陈星项目组111',
sampleCollectTime: '2025-09-15 08:51:00',
sampleItems: [
{ sampleTypeName: '脐带血', sampleCode: 'A1', labStorageCode: 'B0000004' },
@@ -40,6 +42,25 @@ describe('flattenProtocolAuditRows', () => {
assert.equal(rows[0].labStorageCode, 'B0000004')
assert.equal(rows[1].labStorageCode, 'B0000004A')
assert.equal(rows[0].protocolId, 'p1')
assert.equal(rows[0].projectName, '测试项目')
assert.equal(rows[0].teamName, '陈星项目组111')
assert.equal(rows[1].projectName, '测试项目')
assert.equal(rows[1].teamName, '陈星项目组111')
})
it('copies protocol project attribution onto each flat row for table props', () => {
const rows = flattenProtocolAuditRows([
{
id: 'p-attr',
projectName: '归属项目',
teamName: '归属项目组',
sampleItems: [{ sampleCode: 'ZKZX34000199', sampleTypeName: '围产期造血干细胞' }]
}
])
assert.equal(rows.length, 1)
assert.equal(rows[0].projectName, '归属项目')
assert.equal(rows[0].teamName, '归属项目组')
assert.equal(rows[0].sampleCode, 'ZKZX34000199')
})
it('zero samples → one placeholder row, not co-stored', () => {
@@ -89,7 +110,18 @@ describe('flattenProtocolAuditRows', () => {
infectiousDiseaseReportUrls: '',
attachmentUrls: 'https://x/b.pdf',
remark: '备注一',
sampleItems: [{ sampleTypeName: 'NK', sampleCode: 'D2', labStorageCode: 'A1' }]
sampleItems: [
{
sampleTypeName: 'NK',
sampleCode: 'D2',
labStorageCode: 'A1',
freight: {
id: 'fr-1',
transportModeLabel: '专线车',
freightAmountLabel: '100'
}
}
]
}
])
assert.equal(rows[0].monthlyVerifyNo, '202508001')
@@ -101,6 +133,9 @@ describe('flattenProtocolAuditRows', () => {
assert.equal(rows[0].infectiousDiseaseReportUrls, '')
assert.equal(rows[0].attachmentUrls, 'https://x/b.pdf')
assert.equal(rows[0].remark, '备注一')
assert.equal(rows[0].freightId, 'fr-1')
assert.equal(rows[0].freightTransportLabel, '专线车')
assert.equal(rows[0].freightAmountLabel, '100')
})
})
@@ -20,6 +20,9 @@ export type ProtocolAuditFlatRow = {
sampleCount: number
sampleIndex: number
isCoStored: boolean
/** 协议级项目归属,供扁平行表格 prop 绑定 */
projectName: string
teamName: string
collectDate: string
sampleCollectTime: string
motherName: string
@@ -34,6 +37,10 @@ export type ProtocolAuditFlatRow = {
labStorageCode: string
transportType: string
transportLabel: string
/** 样本运费批次;多样本共用时 id 相同 */
freightId: string
freightTransportLabel: string
freightAmountLabel: string
paymentSummary: QcProtocolPaymentSummary | null
monthlyVerifyNo: string
donationStatus: string
@@ -87,7 +94,13 @@ export function flattenProtocolAuditRows(
slots.forEach((item, sampleIndex) => {
const rec = item || {}
const transport = asRecord(rec.transport)
const freight = asRecord(rec.freight)
const transportType = str(transport?.transportType)
const freightAmountLabel = str(freight?.freightAmountLabel)
const freightAmount =
freight?.freightAmount == null || freight?.freightAmount === ''
? ''
: String(freight.freightAmount)
out.push({
rowKey: `${protocolId}|${str(rec.sampleCode) || sampleIndex}`,
protocolId,
@@ -95,6 +108,8 @@ export function flattenProtocolAuditRows(
sampleCount,
sampleIndex,
isCoStored,
projectName: str(protocol.projectName),
teamName: str(protocol.teamName),
collectDate: extractCollectDate(str(protocol.sampleCollectTime)),
sampleCollectTime: str(protocol.sampleCollectTime),
motherName: str(protocol.motherName),
@@ -108,6 +123,9 @@ export function flattenProtocolAuditRows(
labStorageCode: str(rec.labStorageCode),
transportType,
transportLabel: '',
freightId: str(freight?.id),
freightTransportLabel: str(freight?.transportModeLabel) || str(freight?.transportMode),
freightAmountLabel: freightAmountLabel || freightAmount,
paymentSummary:
protocol.paymentSummary && typeof protocol.paymentSummary === 'object'
? (protocol.paymentSummary as QcProtocolPaymentSummary)
@@ -0,0 +1,63 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
getConsentProtocolAttachment,
getSourceProtocolAttachment,
getSourceProtocolAttachmentGroups
} from './protocol-source-attachment'
describe('protocol-source-attachment', () => {
it('returns health survey as primary for cell storage', () => {
const meta = getSourceProtocolAttachment({
sourceType: 'cell_storage',
healthSurveyAttachmentFiles: [{ id: '1', fileUrl: '/a.png' }]
})
assert.equal(meta.label, '孕妇健康调查表')
assert.equal(meta.files.length, 1)
})
it('returns infectious disease report as primary for research donation', () => {
const meta = getSourceProtocolAttachment({
sourceType: 'research_donation',
infectiousDiseaseReportAttachmentFiles: [{ id: '2', fileUrl: '/b.png' }]
})
assert.equal(meta.label, '传染病检测报告')
assert.equal(meta.files.length, 1)
})
it('returns consent attachment from shared field', () => {
const meta = getConsentProtocolAttachment({
consentAttachmentFiles: [{ id: '3', fileUrl: '/c.png' }]
})
assert.equal(meta.label, '知情同意书')
assert.equal(meta.files.length, 1)
})
it('groups cell storage attachments for reception dialog', () => {
const groups = getSourceProtocolAttachmentGroups({
sourceType: 'cell_storage',
protocolAttachmentFiles: [{ id: 'p' }],
consentAttachmentFiles: [{ id: 'c' }],
healthSurveyAttachmentFiles: [{ id: 'h' }]
})
assert.deepEqual(
groups.map((g) => g.label),
['知情同意书', '孕妇健康调查表']
)
assert.ok(groups.every((g) => g.files.length === 1))
assert.equal(groups.length, 2)
})
it('groups research donation attachments for reception dialog', () => {
const groups = getSourceProtocolAttachmentGroups({
sourceType: 'research_donation',
consentAttachmentFiles: [{ id: 'c' }],
infectiousDiseaseReportAttachmentFiles: [{ id: 'i' }],
attachmentFiles: [{ id: 'o' }]
})
assert.deepEqual(
groups.map((g) => g.label),
['知情同意书', '传染病检测报告', '其他附件']
)
})
})
@@ -8,7 +8,7 @@ function normalizeAttachmentFiles(raw: unknown): Array<Record<string, unknown>>
return raw.filter((item) => item && typeof item === 'object') as Array<Record<string, unknown>>
}
/** 按来源返回协议级附件标签与文件列表(细胞存储=孕妇健康调查表,科研捐赠=传染病检测报告) */
/** 按来源返回协议级主附件标签与文件列表(细胞存储=孕妇健康调查表,科研捐赠=传染病检测报告) */
export function getSourceProtocolAttachment(
row: Record<string, unknown>
): ProtocolSourceAttachmentMeta {
@@ -24,3 +24,50 @@ export function getSourceProtocolAttachment(
files: normalizeAttachmentFiles(row.healthSurveyAttachmentFiles)
}
}
/** 知情同意书(细胞存储与科研捐赠共用字段) */
export function getConsentProtocolAttachment(
row: Record<string, unknown>
): ProtocolSourceAttachmentMeta {
return {
label: '知情同意书',
files: normalizeAttachmentFiles(row.consentAttachmentFiles)
}
}
/**
* 样本接收弹窗:按来源返回质检需查看的协议附件分组。
* 细胞存储:仅知情同意书 / 孕妇健康调查表(不展示「协议附件」项)
* 科研捐赠:知情同意书 / 传染病检测报告 / 其他附件
*/
export function getSourceProtocolAttachmentGroups(
row: Record<string, unknown>
): ProtocolSourceAttachmentMeta[] {
const source = String(row.sourceType || 'cell_storage').trim()
if (source === 'research_donation') {
return [
{
label: '知情同意书',
files: normalizeAttachmentFiles(row.consentAttachmentFiles)
},
{
label: '传染病检测报告',
files: normalizeAttachmentFiles(row.infectiousDiseaseReportAttachmentFiles)
},
{
label: '其他附件',
files: normalizeAttachmentFiles(row.attachmentFiles ?? row.otherAttachmentFiles)
}
]
}
return [
{
label: '知情同意书',
files: normalizeAttachmentFiles(row.consentAttachmentFiles)
},
{
label: '孕妇健康调查表',
files: normalizeAttachmentFiles(row.healthSurveyAttachmentFiles)
}
]
}
@@ -56,6 +56,10 @@
<el-icon><Plus /></el-icon>
新增预签约
</el-button>
<el-button class="btn-ghost" :loading="exporting" @click="handleExport">
<el-icon><Download /></el-icon>
导出 Excel
</el-button>
</div>
</div>
@@ -196,7 +200,15 @@
const projectFilter = ref('')
const teamFilter = ref('')
import { onMounted, reactive, ref } from 'vue'
import { Plus, Search, RefreshLeft, Edit, Delete, Check } from '@element-plus/icons-vue'
import { Plus, Search, RefreshLeft, Edit, Delete, Check, Download } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import {
buildPresignLedgerRows,
fetchAllPages,
PRESIGN_HEADERS,
saveCollectionLedgerWorkbook,
prepareCollectionLedgerExport
} from '@/utils/collection-ledger-export'
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { formatMoneyDisplay } from '@/utils/money-format'
import { presignOrderStatusLabel, presignOrderStatusTagType } from '@/utils/status-tag-registry'
@@ -213,6 +225,7 @@
const tableData = ref<Record<string, any>[]>([])
const selectedRows = ref<Record<string, any>[]>([])
const loading = ref(false)
const exporting = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const editingId = ref<string | undefined>(undefined)
@@ -246,6 +259,41 @@
}
}
async function handleExport() {
if (exporting.value) return
exporting.value = true
try {
await prepareCollectionLedgerExport()
const rows = selectedRows.value.length
? selectedRows.value
: await fetchAllPages((page, pageSize) =>
fetchPresignOrderList({
page,
pageSize,
projectId: projectFilter.value || undefined,
teamId: teamFilter.value || undefined,
keyword: searchForm.keyword || undefined,
orderStatus: searchForm.orderStatus || undefined
})
)
if (!rows.length) {
ElMessage.warning('没有可导出的数据')
return
}
await saveCollectionLedgerWorkbook(
'预签约',
PRESIGN_HEADERS,
buildPresignLedgerRows(rows),
'预签约'
)
ElMessage.success('导出成功')
} catch {
ElMessage.error('导出失败,请重试')
} finally {
exporting.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
@@ -75,6 +75,10 @@
<el-icon><Plus /></el-icon>
新增
</el-button>
<el-button class="btn-ghost" :loading="exporting" @click="handleExport">
<el-icon><Download /></el-icon>
导出 Excel
</el-button>
<el-button class="btn-ghost" :disabled="!selectedProtocolCount" @click="openAuditDialog()">
<el-icon><CircleCheck /></el-icon>
审核
@@ -422,6 +426,7 @@
Delete,
Search,
RefreshLeft,
Download,
CircleCheck,
CircleClose,
Select,
@@ -456,6 +461,13 @@
formatProtocolDocBatchDeleteConfirm
} from '@/utils/protocol-doc-label'
import { loadVoidTypeDict } from '@/utils/void-type-dict'
import {
RESEARCH_HEADERS,
buildResearchLedgerRows,
fetchAllPages,
saveCollectionLedgerWorkbook,
prepareCollectionLedgerExport
} from '@/utils/collection-ledger-export'
import {
flattenProtocolAuditRows,
uniqueProtocolsFromFlatRows,
@@ -476,6 +488,7 @@
const sampleCollectTimeRange = ref<[string, string] | null>(null)
const tableData = ref<ResearchDonationRow[]>([])
const selectedFlatRows = ref<ProtocolAuditFlatRow[]>([])
const exporting = ref(false)
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
@@ -529,6 +542,49 @@
}
}
async function handleExport() {
if (exporting.value) return
exporting.value = true
try {
await prepareCollectionLedgerExport()
const rows = selectedFlatRows.value.length
? selectedFlatRows.value
: flattenProtocolAuditRows(
await fetchAllPages((page, pageSize) =>
fetchResearchDonationList({
page,
pageSize,
projectId: projectFilter.value || undefined,
teamId: teamFilter.value || undefined,
keyword: searchForm.keyword || undefined,
donationStatus: searchForm.donationStatus || undefined,
createTimeStart: createTimeRange.value?.[0] || undefined,
createTimeEnd: createTimeRange.value?.[1] || undefined,
sampleCollectTimeStart: sampleCollectTimeRange.value?.[0] || undefined,
sampleCollectTimeEnd: sampleCollectTimeRange.value?.[1] || undefined
})
)
)
if (!rows.length) {
ElMessage.warning('没有可导出的数据')
return
}
const ledger = buildResearchLedgerRows(rows)
await saveCollectionLedgerWorkbook(
'科研表',
RESEARCH_HEADERS,
ledger.rows,
'科研表',
ledger.merges
)
ElMessage.success('导出成功')
} catch {
ElMessage.error('导出失败,请重试')
} finally {
exporting.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
@@ -79,6 +79,10 @@
<el-icon><Plus /></el-icon>
新增
</el-button>
<el-button class="btn-ghost" :loading="exporting" @click="handleExport">
<el-icon><Download /></el-icon>
导出 Excel
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedProtocolCount !== 1"
@@ -396,7 +400,8 @@
Switch,
Timer,
Coin,
More
More,
Download
} from '@element-plus/icons-vue'
import ProtocolFormDialog from '../protocol/ProtocolFormDialog.vue'
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
@@ -424,6 +429,13 @@
transportTypeLabel
} from '@/utils/status-tag-registry'
import { loadVoidTypeDict } from '@/utils/void-type-dict'
import {
CELL_STORAGE_HEADERS,
buildCellStorageLedgerRows,
fetchAllPages,
saveCollectionLedgerWorkbook,
prepareCollectionLedgerExport
} from '@/utils/collection-ledger-export'
import {
flattenProtocolAuditRows,
uniqueProtocolsFromFlatRows,
@@ -442,6 +454,7 @@
const tableData = ref<CollectionProtocolRow[]>([])
const selectedFlatRows = ref<ProtocolAuditFlatRow[]>([])
const loading = ref(false)
const exporting = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const flatRows = computed(() => flattenProtocolAuditRows(tableData.value))
@@ -494,6 +507,49 @@
}
}
async function handleExport() {
if (exporting.value) return
exporting.value = true
try {
await prepareCollectionLedgerExport()
const rows = selectedFlatRows.value.length
? selectedFlatRows.value
: flattenProtocolAuditRows(
await fetchAllPages((page, pageSize) =>
fetchCollectionProtocolList({
page,
pageSize,
projectId: projectFilter.value || undefined,
teamId: teamFilter.value || undefined,
keyword: searchForm.keyword || undefined,
protocolStatus: searchForm.protocolStatus || undefined,
createTimeStart: createTimeRange.value?.[0] || undefined,
createTimeEnd: createTimeRange.value?.[1] || undefined,
sampleCollectTimeStart: sampleCollectTimeRange.value?.[0] || undefined,
sampleCollectTimeEnd: sampleCollectTimeRange.value?.[1] || undefined
})
)
)
if (!rows.length) {
ElMessage.warning('没有可导出的数据')
return
}
const ledger = buildCellStorageLedgerRows(rows)
await saveCollectionLedgerWorkbook(
'自存表',
CELL_STORAGE_HEADERS,
ledger.rows,
'自存表',
ledger.merges
)
ElMessage.success('导出成功')
} catch {
ElMessage.error('导出失败,请重试')
} finally {
exporting.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
@@ -44,6 +44,7 @@
<script setup lang="ts">
import { Document, Download, VideoPlay } from '@element-plus/icons-vue'
import { attachmentFullUrl } from '@/composables/useAttachmentActions'
withDefaults(
defineProps<{
@@ -58,14 +59,8 @@
(e: 'download', file: Record<string, any>): void
}>()
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function resolveFileUrl(suffix: string) {
if (!suffix) return ''
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/')) {
return suffix
}
return `${minioBaseUrl}/${suffix}`
return attachmentFullUrl(suffix)
}
function isImageFile(file: { fileType?: string; fileName?: string; fileUrl?: string }) {
@@ -92,6 +92,15 @@
<el-icon><Box /></el-icon>
登记入库
</el-button>
<el-button
v-if="isCellStorage"
class="btn-ghost"
:loading="exporting"
@click="handleExport"
>
<el-icon><Download /></el-icon>
导出 Excel
</el-button>
<el-button
v-if="isCellStorage"
class="btn-ghost"
@@ -473,6 +482,7 @@
ChatDotRound,
Collection,
Document,
Download,
EditPen,
Files,
Location,
@@ -511,6 +521,13 @@
sampleStorageFlagLabel,
sampleStorageFlagTagType
} from '@/utils/status-tag-registry'
import {
CUSTOMER_SERVICE_HEADERS,
buildCustomerServiceLedgerRows,
fetchAllPages,
saveCollectionLedgerWorkbook,
prepareCollectionLedgerExport
} from '@/utils/collection-ledger-export'
defineOptions({ name: 'UnifiedSampleInfoPage' })
@@ -533,6 +550,7 @@
})
const tableData = ref<Record<string, unknown>[]>([])
const loading = ref(false)
const exporting = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const selectedRows = ref<Record<string, unknown>[]>([])
const tableRef = ref<{ clearSelection?: () => void } | null>(null)
@@ -632,6 +650,44 @@
}
}
async function handleExport() {
if (exporting.value || !isCellStorage.value) return
exporting.value = true
try {
await prepareCollectionLedgerExport()
const rows = selectedRows.value.length
? selectedRows.value
: await fetchAllPages((page, pageSize) =>
fetchProtocolSampleCollectionList({
page,
pageSize,
keyword: searchForm.keyword || undefined,
collectionFlag: searchForm.collectionFlag || undefined,
receptionFlag: searchForm.receptionFlag || undefined,
qcFlag: searchForm.qcFlag || undefined,
storageFlag: searchForm.storageFlag || undefined,
storageCertHandled: searchForm.storageCertHandled || undefined,
insuranceHandled: searchForm.insuranceHandled || undefined
})
)
if (!rows.length) {
ElMessage.warning('没有可导出的数据')
return
}
await saveCollectionLedgerWorkbook(
'客服表',
CUSTOMER_SERVICE_HEADERS,
buildCustomerServiceLedgerRows(rows),
'客服表'
)
ElMessage.success('导出成功')
} catch {
ElMessage.error('导出失败,请重试')
} finally {
exporting.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
@@ -74,6 +74,16 @@
<el-table-column v-if="!isTeam" prop="projectNo" label="项目编号" min-width="160" />
<el-table-column v-if="!isTeam" prop="teamName" label="所属项目组" min-width="160" />
<el-table-column prop="leaderName" label="负责人" min-width="120" />
<el-table-column v-if="isTeam" label="成员" min-width="180" show-overflow-tooltip
><template #default="{ row }">{{
joinChildNames(row.members)
}}</template></el-table-column
>
<el-table-column v-if="isTeam" label="负责医院" min-width="200" show-overflow-tooltip
><template #default="{ row }">{{
joinChildNames(row.hospitals)
}}</template></el-table-column
>
<el-table-column label="状态" min-width="110"
><template #default="{ row }"
><el-tag
@@ -139,12 +149,19 @@
v-else
v-model="form.projectName"
/></el-form-item>
<el-form-item
v-if="!isTeam"
label="项目编号"
prop="projectNo"
:rules="[{ required: true, message: '请输入项目编号' }]"
><el-input v-model="form.projectNo" maxlength="64" show-word-limit
/></el-form-item>
<el-form-item
v-if="!isTeam"
label="所属项目组"
prop="teamId"
:rules="[{ required: true, message: '请选择项目组' }]"
><el-select v-model="form.teamId" filterable
><el-select v-model="form.teamId" filterable @change="onTeamChange"
><el-option
v-for="team in teams"
:key="team.id"
@@ -243,7 +260,7 @@
>
</template>
<script setup lang="ts">
import { managementPayload } from '@/utils/project-management'
import { joinChildNames, managementPayload } from '@/utils/project-management'
import { useMenuPerm } from '@/hooks/core/useMenuPerm'
import {
Plus,
@@ -333,6 +350,11 @@
})
search()
}
function onTeamChange(teamId: string) {
if (readonly.value || isTeam.value) return
const team = teams.value.find((item) => item.id === teamId)
if (team?.leaderUserId) form.value.leaderUserId = team.leaderUserId
}
async function edit(row?: ProjectRecord, view = false) {
readonly.value = view
editingId.value = row?.id || ''
@@ -341,6 +363,7 @@
: {
teamName: '',
projectName: '',
projectNo: '',
leaderUserId: '',
enabled: true,
status: 'pending',
@@ -143,57 +143,58 @@
<el-dialog
v-model="visible"
title="项目归属维护"
width="720px"
width="560px"
class="dialog-form-layout"
append-to-body
modal-class="protocol-dialog-mask"
:close-on-click-modal="false"
>
<el-form label-position="top"
><el-form-item label="目标项目" required
><ProjectSelect
v-model="target"
:key="selected.id"
<el-form class="form-grid-2" label-position="top">
<el-form-item label="原所属项目组">
<el-input :model-value="selected.teamName || '未归属'" disabled />
</el-form-item>
<el-form-item label="原所属项目">
<el-input :model-value="selected.projectName || '未归属'" disabled />
</el-form-item>
<el-form-item label="新项目组" required>
<ProjectSelect
:key="`team-${selected.id}`"
team
v-model="targetTeamId"
:biz-type="selected.bizType"
@update:model-value="previewRows = []" /></el-form-item
><el-form-item label="调整原因" required
><el-input v-model="reason" type="textarea" :maxlength="500" /></el-form-item
></el-form>
<el-alert
title="请先预览全部关联单据,再确认调整。关联集合或归属发生变化时需要重新预览。"
type="info"
:closable="false"
/>
<el-table :data="previewRows" class="list-table" stripe
><el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center" />
<el-table-column prop="bizNo" label="关联单据" min-width="200" /><el-table-column
prop="projectName"
label="原项目"
min-width="160"
/></el-table>
<template #footer
><el-button @click="visible = false"
@update:model-value="onTargetTeamChange"
/>
</el-form-item>
<el-form-item label="新项目" required>
<ProjectSelect
:key="`project-${selected.id}-${targetTeamId}`"
v-model="target"
:team-id="targetTeamId"
:biz-type="selected.bizType"
:disabled="!targetTeamId"
/>
</el-form-item>
<el-form-item label="调整原因" required class="form-col-span-2">
<el-input v-model="reason" type="textarea" :maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false"
><el-icon><Close /></el-icon>取消</el-button
><el-button :loading="busy" @click="preview"
><el-icon><View /></el-icon>预览关联单据</el-button
><el-button
>
<el-button
type="primary"
:disabled="!previewRows.length || !reason.trim()"
:disabled="!target || !reason.trim()"
:loading="busy"
@click="commit"
><el-icon><Check /></el-icon>确认调整</el-button
></template
>
>
</template>
</el-dialog>
<el-dialog
v-model="historyVisible"
title="归属审计记录"
width="800px"
width="1100px"
append-to-body
class="dialog-form-layout"
><el-table :data="historyRows" class="list-table" stripe
@@ -233,7 +234,7 @@
presignOrderStatusTagType,
applicationStatusTagType
} from '@/utils/status-tag-registry'
import { Search, Edit, Clock, Close, View, Check, RefreshRight } from '@element-plus/icons-vue'
import { Search, Edit, Clock, Close, Check, RefreshRight } from '@element-plus/icons-vue'
import { assignmentTabs, assignmentColumns, assignmentKeywordHints } from './assignment-columns'
import ProjectSelect from '@/components/business/ProjectSelect.vue'
import { assignmentSnapshot } from '@/utils/project-management'
@@ -268,10 +269,10 @@
loadError = ref(false),
visible = ref(false),
busy = ref(false),
targetTeamId = ref(''),
target = ref(''),
reason = ref(''),
selected = ref<ProjectRecord>({}),
previewRows = ref<ProjectRecord[]>([]),
historyVisible = ref(false),
historyRows = ref<ProjectRecord[]>([])
const visibleColumns = computed(() =>
@@ -361,47 +362,54 @@
}
function open(row: ProjectRecord) {
selected.value = row
targetTeamId.value = ''
target.value = ''
reason.value = ''
previewRows.value = []
visible.value = true
}
async function preview() {
function onTargetTeamChange() {
target.value = ''
}
async function commit() {
if (!targetTeamId.value) {
ElMessage.warning('请选择新项目组')
return
}
if (!target.value) {
ElMessage.warning('请选择目标项目')
ElMessage.warning('请选择新项目')
return
}
if (!reason.value.trim()) {
ElMessage.warning('请填写调整原因')
return
}
busy.value = true
const previewTarget = target.value
const previewRecord = selected.value
const commitTarget = target.value
const commitRecord = selected.value
try {
const result = await previewAssignment({
const preview = await previewAssignment({
bizType: selected.value.bizType,
id: selected.value.id,
projectId: target.value
})
if (selected.value === previewRecord && target.value === previewTarget && visible.value) {
previewRows.value = result.records
if (selected.value !== commitRecord || target.value !== commitTarget || !visible.value) {
return
}
} finally {
busy.value = false
}
}
async function commit() {
busy.value = true
try {
await saveAssignment({
bizType: selected.value.bizType,
id: selected.value.id,
projectId: target.value,
reason: reason.value.trim(),
expectedAssignments: assignmentSnapshot(previewRows.value)
expectedAssignments: assignmentSnapshot(preview.records)
})
if (selected.value !== commitRecord || target.value !== commitTarget || !visible.value) {
return
}
visible.value = false
ElMessage.success('归属已调整')
await load()
} catch {
previewRows.value = []
// API layer already surfaces the conflict / validation message.
} finally {
busy.value = false
}
@@ -80,6 +80,16 @@
/>
</dd>
</div>
<div class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment">
<dt>{{ consentAttachmentMeta(row).label }}</dt>
<dd>
<ProtocolAttachmentMiniReadonly
:files="consentAttachmentMeta(row).files"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</dd>
</div>
<div v-if="activeTab === 'received'" class="sample-reception-card__meta-item">
<dt>实验室入库编码</dt>
<dd>
@@ -242,7 +252,10 @@
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue'
import ProtocolAttachmentMiniReadonly from '@/components/business/protocol-attachment-mini-readonly/index.vue'
import { getSourceProtocolAttachment } from '@/utils/protocol-source-attachment'
import {
getConsentProtocolAttachment,
getSourceProtocolAttachment
} from '@/utils/protocol-source-attachment'
import { useAttachmentActions } from '@/composables/useAttachmentActions'
defineOptions({ name: 'SampleReceptionCardList' })
@@ -284,6 +297,10 @@
return getSourceProtocolAttachment(row)
}
function consentAttachmentMeta(row: Record<string, unknown>) {
return getConsentProtocolAttachment(row)
}
function protocolStatusLabelBySource(status: string, source: string, voidType?: string): string {
return source === 'research_donation'
? donationStatusDisplayLabel(status, voidType)
@@ -204,41 +204,63 @@
</div>
</div>
</div>
<div id="form-section-transport" class="sample-transport-col">
<div class="section-title section-title--compact">
<div class="section-title-left"><h3>运输信息</h3></div>
<div id="form-section-transport" class="sample-transport-col sample-transport-col--stack">
<div class="sample-transport-stack-block">
<div class="section-title section-title--compact">
<div class="section-title-left"><h3>运输信息</h3></div>
</div>
<div class="data-grid data-grid--2">
<div class="data-item">
<div class="data-label">运输方式</div>
<div class="data-value">
{{ transportTypeLabel(String(currentRow.transportType || '')) }}
</div>
</div>
<div class="data-item">
<div class="data-label">预计到达</div>
<div class="data-value">{{ formatDisplayOrDash(currentRow.etaTime) }}</div>
</div>
<template v-if="currentRow.transportType === 'kdys'">
<div class="data-item">
<div class="data-label">快递公司</div>
<div class="data-value">{{ currentRow.expressCompanyName || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">快递单号</div>
<div class="data-value">{{ currentRow.waybillNo || '-' }}</div>
</div>
</template>
<template v-else-if="currentRow.transportType === 'ryxd'">
<div class="data-item">
<div class="data-label">携带人</div>
<div class="data-value">{{ currentRow.carrierName || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">携带人电话</div>
<div class="data-value">{{ currentRow.carrierPhone || '-' }}</div>
</div>
</template>
</div>
</div>
<div class="data-grid data-grid--2">
<div class="data-item">
<div class="data-label">运输方式</div>
<div class="data-value">
{{ transportTypeLabel(String(currentRow.transportType || '')) }}
<div class="sample-transport-stack-block sample-protocol-attachments">
<div class="section-title section-title--compact">
<div class="section-title-left"><h3>协议附件</h3></div>
</div>
<div class="sample-protocol-attachments__list">
<div
v-for="group in receiveProtocolAttachmentGroups"
:key="group.label"
class="sample-protocol-attachments__item"
>
<div class="data-label">{{ group.label }}</div>
<ProtocolAttachmentMiniReadonly
:files="group.files"
empty-text="暂无附件"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</div>
</div>
<div class="data-item">
<div class="data-label">预计到达</div>
<div class="data-value">{{ formatDisplayOrDash(currentRow.etaTime) }}</div>
</div>
<template v-if="currentRow.transportType === 'kdys'">
<div class="data-item">
<div class="data-label">快递公司</div>
<div class="data-value">{{ currentRow.expressCompanyName || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">快递单号</div>
<div class="data-value">{{ currentRow.waybillNo || '-' }}</div>
</div>
</template>
<template v-else-if="currentRow.transportType === 'ryxd'">
<div class="data-item">
<div class="data-label">携带人</div>
<div class="data-value">{{ currentRow.carrierName || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">携带人电话</div>
<div class="data-value">{{ currentRow.carrierPhone || '-' }}</div>
</div>
</template>
</div>
</div>
</div>
@@ -722,6 +744,7 @@
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import ProtocolAttachmentMiniReadonly from '@/components/business/protocol-attachment-mini-readonly/index.vue'
import SampleReceptionCardList from './SampleReceptionCardList.vue'
import SampleReceptionDualReadonlyPanel from '@/components/business/sample-reception-dual-readonly-panel/index.vue'
import {
@@ -745,6 +768,7 @@
buildPrefilledLabStorageCode,
parseLabStorageReferenceDate
} from '@/utils/sample-reception-lab-storage-code'
import { getSourceProtocolAttachmentGroups } from '@/utils/protocol-source-attachment'
import type { ProductionReceptionRecord } from '@/utils/sample-production-reception-fields'
import {
formatProductionFieldValue,
@@ -955,6 +979,12 @@
.filter(Boolean) as Array<Record<string, unknown>>
})
const receiveProtocolAttachmentGroups = computed(() => {
const row = currentRow.value
if (!row) return []
return getSourceProtocolAttachmentGroups(row as Record<string, unknown>)
})
function formatMetric(v: unknown) {
if (v === null || v === undefined || v === '') return '-'
const n = Number(v)
@@ -1485,6 +1515,8 @@
let detail: Record<string, unknown> = row
try {
detail = (await fetchSampleReceptionDetail(receptionId)) as Record<string, unknown>
// 详情可能带更完整的协议附件字段,合并后供弹窗右侧展示
currentRow.value = { ...row, ...detail }
} catch {
/* 列表行已含主要字段,详情失败时回退 */
}
@@ -1653,6 +1685,44 @@
padding-left: 20px;
border-left: 1px solid #e2e8f0;
}
&--stack {
display: flex;
flex-direction: column;
gap: 14px;
}
}
.sample-transport-stack-block {
min-width: 0;
}
.sample-protocol-attachments {
background: #ecfdf5;
border-radius: 8px;
padding: 12px 14px;
border: 1px solid #a7f3d0;
min-height: 72px;
}
.sample-protocol-attachments__list {
display: flex;
flex-direction: column;
gap: 12px;
}
.sample-protocol-attachments__item {
min-width: 0;
.data-label {
margin-bottom: 6px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
:deep(.protocol-attachment-mini) {
width: 100%;
}
}
.section-title {
+11 -1
View File
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildDrillFilters, assignmentSnapshot } from '../src/utils/project-management'
import {
buildDrillFilters,
assignmentSnapshot,
joinChildNames
} from '../src/utils/project-management'
test('drill preserves applied dates and selected project when a team is opened', () => {
assert.deepEqual(
@@ -65,6 +69,12 @@ test('management payload excludes readonly response fields and preserves child I
)
})
test('joinChildNames joins display names and skips blanks', () => {
assert.equal(joinChildNames([{ name: '甲' }, { name: '' }, { name: '乙' }, {}]), '甲、乙')
assert.equal(joinChildNames([]), '')
assert.equal(joinChildNames(undefined), '')
})
test('filter options use visible historical scope; create and reassignment remain strict', async () => {
const { projectOptionsParams } = await import('../src/utils/project-management')
assert.deepEqual(projectOptionsParams('cell_storage', true), {