Compare commits

...
Author SHA1 Message Date
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
16 changed files with 1493 additions and 46 deletions

No files matched your search

+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.crud.collection_protocol import urls_to_attachment_list
from app.models.models import ( from app.models.models import (
BizCellSampleType, BizCellSampleType,
BizCollectionHospital,
BizCollectionProtocol, BizCollectionProtocol,
BizCollectionRegion,
BizPresignOrder, BizPresignOrder,
BizPresignOrderSampleType, BizPresignOrderSampleType,
BizPresignTransfer, BizPresignTransfer,
@@ -503,9 +505,107 @@ def list_presign_orders(
.limit(page_size) .limit(page_size)
.all() .all()
) )
return [ items = [order_to_dict(db, o, tenant_no, with_children=False) for o in rows]
order_to_dict(db, o, tenant_no, with_children=False) for o in rows attach_presign_ledger_fields(db, items, tenant_no)
], int(total) 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( def get_presign_order(
@@ -409,6 +409,8 @@ def list_protocol_sample_collections(
item["newbornSex"] = (party.newborn_sex or "") if party else "" item["newbornSex"] = (party.newborn_sex or "") if party else ""
item["newbornDeliveryTime"] = _fmt_dt(party.newborn_delivery_time) if party else "" item["newbornDeliveryTime"] = _fmt_dt(party.newborn_delivery_time) if party else ""
item["protocolRemark"] = (proto.remark or "") if proto 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["protocolAttachments"] = (proto.protocol_attachments or "") if proto else ""
item["consentFormUrls"] = (proto.consent_form_urls 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 "" item["healthSurveyFormUrls"] = (proto.health_survey_form_urls or "") if proto else ""
+12
View File
@@ -287,9 +287,15 @@ def _donation_rows_to_sample_list_items(
), ),
"sourceType": "research_donation", "sourceType": "research_donation",
"sourceTypeLabel": "科研捐赠", "sourceTypeLabel": "科研捐赠",
"consentAttachmentFiles": urls_to_attachment_list(
donation.consent_form_urls if donation else None, db, tenant_no
),
"infectiousDiseaseReportAttachmentFiles": urls_to_attachment_list( "infectiousDiseaseReportAttachmentFiles": urls_to_attachment_list(
donation.infectious_disease_report_urls if donation else None, db, tenant_no 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 "", "etaTime": _fmt_dt(transport.eta_time) if transport and transport.eta_time else "",
"transportType": (transport.transport_type if transport else "") or "", "transportType": (transport.transport_type if transport else "") or "",
"waybillNo": (transport.waybill_no 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), **sample_special_to_dict(sample),
"sourceType": "cell_storage", "sourceType": "cell_storage",
"sourceTypeLabel": "细胞存储", "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( "healthSurveyAttachmentFiles": urls_to_attachment_list(
protocol.health_survey_form_urls if protocol else None, db, tenant_no protocol.health_survey_form_urls if protocol else None, db, tenant_no
), ),
+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',
'¥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']
@@ -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,187 @@
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,
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], '')
})
})
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: 'ZKZX34000849',
sampleTypeName: '围产期造血干细胞',
regionName: '亳州区域',
hospitalName: '涡阳县人民医院',
labStorageCode: 'B0000004',
salesUserName: '马丽',
regionManagerUserName: '张颖',
transportType: 'kdys',
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 [row] = buildCellStorageLedgerRows([flat])
assert.equal(row.length, CELL_STORAGE_HEADERS.length)
assert.equal(CELL_STORAGE_HEADERS[13], '涡阳县人民医院')
assert.equal(row[5], 'B0000004')
assert.equal(row[6], '生效中')
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[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], '快递运输')
})
})
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: ''
} as unknown as ProtocolAuditFlatRow
const [row] = buildResearchLedgerRows([flat])
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[18], '女')
assert.equal(row[19], '3000g')
assert.equal(row[20], '顺产')
assert.equal(row[23], '1')
})
})
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,583 @@
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: '转细胞存储'
}
const VOID_TYPE: Record<string, string> = {
khhc: '客户毁存',
jcbhg: '检测不合格',
sysfq: '实验室废弃'
}
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: '剖宫产'
}
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) : ''
}
function statusText(status: unknown, voidType: unknown): string {
const code = text(status)
const base = PROTOCOL_STATUS[code] || code
if (code !== 'yzf') return base
const reason = label(VOID_TYPE, voidType)
return reason ? `${base}(${reason})` : base
}
function voidReason(row: Row): string {
return text(row.voidDesc) || label(VOID_TYPE, row.voidType)
}
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)
}
export function buildPresignLedgerRows(rows: Row[]): string[][] {
return rows.map((row, index) => {
const orderDate = row.orderDate
const times = Array.isArray(row.transferTimes) ? row.transferTimes : []
const received = formatLedgerMoney(row.confirmedDepositAmount)
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),
received ? `${received}元` : '',
times.map((item) => formatLedgerDateTime(item)).filter(Boolean).join(','),
formatLedgerDate(row.expectedDeliveryDate),
text(row.salesUserName),
text(row.regionManagerUserName),
text(row.remark),
''
]
})
}
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] = statusText(status, protocol.voidType)
cells[7] = voidReason(protocol)
cells[8] = text(row.motherName)
cells[9] = 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] = transportText(row)
cells[18] = label(SEX, row.newbornSex)
cells[19] = weightText(row.newbornWeightGram)
cells[20] = label(DELIVERY, row.deliveryMode)
cells[21] = text(row.birthOrder)
cells[22] = formatLedgerDateTime(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[5] = text(row.labStorageCode)
cells[6] = statusText(status, protocol.voidType)
cells[7] = voidReason(protocol)
cells[8] = text(row.motherName)
cells[9] = text(row.protocolNo)
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[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] = transportText(row)
return cells
}
export function buildCellStorageLedgerRows(rows: ProtocolAuditFlatRow[]): string[][] {
return rows.map((row, index) => storageLedgerRow(row, index, false))
}
export function buildResearchLedgerRows(rows: ProtocolAuditFlatRow[]): string[][] {
return rows.map((row, index) => storageLedgerRow(row, index, true))
}
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
) {
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 = '@'
})
})
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`
)
}
@@ -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>> return raw.filter((item) => item && typeof item === 'object') as Array<Record<string, unknown>>
} }
/** 按来源返回协议级附件标签与文件列表(细胞存储=孕妇健康调查表,科研捐赠=传染病检测报告) */ /** 按来源返回协议级主附件标签与文件列表(细胞存储=孕妇健康调查表,科研捐赠=传染病检测报告) */
export function getSourceProtocolAttachment( export function getSourceProtocolAttachment(
row: Record<string, unknown> row: Record<string, unknown>
): ProtocolSourceAttachmentMeta { ): ProtocolSourceAttachmentMeta {
@@ -24,3 +24,50 @@ export function getSourceProtocolAttachment(
files: normalizeAttachmentFiles(row.healthSurveyAttachmentFiles) 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-icon><Plus /></el-icon>
新增预签约 新增预签约
</el-button> </el-button>
<el-button class="btn-ghost" :loading="exporting" @click="handleExport">
<el-icon><Download /></el-icon>
导出 Excel
</el-button>
</div> </div>
</div> </div>
@@ -196,7 +200,14 @@
const projectFilter = ref('') const projectFilter = ref('')
const teamFilter = ref('') const teamFilter = ref('')
import { onMounted, reactive, ref } from 'vue' 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
} from '@/utils/collection-ledger-export'
import { formatDisplayOrDash } from '@/utils/datetime-display' import { formatDisplayOrDash } from '@/utils/datetime-display'
import { formatMoneyDisplay } from '@/utils/money-format' import { formatMoneyDisplay } from '@/utils/money-format'
import { presignOrderStatusLabel, presignOrderStatusTagType } from '@/utils/status-tag-registry' import { presignOrderStatusLabel, presignOrderStatusTagType } from '@/utils/status-tag-registry'
@@ -213,6 +224,7 @@
const tableData = ref<Record<string, any>[]>([]) const tableData = ref<Record<string, any>[]>([])
const selectedRows = ref<Record<string, any>[]>([]) const selectedRows = ref<Record<string, any>[]>([])
const loading = ref(false) const loading = ref(false)
const exporting = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 }) const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingId = ref<string | undefined>(undefined) const editingId = ref<string | undefined>(undefined)
@@ -246,6 +258,40 @@
} }
} }
async function handleExport() {
if (exporting.value) return
exporting.value = true
try {
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() { function handleSearch() {
pagination.page = 1 pagination.page = 1
loadData() loadData()
@@ -75,6 +75,10 @@
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
新增 新增
</el-button> </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-button class="btn-ghost" :disabled="!selectedProtocolCount" @click="openAuditDialog()">
<el-icon><CircleCheck /></el-icon> <el-icon><CircleCheck /></el-icon>
审核 审核
@@ -422,6 +426,7 @@
Delete, Delete,
Search, Search,
RefreshLeft, RefreshLeft,
Download,
CircleCheck, CircleCheck,
CircleClose, CircleClose,
Select, Select,
@@ -456,6 +461,12 @@
formatProtocolDocBatchDeleteConfirm formatProtocolDocBatchDeleteConfirm
} from '@/utils/protocol-doc-label' } from '@/utils/protocol-doc-label'
import { loadVoidTypeDict } from '@/utils/void-type-dict' import { loadVoidTypeDict } from '@/utils/void-type-dict'
import {
RESEARCH_HEADERS,
buildResearchLedgerRows,
fetchAllPages,
saveCollectionLedgerWorkbook
} from '@/utils/collection-ledger-export'
import { import {
flattenProtocolAuditRows, flattenProtocolAuditRows,
uniqueProtocolsFromFlatRows, uniqueProtocolsFromFlatRows,
@@ -476,6 +487,7 @@
const sampleCollectTimeRange = ref<[string, string] | null>(null) const sampleCollectTimeRange = ref<[string, string] | null>(null)
const tableData = ref<ResearchDonationRow[]>([]) const tableData = ref<ResearchDonationRow[]>([])
const selectedFlatRows = ref<ProtocolAuditFlatRow[]>([]) const selectedFlatRows = ref<ProtocolAuditFlatRow[]>([])
const exporting = ref(false)
const loading = ref(false) const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 }) const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
@@ -529,6 +541,46 @@
} }
} }
async function handleExport() {
if (exporting.value) return
exporting.value = true
try {
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
}
await saveCollectionLedgerWorkbook(
'科研表',
RESEARCH_HEADERS,
buildResearchLedgerRows(rows),
'科研表'
)
ElMessage.success('导出成功')
} catch {
ElMessage.error('导出失败,请重试')
} finally {
exporting.value = false
}
}
function handleSearch() { function handleSearch() {
pagination.page = 1 pagination.page = 1
loadData() loadData()
@@ -79,6 +79,10 @@
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
新增 新增
</el-button> </el-button>
<el-button class="btn-ghost" :loading="exporting" @click="handleExport">
<el-icon><Download /></el-icon>
导出 Excel
</el-button>
<el-button <el-button
class="btn-ghost" class="btn-ghost"
:disabled="selectedProtocolCount !== 1" :disabled="selectedProtocolCount !== 1"
@@ -396,7 +400,8 @@
Switch, Switch,
Timer, Timer,
Coin, Coin,
More More,
Download
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import ProtocolFormDialog from '../protocol/ProtocolFormDialog.vue' import ProtocolFormDialog from '../protocol/ProtocolFormDialog.vue'
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue' import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
@@ -424,6 +429,12 @@
transportTypeLabel transportTypeLabel
} from '@/utils/status-tag-registry' } from '@/utils/status-tag-registry'
import { loadVoidTypeDict } from '@/utils/void-type-dict' import { loadVoidTypeDict } from '@/utils/void-type-dict'
import {
CELL_STORAGE_HEADERS,
buildCellStorageLedgerRows,
fetchAllPages,
saveCollectionLedgerWorkbook
} from '@/utils/collection-ledger-export'
import { import {
flattenProtocolAuditRows, flattenProtocolAuditRows,
uniqueProtocolsFromFlatRows, uniqueProtocolsFromFlatRows,
@@ -442,6 +453,7 @@
const tableData = ref<CollectionProtocolRow[]>([]) const tableData = ref<CollectionProtocolRow[]>([])
const selectedFlatRows = ref<ProtocolAuditFlatRow[]>([]) const selectedFlatRows = ref<ProtocolAuditFlatRow[]>([])
const loading = ref(false) const loading = ref(false)
const exporting = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 }) const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const flatRows = computed(() => flattenProtocolAuditRows(tableData.value)) const flatRows = computed(() => flattenProtocolAuditRows(tableData.value))
@@ -494,6 +506,46 @@
} }
} }
async function handleExport() {
if (exporting.value) return
exporting.value = true
try {
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
}
await saveCollectionLedgerWorkbook(
'自存表',
CELL_STORAGE_HEADERS,
buildCellStorageLedgerRows(rows),
'自存表'
)
ElMessage.success('导出成功')
} catch {
ElMessage.error('导出失败,请重试')
} finally {
exporting.value = false
}
}
function handleSearch() { function handleSearch() {
pagination.page = 1 pagination.page = 1
loadData() loadData()
@@ -44,6 +44,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Document, Download, VideoPlay } from '@element-plus/icons-vue' import { Document, Download, VideoPlay } from '@element-plus/icons-vue'
import { attachmentFullUrl } from '@/composables/useAttachmentActions'
withDefaults( withDefaults(
defineProps<{ defineProps<{
@@ -58,14 +59,8 @@
(e: 'download', file: Record<string, any>): void (e: 'download', file: Record<string, any>): void
}>() }>()
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function resolveFileUrl(suffix: string) { function resolveFileUrl(suffix: string) {
if (!suffix) return '' return attachmentFullUrl(suffix)
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/')) {
return suffix
}
return `${minioBaseUrl}/${suffix}`
} }
function isImageFile(file: { fileType?: string; fileName?: string; fileUrl?: string }) { function isImageFile(file: { fileType?: string; fileName?: string; fileUrl?: string }) {
@@ -92,6 +92,15 @@
<el-icon><Box /></el-icon> <el-icon><Box /></el-icon>
登记入库 登记入库
</el-button> </el-button>
<el-button
v-if="isCellStorage"
class="btn-ghost"
:loading="exporting"
@click="handleExport"
>
<el-icon><Download /></el-icon>
导出 Excel
</el-button>
<el-button <el-button
v-if="isCellStorage" v-if="isCellStorage"
class="btn-ghost" class="btn-ghost"
@@ -473,6 +482,7 @@
ChatDotRound, ChatDotRound,
Collection, Collection,
Document, Document,
Download,
EditPen, EditPen,
Files, Files,
Location, Location,
@@ -511,6 +521,12 @@
sampleStorageFlagLabel, sampleStorageFlagLabel,
sampleStorageFlagTagType sampleStorageFlagTagType
} from '@/utils/status-tag-registry' } from '@/utils/status-tag-registry'
import {
CUSTOMER_SERVICE_HEADERS,
buildCustomerServiceLedgerRows,
fetchAllPages,
saveCollectionLedgerWorkbook
} from '@/utils/collection-ledger-export'
defineOptions({ name: 'UnifiedSampleInfoPage' }) defineOptions({ name: 'UnifiedSampleInfoPage' })
@@ -533,6 +549,7 @@
}) })
const tableData = ref<Record<string, unknown>[]>([]) const tableData = ref<Record<string, unknown>[]>([])
const loading = ref(false) const loading = ref(false)
const exporting = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 }) const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const selectedRows = ref<Record<string, unknown>[]>([]) const selectedRows = ref<Record<string, unknown>[]>([])
const tableRef = ref<{ clearSelection?: () => void } | null>(null) const tableRef = ref<{ clearSelection?: () => void } | null>(null)
@@ -632,6 +649,43 @@
} }
} }
async function handleExport() {
if (exporting.value || !isCellStorage.value) return
exporting.value = true
try {
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() { function handleSearch() {
pagination.page = 1 pagination.page = 1
loadData() loadData()
@@ -80,6 +80,16 @@
/> />
</dd> </dd>
</div> </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"> <div v-if="activeTab === 'received'" class="sample-reception-card__meta-item">
<dt>实验室入库编码</dt> <dt>实验室入库编码</dt>
<dd> <dd>
@@ -242,7 +252,10 @@
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue' import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue' import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue'
import ProtocolAttachmentMiniReadonly from '@/components/business/protocol-attachment-mini-readonly/index.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' import { useAttachmentActions } from '@/composables/useAttachmentActions'
defineOptions({ name: 'SampleReceptionCardList' }) defineOptions({ name: 'SampleReceptionCardList' })
@@ -284,6 +297,10 @@
return getSourceProtocolAttachment(row) return getSourceProtocolAttachment(row)
} }
function consentAttachmentMeta(row: Record<string, unknown>) {
return getConsentProtocolAttachment(row)
}
function protocolStatusLabelBySource(status: string, source: string, voidType?: string): string { function protocolStatusLabelBySource(status: string, source: string, voidType?: string): string {
return source === 'research_donation' return source === 'research_donation'
? donationStatusDisplayLabel(status, voidType) ? donationStatusDisplayLabel(status, voidType)
@@ -204,41 +204,63 @@
</div> </div>
</div> </div>
</div> </div>
<div id="form-section-transport" class="sample-transport-col"> <div id="form-section-transport" class="sample-transport-col sample-transport-col--stack">
<div class="section-title section-title--compact"> <div class="sample-transport-stack-block">
<div class="section-title-left"><h3>运输信息</h3></div> <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>
<div class="data-grid data-grid--2"> <div class="sample-transport-stack-block sample-protocol-attachments">
<div class="data-item"> <div class="section-title section-title--compact">
<div class="data-label">运输方式</div> <div class="section-title-left"><h3>协议附件</h3></div>
<div class="data-value"> </div>
{{ transportTypeLabel(String(currentRow.transportType || '')) }} <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> </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> </div>
</div> </div>
@@ -722,6 +744,7 @@
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue' import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue' import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.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 SampleReceptionCardList from './SampleReceptionCardList.vue'
import SampleReceptionDualReadonlyPanel from '@/components/business/sample-reception-dual-readonly-panel/index.vue' import SampleReceptionDualReadonlyPanel from '@/components/business/sample-reception-dual-readonly-panel/index.vue'
import { import {
@@ -745,6 +768,7 @@
buildPrefilledLabStorageCode, buildPrefilledLabStorageCode,
parseLabStorageReferenceDate parseLabStorageReferenceDate
} from '@/utils/sample-reception-lab-storage-code' } from '@/utils/sample-reception-lab-storage-code'
import { getSourceProtocolAttachmentGroups } from '@/utils/protocol-source-attachment'
import type { ProductionReceptionRecord } from '@/utils/sample-production-reception-fields' import type { ProductionReceptionRecord } from '@/utils/sample-production-reception-fields'
import { import {
formatProductionFieldValue, formatProductionFieldValue,
@@ -955,6 +979,12 @@
.filter(Boolean) as Array<Record<string, unknown>> .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) { function formatMetric(v: unknown) {
if (v === null || v === undefined || v === '') return '-' if (v === null || v === undefined || v === '') return '-'
const n = Number(v) const n = Number(v)
@@ -1485,6 +1515,8 @@
let detail: Record<string, unknown> = row let detail: Record<string, unknown> = row
try { try {
detail = (await fetchSampleReceptionDetail(receptionId)) as Record<string, unknown> detail = (await fetchSampleReceptionDetail(receptionId)) as Record<string, unknown>
// 详情可能带更完整的协议附件字段,合并后供弹窗右侧展示
currentRow.value = { ...row, ...detail }
} catch { } catch {
/* 列表行已含主要字段,详情失败时回退 */ /* 列表行已含主要字段,详情失败时回退 */
} }
@@ -1653,6 +1685,44 @@
padding-left: 20px; padding-left: 20px;
border-left: 1px solid #e2e8f0; 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 { .section-title {