Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1afbe06021 | ||
|
|
3ad1a08e30 | ||
|
|
a4425addb0 | ||
|
|
471a6823e2 | ||
|
|
d413398b3d | ||
|
|
6be55186b0 | ||
|
|
348c5c1d3c | ||
|
|
411a4cb57c | ||
|
|
6df6306ef0 | ||
|
|
57049f17b0 | ||
|
|
a214a35f4f | ||
|
|
faef6adf94 | ||
|
|
9f37181478 | ||
|
|
00635740f1 | ||
|
|
76316fad7a | ||
|
|
d4617b4988 | ||
|
|
c40feab76a | ||
|
|
9cf1110495 | ||
|
|
fefef41524 | ||
|
|
e97f47b06a | ||
|
|
a4defb2c94 | ||
|
|
8717702b58 | ||
|
|
d30dbad788 | ||
|
|
e92112e9fd |
No files matched your search
@@ -240,6 +240,10 @@ class Tracker:
|
|||||||
if not value:
|
if not value:
|
||||||
return records
|
return records
|
||||||
records.extend(value)
|
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')
|
raise InstallError('issue tracker pagination limit exceeded')
|
||||||
|
|
||||||
def issues(self):
|
def issues(self):
|
||||||
|
|||||||
@@ -1291,10 +1291,23 @@ def list_collection_protocols(
|
|||||||
)
|
)
|
||||||
|
|
||||||
from app.services.qc_work_order_fee_summary import build_protocol_payment_summaries
|
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(
|
payment_summary_map = build_protocol_payment_summaries(
|
||||||
db, protocol_ids, tenant_no, include_sample_payment_modes=True
|
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 = []
|
result = []
|
||||||
@@ -1376,11 +1389,13 @@ def list_collection_protocols(
|
|||||||
}
|
}
|
||||||
sample_items.append(
|
sample_items.append(
|
||||||
{
|
{
|
||||||
|
"id": s.id,
|
||||||
"sampleTypeName": type_name,
|
"sampleTypeName": type_name,
|
||||||
"sampleCode": code,
|
"sampleCode": code,
|
||||||
"sampleStatus": (s.sample_status or "").strip(),
|
"sampleStatus": (s.sample_status or "").strip(),
|
||||||
"labStorageCode": lab_code_map.get(code, "") if code else "",
|
"labStorageCode": lab_code_map.get(code, "") if code else "",
|
||||||
"transport": transport_dict,
|
"transport": transport_dict,
|
||||||
|
"freight": freight_by_sample.get(s.id),
|
||||||
**sample_special_to_dict(s),
|
**sample_special_to_dict(s),
|
||||||
**build_sample_item_reception_qc_fields(
|
**build_sample_item_reception_qc_fields(
|
||||||
reception_summary_map.get(code) if code else None,
|
reception_summary_map.get(code) if code else None,
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -879,6 +879,20 @@ def list_donations(
|
|||||||
if t.express_company_id
|
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 = []
|
result = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -957,11 +971,13 @@ def list_donations(
|
|||||||
}
|
}
|
||||||
sample_items.append(
|
sample_items.append(
|
||||||
{
|
{
|
||||||
|
"id": s.id,
|
||||||
"sampleTypeName": type_name,
|
"sampleTypeName": type_name,
|
||||||
"sampleCode": code,
|
"sampleCode": code,
|
||||||
"sampleStatus": (s.sample_status or "").strip(),
|
"sampleStatus": (s.sample_status or "").strip(),
|
||||||
"labStorageCode": lab_code_map.get(code, "") if code else "",
|
"labStorageCode": lab_code_map.get(code, "") if code else "",
|
||||||
"transport": transport_dict,
|
"transport": transport_dict,
|
||||||
|
"freight": freight_by_sample.get(s.id),
|
||||||
**sample_special_to_dict(s),
|
**sample_special_to_dict(s),
|
||||||
**build_sample_item_reception_qc_fields(
|
**build_sample_item_reception_qc_fields(
|
||||||
reception_summary_map.get(code) if code else None,
|
reception_summary_map.get(code) if code else None,
|
||||||
|
|||||||
@@ -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
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -984,11 +984,14 @@ def list_protocols(
|
|||||||
for row in cell_rows
|
for row in cell_rows
|
||||||
)
|
)
|
||||||
if source in (None, SOURCE_DONATION):
|
if source in (None, SOURCE_DONATION):
|
||||||
if status or collected_from or collected_until:
|
# 混合列表(source=None)下状态/采集时间只覆盖细胞存储,捐赠侧跳过。
|
||||||
donation_enabled = False
|
# 科研捐赠单源列表支持 protocolStatus→donation_status;采集时间筛仍不支持捐赠。
|
||||||
else:
|
skip_donation = False
|
||||||
donation_enabled = True
|
if source is None and (status or collected_from or collected_until):
|
||||||
if not donation_enabled:
|
skip_donation = True
|
||||||
|
elif source == SOURCE_DONATION and (collected_from or collected_until):
|
||||||
|
skip_donation = True
|
||||||
|
if skip_donation:
|
||||||
query = None
|
query = None
|
||||||
else:
|
else:
|
||||||
query = apply_donation_scope(
|
query = apply_donation_scope(
|
||||||
@@ -1001,6 +1004,8 @@ def list_protocols(
|
|||||||
if query is None:
|
if query is None:
|
||||||
donation_rows = []
|
donation_rows = []
|
||||||
else:
|
else:
|
||||||
|
if status and source == SOURCE_DONATION:
|
||||||
|
query = query.filter(BizResearchDonation.donation_status == status)
|
||||||
if created_from:
|
if created_from:
|
||||||
query = query.filter(BizResearchDonation.create_time >= created_from)
|
query = query.filter(BizResearchDonation.create_time >= created_from)
|
||||||
if created_until:
|
if created_until:
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ def _payment_detail_row(d: BizProtocolPaymentDetail, pay_mode: str, idx: int) ->
|
|||||||
"installmentDueDate": _fmt_ymd(d.installment_due_date),
|
"installmentDueDate": _fmt_ymd(d.installment_due_date),
|
||||||
"dueAmount": _dec(d.due_amount),
|
"dueAmount": _dec(d.due_amount),
|
||||||
"transferAmount": _dec(d.transfer_amount),
|
"transferAmount": _dec(d.transfer_amount),
|
||||||
|
"refundAmount": _dec(getattr(d, "refund_amount", None)),
|
||||||
"payStatus": st,
|
"payStatus": st,
|
||||||
"payStatusLabel": _PAY_STATUS_LABELS.get(st, st or "—"),
|
"payStatusLabel": _PAY_STATUS_LABELS.get(st, st or "—"),
|
||||||
"transferTime": _fmt_dt(getattr(d, "transfer_time", None)),
|
"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)]
|
detail_rows = [_payment_detail_row(d, pay_mode, i) for i, d in enumerate(sorted_details)]
|
||||||
received = _received_amount_from_details(sorted_details)
|
received = _received_amount_from_details(sorted_details)
|
||||||
pending = max(contract - received, 0) if contract is not None else None
|
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]
|
statuses = [(d.pay_status or "").strip() for d in sorted_details]
|
||||||
overall = _protocol_overall_status(statuses, contract, pending)
|
overall = _protocol_overall_status(statuses, contract, pending)
|
||||||
hint = _protocol_status_hint(pay_mode, statuses) or overall["overallPayStatusLabel"]
|
hint = _protocol_status_hint(pay_mode, statuses) or overall["overallPayStatusLabel"]
|
||||||
@@ -165,6 +174,7 @@ def build_protocol_payment_summary(
|
|||||||
"contractAmount": contract,
|
"contractAmount": contract,
|
||||||
"receivedAmount": received,
|
"receivedAmount": received,
|
||||||
"pendingAmount": pending,
|
"pendingAmount": pending,
|
||||||
|
"refundAmount": refund_total if has_refund else None,
|
||||||
"statusHint": hint,
|
"statusHint": hint,
|
||||||
"installmentCount": len(detail_rows) if pay_mode in ("fq", "djfq") else None,
|
"installmentCount": len(detail_rows) if pay_mode in ("fq", "djfq") else None,
|
||||||
"paymentDetails": detail_rows,
|
"paymentDetails": detail_rows,
|
||||||
|
|||||||
@@ -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']
|
||||||
@@ -3733,6 +3733,29 @@ def test_protocol_list_filters_status_create_time_and_sample_collect_time():
|
|||||||
assert [item['id'] for item in items] == ['hit']
|
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():
|
def test_cell_create_rolls_back_main_customer_and_children_on_child_failure():
|
||||||
from app.services.market_protocol import create_protocol
|
from app.services.market_protocol import create_protocol
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -28,25 +28,27 @@
|
|||||||
class="market-form-fields"
|
class="market-form-fields"
|
||||||
:class="{ 'market-form-fields--grid-2': section.fieldsLayout === 'grid-2' }"
|
:class="{ 'market-form-fields--grid-2': section.fieldsLayout === 'grid-2' }"
|
||||||
>
|
>
|
||||||
<MarketFormField
|
<template v-for="item in fieldsWithInsert" :key="item.field.key">
|
||||||
v-for="field in visibleFields"
|
<slot v-if="item.insertBefore" name="insert" />
|
||||||
:key="field.key"
|
<MarketFormField
|
||||||
:field="field"
|
:field="item.field"
|
||||||
:model-value="value?.[field.key]"
|
:model-value="value?.[item.field.key]"
|
||||||
:options="fieldOptions(field)"
|
:options="fieldOptions(item.field)"
|
||||||
:uploading="uploading"
|
:uploading="uploading"
|
||||||
:disabled="fieldDisabled(field)"
|
:disabled="fieldDisabled(item.field)"
|
||||||
:search-customers="searchCustomers"
|
:search-customers="searchCustomers"
|
||||||
@update:model-value="setValue(field, $event)"
|
@update:model-value="setValue(item.field, $event)"
|
||||||
@upload="$emit('upload', { section, field, file: $event })"
|
@upload="$emit('upload', { section, field: item.field, file: $event })"
|
||||||
@customer-select="$emit('customer-select', { section, ...$event })"
|
@customer-select="$emit('customer-select', { section, ...$event })"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
<slot v-if="appendInsert" name="insert" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, useSlots } from 'vue'
|
||||||
import MarketFormField from './MarketFormField.vue'
|
import MarketFormField from './MarketFormField.vue'
|
||||||
import { sampleCodeOptionsForRow } from '../reference-options.js'
|
import { sampleCodeOptionsForRow } from '../reference-options.js'
|
||||||
|
|
||||||
@@ -58,9 +60,12 @@ const props = defineProps({
|
|||||||
mode: { type: String, default: 'create' },
|
mode: { type: String, default: 'create' },
|
||||||
uploading: { type: Boolean, default: false },
|
uploading: { type: Boolean, default: false },
|
||||||
disabled: { 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 emit = defineEmits(['update:modelValue', 'upload', 'field-change', 'customer-select'])
|
||||||
|
const slots = useSlots()
|
||||||
const value = computed(() => props.modelValue || {})
|
const value = computed(() => props.modelValue || {})
|
||||||
const rows = computed(() => Array.isArray(props.modelValue) ? props.modelValue : [])
|
const rows = computed(() => Array.isArray(props.modelValue) ? props.modelValue : [])
|
||||||
const visibleFields = computed(() => (props.section.fields || []).filter(
|
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) &&
|
!(typeof field.showWhen === 'function' && field.showWhen(value.value) === false) &&
|
||||||
!(field.createOnly && props.mode !== 'create')
|
!(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) {
|
function fieldOptions(field, rowIndex = -1) {
|
||||||
const base = field.options || props.options[field.optionKey] || (field.optionKey === 'users' ? props.options.staff : []) || []
|
const base = field.options || props.options[field.optionKey] || (field.optionKey === 'users' ? props.options.staff : []) || []
|
||||||
|
|||||||
@@ -1,27 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="market-form-section">
|
<component :is="embedded ? 'div' : 'section'" :class="rootClass">
|
||||||
<h2>{{ filter ? '项目筛选' : '项目归属' }}<span v-if="required && !editing && !filter"> *</span></h2>
|
<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">
|
<template v-if="editing">
|
||||||
<van-cell title="所属项目" :value="projectLabel(record)" />
|
<template v-if="embedded">
|
||||||
<van-cell title="项目组" :value="record.teamName || '—'" />
|
<div class="market-form-field__control market-form-field__control--text" :aria-labelledby="labelId">
|
||||||
<van-cell label="归属调整请使用 Web 项目归属维护" />
|
{{ 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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<PresignSelectField
|
<div :class="embedded ? 'market-form-field__control' : undefined">
|
||||||
:model-value="modelValue || ''"
|
<PresignSelectField
|
||||||
:options="selectOptions"
|
:model-value="modelValue || ''"
|
||||||
:disabled="disabled || loading || Boolean(error)"
|
:options="selectOptions"
|
||||||
:loading="loading"
|
:disabled="disabled || loading || Boolean(error)"
|
||||||
searchable
|
:loading="loading"
|
||||||
title="所属项目"
|
searchable
|
||||||
:placeholder="filter ? '全部项目' : '请选择所属项目'"
|
title="所属项目"
|
||||||
@update:model-value="emit('update:modelValue', $event)"
|
: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" />
|
</div>
|
||||||
<van-cell v-else-if="!options.length" :label="required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目'" />
|
<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>
|
</template>
|
||||||
</section>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -35,6 +57,7 @@ const props = defineProps({
|
|||||||
bizType: { type: String, required: true },
|
bizType: { type: String, required: true },
|
||||||
editing: { type: Boolean, default: false },
|
editing: { type: Boolean, default: false },
|
||||||
filter: { type: Boolean, default: false },
|
filter: { type: Boolean, default: false },
|
||||||
|
embedded: { type: Boolean, default: false },
|
||||||
context: { type: Object, default: null },
|
context: { type: Object, default: null },
|
||||||
disabled: { type: Boolean, default: false },
|
disabled: { type: Boolean, default: false },
|
||||||
record: { type: Object, default: () => ({}) }
|
record: { type: Object, default: () => ({}) }
|
||||||
@@ -44,7 +67,9 @@ const options = ref([])
|
|||||||
const required = ref(false)
|
const required = ref(false)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const labelId = `project-assignment-${Math.random().toString(36).slice(2, 9)}`
|
||||||
let controller
|
let controller
|
||||||
|
const rootClass = computed(() => (props.embedded ? 'market-form-field' : 'market-form-section'))
|
||||||
const selectOptions = computed(() => [
|
const selectOptions = computed(() => [
|
||||||
...(!required.value || props.filter ? [{ text: props.filter ? '全部项目' : '暂不归属', value: '' }] : []),
|
...(!required.value || props.filter ? [{ text: props.filter ? '全部项目' : '暂不归属', value: '' }] : []),
|
||||||
...options.value
|
...options.value
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ export function createListFilters(module, source = '') {
|
|||||||
result.source = SOURCES.has(text(source)) ? text(source) : 'cell_storage'
|
result.source = SOURCES.has(text(source)) ? text(source) : 'cell_storage'
|
||||||
return {
|
return {
|
||||||
source: result.source,
|
source: result.source,
|
||||||
protocolStatus: result.protocolStatus
|
protocolStatus: result.protocolStatus,
|
||||||
|
projectId: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ test('creates isolated defaults for every market module', () => {
|
|||||||
|
|
||||||
assert.deepEqual(first, {
|
assert.deepEqual(first, {
|
||||||
source: 'cell_storage',
|
source: 'cell_storage',
|
||||||
protocolStatus: 'sxz'
|
protocolStatus: 'sxz',
|
||||||
|
projectId: ''
|
||||||
})
|
})
|
||||||
assert.deepEqual(second, {
|
assert.deepEqual(second, {
|
||||||
source: 'research_donation',
|
source: 'research_donation',
|
||||||
protocolStatus: ''
|
protocolStatus: '',
|
||||||
|
projectId: ''
|
||||||
})
|
})
|
||||||
assert.deepEqual(createListFilters('presign'), { orderStatus: '' })
|
assert.deepEqual(createListFilters('presign'), { orderStatus: '' })
|
||||||
assert.deepEqual(createListFilters('payment-chase'), { chaseType: '', payMode: '' })
|
assert.deepEqual(createListFilters('payment-chase'), { chaseType: '', payMode: '' })
|
||||||
@@ -66,8 +68,14 @@ test('drops incomplete ranges and counts only user-selectable filters', () => {
|
|||||||
}), { progressStatus: 'yjq' })
|
}), { progressStatus: 'yjq' })
|
||||||
assert.equal(activeFilterCount('protocol', {
|
assert.equal(activeFilterCount('protocol', {
|
||||||
source: 'cell_storage',
|
source: 'cell_storage',
|
||||||
protocolStatus: 'sxz'
|
protocolStatus: 'sxz',
|
||||||
|
projectId: ''
|
||||||
}), 1)
|
}), 1)
|
||||||
|
assert.equal(activeFilterCount('protocol', {
|
||||||
|
source: 'research_donation',
|
||||||
|
protocolStatus: 'sxz',
|
||||||
|
projectId: 'p1'
|
||||||
|
}), 2)
|
||||||
assert.equal(activeFilterCount('freight', {
|
assert.equal(activeFilterCount('freight', {
|
||||||
transportMode: '', sampleSource: 'research_donation'
|
transportMode: '', sampleSource: 'research_donation'
|
||||||
}), 1)
|
}), 1)
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
|
|||||||
|
|
||||||
assert.match(protocol, /inlineDropdownFilters/)
|
assert.match(protocol, /inlineDropdownFilters/)
|
||||||
assert.match(protocol, /全部协议状态/)
|
assert.match(protocol, /全部协议状态/)
|
||||||
|
assert.match(protocol, /source:\s*'research_donation'/)
|
||||||
assert.match(protocol, /filterFields:\s*\[\]/)
|
assert.match(protocol, /filterFields:\s*\[\]/)
|
||||||
assert.doesNotMatch(protocol, /createTimeRange/)
|
assert.doesNotMatch(protocol, /createTimeRange/)
|
||||||
assert.doesNotMatch(protocol, /sampleCollectTimeRange/)
|
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.doesNotMatch(filters, /createTimeRange/)
|
||||||
assert.match(genericList, /inlineDropdownFilters/)
|
assert.match(genericList, /inlineDropdownFilters/)
|
||||||
assert.match(genericList, /market-list__filter-row/)
|
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', () => {
|
test('protocol list exposes mobile batch selection and sequential authorized deletion', () => {
|
||||||
|
|||||||
@@ -478,6 +478,12 @@ export default {
|
|||||||
allText: '全部协议状态',
|
allText: '全部协议状态',
|
||||||
source: 'cell_storage',
|
source: 'cell_storage',
|
||||||
options: CELL_PROTOCOL_STATUS_OPTIONS.map((item) => ({ text: item.label, value: item.value }))
|
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) {
|
statusLabel(detail) {
|
||||||
|
|||||||
@@ -1247,6 +1247,25 @@ body.van-toast--unclickable .presign-order-page * {
|
|||||||
font-weight: 500;
|
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 > .van-field,
|
||||||
.market-form-field__control {
|
.market-form-field__control {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -12,25 +12,52 @@
|
|||||||
</van-empty>
|
</van-empty>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ProjectAssignmentField v-if="context.module === 'protocol'" ref="projectAssignment"
|
<template v-for="section in leadingSections" :key="section.key">
|
||||||
v-model="formData.projectId" :biz-type="formData.source" :editing="mode === 'edit'"
|
<MarketFormSection
|
||||||
:record="formData" :disabled="fieldsLocked" @update:model-value="dirty = true" />
|
v-if="!(context.module === 'protocol' && section.key === 'protocol')"
|
||||||
<MarketFormSection
|
:section="section"
|
||||||
v-for="section in leadingSections"
|
:model-value="sectionValue(section)"
|
||||||
:key="section.key"
|
:options="formOptions"
|
||||||
:section="section"
|
:source="formData.source"
|
||||||
:model-value="sectionValue(section)"
|
:mode="mode"
|
||||||
:options="formOptions"
|
:uploading="false"
|
||||||
:source="formData.source"
|
:disabled="fieldsLocked"
|
||||||
:mode="mode"
|
:search-customers="searchProtocolCustomers"
|
||||||
:uploading="false"
|
@update:model-value="setSectionValue(section, $event)"
|
||||||
:disabled="fieldsLocked"
|
@upload="uploadField"
|
||||||
:search-customers="searchProtocolCustomers"
|
@field-change="handleFieldChange"
|
||||||
@update:model-value="setSectionValue(section, $event)"
|
@customer-select="handleCustomerSelect"
|
||||||
@upload="uploadField"
|
/>
|
||||||
@field-change="handleFieldChange"
|
<MarketFormSection
|
||||||
@customer-select="handleCustomerSelect"
|
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
|
<section
|
||||||
v-if="context.module === 'protocol' && formData.source === 'cell_storage' && showDepositSection"
|
v-if="context.module === 'protocol' && formData.source === 'cell_storage' && showDepositSection"
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
:show-header="false"
|
:show-header="false"
|
||||||
>
|
>
|
||||||
<div class="market-list__filters">
|
<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
|
<div
|
||||||
v-if="listFilterTabs.length"
|
v-if="listFilterTabs.length"
|
||||||
class="market-source-tabs"
|
class="market-source-tabs"
|
||||||
@@ -59,15 +57,24 @@
|
|||||||
<span v-if="activeFilters" class="market-list__filter-count">{{ activeFilters }}</span>
|
<span v-if="activeFilters" class="market-list__filter-count">{{ activeFilters }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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-menu active-color="#0a9f6d">
|
||||||
<van-dropdown-item
|
<van-dropdown-item
|
||||||
v-for="field in inlineDropdownFilters"
|
v-for="field in inlineDropdownFilters"
|
||||||
:key="field.key"
|
:key="`${field.source || 'all'}:${field.key}`"
|
||||||
:model-value="listFilters[field.key]"
|
:model-value="listFilters[field.key]"
|
||||||
:options="inlineFilterOptionsByKey[field.key] || []"
|
:options="inlineFilterOptionsByKey[field.key] || []"
|
||||||
@change="(value) => onInlineFilterChange(field.key, value)"
|
@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>
|
</van-dropdown-menu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,11 +161,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import ProjectAssignmentField from '../../market/components/ProjectAssignmentField.vue'
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
|
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 { intersectAllowedActions } from '../../market/action-policy.js'
|
||||||
import { deleteMarketItemsSequentially } from '../../market/batch-delete.js'
|
import { deleteMarketItemsSequentially } from '../../market/batch-delete.js'
|
||||||
import {
|
import {
|
||||||
@@ -169,6 +180,7 @@ import {
|
|||||||
import { marketSearchPlaceholder } from '../../market/list-presenter.js'
|
import { marketSearchPlaceholder } from '../../market/list-presenter.js'
|
||||||
import { marketErrorMessage } from '../../market/market-error.js'
|
import { marketErrorMessage } from '../../market/market-error.js'
|
||||||
import { getMarketModule } from '../../market/module-registry.js'
|
import { getMarketModule } from '../../market/module-registry.js'
|
||||||
|
import { projectOptions } from '../../market/project-assignment.js'
|
||||||
import {
|
import {
|
||||||
getMarketRouteContext,
|
getMarketRouteContext,
|
||||||
MARKET_MODULE_TITLES,
|
MARKET_MODULE_TITLES,
|
||||||
@@ -247,6 +259,12 @@ const inlineDropdownFilters = computed(() => {
|
|||||||
const source = listFilters.value.source || listStatus.value
|
const source = listFilters.value.source || listStatus.value
|
||||||
return rows.filter((field) => !field.source || field.source === source)
|
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(() =>
|
const activeFilters = computed(() =>
|
||||||
activeFilterCount(context.value.module, listFilters.value)
|
activeFilterCount(context.value.module, listFilters.value)
|
||||||
)
|
)
|
||||||
@@ -299,6 +317,7 @@ const allDeletableSelected = computed(() =>
|
|||||||
)
|
)
|
||||||
let requestSequence = 0
|
let requestSequence = 0
|
||||||
let abortController = null
|
let abortController = null
|
||||||
|
let projectOptionsController = null
|
||||||
let searchTimer = null
|
let searchTimer = null
|
||||||
|
|
||||||
function normalizeList(result) {
|
function normalizeList(result) {
|
||||||
@@ -360,7 +379,32 @@ function queueSearch() {
|
|||||||
searchTimer = window.setTimeout(search, 350)
|
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() {
|
async function loadListFilterOptions() {
|
||||||
|
await loadProtocolProjectOptions()
|
||||||
const optionKeys = [
|
const optionKeys = [
|
||||||
...sheetFilterFields.value.map((field) => field.optionKey),
|
...sheetFilterFields.value.map((field) => field.optionKey),
|
||||||
...inlineDropdownFilters.value.map((field) => field.optionKey)
|
...inlineDropdownFilters.value.map((field) => field.optionKey)
|
||||||
@@ -596,6 +640,7 @@ function switchListStatus(value) {
|
|||||||
tab: next
|
tab: next
|
||||||
}
|
}
|
||||||
router.replace({ query: nextQuery }).catch(() => {})
|
router.replace({ query: nextQuery }).catch(() => {})
|
||||||
|
loadProtocolProjectOptions()
|
||||||
loadPage(true)
|
loadPage(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,6 +689,7 @@ onBeforeUnmount(() => {
|
|||||||
const requestId = ++requestSequence
|
const requestId = ++requestSequence
|
||||||
listState.value = invalidateMarketRequestState(listState.value, requestId)
|
listState.value = invalidateMarketRequestState(listState.value, requestId)
|
||||||
abortController?.abort()
|
abortController?.abort()
|
||||||
|
projectOptionsController?.abort()
|
||||||
window.clearTimeout(searchTimer)
|
window.clearTimeout(searchTimer)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -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`
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -110,7 +110,18 @@ describe('flattenProtocolAuditRows', () => {
|
|||||||
infectiousDiseaseReportUrls: '',
|
infectiousDiseaseReportUrls: '',
|
||||||
attachmentUrls: 'https://x/b.pdf',
|
attachmentUrls: 'https://x/b.pdf',
|
||||||
remark: '备注一',
|
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')
|
assert.equal(rows[0].monthlyVerifyNo, '202508001')
|
||||||
@@ -122,6 +133,9 @@ describe('flattenProtocolAuditRows', () => {
|
|||||||
assert.equal(rows[0].infectiousDiseaseReportUrls, '')
|
assert.equal(rows[0].infectiousDiseaseReportUrls, '')
|
||||||
assert.equal(rows[0].attachmentUrls, 'https://x/b.pdf')
|
assert.equal(rows[0].attachmentUrls, 'https://x/b.pdf')
|
||||||
assert.equal(rows[0].remark, '备注一')
|
assert.equal(rows[0].remark, '备注一')
|
||||||
|
assert.equal(rows[0].freightId, 'fr-1')
|
||||||
|
assert.equal(rows[0].freightTransportLabel, '专线车')
|
||||||
|
assert.equal(rows[0].freightAmountLabel, '100')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export type ProtocolAuditFlatRow = {
|
|||||||
labStorageCode: string
|
labStorageCode: string
|
||||||
transportType: string
|
transportType: string
|
||||||
transportLabel: string
|
transportLabel: string
|
||||||
|
/** 样本运费批次;多样本共用时 id 相同 */
|
||||||
|
freightId: string
|
||||||
|
freightTransportLabel: string
|
||||||
|
freightAmountLabel: string
|
||||||
paymentSummary: QcProtocolPaymentSummary | null
|
paymentSummary: QcProtocolPaymentSummary | null
|
||||||
monthlyVerifyNo: string
|
monthlyVerifyNo: string
|
||||||
donationStatus: string
|
donationStatus: string
|
||||||
@@ -90,7 +94,13 @@ export function flattenProtocolAuditRows(
|
|||||||
slots.forEach((item, sampleIndex) => {
|
slots.forEach((item, sampleIndex) => {
|
||||||
const rec = item || {}
|
const rec = item || {}
|
||||||
const transport = asRecord(rec.transport)
|
const transport = asRecord(rec.transport)
|
||||||
|
const freight = asRecord(rec.freight)
|
||||||
const transportType = str(transport?.transportType)
|
const transportType = str(transport?.transportType)
|
||||||
|
const freightAmountLabel = str(freight?.freightAmountLabel)
|
||||||
|
const freightAmount =
|
||||||
|
freight?.freightAmount == null || freight?.freightAmount === ''
|
||||||
|
? ''
|
||||||
|
: String(freight.freightAmount)
|
||||||
out.push({
|
out.push({
|
||||||
rowKey: `${protocolId}|${str(rec.sampleCode) || sampleIndex}`,
|
rowKey: `${protocolId}|${str(rec.sampleCode) || sampleIndex}`,
|
||||||
protocolId,
|
protocolId,
|
||||||
@@ -113,6 +123,9 @@ export function flattenProtocolAuditRows(
|
|||||||
labStorageCode: str(rec.labStorageCode),
|
labStorageCode: str(rec.labStorageCode),
|
||||||
transportType,
|
transportType,
|
||||||
transportLabel: '',
|
transportLabel: '',
|
||||||
|
freightId: str(freight?.id),
|
||||||
|
freightTransportLabel: str(freight?.transportModeLabel) || str(freight?.transportMode),
|
||||||
|
freightAmountLabel: freightAmountLabel || freightAmount,
|
||||||
paymentSummary:
|
paymentSummary:
|
||||||
protocol.paymentSummary && typeof protocol.paymentSummary === 'object'
|
protocol.paymentSummary && typeof protocol.paymentSummary === 'object'
|
||||||
? (protocol.paymentSummary as QcProtocolPaymentSummary)
|
? (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>>
|
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,15 @@
|
|||||||
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,
|
||||||
|
prepareCollectionLedgerExport
|
||||||
|
} 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 +225,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 +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() {
|
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,13 @@
|
|||||||
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,
|
||||||
|
prepareCollectionLedgerExport
|
||||||
|
} from '@/utils/collection-ledger-export'
|
||||||
import {
|
import {
|
||||||
flattenProtocolAuditRows,
|
flattenProtocolAuditRows,
|
||||||
uniqueProtocolsFromFlatRows,
|
uniqueProtocolsFromFlatRows,
|
||||||
@@ -476,6 +488,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 +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() {
|
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,13 @@
|
|||||||
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,
|
||||||
|
prepareCollectionLedgerExport
|
||||||
|
} from '@/utils/collection-ledger-export'
|
||||||
import {
|
import {
|
||||||
flattenProtocolAuditRows,
|
flattenProtocolAuditRows,
|
||||||
uniqueProtocolsFromFlatRows,
|
uniqueProtocolsFromFlatRows,
|
||||||
@@ -442,6 +454,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 +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() {
|
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,13 @@
|
|||||||
sampleStorageFlagLabel,
|
sampleStorageFlagLabel,
|
||||||
sampleStorageFlagTagType
|
sampleStorageFlagTagType
|
||||||
} from '@/utils/status-tag-registry'
|
} from '@/utils/status-tag-registry'
|
||||||
|
import {
|
||||||
|
CUSTOMER_SERVICE_HEADERS,
|
||||||
|
buildCustomerServiceLedgerRows,
|
||||||
|
fetchAllPages,
|
||||||
|
saveCollectionLedgerWorkbook,
|
||||||
|
prepareCollectionLedgerExport
|
||||||
|
} from '@/utils/collection-ledger-export'
|
||||||
|
|
||||||
defineOptions({ name: 'UnifiedSampleInfoPage' })
|
defineOptions({ name: 'UnifiedSampleInfoPage' })
|
||||||
|
|
||||||
@@ -533,6 +550,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 +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() {
|
function handleSearch() {
|
||||||
pagination.page = 1
|
pagination.page = 1
|
||||||
loadData()
|
loadData()
|
||||||
|
|||||||
+18
-1
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user